Compare commits

...

32 commits

Author SHA1 Message Date
Athanasios Oikonomou
24a909aaec
Merge 86f33de9f3 into d1d42128e5 2025-12-10 22:29:02 +01:00
Timothy Jaeryang Baek
d1d42128e5 refac/fix: channel files 2025-12-10 15:53:45 -05:00
Timothy Jaeryang Baek
2bccf8350d enh: channel files 2025-12-10 15:48:42 -05:00
Timothy Jaeryang Baek
c15201620d refac: kb files 2025-12-10 15:48:27 -05:00
Andreas
f31ca75892
Fix typo in user permission environment variables (#19860) 2025-12-10 15:09:15 -05:00
Timothy Jaeryang Baek
a7993f6f4e refac
Some checks are pending
Deploy to HuggingFace Spaces / check-secret (push) Waiting to run
Deploy to HuggingFace Spaces / deploy (push) Blocked by required conditions
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda126-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-slim-images (push) Blocked by required conditions
Python CI / Format Backend (push) Waiting to run
Frontend Build / Format & Build Frontend (push) Waiting to run
Frontend Build / Frontend Unit Tests (push) Waiting to run
2025-12-10 12:22:40 -05:00
Timothy Jaeryang Baek
ae47101dc6 refac 2025-12-10 11:07:41 -05:00
Timothy Jaeryang Baek
cf6a1300ca enh: experimental chat usage stats endpoint
Some checks are pending
Deploy to HuggingFace Spaces / check-secret (push) Waiting to run
Deploy to HuggingFace Spaces / deploy (push) Blocked by required conditions
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda126-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-slim-images (push) Blocked by required conditions
Python CI / Format Backend (push) Waiting to run
Frontend Build / Format & Build Frontend (push) Waiting to run
Frontend Build / Frontend Unit Tests (push) Waiting to run
2025-12-10 02:00:00 -05:00
Timothy Jaeryang Baek
a934dc997e refac: drop legacy kb support 2025-12-10 01:07:12 -05:00
Timothy Jaeryang Baek
ed2db0d04b refac 2025-12-10 00:58:08 -05:00
Timothy Jaeryang Baek
4ecacda28c refac 2025-12-10 00:55:31 -05:00
Timothy Jaeryang Baek
94a8439105 feat/enh: kb file pagination 2025-12-10 00:53:41 -05:00
Timothy Jaeryang Baek
7b0b16ebbd refac 2025-12-09 23:57:46 -05:00
Timothy Jaeryang Baek
49d54c5821 refac 2025-12-09 23:33:48 -05:00
Timothy Jaeryang Baek
0eafc09965 refac: styling 2025-12-09 22:28:38 -05:00
Timothy Jaeryang Baek
6a75620fcb refac: styling
Some checks are pending
Deploy to HuggingFace Spaces / check-secret (push) Waiting to run
Deploy to HuggingFace Spaces / deploy (push) Blocked by required conditions
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda126-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-slim-images (push) Blocked by required conditions
Python CI / Format Backend (push) Waiting to run
Frontend Build / Format & Build Frontend (push) Waiting to run
Frontend Build / Frontend Unit Tests (push) Waiting to run
2025-12-09 21:53:34 -05:00
Timothy Jaeryang Baek
205c711120 enh: expand input 2025-12-09 21:50:27 -05:00
Timothy Jaeryang Baek
3af96c9d4e refac: styling 2025-12-09 21:11:49 -05:00
Timothy Jaeryang Baek
6e0badde67 refac: styling 2025-12-09 21:10:50 -05:00
Timothy Jaeryang Baek
b29e7fd0be refac/fix: styling 2025-12-09 21:01:39 -05:00
Timothy Jaeryang Baek
02df867843 refac 2025-12-09 20:52:18 -05:00
Timothy Jaeryang Baek
00c2b6ca40 feat/enh: create note from input 2025-12-09 20:49:46 -05:00
Timothy Jaeryang Baek
65d4b22c7c refac 2025-12-09 19:47:38 -05:00
Timothy Jaeryang Baek
a4fe823893 refac: styling 2025-12-09 19:45:13 -05:00
Timothy Jaeryang Baek
103ff0c5e4 refac 2025-12-09 18:03:01 -05:00
Timothy Jaeryang Baek
4363df175d enh: read only notes 2025-12-09 17:57:15 -05:00
Timothy Jaeryang Baek
307b37d5e2 refac 2025-12-09 17:19:42 -05:00
Timothy Jaeryang Baek
9b24cddef6 enh/refac: notes 2025-12-09 16:45:08 -05:00
Shirasawa
1ea555a5ac
i18n: improve Chinese translation (#19830) 2025-12-09 15:29:43 -05:00
Shirasawa
c24b1207a0
fix: fixed missing text in the explanation feature (#19829) 2025-12-09 15:29:27 -05:00
Classic298
44e41806f2
chore: dep bump across many dependencies (#19850)
* Update pyproject.toml (#101)

* Update pyproject.toml

* Update requirements.txt

* Update requirements-min.txt

* Upgrade Playwright version to 1.57.0

* Update langchain-community version to 0.3.29

* Update requirements.txt

* Update requirements-min.txt
2025-12-09 15:28:21 -05:00
Athanasios Oikonomou
86f33de9f3 feat: support per-model RAG template override
Add `rag_template` as a configurable parameter in model settings, allowing each
model to define its own RAG template instead of always using the global default.
- Middleware now selects the model-specific RAG template if provided.
- Model editor UI updated to allow editing and saving `rag_template`.
- Fallback to global `RAG_TEMPLATE` remains when no override is set.
2025-09-24 22:58:10 +03:00
44 changed files with 2614 additions and 1182 deletions

View file

@ -1306,7 +1306,7 @@ USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = (
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING = ( USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING = (
os.environ.get( os.environ.get(
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False" "USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING", "False"
).lower() ).lower()
== "true" == "true"
) )
@ -1345,7 +1345,7 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING = (
USER_PERMISSIONS_NOTES_ALLOW_SHARING = ( USER_PERMISSIONS_NOTES_ALLOW_SHARING = (
os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING", "False").lower() os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_SHARING", "False").lower()
== "true" == "true"
) )

View file

@ -0,0 +1,54 @@
"""Add channel file table
Revision ID: 6283dc0e4d8d
Revises: 3e0e00844bb0
Create Date: 2025-12-10 15:11:39.424601
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import open_webui.internal.db
# revision identifiers, used by Alembic.
revision: str = "6283dc0e4d8d"
down_revision: Union[str, None] = "3e0e00844bb0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"channel_file",
sa.Column("id", sa.Text(), primary_key=True),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column(
"channel_id",
sa.Text(),
sa.ForeignKey("channel.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"file_id",
sa.Text(),
sa.ForeignKey("file.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("created_at", sa.BigInteger(), nullable=False),
sa.Column("updated_at", sa.BigInteger(), nullable=False),
# indexes
sa.Index("ix_channel_file_channel_id", "channel_id"),
sa.Index("ix_channel_file_file_id", "file_id"),
sa.Index("ix_channel_file_user_id", "user_id"),
# unique constraints
sa.UniqueConstraint(
"channel_id", "file_id", name="uq_channel_file_channel_file"
), # prevent duplicate entries
)
def downgrade() -> None:
op.drop_table("channel_file")

View file

@ -10,7 +10,18 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, case, cast from sqlalchemy import (
BigInteger,
Boolean,
Column,
ForeignKey,
String,
Text,
JSON,
UniqueConstraint,
case,
cast,
)
from sqlalchemy import or_, func, select, and_, text from sqlalchemy import or_, func, select, and_, text
from sqlalchemy.sql import exists from sqlalchemy.sql import exists
@ -137,6 +148,38 @@ class ChannelMemberModel(BaseModel):
updated_at: Optional[int] = None # timestamp in epoch (time_ns) updated_at: Optional[int] = None # timestamp in epoch (time_ns)
class ChannelFile(Base):
__tablename__ = "channel_file"
id = Column(Text, unique=True, primary_key=True)
channel_id = Column(
Text, ForeignKey("channel.id", ondelete="CASCADE"), nullable=False
)
file_id = Column(Text, ForeignKey("file.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Text, nullable=False)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (
UniqueConstraint("channel_id", "file_id", name="uq_channel_file_channel_file"),
)
class ChannelFileModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
channel_id: str
file_id: str
user_id: str
created_at: int # timestamp in epoch (time_ns)
updated_at: int # timestamp in epoch (time_ns)
class ChannelWebhook(Base): class ChannelWebhook(Base):
__tablename__ = "channel_webhook" __tablename__ = "channel_webhook"
@ -642,6 +685,135 @@ class ChannelTable:
channel = db.query(Channel).filter(Channel.id == id).first() channel = db.query(Channel).filter(Channel.id == id).first()
return ChannelModel.model_validate(channel) if channel else None return ChannelModel.model_validate(channel) if channel else None
def get_channels_by_file_id(self, file_id: str) -> list[ChannelModel]:
with get_db() as db:
channel_files = (
db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all()
)
channel_ids = [cf.channel_id for cf in channel_files]
channels = db.query(Channel).filter(Channel.id.in_(channel_ids)).all()
return [ChannelModel.model_validate(channel) for channel in channels]
def get_channels_by_file_id_and_user_id(
self, file_id: str, user_id: str
) -> list[ChannelModel]:
with get_db() as db:
# 1. Determine which channels have this file
channel_file_rows = (
db.query(ChannelFile).filter(ChannelFile.file_id == file_id).all()
)
channel_ids = [row.channel_id for row in channel_file_rows]
if not channel_ids:
return []
# 2. Load all channel rows that still exist
channels = (
db.query(Channel)
.filter(
Channel.id.in_(channel_ids),
Channel.deleted_at.is_(None),
Channel.archived_at.is_(None),
)
.all()
)
if not channels:
return []
# Preload user's group membership
user_group_ids = [g.id for g in Groups.get_groups_by_member_id(user_id)]
allowed_channels = []
for channel in channels:
# --- Case A: group or dm => user must be an active member ---
if channel.type in ["group", "dm"]:
membership = (
db.query(ChannelMember)
.filter(
ChannelMember.channel_id == channel.id,
ChannelMember.user_id == user_id,
ChannelMember.is_active.is_(True),
)
.first()
)
if membership:
allowed_channels.append(ChannelModel.model_validate(channel))
continue
# --- Case B: standard channel => rely on ACL permissions ---
query = db.query(Channel).filter(Channel.id == channel.id)
query = self._has_permission(
db,
query,
{"user_id": user_id, "group_ids": user_group_ids},
permission="read",
)
allowed = query.first()
if allowed:
allowed_channels.append(ChannelModel.model_validate(allowed))
return allowed_channels
def get_channel_by_id_and_user_id(
self, id: str, user_id: str
) -> Optional[ChannelModel]:
with get_db() as db:
# Fetch the channel
channel: Channel = (
db.query(Channel)
.filter(
Channel.id == id,
Channel.deleted_at.is_(None),
Channel.archived_at.is_(None),
)
.first()
)
if not channel:
return None
# If the channel is a group or dm, read access requires membership (active)
if channel.type in ["group", "dm"]:
membership = (
db.query(ChannelMember)
.filter(
ChannelMember.channel_id == id,
ChannelMember.user_id == user_id,
ChannelMember.is_active.is_(True),
)
.first()
)
if membership:
return ChannelModel.model_validate(channel)
else:
return None
# For channels that are NOT group/dm, fall back to ACL-based read access
query = db.query(Channel).filter(Channel.id == id)
# Determine user groups
user_group_ids = [
group.id for group in Groups.get_groups_by_member_id(user_id)
]
# Apply ACL rules
query = self._has_permission(
db,
query,
{"user_id": user_id, "group_ids": user_group_ids},
permission="read",
)
channel_allowed = query.first()
return (
ChannelModel.model_validate(channel_allowed)
if channel_allowed
else None
)
def update_channel_by_id( def update_channel_by_id(
self, id: str, form_data: ChannelForm self, id: str, form_data: ChannelForm
) -> Optional[ChannelModel]: ) -> Optional[ChannelModel]:
@ -663,6 +835,44 @@ class ChannelTable:
db.commit() db.commit()
return ChannelModel.model_validate(channel) if channel else None return ChannelModel.model_validate(channel) if channel else None
def add_file_to_channel_by_id(
self, channel_id: str, file_id: str, user_id: str
) -> Optional[ChannelFileModel]:
with get_db() as db:
channel_file = ChannelFileModel(
**{
"id": str(uuid.uuid4()),
"channel_id": channel_id,
"file_id": file_id,
"user_id": user_id,
"created_at": int(time.time()),
"updated_at": int(time.time()),
}
)
try:
result = ChannelFile(**channel_file.model_dump())
db.add(result)
db.commit()
db.refresh(result)
if result:
return ChannelFileModel.model_validate(result)
else:
return None
except Exception:
return None
def remove_file_from_channel_by_id(self, channel_id: str, file_id: str) -> bool:
try:
with get_db() as db:
db.query(ChannelFile).filter_by(
channel_id=channel_id, file_id=file_id
).delete()
db.commit()
return True
except Exception:
return False
def delete_channel_by_id(self, id: str): def delete_channel_by_id(self, id: str):
with get_db() as db: with get_db() as db:
db.query(Channel).filter(Channel.id == id).delete() db.query(Channel).filter(Channel.id == id).delete()

View file

@ -126,6 +126,49 @@ class ChatTitleIdResponse(BaseModel):
created_at: int created_at: int
class ChatListResponse(BaseModel):
items: list[ChatModel]
total: int
class ChatUsageStatsResponse(BaseModel):
id: str # chat id
models: dict = {} # models used in the chat with their usage counts
message_count: int # number of messages in the chat
history_models: dict = {} # models used in the chat history with their usage counts
history_message_count: int # number of messages in the chat history
history_user_message_count: int # number of user messages in the chat history
history_assistant_message_count: (
int # number of assistant messages in the chat history
)
average_response_time: (
float # average response time of assistant messages in seconds
)
average_user_message_content_length: (
float # average length of user message contents
)
average_assistant_message_content_length: (
float # average length of assistant message contents
)
tags: list[str] = [] # tags associated with the chat
last_message_at: int # timestamp of the last message
updated_at: int
created_at: int
model_config = ConfigDict(extra="allow")
class ChatUsageStatsListResponse(BaseModel):
items: list[ChatUsageStatsResponse]
total: int
model_config = ConfigDict(extra="allow")
class ChatTable: class ChatTable:
def _clean_null_bytes(self, obj): def _clean_null_bytes(self, obj):
""" """
@ -675,14 +718,31 @@ class ChatTable:
) )
return [ChatModel.model_validate(chat) for chat in all_chats] return [ChatModel.model_validate(chat) for chat in all_chats]
def get_chats_by_user_id(self, user_id: str) -> list[ChatModel]: def get_chats_by_user_id(
self, user_id: str, skip: Optional[int] = None, limit: Optional[int] = None
) -> ChatListResponse:
with get_db() as db: with get_db() as db:
all_chats = ( query = (
db.query(Chat) db.query(Chat)
.filter_by(user_id=user_id) .filter_by(user_id=user_id)
.order_by(Chat.updated_at.desc()) .order_by(Chat.updated_at.desc())
) )
return [ChatModel.model_validate(chat) for chat in all_chats]
total = query.count()
if skip is not None:
query = query.offset(skip)
if limit is not None:
query = query.limit(limit)
all_chats = query.all()
return ChatListResponse(
**{
"items": [ChatModel.model_validate(chat) for chat in all_chats],
"total": total,
}
)
def get_pinned_chats_by_user_id(self, user_id: str) -> list[ChatModel]: def get_pinned_chats_by_user_id(self, user_id: str) -> list[ChatModel]:
with get_db() as db: with get_db() as db:

View file

@ -238,6 +238,7 @@ class FilesTable:
try: try:
file = db.query(File).filter_by(id=id).first() file = db.query(File).filter_by(id=id).first()
file.hash = hash file.hash = hash
file.updated_at = int(time.time())
db.commit() db.commit()
return FileModel.model_validate(file) return FileModel.model_validate(file)
@ -249,6 +250,7 @@ class FilesTable:
try: try:
file = db.query(File).filter_by(id=id).first() file = db.query(File).filter_by(id=id).first()
file.data = {**(file.data if file.data else {}), **data} file.data = {**(file.data if file.data else {}), **data}
file.updated_at = int(time.time())
db.commit() db.commit()
return FileModel.model_validate(file) return FileModel.model_validate(file)
except Exception as e: except Exception as e:
@ -260,6 +262,7 @@ class FilesTable:
try: try:
file = db.query(File).filter_by(id=id).first() file = db.query(File).filter_by(id=id).first()
file.meta = {**(file.meta if file.meta else {}), **meta} file.meta = {**(file.meta if file.meta else {}), **meta}
file.updated_at = int(time.time())
db.commit() db.commit()
return FileModel.model_validate(file) return FileModel.model_validate(file)
except Exception: except Exception:

View file

@ -7,9 +7,14 @@ import uuid
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
from open_webui.models.files import File, FileModel, FileMetadataResponse from open_webui.models.files import (
File,
FileModel,
FileMetadataResponse,
FileModelResponse,
)
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse from open_webui.models.users import User, UserModel, Users, UserResponse
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@ -21,6 +26,7 @@ from sqlalchemy import (
Text, Text,
JSON, JSON,
UniqueConstraint, UniqueConstraint,
or_,
) )
from open_webui.utils.access_control import has_access from open_webui.utils.access_control import has_access
@ -135,6 +141,15 @@ class KnowledgeForm(BaseModel):
access_control: Optional[dict] = None access_control: Optional[dict] = None
class FileUserResponse(FileModelResponse):
user: Optional[UserResponse] = None
class KnowledgeFileListResponse(BaseModel):
items: list[FileUserResponse]
total: int
class KnowledgeTable: class KnowledgeTable:
def insert_new_knowledge( def insert_new_knowledge(
self, user_id: str, form_data: KnowledgeForm self, user_id: str, form_data: KnowledgeForm
@ -217,6 +232,21 @@ class KnowledgeTable:
except Exception: except Exception:
return None return None
def get_knowledge_by_id_and_user_id(
self, id: str, user_id: str
) -> Optional[KnowledgeModel]:
knowledge = self.get_knowledge_by_id(id)
if not knowledge:
return None
if knowledge.user_id == user_id:
return knowledge
user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
if has_access(user_id, "write", knowledge.access_control, user_group_ids):
return knowledge
return None
def get_knowledges_by_file_id(self, file_id: str) -> list[KnowledgeModel]: def get_knowledges_by_file_id(self, file_id: str) -> list[KnowledgeModel]:
try: try:
with get_db() as db: with get_db() as db:
@ -232,6 +262,88 @@ class KnowledgeTable:
except Exception: except Exception:
return [] return []
def search_files_by_id(
self,
knowledge_id: str,
user_id: str,
filter: dict,
skip: int = 0,
limit: int = 30,
) -> KnowledgeFileListResponse:
try:
with get_db() as db:
query = (
db.query(File, User)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.outerjoin(User, User.id == KnowledgeFile.user_id)
.filter(KnowledgeFile.knowledge_id == knowledge_id)
)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(or_(File.filename.ilike(f"%{query_key}%")))
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(KnowledgeFile.user_id == user_id)
elif view_option == "shared":
query = query.filter(KnowledgeFile.user_id != user_id)
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by == "name":
if direction == "asc":
query = query.order_by(File.filename.asc())
else:
query = query.order_by(File.filename.desc())
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(File.created_at.asc())
else:
query = query.order_by(File.created_at.desc())
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(File.updated_at.asc())
else:
query = query.order_by(File.updated_at.desc())
else:
query = query.order_by(File.updated_at.desc())
else:
query = query.order_by(File.updated_at.desc())
# Count BEFORE pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
files = []
for file, user in items:
files.append(
FileUserResponse(
**FileModel.model_validate(file).model_dump(),
user=(
UserResponse(
**UserModel.model_validate(user).model_dump()
)
if user
else None
),
)
)
return KnowledgeFileListResponse(items=files, total=total)
except Exception as e:
print(e)
return KnowledgeFileListResponse(items=[], total=0)
def get_files_by_id(self, knowledge_id: str) -> list[FileModel]: def get_files_by_id(self, knowledge_id: str) -> list[FileModel]:
try: try:
with get_db() as db: with get_db() as db:

View file

@ -7,12 +7,15 @@ from functools import lru_cache
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.utils.access_control import has_access from open_webui.utils.access_control import has_access
from open_webui.models.users import Users, UserResponse from open_webui.models.users import User, UserModel, Users, UserResponse
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy import or_, func, select, and_, text from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func
from sqlalchemy.sql import exists from sqlalchemy.sql import exists
#################### ####################
@ -75,7 +78,138 @@ class NoteUserResponse(NoteModel):
user: Optional[UserResponse] = None user: Optional[UserResponse] = None
class NoteItemResponse(BaseModel):
id: str
title: str
data: Optional[dict]
updated_at: int
created_at: int
user: Optional[UserResponse] = None
class NoteListResponse(BaseModel):
items: list[NoteUserResponse]
total: int
class NoteTable: class NoteTable:
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
dialect_name = db.bind.dialect.name
conditions = []
# Handle read_only permission separately
if permission == "read_only":
# For read_only, we want items where:
# 1. User has explicit read permission (via groups or user-level)
# 2. BUT does NOT have write permission
# 3. Public items are NOT considered read_only
read_conditions = []
# Group-level read permission
if group_ids:
group_read_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_read_conditions.append(
Note.access_control["read"]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_read_conditions.append(
cast(
Note.access_control["read"]["group_ids"],
JSONB,
).contains([gid])
)
if group_read_conditions:
read_conditions.append(or_(*group_read_conditions))
# Combine read conditions
if read_conditions:
has_read = or_(*read_conditions)
else:
# If no read conditions, return empty result
return query.filter(False)
# Now exclude items where user has write permission
write_exclusions = []
# Exclude items owned by user (they have implicit write)
if user_id:
write_exclusions.append(Note.user_id != user_id)
# Exclude items where user has explicit write permission via groups
if group_ids:
group_write_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_write_conditions.append(
Note.access_control["write"]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_write_conditions.append(
cast(
Note.access_control["write"]["group_ids"],
JSONB,
).contains([gid])
)
if group_write_conditions:
# User should NOT have write permission
write_exclusions.append(~or_(*group_write_conditions))
# Exclude public items (items without access_control)
write_exclusions.append(Note.access_control.isnot(None))
write_exclusions.append(cast(Note.access_control, String) != "null")
# Combine: has read AND does not have write AND not public
if write_exclusions:
query = query.filter(and_(has_read, *write_exclusions))
else:
query = query.filter(has_read)
return query
# Original logic for other permissions (read, write, etc.)
# Public access conditions
if group_ids or user_id:
conditions.extend(
[
Note.access_control.is_(None),
cast(Note.access_control, String) == "null",
]
)
# User-level permission (owner has all permissions)
if user_id:
conditions.append(Note.user_id == user_id)
# Group-level permission
if group_ids:
group_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_conditions.append(
Note.access_control[permission]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_conditions.append(
cast(
Note.access_control[permission]["group_ids"],
JSONB,
).contains([gid])
)
conditions.append(or_(*group_conditions))
if conditions:
query = query.filter(or_(*conditions))
return query
def insert_new_note( def insert_new_note(
self, self,
form_data: NoteForm, form_data: NoteForm,
@ -110,15 +244,105 @@ class NoteTable:
notes = query.all() notes = query.all()
return [NoteModel.model_validate(note) for note in notes] return [NoteModel.model_validate(note) for note in notes]
def search_notes(
self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30
) -> NoteListResponse:
with get_db() as db:
query = db.query(Note, User).outerjoin(User, User.id == Note.user_id)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(
or_(
Note.title.ilike(f"%{query_key}%"),
Note.data["content"]["md"].ilike(f"%{query_key}%"),
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Note.user_id == user_id)
elif view_option == "shared":
query = query.filter(Note.user_id != user_id)
# Apply access control filtering
if "permission" in filter:
permission = filter["permission"]
else:
permission = "write"
query = self._has_permission(
db,
query,
filter,
permission=permission,
)
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by == "name":
if direction == "asc":
query = query.order_by(Note.title.asc())
else:
query = query.order_by(Note.title.desc())
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(Note.created_at.asc())
else:
query = query.order_by(Note.created_at.desc())
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(Note.updated_at.asc())
else:
query = query.order_by(Note.updated_at.desc())
else:
query = query.order_by(Note.updated_at.desc())
else:
query = query.order_by(Note.updated_at.desc())
# Count BEFORE pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
notes = []
for note, user in items:
notes.append(
NoteUserResponse(
**NoteModel.model_validate(note).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
else None
),
)
)
return NoteListResponse(items=notes, total=total)
def get_notes_by_user_id( def get_notes_by_user_id(
self, self,
user_id: str, user_id: str,
permission: str = "read",
skip: Optional[int] = None, skip: Optional[int] = None,
limit: Optional[int] = None, limit: Optional[int] = None,
) -> list[NoteModel]: ) -> list[NoteModel]:
with get_db() as db: with get_db() as db:
query = db.query(Note).filter(Note.user_id == user_id) user_group_ids = [
query = query.order_by(Note.updated_at.desc()) group.id for group in Groups.get_groups_by_member_id(user_id)
]
query = db.query(Note).order_by(Note.updated_at.desc())
query = self._has_permission(
db, query, {"user_id": user_id, "group_ids": user_group_ids}, permission
)
if skip is not None: if skip is not None:
query = query.offset(skip) query = query.offset(skip)
@ -128,56 +352,6 @@ class NoteTable:
notes = query.all() notes = query.all()
return [NoteModel.model_validate(note) for note in notes] return [NoteModel.model_validate(note) for note in notes]
def get_notes_by_permission(
self,
user_id: str,
permission: str = "write",
skip: Optional[int] = None,
limit: Optional[int] = None,
) -> list[NoteModel]:
with get_db() as db:
user_groups = Groups.get_groups_by_member_id(user_id)
user_group_ids = {group.id for group in user_groups}
# Order newest-first. We stream to keep memory usage low.
query = (
db.query(Note)
.order_by(Note.updated_at.desc())
.execution_options(stream_results=True)
.yield_per(256)
)
results: list[NoteModel] = []
n_skipped = 0
for note in query:
# Fast-pass #1: owner
if note.user_id == user_id:
permitted = True
# Fast-pass #2: public/open
elif note.access_control is None:
# Technically this should mean public access for both read and write, but we'll only do read for now
# We might want to change this behavior later
permitted = permission == "read"
else:
permitted = has_access(
user_id, permission, note.access_control, user_group_ids
)
if not permitted:
continue
# Apply skip AFTER permission filtering so it counts only accessible notes
if skip and n_skipped < skip:
n_skipped += 1
continue
results.append(NoteModel.model_validate(note))
if limit is not None and len(results) >= limit:
break
return results
def get_note_by_id(self, id: str) -> Optional[NoteModel]: def get_note_by_id(self, id: str) -> Optional[NoteModel]:
with get_db() as db: with get_db() as db:
note = db.query(Note).filter(Note.id == id).first() note = db.query(Note).filter(Note.id == id).first()

View file

@ -5,11 +5,11 @@ from open_webui.internal.db import Base, JSONField, get_db
from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL from open_webui.env import DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL
from open_webui.models.chats import Chats from open_webui.models.chats import Chats
from open_webui.models.groups import Groups, GroupMember from open_webui.models.groups import Groups, GroupMember
from open_webui.models.channels import ChannelMember from open_webui.models.channels import ChannelMember
from open_webui.utils.misc import throttle from open_webui.utils.misc import throttle

View file

@ -3,10 +3,12 @@ import logging
from typing import Optional from typing import Optional
from open_webui.utils.misc import get_message_list
from open_webui.socket.main import get_event_emitter from open_webui.socket.main import get_event_emitter
from open_webui.models.chats import ( from open_webui.models.chats import (
ChatForm, ChatForm,
ChatImportForm, ChatImportForm,
ChatUsageStatsListResponse,
ChatsImportForm, ChatsImportForm,
ChatResponse, ChatResponse,
Chats, Chats,
@ -66,6 +68,132 @@ def get_session_user_chat_list(
) )
############################
# GetChatUsageStats
# EXPERIMENTAL: may be removed in future releases
############################
@router.get("/stats/usage", response_model=ChatUsageStatsListResponse)
def get_session_user_chat_usage_stats(
items_per_page: Optional[int] = 50,
page: Optional[int] = 1,
user=Depends(get_verified_user),
):
try:
limit = items_per_page
skip = (page - 1) * limit
result = Chats.get_chats_by_user_id(user.id, skip=skip, limit=limit)
chats = result.items
total = result.total
chat_stats = []
for chat in chats:
messages_map = chat.chat.get("history", {}).get("messages", {})
message_id = chat.chat.get("history", {}).get("currentId")
if messages_map and message_id:
try:
history_models = {}
history_message_count = len(messages_map)
history_user_messages = []
history_assistant_messages = []
for message in messages_map.values():
if message.get("role", "") == "user":
history_user_messages.append(message)
elif message.get("role", "") == "assistant":
history_assistant_messages.append(message)
model = message.get("model", None)
if model:
if model not in history_models:
history_models[model] = 0
history_models[model] += 1
average_user_message_content_length = (
sum(
len(message.get("content", ""))
for message in history_user_messages
)
/ len(history_user_messages)
if len(history_user_messages) > 0
else 0
)
average_assistant_message_content_length = (
sum(
len(message.get("content", ""))
for message in history_assistant_messages
)
/ len(history_assistant_messages)
if len(history_assistant_messages) > 0
else 0
)
response_times = []
for message in history_assistant_messages:
user_message_id = message.get("parentId", None)
if user_message_id and user_message_id in messages_map:
user_message = messages_map[user_message_id]
response_time = message.get(
"timestamp", 0
) - user_message.get("timestamp", 0)
response_times.append(response_time)
average_response_time = (
sum(response_times) / len(response_times)
if len(response_times) > 0
else 0
)
message_list = get_message_list(messages_map, message_id)
message_count = len(message_list)
models = {}
for message in reversed(message_list):
if message.get("role") == "assistant":
model = message.get("model", None)
if model:
if model not in models:
models[model] = 0
models[model] += 1
annotation = message.get("annotation", {})
chat_stats.append(
{
"id": chat.id,
"models": models,
"message_count": message_count,
"history_models": history_models,
"history_message_count": history_message_count,
"history_user_message_count": len(history_user_messages),
"history_assistant_message_count": len(
history_assistant_messages
),
"average_response_time": average_response_time,
"average_user_message_content_length": average_user_message_content_length,
"average_assistant_message_content_length": average_assistant_message_content_length,
"tags": chat.meta.get("tags", []),
"last_message_at": message_list[-1].get("timestamp", None),
"updated_at": chat.updated_at,
"created_at": chat.created_at,
}
)
except Exception as e:
pass
return ChatUsageStatsListResponse(items=chat_stats, total=total)
except Exception as e:
log.exception(e)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
############################ ############################
# DeleteAllChats # DeleteAllChats
############################ ############################

View file

@ -27,6 +27,7 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
from open_webui.models.channels import Channels
from open_webui.models.users import Users from open_webui.models.users import Users
from open_webui.models.files import ( from open_webui.models.files import (
FileForm, FileForm,
@ -91,6 +92,10 @@ def has_access_to_file(
if knowledge_base.id == knowledge_base_id: if knowledge_base.id == knowledge_base_id:
return True return True
channels = Channels.get_channels_by_file_id_and_user_id(file_id, user.id)
if access_type == "read" and channels:
return True
return False return False
@ -138,6 +143,7 @@ def process_uploaded_file(request, file, file_path, file_item, file_metadata, us
f"File type {file.content_type} is not provided, but trying to process anyway" f"File type {file.content_type} is not provided, but trying to process anyway"
) )
process_file(request, ProcessFileForm(file_id=file_item.id), user=user) process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
except Exception as e: except Exception as e:
log.error(f"Error processing file: {file_item.id}") log.error(f"Error processing file: {file_item.id}")
Files.update_file_data_by_id( Files.update_file_data_by_id(
@ -247,6 +253,13 @@ def upload_file_handler(
), ),
) )
if "channel_id" in file_metadata:
channel = Channels.get_channel_by_id_and_user_id(
file_metadata["channel_id"], user.id
)
if channel:
Channels.add_file_to_channel_by_id(channel.id, file_item.id, user.id)
if process: if process:
if background_tasks and process_in_background: if background_tasks and process_in_background:
background_tasks.add_task( background_tasks.add_task(

View file

@ -5,6 +5,7 @@ from fastapi.concurrency import run_in_threadpool
import logging import logging
from open_webui.models.knowledge import ( from open_webui.models.knowledge import (
KnowledgeFileListResponse,
Knowledges, Knowledges,
KnowledgeForm, KnowledgeForm,
KnowledgeResponse, KnowledgeResponse,
@ -264,6 +265,59 @@ async def update_knowledge_by_id(
) )
############################
# GetKnowledgeFilesById
############################
@router.get("/{id}/files", response_model=KnowledgeFileListResponse)
async def get_knowledge_files_by_id(
id: str,
query: Optional[str] = None,
view_option: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
):
knowledge = Knowledges.get_knowledge_by_id(id=id)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if not (
user.role == "admin"
or knowledge.user_id == user.id
or has_access(user.id, "read", knowledge.access_control)
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
page = max(page, 1)
limit = 30
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if view_option:
filter["view_option"] = view_option
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
return Knowledges.search_files_by_id(
id, user.id, filter=filter, skip=skip, limit=limit
)
############################ ############################
# AddFileToKnowledge # AddFileToKnowledge
############################ ############################
@ -309,11 +363,6 @@ def add_file_to_knowledge_by_id(
detail=ERROR_MESSAGES.FILE_NOT_PROCESSED, detail=ERROR_MESSAGES.FILE_NOT_PROCESSED,
) )
# Add file to knowledge base
Knowledges.add_file_to_knowledge_by_id(
knowledge_id=id, file_id=form_data.file_id, user_id=user.id
)
# Add content to the vector database # Add content to the vector database
try: try:
process_file( process_file(
@ -321,6 +370,11 @@ def add_file_to_knowledge_by_id(
ProcessFileForm(file_id=form_data.file_id, collection_name=id), ProcessFileForm(file_id=form_data.file_id, collection_name=id),
user=user, user=user,
) )
# Add file to knowledge base
Knowledges.add_file_to_knowledge_by_id(
knowledge_id=id, file_id=form_data.file_id, user_id=user.id
)
except Exception as e: except Exception as e:
log.debug(e) log.debug(e)
raise HTTPException( raise HTTPException(

View file

@ -8,11 +8,21 @@ from pydantic import BaseModel
from open_webui.socket.main import sio from open_webui.socket.main import sio
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse from open_webui.models.users import Users, UserResponse
from open_webui.models.notes import Notes, NoteModel, NoteForm, NoteUserResponse from open_webui.models.notes import (
NoteListResponse,
Notes,
NoteModel,
NoteForm,
NoteUserResponse,
)
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT from open_webui.config import (
BYPASS_ADMIN_ACCESS_CONTROL,
ENABLE_ADMIN_CHAT_ACCESS,
ENABLE_ADMIN_EXPORT,
)
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
@ -30,39 +40,17 @@ router = APIRouter()
############################ ############################
@router.get("/", response_model=list[NoteUserResponse]) class NoteItemResponse(BaseModel):
async def get_notes(request: Request, user=Depends(get_verified_user)):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
notes = [
NoteUserResponse(
**{
**note.model_dump(),
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
}
)
for note in Notes.get_notes_by_permission(user.id, "write")
]
return notes
class NoteTitleIdResponse(BaseModel):
id: str id: str
title: str title: str
data: Optional[dict]
updated_at: int updated_at: int
created_at: int created_at: int
user: Optional[UserResponse] = None
@router.get("/list", response_model=list[NoteTitleIdResponse]) @router.get("/", response_model=list[NoteItemResponse])
async def get_note_list( async def get_notes(
request: Request, page: Optional[int] = None, user=Depends(get_verified_user) request: Request, page: Optional[int] = None, user=Depends(get_verified_user)
): ):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
@ -80,15 +68,64 @@ async def get_note_list(
skip = (page - 1) * limit skip = (page - 1) * limit
notes = [ notes = [
NoteTitleIdResponse(**note.model_dump()) NoteUserResponse(
for note in Notes.get_notes_by_permission( **{
user.id, "write", skip=skip, limit=limit **note.model_dump(),
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
}
) )
for note in Notes.get_notes_by_user_id(user.id, "read", skip=skip, limit=limit)
] ]
return notes return notes
@router.get("/search", response_model=NoteListResponse)
async def search_notes(
request: Request,
query: Optional[str] = None,
view_option: Optional[str] = None,
permission: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
limit = None
skip = None
if page is not None:
limit = 60
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if view_option:
filter["view_option"] = view_option
if permission:
filter["permission"] = permission
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
return Notes.search_notes(user.id, filter, skip=skip, limit=limit)
############################ ############################
# CreateNewNote # CreateNewNote
############################ ############################
@ -98,7 +135,6 @@ async def get_note_list(
async def create_new_note( async def create_new_note(
request: Request, form_data: NoteForm, user=Depends(get_verified_user) request: Request, form_data: NoteForm, user=Depends(get_verified_user)
): ):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
): ):
@ -122,7 +158,11 @@ async def create_new_note(
############################ ############################
@router.get("/{id}", response_model=Optional[NoteModel]) class NoteResponse(NoteModel):
write_access: bool = False
@router.get("/{id}", response_model=Optional[NoteResponse])
async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)): async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
@ -146,7 +186,15 @@ async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_us
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT() status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
) )
return note write_access = (
user.role == "admin"
or (user.id == note.user_id)
or has_access(
user.id, type="write", access_control=note.access_control, strict=False
)
)
return NoteResponse(**note.model_dump(), write_access=write_access)
############################ ############################

View file

@ -1550,10 +1550,15 @@ async def process_chat_payload(request, form_data, user, metadata, model):
if prompt is None: if prompt is None:
raise Exception("No user message found") raise Exception("No user message found")
model_rag_template = (
model.get("info", {}).get("params", {}).get("rag_template", "")
or request.app.state.config.RAG_TEMPLATE
)
if context_string != "": if context_string != "":
form_data["messages"] = add_or_update_user_message( form_data["messages"] = add_or_update_user_message(
rag_template( rag_template(
request.app.state.config.RAG_TEMPLATE, model_rag_template,
context_string, context_string,
prompt, prompt,
), ),

View file

@ -624,14 +624,17 @@ def stream_chunks_handler(stream: aiohttp.StreamReader):
yield line yield line
else: else:
yield b"data: {}" yield b"data: {}"
yield b"\n"
else: else:
# Normal mode: check if line exceeds limit # Normal mode: check if line exceeds limit
if len(line) > max_buffer_size: if len(line) > max_buffer_size:
skip_mode = True skip_mode = True
yield b"data: {}" yield b"data: {}"
yield b"\n"
log.info(f"Skip mode triggered, line size: {len(line)}") log.info(f"Skip mode triggered, line size: {len(line)}")
else: else:
yield line yield line
yield b"\n"
# Save the last incomplete fragment # Save the last incomplete fragment
buffer = lines[-1] buffer = lines[-1]
@ -646,5 +649,6 @@ def stream_chunks_handler(stream: aiohttp.StreamReader):
# Process remaining buffer data # Process remaining buffer data
if buffer and not skip_mode: if buffer and not skip_mode:
yield buffer yield buffer
yield b"\n"
return yield_safe_stream_chunks() return yield_safe_stream_chunks()

View file

@ -1,7 +1,7 @@
# Minimal requirements for backend to run # Minimal requirements for backend to run
# WIP: use this as a reference to build a minimal docker image # WIP: use this as a reference to build a minimal docker image
fastapi==0.123.0 fastapi==0.124.0
uvicorn[standard]==0.37.0 uvicorn[standard]==0.37.0
pydantic==2.12.5 pydantic==2.12.5
python-multipart==0.0.20 python-multipart==0.0.20
@ -16,7 +16,7 @@ PyJWT[crypto]==2.10.1
authlib==1.6.5 authlib==1.6.5
requests==2.32.5 requests==2.32.5
aiohttp==3.12.15 aiohttp==3.13.2
async-timeout async-timeout
aiocache aiocache
aiofiles aiofiles
@ -24,21 +24,21 @@ starlette-compress==1.6.1
httpx[socks,http2,zstd,cli,brotli]==0.28.1 httpx[socks,http2,zstd,cli,brotli]==0.28.1
starsessions[redis]==2.2.1 starsessions[redis]==2.2.1
sqlalchemy==2.0.38 sqlalchemy==2.0.44
alembic==1.17.2 alembic==1.17.2
peewee==3.18.3 peewee==3.18.3
peewee-migrate==1.14.3 peewee-migrate==1.14.3
pycrdt==0.12.25 pycrdt==0.12.44
redis redis
APScheduler==3.10.4 APScheduler==3.11.1
RestrictedPython==8.0 RestrictedPython==8.1
loguru==0.7.3 loguru==0.7.3
asgiref==3.11.0 asgiref==3.11.0
mcp==1.22.0 mcp==1.23.1
openai openai
langchain==0.3.27 langchain==0.3.27
@ -46,6 +46,6 @@ langchain-community==0.3.29
fake-useragent==2.2.0 fake-useragent==2.2.0
chromadb==1.3.5 chromadb==1.3.5
black==25.11.0 black==25.12.0
pydub pydub
chardet==5.2.0 chardet==5.2.0

View file

@ -1,4 +1,4 @@
fastapi==0.123.0 fastapi==0.124.0
uvicorn[standard]==0.37.0 uvicorn[standard]==0.37.0
pydantic==2.12.5 pydantic==2.12.5
python-multipart==0.0.20 python-multipart==0.0.20
@ -13,7 +13,7 @@ PyJWT[crypto]==2.10.1
authlib==1.6.5 authlib==1.6.5
requests==2.32.5 requests==2.32.5
aiohttp==3.12.15 aiohttp==3.13.2
async-timeout async-timeout
aiocache aiocache
aiofiles aiofiles
@ -21,27 +21,27 @@ starlette-compress==1.6.1
httpx[socks,http2,zstd,cli,brotli]==0.28.1 httpx[socks,http2,zstd,cli,brotli]==0.28.1
starsessions[redis]==2.2.1 starsessions[redis]==2.2.1
sqlalchemy==2.0.38 sqlalchemy==2.0.44
alembic==1.17.2 alembic==1.17.2
peewee==3.18.3 peewee==3.18.3
peewee-migrate==1.14.3 peewee-migrate==1.14.3
pycrdt==0.12.25 pycrdt==0.12.44
redis redis
APScheduler==3.10.4 APScheduler==3.11.1
RestrictedPython==8.0 RestrictedPython==8.1
loguru==0.7.3 loguru==0.7.3
asgiref==3.11.0 asgiref==3.11.0
# AI libraries # AI libraries
tiktoken tiktoken
mcp==1.22.0 mcp==1.23.3
openai openai
anthropic anthropic
google-genai==1.52.0 google-genai==1.54.0
google-generativeai==0.8.5 google-generativeai==0.8.5
langchain==0.3.27 langchain==0.3.27
@ -49,8 +49,8 @@ langchain-community==0.3.29
fake-useragent==2.2.0 fake-useragent==2.2.0
chromadb==1.3.5 chromadb==1.3.5
weaviate-client==4.17.0 weaviate-client==4.18.3
opensearch-py==2.8.0 opensearch-py==3.1.0
transformers==4.57.3 transformers==4.57.3
sentence-transformers==5.1.2 sentence-transformers==5.1.2
@ -60,43 +60,43 @@ einops==0.8.1
ftfy==6.3.1 ftfy==6.3.1
chardet==5.2.0 chardet==5.2.0
pypdf==6.4.0 pypdf==6.4.1
fpdf2==2.8.2 fpdf2==2.8.5
pymdown-extensions==10.17.2 pymdown-extensions==10.18
docx2txt==0.8 docx2txt==0.9
python-pptx==1.0.2 python-pptx==1.0.2
unstructured==0.18.21 unstructured==0.18.21
msoffcrypto-tool==5.4.2 msoffcrypto-tool==5.4.2
nltk==3.9.1 nltk==3.9.2
Markdown==3.10 Markdown==3.10
pypandoc==1.16.2 pypandoc==1.16.2
pandas==2.2.3 pandas==2.3.3
openpyxl==3.1.5 openpyxl==3.1.5
pyxlsb==1.0.10 pyxlsb==1.0.10
xlrd==2.0.1 xlrd==2.0.2
validators==0.35.0 validators==0.35.0
psutil psutil
sentencepiece sentencepiece
soundfile==0.13.1 soundfile==0.13.1
pillow==11.3.0 pillow==12.0.0
opencv-python-headless==4.11.0.86 opencv-python-headless==4.12.0.88
rapidocr-onnxruntime==1.4.4 rapidocr-onnxruntime==1.4.4
rank-bm25==0.2.2 rank-bm25==0.2.2
onnxruntime==1.20.1 onnxruntime==1.23.2
faster-whisper==1.1.1 faster-whisper==1.2.1
black==25.11.0 black==25.12.0
youtube-transcript-api==1.2.2 youtube-transcript-api==1.2.3
pytube==15.0.0 pytube==15.0.0
pydub pydub
ddgs==9.9.2 ddgs==9.9.3
azure-ai-documentintelligence==1.0.2 azure-ai-documentintelligence==1.0.2
azure-identity==1.25.0 azure-identity==1.25.1
azure-storage-blob==12.24.1 azure-storage-blob==12.27.1
azure-search-documents==11.6.0 azure-search-documents==11.6.0
## Google Drive ## Google Drive
@ -105,26 +105,26 @@ google-auth-httplib2
google-auth-oauthlib google-auth-oauthlib
googleapis-common-protos==1.72.0 googleapis-common-protos==1.72.0
google-cloud-storage==2.19.0 google-cloud-storage==3.7.0
## Databases ## Databases
pymongo pymongo
psycopg2-binary==2.9.10 psycopg2-binary==2.9.11
pgvector==0.4.1 pgvector==0.4.2
PyMySQL==1.1.1 PyMySQL==1.1.2
boto3==1.41.5 boto3==1.42.5
pymilvus==2.6.5 pymilvus==2.6.5
qdrant-client==1.16.1 qdrant-client==1.16.1
playwright==1.56.0 # Caution: version must match docker-compose.playwright.yaml playwright==1.57.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
elasticsearch==9.1.0 elasticsearch==9.2.0
pinecone==6.0.2 pinecone==6.0.2
oracledb==3.2.0 oracledb==3.4.1
av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720 av==14.0.1 # Caution: Set due to FATAL FIPS SELFTEST FAILURE, see discussion https://github.com/open-webui/open-webui/discussions/15720
colbert-ai==0.2.21 colbert-ai==0.2.22
## Tests ## Tests
@ -136,17 +136,17 @@ pytest-docker~=3.2.5
ldap3==2.9.1 ldap3==2.9.1
## Firecrawl ## Firecrawl
firecrawl-py==4.10.0 firecrawl-py==4.10.4
## Trace ## Trace
opentelemetry-api==1.38.0 opentelemetry-api==1.39.0
opentelemetry-sdk==1.38.0 opentelemetry-sdk==1.39.0
opentelemetry-exporter-otlp==1.38.0 opentelemetry-exporter-otlp==1.39.0
opentelemetry-instrumentation==0.59b0 opentelemetry-instrumentation==0.60b0
opentelemetry-instrumentation-fastapi==0.59b0 opentelemetry-instrumentation-fastapi==0.60b0
opentelemetry-instrumentation-sqlalchemy==0.59b0 opentelemetry-instrumentation-sqlalchemy==0.60b0
opentelemetry-instrumentation-redis==0.59b0 opentelemetry-instrumentation-redis==0.60b0
opentelemetry-instrumentation-requests==0.59b0 opentelemetry-instrumentation-requests==0.60b0
opentelemetry-instrumentation-logging==0.59b0 opentelemetry-instrumentation-logging==0.60b0
opentelemetry-instrumentation-httpx==0.59b0 opentelemetry-instrumentation-httpx==0.60b0
opentelemetry-instrumentation-aiohttp-client==0.59b0 opentelemetry-instrumentation-aiohttp-client==0.60b0

View file

@ -1,8 +1,8 @@
services: services:
playwright: playwright:
image: mcr.microsoft.com/playwright:v1.56.0-noble # Version must match requirements.txt image: mcr.microsoft.com/playwright:v1.57.0-noble # Version must match requirements.txt
container_name: playwright container_name: playwright
command: npx -y playwright@1.56.0 run-server --port 3000 --host 0.0.0.0 command: npx -y playwright@1.57.0 run-server --port 3000 --host 0.0.0.0
open-webui: open-webui:
environment: environment:

View file

@ -6,7 +6,7 @@ authors = [
] ]
license = { file = "LICENSE" } license = { file = "LICENSE" }
dependencies = [ dependencies = [
"fastapi==0.123.0", "fastapi==0.124.0",
"uvicorn[standard]==0.37.0", "uvicorn[standard]==0.37.0",
"pydantic==2.12.5", "pydantic==2.12.5",
"python-multipart==0.0.20", "python-multipart==0.0.20",
@ -21,7 +21,7 @@ dependencies = [
"authlib==1.6.5", "authlib==1.6.5",
"requests==2.32.5", "requests==2.32.5",
"aiohttp==3.12.15", "aiohttp==3.13.2",
"async-timeout", "async-timeout",
"aiocache", "aiocache",
"aiofiles", "aiofiles",
@ -29,26 +29,26 @@ dependencies = [
"httpx[socks,http2,zstd,cli,brotli]==0.28.1", "httpx[socks,http2,zstd,cli,brotli]==0.28.1",
"starsessions[redis]==2.2.1", "starsessions[redis]==2.2.1",
"sqlalchemy==2.0.38", "sqlalchemy==2.0.44",
"alembic==1.17.2", "alembic==1.17.2",
"peewee==3.18.3", "peewee==3.18.3",
"peewee-migrate==1.14.3", "peewee-migrate==1.14.3",
"pycrdt==0.12.25", "pycrdt==0.12.44",
"redis", "redis",
"APScheduler==3.10.4", "APScheduler==3.11.1",
"RestrictedPython==8.0", "RestrictedPython==8.1",
"loguru==0.7.3", "loguru==0.7.3",
"asgiref==3.11.0", "asgiref==3.11.0",
"tiktoken", "tiktoken",
"mcp==1.22.0", "mcp==1.23.3",
"openai", "openai",
"anthropic", "anthropic",
"google-genai==1.52.0", "google-genai==1.54.0",
"google-generativeai==0.8.5", "google-generativeai==0.8.5",
"langchain==0.3.27", "langchain==0.3.27",
@ -56,62 +56,62 @@ dependencies = [
"fake-useragent==2.2.0", "fake-useragent==2.2.0",
"chromadb==1.3.5", "chromadb==1.3.5",
"opensearch-py==2.8.0", "opensearch-py==3.1.0",
"PyMySQL==1.1.1", "PyMySQL==1.1.2",
"boto3==1.41.5", "boto3==1.42.5",
"transformers==4.57.3", "transformers==4.57.3",
"sentence-transformers==5.1.2", "sentence-transformers==5.1.2",
"accelerate", "accelerate",
"pyarrow==20.0.0", "pyarrow==20.0.0", # fix: pin pyarrow version to 20 for rpi compatibility #15897
"einops==0.8.1", "einops==0.8.1",
"ftfy==6.3.1", "ftfy==6.3.1",
"chardet==5.2.0", "chardet==5.2.0",
"pypdf==6.4.0", "pypdf==6.4.1",
"fpdf2==2.8.2", "fpdf2==2.8.5",
"pymdown-extensions==10.17.2", "pymdown-extensions==10.18",
"docx2txt==0.8", "docx2txt==0.9",
"python-pptx==1.0.2", "python-pptx==1.0.2",
"unstructured==0.18.21", "unstructured==0.18.21",
"msoffcrypto-tool==5.4.2", "msoffcrypto-tool==5.4.2",
"nltk==3.9.1", "nltk==3.9.2",
"Markdown==3.10", "Markdown==3.10",
"pypandoc==1.16.2", "pypandoc==1.16.2",
"pandas==2.2.3", "pandas==2.3.3",
"openpyxl==3.1.5", "openpyxl==3.1.5",
"pyxlsb==1.0.10", "pyxlsb==1.0.10",
"xlrd==2.0.1", "xlrd==2.0.2",
"validators==0.35.0", "validators==0.35.0",
"psutil", "psutil",
"sentencepiece", "sentencepiece",
"soundfile==0.13.1", "soundfile==0.13.1",
"azure-ai-documentintelligence==1.0.2", "azure-ai-documentintelligence==1.0.2",
"pillow==11.3.0", "pillow==12.0.0",
"opencv-python-headless==4.11.0.86", "opencv-python-headless==4.12.0.88",
"rapidocr-onnxruntime==1.4.4", "rapidocr-onnxruntime==1.4.4",
"rank-bm25==0.2.2", "rank-bm25==0.2.2",
"onnxruntime==1.20.1", "onnxruntime==1.23.2",
"faster-whisper==1.1.1", "faster-whisper==1.2.1",
"black==25.11.0", "black==25.12.0",
"youtube-transcript-api==1.2.2", "youtube-transcript-api==1.2.3",
"pytube==15.0.0", "pytube==15.0.0",
"pydub", "pydub",
"ddgs==9.9.2", "ddgs==9.9.3",
"google-api-python-client", "google-api-python-client",
"google-auth-httplib2", "google-auth-httplib2",
"google-auth-oauthlib", "google-auth-oauthlib",
"googleapis-common-protos==1.72.0", "googleapis-common-protos==1.72.0",
"google-cloud-storage==2.19.0", "google-cloud-storage==3.7.0",
"azure-identity==1.25.0", "azure-identity==1.25.1",
"azure-storage-blob==12.24.1", "azure-storage-blob==12.27.1",
"ldap3==2.9.1", "ldap3==2.9.1",
] ]
@ -130,8 +130,8 @@ classifiers = [
[project.optional-dependencies] [project.optional-dependencies]
postgres = [ postgres = [
"psycopg2-binary==2.9.10", "psycopg2-binary==2.9.11",
"pgvector==0.4.1", "pgvector==0.4.2",
] ]
all = [ all = [
@ -143,17 +143,18 @@ all = [
"docker~=7.1.0", "docker~=7.1.0",
"pytest~=8.3.2", "pytest~=8.3.2",
"pytest-docker~=3.2.5", "pytest-docker~=3.2.5",
"playwright==1.56.0", "playwright==1.57.0", # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
"elasticsearch==9.1.0", "elasticsearch==9.2.0",
"qdrant-client==1.16.1", "qdrant-client==1.16.1",
"weaviate-client==4.17.0", "pymilvus==2.6.4",
"weaviate-client==4.18.3",
"pymilvus==2.6.5", "pymilvus==2.6.5",
"pinecone==6.0.2", "pinecone==6.0.2",
"oracledb==3.2.0", "oracledb==3.4.1",
"colbert-ai==0.2.21", "colbert-ai==0.2.22",
"firecrawl-py==4.10.0", "firecrawl-py==4.10.4",
"azure-search-documents==11.6.0", "azure-search-documents==11.6.0",
] ]

View file

@ -803,3 +803,7 @@ body {
position: relative; position: relative;
z-index: 0; z-index: 0;
} }
#note-content-container .ProseMirror {
padding-bottom: 2rem; /* space for the bottom toolbar */
}

View file

@ -132,6 +132,56 @@ export const getKnowledgeById = async (token: string, id: string) => {
return res; return res;
}; };
export const searchKnowledgeFilesById = async (
token: string,
id: string,
query?: string | null = null,
viewOption?: string | null = null,
orderBy?: string | null = null,
direction?: string | null = null,
page: number = 1
) => {
let error = null;
const searchParams = new URLSearchParams();
if (query) searchParams.append('query', query);
if (viewOption) searchParams.append('view_option', viewOption);
if (orderBy) searchParams.append('order_by', orderBy);
if (direction) searchParams.append('direction', direction);
searchParams.append('page', page.toString());
const res = await fetch(
`${WEBUI_API_BASE_URL}/knowledge/${id}/files?${searchParams.toString()}`,
{
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
}
)
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
type KnowledgeUpdateForm = { type KnowledgeUpdateForm = {
name?: string; name?: string;
description?: string; description?: string;

View file

@ -91,6 +91,65 @@ export const getNotes = async (token: string = '', raw: boolean = false) => {
return grouped; return grouped;
}; };
export const searchNotes = async (
token: string = '',
query: string | null = null,
viewOption: string | null = null,
permission: string | null = null,
sortKey: string | null = null,
page: number | null = null
) => {
let error = null;
const searchParams = new URLSearchParams();
if (query !== null) {
searchParams.append('query', query);
}
if (viewOption !== null) {
searchParams.append('view_option', viewOption);
}
if (permission !== null) {
searchParams.append('permission', permission);
}
if (sortKey !== null) {
searchParams.append('order_by', sortKey);
}
if (page !== null) {
searchParams.append('page', `${page}`);
}
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/search?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getNoteList = async (token: string = '', page: number | null = null) => { export const getNoteList = async (token: string = '', page: number | null = null) => {
let error = null; let error = null;
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
@ -99,7 +158,7 @@ export const getNoteList = async (token: string = '', page: number | null = null
searchParams.append('page', `${page}`); searchParams.append('page', `${page}`);
} }
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list?${searchParams.toString()}`, { const res = await fetch(`${WEBUI_API_BASE_URL}/notes/?${searchParams.toString()}`, {
method: 'GET', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',

View file

@ -339,7 +339,7 @@
</tr> </tr>
</thead> </thead>
<tbody class=""> <tbody class="">
{#each users as user, userIdx} {#each users as user, userIdx (user.id)}
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs"> <tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
<td class="px-3 py-1 min-w-[7rem] w-28"> <td class="px-3 py-1 min-w-[7rem] w-28">
<button <button

View file

@ -365,6 +365,7 @@
bind:chatInputElement bind:chatInputElement
bind:replyToMessage bind:replyToMessage
{typingUsers} {typingUsers}
{channel}
userSuggestions={true} userSuggestions={true}
channelSuggestions={true} channelSuggestions={true}
disabled={!channel?.write_access} disabled={!channel?.write_access}

View file

@ -106,7 +106,7 @@
<div class=""> <div class="">
<button <button
type="button" type="button"
class=" px-3 py-1.5 gap-1 rounded-xl bg-black dark:text-white dark:bg-gray-850/50 text-black transition font-medium text-xs flex items-center justify-center" class=" px-3 py-1.5 gap-1 rounded-xl bg-gray-100/50 dark:text-white dark:bg-gray-850/50 text-black transition font-medium text-xs flex items-center justify-center"
on:click={onAdd} on:click={onAdd}
> >
<Plus className="size-3.5 " /> <Plus className="size-3.5 " />

View file

@ -42,9 +42,10 @@
import XMark from '../icons/XMark.svelte'; import XMark from '../icons/XMark.svelte';
export let placeholder = $i18n.t('Type here...'); export let placeholder = $i18n.t('Type here...');
export let chatInputElement;
export let id = null; export let id = null;
export let chatInputElement; export let channel = null;
export let typingUsers = []; export let typingUsers = [];
export let inputLoading = false; export let inputLoading = false;
@ -459,15 +460,16 @@
try { try {
// During the file upload, file content is automatically extracted. // During the file upload, file content is automatically extracted.
// If the file is an audio file, provide the language for STT. // If the file is an audio file, provide the language for STT.
let metadata = null; let metadata = {
if ( channel_id: channel.id,
(file.type.startsWith('audio/') || file.type.startsWith('video/')) && // If the file is an audio file, provide the language for STT.
...((file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
$settings?.audio?.stt?.language $settings?.audio?.stt?.language
) { ? {
metadata = {
language: $settings?.audio?.stt?.language language: $settings?.audio?.stt?.language
};
} }
: {})
};
const uploadedFile = await uploadFile(localStorage.token, file, metadata, process); const uploadedFile = await uploadFile(localStorage.token, file, metadata, process);

View file

@ -1,14 +1,22 @@
<script lang="ts"> <script lang="ts">
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { marked } from 'marked';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { createPicker, getAuthToken } from '$lib/utils/google-drive-picker'; import dayjs from '$lib/dayjs';
import { pickAndDownloadFile } from '$lib/utils/onedrive-file-picker'; import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(duration);
dayjs.extend(relativeTime);
import { onMount, tick, getContext, createEventDispatcher, onDestroy } from 'svelte'; import { onMount, tick, getContext, createEventDispatcher, onDestroy } from 'svelte';
import { createPicker, getAuthToken } from '$lib/utils/google-drive-picker';
import { pickAndDownloadFile } from '$lib/utils/onedrive-file-picker';
import { KokoroWorker } from '$lib/workers/KokoroWorker';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
import { import {
@ -49,6 +57,9 @@
import { WEBUI_BASE_URL, WEBUI_API_BASE_URL, PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants'; import { WEBUI_BASE_URL, WEBUI_API_BASE_URL, PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
import { createNoteHandler } from '../notes/utils';
import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
import InputMenu from './MessageInput/InputMenu.svelte'; import InputMenu from './MessageInput/InputMenu.svelte';
import VoiceRecording from './MessageInput/VoiceRecording.svelte'; import VoiceRecording from './MessageInput/VoiceRecording.svelte';
import FilesOverlay from './MessageInput/FilesOverlay.svelte'; import FilesOverlay from './MessageInput/FilesOverlay.svelte';
@ -60,11 +71,9 @@
import Image from '../common/Image.svelte'; import Image from '../common/Image.svelte';
import XMark from '../icons/XMark.svelte'; import XMark from '../icons/XMark.svelte';
import Headphone from '../icons/Headphone.svelte';
import GlobeAlt from '../icons/GlobeAlt.svelte'; import GlobeAlt from '../icons/GlobeAlt.svelte';
import Photo from '../icons/Photo.svelte'; import Photo from '../icons/Photo.svelte';
import Wrench from '../icons/Wrench.svelte'; import Wrench from '../icons/Wrench.svelte';
import CommandLine from '../icons/CommandLine.svelte';
import Sparkles from '../icons/Sparkles.svelte'; import Sparkles from '../icons/Sparkles.svelte';
import InputVariablesModal from './MessageInput/InputVariablesModal.svelte'; import InputVariablesModal from './MessageInput/InputVariablesModal.svelte';
@ -74,12 +83,13 @@
import Component from '../icons/Component.svelte'; import Component from '../icons/Component.svelte';
import PlusAlt from '../icons/PlusAlt.svelte'; import PlusAlt from '../icons/PlusAlt.svelte';
import { KokoroWorker } from '$lib/workers/KokoroWorker';
import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
import CommandSuggestionList from './MessageInput/CommandSuggestionList.svelte'; import CommandSuggestionList from './MessageInput/CommandSuggestionList.svelte';
import Knobs from '../icons/Knobs.svelte'; import Knobs from '../icons/Knobs.svelte';
import ValvesModal from '../workspace/common/ValvesModal.svelte'; import ValvesModal from '../workspace/common/ValvesModal.svelte';
import PageEdit from '../icons/PageEdit.svelte';
import { goto } from '$app/navigation';
import InputModal from '../common/InputModal.svelte';
import Expand from '../icons/Expand.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -109,6 +119,8 @@
export let webSearchEnabled = false; export let webSearchEnabled = false;
export let codeInterpreterEnabled = false; export let codeInterpreterEnabled = false;
let inputContent = null;
let showInputVariablesModal = false; let showInputVariablesModal = false;
let inputVariablesModalCallback = (variableValues) => {}; let inputVariablesModalCallback = (variableValues) => {};
let inputVariables = {}; let inputVariables = {};
@ -410,6 +422,8 @@
let inputFiles; let inputFiles;
let showInputModal = false;
let dragged = false; let dragged = false;
let shiftKey = false; let shiftKey = false;
@ -730,6 +744,25 @@
}); });
}; };
const createNote = async () => {
if (inputContent?.md.trim() === '' && inputContent?.html.trim() === '') {
toast.error($i18n.t('Cannot create an empty note.'));
return;
}
const res = await createNoteHandler(
dayjs().format('YYYY-MM-DD'),
inputContent?.md,
inputContent?.html
);
if (res) {
// Clear the input content saved in session storage.
sessionStorage.removeItem('chat-input');
goto(`/notes/${res.id}`);
}
};
const onDragOver = (e) => { const onDragOver = (e) => {
e.preventDefault(); e.preventDefault();
@ -955,6 +988,20 @@
}} }}
/> />
<InputModal
bind:show={showInputModal}
bind:value={prompt}
bind:inputContent
onChange={(content) => {
console.log(content);
chatInputElement?.setContent(content?.json ?? null);
}}
onClose={async () => {
await tick();
chatInputElement?.focus();
}}
/>
{#if loaded} {#if loaded}
<div class="w-full font-primary"> <div class="w-full font-primary">
<div class=" mx-auto inset-x-0 bg-transparent flex justify-center"> <div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
@ -1189,14 +1236,33 @@
: ''}" : ''}"
id="chat-input-container" id="chat-input-container"
> >
{#if prompt.split('\n').length > 2}
<div class="fixed top-0 right-0 z-20">
<div class="mt-2.5 mr-3">
<button
type="button"
class="p-1 rounded-lg hover:bg-gray-100/50 dark:hover:bg-gray-800/50"
aria-label="Expand input"
on:click={async () => {
showInputModal = true;
}}
>
<Expand />
</button>
</div>
</div>
{/if}
{#if suggestions} {#if suggestions}
{#key $settings?.richTextInput ?? true} {#key $settings?.richTextInput ?? true}
{#key $settings?.showFormattingToolbar ?? false} {#key $settings?.showFormattingToolbar ?? false}
<RichTextInput <RichTextInput
bind:this={chatInputElement} bind:this={chatInputElement}
id="chat-input" id="chat-input"
onChange={(e) => { editable={!showInputModal}
prompt = e.md; onChange={(content) => {
prompt = content.md;
inputContent = content;
command = getCommand(); command = getCommand();
}} }}
json={true} json={true}
@ -1620,13 +1686,54 @@
</div> </div>
</div> </div>
<div class="self-end flex space-x-1 mr-1 shrink-0"> <div class="self-end flex space-x-1 mr-1 shrink-0 gap-[0.5px]">
{#if (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true) || generating}
<div class=" flex items-center">
<Tooltip content={$i18n.t('Stop')}>
<button
class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
on:click={() => {
stopResponse();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-5"
>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
</div>
{:else}
{#if prompt !== '' && !history?.currentId && ($config?.features?.enable_notes ?? false) && ($user?.role === 'admin' || ($user?.permissions?.features?.notes ?? true))}
<Tooltip content={$i18n.t('Create note')} className=" flex items-center">
<button
id="send-message-button"
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 self-center"
type="button"
disabled={prompt === '' && files.length === 0}
on:click={() => {
createNote();
}}
>
<PageEdit className="size-4.5 translate-y-[0.5px]" />
</button>
</Tooltip>
{/if}
{#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))} {#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
<!-- {$i18n.t('Record voice')} --> <!-- {$i18n.t('Record voice')} -->
<Tooltip content={$i18n.t('Dictate')}> <Tooltip content={$i18n.t('Dictate')}>
<button <button
id="voice-input-button" id="voice-input-button"
class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 mr-0.5 self-center" class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 self-center mr-0.5"
type="button" type="button"
on:click={async () => { on:click={async () => {
try { try {
@ -1660,7 +1767,7 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
class="w-5 h-5 translate-y-[0.5px]" class="size-5 translate-y-[0.5px]"
> >
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" /> <path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
<path <path
@ -1671,31 +1778,7 @@
</Tooltip> </Tooltip>
{/if} {/if}
{#if (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true) || generating} {#if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
<div class=" flex items-center">
<Tooltip content={$i18n.t('Stop')}>
<button
class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
on:click={() => {
stopResponse();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="size-5"
>
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
</div>
{:else if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
<div class=" flex items-center"> <div class=" flex items-center">
<!-- {$i18n.t('Call')} --> <!-- {$i18n.t('Call')} -->
<Tooltip content={$i18n.t('Voice mode')}> <Tooltip content={$i18n.t('Voice mode')}>
@ -1785,6 +1868,7 @@
</Tooltip> </Tooltip>
</div> </div>
{/if} {/if}
{/if}
</div> </div>
</div> </div>
</div> </div>

View file

@ -80,41 +80,6 @@
}; };
onMount(async () => { onMount(async () => {
let legacy_documents = knowledge
.filter((item) => item?.meta?.document)
.map((item) => ({
...item,
type: 'file'
}));
let legacy_collections =
legacy_documents.length > 0
? [
{
name: 'All Documents',
legacy: true,
type: 'collection',
description: 'Deprecated (legacy collection), please create a new knowledge base.',
title: $i18n.t('All Documents'),
collection_names: legacy_documents.map((item) => item.id)
},
...legacy_documents
.reduce((a, item) => {
return [...new Set([...a, ...(item?.meta?.tags ?? []).map((tag) => tag.name)])];
}, [])
.map((tag) => ({
name: tag,
legacy: true,
type: 'collection',
description: 'Deprecated (legacy collection), please create a new knowledge base.',
collection_names: legacy_documents
.filter((item) => (item?.meta?.tags ?? []).map((tag) => tag.name).includes(tag))
.map((item) => item.id)
}))
]
: [];
let collections = knowledge let collections = knowledge
.filter((item) => !item?.meta?.document) .filter((item) => !item?.meta?.document)
.map((item) => ({ .map((item) => ({
@ -154,19 +119,7 @@
title: folder.name title: folder.name
})); }));
items = [ items = [...folder_items, ...collections, ...collection_files];
...folder_items,
...collections,
...collection_files,
...legacy_collections,
...legacy_documents
].map((item) => {
return {
...item,
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
};
});
fuse = new Fuse(items, { fuse = new Fuse(items, {
keys: ['name', 'description'] keys: ['name', 'description']
}); });

View file

@ -24,41 +24,6 @@
await knowledge.set(await getKnowledgeBases(localStorage.token)); await knowledge.set(await getKnowledgeBases(localStorage.token));
} }
let legacy_documents = $knowledge
.filter((item) => item?.meta?.document)
.map((item) => ({
...item,
type: 'file'
}));
let legacy_collections =
legacy_documents.length > 0
? [
{
name: 'All Documents',
legacy: true,
type: 'collection',
description: 'Deprecated (legacy collection), please create a new knowledge base.',
title: $i18n.t('All Documents'),
collection_names: legacy_documents.map((item) => item.id)
},
...legacy_documents
.reduce((a, item) => {
return [...new Set([...a, ...(item?.meta?.tags ?? []).map((tag) => tag.name)])];
}, [])
.map((tag) => ({
name: tag,
legacy: true,
type: 'collection',
description: 'Deprecated (legacy collection), please create a new knowledge base.',
collection_names: legacy_documents
.filter((item) => (item?.meta?.tags ?? []).map((tag) => tag.name).includes(tag))
.map((item) => item.id)
}))
]
: [];
let collections = $knowledge let collections = $knowledge
.filter((item) => !item?.meta?.document) .filter((item) => !item?.meta?.document)
.map((item) => ({ .map((item) => ({
@ -91,15 +56,7 @@
] ]
: []; : [];
items = [...collections, ...collection_files, ...legacy_collections, ...legacy_documents].map( items = [...collections, ...collection_files];
(item) => {
return {
...item,
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
};
}
);
await tick(); await tick();
loaded = true; loaded = true;

View file

@ -0,0 +1,62 @@
<script lang="ts">
import { getContext } from 'svelte';
import { Select, DropdownMenu } from 'bits-ui';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
const i18n = getContext('i18n');
export let align = 'center';
export let className = '';
export let value = '';
export let placeholder = 'Select an option';
export let items = [
{ value: 'new', label: $i18n.t('New') },
{ value: 'top', label: $i18n.t('Top') }
];
export let onChange: (value: string) => void = () => {};
let open = false;
</script>
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger>
<div
class={className
? className
: 'flex w-full items-center gap-2 truncate bg-transparent px-0.5 text-sm placeholder-gray-400 outline-hidden focus:outline-hidden'}
>
{items.find((item) => item.value === value)?.label ?? placeholder}
<ChevronDown className=" size-3" strokeWidth="2.5" />
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content {align}>
<div
class="dark:bg-gray-850 z-50 w-full rounded-2xl border border-gray-100 bg-white p-1 shadow-lg dark:border-gray-800 dark:text-white"
>
{#each items as item}
<button
class="flex w-full cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-800 {value ===
item.value
? ' '
: ' text-gray-500 dark:text-gray-400'}"
type="button"
on:click={() => {
if (value === item.value) {
value = null;
} else {
value = item.value;
}
open = false;
onChange(value);
}}
>
{item.label}
</button>
{/each}
</div>
</DropdownMenu.Content>
</DropdownMenu.Root>

View file

@ -0,0 +1,79 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { settings } from '$lib/stores';
import Drawer from './Drawer.svelte';
import RichTextInput from './RichTextInput.svelte';
const i18n = getContext('i18n');
export let id = 'input-modal';
export let show = false;
export let value = null;
export let inputContent = null;
export let autocomplete = false;
export let generateAutoCompletion = null;
export let onChange = () => {};
export let onClose = () => {};
let inputElement;
</script>
<Drawer bind:show>
<div class="flex h-full min-h-screen flex-col">
<div
class=" sticky top-0 z-30 flex justify-between bg-white px-4.5 pt-3 pb-3 dark:bg-gray-900 dark:text-gray-100"
>
<div class=" font-primary self-center text-lg">
{$i18n.t('Input')}
</div>
<button
class="self-center"
aria-label="Close"
onclick={() => {
show = false;
onClose();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-5 w-5"
>
<path
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
/>
</svg>
</button>
</div>
<div class="flex w-full px-4 dark:text-gray-200 min-h-full flex-1">
<div class="flex-1 w-full min-h-full">
<RichTextInput
bind:this={inputElement}
{id}
onChange={(content) => {
value = content.md;
inputContent = content;
onChange(content);
}}
json={true}
value={inputContent?.json}
html={inputContent?.html}
richText={$settings?.richTextInput ?? true}
messageInput={true}
showFormattingToolbar={$settings?.showFormattingToolbar ?? false}
floatingMenuPlacement={'top-start'}
insertPromptAsRichText={$settings?.insertPromptAsRichText ?? false}
{autocomplete}
{generateAutoCompletion}
/>
</div>
</div>
</div>
</Drawer>

View file

@ -169,7 +169,7 @@
export let documentId = ''; export let documentId = '';
export let className = 'input-prose'; export let className = 'input-prose min-h-fit h-full';
export let placeholder = $i18n.t('Type here...'); export let placeholder = $i18n.t('Type here...');
let _placeholder = placeholder; let _placeholder = placeholder;
@ -1156,7 +1156,5 @@
<div <div
bind:this={element} bind:this={element}
class="relative w-full min-w-full h-full min-h-fit {className} {!editable class="relative w-full min-w-full {className} {!editable ? 'cursor-not-allowed' : ''}"
? 'cursor-not-allowed'
: ''}"
/> />

View file

@ -0,0 +1,21 @@
<script lang="ts">
export let className = 'w-4 h-4';
export let strokeWidth = '1.5';
</script>
<svg
class={className}
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
stroke-width={strokeWidth}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path d="M9 9L4 4M4 4V8M4 4H8" stroke-linecap="round" stroke-linejoin="round"></path><path
d="M15 9L20 4M20 4V8M20 4H16"
stroke-linecap="round"
stroke-linejoin="round"
></path><path d="M9 15L4 20M4 20V16M4 20H8" stroke-linecap="round" stroke-linejoin="round"
></path><path d="M15 15L20 20M20 20V16M20 20H16" stroke-linecap="round" stroke-linejoin="round"
></path></svg
>

View file

@ -0,0 +1,24 @@
<script lang="ts">
export let className = 'w-4 h-4';
export let strokeWidth = '1.5';
</script>
<svg
class={className}
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
stroke-width={strokeWidth}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path d="M9 12H12M15 12H12M12 12V9M12 12V15" stroke-linecap="round" stroke-linejoin="round"
></path><path
d="M4 21.4V2.6C4 2.26863 4.26863 2 4.6 2H16.2515C16.4106 2 16.5632 2.06321 16.6757 2.17574L19.8243 5.32426C19.9368 5.43679 20 5.5894 20 5.74853V21.4C20 21.7314 19.7314 22 19.4 22H4.6C4.26863 22 4 21.7314 4 21.4Z"
stroke-linecap="round"
stroke-linejoin="round"
></path><path
d="M16 2V5.4C16 5.73137 16.2686 6 16.6 6H20"
stroke-linecap="round"
stroke-linejoin="round"
></path></svg
>

View file

@ -157,6 +157,16 @@
if (res) { if (res) {
note = res; note = res;
files = res.data.files || []; files = res.data.files || [];
if (note?.write_access) {
$socket?.emit('join-note', {
note_id: id,
auth: {
token: localStorage.token
}
});
$socket?.on('note-events', noteEventHandler);
}
} else { } else {
goto('/'); goto('/');
return; return;
@ -781,13 +791,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
onMount(async () => { onMount(async () => {
await tick(); await tick();
$socket?.emit('join-note', {
note_id: id,
auth: {
token: localStorage.token
}
});
$socket?.on('note-events', noteEventHandler);
if ($settings?.models) { if ($settings?.models) {
selectedModelId = $settings?.models[0]; selectedModelId = $settings?.models[0];
@ -956,6 +959,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
{/if} {/if}
<div class="flex items-center gap-0.5 translate-x-1"> <div class="flex items-center gap-0.5 translate-x-1">
{#if note?.write_access}
{#if editor} {#if editor}
<div> <div>
<div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr"> <div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr">
@ -1019,6 +1023,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
<AdjustmentsHorizontalOutline /> <AdjustmentsHorizontalOutline />
</button> </button>
</Tooltip> </Tooltip>
{/if}
<NoteMenu <NoteMenu
onDownload={(type) => { onDownload={(type) => {
@ -1071,11 +1076,9 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
}} }}
> >
<div <div
class="flex gap-1 items-center text-xs font-medium text-gray-500 dark:text-gray-500 w-fit" class="flex gap-0.5 items-center text-xs font-medium text-gray-500 dark:text-gray-500 w-fit"
> >
<button class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit"> <button class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit">
<Calendar className="size-3.5" strokeWidth="2" />
<!-- check for same date, yesterday, last week, and other --> <!-- check for same date, yesterday, last week, and other -->
{#if dayjs(note.created_at / 1000000).isSame(dayjs(), 'day')} {#if dayjs(note.created_at / 1000000).isSame(dayjs(), 'day')}
@ -1099,6 +1102,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
{/if} {/if}
</button> </button>
{#if note?.write_access}
<button <button
class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit" class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit"
on:click={() => { on:click={() => {
@ -1106,10 +1110,13 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
}} }}
disabled={note?.user_id !== $user?.id && $user?.role !== 'admin'} disabled={note?.user_id !== $user?.id && $user?.role !== 'admin'}
> >
<Users className="size-3.5" strokeWidth="2" />
<span> {note?.access_control ? $i18n.t('Private') : $i18n.t('Everyone')} </span> <span> {note?.access_control ? $i18n.t('Private') : $i18n.t('Everyone')} </span>
</button> </button>
{:else}
<div>
{$i18n.t('Read-Only Access')}
</div>
{/if}
{#if editor} {#if editor}
<div class="flex items-center gap-1 px-1 min-w-fit"> <div class="flex items-center gap-1 px-1 min-w-fit">
@ -1130,7 +1137,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
</div> </div>
<div <div
class=" flex-1 w-full h-full overflow-auto px-3.5 pb-20 relative pt-2.5" class=" flex-1 w-full h-full overflow-auto px-3.5 relative"
id="note-content-container" id="note-content-container"
> >
{#if editing} {#if editing}
@ -1145,7 +1152,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
bind:this={inputElement} bind:this={inputElement}
bind:editor bind:editor
id={`note-${note.id}`} id={`note-${note.id}`}
className="input-prose-sm px-0.5" className="input-prose-sm px-0.5 h-[calc(100%-2rem)]"
json={true} json={true}
bind:value={note.data.content.json} bind:value={note.data.content.json}
html={note.data?.content?.html} html={note.data?.content?.html}
@ -1158,7 +1165,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
image={true} image={true}
{files} {files}
placeholder={$i18n.t('Write something...')} placeholder={$i18n.t('Write something...')}
editable={versionIdx === null && !editing} editable={versionIdx === null && !editing && note?.write_access}
onSelectionUpdate={({ editor }) => { onSelectionUpdate={({ editor }) => {
const { from, to } = editor.state.selection; const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to, ' '); const selectedText = editor.state.doc.textBetween(from, to, ' ');
@ -1243,8 +1250,8 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
</div> </div>
{/if} {/if}
</div> </div>
<div class="absolute z-20 bottom-0 right-0 p-3.5 max-w-full w-full flex"> <div class="absolute z-50 bottom-0 right-0 p-3.5 flex select-none">
<div class="flex gap-1 w-full min-w-full justify-between"> <div class="flex flex-col gap-2 justify-end">
{#if recording} {#if recording}
<div class="flex-1 w-full"> <div class="flex-1 w-full">
<VoiceRecording <VoiceRecording
@ -1269,6 +1276,39 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
/> />
</div> </div>
{:else} {:else}
<div
class="cursor-pointer flex gap-0.5 rounded-full border border-gray-50 dark:border-gray-850/30 dark:bg-gray-850 transition shadow-xl"
>
<Tooltip content={$i18n.t('AI')} placement="top">
{#if editing}
<button
class="p-2 flex justify-center items-center hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full transition shrink-0"
on:click={() => {
stopResponseHandler();
}}
type="button"
>
<Spinner className="size-5" />
</button>
{:else}
<AiMenu
onEdit={() => {
enhanceNoteHandler();
}}
onChat={() => {
showPanel = true;
selectedPanel = 'chat';
}}
>
<div
class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 bg-white dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl"
>
<SparklesSolid />
</div>
</AiMenu>
{/if}
</Tooltip>
</div>
<RecordMenu <RecordMenu
onRecord={async () => { onRecord={async () => {
displayMediaRecord = false; displayMediaRecord = false;
@ -1324,40 +1364,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
</div> </div>
</Tooltip> </Tooltip>
</RecordMenu> </RecordMenu>
<div
class="cursor-pointer flex gap-0.5 rounded-full border border-gray-50 dark:border-gray-850/30 dark:bg-gray-850 transition shadow-xl"
>
<Tooltip content={$i18n.t('AI')} placement="top">
{#if editing}
<button
class="p-2 flex justify-center items-center hover:bg-gray-50 dark:hover:bg-gray-800 rounded-full transition shrink-0"
on:click={() => {
stopResponseHandler();
}}
type="button"
>
<Spinner className="size-5" />
</button>
{:else}
<AiMenu
onEdit={() => {
enhanceNoteHandler();
}}
onChat={() => {
showPanel = true;
selectedPanel = 'chat';
}}
>
<div
class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 bg-white dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl"
>
<SparklesSolid />
</div>
</AiMenu>
{/if}
</Tooltip>
</div>
{/if} {/if}
</div> </div>
</div> </div>

View file

@ -1,9 +1,7 @@
<script lang="ts"> <script lang="ts">
import { marked } from 'marked'; import { marked } from 'marked';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import fileSaver from 'file-saver'; import fileSaver from 'file-saver';
import Fuse from 'fuse.js';
const { saveAs } = fileSaver; const { saveAs } = fileSaver;
@ -25,17 +23,16 @@
} }
} }
import { onMount, getContext, onDestroy } from 'svelte';
const i18n = getContext('i18n');
// Assuming $i18n.languages is an array of language codes // Assuming $i18n.languages is an array of language codes
$: loadLocale($i18n.languages); $: loadLocale($i18n.languages);
import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount, getContext, onDestroy } from 'svelte';
import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores'; import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores';
import { createNewNote, deleteNoteById, getNoteList, searchNotes } from '$lib/apis/notes';
import { createNewNote, deleteNoteById, getNotes } from '$lib/apis/notes';
import { capitalizeFirstLetter, copyToClipboard, getTimeRange } from '$lib/utils'; import { capitalizeFirstLetter, copyToClipboard, getTimeRange } from '$lib/utils';
import { downloadPdf, createNoteHandler } from './utils'; import { downloadPdf, createNoteHandler } from './utils';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte'; import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
@ -48,58 +45,31 @@
import NoteMenu from './Notes/NoteMenu.svelte'; import NoteMenu from './Notes/NoteMenu.svelte';
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte'; import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
import XMark from '../icons/XMark.svelte'; import XMark from '../icons/XMark.svelte';
import DropdownOptions from '../common/DropdownOptions.svelte';
import Loader from '../common/Loader.svelte';
const i18n = getContext('i18n');
let loaded = false; let loaded = false;
let importFiles = ''; let importFiles = '';
let query = '';
let noteItems = [];
let fuse = null;
let selectedNote = null; let selectedNote = null;
let notes = {};
$: if (fuse) {
notes = groupNotes(
query
? fuse.search(query).map((e) => {
return e.item;
})
: noteItems
);
}
let showDeleteConfirm = false; let showDeleteConfirm = false;
const groupNotes = (res) => { let notes = {};
console.log(res);
if (!Array.isArray(res)) {
return {}; // or throw new Error("Notes response is not an array")
}
// Build the grouped object let items = null;
const grouped: Record<string, any[]> = {}; let total = null;
for (const note of res) {
const timeRange = getTimeRange(note.updated_at / 1000000000);
if (!grouped[timeRange]) {
grouped[timeRange] = [];
}
grouped[timeRange].push({
...note,
timeRange
});
}
return grouped;
};
const init = async () => { let query = '';
noteItems = await getNotes(localStorage.token, true);
fuse = new Fuse(noteItems, { let sortKey = null;
keys: ['title'] let displayOption = null;
}); let viewOption = null;
}; let permission = null;
let page = 1;
let itemsLoading = false;
let allItemsLoaded = false;
const downloadHandler = async (type) => { const downloadHandler = async (type) => {
if (type === 'txt') { if (type === 'txt') {
@ -173,6 +143,96 @@
} }
}; };
const reset = () => {
page = 1;
items = null;
total = null;
allItemsLoaded = false;
itemsLoading = false;
notes = {};
};
const loadMoreItems = async () => {
if (allItemsLoaded) return;
page += 1;
await getItemsPage();
};
const init = async () => {
reset();
await getItemsPage();
};
$: if (
loaded &&
query !== undefined &&
sortKey !== undefined &&
permission !== undefined &&
viewOption !== undefined
) {
init();
}
const getItemsPage = async () => {
itemsLoading = true;
if (viewOption === 'created') {
permission = null;
}
const res = await searchNotes(
localStorage.token,
query,
viewOption,
permission,
sortKey,
page
).catch(() => {
return [];
});
if (res) {
console.log(res);
total = res.total;
const pageItems = res.items;
if ((pageItems ?? []).length === 0) {
allItemsLoaded = true;
} else {
allItemsLoaded = false;
}
if (items) {
items = [...items, ...pageItems];
} else {
items = pageItems;
}
}
itemsLoading = false;
return res;
};
const groupNotes = (res) => {
if (!Array.isArray(res)) {
return {}; // or throw new Error("Notes response is not an array")
}
// Build the grouped object
const grouped: Record<string, any[]> = {};
for (const note of res) {
const timeRange = getTimeRange(note.updated_at / 1000000000);
if (!grouped[timeRange]) {
grouped[timeRange] = [];
}
grouped[timeRange].push({
...note,
timeRange
});
}
return grouped;
};
let dragged = false; let dragged = false;
const onDragOver = (e) => { const onDragOver = (e) => {
@ -205,6 +265,18 @@
dragged = false; dragged = false;
}; };
onMount(async () => {
viewOption = localStorage?.noteViewOption ?? null;
displayOption = localStorage?.noteDisplayOption ?? null;
loaded = true;
const dropzoneElement = document.getElementById('notes-container');
dropzoneElement?.addEventListener('dragover', onDragOver);
dropzoneElement?.addEventListener('drop', onDrop);
dropzoneElement?.addEventListener('dragleave', onDragLeave);
});
onDestroy(() => { onDestroy(() => {
console.log('destroy'); console.log('destroy');
const dropzoneElement = document.getElementById('notes-container'); const dropzoneElement = document.getElementById('notes-container');
@ -215,17 +287,6 @@
dropzoneElement?.removeEventListener('dragleave', onDragLeave); dropzoneElement?.removeEventListener('dragleave', onDragLeave);
} }
}); });
onMount(async () => {
await init();
loaded = true;
const dropzoneElement = document.getElementById('notes-container');
dropzoneElement?.addEventListener('dragover', onDragOver);
dropzoneElement?.addEventListener('drop', onDrop);
dropzoneElement?.addEventListener('dragleave', onDragLeave);
});
</script> </script>
<svelte:head> <svelte:head>
@ -236,7 +297,7 @@
<FilesOverlay show={dragged} /> <FilesOverlay show={dragged} />
<div id="notes-container" class="w-full min-h-full h-full"> <div id="notes-container" class="w-full min-h-full h-full px-3 md:px-[18px]">
{#if loaded} {#if loaded}
<DeleteConfirmDialog <DeleteConfirmDialog
bind:show={showDeleteConfirm} bind:show={showDeleteConfirm}
@ -251,8 +312,41 @@
</div> </div>
</DeleteConfirmDialog> </DeleteConfirmDialog>
<div class="flex flex-col gap-1 px-3.5"> <div class="flex flex-col gap-1 px-1 mt-1.5 mb-3">
<div class=" flex flex-1 items-center w-full space-x-2"> <div class="flex justify-between items-center">
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
<div>
{$i18n.t('Notes')}
</div>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{total}
</div>
</div>
<div class="flex w-full justify-end gap-1.5">
<button
class=" px-2 py-1.5 rounded-xl bg-black text-white dark:bg-white dark:text-black transition font-medium text-sm flex items-center"
on:click={async () => {
const res = await createNoteHandler(dayjs().format('YYYY-MM-DD'));
if (res) {
goto(`/notes/${res.id}`);
}
}}
>
<Plus className="size-3" strokeWidth="2.5" />
<div class=" md:ml-1 text-xs">{$i18n.t('New Note')}</div>
</button>
</div>
</div>
</div>
<div
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30"
>
<div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
<div class="flex flex-1 items-center"> <div class="flex flex-1 items-center">
<div class=" self-center ml-1 mr-3"> <div class=" self-center ml-1 mr-3">
<Search className="size-3.5" /> <Search className="size-3.5" />
@ -277,18 +371,174 @@
{/if} {/if}
</div> </div>
</div> </div>
<div class="px-3 flex justify-between">
<div
class="flex w-full bg-transparent overflow-x-auto scrollbar-none"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex gap-3 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
>
<DropdownOptions
align="start"
className="flex w-full items-center gap-2 truncate px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-850 rounded-xl placeholder-gray-400 outline-hidden focus:outline-hidden"
bind:value={viewOption}
items={[
{ value: null, label: $i18n.t('All') },
{ value: 'created', label: $i18n.t('Created by you') },
{ value: 'shared', label: $i18n.t('Shared with you') }
]}
onChange={(value) => {
if (value) {
localStorage.noteViewOption = value;
} else {
delete localStorage.noteViewOption;
}
}}
/>
{#if [null, 'shared'].includes(viewOption)}
<DropdownOptions
align="start"
bind:value={permission}
items={[
{ value: null, label: $i18n.t('Write') },
{ value: 'read_only', label: $i18n.t('Read Only') }
]}
/>
{/if}
</div>
</div> </div>
<div class="px-4.5 @container h-full pt-2"> <div>
{#if Object.keys(notes).length > 0} <DropdownOptions
<div class="pb-10"> align="start"
{#each Object.keys(notes) as timeRange} bind:value={displayOption}
<div class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium pb-2.5"> items={[
{ value: null, label: $i18n.t('List') },
{ value: 'grid', label: $i18n.t('Grid') }
]}
onChange={() => {
if (displayOption) {
localStorage.noteDisplayOption = displayOption;
} else {
delete localStorage.noteDisplayOption;
}
}}
/>
</div>
</div>
{#if items !== null && total !== null}
{#if (items ?? []).length > 0}
{@const notes = groupNotes(items)}
<div class="@container h-full py-2.5 px-2.5">
<div class="">
{#each Object.keys(notes) as timeRange, idx}
<div
class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium px-2.5 pb-2.5"
>
{$i18n.t(timeRange)} {$i18n.t(timeRange)}
</div> </div>
{#if displayOption === null}
<div <div
class="mb-5 gap-2.5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5" class="{Object.keys(notes).length - 1 !== idx
? 'mb-3'
: ''} gap-1.5 flex flex-col"
>
{#each notes[timeRange] as note, idx (note.id)}
<div
class=" flex cursor-pointer w-full px-3.5 py-1.5 border border-gray-50 dark:border-gray-850/30 bg-transparent dark:hover:bg-gray-850 hover:bg-white rounded-2xl transition"
>
<a href={`/notes/${note.id}`} class="w-full flex flex-col justify-between">
<div class="flex-1">
<div class=" flex items-center gap-2 self-center justify-between">
<Tooltip
content={note.title}
className="flex-1"
placement="top-start"
>
<div
class=" text-sm font-medium capitalize flex-1 w-full line-clamp-1"
>
{note.title}
</div>
</Tooltip>
<div class="flex shrink-0 items-center text-xs gap-2.5">
<Tooltip content={dayjs(note.updated_at / 1000000).format('LLLL')}>
<div>
{dayjs(note.updated_at / 1000000).fromNow()}
</div>
</Tooltip>
<Tooltip
content={note?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
<div class="shrink-0 text-gray-500">
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
note?.user?.name ??
note?.user?.email ??
$i18n.t('Deleted User')
)
})}
</div>
</Tooltip>
<div>
<NoteMenu
onDownload={(type) => {
selectedNote = note;
downloadHandler(type);
}}
onCopyLink={async () => {
const baseUrl = window.location.origin;
const res = await copyToClipboard(
`${baseUrl}/notes/${note.id}`
);
if (res) {
toast.success($i18n.t('Copied link to clipboard'));
} else {
toast.error($i18n.t('Failed to copy link'));
}
}}
onDelete={() => {
selectedNote = note;
showDeleteConfirm = true;
}}
>
<button
class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
>
<EllipsisHorizontal className="size-5" />
</button>
</NoteMenu>
</div>
</div>
</div>
</div>
</a>
</div>
{/each}
</div>
{:else if displayOption === 'grid'}
<div
class="{Object.keys(notes).length - 1 !== idx
? 'mb-5'
: ''} gap-2.5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
> >
{#each notes[timeRange] as note, idx (note.id)} {#each notes[timeRange] as note, idx (note.id)}
<div <div
@ -300,8 +550,12 @@
class="w-full -translate-y-0.5 flex flex-col justify-between" class="w-full -translate-y-0.5 flex flex-col justify-between"
> >
<div class="flex-1"> <div class="flex-1">
<div class=" flex items-center gap-2 self-center mb-1 justify-between"> <div
<div class=" font-semibold line-clamp-1 capitalize">{note.title}</div> class=" flex items-center gap-2 self-center mb-1 justify-between"
>
<div class=" font-semibold line-clamp-1 capitalize">
{note.title}
</div>
<div> <div>
<NoteMenu <NoteMenu
@ -312,7 +566,9 @@
}} }}
onCopyLink={async () => { onCopyLink={async () => {
const baseUrl = window.location.origin; const baseUrl = window.location.origin;
const res = await copyToClipboard(`${baseUrl}/notes/${note.id}`); const res = await copyToClipboard(
`${baseUrl}/notes/${note.id}`
);
if (res) { if (res) {
toast.success($i18n.t('Copied link to clipboard')); toast.success($i18n.t('Copied link to clipboard'));
@ -358,7 +614,9 @@
<div class="shrink-0 text-gray-500"> <div class="shrink-0 text-gray-500">
{$i18n.t('By {{name}}', { {$i18n.t('By {{name}}', {
name: capitalizeFirstLetter( name: capitalizeFirstLetter(
note?.user?.name ?? note?.user?.email ?? $i18n.t('Deleted User') note?.user?.name ??
note?.user?.email ??
$i18n.t('Deleted User')
) )
})} })}
</div> </div>
@ -369,102 +627,49 @@
</div> </div>
{/each} {/each}
</div> </div>
{/if}
{/each} {/each}
{#if !allItemsLoaded}
<Loader
on:visible={(e) => {
if (!itemsLoading) {
loadMoreItems();
}
}}
>
<div
class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2"
>
<Spinner className=" size-4" />
<div class=" ">{$i18n.t('Loading...')}</div>
</div>
</Loader>
{/if}
</div>
</div> </div>
{:else} {:else}
<div class="w-full h-full flex flex-col items-center justify-center"> <div class="w-full h-full flex flex-col items-center justify-center">
<div class="pb-20 text-center"> <div class="py-20 text-center">
<div class=" text-xl font-medium text-gray-400 dark:text-gray-600"> <div class=" text-sm text-gray-400 dark:text-gray-600">
{$i18n.t('No Notes')} {$i18n.t('No Notes')}
</div> </div>
<div class="mt-1 text-sm text-gray-300 dark:text-gray-700"> <div class="mt-1 text-xs text-gray-300 dark:text-gray-700">
{$i18n.t('Create your first note by clicking on the plus button below.')} {$i18n.t('Create your first note by clicking on the plus button below.')}
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}
</div>
<div class="absolute bottom-0 left-0 right-0 p-5 max-w-full flex justify-end">
<div class="flex gap-0.5 justify-end w-full">
<Tooltip content={$i18n.t('Create Note')}>
<button
class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 bg-white dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl"
type="button"
on:click={async () => {
const res = await createNoteHandler(dayjs().format('YYYY-MM-DD'));
if (res) {
goto(`/notes/${res.id}`);
}
}}
>
<Plus className="size-4.5" strokeWidth="2.5" />
</button>
</Tooltip>
<!-- <button
class="cursor-pointer p-2.5 flex rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition shadow-xl"
>
<SparklesSolid className="size-4" />
</button> -->
</div>
</div>
<!-- {#if $user?.role === 'admin'}
<div class=" flex justify-end w-full mb-3">
<div class="flex space-x-2">
<input
id="notes-import-input"
bind:files={importFiles}
type="file"
accept=".md"
hidden
on:change={() => {
console.log(importFiles);
const reader = new FileReader();
reader.onload = async (event) => {
console.log(event.target.result);
};
reader.readAsText(importFiles[0]);
}}
/>
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
on:click={() => {
const notesImportInputElement = document.getElementById('notes-import-input');
if (notesImportInputElement) {
notesImportInputElement.click();
}
}}
>
<div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Notes')}</div>
<div class=" self-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z"
clip-rule="evenodd"
/>
</svg>
</div>
</button>
</div>
</div>
{/if} -->
{:else} {:else}
<div class="w-full h-full flex justify-center items-center"> <div class="w-full h-full flex justify-center items-center py-10">
<Spinner className="size-5" /> <Spinner className="size-4" />
</div>
{/if}
</div>
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-4" />
</div> </div>
{/if} {/if}
</div> </div>

View file

@ -107,7 +107,7 @@ export const downloadPdf = async (note) => {
pdf.save(`${note.title}.pdf`); pdf.save(`${note.title}.pdf`);
}; };
export const createNoteHandler = async (title: string, content?: string) => { export const createNoteHandler = async (title: string, md?: string, html?: string) => {
// $i18n.t('New Note'), // $i18n.t('New Note'),
const res = await createNewNote(localStorage.token, { const res = await createNewNote(localStorage.token, {
// YYYY-MM-DD // YYYY-MM-DD
@ -115,8 +115,8 @@ export const createNoteHandler = async (title: string, content?: string) => {
data: { data: {
content: { content: {
json: null, json: null,
html: content ?? '', html: html || md || '',
md: content ?? '' md: md || ''
} }
}, },
meta: null, meta: null,

View file

@ -31,7 +31,8 @@
removeFileFromKnowledgeById, removeFileFromKnowledgeById,
resetKnowledgeById, resetKnowledgeById,
updateFileFromKnowledgeById, updateFileFromKnowledgeById,
updateKnowledgeById updateKnowledgeById,
searchKnowledgeFilesById
} from '$lib/apis/knowledge'; } from '$lib/apis/knowledge';
import { blobToFile } from '$lib/utils'; import { blobToFile } from '$lib/utils';
@ -43,22 +44,25 @@
import AddTextContentModal from './KnowledgeBase/AddTextContentModal.svelte'; import AddTextContentModal from './KnowledgeBase/AddTextContentModal.svelte';
import SyncConfirmDialog from '../../common/ConfirmDialog.svelte'; import SyncConfirmDialog from '../../common/ConfirmDialog.svelte';
import RichTextInput from '$lib/components/common/RichTextInput.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
import Drawer from '$lib/components/common/Drawer.svelte'; import Drawer from '$lib/components/common/Drawer.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte'; import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte'; import LockClosed from '$lib/components/icons/LockClosed.svelte';
import AccessControlModal from '../common/AccessControlModal.svelte'; import AccessControlModal from '../common/AccessControlModal.svelte';
import Search from '$lib/components/icons/Search.svelte'; import Search from '$lib/components/icons/Search.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import FilesOverlay from '$lib/components/chat/MessageInput/FilesOverlay.svelte'; import FilesOverlay from '$lib/components/chat/MessageInput/FilesOverlay.svelte';
import DropdownOptions from '$lib/components/common/DropdownOptions.svelte';
import Pagination from '$lib/components/common/Pagination.svelte';
let largeScreen = true; let largeScreen = true;
let pane; let pane;
let showSidepanel = true; let showSidepanel = true;
let minSize = 0;
let showAddTextContentModal = false;
let showSyncConfirmModal = false;
let showAccessControlModal = false;
let minSize = 0;
type Knowledge = { type Knowledge = {
id: string; id: string;
name: string; name: string;
@ -71,52 +75,89 @@
let id = null; let id = null;
let knowledge: Knowledge | null = null; let knowledge: Knowledge | null = null;
let query = ''; let knowledgeId = null;
let showAddTextContentModal = false; let selectedFileId = null;
let showSyncConfirmModal = false; let selectedFile = null;
let showAccessControlModal = false; let selectedFileContent = '';
let inputFiles = null; let inputFiles = null;
let filteredItems = []; let query = '';
$: if (knowledge && knowledge.files) { let viewOption = null;
fuse = new Fuse(knowledge.files, { let sortKey = null;
keys: ['meta.name', 'meta.description'] let direction = null;
let currentPage = 1;
let fileItems = null;
let fileItemsTotal = null;
const reset = () => {
currentPage = 1;
};
const init = async () => {
reset();
await getItemsPage();
};
$: if (
knowledgeId !== null &&
query !== undefined &&
viewOption !== undefined &&
sortKey !== undefined &&
direction !== undefined &&
currentPage !== undefined
) {
getItemsPage();
}
$: if (
query !== undefined &&
viewOption !== undefined &&
sortKey !== undefined &&
direction !== undefined
) {
reset();
}
const getItemsPage = async () => {
if (knowledgeId === null) return;
fileItems = null;
fileItemsTotal = null;
if (sortKey === null) {
direction = null;
}
const res = await searchKnowledgeFilesById(
localStorage.token,
knowledge.id,
query,
viewOption,
sortKey,
direction,
currentPage
).catch(() => {
return null;
}); });
if (res) {
fileItems = res.items;
fileItemsTotal = res.total;
} }
return res;
};
$: if (fuse) { const fileSelectHandler = async (file) => {
filteredItems = query try {
? fuse.search(query).map((e) => { selectedFile = file;
return e.item; selectedFileContent = selectedFile?.data?.content || '';
}) } catch (e) {
: (knowledge?.files ?? []); toast.error($i18n.t('Failed to load file content.'));
} }
};
let selectedFile = null;
let selectedFileId = null;
let selectedFileContent = '';
// Add cache object
let fileContentCache = new Map();
$: if (selectedFileId) {
const file = (knowledge?.files ?? []).find((file) => file.id === selectedFileId);
if (file) {
fileSelectHandler(file);
} else {
selectedFile = null;
}
} else {
selectedFile = null;
}
let fuse = null;
let debounceTimeout = null;
let mediaQuery;
let dragged = false;
let isSaving = false;
const createFileFromText = (name, content) => { const createFileFromText = (name, content) => {
const blob = new Blob([content], { type: 'text/plain' }); const blob = new Blob([content], { type: 'text/plain' });
@ -163,19 +204,18 @@
return; return;
} }
knowledge.files = [...(knowledge.files ?? []), fileItem]; fileItems = [...(fileItems ?? []), fileItem];
try { try {
let metadata = {
knowledge_id: knowledge.id,
// If the file is an audio file, provide the language for STT. // If the file is an audio file, provide the language for STT.
let metadata = null; ...((file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
if (
(file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
$settings?.audio?.stt?.language $settings?.audio?.stt?.language
) { ? {
metadata = {
language: $settings?.audio?.stt?.language language: $settings?.audio?.stt?.language
};
} }
: {})
};
const uploadedFile = await uploadFile(localStorage.token, file, metadata).catch((e) => { const uploadedFile = await uploadFile(localStorage.token, file, metadata).catch((e) => {
toast.error(`${e}`); toast.error(`${e}`);
@ -184,7 +224,7 @@
if (uploadedFile) { if (uploadedFile) {
console.log(uploadedFile); console.log(uploadedFile);
knowledge.files = knowledge.files.map((item) => { fileItems = fileItems.map((item) => {
if (item.itemId === tempItemId) { if (item.itemId === tempItemId) {
item.id = uploadedFile.id; item.id = uploadedFile.id;
} }
@ -197,7 +237,7 @@
if (uploadedFile.error) { if (uploadedFile.error) {
console.warn('File upload warning:', uploadedFile.error); console.warn('File upload warning:', uploadedFile.error);
toast.warning(uploadedFile.error); toast.warning(uploadedFile.error);
knowledge.files = knowledge.files.filter((file) => file.id !== uploadedFile.id); fileItems = fileItems.filter((file) => file.id !== uploadedFile.id);
} else { } else {
await addFileHandler(uploadedFile.id); await addFileHandler(uploadedFile.id);
} }
@ -401,19 +441,17 @@
}; };
const addFileHandler = async (fileId) => { const addFileHandler = async (fileId) => {
const updatedKnowledge = await addFileToKnowledgeById(localStorage.token, id, fileId).catch( const res = await addFileToKnowledgeById(localStorage.token, id, fileId).catch((e) => {
(e) => {
toast.error(`${e}`); toast.error(`${e}`);
return null; return null;
} });
);
if (updatedKnowledge) { if (res) {
knowledge = updatedKnowledge;
toast.success($i18n.t('File added successfully.')); toast.success($i18n.t('File added successfully.'));
init();
} else { } else {
toast.error($i18n.t('Failed to add file.')); toast.error($i18n.t('Failed to add file.'));
knowledge.files = knowledge.files.filter((file) => file.id !== fileId); fileItems = fileItems.filter((file) => file.id !== fileId);
} }
}; };
@ -422,13 +460,12 @@
console.log('Starting file deletion process for:', fileId); console.log('Starting file deletion process for:', fileId);
// Remove from knowledge base only // Remove from knowledge base only
const updatedKnowledge = await removeFileFromKnowledgeById(localStorage.token, id, fileId); const res = await removeFileFromKnowledgeById(localStorage.token, id, fileId);
console.log('Knowledge base updated:', res);
console.log('Knowledge base updated:', updatedKnowledge); if (res) {
if (updatedKnowledge) {
knowledge = updatedKnowledge;
toast.success($i18n.t('File removed successfully.')); toast.success($i18n.t('File removed successfully.'));
await init();
} }
} catch (e) { } catch (e) {
console.error('Error in deleteFileHandler:', e); console.error('Error in deleteFileHandler:', e);
@ -436,32 +473,38 @@
} }
}; };
let debounceTimeout = null;
let mediaQuery;
let dragged = false;
let isSaving = false;
const updateFileContentHandler = async () => { const updateFileContentHandler = async () => {
if (isSaving) { if (isSaving) {
console.log('Save operation already in progress, skipping...'); console.log('Save operation already in progress, skipping...');
return; return;
} }
isSaving = true; isSaving = true;
try { try {
const fileId = selectedFile.id; const res = await updateFileDataContentById(
const content = selectedFileContent;
// Clear the cache for this file since we're updating it
fileContentCache.delete(fileId);
const res = await updateFileDataContentById(localStorage.token, fileId, content).catch(
(e) => {
toast.error(`${e}`);
}
);
const updatedKnowledge = await updateFileFromKnowledgeById(
localStorage.token, localStorage.token,
id, selectedFile.id,
fileId selectedFileContent
).catch((e) => { ).catch((e) => {
toast.error(`${e}`); toast.error(`${e}`);
return null;
}); });
if (res && updatedKnowledge) {
knowledge = updatedKnowledge; if (res) {
toast.success($i18n.t('File content updated successfully.')); toast.success($i18n.t('File content updated successfully.'));
selectedFileId = null;
selectedFile = null;
selectedFileContent = '';
await init();
} }
} finally { } finally {
isSaving = false; isSaving = false;
@ -504,29 +547,6 @@
} }
}; };
const fileSelectHandler = async (file) => {
try {
selectedFile = file;
// Check cache first
if (fileContentCache.has(file.id)) {
selectedFileContent = fileContentCache.get(file.id);
return;
}
const response = await getFileById(localStorage.token, file.id);
if (response) {
selectedFileContent = response.data.content;
// Cache the content
fileContentCache.set(file.id, response.data.content);
} else {
toast.error($i18n.t('No content found in file.'));
}
} catch (e) {
toast.error($i18n.t('Failed to load file content.'));
}
};
const onDragOver = (e) => { const onDragOver = (e) => {
e.preventDefault(); e.preventDefault();
@ -627,7 +647,6 @@
} }
id = $page.params.id; id = $page.params.id;
const res = await getKnowledgeById(localStorage.token, id).catch((e) => { const res = await getKnowledgeById(localStorage.token, id).catch((e) => {
toast.error(`${e}`); toast.error(`${e}`);
return null; return null;
@ -635,6 +654,7 @@
if (res) { if (res) {
knowledge = res; knowledge = res;
knowledgeId = knowledge?.id;
} else { } else {
goto('/workspace/knowledge'); goto('/workspace/knowledge');
} }
@ -705,32 +725,42 @@
}} }}
/> />
<div class="flex flex-col w-full h-full translate-y-1" id="collection-container"> <div class="flex flex-col w-full h-full min-h-full" id="collection-container">
{#if id && knowledge} {#if id && knowledge}
<AccessControlModal <AccessControlModal
bind:show={showAccessControlModal} bind:show={showAccessControlModal}
bind:accessControl={knowledge.access_control} bind:accessControl={knowledge.access_control}
share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'} share={$user?.permissions?.sharing?.knowledge || $user?.role === 'admin'}
sharePu={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'} sharePublic={$user?.permissions?.sharing?.public_knowledge || $user?.role === 'admin'}
onChange={() => { onChange={() => {
changeDebounceHandler(); changeDebounceHandler();
}} }}
accessRoles={['read', 'write']} accessRoles={['read', 'write']}
/> />
<div class="w-full mb-2.5"> <div class="w-full px-2">
<div class=" flex w-full"> <div class=" flex w-full">
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center justify-between w-full px-0.5 mb-1"> <div class="flex items-center justify-between w-full">
<div class="w-full"> <div class="w-full flex justify-between items-center">
<input <input
type="text" type="text"
class="text-left w-full font-medium text-2xl font-primary bg-transparent outline-hidden" class="text-left w-full font-medium text-lg font-primary bg-transparent outline-hidden flex-1"
bind:value={knowledge.name} bind:value={knowledge.name}
placeholder={$i18n.t('Knowledge Name')} placeholder={$i18n.t('Knowledge Name')}
on:input={() => { on:input={() => {
changeDebounceHandler(); changeDebounceHandler();
}} }}
/> />
<div class="shrink-0 mr-2.5">
{#if (knowledge?.files ?? []).length}
<div class="text-xs text-gray-500">
{$i18n.t('{{count}} files', {
count: (knowledge?.files ?? []).length
})}
</div>
{/if}
</div>
</div> </div>
<div class="self-center shrink-0"> <div class="self-center shrink-0">
@ -750,7 +780,7 @@
</div> </div>
</div> </div>
<div class="flex w-full px-1"> <div class="flex w-full">
<input <input
type="text" type="text"
class="text-left text-xs w-full text-gray-500 bg-transparent outline-hidden" class="text-left text-xs w-full text-gray-500 bg-transparent outline-hidden"
@ -765,153 +795,18 @@
</div> </div>
</div> </div>
<div class="flex flex-row flex-1 h-full max-h-full pb-2.5 gap-3">
{#if largeScreen}
<div class="flex-1 flex justify-start w-full h-full max-h-full">
{#if selectedFile}
<div class=" flex flex-col w-full">
<div class="shrink-0 mb-2 flex items-center">
{#if !showSidepanel}
<div class="-translate-x-2">
<button
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
on:click={() => {
pane.expand();
}}
>
<ChevronLeft strokeWidth="2.5" />
</button>
</div>
{/if}
<div class=" flex-1 text-xl font-medium">
<a
class="hover:text-gray-500 dark:hover:text-gray-100 hover:underline grow line-clamp-1"
href={selectedFile.id ? `/api/v1/files/${selectedFile.id}/content` : '#'}
target="_blank"
>
{decodeString(selectedFile?.meta?.name)}
</a>
</div>
<div>
<button
class="self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSaving}
on:click={() => {
updateFileContentHandler();
}}
>
{$i18n.t('Save')}
{#if isSaving}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</div>
<div <div
class=" flex-1 w-full h-full max-h-full text-sm bg-transparent outline-hidden overflow-y-auto scrollbar-hidden" class="mt-2 mb-2.5 py-2 -mx-0 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30 flex-1"
> >
{#key selectedFile.id} <div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
<textarea <div class="flex flex-1 items-center">
class="w-full h-full outline-none resize-none"
bind:value={selectedFileContent}
placeholder={$i18n.t('Add content here')}
/>
{/key}
</div>
</div>
{:else}
<div class="h-full flex w-full">
<div class="m-auto text-xs text-center text-gray-200 dark:text-gray-700">
{$i18n.t('Drag and drop a file to upload or select a file to view')}
</div>
</div>
{/if}
</div>
{:else if !largeScreen && selectedFileId !== null}
<Drawer
className="h-full"
show={selectedFileId !== null}
onClose={() => {
selectedFileId = null;
}}
>
<div class="flex flex-col justify-start h-full max-h-full p-2">
<div class=" flex flex-col w-full h-full max-h-full">
<div class="shrink-0 mt-1 mb-2 flex items-center">
<div class="mr-2">
<button
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
on:click={() => {
selectedFileId = null;
}}
>
<ChevronLeft strokeWidth="2.5" />
</button>
</div>
<div class=" flex-1 text-xl line-clamp-1">
{selectedFile?.meta?.name}
</div>
<div>
<button
class="self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSaving}
on:click={() => {
updateFileContentHandler();
}}
>
{$i18n.t('Save')}
{#if isSaving}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</div>
<div
class=" flex-1 w-full h-full max-h-full py-2.5 px-3.5 rounded-lg text-sm bg-transparent overflow-y-auto scrollbar-hidden"
>
{#key selectedFile.id}
<textarea
class="w-full h-full outline-none resize-none"
bind:value={selectedFileContent}
placeholder={$i18n.t('Add content here')}
/>
{/key}
</div>
</div>
</div>
</Drawer>
{/if}
<div
class="{largeScreen ? 'shrink-0 w-72 max-w-72' : 'flex-1'}
flex
py-2
rounded-2xl
border
border-gray-50
h-full
dark:border-gray-850"
>
<div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
<div class="w-full h-full flex flex-col">
<div class=" px-3">
<div class="flex mb-0.5">
<div class=" self-center ml-1 mr-3"> <div class=" self-center ml-1 mr-3">
<Search /> <Search className="size-3.5" />
</div> </div>
<input <input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent" class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query} bind:value={query}
placeholder={`${$i18n.t('Search Collection')}${(knowledge?.files ?? []).length ? ` (${(knowledge?.files ?? []).length})` : ''}`} placeholder={`${$i18n.t('Search Collection')}`}
on:focus={() => { on:focus={() => {
selectedFileId = null; selectedFileId = null;
}} }}
@ -936,23 +831,96 @@
</div> </div>
</div> </div>
{#if filteredItems.length > 0} <div class="px-3 flex justify-between">
<div
class="flex w-full bg-transparent overflow-x-auto scrollbar-none"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex gap-3 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
>
<DropdownOptions
align="start"
className="flex w-full items-center gap-2 truncate px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-850 rounded-xl placeholder-gray-400 outline-hidden focus:outline-hidden"
bind:value={viewOption}
items={[
{ value: null, label: $i18n.t('All') },
{ value: 'created', label: $i18n.t('Created by you') },
{ value: 'shared', label: $i18n.t('Shared with you') }
]}
onChange={(value) => {
if (value) {
localStorage.workspaceViewOption = value;
} else {
delete localStorage.workspaceViewOption;
}
}}
/>
<DropdownOptions
align="start"
bind:value={sortKey}
placeholder={$i18n.t('Sort')}
items={[
{ value: 'name', label: $i18n.t('Name') },
{ value: 'created_at', label: $i18n.t('Created At') },
{ value: 'updated_at', label: $i18n.t('Updated At') }
]}
/>
{#if sortKey}
<DropdownOptions
align="start"
bind:value={direction}
items={[
{ value: 'asc', label: $i18n.t('Asc') },
{ value: null, label: $i18n.t('Desc') }
]}
/>
{/if}
</div>
</div>
</div>
{#if fileItems !== null && fileItemsTotal !== null}
<div class="flex flex-row flex-1 gap-3 px-2.5 mt-2">
<div class="flex-1 flex">
<div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
<div class="w-full h-full flex flex-col min-h-full">
{#if fileItems.length > 0}
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs"> <div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
<Files <Files
small files={fileItems}
files={filteredItems}
{selectedFileId} {selectedFileId}
on:click={(e) => { onClick={(fileId) => {
selectedFileId = selectedFileId === e.detail ? null : e.detail; selectedFileId = fileId;
}}
on:delete={(e) => {
console.log(e.detail);
if (fileItems) {
const file = fileItems.find((file) => file.id === selectedFileId);
if (file) {
fileSelectHandler(file);
} else {
selectedFile = null;
}
}
}}
onDelete={(fileId) => {
selectedFileId = null; selectedFileId = null;
deleteFileHandler(e.detail); selectedFile = null;
deleteFileHandler(fileId);
}} }}
/> />
</div> </div>
{#if fileItemsTotal > 30}
<Pagination bind:page={currentPage} count={fileItemsTotal} perPage={30} />
{/if}
{:else} {:else}
<div class="my-3 flex flex-col justify-center text-center text-gray-500 text-xs"> <div class="my-3 flex flex-col justify-center text-center text-gray-500 text-xs">
<div> <div>
@ -963,6 +931,69 @@
</div> </div>
</div> </div>
</div> </div>
{#if selectedFileId !== null}
<Drawer
className="h-full"
show={selectedFileId !== null}
onClose={() => {
selectedFileId = null;
selectedFile = null;
}}
>
<div class="flex flex-col justify-start h-full max-h-full">
<div class=" flex flex-col w-full h-full max-h-full">
<div class="shrink-0 flex items-center p-2">
<div class="mr-2">
<button
class="w-full text-left text-sm p-1.5 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
on:click={() => {
selectedFileId = null;
selectedFile = null;
}}
>
<ChevronLeft strokeWidth="2.5" />
</button>
</div>
<div class=" flex-1 text-lg line-clamp-1">
{selectedFile?.meta?.name}
</div>
<div>
<button
class="flex self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSaving}
on:click={() => {
updateFileContentHandler();
}}
>
{$i18n.t('Save')}
{#if isSaving}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</div>
{#key selectedFile.id}
<textarea
class="w-full h-full text-sm outline-none resize-none px-3 py-2"
bind:value={selectedFileContent}
placeholder={$i18n.t('Add content here')}
/>
{/key}
</div>
</div>
</Drawer>
{/if}
</div>
{:else}
<div class="my-10">
<Spinner className="size-4" />
</div>
{/if}
</div> </div>
{:else} {:else}
<Spinner className="size-5" /> <Spinner className="size-5" />

View file

@ -50,14 +50,14 @@
<div slot="content"> <div slot="content">
<DropdownMenu.Content <DropdownMenu.Content
class="w-full max-w-44 rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm" class="w-full max-w-[200px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg transition"
sideOffset={4} sideOffset={4}
side="bottom" side="bottom"
align="end" align="end"
transition={flyAndScale} transition={flyAndScale}
> >
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => { on:click={() => {
dispatch('upload', { type: 'files' }); dispatch('upload', { type: 'files' });
}} }}
@ -67,7 +67,7 @@
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => { on:click={() => {
dispatch('upload', { type: 'directory' }); dispatch('upload', { type: 'directory' });
}} }}
@ -83,7 +83,7 @@
className="w-full" className="w-full"
> >
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => { on:click={() => {
dispatch('sync', { type: 'directory' }); dispatch('sync', { type: 'directory' });
}} }}
@ -94,7 +94,7 @@
</Tooltip> </Tooltip>
<DropdownMenu.Item <DropdownMenu.Item
class="flex gap-2 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md" class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => { on:click={() => {
dispatch('upload', { type: 'text' }); dispatch('upload', { type: 'text' });
}} }}

View file

@ -1,45 +1,97 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte'; import dayjs from '$lib/dayjs';
const dispatch = createEventDispatcher(); import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import FileItem from '$lib/components/common/FileItem.svelte'; dayjs.extend(duration);
dayjs.extend(relativeTime);
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { capitalizeFirstLetter, formatFileSize } from '$lib/utils';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
export let selectedFileId = null; export let selectedFileId = null;
export let files = []; export let files = [];
export let small = false; export let onClick = (fileId) => {};
export let onDelete = (fileId) => {};
</script> </script>
<div class=" max-h-full flex flex-col w-full"> <div class=" max-h-full flex flex-col w-full gap-[0.5px]">
{#each files as file} {#each files as file (file?.id ?? file?.tempId)}
<div class="mt-1 px-2"> <div
<FileItem class=" flex cursor-pointer w-full px-1.5 py-0.5 bg-transparent dark:hover:bg-gray-850/50 hover:bg-white rounded-xl transition {selectedFileId
className="w-full" ? ''
colorClassName="{selectedFileId === file.id : 'hover:bg-gray-100 dark:hover:bg-gray-850'}"
? ' bg-gray-50 dark:bg-gray-850' >
: 'bg-transparent'} hover:bg-gray-50 dark:hover:bg-gray-850 transition" <button
{small} class="relative group flex items-center gap-1 rounded-xl p-2 text-left flex-1 justify-between"
item={file} type="button"
name={file?.name ?? file?.meta?.name} on:click={async () => {
type="file" console.log(file);
size={file?.size ?? file?.meta?.size ?? ''} onClick(file?.id ?? file?.tempId);
loading={file.status === 'uploading'} }}
dismissible >
<div class="">
<div class="flex gap-2 items-center line-clamp-1">
<div class="shrink-0">
{#if file?.status !== 'uploading'}
<DocumentPage className="size-3" />
{:else}
<Spinner className="size-3" />
{/if}
</div>
<div class="line-clamp-1">
{file?.name ?? file?.meta?.name}
{#if file?.meta?.size}
<span class="text-xs text-gray-500">{formatFileSize(file?.meta?.size)}</span>
{/if}
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Tooltip content={dayjs(file.updated_at * 1000).format('LLLL')}>
<div>
{dayjs(file.updated_at * 1000).fromNow()}
</div>
</Tooltip>
<Tooltip
content={file?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
<div class="shrink-0 text-gray-500">
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
file?.user?.name ?? file?.user?.email ?? $i18n.t('Deleted User')
)
})}
</div>
</Tooltip>
</div>
</button>
<div class="flex items-center">
<Tooltip content={$i18n.t('Delete')}>
<button
class="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition"
type="button"
on:click={() => { on:click={() => {
if (file.status === 'uploading') { onDelete(file?.id ?? file?.tempId);
return;
}
dispatch('click', file.id);
}} }}
on:dismiss={() => { >
if (file.status === 'uploading') { <XMark />
return; </button>
} </Tooltip>
</div>
dispatch('delete', file.id);
}}
/>
</div> </div>
{/each} {/each}
</div> </div>

View file

@ -53,41 +53,6 @@
}; };
}); });
let legacy_documents = knowledgeItems
.filter((item) => item?.meta?.document)
.map((item) => ({
...item,
type: 'file'
}));
let legacy_collections =
legacy_documents.length > 0
? [
{
name: 'All Documents',
legacy: true,
type: 'collection',
description: 'Deprecated (legacy collection), please create a new knowledge base.',
title: $i18n.t('All Documents'),
collection_names: legacy_documents.map((item) => item.id)
},
...legacy_documents
.reduce((a, item) => {
return [...new Set([...a, ...(item?.meta?.tags ?? []).map((tag) => tag.name)])];
}, [])
.map((tag) => ({
name: tag,
legacy: true,
type: 'collection',
description: 'Deprecated (legacy collection), please create a new knowledge base.',
collection_names: legacy_documents
.filter((item) => (item?.meta?.tags ?? []).map((tag) => tag.name).includes(tag))
.map((item) => item.id)
}))
]
: [];
let collections = knowledgeItems let collections = knowledgeItems
.filter((item) => !item?.meta?.document) .filter((item) => !item?.meta?.document)
.map((item) => ({ .map((item) => ({
@ -118,13 +83,7 @@
] ]
: []; : [];
items = [...notes, ...collections, ...legacy_collections].map((item) => { items = [...notes, ...collections, ...collection_files];
return {
...item,
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
};
});
fuse = new Fuse(items, { fuse = new Fuse(items, {
keys: ['name', 'description'] keys: ['name', 'description']
}); });

View file

@ -67,6 +67,7 @@
} }
let system = ''; let system = '';
let rag_template = '';
let info = { let info = {
id: '', id: '',
base_model_id: null, base_model_id: null,
@ -78,12 +79,14 @@
tags: [] tags: []
}, },
params: { params: {
system: '' system: '',
rag_template: ''
} }
}; };
let params = { let params: {
system: '' system: '';
rag_template: '';
}; };
let knowledge = []; let knowledge = [];
@ -207,6 +210,7 @@
} }
info.params.system = system.trim() === '' ? null : system; info.params.system = system.trim() === '' ? null : system;
info.params.rag_template = rag_template.trim() === '' ? null : rag_template;
info.params.stop = params.stop ? params.stop.split(',').filter((s) => s.trim()) : null; info.params.stop = params.stop ? params.stop.split(',').filter((s) => s.trim()) : null;
Object.keys(info.params).forEach((key) => { Object.keys(info.params).forEach((key) => {
if (info.params[key] === '' || info.params[key] === null) { if (info.params[key] === '' || info.params[key] === null) {
@ -254,6 +258,7 @@
} }
system = model?.params?.system ?? ''; system = model?.params?.system ?? '';
rag_template = model?.params?.rag_template ?? '';
params = { ...params, ...model?.params }; params = { ...params, ...model?.params };
params.stop = params?.stop params.stop = params?.stop
@ -644,6 +649,18 @@
</div> </div>
</div> </div>
<div class="my-1">
<div class=" text-xs font-semibold mb-2">{$i18n.t('RAG Template')}</div>
<div>
<Textarea
placeholder={$i18n.t(
'Leave empty to use the default prompt, or enter a custom prompt'
)}
bind:value={rag_template}
/>
</div>
</div>
<div class="flex w-full justify-between"> <div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium"> <div class=" self-center text-xs font-medium">
{$i18n.t('Advanced Params')} {$i18n.t('Advanced Params')}

View file

@ -1624,6 +1624,7 @@
"Tika": "Tika", "Tika": "Tika",
"Tika Server URL required.": "请输入 Tika 服务器接口地址", "Tika Server URL required.": "请输入 Tika 服务器接口地址",
"Tiktoken": "Tiktoken", "Tiktoken": "Tiktoken",
"Timeout": "超时时间",
"Title": "标题", "Title": "标题",
"Title Auto-Generation": "自动生成标题", "Title Auto-Generation": "自动生成标题",
"Title cannot be an empty string.": "标题不能为空", "Title cannot be an empty string.": "标题不能为空",

View file

@ -1624,6 +1624,7 @@
"Tika": "Tika", "Tika": "Tika",
"Tika Server URL required.": "需要提供 Tika 伺服器 URL。", "Tika Server URL required.": "需要提供 Tika 伺服器 URL。",
"Tiktoken": "Tiktoken", "Tiktoken": "Tiktoken",
"Timeout": "逾時時間",
"Title": "標題", "Title": "標題",
"Title Auto-Generation": "自動產生標題", "Title Auto-Generation": "自動產生標題",
"Title cannot be an empty string.": "標題不能是空字串。", "Title cannot be an empty string.": "標題不能是空字串。",

View file

@ -110,7 +110,7 @@
</div> </div>
</nav> </nav>
<div class=" pb-1 flex-1 max-h-full overflow-y-auto @container"> <div class=" flex-1 max-h-full overflow-y-auto @container">
<Notes /> <Notes />
</div> </div>
</div> </div>