This commit is contained in:
Tim Baek 2025-12-11 06:09:19 +00:00 committed by GitHub
commit 4ffe9be546
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
114 changed files with 4911 additions and 2517 deletions

View file

@ -3,8 +3,6 @@ pnpm-lock.yaml
package-lock.json
yarn.lock
kubernetes/
# Copy of .gitignore
.DS_Store
node_modules

View file

@ -1,35 +0,0 @@
### Installing Both Ollama and Open WebUI Using Kustomize
For cpu-only pod
```bash
kubectl apply -f ./kubernetes/manifest/base
```
For gpu-enabled pod
```bash
kubectl apply -k ./kubernetes/manifest
```
### Installing Both Ollama and Open WebUI Using Helm
Package Helm file first
```bash
helm package ./kubernetes/helm/
```
For cpu-only pod
```bash
helm install ollama-webui ./ollama-webui-*.tgz
```
For gpu-enabled pod
```bash
helm install ollama-webui ./ollama-webui-*.tgz --set ollama.resources.limits.nvidia.com/gpu="1"
```
Check the `kubernetes/helm/values.yaml` file to know which parameters are available for customization

View file

@ -1,4 +1,4 @@
Copyright (c) 2023-2025 Timothy Jaeryang Baek (Open WebUI)
Copyright (c) 2023- Open WebUI Inc. [Created by Timothy Jaeryang Baek]
All rights reserved.
Redistribution and use in source and binary forms, with or without

View file

@ -629,6 +629,12 @@ OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID = (
== "true"
)
OAUTH_AUDIENCE = PersistentConfig(
"OAUTH_AUDIENCE",
"oauth.audience",
os.environ.get("OAUTH_AUDIENCE", ""),
)
def load_oauth_providers():
OAUTH_PROVIDERS.clear()
@ -1300,7 +1306,7 @@ USER_PERMISSIONS_WORKSPACE_MODELS_ALLOW_PUBLIC_SHARING = (
USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING = (
os.environ.get(
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_PUBLIC_SHARING", "False"
"USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ALLOW_SHARING", "False"
).lower()
== "true"
)
@ -1339,7 +1345,7 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_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"
)
@ -2994,6 +3000,12 @@ WEB_LOADER_CONCURRENT_REQUESTS = PersistentConfig(
int(os.getenv("WEB_LOADER_CONCURRENT_REQUESTS", "10")),
)
WEB_LOADER_TIMEOUT = PersistentConfig(
"WEB_LOADER_TIMEOUT",
"rag.web.loader.timeout",
os.getenv("WEB_LOADER_TIMEOUT", ""),
)
ENABLE_WEB_LOADER_SSL_VERIFICATION = PersistentConfig(
"ENABLE_WEB_LOADER_SSL_VERIFICATION",

View file

@ -395,6 +395,13 @@ try:
except ValueError:
REDIS_SENTINEL_MAX_RETRY_COUNT = 2
REDIS_SOCKET_CONNECT_TIMEOUT = os.environ.get("REDIS_SOCKET_CONNECT_TIMEOUT", "")
try:
REDIS_SOCKET_CONNECT_TIMEOUT = float(REDIS_SOCKET_CONNECT_TIMEOUT)
except ValueError:
REDIS_SOCKET_CONNECT_TIMEOUT = None
####################################
# UVICORN WORKERS
####################################
@ -620,9 +627,16 @@ ENABLE_WEBSOCKET_SUPPORT = (
WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
WEBSOCKET_REDIS_OPTIONS = os.environ.get("WEBSOCKET_REDIS_OPTIONS", "")
if WEBSOCKET_REDIS_OPTIONS == "":
log.debug("No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None")
WEBSOCKET_REDIS_OPTIONS = None
if REDIS_SOCKET_CONNECT_TIMEOUT:
WEBSOCKET_REDIS_OPTIONS = {
"socket_connect_timeout": REDIS_SOCKET_CONNECT_TIMEOUT
}
else:
log.debug("No WEBSOCKET_REDIS_OPTIONS provided, defaulting to None")
WEBSOCKET_REDIS_OPTIONS = None
else:
try:
WEBSOCKET_REDIS_OPTIONS = json.loads(WEBSOCKET_REDIS_OPTIONS)

View file

@ -208,6 +208,7 @@ from open_webui.config import (
FIRECRAWL_API_KEY,
WEB_LOADER_ENGINE,
WEB_LOADER_CONCURRENT_REQUESTS,
WEB_LOADER_TIMEOUT,
WHISPER_MODEL,
WHISPER_VAD_FILTER,
WHISPER_LANGUAGE,
@ -922,6 +923,7 @@ app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS = WEB_SEARCH_CONCURRENT_REQUESTS
app.state.config.WEB_LOADER_ENGINE = WEB_LOADER_ENGINE
app.state.config.WEB_LOADER_CONCURRENT_REQUESTS = WEB_LOADER_CONCURRENT_REQUESTS
app.state.config.WEB_LOADER_TIMEOUT = WEB_LOADER_TIMEOUT
app.state.config.WEB_SEARCH_TRUST_ENV = WEB_SEARCH_TRUST_ENV
app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
@ -1031,6 +1033,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
if app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai"
else None
),
enable_async=app.state.config.ENABLE_ASYNC_EMBEDDING,
)
app.state.RERANKING_FUNCTION = get_reranking_function(

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

@ -0,0 +1,49 @@
"""Update channel file and knowledge table
Revision ID: 81cc2ce44d79
Revises: 6283dc0e4d8d
Create Date: 2025-12-10 16:07:58.001282
"""
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 = "81cc2ce44d79"
down_revision: Union[str, None] = "6283dc0e4d8d"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add message_id column to channel_file table
with op.batch_alter_table("channel_file", schema=None) as batch_op:
batch_op.add_column(
sa.Column(
"message_id",
sa.Text(),
sa.ForeignKey(
"message.id", ondelete="CASCADE", name="fk_channel_file_message_id"
),
nullable=True,
)
)
# Add data column to knowledge table
with op.batch_alter_table("knowledge", schema=None) as batch_op:
batch_op.add_column(sa.Column("data", sa.JSON(), nullable=True))
def downgrade() -> None:
# Remove message_id column from channel_file table
with op.batch_alter_table("channel_file", schema=None) as batch_op:
batch_op.drop_column("message_id")
# Remove data column from knowledge table
with op.batch_alter_table("knowledge", schema=None) as batch_op:
batch_op.drop_column("data")

View file

@ -10,7 +10,18 @@ from pydantic import BaseModel, ConfigDict
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.sql import exists
@ -137,6 +148,41 @@ class ChannelMemberModel(BaseModel):
updated_at: Optional[int] = None # timestamp in epoch (time_ns)
class ChannelFile(Base):
__tablename__ = "channel_file"
id = Column(Text, unique=True, primary_key=True)
user_id = Column(Text, nullable=False)
channel_id = Column(
Text, ForeignKey("channel.id", ondelete="CASCADE"), nullable=False
)
message_id = Column(
Text, ForeignKey("message.id", ondelete="CASCADE"), nullable=True
)
file_id = Column(Text, ForeignKey("file.id", ondelete="CASCADE"), 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):
__tablename__ = "channel_webhook"
@ -642,6 +688,135 @@ class ChannelTable:
channel = db.query(Channel).filter(Channel.id == id).first()
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(
self, id: str, form_data: ChannelForm
) -> Optional[ChannelModel]:
@ -663,6 +838,65 @@ class ChannelTable:
db.commit()
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 set_file_message_id_in_channel_by_id(
self, channel_id: str, file_id: str, message_id: str
) -> bool:
try:
with get_db() as db:
channel_file = (
db.query(ChannelFile)
.filter_by(channel_id=channel_id, file_id=file_id)
.first()
)
if not channel_file:
return False
channel_file.message_id = message_id
channel_file.updated_at = int(time.time())
db.commit()
return True
except Exception:
return False
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):
with get_db() as db:
db.query(Channel).filter(Channel.id == id).delete()

View file

@ -126,6 +126,49 @@ class ChatTitleIdResponse(BaseModel):
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:
def _clean_null_bytes(self, obj):
"""
@ -675,14 +718,31 @@ class ChatTable:
)
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:
all_chats = (
query = (
db.query(Chat)
.filter_by(user_id=user_id)
.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]:
with get_db() as db:

View file

@ -104,6 +104,11 @@ class FileUpdateForm(BaseModel):
meta: Optional[dict] = None
class FileListResponse(BaseModel):
items: list[FileModel]
total: int
class FilesTable:
def insert_new_file(self, user_id: str, form_data: FileForm) -> Optional[FileModel]:
with get_db() as db:
@ -238,6 +243,7 @@ class FilesTable:
try:
file = db.query(File).filter_by(id=id).first()
file.hash = hash
file.updated_at = int(time.time())
db.commit()
return FileModel.model_validate(file)
@ -249,6 +255,7 @@ class FilesTable:
try:
file = db.query(File).filter_by(id=id).first()
file.data = {**(file.data if file.data else {}), **data}
file.updated_at = int(time.time())
db.commit()
return FileModel.model_validate(file)
except Exception as e:
@ -260,6 +267,7 @@ class FilesTable:
try:
file = db.query(File).filter_by(id=id).first()
file.meta = {**(file.meta if file.meta else {}), **meta}
file.updated_at = int(time.time())
db.commit()
return FileModel.model_validate(file)
except Exception:

View file

@ -5,11 +5,17 @@ from typing import Optional
import uuid
from open_webui.internal.db import Base, get_db
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.users import Users, UserResponse
from open_webui.models.users import User, UserModel, Users, UserResponse
from pydantic import BaseModel, ConfigDict
@ -21,9 +27,12 @@ from sqlalchemy import (
Text,
JSON,
UniqueConstraint,
or_,
)
from open_webui.utils.access_control import has_access
from open_webui.utils.db.access_control import has_permission
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MODELS"])
@ -126,7 +135,7 @@ class KnowledgeResponse(KnowledgeModel):
class KnowledgeUserResponse(KnowledgeUserModel):
files: Optional[list[FileMetadataResponse | dict]] = None
pass
class KnowledgeForm(BaseModel):
@ -135,6 +144,20 @@ class KnowledgeForm(BaseModel):
access_control: Optional[dict] = None
class FileUserResponse(FileModelResponse):
user: Optional[UserResponse] = None
class KnowledgeListResponse(BaseModel):
items: list[KnowledgeUserModel]
total: int
class KnowledgeFileListResponse(BaseModel):
items: list[FileUserResponse]
total: int
class KnowledgeTable:
def insert_new_knowledge(
self, user_id: str, form_data: KnowledgeForm
@ -162,12 +185,13 @@ class KnowledgeTable:
except Exception:
return None
def get_knowledge_bases(self) -> list[KnowledgeUserModel]:
def get_knowledge_bases(
self, skip: int = 0, limit: int = 30
) -> list[KnowledgeUserModel]:
with get_db() as db:
all_knowledge = (
db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
)
user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
users = Users.get_users_by_user_ids(user_ids) if user_ids else []
@ -186,6 +210,126 @@ class KnowledgeTable:
)
return knowledge_bases
def search_knowledge_bases(
self, user_id: str, filter: dict, skip: int = 0, limit: int = 30
) -> KnowledgeListResponse:
try:
with get_db() as db:
query = db.query(Knowledge, User).outerjoin(
User, User.id == Knowledge.user_id
)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(
or_(
Knowledge.name.ilike(f"%{query_key}%"),
Knowledge.description.ilike(f"%{query_key}%"),
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Knowledge.user_id == user_id)
elif view_option == "shared":
query = query.filter(Knowledge.user_id != user_id)
query = has_permission(db, Knowledge, query, filter)
query = query.order_by(Knowledge.updated_at.desc())
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
knowledge_bases = []
for knowledge_base, user in items:
knowledge_bases.append(
KnowledgeUserModel.model_validate(
{
**KnowledgeModel.model_validate(
knowledge_base
).model_dump(),
"user": (
UserModel.model_validate(user).model_dump()
if user
else None
),
}
)
)
return KnowledgeListResponse(items=knowledge_bases, total=total)
except Exception as e:
print(e)
return KnowledgeListResponse(items=[], total=0)
def search_knowledge_files(
self, filter: dict, skip: int = 0, limit: int = 30
) -> KnowledgeFileListResponse:
"""
Scalable version: search files across all knowledge bases the user has
READ access to, without loading all KBs or using large IN() lists.
"""
try:
with get_db() as db:
# Base query: join Knowledge → KnowledgeFile → File
query = (
db.query(File, User)
.join(KnowledgeFile, File.id == KnowledgeFile.file_id)
.join(Knowledge, KnowledgeFile.knowledge_id == Knowledge.id)
.outerjoin(User, User.id == KnowledgeFile.user_id)
)
# Apply access-control directly to the joined query
# This makes the database handle filtering, even with 10k+ KBs
query = has_permission(db, Knowledge, query, filter)
# Apply filename search
if filter:
q = filter.get("query")
if q:
query = query.filter(File.filename.ilike(f"%{q}%"))
# Order by file changes
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)
rows = query.all()
items = []
for file, user in rows:
items.append(
FileUserResponse(
**FileModel.model_validate(file).model_dump(),
user=(
UserResponse(
**UserModel.model_validate(user).model_dump()
)
if user
else None
),
)
)
return KnowledgeFileListResponse(items=items, total=total)
except Exception as e:
print("search_knowledge_files error:", e)
return KnowledgeFileListResponse(items=[], total=0)
def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
knowledge = self.get_knowledge_by_id(id)
if not knowledge:
@ -217,6 +361,21 @@ class KnowledgeTable:
except Exception:
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]:
try:
with get_db() as db:
@ -232,6 +391,88 @@ class KnowledgeTable:
except Exception:
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]:
try:
with get_db() as db:

View file

@ -9,7 +9,7 @@ from open_webui.models.users import Users, User, UserNameResponse
from open_webui.models.channels import Channels, ChannelMember
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, field_validator
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy import or_, func, select, and_, text
from sqlalchemy.sql import exists
@ -108,11 +108,24 @@ class MessageUserResponse(MessageModel):
user: Optional[UserNameResponse] = None
class MessageUserSlimResponse(MessageUserResponse):
data: bool | None = None
@field_validator("data", mode="before")
def convert_data_to_bool(cls, v):
# No data or not a dict → False
if not isinstance(v, dict):
return False
# True if ANY value in the dict is non-empty
return any(bool(val) for val in v.values())
class MessageReplyToResponse(MessageUserResponse):
reply_to_message: Optional[MessageUserResponse] = None
reply_to_message: Optional[MessageUserSlimResponse] = None
class MessageWithReactionsResponse(MessageUserResponse):
class MessageWithReactionsResponse(MessageUserSlimResponse):
reactions: list[Reactions]

View file

@ -7,12 +7,15 @@ from functools import lru_cache
from open_webui.internal.db import Base, get_db
from open_webui.models.groups import Groups
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 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
####################
@ -75,7 +78,138 @@ class NoteUserResponse(NoteModel):
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:
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(
self,
form_data: NoteForm,
@ -110,15 +244,107 @@ class NoteTable:
notes = query.all()
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}%"),
cast(Note.data["content"]["md"], Text).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(
self,
user_id: str,
permission: str = "read",
skip: Optional[int] = None,
limit: Optional[int] = None,
) -> list[NoteModel]:
with get_db() as db:
query = db.query(Note).filter(Note.user_id == user_id)
query = query.order_by(Note.updated_at.desc())
user_group_ids = [
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:
query = query.offset(skip)
@ -128,56 +354,6 @@ class NoteTable:
notes = query.all()
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]:
with get_db() as db:
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.models.chats import Chats
from open_webui.models.groups import Groups, GroupMember
from open_webui.models.channels import ChannelMember
from open_webui.utils.misc import throttle

View file

@ -144,19 +144,17 @@ class DoclingLoader:
with open(self.file_path, "rb") as f:
headers = {}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
files = {
"files": (
self.file_path,
f,
self.mime_type or "application/octet-stream",
)
}
headers["X-Api-Key"] = f"Bearer {self.api_key}"
r = requests.post(
f"{self.url}/v1/convert/file",
files=files,
files={
"files": (
self.file_path,
f,
self.mime_type or "application/octet-stream",
)
},
data={
"image_export_mode": "placeholder",
**self.params,

View file

@ -33,6 +33,7 @@ from open_webui.config import (
PLAYWRIGHT_WS_URL,
PLAYWRIGHT_TIMEOUT,
WEB_LOADER_ENGINE,
WEB_LOADER_TIMEOUT,
FIRECRAWL_API_BASE_URL,
FIRECRAWL_API_KEY,
TAVILY_API_KEY,
@ -674,6 +675,20 @@ def get_web_loader(
if WEB_LOADER_ENGINE.value == "" or WEB_LOADER_ENGINE.value == "safe_web":
WebLoaderClass = SafeWebBaseLoader
request_kwargs = {}
if WEB_LOADER_TIMEOUT.value:
try:
timeout_value = float(WEB_LOADER_TIMEOUT.value)
except ValueError:
timeout_value = None
if timeout_value:
request_kwargs["timeout"] = timeout_value
if request_kwargs:
web_loader_args["requests_kwargs"] = request_kwargs
if WEB_LOADER_ENGINE.value == "playwright":
WebLoaderClass = SafePlaywrightURLLoader
web_loader_args["playwright_timeout"] = PLAYWRIGHT_TIMEOUT.value

View file

@ -33,6 +33,7 @@ from fastapi.responses import FileResponse
from pydantic import BaseModel
from open_webui.utils.misc import strict_match_mime_type
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.headers import include_user_info_headers
from open_webui.config import (
@ -1155,17 +1156,9 @@ def transcription(
stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
)
) or ["audio/*", "video/webm"]
if not any(
fnmatch(file.content_type, content_type)
for content_type in (
stt_supported_content_types
if stt_supported_content_types
and any(t.strip() for t in stt_supported_content_types)
else ["audio/*", "video/webm"]
)
):
if not strict_match_mime_type(stt_supported_content_types, file.content_type):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.FILE_NOT_SUPPORTED,

View file

@ -5,7 +5,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks
from pydantic import BaseModel
from pydantic import field_validator
from open_webui.socket.main import (
emit_to_users,
@ -39,6 +39,8 @@ from open_webui.models.messages import (
)
from open_webui.utils.files import get_image_base64_from_file_id
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
@ -666,7 +668,16 @@ async def delete_channel_by_id(
class MessageUserResponse(MessageResponse):
pass
data: bool | None = None
@field_validator("data", mode="before")
def convert_data_to_bool(cls, v):
# No data or not a dict → False
if not isinstance(v, dict):
return False
# True if ANY value in the dict is non-empty
return any(bool(val) for val in v.values())
@router.get("/{id}/messages", response_model=list[MessageUserResponse])
@ -906,6 +917,10 @@ async def model_response_handler(request, channel, message, user):
for file in thread_message_files:
if file.get("type", "") == "image":
images.append(file.get("url", ""))
elif file.get("content_type", "").startswith("image/"):
image = get_image_base64_from_file_id(file.get("id", ""))
if image:
images.append(image)
thread_history_string = "\n\n".join(thread_history)
system_message = {
@ -1078,6 +1093,15 @@ async def post_new_message(
try:
message, channel = await new_message_handler(request, id, form_data, user)
try:
if files := message.data.get("files", []):
for file in files:
Channels.set_file_message_id_in_channel_by_id(
channel.id, file.get("id", ""), message.id
)
except Exception as e:
log.debug(e)
active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
async def background_handler():
@ -1108,7 +1132,7 @@ async def post_new_message(
############################
@router.get("/{id}/messages/{message_id}", response_model=Optional[MessageUserResponse])
@router.get("/{id}/messages/{message_id}", response_model=Optional[MessageResponse])
async def get_channel_message(
id: str, message_id: str, user=Depends(get_verified_user)
):
@ -1142,7 +1166,7 @@ async def get_channel_message(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
return MessageUserResponse(
return MessageResponse(
**{
**message.model_dump(),
"user": UserNameResponse(
@ -1152,6 +1176,48 @@ async def get_channel_message(
)
############################
# GetChannelMessageData
############################
@router.get("/{id}/messages/{message_id}/data", response_model=Optional[dict])
async def get_channel_message_data(
id: str, message_id: str, user=Depends(get_verified_user)
):
channel = Channels.get_channel_by_id(id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if channel.type in ["group", "dm"]:
if not Channels.is_user_channel_member(channel.id, user.id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
else:
if user.role != "admin" and not has_access(
user.id, type="read", access_control=channel.access_control
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
)
message = Messages.get_message_by_id(message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
)
if message.channel_id != id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
)
return message.data
############################
# PinChannelMessage
############################

View file

@ -3,10 +3,12 @@ import logging
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.models.chats import (
ChatForm,
ChatImportForm,
ChatUsageStatsListResponse,
ChatsImportForm,
ChatResponse,
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
############################

View file

@ -27,6 +27,7 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS
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.files import (
FileForm,
@ -38,7 +39,6 @@ from open_webui.models.knowledge import Knowledges
from open_webui.models.groups import Groups
from open_webui.routers.knowledge import get_knowledge, get_knowledge_list
from open_webui.routers.retrieval import ProcessFileForm, process_file
from open_webui.routers.audio import transcribe
@ -47,7 +47,7 @@ from open_webui.storage.provider import Storage
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.access_control import has_access
from open_webui.utils.misc import strict_match_mime_type
from pydantic import BaseModel
log = logging.getLogger(__name__)
@ -91,6 +91,10 @@ def has_access_to_file(
if knowledge_base.id == knowledge_base_id:
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
@ -104,17 +108,9 @@ def process_uploaded_file(request, file, file_path, file_item, file_metadata, us
if file.content_type:
stt_supported_content_types = getattr(
request.app.state.config, "STT_SUPPORTED_CONTENT_TYPES", []
)
) or ["audio/*", "video/webm"]
if any(
fnmatch(file.content_type, content_type)
for content_type in (
stt_supported_content_types
if stt_supported_content_types
and any(t.strip() for t in stt_supported_content_types)
else ["audio/*", "video/webm"]
)
):
if strict_match_mime_type(stt_supported_content_types, file.content_type):
file_path = Storage.get_file(file_path)
result = transcribe(request, file_path, file_metadata, user)
@ -138,6 +134,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"
)
process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
except Exception as e:
log.error(f"Error processing file: {file_item.id}")
Files.update_file_data_by_id(
@ -179,7 +176,7 @@ def upload_file_handler(
user=Depends(get_verified_user),
background_tasks: Optional[BackgroundTasks] = None,
):
log.info(f"file.content_type: {file.content_type}")
log.info(f"file.content_type: {file.content_type} {process}")
if isinstance(metadata, str):
try:
@ -247,6 +244,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 background_tasks and process_in_background:
background_tasks.add_task(

View file

@ -4,7 +4,9 @@ from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
from fastapi.concurrency import run_in_threadpool
import logging
from open_webui.models.groups import Groups
from open_webui.models.knowledge import (
KnowledgeFileListResponse,
Knowledges,
KnowledgeForm,
KnowledgeResponse,
@ -39,41 +41,115 @@ router = APIRouter()
# getKnowledgeBases
############################
@router.get("/", response_model=list[KnowledgeUserResponse])
async def get_knowledge(user=Depends(get_verified_user)):
# Return knowledge bases with read access
knowledge_bases = []
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
knowledge_bases = Knowledges.get_knowledge_bases()
else:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "read")
return [
KnowledgeUserResponse(
**knowledge_base.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge_base.id),
)
for knowledge_base in knowledge_bases
]
PAGE_ITEM_COUNT = 30
@router.get("/list", response_model=list[KnowledgeUserResponse])
async def get_knowledge_list(user=Depends(get_verified_user)):
# Return knowledge bases with write access
knowledge_bases = []
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
knowledge_bases = Knowledges.get_knowledge_bases()
else:
knowledge_bases = Knowledges.get_knowledge_bases_by_user_id(user.id, "write")
class KnowledgeAccessResponse(KnowledgeUserResponse):
write_access: Optional[bool] = False
return [
KnowledgeUserResponse(
**knowledge_base.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge_base.id),
)
for knowledge_base in knowledge_bases
]
class KnowledgeAccessListResponse(BaseModel):
items: list[KnowledgeAccessResponse]
total: int
@router.get("/", response_model=KnowledgeAccessListResponse)
async def get_knowledge_bases(page: Optional[int] = 1, user=Depends(get_verified_user)):
page = max(page, 1)
limit = PAGE_ITEM_COUNT
skip = (page - 1) * limit
filter = {}
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
result = Knowledges.search_knowledge_bases(
user.id, filter=filter, skip=skip, limit=limit
)
return KnowledgeAccessListResponse(
items=[
KnowledgeAccessResponse(
**knowledge_base.model_dump(),
write_access=(
user.id == knowledge_base.user_id
or has_access(user.id, "write", knowledge_base.access_control)
),
)
for knowledge_base in result.items
],
total=result.total,
)
@router.get("/search", response_model=KnowledgeAccessListResponse)
async def search_knowledge_bases(
query: Optional[str] = None,
view_option: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
):
page = max(page, 1)
limit = PAGE_ITEM_COUNT
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if view_option:
filter["view_option"] = view_option
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
result = Knowledges.search_knowledge_bases(
user.id, filter=filter, skip=skip, limit=limit
)
return KnowledgeAccessListResponse(
items=[
KnowledgeAccessResponse(
**knowledge_base.model_dump(),
write_access=(
user.id == knowledge_base.user_id
or has_access(user.id, "write", knowledge_base.access_control)
),
)
for knowledge_base in result.items
],
total=result.total,
)
@router.get("/search/files", response_model=KnowledgeFileListResponse)
async def search_knowledge_files(
query: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
):
page = max(page, 1)
limit = PAGE_ITEM_COUNT
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
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 Knowledges.search_knowledge_files(filter=filter, skip=skip, limit=limit)
############################
@ -185,7 +261,8 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
class KnowledgeFilesResponse(KnowledgeResponse):
files: list[FileMetadataResponse]
files: Optional[list[FileMetadataResponse]] = None
write_access: Optional[bool] = False
@router.get("/{id}", response_model=Optional[KnowledgeFilesResponse])
@ -201,7 +278,10 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)):
return KnowledgeFilesResponse(
**knowledge.model_dump(),
files=Knowledges.get_file_metadatas_by_id(knowledge.id),
write_access=(
user.id == knowledge.user_id
or has_access(user.id, "write", knowledge.access_control)
),
)
else:
raise HTTPException(
@ -264,6 +344,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
############################
@ -309,11 +442,6 @@ def add_file_to_knowledge_by_id(
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
try:
process_file(
@ -321,6 +449,11 @@ def add_file_to_knowledge_by_id(
ProcessFileForm(file_id=form_data.file_id, collection_name=id),
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:
log.debug(e)
raise HTTPException(

View file

@ -8,11 +8,21 @@ from pydantic import BaseModel
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.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.env import SRC_LOG_LEVELS
@ -30,39 +40,17 @@ router = APIRouter()
############################
@router.get("/", response_model=list[NoteUserResponse])
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):
class NoteItemResponse(BaseModel):
id: str
title: str
data: Optional[dict]
updated_at: int
created_at: int
user: Optional[UserResponse] = None
@router.get("/list", response_model=list[NoteTitleIdResponse])
async def get_note_list(
@router.get("/", response_model=list[NoteItemResponse])
async def get_notes(
request: Request, page: Optional[int] = None, user=Depends(get_verified_user)
):
if user.role != "admin" and not has_permission(
@ -80,15 +68,64 @@ async def get_note_list(
skip = (page - 1) * limit
notes = [
NoteTitleIdResponse(**note.model_dump())
for note in Notes.get_notes_by_permission(
user.id, "write", skip=skip, limit=limit
NoteUserResponse(
**{
**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
@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
############################
@ -98,7 +135,6 @@ async def get_note_list(
async def create_new_note(
request: Request, form_data: NoteForm, user=Depends(get_verified_user)
):
if user.role != "admin" and not has_permission(
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)):
if user.role != "admin" and not has_permission(
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()
)
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

@ -536,6 +536,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
"SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID,
"SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK,
"WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE,
"WEB_LOADER_TIMEOUT": request.app.state.config.WEB_LOADER_TIMEOUT,
"ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
"PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL,
"PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT,
@ -594,6 +595,7 @@ class WebConfig(BaseModel):
SOUGOU_API_SID: Optional[str] = None
SOUGOU_API_SK: Optional[str] = None
WEB_LOADER_ENGINE: Optional[str] = None
WEB_LOADER_TIMEOUT: Optional[str] = None
ENABLE_WEB_LOADER_SSL_VERIFICATION: Optional[bool] = None
PLAYWRIGHT_WS_URL: Optional[str] = None
PLAYWRIGHT_TIMEOUT: Optional[int] = None
@ -1071,6 +1073,8 @@ async def update_rag_config(
# Web loader settings
request.app.state.config.WEB_LOADER_ENGINE = form_data.web.WEB_LOADER_ENGINE
request.app.state.config.WEB_LOADER_TIMEOUT = form_data.web.WEB_LOADER_TIMEOUT
request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = (
form_data.web.ENABLE_WEB_LOADER_SSL_VERIFICATION
)
@ -1206,6 +1210,7 @@ async def update_rag_config(
"SOUGOU_API_SID": request.app.state.config.SOUGOU_API_SID,
"SOUGOU_API_SK": request.app.state.config.SOUGOU_API_SK,
"WEB_LOADER_ENGINE": request.app.state.config.WEB_LOADER_ENGINE,
"WEB_LOADER_TIMEOUT": request.app.state.config.WEB_LOADER_TIMEOUT,
"ENABLE_WEB_LOADER_SSL_VERIFICATION": request.app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION,
"PLAYWRIGHT_WS_URL": request.app.state.config.PLAYWRIGHT_WS_URL,
"PLAYWRIGHT_TIMEOUT": request.app.state.config.PLAYWRIGHT_TIMEOUT,
@ -1401,6 +1406,7 @@ def save_docs_to_vector_db(
if request.app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai"
else None
),
enable_async=request.app.state.config.ENABLE_ASYNC_EMBEDDING,
)
# Run async embedding in sync context

View file

@ -391,6 +391,7 @@ async def update_user_info_by_session_user(
class UserActiveResponse(UserStatus):
name: str
profile_image_url: Optional[str] = None
groups: Optional[list] = []
is_active: bool
model_config = ConfigDict(extra="allow")
@ -412,11 +413,12 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
)
user = Users.get_user_by_id(user_id)
if user:
groups = Groups.get_groups_by_member_id(user_id)
return UserActiveResponse(
**{
**user.model_dump(),
"groups": [{"id": group.id, "name": group.name} for group in groups],
"is_active": Users.is_user_active(user_id),
}
)

View file

@ -0,0 +1,130 @@
from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func
def has_permission(db, DocumentModel, 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(
DocumentModel.access_control["read"]["group_ids"].contains(
[gid]
)
)
elif dialect_name == "postgresql":
group_read_conditions.append(
cast(
DocumentModel.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(DocumentModel.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(
DocumentModel.access_control["write"]["group_ids"].contains(
[gid]
)
)
elif dialect_name == "postgresql":
group_write_conditions.append(
cast(
DocumentModel.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(DocumentModel.access_control.isnot(None))
write_exclusions.append(cast(DocumentModel.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(
[
DocumentModel.access_control.is_(None),
cast(DocumentModel.access_control, String) == "null",
]
)
# User-level permission (owner has all permissions)
if user_id:
conditions.append(DocumentModel.user_id == user_id)
# Group-level permission
if group_ids:
group_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_conditions.append(
DocumentModel.access_control[permission]["group_ids"].contains(
[gid]
)
)
elif dialect_name == "postgresql":
group_conditions.append(
cast(
DocumentModel.access_control[permission]["group_ids"],
JSONB,
).contains([gid])
)
conditions.append(or_(*group_conditions))
if conditions:
query = query.filter(or_(*conditions))
return query

View file

@ -10,7 +10,11 @@ from fastapi import (
Request,
UploadFile,
)
from typing import Optional
from pathlib import Path
from open_webui.storage.provider import Storage
from open_webui.models.files import Files
from open_webui.routers.files import upload_file_handler
import mimetypes
@ -113,3 +117,26 @@ def get_file_url_from_base64(request, base64_file_string, metadata, user):
elif "data:audio/wav;base64" in base64_file_string:
return get_audio_url_from_base64(request, base64_file_string, metadata, user)
return None
def get_image_base64_from_file_id(id: str) -> Optional[str]:
file = Files.get_file_by_id(id)
if not file:
return None
try:
file_path = Storage.get_file(file.path)
file_path = Path(file_path)
# Check if the file already exists in the cache
if file_path.is_file():
import base64
with open(file_path, "rb") as image_file:
encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
content_type, _ = mimetypes.guess_type(file_path.name)
return f"data:{content_type};base64,{encoded_string}"
else:
return None
except Exception as e:
return None

View file

@ -716,17 +716,18 @@ async def chat_web_search_handler(
return form_data
def get_last_images(message_list):
def get_images_from_messages(message_list):
images = []
for message in reversed(message_list):
images_flag = False
message_images = []
for file in message.get("files", []):
if file.get("type") == "image":
images.append(file.get("url"))
images_flag = True
message_images.append(file.get("url"))
if images_flag:
break
if message_images:
images.append(message_images)
return images
@ -780,7 +781,16 @@ async def chat_image_generation_handler(
user_message = get_last_user_message(message_list)
prompt = user_message
input_images = get_last_images(message_list)
message_images = get_images_from_messages(message_list)
# Limit to first 2 sets of images
# We may want to change this in the future to allow more images
input_images = []
for idx, images in enumerate(message_images):
if idx >= 2:
break
for image in images:
input_images.append(image)
system_message_content = ""

View file

@ -9,6 +9,7 @@ from pathlib import Path
from typing import Callable, Optional, Sequence, Union
import json
import aiohttp
import mimeparse
import collections.abc
@ -577,6 +578,37 @@ def throttle(interval: float = 10.0):
return decorator
def strict_match_mime_type(supported: list[str] | str, header: str) -> Optional[str]:
"""
Strictly match the mime type with the supported mime types.
:param supported: The supported mime types.
:param header: The header to match.
:return: The matched mime type or None if no match is found.
"""
try:
if isinstance(supported, str):
supported = supported.split(",")
supported = [s for s in supported if s.strip() and "/" in s]
match = mimeparse.best_match(supported, header)
if not match:
return None
_, _, match_params = mimeparse.parse_mime_type(match)
_, _, header_params = mimeparse.parse_mime_type(header)
for k, v in match_params.items():
if header_params.get(k) != v:
return None
return match
except Exception as e:
log.exception(f"Failed to match mime type {header}: {e}")
return None
def extract_urls(text: str) -> list[str]:
# Regex pattern to match URLs
url_pattern = re.compile(
@ -624,14 +656,17 @@ def stream_chunks_handler(stream: aiohttp.StreamReader):
yield line
else:
yield b"data: {}"
yield b"\n"
else:
# Normal mode: check if line exceeds limit
if len(line) > max_buffer_size:
skip_mode = True
yield b"data: {}"
yield b"\n"
log.info(f"Skip mode triggered, line size: {len(line)}")
else:
yield line
yield b"\n"
# Save the last incomplete fragment
buffer = lines[-1]
@ -646,5 +681,6 @@ def stream_chunks_handler(stream: aiohttp.StreamReader):
# Process remaining buffer data
if buffer and not skip_mode:
yield buffer
yield b"\n"
return yield_safe_stream_chunks()

View file

@ -55,6 +55,7 @@ from open_webui.config import (
OAUTH_ALLOWED_DOMAINS,
OAUTH_UPDATE_PICTURE_ON_LOGIN,
OAUTH_ACCESS_TOKEN_REQUEST_INCLUDE_CLIENT_ID,
OAUTH_AUDIENCE,
WEBHOOK_URL,
JWT_EXPIRES_IN,
AppConfig,
@ -126,6 +127,7 @@ auth_manager_config.OAUTH_ALLOWED_DOMAINS = OAUTH_ALLOWED_DOMAINS
auth_manager_config.WEBHOOK_URL = WEBHOOK_URL
auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN = OAUTH_UPDATE_PICTURE_ON_LOGIN
auth_manager_config.OAUTH_AUDIENCE = OAUTH_AUDIENCE
FERNET = None
@ -1270,7 +1272,12 @@ class OAuthManager:
client = self.get_client(provider)
if client is None:
raise HTTPException(404)
return await client.authorize_redirect(request, redirect_uri)
kwargs = {}
if (auth_manager_config.OAUTH_AUDIENCE):
kwargs["audience"] = auth_manager_config.OAUTH_AUDIENCE
return await client.authorize_redirect(request, redirect_uri, **kwargs)
async def handle_callback(self, request, provider, response):
if provider not in OAUTH_PROVIDERS:

View file

@ -7,6 +7,7 @@ import redis
from open_webui.env import (
REDIS_CLUSTER,
REDIS_SOCKET_CONNECT_TIMEOUT,
REDIS_SENTINEL_HOSTS,
REDIS_SENTINEL_MAX_RETRY_COUNT,
REDIS_SENTINEL_PORT,
@ -162,6 +163,7 @@ def get_redis_connection(
username=redis_config["username"],
password=redis_config["password"],
decode_responses=decode_responses,
socket_connect_timeout=REDIS_SOCKET_CONNECT_TIMEOUT,
)
connection = SentinelRedisProxy(
sentinel,
@ -188,6 +190,7 @@ def get_redis_connection(
username=redis_config["username"],
password=redis_config["password"],
decode_responses=decode_responses,
socket_connect_timeout=REDIS_SOCKET_CONNECT_TIMEOUT,
)
connection = SentinelRedisProxy(
sentinel,

View file

@ -1,7 +1,7 @@
# Minimal requirements for backend to run
# 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
pydantic==2.12.5
python-multipart==0.0.20
@ -16,7 +16,7 @@ PyJWT[crypto]==2.10.1
authlib==1.6.5
requests==2.32.5
aiohttp==3.12.15
aiohttp==3.13.2
async-timeout
aiocache
aiofiles
@ -24,28 +24,28 @@ starlette-compress==1.6.1
httpx[socks,http2,zstd,cli,brotli]==0.28.1
starsessions[redis]==2.2.1
sqlalchemy==2.0.38
sqlalchemy==2.0.44
alembic==1.17.2
peewee==3.18.3
peewee-migrate==1.14.3
pycrdt==0.12.25
pycrdt==0.12.44
redis
APScheduler==3.10.4
RestrictedPython==8.0
APScheduler==3.11.1
RestrictedPython==8.1
loguru==0.7.3
asgiref==3.11.0
mcp==1.22.0
mcp==1.23.1
openai
langchain==0.3.27
langchain-community==0.3.29
fake-useragent==2.2.0
chromadb==1.1.0
black==25.11.0
chromadb==1.3.5
black==25.12.0
pydub
chardet==5.2.0

View file

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

View file

@ -1,8 +1,8 @@
services:
playwright:
image: mcr.microsoft.com/playwright:v1.49.1-noble # Version must match requirements.txt
image: mcr.microsoft.com/playwright:v1.57.0-noble # Version must match requirements.txt
container_name: playwright
command: npx -y playwright@1.49.1 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:
environment:

View file

@ -1,4 +0,0 @@
# Helm Charts
Open WebUI Helm Charts are now hosted in a separate repo, which can be found here: https://github.com/open-webui/helm-charts
The charts are released at https://helm.openwebui.com.

View file

@ -1,8 +0,0 @@
resources:
- open-webui.yaml
- ollama-service.yaml
- ollama-statefulset.yaml
- webui-deployment.yaml
- webui-service.yaml
- webui-ingress.yaml
- webui-pvc.yaml

View file

@ -1,12 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: ollama-service
namespace: open-webui
spec:
selector:
app: ollama
ports:
- protocol: TCP
port: 11434
targetPort: 11434

View file

@ -1,41 +0,0 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: ollama
namespace: open-webui
spec:
serviceName: "ollama"
replicas: 1
selector:
matchLabels:
app: ollama
template:
metadata:
labels:
app: ollama
spec:
containers:
- name: ollama
image: ollama/ollama:latest
ports:
- containerPort: 11434
resources:
requests:
cpu: "2000m"
memory: "2Gi"
limits:
cpu: "4000m"
memory: "4Gi"
nvidia.com/gpu: "0"
volumeMounts:
- name: ollama-volume
mountPath: /root/.ollama
tty: true
volumeClaimTemplates:
- metadata:
name: ollama-volume
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 30Gi

View file

@ -1,4 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: open-webui

View file

@ -1,38 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: open-webui-deployment
namespace: open-webui
spec:
replicas: 1
selector:
matchLabels:
app: open-webui
template:
metadata:
labels:
app: open-webui
spec:
containers:
- name: open-webui
image: ghcr.io/open-webui/open-webui:main
ports:
- containerPort: 8080
resources:
requests:
cpu: "500m"
memory: "500Mi"
limits:
cpu: "1000m"
memory: "1Gi"
env:
- name: OLLAMA_BASE_URL
value: "http://ollama-service.open-webui.svc.cluster.local:11434"
tty: true
volumeMounts:
- name: webui-volume
mountPath: /app/backend/data
volumes:
- name: webui-volume
persistentVolumeClaim:
claimName: open-webui-pvc

View file

@ -1,20 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: open-webui-ingress
namespace: open-webui
#annotations:
# Use appropriate annotations for your Ingress controller, e.g., for NGINX:
# nginx.ingress.kubernetes.io/rewrite-target: /
spec:
rules:
- host: open-webui.minikube.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: open-webui-service
port:
number: 8080

View file

@ -1,12 +0,0 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
labels:
app: open-webui
name: open-webui-pvc
namespace: open-webui
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 2Gi

View file

@ -1,15 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: open-webui-service
namespace: open-webui
spec:
type: NodePort # Use LoadBalancer if you're on a cloud that supports it
selector:
app: open-webui
ports:
- protocol: TCP
port: 8080
targetPort: 8080
# If using NodePort, you can optionally specify the nodePort:
# nodePort: 30000

View file

@ -1,8 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../base
patches:
- path: ollama-statefulset-gpu.yaml

View file

@ -1,17 +0,0 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: ollama
namespace: open-webui
spec:
selector:
matchLabels:
app: ollama
serviceName: "ollama"
template:
spec:
containers:
- name: ollama
resources:
limits:
nvidia.com/gpu: "1"

View file

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

View file

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

View file

@ -491,6 +491,44 @@ export const getChannelThreadMessages = async (
return res;
};
export const getMessageData = async (
token: string = '',
channel_id: string,
message_id: string
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/data`,
{
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 MessageForm = {
temp_id?: string;
reply_to_id?: string;

View file

@ -1,16 +1,26 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { splitStream } from '$lib/utils';
export const uploadFile = async (token: string, file: File, metadata?: object | null) => {
export const uploadFile = async (
token: string,
file: File,
metadata?: object | null,
process?: boolean | null
) => {
const data = new FormData();
data.append('file', file);
if (metadata) {
data.append('metadata', JSON.stringify(metadata));
}
const searchParams = new URLSearchParams();
if (process !== undefined && process !== null) {
searchParams.append('process', String(process));
}
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/files/`, {
const res = await fetch(`${WEBUI_API_BASE_URL}/files/?${searchParams.toString()}`, {
method: 'POST',
headers: {
Accept: 'application/json',

View file

@ -38,10 +38,13 @@ export const createNewKnowledge = async (
return res;
};
export const getKnowledgeBases = async (token: string = '') => {
export const getKnowledgeBases = async (token: string = '', page: number | null = null) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/`, {
const searchParams = new URLSearchParams();
if (page) searchParams.append('page', page.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
@ -69,10 +72,20 @@ export const getKnowledgeBases = async (token: string = '') => {
return res;
};
export const getKnowledgeBaseList = async (token: string = '') => {
export const searchKnowledgeBases = async (
token: string = '',
query: string | null = null,
viewOption: string | null = null,
page: number | null = null
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/list`, {
const searchParams = new URLSearchParams();
if (query) searchParams.append('query', query);
if (viewOption) searchParams.append('view_option', viewOption);
if (page) searchParams.append('page', page.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/search?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
@ -100,6 +113,55 @@ export const getKnowledgeBaseList = async (token: string = '') => {
return res;
};
export const searchKnowledgeFiles = async (
token: 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/search/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;
};
export const getKnowledgeById = async (token: string, id: string) => {
let error = null;
@ -132,6 +194,56 @@ export const getKnowledgeById = async (token: string, id: string) => {
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 = {
name?: string;
description?: string;

View file

@ -91,6 +91,65 @@ export const getNotes = async (token: string = '', raw: boolean = false) => {
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) => {
let error = null;
const searchParams = new URLSearchParams();
@ -99,7 +158,7 @@ export const getNoteList = async (token: string = '', page: number | null = null
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',
headers: {
Accept: 'application/json',

View file

@ -47,7 +47,7 @@
if (pipeline && (pipeline?.valves ?? false)) {
for (const property in valves_spec.properties) {
if (valves_spec.properties[property]?.type === 'array') {
valves[property] = valves[property].split(',').map((v) => v.trim());
valves[property] = (valves[property] ?? '').split(',').map((v) => v.trim());
}
}

View file

@ -767,6 +767,19 @@
</div>
{#if webConfig.WEB_LOADER_ENGINE === '' || webConfig.WEB_LOADER_ENGINE === 'safe_web'}
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Timeout')}
</div>
<div class="flex items-center relative">
<input
class="flex-1 w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Timeout')}
bind:value={webConfig.WEB_LOADER_TIMEOUT}
/>
</div>
</div>
<div class=" mb-2.5 flex w-full justify-between">
<div class=" self-center text-xs font-medium">
{$i18n.t('Verify SSL Certificate')}

View file

@ -339,7 +339,7 @@
</tr>
</thead>
<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">
<td class="px-3 py-1 min-w-[7rem] w-28">
<button

View file

@ -290,7 +290,7 @@
<div
class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-260px)]'
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
: ''} w-full max-w-full flex flex-col"
id="channel-container"
>
@ -365,6 +365,7 @@
bind:chatInputElement
bind:replyToMessage
{typingUsers}
{channel}
userSuggestions={true}
channelSuggestions={true}
disabled={!channel?.write_access}

View file

@ -106,7 +106,7 @@
<div class="">
<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}
>
<Plus className="size-3.5 " />

View file

@ -42,9 +42,10 @@
import XMark from '../icons/XMark.svelte';
export let placeholder = $i18n.t('Type here...');
export let chatInputElement;
export let id = null;
export let chatInputElement;
export let channel = null;
export let typingUsers = [];
export let inputLoading = false;
@ -421,13 +422,10 @@
imageUrl = await compressImageHandler(imageUrl, $settings, $config);
}
files = [
...files,
{
type: 'image',
url: `${imageUrl}`
}
];
const blob = await (await fetch(imageUrl)).blob();
const compressedFile = new File([blob], file.name, { type: file.type });
uploadFileHandler(compressedFile, false);
};
reader.readAsDataURL(file['type'] === 'image/heic' ? await convertHeicToJpeg(file) : file);
@ -437,7 +435,7 @@
});
};
const uploadFileHandler = async (file) => {
const uploadFileHandler = async (file, process = true) => {
const tempItemId = uuidv4();
const fileItem = {
type: 'file',
@ -461,19 +459,19 @@
try {
// During the file upload, file content is automatically extracted.
// If the file is an audio file, provide the language for STT.
let metadata = null;
if (
(file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
let metadata = {
channel_id: channel.id,
// If the file is an audio file, provide the language for STT.
...((file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
$settings?.audio?.stt?.language
) {
metadata = {
language: $settings?.audio?.stt?.language
};
}
? {
language: $settings?.audio?.stt?.language
}
: {})
};
const uploadedFile = await uploadFile(localStorage.token, file, metadata);
const uploadedFile = await uploadFile(localStorage.token, file, metadata, process);
if (uploadedFile) {
console.info('File upload completed:', {
@ -492,6 +490,7 @@
fileItem.id = uploadedFile.id;
fileItem.collection_name =
uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
fileItem.content_type = uploadedFile.meta?.content_type || uploadedFile.content_type;
fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
files = files;
@ -807,11 +806,11 @@
{#if files.length > 0}
<div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2">
{#each files as file, fileIdx}
{#if file.type === 'image'}
{#if file.type === 'image' || (file?.content_type ?? '').startsWith('image/')}
<div class=" relative group">
<div class="relative">
<Image
src={file.url}
src={`${file.url}${file?.content_type ? '/content' : ''}`}
alt=""
imageClassName=" size-10 rounded-xl object-cover"
/>

View file

@ -126,6 +126,7 @@
{#each messageList as message, messageIdx (id ? `${id}-${message.id}` : message.id)}
<Message
{message}
{channel}
{thread}
replyToMessage={replyToMessage?.id === message.id}
disabled={!channel?.write_access || message?.temp_id}

View file

@ -17,6 +17,7 @@
import { settings, user, shortCodesToEmojis } from '$lib/stores';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { getMessageData } from '$lib/apis/channels';
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
@ -42,6 +43,8 @@
export let className = '';
export let message;
export let channel;
export let showUserProfile = true;
export let thread = false;
@ -61,6 +64,21 @@
let edit = false;
let editedContent = null;
let showDeleteConfirmDialog = false;
const loadMessageData = async () => {
if (message && message?.data) {
const res = await getMessageData(localStorage.token, channel?.id, message.id);
if (res) {
message.data = res;
}
}
};
onMount(async () => {
if (message && message?.data) {
await loadMessageData();
}
});
</script>
<ConfirmDialog
@ -314,12 +332,27 @@
</Name>
{/if}
{#if (message?.data?.files ?? []).length > 0}
{#if message?.data === true}
<!-- loading indicator -->
<div class=" my-2">
<Skeleton />
</div>
{:else if (message?.data?.files ?? []).length > 0}
<div class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap">
{#each message?.data?.files as file}
<div>
{#if file.type === 'image'}
<Image src={file.url} alt={file.name} imageClassName=" max-h-96 rounded-lg" />
{#if file.type === 'image' || (file?.content_type ?? '').startsWith('image/')}
<Image
src={`${file.url}${file?.content_type ? '/content' : ''}`}
alt={file.name}
imageClassName=" max-h-96 rounded-lg"
/>
{:else if file.type === 'video' || (file?.content_type ?? '').startsWith('video/')}
<video
src={`${file.url}${file?.content_type ? '/content' : ''}`}
controls
class=" max-h-96 rounded-lg"
></video>
{:else}
<FileItem
item={file}

View file

@ -102,6 +102,18 @@
</div>
{/if}
{#if (user?.groups ?? []).length > 0}
<div class="mx-3.5 mt-2 flex gap-0.5">
{#each user.groups as group}
<div
class="px-1.5 py-0.5 rounded-lg bg-gray-50 dark:text-white dark:bg-gray-900/50 text-black transition text-xs"
>
{group.name}
</div>
{/each}
</div>
{/if}
{#if $_user?.id !== user.id}
<hr class="border-gray-100/50 dark:border-gray-800/50 my-2.5" />

View file

@ -902,8 +902,15 @@
const initNewChat = async () => {
console.log('initNewChat');
if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) {
await temporaryChatEnabled.set(true);
if ($user?.role !== 'admin') {
if ($user?.permissions?.chat?.temporary_enforced) {
await temporaryChatEnabled.set(true);
}
if (!$user?.permissions?.chat?.temporary) {
await temporaryChatEnabled.set(false);
return;
}
}
if ($settings?.temporaryChatByDefault ?? false) {
@ -2377,7 +2384,7 @@
<div
class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? ' md:max-w-[calc(100%-260px)]'
? ' md:max-w-[calc(100%-var(--sidebar-width))]'
: ' '} w-full max-w-full flex flex-col"
id="chat-container"
>

View file

@ -1,10 +1,37 @@
<script>
<script lang="ts">
import { embed, showControls, showEmbeds } from '$lib/stores';
import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let overlay = false;
const getSrcUrl = (url: string, chatId?: string, messageId?: string) => {
try {
const parsed = new URL(url);
if (chatId) {
parsed.searchParams.set('chat_id', chatId);
}
if (messageId) {
parsed.searchParams.set('message_id', messageId);
}
return parsed.toString();
} catch {
// Fallback for relative URLs or invalid input
const hasQuery = url.includes('?');
const parts = [];
if (chatId) parts.push(`chat_id=${encodeURIComponent(chatId)}`);
if (messageId) parts.push(`message_id=${encodeURIComponent(messageId)}`);
if (parts.length === 0) return url;
return url + (hasQuery ? '&' : '?') + parts.join('&');
}
};
</script>
{#if $embed}
@ -40,7 +67,11 @@
<div class=" absolute top-0 left-0 right-0 bottom-0 z-10"></div>
{/if}
<FullHeightIframe src={$embed?.url} iframeClassName="w-full h-full" />
<FullHeightIframe
src={getSrcUrl($embed?.url ?? '', $embed?.chatId, $embed?.messageId)}
payload={$embed?.source ?? null}
iframeClassName="w-full h-full"
/>
</div>
</div>
{/if}

View file

@ -1,14 +1,22 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { toast } from 'svelte-sonner';
import { marked } from 'marked';
import { v4 as uuidv4 } from 'uuid';
import { createPicker, getAuthToken } from '$lib/utils/google-drive-picker';
import { pickAndDownloadFile } from '$lib/utils/onedrive-file-picker';
import dayjs from '$lib/dayjs';
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 { 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();
import {
@ -49,6 +57,9 @@
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 VoiceRecording from './MessageInput/VoiceRecording.svelte';
import FilesOverlay from './MessageInput/FilesOverlay.svelte';
@ -60,11 +71,9 @@
import Image from '../common/Image.svelte';
import XMark from '../icons/XMark.svelte';
import Headphone from '../icons/Headphone.svelte';
import GlobeAlt from '../icons/GlobeAlt.svelte';
import Photo from '../icons/Photo.svelte';
import Wrench from '../icons/Wrench.svelte';
import CommandLine from '../icons/CommandLine.svelte';
import Sparkles from '../icons/Sparkles.svelte';
import InputVariablesModal from './MessageInput/InputVariablesModal.svelte';
@ -74,12 +83,13 @@
import Component from '../icons/Component.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 Knobs from '../icons/Knobs.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');
@ -109,6 +119,8 @@
export let webSearchEnabled = false;
export let codeInterpreterEnabled = false;
let inputContent = null;
let showInputVariablesModal = false;
let inputVariablesModalCallback = (variableValues) => {};
let inputVariables = {};
@ -410,6 +422,8 @@
let inputFiles;
let showInputModal = false;
let dragged = 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) => {
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}
<div class="w-full font-primary">
<div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
@ -1189,14 +1236,33 @@
: ''}"
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}
{#key $settings?.richTextInput ?? true}
{#key $settings?.showFormattingToolbar ?? false}
<RichTextInput
bind:this={chatInputElement}
id="chat-input"
onChange={(e) => {
prompt = e.md;
editable={!showInputModal}
onChange={(content) => {
prompt = content.md;
inputContent = content;
command = getCommand();
}}
json={true}
@ -1620,57 +1686,7 @@
</div>
</div>
<div class="self-end flex space-x-1 mr-1 shrink-0">
{#if (!history?.currentId || history.messages[history.currentId]?.done == true) && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.stt ?? true))}
<!-- {$i18n.t('Record voice')} -->
<Tooltip content={$i18n.t('Dictate')}>
<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"
type="button"
on:click={async () => {
try {
let stream = await navigator.mediaDevices
.getUserMedia({ audio: true })
.catch(function (err) {
toast.error(
$i18n.t(
`Permission denied when accessing microphone: {{error}}`,
{
error: err
}
)
);
return null;
});
if (stream) {
recording = true;
const tracks = stream.getTracks();
tracks.forEach((track) => track.stop());
}
stream = null;
} catch {
toast.error($i18n.t('Permission denied when accessing microphone'));
}
}}
aria-label="Voice Input"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5 translate-y-[0.5px]"
>
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
<path
d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
/>
</svg>
</button>
</Tooltip>
{/if}
<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')}>
@ -1695,95 +1711,163 @@
</button>
</Tooltip>
</div>
{:else if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
<div class=" flex items-center">
<!-- {$i18n.t('Call')} -->
<Tooltip content={$i18n.t('Voice mode')}>
{: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
class=" bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full p-1.5 self-center"
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))}
<!-- {$i18n.t('Record voice')} -->
<Tooltip content={$i18n.t('Dictate')}>
<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 self-center mr-0.5"
type="button"
on:click={async () => {
if (selectedModels.length > 1) {
toast.error($i18n.t('Select only one model to call'));
return;
}
if ($config.audio.stt.engine === 'web') {
toast.error(
$i18n.t('Call feature is not supported when using Web STT engine')
);
return;
}
// check if user has access to getUserMedia
try {
let stream = await navigator.mediaDevices.getUserMedia({
audio: true
});
// If the user grants the permission, proceed to show the call overlay
let stream = await navigator.mediaDevices
.getUserMedia({ audio: true })
.catch(function (err) {
toast.error(
$i18n.t(
`Permission denied when accessing microphone: {{error}}`,
{
error: err
}
)
);
return null;
});
if (stream) {
recording = true;
const tracks = stream.getTracks();
tracks.forEach((track) => track.stop());
}
stream = null;
if ($settings.audio?.tts?.engine === 'browser-kokoro') {
// If the user has not initialized the TTS worker, initialize it
if (!$TTSWorker) {
await TTSWorker.set(
new KokoroWorker({
dtype: $settings.audio?.tts?.engineConfig?.dtype ?? 'fp32'
})
);
await $TTSWorker.init();
}
}
showCallOverlay.set(true);
showControls.set(true);
} catch (err) {
// If the user denies the permission or an error occurs, show an error message
toast.error(
$i18n.t('Permission denied when accessing media devices')
);
} catch {
toast.error($i18n.t('Permission denied when accessing microphone'));
}
}}
aria-label={$i18n.t('Voice mode')}
>
<Voice className="size-5" strokeWidth="2.5" />
</button>
</Tooltip>
</div>
{:else}
<div class=" flex items-center">
<Tooltip content={$i18n.t('Send message')}>
<button
id="send-message-button"
class="{!(prompt === '' && files.length === 0)
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
type="submit"
disabled={prompt === '' && files.length === 0}
aria-label="Voice Input"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
viewBox="0 0 20 20"
fill="currentColor"
class="size-5"
class="size-5 translate-y-[0.5px]"
>
<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
<path
fill-rule="evenodd"
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
clip-rule="evenodd"
d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
/>
</svg>
</button>
</Tooltip>
</div>
{/if}
{#if prompt === '' && files.length === 0 && ($_user?.role === 'admin' || ($_user?.permissions?.chat?.call ?? true))}
<div class=" flex items-center">
<!-- {$i18n.t('Call')} -->
<Tooltip content={$i18n.t('Voice mode')}>
<button
class=" bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full p-1.5 self-center"
type="button"
on:click={async () => {
if (selectedModels.length > 1) {
toast.error($i18n.t('Select only one model to call'));
return;
}
if ($config.audio.stt.engine === 'web') {
toast.error(
$i18n.t('Call feature is not supported when using Web STT engine')
);
return;
}
// check if user has access to getUserMedia
try {
let stream = await navigator.mediaDevices.getUserMedia({
audio: true
});
// If the user grants the permission, proceed to show the call overlay
if (stream) {
const tracks = stream.getTracks();
tracks.forEach((track) => track.stop());
}
stream = null;
if ($settings.audio?.tts?.engine === 'browser-kokoro') {
// If the user has not initialized the TTS worker, initialize it
if (!$TTSWorker) {
await TTSWorker.set(
new KokoroWorker({
dtype: $settings.audio?.tts?.engineConfig?.dtype ?? 'fp32'
})
);
await $TTSWorker.init();
}
}
showCallOverlay.set(true);
showControls.set(true);
} catch (err) {
// If the user denies the permission or an error occurs, show an error message
toast.error(
$i18n.t('Permission denied when accessing media devices')
);
}
}}
aria-label={$i18n.t('Voice mode')}
>
<Voice className="size-5" strokeWidth="2.5" />
</button>
</Tooltip>
</div>
{:else}
<div class=" flex items-center">
<Tooltip content={$i18n.t('Send message')}>
<button
id="send-message-button"
class="{!(prompt === '' && files.length === 0)
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
type="submit"
disabled={prompt === '' && files.length === 0}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-5"
>
<path
fill-rule="evenodd"
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
</div>
{/if}
{/if}
</div>
</div>

View file

@ -28,9 +28,6 @@
await Promise.all([
(async () => {
prompts.set(await getPrompts(localStorage.token));
})(),
(async () => {
knowledge.set(await getKnowledgeBases(localStorage.token));
})()
]);
loading = false;
@ -103,7 +100,6 @@
bind:this={suggestionElement}
{query}
bind:filteredItems
knowledge={$knowledge ?? []}
onSelect={(e) => {
const { type, data } = e;

View file

@ -1,19 +1,21 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import Fuse from 'fuse.js';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
import { tick, getContext, onMount, onDestroy } from 'svelte';
import { removeLastWordFromString, isValidHttpUrl, isYoutubeUrl } from '$lib/utils';
import { folders } from '$lib/stores';
import { getFolders } from '$lib/apis/folders';
import { searchKnowledgeBases, searchKnowledgeFiles } from '$lib/apis/knowledge';
import { removeLastWordFromString, isValidHttpUrl, isYoutubeUrl, decodeString } from '$lib/utils';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
import Database from '$lib/components/icons/Database.svelte';
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
import Youtube from '$lib/components/icons/Youtube.svelte';
import { folders } from '$lib/stores';
import Folder from '$lib/components/icons/Folder.svelte';
const i18n = getContext('i18n');
@ -21,35 +23,24 @@
export let query = '';
export let onSelect = (e) => {};
export let knowledge = [];
let selectedIdx = 0;
let items = [];
let fuse = null;
export let filteredItems = [];
$: if (fuse) {
filteredItems = [
...(query
? fuse.search(query).map((e) => {
return e.item;
})
: items),
...(query.startsWith('http')
? isYoutubeUrl(query)
? [{ type: 'youtube', name: query, description: query }]
: [
{
type: 'web',
name: query,
description: query
}
]
: [])
];
}
$: filteredItems = [
...(query.startsWith('http')
? isYoutubeUrl(query)
? [{ type: 'youtube', name: query, description: query }]
: [
{
type: 'web',
name: query,
description: query
}
]
: []),
...items
];
$: if (query) {
selectedIdx = 0;
@ -71,105 +62,70 @@
item.click();
}
};
const decodeString = (str: string) => {
try {
return decodeURIComponent(str);
} catch (e) {
return str;
let folderItems = [];
let knowledgeItems = [];
let fileItems = [];
$: items = [...folderItems, ...knowledgeItems, ...fileItems];
$: if (query !== null) {
getItems();
}
const getItems = () => {
getFolderItems();
getKnowledgeItems();
getKnowledgeFileItems();
};
const getFolderItems = async () => {
folderItems = $folders
.map((folder) => ({
...folder,
type: 'folder',
description: $i18n.t('Folder'),
title: folder.name
}))
.filter((folder) => folder.name.toLowerCase().includes(query.toLowerCase()));
};
const getKnowledgeItems = async () => {
const res = await searchKnowledgeBases(localStorage.token, query).catch(() => {
return null;
});
if (res) {
knowledgeItems = res.items.map((item) => {
return {
...item,
type: 'collection'
};
});
}
};
const getKnowledgeFileItems = async () => {
const res = await searchKnowledgeFiles(localStorage.token, query).catch(() => {
return null;
});
if (res) {
fileItems = res.items.map((item) => {
return {
...item,
type: 'file',
name: item.filename,
description: item.collection ? item.collection.name : ''
};
});
}
};
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
.filter((item) => !item?.meta?.document)
.map((item) => ({
...item,
type: 'collection'
}));
let collection_files =
knowledge.length > 0
? [
...knowledge
.reduce((a, item) => {
return [
...new Set([
...a,
...(item?.files ?? []).map((file) => ({
...file,
collection: { name: item.name, description: item.description } // DO NOT REMOVE, USED IN FILE DESCRIPTION/ATTACHMENT
}))
])
];
}, [])
.map((file) => ({
...file,
name: file?.meta?.name,
description: `${file?.collection?.description}`,
knowledge: true, // DO NOT REMOVE, USED TO INDICATE KNOWLEDGE BASE FILE
type: 'file'
}))
]
: [];
let folder_items = $folders.map((folder) => ({
...folder,
type: 'folder',
description: $i18n.t('Folder'),
title: folder.name
}));
items = [
...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, {
keys: ['name', 'description']
});
if ($folders === null) {
await folders.set(await getFolders(localStorage.token));
}
await tick();
});
@ -189,12 +145,20 @@
});
</script>
<div class="px-2 text-xs text-gray-500 py-1">
{$i18n.t('Knowledge')}
</div>
{#if filteredItems.length > 0 || query.startsWith('http')}
{#each filteredItems as item, idx}
{#if idx === 0 || item?.type !== items[idx - 1]?.type}
<div class="px-2 text-xs text-gray-500 py-1">
{#if item?.type === 'folder'}
{$i18n.t('Folders')}
{:else if item?.type === 'collection'}
{$i18n.t('Collections')}
{:else if item?.type === 'file'}
{$i18n.t('Files')}
{/if}
</div>
{/if}
{#if !['youtube', 'web'].includes(item.type)}
<button
class=" px-2 py-1 rounded-xl w-full text-left flex justify-between items-center {idx ===

View file

@ -18,7 +18,7 @@
<div
bind:this={overlayElement}
class="fixed {$showSidebar
? 'left-0 md:left-[260px] md:w-[calc(100%-260px)]'
? 'left-0 md:left-[var(--sidebar-width)] md:w-[calc(100%-var(--sidebar-width))]'
: 'left-0'} fixed top-0 right-0 bottom-0 w-full h-full flex z-9999 touch-none pointer-events-none"
id="dropzone"
role="region"

View file

@ -73,16 +73,6 @@
}
};
const init = async () => {
if ($knowledge === null) {
await knowledge.set(await getKnowledgeBases(localStorage.token));
}
};
$: if (show) {
init();
}
const onSelect = (item) => {
if (files.find((f) => f.id === item.id)) {
return;
@ -249,37 +239,35 @@
</Tooltip>
{/if}
{#if ($knowledge ?? []).length > 0}
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
: !fileUploadEnabled
? $i18n.t('You do not have permission to upload files.')
: ''}
className="w-full"
>
<button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
tab = 'knowledge';
}}
>
<button
class="flex gap-2 w-full items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
tab = 'knowledge';
}}
>
<Database />
<Database />
<div class="flex items-center w-full justify-between">
<div class=" line-clamp-1">
{$i18n.t('Attach Knowledge')}
</div>
<div class="text-gray-500">
<ChevronRight />
</div>
<div class="flex items-center w-full justify-between">
<div class=" line-clamp-1">
{$i18n.t('Attach Knowledge')}
</div>
</button>
</Tooltip>
{/if}
<div class="text-gray-500">
<ChevronRight />
</div>
</div>
</button>
</Tooltip>
{#if ($chats ?? []).length > 0}
<Tooltip

View file

@ -4,157 +4,296 @@
import { decodeString } from '$lib/utils';
import { knowledge } from '$lib/stores';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import { getKnowledgeBases, searchKnowledgeFilesById } from '$lib/apis/knowledge';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Database from '$lib/components/icons/Database.svelte';
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Loader from '$lib/components/common/Loader.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
const i18n = getContext('i18n');
export let onSelect = (e) => {};
let loaded = false;
let items = [];
let selectedIdx = 0;
onMount(async () => {
if ($knowledge === null) {
await knowledge.set(await getKnowledgeBases(localStorage.token));
let selectedItem = null;
let selectedFileItemsPage = 1;
let selectedFileItems = null;
let selectedFileItemsTotal = null;
let selectedFileItemsLoading = false;
let selectedFileAllItemsLoaded = false;
$: if (selectedItem) {
initSelectedFileItems();
}
const initSelectedFileItems = async () => {
selectedFileItemsPage = 1;
selectedFileItems = null;
selectedFileItemsTotal = null;
selectedFileAllItemsLoaded = false;
selectedFileItemsLoading = false;
await tick();
await getSelectedFileItemsPage();
};
const loadMoreSelectedFileItems = async () => {
if (selectedFileAllItemsLoaded) return;
selectedFileItemsPage += 1;
await getSelectedFileItemsPage();
};
const getSelectedFileItemsPage = async () => {
if (!selectedItem) return;
selectedFileItemsLoading = true;
const res = await searchKnowledgeFilesById(
localStorage.token,
selectedItem.id,
null,
null,
null,
null,
selectedFileItemsPage
).catch(() => {
return null;
});
if (res) {
selectedFileItemsTotal = res.total;
const pageItems = res.items;
if ((pageItems ?? []).length === 0) {
selectedFileAllItemsLoaded = true;
} else {
selectedFileAllItemsLoaded = false;
}
if (selectedFileItems) {
selectedFileItems = [...selectedFileItems, ...pageItems];
} else {
selectedFileItems = pageItems;
}
}
let legacy_documents = $knowledge
.filter((item) => item?.meta?.document)
.map((item) => ({
...item,
type: 'file'
}));
selectedFileItemsLoading = false;
return res;
};
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)
},
let page = 1;
let items = null;
let total = null;
...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 itemsLoading = false;
let allItemsLoaded = false;
let collections = $knowledge
.filter((item) => !item?.meta?.document)
.map((item) => ({
...item,
type: 'collection'
}));
``;
let collection_files =
$knowledge.length > 0
? [
...$knowledge
.reduce((a, item) => {
return [
...new Set([
...a,
...(item?.files ?? []).map((file) => ({
...file,
collection: { name: item.name, description: item.description } // DO NOT REMOVE, USED IN FILE DESCRIPTION/ATTACHMENT
}))
])
];
}, [])
.map((file) => ({
...file,
name: file?.meta?.name,
description: `${file?.collection?.name} - ${file?.collection?.description}`,
knowledge: true, // DO NOT REMOVE, USED TO INDICATE KNOWLEDGE BASE FILE
type: 'file'
}))
]
: [];
items = [...collections, ...collection_files, ...legacy_collections, ...legacy_documents].map(
(item) => {
return {
...item,
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
};
}
);
$: if (loaded) {
init();
}
const init = async () => {
reset();
await tick();
await getItemsPage();
};
const reset = () => {
page = 1;
items = null;
total = null;
allItemsLoaded = false;
itemsLoading = false;
};
const loadMoreItems = async () => {
if (allItemsLoaded) return;
page += 1;
await getItemsPage();
};
const getItemsPage = async () => {
itemsLoading = true;
const res = await getKnowledgeBases(localStorage.token, page).catch(() => {
return null;
});
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;
};
onMount(async () => {
await tick();
loaded = true;
});
</script>
{#if loaded}
{#if loaded && items !== null}
<div class="flex flex-col gap-0.5">
{#each items as item, idx}
<button
class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm {idx ===
selectedIdx
? ' bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
console.log(item);
onSelect(item);
}}
on:mousemove={() => {
selectedIdx = idx;
}}
on:mouseleave={() => {
if (idx === 0) {
selectedIdx = -1;
}
}}
data-selected={idx === selectedIdx}
>
<div class=" text-black dark:text-gray-100 flex items-center gap-1">
<Tooltip
content={item?.legacy
? $i18n.t('Legacy')
: item?.type === 'file'
? $i18n.t('File')
: item?.type === 'collection'
? $i18n.t('Collection')
: ''}
placement="top"
{#if items.length === 0}
<div class="py-4 text-center text-sm text-gray-500 dark:text-gray-400">
{$i18n.t('No knowledge bases found.')}
</div>
{:else}
{#each items as item, idx (item.id)}
<div
class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm {idx ===
selectedIdx
? ' bg-gray-50 dark:bg-gray-800 dark:text-gray-100 selected-command-option-button'
: ''}"
>
<button
class="w-full flex-1"
type="button"
on:click={() => {
onSelect({
type: 'collection',
...item
});
}}
on:mousemove={() => {
selectedIdx = idx;
}}
on:mouseleave={() => {
if (idx === 0) {
selectedIdx = -1;
}
}}
data-selected={idx === selectedIdx}
>
{#if item?.type === 'collection'}
<Database className="size-4" />
{:else}
<DocumentPage className="size-4" />
{/if}
</Tooltip>
<div class=" text-black dark:text-gray-100 flex items-center gap-1 shrink-0">
<Tooltip content={$i18n.t('Collection')} placement="top">
<Database className="size-4" />
</Tooltip>
<Tooltip content={item.description || decodeString(item?.name)} placement="top-start">
<div class="line-clamp-1 flex-1">
{decodeString(item?.name)}
<Tooltip content={item.description || decodeString(item?.name)} placement="top-start">
<div class="line-clamp-1 flex-1 text-sm">
{decodeString(item?.name)}
</div>
</Tooltip>
</div>
</button>
<Tooltip content={$i18n.t('Show Files')} placement="top">
<button
type="button"
class=" ml-2 opacity-50 hover:opacity-100 transition"
on:click={() => {
if (selectedItem && selectedItem.id === item.id) {
selectedItem = null;
} else {
selectedItem = item;
}
}}
>
{#if selectedItem && selectedItem.id === item.id}
<ChevronDown className="size-3" />
{:else}
<ChevronRight className="size-3" />
{/if}
</button>
</Tooltip>
</div>
</button>
{/each}
{#if selectedItem && selectedItem.id === item.id}
<div class="pl-3 mb-1 flex flex-col gap-0.5">
{#if selectedFileItems === null && selectedFileItemsTotal === null}
<div class=" py-1 flex justify-center">
<Spinner className="size-3" />
</div>
{:else if selectedFileItemsTotal === 0}
<div class=" text-xs text-gray-500 dark:text-gray-400 italic py-0.5 px-2">
{$i18n.t('No files in this knowledge base.')}
</div>
{:else}
{#each selectedFileItems as file, fileIdx (file.id)}
<button
class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm hover:bg-gray-50 hover:dark:bg-gray-800 hover:dark:text-gray-100"
type="button"
on:click={() => {
console.log(file);
onSelect({
type: 'file',
name: file?.meta?.name,
...file
});
}}
>
<div class=" flex items-center gap-1.5">
<Tooltip content={$i18n.t('Collection')} placement="top">
<DocumentPage className="size-4" />
</Tooltip>
<Tooltip content={decodeString(file?.meta?.name)} placement="top-start">
<div class="line-clamp-1 flex-1 text-sm">
{decodeString(file?.meta?.name)}
</div>
</Tooltip>
</div>
</button>
{/each}
{#if !selectedFileAllItemsLoaded && !selectedFileItemsLoading}
<Loader
on:visible={async (e) => {
if (!selectedFileItemsLoading) {
await loadMoreSelectedFileItems();
}
}}
>
<div
class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2"
>
<Spinner className=" size-3" />
<div class=" ">{$i18n.t('Loading...')}</div>
</div>
</Loader>
{/if}
{/if}
</div>
{/if}
{/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}
{/if}
</div>
{:else}
<div class="py-4.5">

View file

@ -1,11 +1,14 @@
<script lang="ts">
import { getContext } from 'svelte';
import CitationModal from './Citations/CitationModal.svelte';
import { embed, showControls, showEmbeds } from '$lib/stores';
import CitationModal from './Citations/CitationModal.svelte';
const i18n = getContext('i18n');
export let id = '';
export let chatId = '';
export let sources = [];
export let readOnly = false;
@ -35,8 +38,11 @@
showControls.set(true);
showEmbeds.set(true);
embed.set({
url: embedUrl,
title: citations[sourceIdx]?.source?.name || 'Embedded Content',
url: embedUrl
source: citations[sourceIdx],
chatId: chatId,
messageId: id
});
}
} else {

View file

@ -75,12 +75,11 @@
`<sup class="footnote-ref footnote-ref-text">${token.escapedText}</sup>`
) || ''}
{:else if token.type === 'citation'}
<SourceToken {id} {token} {sourceIds} onClick={onSourceClick} />
<!-- {#if token.ids && token.ids.length > 0}
{#each token.ids as sourceId}
<Source id={sourceId - 1} title={sourceIds[sourceId - 1]} onClick={onSourceClick} />
{/each}
{/if} -->
{#if (sourceIds ?? []).length > 0}
<SourceToken {id} {token} {sourceIds} onClick={onSourceClick} />
{:else}
<TextToken {token} {done} />
{/if}
{:else if token.type === 'text'}
<TextToken {token} {done} />
{/if}

View file

@ -39,37 +39,41 @@
};
</script>
{#if (token?.ids ?? []).length == 1}
<Source id={token.ids[0] - 1} title={sourceIds[token.ids[0] - 1]} {onClick} />
{:else}
<LinkPreview.Root openDelay={0} bind:open={openPreview}>
<LinkPreview.Trigger>
<button
class="text-[10px] w-fit translate-y-[2px] px-2 py-0.5 dark:bg-white/5 dark:text-white/80 dark:hover:text-white bg-gray-50 text-black/80 hover:text-black transition rounded-xl"
on:click={() => {
openPreview = !openPreview;
}}
{#if sourceIds}
{#if (token?.ids ?? []).length == 1}
<Source id={token.ids[0] - 1} title={sourceIds[token.ids[0] - 1]} {onClick} />
{:else}
<LinkPreview.Root openDelay={0} bind:open={openPreview}>
<LinkPreview.Trigger>
<button
class="text-[10px] w-fit translate-y-[2px] px-2 py-0.5 dark:bg-white/5 dark:text-white/80 dark:hover:text-white bg-gray-50 text-black/80 hover:text-black transition rounded-xl"
on:click={() => {
openPreview = !openPreview;
}}
>
<span class="line-clamp-1">
{getDisplayTitle(formattedTitle(decodeString(sourceIds[token.ids[0] - 1])))}
<span class="dark:text-white/50 text-black/50">+{(token?.ids ?? []).length - 1}</span>
</span>
</button>
</LinkPreview.Trigger>
<LinkPreview.Content
class="z-[999]"
align="start"
strategy="fixed"
sideOffset={6}
el={containerElement}
>
<span class="line-clamp-1">
{getDisplayTitle(formattedTitle(decodeString(sourceIds[token.ids[0] - 1])))}
<span class="dark:text-white/50 text-black/50">+{(token?.ids ?? []).length - 1}</span>
</span>
</button>
</LinkPreview.Trigger>
<LinkPreview.Content
class="z-[999]"
align="start"
strategy="fixed"
sideOffset={6}
el={containerElement}
>
<div class="bg-gray-50 dark:bg-gray-850 rounded-xl p-1 cursor-pointer">
{#each token.ids as sourceId}
<div class="">
<Source id={sourceId - 1} title={sourceIds[sourceId - 1]} {onClick} />
</div>
{/each}
</div>
</LinkPreview.Content>
</LinkPreview.Root>
<div class="bg-gray-50 dark:bg-gray-850 rounded-xl p-1 cursor-pointer">
{#each token.ids as sourceId}
<div class="">
<Source id={sourceId - 1} title={sourceIds[sourceId - 1]} {onClick} />
</div>
{/each}
</div>
</LinkPreview.Content>
</LinkPreview.Root>
{/if}
{:else}
<span>{token.raw}</span>
{/if}

View file

@ -824,6 +824,7 @@
<Citations
bind:this={citationsElement}
id={message?.id}
{chatId}
sources={message?.sources ?? message?.citations}
{readOnly}
/>
@ -1460,37 +1461,35 @@
{/if}
{/if}
{#if isLastMessage}
{#each model?.actions ?? [] as action}
<Tooltip content={action.name} placement="bottom">
<button
type="button"
aria-label={action.name}
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
on:click={() => {
actionMessage(action.id, message);
}}
>
{#if action?.icon}
<div class="size-4">
<img
src={action.icon}
class="w-4 h-4 {action.icon.includes('svg')
? 'dark:invert-[80%]'
: ''}"
style="fill: currentColor;"
alt={action.name}
/>
</div>
{:else}
<Sparkles strokeWidth="2.1" className="size-4" />
{/if}
</button>
</Tooltip>
{/each}
{/if}
{#each model?.actions ?? [] as action}
<Tooltip content={action.name} placement="bottom">
<button
type="button"
aria-label={action.name}
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
on:click={() => {
actionMessage(action.id, message);
}}
>
{#if action?.icon}
<div class="size-4">
<img
src={action.icon}
class="w-4 h-4 {action.icon.includes('svg')
? 'dark:invert-[80%]'
: ''}"
style="fill: currentColor;"
alt={action.name}
/>
</div>
{:else}
<Sparkles strokeWidth="2.1" className="size-4" />
{/if}
</button>
</Tooltip>
{/each}
{/if}
{/if}
{/if}

View file

@ -364,7 +364,7 @@
type="button"
class="rounded-lg p-1 transition outline-gray-200 hover:bg-gray-100 dark:outline-gray-700 dark:hover:bg-gray-800"
on:click={() => {
textScale = Math.max(1, textScale);
textScale = Math.max(1, parseFloat((textScale - 0.1).toFixed(2)));
setTextScaleHandler(textScale);
}}
aria-labelledby="ui-scale-label"
@ -397,7 +397,7 @@
type="button"
class="rounded-lg p-1 transition outline-gray-200 hover:bg-gray-100 dark:outline-gray-700 dark:hover:bg-gray-800"
on:click={() => {
textScale = Math.min(1.5, textScale);
textScale = Math.min(1.5, parseFloat((textScale + 0.1).toFixed(2)));
setTextScaleHandler(textScale);
}}
aria-labelledby="ui-scale-label"
@ -713,24 +713,26 @@
</div>
</div>
<div>
<div class=" py-0.5 flex w-full justify-between">
<div id="temp-chat-default-label" class=" self-center text-xs">
{$i18n.t('Temporary Chat by Default')}
</div>
{#if $user.role === 'admin' || $user?.permissions?.chat?.temporary}
<div>
<div class=" py-0.5 flex w-full justify-between">
<div id="temp-chat-default-label" class=" self-center text-xs">
{$i18n.t('Temporary Chat by Default')}
</div>
<div class="flex items-center gap-2 p-1">
<Switch
ariaLabelledbyId="temp-chat-default-label"
tooltip={true}
bind:state={temporaryChatByDefault}
on:change={() => {
saveSettings({ temporaryChatByDefault });
}}
/>
<div class="flex items-center gap-2 p-1">
<Switch
ariaLabelledbyId="temp-chat-default-label"
tooltip={true}
bind:state={temporaryChatByDefault}
on:change={() => {
saveSettings({ temporaryChatByDefault });
}}
/>
</div>
</div>
</div>
</div>
{/if}
<div>
<div class=" py-0.5 flex w-full justify-between">

View file

@ -12,7 +12,14 @@
});
onDestroy(() => {
document.body.removeChild(popupElement);
if (popupElement && popupElement.parentNode) {
try {
popupElement.parentNode.removeChild(popupElement);
} catch (err) {
console.warn('Failed to remove popupElement:', err);
}
}
document.body.style.overflow = 'unset';
});
</script>

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

@ -21,6 +21,8 @@
'strict-origin-when-cross-origin';
export let allowFullscreen = true;
export let payload = null; // payload to send into the iframe on request
let iframe: HTMLIFrameElement | null = null;
let iframeSrc: string | null = null;
let iframeDoc: string | null = null;
@ -142,13 +144,29 @@ window.Chart = parent.Chart; // Chart previously assigned on parent
}
}
// Handle height messages from the iframe (we also verify the sender)
function onMessage(e: MessageEvent) {
if (!iframe || e.source !== iframe.contentWindow) return;
const data = e.data as { type?: string; height?: number };
const data = e.data || {};
if (data?.type === 'iframe:height' && typeof data.height === 'number') {
iframe.style.height = Math.max(0, data.height) + 'px';
}
// Pong message for testing connectivity
if (data?.type === 'pong') {
console.log('Received pong from iframe:', data);
// Optional: reply back
iframe.contentWindow?.postMessage({ type: 'pong:ack' }, '*');
}
// Send payload data if requested
if (data?.type === 'payload') {
iframe.contentWindow?.postMessage(
{ type: 'payload', requestId: data?.requestId ?? null, payload: payload },
'*'
);
}
}
// When the iframe loads, try same-origin resize (cross-origin will noop)

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

@ -36,7 +36,7 @@
let filter = {};
$: filter = {
...(query ? { query } : {}),
...(query ? { query: query } : {}),
...(orderBy ? { order_by: orderBy } : {}),
...(direction ? { direction } : {})
};

View file

@ -121,6 +121,7 @@
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Chats')}
maxlength="500"
/>
{#if query}

View file

@ -437,33 +437,37 @@
{#if !$temporaryChatEnabled && chat?.id}
<hr class="border-gray-50/30 dark:border-gray-800/30 my-1" />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
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 select-none w-full"
>
<Folder strokeWidth="1.5" />
{#if $folders.length > 0}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
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 select-none w-full"
>
<Folder strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Move')}</div>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-full rounded-2xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white border border-gray-100 dark:border-gray-800 shadow-lg max-h-52 overflow-y-auto scrollbar-hidden"
transition={flyAndScale}
sideOffset={8}
>
{#each $folders.sort((a, b) => b.updated_at - a.updated_at) as folder}
<DropdownMenu.Item
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={() => {
moveChatHandler(chat?.id, folder?.id);
}}
>
<Folder strokeWidth="1.5" />
<div class="flex items-center">{$i18n.t('Move')}</div>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-full rounded-2xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white border border-gray-100 dark:border-gray-800 shadow-lg max-h-52 overflow-y-auto scrollbar-hidden"
transition={flyAndScale}
sideOffset={8}
>
{#each $folders.sort((a, b) => b.updated_at - a.updated_at) as folder}
{#if folder?.id}
<DropdownMenu.Item
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={() => {
moveChatHandler(chat.id, folder.id);
}}
>
<Folder strokeWidth="1.5" />
<div class="flex items-center">{folder?.name ?? 'Folder'}</div>
</DropdownMenu.Item>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<div class="flex items-center">{folder.name ?? 'Folder'}</div>
</DropdownMenu.Item>
{/if}
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{/if}
<DropdownMenu.Item
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"

View file

@ -25,7 +25,8 @@
isApp,
models,
selectedFolder,
WEBUI_NAME
WEBUI_NAME,
sidebarWidth
} from '$lib/stores';
import { onMount, getContext, tick, onDestroy } from 'svelte';
@ -182,12 +183,18 @@
const initChannels = async () => {
// default (none), group, dm type
await channels.set(
(await getChannels(localStorage.token)).sort(
(a, b) =>
['', null, 'group', 'dm'].indexOf(a.type) - ['', null, 'group', 'dm'].indexOf(b.type)
)
);
const res = await getChannels(localStorage.token).catch((error) => {
return null;
});
if (res) {
await channels.set(
res.sort(
(a, b) =>
['', null, 'group', 'dm'].indexOf(a.type) - ['', null, 'group', 'dm'].indexOf(b.type)
)
);
}
};
const initChatList = async () => {
@ -365,8 +372,55 @@
selectedChatId = null;
};
const MIN_WIDTH = 220;
const MAX_WIDTH = 480;
let isResizing = false;
let startWidth = 0;
let startClientX = 0;
const resizeStartHandler = (e: MouseEvent) => {
if ($mobile) return;
isResizing = true;
startClientX = e.clientX;
startWidth = $sidebarWidth ?? 260;
document.body.style.userSelect = 'none';
};
const resizeEndHandler = () => {
if (!isResizing) return;
isResizing = false;
document.body.style.userSelect = '';
localStorage.setItem('sidebarWidth', String($sidebarWidth));
};
const resizeSidebarHandler = (endClientX) => {
const dx = endClientX - startClientX;
const newSidebarWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + dx));
sidebarWidth.set(newSidebarWidth);
document.documentElement.style.setProperty('--sidebar-width', `${newSidebarWidth}px`);
};
let unsubscribers = [];
onMount(async () => {
try {
const width = Number(localStorage.getItem('sidebarWidth'));
if (!Number.isNaN(width) && width >= MIN_WIDTH && width <= MAX_WIDTH) {
sidebarWidth.set(width);
}
} catch {}
document.documentElement.style.setProperty('--sidebar-width', `${$sidebarWidth}px`);
sidebarWidth.subscribe((w) => {
document.documentElement.style.setProperty('--sidebar-width', `${w}px`);
});
await showSidebar.set(!$mobile ? localStorage.sidebar === 'true' : false);
unsubscribers = [
@ -564,6 +618,16 @@
}}
/>
<svelte:window
on:mousemove={(e) => {
if (!isResizing) return;
resizeSidebarHandler(e.clientX);
}}
on:mouseup={() => {
resizeEndHandler();
}}
/>
{#if !$mobile && !$showSidebar}
<div
class=" pt-[7px] pb-2 px-2 flex flex-col justify-between text-black dark:text-white hover:bg-gray-50/30 dark:hover:bg-gray-950/30 h-full z-10 transition-all border-e-[0.5px] border-gray-50 dark:border-gray-850/30"
@ -769,7 +833,7 @@
data-state={$showSidebar}
>
<div
class=" my-auto flex flex-col justify-between h-screen max-h-[100dvh] w-[260px] overflow-x-hidden scrollbar-hidden z-50 {$showSidebar
class=" my-auto flex flex-col justify-between h-screen max-h-[100dvh] w-[var(--sidebar-width)] overflow-x-hidden scrollbar-hidden z-50 {$showSidebar
? ''
: 'invisible'}"
>
@ -1315,4 +1379,17 @@
</div>
</div>
</div>
{#if !$mobile}
<div
class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850/30 hover:border-gray-200 dark:hover:border-gray-800 transition z-20"
id="sidebar-resizer"
on:mousedown={resizeStartHandler}
role="separator"
>
<div
class=" absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
/>
</div>
{/if}
{/if}

View file

@ -209,6 +209,7 @@
class="w-full rounded-r-xl py-1.5 pl-2.5 text-sm bg-transparent dark:text-gray-300 outline-hidden"
placeholder={placeholder ? placeholder : $i18n.t('Search')}
autocomplete="off"
maxlength="500"
bind:value
on:input={() => {
dispatch('input');

View file

@ -157,6 +157,16 @@
if (res) {
note = res;
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 {
goto('/');
return;
@ -781,13 +791,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
onMount(async () => {
await tick();
$socket?.emit('join-note', {
note_id: id,
auth: {
token: localStorage.token
}
});
$socket?.on('note-events', noteEventHandler);
if ($settings?.models) {
selectedModelId = $settings?.models[0];
@ -956,70 +959,72 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
{/if}
<div class="flex items-center gap-0.5 translate-x-1">
{#if editor}
<div>
<div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr">
<button
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
on:click={() => {
editor.chain().focus().undo().run();
// versionNavigateHandler('prev');
}}
disabled={!editor.can().undo()}
>
<ArrowUturnLeft className="size-4" />
</button>
{#if note?.write_access}
{#if editor}
<div>
<div class="flex items-center gap-0.5 self-center min-w-fit" dir="ltr">
<button
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
on:click={() => {
editor.chain().focus().undo().run();
// versionNavigateHandler('prev');
}}
disabled={!editor.can().undo()}
>
<ArrowUturnLeft className="size-4" />
</button>
<button
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
on:click={() => {
editor.chain().focus().redo().run();
// versionNavigateHandler('next');
}}
disabled={!editor.can().redo()}
>
<ArrowUturnRight className="size-4" />
</button>
<button
class="self-center p-1 hover:enabled:bg-black/5 dark:hover:enabled:bg-white/5 dark:hover:enabled:text-white hover:enabled:text-black rounded-md transition disabled:cursor-not-allowed disabled:text-gray-500 disabled:hover:text-gray-500"
on:click={() => {
editor.chain().focus().redo().run();
// versionNavigateHandler('next');
}}
disabled={!editor.can().redo()}
>
<ArrowUturnRight className="size-4" />
</button>
</div>
</div>
</div>
{/if}
<Tooltip placement="top" content={$i18n.t('Chat')} className="cursor-pointer">
<button
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
on:click={() => {
if (showPanel && selectedPanel === 'chat') {
showPanel = false;
} else {
if (!showPanel) {
showPanel = true;
}
selectedPanel = 'chat';
}
}}
>
<ChatBubbleOval />
</button>
</Tooltip>
<Tooltip placement="top" content={$i18n.t('Controls')} className="cursor-pointer">
<button
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
on:click={() => {
if (showPanel && selectedPanel === 'settings') {
showPanel = false;
} else {
if (!showPanel) {
showPanel = true;
}
selectedPanel = 'settings';
}
}}
>
<AdjustmentsHorizontalOutline />
</button>
</Tooltip>
{/if}
<Tooltip placement="top" content={$i18n.t('Chat')} className="cursor-pointer">
<button
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
on:click={() => {
if (showPanel && selectedPanel === 'chat') {
showPanel = false;
} else {
if (!showPanel) {
showPanel = true;
}
selectedPanel = 'chat';
}
}}
>
<ChatBubbleOval />
</button>
</Tooltip>
<Tooltip placement="top" content={$i18n.t('Controls')} className="cursor-pointer">
<button
class="p-1.5 bg-transparent hover:bg-white/5 transition rounded-lg"
on:click={() => {
if (showPanel && selectedPanel === 'settings') {
showPanel = false;
} else {
if (!showPanel) {
showPanel = true;
}
selectedPanel = 'settings';
}
}}
>
<AdjustmentsHorizontalOutline />
</button>
</Tooltip>
<NoteMenu
onDownload={(type) => {
downloadHandler(type);
@ -1071,11 +1076,9 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
}}
>
<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">
<Calendar className="size-3.5" strokeWidth="2" />
<!-- check for same date, yesterday, last week, and other -->
{#if dayjs(note.created_at / 1000000).isSame(dayjs(), 'day')}
@ -1099,17 +1102,21 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
{/if}
</button>
<button
class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit"
on:click={() => {
showAccessControlModal = true;
}}
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>
</button>
{#if note?.write_access}
<button
class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg min-w-fit"
on:click={() => {
showAccessControlModal = true;
}}
disabled={note?.user_id !== $user?.id && $user?.role !== 'admin'}
>
<span> {note?.access_control ? $i18n.t('Private') : $i18n.t('Everyone')} </span>
</button>
{:else}
<div>
{$i18n.t('Read-Only Access')}
</div>
{/if}
{#if editor}
<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
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"
>
{#if editing}
@ -1145,7 +1152,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
bind:this={inputElement}
bind:editor
id={`note-${note.id}`}
className="input-prose-sm px-0.5"
className="input-prose-sm px-0.5 h-[calc(100%-2rem)]"
json={true}
bind:value={note.data.content.json}
html={note.data?.content?.html}
@ -1158,7 +1165,7 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
image={true}
{files}
placeholder={$i18n.t('Write something...')}
editable={versionIdx === null && !editing}
editable={versionIdx === null && !editing && note?.write_access}
onSelectionUpdate={({ editor }) => {
const { from, to } = editor.state.selection;
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>
{/if}
</div>
<div class="absolute z-20 bottom-0 right-0 p-3.5 max-w-full w-full flex">
<div class="flex gap-1 w-full min-w-full justify-between">
<div class="absolute z-50 bottom-0 right-0 p-3.5 flex select-none">
<div class="flex flex-col gap-2 justify-end">
{#if recording}
<div class="flex-1 w-full">
<VoiceRecording
@ -1269,6 +1276,39 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
/>
</div>
{: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
onRecord={async () => {
displayMediaRecord = false;
@ -1324,40 +1364,6 @@ Provide the enhanced notes in markdown format. Use markdown syntax for headings,
</div>
</Tooltip>
</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}
</div>
</div>

View file

@ -1,9 +1,7 @@
<script lang="ts">
import { marked } from 'marked';
import { toast } from 'svelte-sonner';
import fileSaver from 'file-saver';
import Fuse from 'fuse.js';
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
$: loadLocale($i18n.languages);
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount, getContext, onDestroy } from 'svelte';
import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores';
import { createNewNote, deleteNoteById, getNotes } from '$lib/apis/notes';
import { createNewNote, deleteNoteById, getNoteList, searchNotes } from '$lib/apis/notes';
import { capitalizeFirstLetter, copyToClipboard, getTimeRange } from '$lib/utils';
import { downloadPdf, createNoteHandler } from './utils';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
@ -48,58 +45,31 @@
import NoteMenu from './Notes/NoteMenu.svelte';
import FilesOverlay from '../chat/MessageInput/FilesOverlay.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 importFiles = '';
let query = '';
let noteItems = [];
let fuse = null;
let selectedNote = null;
let notes = {};
$: if (fuse) {
notes = groupNotes(
query
? fuse.search(query).map((e) => {
return e.item;
})
: noteItems
);
}
let showDeleteConfirm = false;
const groupNotes = (res) => {
console.log(res);
if (!Array.isArray(res)) {
return {}; // or throw new Error("Notes response is not an array")
}
let notes = {};
// 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 items = null;
let total = null;
const init = async () => {
noteItems = await getNotes(localStorage.token, true);
let query = '';
fuse = new Fuse(noteItems, {
keys: ['title']
});
};
let sortKey = null;
let displayOption = null;
let viewOption = null;
let permission = null;
let page = 1;
let itemsLoading = false;
let allItemsLoaded = false;
const downloadHandler = async (type) => {
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;
const onDragOver = (e) => {
@ -205,6 +265,18 @@
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(() => {
console.log('destroy');
const dropzoneElement = document.getElementById('notes-container');
@ -215,17 +287,6 @@
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>
<svelte:head>
@ -236,7 +297,7 @@
<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}
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
@ -251,8 +312,41 @@
</div>
</DeleteConfirmDialog>
<div class="flex flex-col gap-1 px-3.5">
<div class=" flex flex-1 items-center w-full space-x-2">
<div class="flex flex-col gap-1 px-1 mt-1.5 mb-3">
<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=" 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=" self-center ml-1 mr-3">
<Search className="size-3.5" />
@ -277,194 +371,305 @@
{/if}
</div>
</div>
</div>
<div class="px-4.5 @container h-full pt-2">
{#if Object.keys(notes).length > 0}
<div class="pb-10">
{#each Object.keys(notes) as timeRange}
<div class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium pb-2.5">
{$i18n.t(timeRange)}
</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"
>
{#each notes[timeRange] as note, idx (note.id)}
<div
class=" flex space-x-4 cursor-pointer w-full px-4.5 py-4 border border-gray-50 dark:border-gray-850/30 bg-transparent dark:hover:bg-gray-850 hover:bg-white rounded-2xl transition"
>
<div class=" flex flex-1 space-x-4 cursor-pointer w-full">
<a
href={`/notes/${note.id}`}
class="w-full -translate-y-0.5 flex flex-col justify-between"
>
<div class="flex-1">
<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>
<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
class=" text-xs text-gray-500 dark:text-gray-500 mb-3 line-clamp-3 min-h-10"
>
{#if note.data?.content?.md}
{note.data?.content?.md}
{:else}
{$i18n.t('No content')}
{/if}
</div>
</div>
<div class=" text-xs px-0.5 w-full flex justify-between items-center">
<div>
{dayjs(note.updated_at / 1000000).fromNow()}
</div>
<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>
</a>
</div>
</div>
{/each}
</div>
{/each}
</div>
{:else}
<div class="w-full h-full flex flex-col items-center justify-center">
<div class="pb-20 text-center">
<div class=" text-xl font-medium text-gray-400 dark:text-gray-600">
{$i18n.t('No Notes')}
</div>
<div class="mt-1 text-sm text-gray-300 dark:text-gray-700">
{$i18n.t('Create your first note by clicking on the plus button below.')}
</div>
</div>
</div>
{/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="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=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Notes')}</div>
<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;
}
}}
/>
<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"
{#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') }
]}
/>
</svg>
{/if}
</div>
</button>
</div>
<div>
<DropdownOptions
align="start"
bind:value={displayOption}
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)}
</div>
{#if displayOption === null}
<div
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)}
<div
class=" flex space-x-4 cursor-pointer w-full px-4.5 py-4 border border-gray-50 dark:border-gray-850/30 bg-transparent dark:hover:bg-gray-850 hover:bg-white rounded-2xl transition"
>
<div class=" flex flex-1 space-x-4 cursor-pointer w-full">
<a
href={`/notes/${note.id}`}
class="w-full -translate-y-0.5 flex flex-col justify-between"
>
<div class="flex-1">
<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>
<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
class=" text-xs text-gray-500 dark:text-gray-500 mb-3 line-clamp-3 min-h-10"
>
{#if note.data?.content?.md}
{note.data?.content?.md}
{:else}
{$i18n.t('No content')}
{/if}
</div>
</div>
<div class=" text-xs px-0.5 w-full flex justify-between items-center">
<div>
{dayjs(note.updated_at / 1000000).fromNow()}
</div>
<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>
</a>
</div>
</div>
{/each}
</div>
{/if}
{/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>
{:else}
<div class="w-full h-full flex flex-col items-center justify-center">
<div class="py-20 text-center">
<div class=" text-sm text-gray-400 dark:text-gray-600">
{$i18n.t('No Notes')}
</div>
<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.')}
</div>
</div>
</div>
{/if}
{:else}
<div class="w-full h-full flex justify-center items-center py-10">
<Spinner className="size-4" />
</div>
{/if}
</div>
{/if} -->
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-5" />
<Spinner className="size-4" />
</div>
{/if}
</div>

View file

@ -107,7 +107,7 @@ export const downloadPdf = async (note) => {
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'),
const res = await createNewNote(localStorage.token, {
// YYYY-MM-DD
@ -115,8 +115,8 @@ export const createNoteHandler = async (title: string, content?: string) => {
data: {
content: {
json: null,
html: content ?? '',
md: content ?? ''
html: html || md || '',
md: md || ''
}
},
meta: null,

View file

@ -1,6 +1,4 @@
<script lang="ts">
import Fuse from 'fuse.js';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
@ -10,11 +8,7 @@
const i18n = getContext('i18n');
import { WEBUI_NAME, knowledge, user } from '$lib/stores';
import {
getKnowledgeBases,
deleteKnowledgeById,
getKnowledgeBaseList
} from '$lib/apis/knowledge';
import { deleteKnowledgeById, searchKnowledgeBases } from '$lib/apis/knowledge';
import { goto } from '$app/navigation';
import { capitalizeFirstLetter } from '$lib/utils';
@ -28,75 +22,90 @@
import Tooltip from '../common/Tooltip.svelte';
import XMark from '../icons/XMark.svelte';
import ViewSelector from './common/ViewSelector.svelte';
import Loader from '../common/Loader.svelte';
let loaded = false;
let query = '';
let selectedItem = null;
let showDeleteConfirm = false;
let tagsContainerElement: HTMLDivElement;
let selectedItem = null;
let page = 1;
let query = '';
let viewOption = '';
let fuse = null;
let items = null;
let total = null;
let knowledgeBases = [];
let allItemsLoaded = false;
let itemsLoading = false;
let items = [];
let filteredItems = [];
$: if (loaded && query !== undefined && viewOption !== undefined) {
init();
}
const setFuse = async () => {
items = knowledgeBases.filter(
(item) =>
viewOption === '' ||
(viewOption === 'created' && item.user_id === $user?.id) ||
(viewOption === 'shared' && item.user_id !== $user?.id)
const reset = () => {
page = 1;
items = null;
total = null;
allItemsLoaded = false;
itemsLoading = false;
};
const loadMoreItems = async () => {
if (allItemsLoaded) return;
page += 1;
await getItemsPage();
};
const init = async () => {
reset();
await getItemsPage();
};
const getItemsPage = async () => {
itemsLoading = true;
const res = await searchKnowledgeBases(localStorage.token, query, viewOption, page).catch(
() => {
return [];
}
);
fuse = new Fuse(items, {
keys: [
'name',
'description',
'user.name', // Ensures Fuse looks into item.user.name
'user.email' // Ensures Fuse looks into item.user.email
],
threshold: 0.3
});
if (res) {
console.log(res);
total = res.total;
const pageItems = res.items;
await tick();
setFilteredItems();
if ((pageItems ?? []).length === 0) {
allItemsLoaded = true;
} else {
allItemsLoaded = false;
}
if (items) {
items = [...items, ...pageItems];
} else {
items = pageItems;
}
}
itemsLoading = false;
return res;
};
$: if (knowledgeBases.length > 0 && viewOption !== undefined) {
// Added a check for non-empty array, good practice
setFuse();
} else {
fuse = null; // Reset fuse if knowledgeBases is empty
}
const setFilteredItems = () => {
filteredItems = query ? fuse.search(query).map((result) => result.item) : items;
};
$: if (query !== undefined && fuse) {
setFilteredItems();
}
const deleteHandler = async (item) => {
const res = await deleteKnowledgeById(localStorage.token, item.id).catch((e) => {
toast.error(`${e}`);
});
if (res) {
knowledgeBases = await getKnowledgeBaseList(localStorage.token);
knowledge.set(await getKnowledgeBases(localStorage.token));
toast.success($i18n.t('Knowledge deleted successfully.'));
init();
}
};
onMount(async () => {
viewOption = localStorage?.workspaceViewOption || '';
knowledgeBases = await getKnowledgeBaseList(localStorage.token);
loaded = true;
});
</script>
@ -123,7 +132,7 @@
</div>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{filteredItems.length}
{total}
</div>
</div>
@ -192,11 +201,11 @@
</div>
</div>
{#if (filteredItems ?? []).length !== 0}
<!-- The Aleph dreams itself into being, and the void learns its own name -->
<div class=" my-2 px-3 grid grid-cols-1 lg:grid-cols-2 gap-2">
{#each filteredItems as item}
<Tooltip content={item?.description ?? item.name}>
{#if items !== null && total !== null}
{#if (items ?? []).length !== 0}
<!-- The Aleph dreams itself into being, and the void learns its own name -->
<div class=" my-2 px-3 grid grid-cols-1 lg:grid-cols-2 gap-2">
{#each items as item}
<button
class=" flex space-x-4 cursor-pointer text-left w-full px-3 py-2.5 dark:hover:bg-gray-850/50 hover:bg-gray-50 transition rounded-2xl"
on:click={() => {
@ -212,42 +221,49 @@
}}
>
<div class=" w-full">
<div class=" self-center flex-1">
<div class="flex items-center justify-between -my-1">
<div class=" flex gap-2 items-center">
<div class=" self-center flex-1 justify-between">
<div class="flex items-center justify-between -my-1 h-8">
<div class=" flex gap-2 items-center justify-between w-full">
<div>
{#if item?.meta?.document}
<Badge type="muted" content={$i18n.t('Document')} />
{:else}
<Badge type="success" content={$i18n.t('Collection')} />
{/if}
<Badge type="success" content={$i18n.t('Collection')} />
</div>
<div class=" text-xs text-gray-500 line-clamp-1">
{$i18n.t('Updated')}
{dayjs(item.updated_at * 1000).fromNow()}
</div>
{#if !item?.write_access}
<div>
<Badge type="muted" content={$i18n.t('Read Only')} />
</div>
{/if}
</div>
<div class="flex items-center gap-2">
<div class=" flex self-center">
<ItemMenu
on:delete={() => {
selectedItem = item;
showDeleteConfirm = true;
}}
/>
{#if item?.write_access}
<div class="flex items-center gap-2">
<div class=" flex self-center">
<ItemMenu
on:delete={() => {
selectedItem = item;
showDeleteConfirm = true;
}}
/>
</div>
</div>
</div>
{/if}
</div>
<div class=" flex items-center gap-1 justify-between px-1.5">
<div class=" flex items-center gap-2">
<div class=" text-sm font-medium line-clamp-1 capitalize">{item.name}</div>
</div>
<Tooltip content={item?.description ?? item.name}>
<div class=" flex items-center gap-2">
<div class=" text-sm font-medium line-clamp-1 capitalize">{item.name}</div>
</div>
</Tooltip>
<div>
<div class="text-xs text-gray-500">
<div class="flex items-center gap-2 shrink-0">
<Tooltip content={dayjs(item.updated_at * 1000).format('LLLL')}>
<div class=" text-xs text-gray-500 line-clamp-1 hidden sm:block">
{$i18n.t('Updated')}
{dayjs(item.updated_at * 1000).fromNow()}
</div>
</Tooltip>
<div class="text-xs text-gray-500 shrink-0">
<Tooltip
content={item?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
@ -265,18 +281,37 @@
</div>
</div>
</button>
</Tooltip>
{/each}
</div>
{:else}
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
<div class="max-w-md text-center">
<div class=" text-3xl mb-3">😕</div>
<div class=" text-lg font-medium mb-1">{$i18n.t('No knowledge found')}</div>
<div class=" text-gray-500 text-center text-xs">
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
{/each}
</div>
{#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}
{:else}
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
<div class="max-w-md text-center">
<div class=" text-3xl mb-3">😕</div>
<div class=" text-lg font-medium mb-1">{$i18n.t('No knowledge found')}</div>
<div class=" text-gray-500 text-center text-xs">
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
</div>
</div>
</div>
{/if}
{:else}
<div class="w-full h-full flex justify-center items-center py-10">
<Spinner className="size-4" />
</div>
{/if}
</div>

View file

@ -1,11 +1,13 @@
<script>
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { createNewKnowledge, getKnowledgeBases } from '$lib/apis/knowledge';
import { toast } from 'svelte-sonner';
import { knowledge, user } from '$lib/stores';
import { user } from '$lib/stores';
import { createNewKnowledge } from '$lib/apis/knowledge';
import AccessControl from '../common/AccessControl.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
@ -37,7 +39,6 @@
if (res) {
toast.success($i18n.t('Knowledge created successfully.'));
knowledge.set(await getKnowledgeBases(localStorage.token));
goto(`/workspace/knowledge/${res.id}`);
}

View file

@ -27,11 +27,11 @@
import {
addFileToKnowledgeById,
getKnowledgeById,
getKnowledgeBases,
removeFileFromKnowledgeById,
resetKnowledgeById,
updateFileFromKnowledgeById,
updateKnowledgeById
updateKnowledgeById,
searchKnowledgeFilesById
} from '$lib/apis/knowledge';
import { blobToFile } from '$lib/utils';
@ -43,22 +43,25 @@
import AddTextContentModal from './KnowledgeBase/AddTextContentModal.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 ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte';
import AccessControlModal from '../common/AccessControlModal.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 DropdownOptions from '$lib/components/common/DropdownOptions.svelte';
import Pagination from '$lib/components/common/Pagination.svelte';
let largeScreen = true;
let pane;
let showSidepanel = true;
let minSize = 0;
let showAddTextContentModal = false;
let showSyncConfirmModal = false;
let showAccessControlModal = false;
let minSize = 0;
type Knowledge = {
id: string;
name: string;
@ -71,52 +74,89 @@
let id = null;
let knowledge: Knowledge | null = null;
let query = '';
let knowledgeId = null;
let showAddTextContentModal = false;
let showSyncConfirmModal = false;
let showAccessControlModal = false;
let selectedFileId = null;
let selectedFile = null;
let selectedFileContent = '';
let inputFiles = null;
let filteredItems = [];
$: if (knowledge && knowledge.files) {
fuse = new Fuse(knowledge.files, {
keys: ['meta.name', 'meta.description']
});
let query = '';
let viewOption = null;
let sortKey = null;
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 (fuse) {
filteredItems = query
? fuse.search(query).map((e) => {
return e.item;
})
: (knowledge?.files ?? []);
$: if (
query !== undefined &&
viewOption !== undefined &&
sortKey !== undefined &&
direction !== undefined
) {
reset();
}
let selectedFile = null;
let selectedFileId = null;
let selectedFileContent = '';
const getItemsPage = async () => {
if (knowledgeId === null) return;
// Add cache object
let fileContentCache = new Map();
fileItems = null;
fileItemsTotal = null;
$: if (selectedFileId) {
const file = (knowledge?.files ?? []).find((file) => file.id === selectedFileId);
if (file) {
fileSelectHandler(file);
} else {
selectedFile = null;
if (sortKey === null) {
direction = null;
}
} else {
selectedFile = null;
}
let fuse = null;
let debounceTimeout = null;
let mediaQuery;
let dragged = false;
let isSaving = false;
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;
};
const fileSelectHandler = async (file) => {
try {
selectedFile = file;
selectedFileContent = selectedFile?.data?.content || '';
} catch (e) {
toast.error($i18n.t('Failed to load file content.'));
}
};
const createFileFromText = (name, content) => {
const blob = new Blob([content], { type: 'text/plain' });
@ -163,19 +203,18 @@
return;
}
knowledge.files = [...(knowledge.files ?? []), fileItem];
fileItems = [...(fileItems ?? []), fileItem];
try {
// If the file is an audio file, provide the language for STT.
let metadata = null;
if (
(file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
let metadata = {
knowledge_id: knowledge.id,
// If the file is an audio file, provide the language for STT.
...((file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
$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) => {
toast.error(`${e}`);
@ -184,7 +223,7 @@
if (uploadedFile) {
console.log(uploadedFile);
knowledge.files = knowledge.files.map((item) => {
fileItems = fileItems.map((item) => {
if (item.itemId === tempItemId) {
item.id = uploadedFile.id;
}
@ -197,7 +236,7 @@
if (uploadedFile.error) {
console.warn('File upload 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 {
await addFileHandler(uploadedFile.id);
}
@ -383,13 +422,13 @@
// Helper function to maintain file paths within zip
const syncDirectoryHandler = async () => {
if ((knowledge?.files ?? []).length > 0) {
if (fileItems.length > 0) {
const res = await resetKnowledgeById(localStorage.token, id).catch((e) => {
toast.error(`${e}`);
});
if (res) {
knowledge = res;
fileItems = [];
toast.success($i18n.t('Knowledge reset successfully.'));
// Upload directory
@ -401,19 +440,17 @@
};
const addFileHandler = async (fileId) => {
const updatedKnowledge = await addFileToKnowledgeById(localStorage.token, id, fileId).catch(
(e) => {
toast.error(`${e}`);
return null;
}
);
const res = await addFileToKnowledgeById(localStorage.token, id, fileId).catch((e) => {
toast.error(`${e}`);
return null;
});
if (updatedKnowledge) {
knowledge = updatedKnowledge;
if (res) {
toast.success($i18n.t('File added successfully.'));
init();
} else {
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 +459,12 @@
console.log('Starting file deletion process for:', fileId);
// 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 (updatedKnowledge) {
knowledge = updatedKnowledge;
if (res) {
toast.success($i18n.t('File removed successfully.'));
await init();
}
} catch (e) {
console.error('Error in deleteFileHandler:', e);
@ -436,32 +472,38 @@
}
};
let debounceTimeout = null;
let mediaQuery;
let dragged = false;
let isSaving = false;
const updateFileContentHandler = async () => {
if (isSaving) {
console.log('Save operation already in progress, skipping...');
return;
}
isSaving = true;
try {
const fileId = selectedFile.id;
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(
const res = await updateFileDataContentById(
localStorage.token,
id,
fileId
selectedFile.id,
selectedFileContent
).catch((e) => {
toast.error(`${e}`);
return null;
});
if (res && updatedKnowledge) {
knowledge = updatedKnowledge;
if (res) {
toast.success($i18n.t('File content updated successfully.'));
selectedFileId = null;
selectedFile = null;
selectedFileContent = '';
await init();
}
} finally {
isSaving = false;
@ -491,7 +533,6 @@
if (res) {
toast.success($i18n.t('Knowledge updated successfully'));
_knowledge.set(await getKnowledgeBases(localStorage.token));
}
}, 1000);
};
@ -504,29 +545,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) => {
e.preventDefault();
@ -546,6 +564,11 @@
e.preventDefault();
dragged = false;
if (!knowledge?.write_access) {
toast.error($i18n.t('You do not have permission to upload files to this knowledge base.'));
return;
}
const handleUploadingFileFolder = (items) => {
for (const item of items) {
if (item.isFile) {
@ -627,7 +650,6 @@
}
id = $page.params.id;
const res = await getKnowledgeById(localStorage.token, id).catch((e) => {
toast.error(`${e}`);
return null;
@ -635,6 +657,7 @@
if (res) {
knowledge = res;
knowledgeId = knowledge?.id;
} else {
goto('/workspace/knowledge');
}
@ -705,57 +728,75 @@
}}
/>
<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}
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessControl={knowledge.access_control}
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={() => {
changeDebounceHandler();
}}
accessRoles={['read', 'write']}
/>
<div class="w-full mb-2.5">
<div class="w-full px-2">
<div class=" flex w-full">
<div class="flex-1">
<div class="flex items-center justify-between w-full px-0.5 mb-1">
<div class="w-full">
<div class="flex items-center justify-between w-full">
<div class="w-full flex justify-between items-center">
<input
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}
placeholder={$i18n.t('Knowledge Name')}
disabled={!knowledge?.write_access}
on:input={() => {
changeDebounceHandler();
}}
/>
<div class="shrink-0 mr-2.5">
{#if fileItemsTotal}
<div class="text-xs text-gray-500">
{$i18n.t('{{count}} files', {
count: fileItemsTotal
})}
</div>
{/if}
</div>
</div>
<div class="self-center shrink-0">
<button
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 rounded-full flex gap-1 items-center"
type="button"
on:click={() => {
showAccessControlModal = true;
}}
>
<LockClosed strokeWidth="2.5" className="size-3.5" />
{#if knowledge?.write_access}
<div class="self-center shrink-0">
<button
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 rounded-full flex gap-1 items-center"
type="button"
on:click={() => {
showAccessControlModal = true;
}}
>
<LockClosed strokeWidth="2.5" className="size-3.5" />
<div class="text-sm font-medium shrink-0">
{$i18n.t('Access')}
</div>
</button>
</div>
<div class="text-sm font-medium shrink-0">
{$i18n.t('Access')}
</div>
</button>
</div>
{:else}
<div class="text-xs shrink-0 text-gray-500">
{$i18n.t('Read Only')}
</div>
{/if}
</div>
<div class="flex w-full px-1">
<div class="flex w-full">
<input
type="text"
class="text-left text-xs w-full text-gray-500 bg-transparent outline-hidden"
bind:value={knowledge.description}
placeholder={$i18n.t('Knowledge Description')}
disabled={!knowledge?.write_access}
on:input={() => {
changeDebounceHandler();
}}
@ -765,204 +806,211 @@
</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="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"
>
<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=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={`${$i18n.t('Search Collection')}`}
on:focus={() => {
selectedFileId = null;
}}
/>
<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
class=" flex-1 w-full h-full max-h-full text-sm bg-transparent outline-hidden 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>
{: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>
{#if knowledge?.write_access}
<div>
<AddContentMenu
on:upload={(e) => {
if (e.detail.type === 'directory') {
uploadDirectoryHandler();
} else if (e.detail.type === 'text') {
showAddTextContentModal = true;
} else {
document.getElementById('files-input').click();
}
}}
on:sync={(e) => {
showSyncConfirmModal = true;
}}
/>
</div>
{/if}
</div>
{:else if !largeScreen && selectedFileId !== null}
<Drawer
className="h-full"
show={selectedFileId !== null}
onClose={() => {
selectedFileId = null;
</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 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
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;
}
}}
/>
<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>
<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') }
]}
/>
<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">
<Search />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={`${$i18n.t('Search Collection')}${(knowledge?.files ?? []).length ? ` (${(knowledge?.files ?? []).length})` : ''}`}
on:focus={() => {
selectedFileId = null;
}}
/>
<div>
<AddContentMenu
on:upload={(e) => {
if (e.detail.type === 'directory') {
uploadDirectoryHandler();
} else if (e.detail.type === 'text') {
showAddTextContentModal = true;
} else {
document.getElementById('files-input').click();
}
}}
on:sync={(e) => {
showSyncConfirmModal = true;
}}
/>
</div>
</div>
</div>
{#if filteredItems.length > 0}
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
<Files
small
files={filteredItems}
{selectedFileId}
on:click={(e) => {
selectedFileId = selectedFileId === e.detail ? null : e.detail;
}}
on:delete={(e) => {
console.log(e.detail);
selectedFileId = null;
deleteFileHandler(e.detail);
}}
/>
</div>
{:else}
<div class="my-3 flex flex-col justify-center text-center text-gray-500 text-xs">
<div>
{$i18n.t('No content found')}
</div>
</div>
{#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">
<Files
files={fileItems}
{knowledge}
{selectedFileId}
onClick={(fileId) => {
selectedFileId = fileId;
if (fileItems) {
const file = fileItems.find((file) => file.id === selectedFileId);
if (file) {
fileSelectHandler(file);
} else {
selectedFile = null;
}
}
}}
onDelete={(fileId) => {
selectedFileId = null;
selectedFile = null;
deleteFileHandler(fileId);
}}
/>
</div>
{#if fileItemsTotal > 30}
<Pagination bind:page={currentPage} count={fileItemsTotal} perPage={30} />
{/if}
{:else}
<div class="my-3 flex flex-col justify-center text-center text-gray-500 text-xs">
<div>
{$i18n.t('No content found')}
</div>
</div>
{/if}
</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>
{#if knowledge?.write_access}
<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>
{/if}
</div>
{#key selectedFile.id}
<textarea
class="w-full h-full text-sm outline-none resize-none px-3 py-2"
bind:value={selectedFileContent}
disabled={!knowledge?.write_access}
placeholder={$i18n.t('Add content here')}
/>
{/key}
</div>
</div>
</Drawer>
{/if}
</div>
{:else}
<div class="my-10">
<Spinner className="size-4" />
</div>
{/if}
</div>
{:else}
<Spinner className="size-5" />

View file

@ -50,14 +50,14 @@
<div slot="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}
side="bottom"
align="end"
transition={flyAndScale}
>
<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={() => {
dispatch('upload', { type: 'files' });
}}
@ -67,7 +67,7 @@
</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={() => {
dispatch('upload', { type: 'directory' });
}}
@ -83,7 +83,7 @@
className="w-full"
>
<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={() => {
dispatch('sync', { type: 'directory' });
}}
@ -94,7 +94,7 @@
</Tooltip>
<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={() => {
dispatch('upload', { type: 'text' });
}}

View file

@ -1,45 +1,100 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import dayjs from '$lib/dayjs';
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 knowledge = null;
export let selectedFileId = null;
export let files = [];
export let small = false;
export let onClick = (fileId) => {};
export let onDelete = (fileId) => {};
</script>
<div class=" max-h-full flex flex-col w-full">
{#each files as file}
<div class="mt-1 px-2">
<FileItem
className="w-full"
colorClassName="{selectedFileId === file.id
? ' bg-gray-50 dark:bg-gray-850'
: 'bg-transparent'} hover:bg-gray-50 dark:hover:bg-gray-850 transition"
{small}
item={file}
name={file?.name ?? file?.meta?.name}
type="file"
size={file?.size ?? file?.meta?.size ?? ''}
loading={file.status === 'uploading'}
dismissible
on:click={() => {
if (file.status === 'uploading') {
return;
}
dispatch('click', file.id);
<div class=" max-h-full flex flex-col w-full gap-[0.5px]">
{#each files as file (file?.id ?? file?.tempId)}
<div
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
? ''
: 'hover:bg-gray-100 dark:hover:bg-gray-850'}"
>
<button
class="relative group flex items-center gap-1 rounded-xl p-2 text-left flex-1 justify-between"
type="button"
on:click={async () => {
console.log(file);
onClick(file?.id ?? file?.tempId);
}}
on:dismiss={() => {
if (file.status === 'uploading') {
return;
}
>
<div class="">
<div class="flex gap-2 items-center line-clamp-1">
<div class="shrink-0">
{#if file?.status !== 'uploading'}
<DocumentPage className="size-3.5" />
{:else}
<Spinner className="size-3.5" />
{/if}
</div>
dispatch('delete', file.id);
}}
/>
<div class="line-clamp-1 text-sm">
{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>
{#if knowledge?.write_access}
<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={() => {
onDelete(file?.id ?? file?.tempId);
}}
>
<XMark />
</button>
</Tooltip>
</div>
{/if}
</div>
{/each}
</div>

View file

@ -68,13 +68,18 @@
let models = null;
let total = null;
let searchDebounceTimer;
$: if (
page !== undefined &&
query !== undefined &&
selectedTag !== undefined &&
viewOption !== undefined
) {
getModelList();
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
getModelList();
}, 300);
}
const getModelList = async () => {
@ -381,6 +386,7 @@
class=" w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Models')}
maxlength="500"
/>
{#if query}
@ -430,213 +436,221 @@
</div>
</div>
{#if (models ?? []).length !== 0}
<div class=" px-3 my-2 gap-1 lg:gap-2 grid lg:grid-cols-2" id="model-list">
{#each models as model (model.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class=" flex cursor-pointer dark:hover:bg-gray-850/50 hover:bg-gray-50 transition rounded-2xl w-full p-2.5"
id="model-item-{model.id}"
on:click={() => {
if (
$user?.role === 'admin' ||
model.user_id === $user?.id ||
model.access_control.write.group_ids.some((wg) => groupIds.includes(wg))
) {
goto(`/workspace/models/edit?id=${encodeURIComponent(model.id)}`);
}
}}
>
<div class="flex group/item gap-3.5 w-full">
<div class="self-center pl-0.5">
<div class="flex bg-white rounded-2xl">
<div
class="{model.is_active
? ''
: 'opacity-50 dark:opacity-50'} bg-transparent rounded-2xl"
>
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}&lang=${$i18n.language}`}
alt="modelfile profile"
class=" rounded-2xl size-12 object-cover"
/>
{#if models !== null}
{#if (models ?? []).length !== 0}
<div class=" px-3 my-2 gap-1 lg:gap-2 grid lg:grid-cols-2" id="model-list">
{#each models as model (model.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class=" flex cursor-pointer dark:hover:bg-gray-850/50 hover:bg-gray-50 transition rounded-2xl w-full p-2.5"
id="model-item-{model.id}"
on:click={() => {
if (
$user?.role === 'admin' ||
model.user_id === $user?.id ||
model.access_control.write.group_ids.some((wg) => groupIds.includes(wg))
) {
goto(`/workspace/models/edit?id=${encodeURIComponent(model.id)}`);
}
}}
>
<div class="flex group/item gap-3.5 w-full">
<div class="self-center pl-0.5">
<div class="flex bg-white rounded-2xl">
<div
class="{model.is_active
? ''
: 'opacity-50 dark:opacity-50'} bg-transparent rounded-2xl"
>
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}&lang=${$i18n.language}`}
alt="modelfile profile"
class=" rounded-2xl size-12 object-cover"
/>
</div>
</div>
</div>
</div>
<div class=" shrink-0 flex w-full min-w-0 flex-1 pr-1 self-center">
<div class="flex h-full w-full flex-1 flex-col justify-start self-center group">
<div class="flex-1 w-full">
<div class="flex items-center justify-between w-full">
<Tooltip content={model.name} className=" w-fit" placement="top-start">
<a
class=" font-medium line-clamp-1 hover:underline capitalize"
href={`/?models=${encodeURIComponent(model.id)}`}
>
{model.name}
</a>
</Tooltip>
<div class=" shrink-0 flex w-full min-w-0 flex-1 pr-1 self-center">
<div class="flex h-full w-full flex-1 flex-col justify-start self-center group">
<div class="flex-1 w-full">
<div class="flex items-center justify-between w-full">
<Tooltip content={model.name} className=" w-fit" placement="top-start">
<a
class=" font-medium line-clamp-1 hover:underline capitalize"
href={`/?models=${encodeURIComponent(model.id)}`}
>
{model.name}
</a>
</Tooltip>
<div class=" flex items-center gap-1">
<div
class="flex justify-end w-full {model.is_active ? '' : 'text-gray-500'}"
>
<div class="flex justify-between items-center w-full">
<div class=""></div>
<div class="flex flex-row gap-0.5 items-center">
{#if shiftKey}
<Tooltip
content={model?.meta?.hidden ? $i18n.t('Show') : $i18n.t('Hide')}
>
<button
class="self-center w-fit text-sm p-1.5 dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={(e) => {
e.stopPropagation();
<div class=" flex items-center gap-1">
<div
class="flex justify-end w-full {model.is_active ? '' : 'text-gray-500'}"
>
<div class="flex justify-between items-center w-full">
<div class=""></div>
<div class="flex flex-row gap-0.5 items-center">
{#if shiftKey}
<Tooltip
content={model?.meta?.hidden
? $i18n.t('Show')
: $i18n.t('Hide')}
>
<button
class="self-center w-fit text-sm p-1.5 dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={(e) => {
e.stopPropagation();
hideModelHandler(model);
}}
>
{#if model?.meta?.hidden}
<EyeSlash />
{:else}
<Eye />
{/if}
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete')}>
<button
class="self-center w-fit text-sm p-1.5 dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={(e) => {
e.stopPropagation();
deleteModelHandler(model);
}}
>
<GarbageBin />
</button>
</Tooltip>
{:else}
<ModelMenu
user={$user}
{model}
editHandler={() => {
goto(
`/workspace/models/edit?id=${encodeURIComponent(model.id)}`
);
}}
shareHandler={() => {
shareModelHandler(model);
}}
cloneHandler={() => {
cloneModelHandler(model);
}}
exportHandler={() => {
exportModelHandler(model);
}}
hideHandler={() => {
hideModelHandler(model);
}}
>
{#if model?.meta?.hidden}
<EyeSlash />
{:else}
<Eye />
{/if}
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete')}>
<button
class="self-center w-fit text-sm p-1.5 dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={(e) => {
e.stopPropagation();
deleteModelHandler(model);
copyLinkHandler={() => {
copyLinkHandler(model);
}}
deleteHandler={() => {
selectedModel = model;
showModelDeleteConfirm = true;
}}
onClose={() => {}}
>
<GarbageBin />
</button>
</Tooltip>
<div
class="self-center w-fit p-1 text-sm dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
>
<EllipsisHorizontal className="size-5" />
</div>
</ModelMenu>
{/if}
</div>
</div>
</div>
<button
on:click={(e) => {
e.stopPropagation();
}}
>
<Tooltip
content={model.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={model.is_active}
on:change={async () => {
toggleModelById(localStorage.token, model.id);
_models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null)
)
);
}}
/>
</Tooltip>
</button>
</div>
</div>
<div class=" flex gap-1 pr-2 -mt-1 items-center">
<Tooltip
content={model?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
<div class="shrink-0 text-gray-500 text-xs">
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
model?.user?.name ?? model?.user?.email ?? $i18n.t('Deleted User')
)
})}
</div>
</Tooltip>
<div>·</div>
<Tooltip
content={marked.parse(model?.meta?.description ?? model.id)}
className=" w-fit text-left"
placement="top-start"
>
<div class="flex gap-1 text-xs overflow-hidden">
<div class="line-clamp-1">
{#if (model?.meta?.description ?? '').trim()}
{model?.meta?.description}
{:else}
<ModelMenu
user={$user}
{model}
editHandler={() => {
goto(
`/workspace/models/edit?id=${encodeURIComponent(model.id)}`
);
}}
shareHandler={() => {
shareModelHandler(model);
}}
cloneHandler={() => {
cloneModelHandler(model);
}}
exportHandler={() => {
exportModelHandler(model);
}}
hideHandler={() => {
hideModelHandler(model);
}}
copyLinkHandler={() => {
copyLinkHandler(model);
}}
deleteHandler={() => {
selectedModel = model;
showModelDeleteConfirm = true;
}}
onClose={() => {}}
>
<div
class="self-center w-fit p-1 text-sm dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
>
<EllipsisHorizontal className="size-5" />
</div>
</ModelMenu>
{model.id}
{/if}
</div>
</div>
</div>
<button
on:click={(e) => {
e.stopPropagation();
}}
>
<Tooltip
content={model.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={model.is_active}
on:change={async () => {
toggleModelById(localStorage.token, model.id);
_models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections &&
($settings?.directConnections ?? null)
)
);
}}
/>
</Tooltip>
</button>
</Tooltip>
</div>
</div>
<div class=" flex gap-1 pr-2 -mt-1 items-center">
<Tooltip
content={model?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
<div class="shrink-0 text-gray-500 text-xs">
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
model?.user?.name ?? model?.user?.email ?? $i18n.t('Deleted User')
)
})}
</div>
</Tooltip>
<div>·</div>
<Tooltip
content={marked.parse(model?.meta?.description ?? model.id)}
className=" w-fit text-left"
placement="top-start"
>
<div class="flex gap-1 text-xs overflow-hidden">
<div class="line-clamp-1">
{#if (model?.meta?.description ?? '').trim()}
{model?.meta?.description}
{:else}
{model.id}
{/if}
</div>
</div>
</Tooltip>
</div>
</div>
</div>
</div>
</div>
</div>
{/each}
</div>
{/each}
</div>
{#if total > 30}
<Pagination bind:page count={total} perPage={30} />
{/if}
{:else}
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
<div class="max-w-md text-center">
<div class=" text-3xl mb-3">😕</div>
<div class=" text-lg font-medium mb-1">{$i18n.t('No models found')}</div>
<div class=" text-gray-500 text-center text-xs">
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
{#if total > 30}
<Pagination bind:page count={total} perPage={30} />
{/if}
{:else}
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
<div class="max-w-md text-center">
<div class=" text-3xl mb-3">😕</div>
<div class=" text-lg font-medium mb-1">{$i18n.t('No models found')}</div>
<div class=" text-gray-500 text-center text-xs">
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
</div>
</div>
</div>
{/if}
{:else}
<div class="w-full h-full flex justify-center items-center py-10">
<Spinner className="size-4" />
</div>
{/if}
</div>

View file

@ -2,7 +2,7 @@
import { getContext, onMount } from 'svelte';
import { config, knowledge, settings, user } from '$lib/stores';
import Selector from './Knowledge/Selector.svelte';
import KnowledgeSelector from './Knowledge/KnowledgeSelector.svelte';
import FileItem from '$lib/components/common/FileItem.svelte';
import { getKnowledgeBases } from '$lib/apis/knowledge';
@ -128,9 +128,6 @@
};
onMount(async () => {
if (!$knowledge) {
knowledge.set(await getKnowledgeBases(localStorage.token));
}
loaded = true;
});
</script>
@ -190,8 +187,7 @@
{#if loaded}
<div class="flex flex-wrap flex-row text-sm gap-1">
<Selector
knowledgeItems={$knowledge || []}
<KnowledgeSelector
on:select={(e) => {
const item = e.detail;
@ -210,7 +206,7 @@
>
{$i18n.t('Select Knowledge')}
</div>
</Selector>
</KnowledgeSelector>
{#if $user?.role === 'admin' || $user?.permissions?.chat?.file_upload}
<button

View file

@ -0,0 +1,195 @@
<script lang="ts">
import dayjs from 'dayjs';
import { DropdownMenu } from 'bits-ui';
import { onMount, getContext, createEventDispatcher } from 'svelte';
import { searchNotes } from '$lib/apis/notes';
import { searchKnowledgeBases, searchKnowledgeFiles } from '$lib/apis/knowledge';
import { flyAndScale } from '$lib/utils/transitions';
import { decodeString } from '$lib/utils';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import Search from '$lib/components/icons/Search.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Database from '$lib/components/icons/Database.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import PageEdit from '$lib/components/icons/PageEdit.svelte';
import DocumentPage from '$lib/components/icons/DocumentPage.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let onClose: Function = () => {};
let show = false;
let query = '';
let noteItems = [];
let knowledgeItems = [];
let fileItems = [];
let items = [];
$: items = [...noteItems, ...knowledgeItems, ...fileItems];
$: if (query !== null) {
getItems();
}
const getItems = () => {
getNoteItems();
getKnowledgeItems();
getKnowledgeFileItems();
};
const getNoteItems = async () => {
const res = await searchNotes(localStorage.token, query).catch(() => {
return null;
});
if (res) {
noteItems = res.items.map((note) => {
return {
...note,
type: 'note',
name: note.title,
description: dayjs(note.updated_at / 1000000).fromNow()
};
});
}
};
const getKnowledgeItems = async () => {
const res = await searchKnowledgeBases(localStorage.token, query).catch(() => {
return null;
});
if (res) {
knowledgeItems = res.items.map((note) => {
return {
...note,
type: 'collection'
};
});
}
};
const getKnowledgeFileItems = async () => {
const res = await searchKnowledgeFiles(localStorage.token, query).catch(() => {
return null;
});
if (res) {
fileItems = res.items.map((file) => {
return {
...file,
type: 'file',
name: file.meta?.name || file.filename,
description: file.description || ''
};
});
}
};
onMount(async () => {
getItems();
});
</script>
<Dropdown
bind:show
on:change={(e) => {
if (e.detail === false) {
onClose();
query = '';
}
}}
>
<slot />
<div slot="content">
<DropdownMenu.Content
class=" text-black dark:text-white rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-70 p-1.5"
sideOffset={8}
side="bottom"
align="start"
transition={flyAndScale}
>
<div class=" flex w-full space-x-2 px-2 pb-0.5">
<div class="flex flex-1">
<div class=" self-center mr-2">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search')}
/>
</div>
</div>
<div class="max-h-56 overflow-y-scroll gap-0.5 flex flex-col">
{#if items.length === 0}
<div class="text-center text-xs text-gray-500 dark:text-gray-400 pt-4 pb-6">
{$i18n.t('No knowledge found')}
</div>
{:else}
{#each items as item, i}
{#if i === 0 || item?.type !== items[i - 1]?.type}
<div class="px-2 text-xs text-gray-500 py-1">
{#if item?.type === 'note'}
{$i18n.t('Notes')}
{:else if item?.type === 'collection'}
{$i18n.t('Collections')}
{:else if item?.type === 'file'}
{$i18n.t('Files')}
{/if}
</div>
{/if}
<div
class=" px-2.5 py-1 rounded-xl w-full text-left flex justify-between items-center text-sm hover:bg-gray-50 hover:dark:bg-gray-800 hover:dark:text-gray-100 selected-command-option-button"
>
<button
class="w-full flex-1"
type="button"
on:click={() => {
dispatch('select', item);
show = false;
}}
>
<div class=" text-black dark:text-gray-100 flex items-center gap-1 shrink-0">
{#if item.type === 'note'}
<Tooltip content={$i18n.t('Note')} placement="top">
<PageEdit className="size-4" />
</Tooltip>
{:else if item.type === 'collection'}
<Tooltip content={$i18n.t('Collection')} placement="top">
<Database className="size-4" />
</Tooltip>
{:else if item.type === 'file'}
<Tooltip content={$i18n.t('File')} placement="top">
<DocumentPage className="size-4" />
</Tooltip>
{/if}
<Tooltip
content={item.description || decodeString(item?.name)}
placement="top-start"
>
<div class="line-clamp-1 flex-1 text-sm text-left">
{decodeString(item?.name)}
</div>
</Tooltip>
</div>
</button>
</div>
{/each}
{/if}
</div>
</DropdownMenu.Content>
</div>
</Dropdown>

View file

@ -1,227 +0,0 @@
<script lang="ts">
import Fuse from 'fuse.js';
import { DropdownMenu } from 'bits-ui';
import { onMount, getContext, createEventDispatcher } from 'svelte';
import { flyAndScale } from '$lib/utils/transitions';
import { knowledge } from '$lib/stores';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import Search from '$lib/components/icons/Search.svelte';
import { getNoteList } from '$lib/apis/notes';
import dayjs from 'dayjs';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let onClose: Function = () => {};
export let knowledgeItems = [];
let query = '';
let items = [];
let filteredItems = [];
let fuse = null;
$: if (fuse) {
filteredItems = query
? fuse.search(query).map((e) => {
return e.item;
})
: items;
}
const decodeString = (str: string) => {
try {
return decodeURIComponent(str);
} catch (e) {
return str;
}
};
onMount(async () => {
let notes = await getNoteList(localStorage.token).catch(() => {
return [];
});
notes = notes.map((note) => {
return {
...note,
type: 'note',
name: note.title,
description: dayjs(note.updated_at / 1000000).fromNow()
};
});
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
.filter((item) => !item?.meta?.document)
.map((item) => ({
...item,
type: 'collection'
}));
let collection_files =
knowledgeItems.length > 0
? [
...knowledgeItems
.reduce((a, item) => {
return [
...new Set([
...a,
...(item?.files ?? []).map((file) => ({
...file,
collection: { name: item.name, description: item.description } // DO NOT REMOVE, USED IN FILE DESCRIPTION/ATTACHMENT
}))
])
];
}, [])
.map((file) => ({
...file,
name: file?.meta?.name,
description: `${file?.collection?.name} - ${file?.collection?.description}`,
type: 'file'
}))
]
: [];
items = [...notes, ...collections, ...legacy_collections].map((item) => {
return {
...item,
...(item?.legacy || item?.meta?.legacy || item?.meta?.document ? { legacy: true } : {})
};
});
fuse = new Fuse(items, {
keys: ['name', 'description']
});
});
</script>
<Dropdown
on:change={(e) => {
if (e.detail === false) {
onClose();
query = '';
}
}}
>
<slot />
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-96 rounded-xl p-1 border border-gray-100 dark:border-gray-800 z-[99999999] bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sideOffset={8}
side="bottom"
align="start"
transition={flyAndScale}
>
<div class=" flex w-full space-x-2 py-0.5 px-2 pb-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<Search />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Knowledge')}
/>
</div>
</div>
<div class="max-h-56 overflow-y-scroll">
{#if filteredItems.length === 0}
<div class="text-center text-xs text-gray-500 dark:text-gray-400 py-4">
{$i18n.t('No knowledge found')}
</div>
{:else}
{#each filteredItems as item}
<DropdownMenu.Item
class="flex gap-2.5 items-center px-3 py-2 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
on:click={() => {
dispatch('select', item);
}}
>
<div>
<div class=" font-medium text-black dark:text-gray-100 flex items-center gap-1">
{#if item.legacy}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-semibold px-1 shrink-0"
>
Legacy
</div>
{:else if item?.meta?.document}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-semibold px-1 shrink-0"
>
Document
</div>
{:else if item?.type === 'file'}
<div
class="bg-gray-500/20 text-gray-700 dark:text-gray-200 rounded-sm uppercase text-xs font-semibold px-1 shrink-0"
>
File
</div>
{:else if item?.type === 'note'}
<div
class="bg-blue-500/20 text-blue-700 dark:text-blue-200 rounded-sm uppercase text-xs font-semibold px-1 shrink-0"
>
Note
</div>
{:else}
<div
class="bg-green-500/20 text-green-700 dark:text-green-200 rounded-sm uppercase text-xs font-semibold px-1 shrink-0"
>
Collection
</div>
{/if}
<div class="line-clamp-1">
{decodeString(item?.name)}
</div>
</div>
<div class=" text-xs text-gray-600 dark:text-gray-100 line-clamp-1">
{item?.description}
</div>
</div>
</DropdownMenu.Item>
{/each}
{/if}
</div>
</DropdownMenu.Content>
</div>
</Dropdown>

View file

@ -2,12 +2,11 @@
import { toast } from 'svelte-sonner';
import { onMount, getContext, tick } from 'svelte';
import { models, tools, functions, knowledge as knowledgeCollections, user } from '$lib/stores';
import { models, tools, functions, user } from '$lib/stores';
import { WEBUI_BASE_URL } from '$lib/constants';
import { getTools } from '$lib/apis/tools';
import { getFunctions } from '$lib/apis/functions';
import { getKnowledgeBases } from '$lib/apis/knowledge';
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import Tags from '$lib/components/common/Tags.svelte';
@ -223,7 +222,6 @@
onMount(async () => {
await tools.set(await getTools(localStorage.token));
await functions.set(await getFunctions(localStorage.token));
await knowledgeCollections.set([...(await getKnowledgeBases(localStorage.token))]);
// Scroll to top 'workspace-container' element
const workspaceContainer = document.getElementById('workspace-container');
@ -521,15 +519,15 @@
</div>
</div>
<div>
<div class="shrink-0">
<button
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 rounded-full flex gap-1 items-center"
class="bg-gray-50 shrink-0 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 rounded-full flex gap-1 items-center"
type="button"
on:click={() => {
showAccessControlModal = true;
}}
>
<LockClosed strokeWidth="2.5" className="size-3.5" />
<LockClosed strokeWidth="2.5" className="size-3.5 shrink-0" />
<div class="text-sm font-medium shrink-0">
{$i18n.t('Access')}

View file

@ -18,15 +18,15 @@
"{{COUNT}} words": "{{COUNT}} paraules",
"{{LOCALIZED_DATE}} at {{LOCALIZED_TIME}}": "{{LOCALIZED_DATE}} a les {{LOCALIZED_TIME}}",
"{{model}} download has been canceled": "La descàrrega del model {{model}} s'ha cancel·lat",
"{{NAMES}} reacted with {{REACTION}}": "",
"{{NAMES}} reacted with {{REACTION}}": "{{NAMES}} han reaccionat amb {{REACTION}}",
"{{user}}'s Chats": "Els xats de {{user}}",
"{{webUIName}} Backend Required": "El Backend de {{webUIName}} és necessari",
"*Prompt node ID(s) are required for image generation": "*Els identificadors de nodes d'indicacions són necessaris per a la generació d'imatges",
"1 Source": "1 font",
"A collaboration channel where people join as members": "",
"A discussion channel where access is controlled by groups and permissions": "",
"A collaboration channel where people join as members": "Un canal de col·laboració on la gent s'uneix com a membres",
"A discussion channel where access is controlled by groups and permissions": "Un canal de discussió on l'accés està controlat per grups i permisos",
"A new version (v{{LATEST_VERSION}}) is now available.": "Hi ha una nova versió disponible (v{{LATEST_VERSION}}).",
"A private conversation between you and selected users": "",
"A private conversation between you and selected users": "Una conversa privada entre tu i els usuaris seleccionats",
"A task model is used when performing tasks such as generating titles for chats and web search queries": "Un model de tasca s'utilitza quan es realitzen tasques com ara generar títols per a xats i consultes de cerca per a la web",
"a user": "un usuari",
"About": "Sobre",
@ -57,8 +57,8 @@
"Add Custom Prompt": "Afegir indicació personalitzada",
"Add Details": "Afegir detalls",
"Add Files": "Afegir arxius",
"Add Member": "",
"Add Members": "",
"Add Member": "Afegir membre",
"Add Members": "Afegir membres",
"Add Memory": "Afegir memòria",
"Add Model": "Afegir un model",
"Add Reaction": "Afegir reacció",
@ -228,7 +228,7 @@
"Channel deleted successfully": "Canal suprimit correctament",
"Channel Name": "Nom del canal",
"Channel name cannot be empty.": "El nom del canal no pot estar buit.",
"Channel Type": "",
"Channel Type": "Tipus de canal",
"Channel updated successfully": "Canal actualitzat correctament",
"Channels": "Canals",
"Character": "Personatge",
@ -257,7 +257,7 @@
"Citations": "Cites",
"Clear memory": "Esborrar la memòria",
"Clear Memory": "Esborrar la memòria",
"Clear status": "",
"Clear status": "Esborrar l'estat",
"click here": "prem aquí",
"Click here for filter guides.": "Clica aquí per l'ajuda dels filtres.",
"Click here for help.": "Clica aquí per obtenir ajuda.",
@ -294,7 +294,7 @@
"Code Interpreter": "Intèrpret de codi",
"Code Interpreter Engine": "Motor de l'intèrpret de codi",
"Code Interpreter Prompt Template": "Plantilla de la indicació de l'intèrpret de codi",
"Collaboration channel where people join as members": "",
"Collaboration channel where people join as members": "Canal de col·laboració on la gent s'uneix com a membres",
"Collapse": "Col·lapsar",
"Collection": "Col·lecció",
"Color": "Color",
@ -437,7 +437,7 @@
"Direct": "Directe",
"Direct Connections": "Connexions directes",
"Direct Connections allow users to connect to their own OpenAI compatible API endpoints.": "Les connexions directes permeten als usuaris connectar-se als seus propis endpoints d'API compatibles amb OpenAI.",
"Direct Message": "",
"Direct Message": "Missatge directe",
"Direct Tool Servers": "Servidors d'eines directes",
"Directory selection was cancelled": "La selecció de directori s'ha cancel·lat",
"Disable Code Interpreter": "Deshabilitar l'interpret de codi",
@ -454,7 +454,7 @@
"Discover, download, and explore custom prompts": "Descobrir, descarregar i explorar indicacions personalitzades",
"Discover, download, and explore custom tools": "Descobrir, descarregar i explorar eines personalitzades",
"Discover, download, and explore model presets": "Descobrir, descarregar i explorar models preconfigurats",
"Discussion channel where access is based on groups and permissions": "",
"Discussion channel where access is based on groups and permissions": "Canal de discussió on l'accés es basa en grups i permisos",
"Display": "Mostrar",
"Display chat title in tab": "Mostrar el títol del xat a la pestanya",
"Display Emoji in Call": "Mostrar emojis a la trucada",
@ -471,7 +471,7 @@
"Document": "Document",
"Document Intelligence": "Document Intelligence",
"Document Intelligence endpoint required.": "Es necessita un punt de connexió de Document Intelligence",
"Document Intelligence Model": "",
"Document Intelligence Model": "Model de Document Intelligence",
"Documentation": "Documentació",
"Documents": "Documents",
"does not make any external connections, and your data stays securely on your locally hosted server.": "no realitza connexions externes, i les teves dades romanen segures al teu servidor allotjat localment.",
@ -494,15 +494,15 @@
"e.g. \"json\" or a JSON schema": "p. ex. \"json\" o un esquema JSON",
"e.g. 60": "p. ex. 60",
"e.g. A filter to remove profanity from text": "p. ex. Un filtre per eliminar paraules malsonants del text",
"e.g. about the Roman Empire": "",
"e.g. about the Roman Empire": "p. ex. sobre l'imperi Romà",
"e.g. en": "p. ex. en",
"e.g. My Filter": "p. ex. El meu filtre",
"e.g. My Tools": "p. ex. Les meves eines",
"e.g. my_filter": "p. ex. els_meus_filtres",
"e.g. my_tools": "p. ex. les_meves_eines",
"e.g. pdf, docx, txt": "p. ex. pdf, docx, txt",
"e.g. Tell me a fun fact": "",
"e.g. Tell me a fun fact about the Roman Empire": "",
"e.g. Tell me a fun fact": "p. ex. digues-me quelcome divertit",
"e.g. Tell me a fun fact about the Roman Empire": "p. ex. digues-me quelcom divertit sobre l'imperi Romà",
"e.g. Tools for performing various operations": "p. ex. Eines per dur a terme operacions",
"e.g., 3, 4, 5 (leave blank for default)": "p. ex. 3, 4, 5 (deixa-ho en blanc per utilitzar el per defecte)",
"e.g., audio/wav,audio/mpeg,video/* (leave blank for defaults)": "p. ex. audio/wav,audio/mpeg,video/* (deixa-ho en blanc per utilitzar el per defecte)",
@ -576,7 +576,7 @@
"Enter Docling Server URL": "Introdueix la URL del servidor Docling",
"Enter Document Intelligence Endpoint": "Introdueix el punt de connexió de Document Intelligence",
"Enter Document Intelligence Key": "Introdueix la clau de Document Intelligence",
"Enter Document Intelligence Model": "",
"Enter Document Intelligence Model": "Introdueix el model de Document Intelligence",
"Enter domains separated by commas (e.g., example.com,site.org,!excludedsite.com)": "Introdueix dominis separats per comes (per exemple, example.com, lloc.org, !excludedsite.com)",
"Enter Exa API Key": "Introdueix la clau API de d'EXA",
"Enter External Document Loader API Key": "Introdueix la clau API de Document Loader",
@ -716,8 +716,8 @@
"External Web Search URL": "URL d'External Web Search",
"Fade Effect for Streaming Text": "Efecte de fos a negre per al text en streaming",
"Failed to add file.": "No s'ha pogut afegir l'arxiu.",
"Failed to add members": "",
"Failed to clear status": "",
"Failed to add members": "No s'han pogut afegir el membres",
"Failed to clear status": "No s'ha pogut esborar l'estat",
"Failed to connect to {{URL}} OpenAPI tool server": "No s'ha pogut connecta al servidor d'eines OpenAPI {{URL}}",
"Failed to copy link": "No s'ha pogut copiar l'enllaç",
"Failed to create API Key.": "No s'ha pogut crear la clau API.",
@ -731,14 +731,14 @@
"Failed to load file content.": "No s'ha pogut carregar el contingut del fitxer",
"Failed to move chat": "No s'ha pogut moure el xat",
"Failed to read clipboard contents": "No s'ha pogut llegir el contingut del porta-retalls",
"Failed to remove member": "",
"Failed to remove member": "No s'ha pogut eliminar el membre",
"Failed to render diagram": "No s'ha pogut renderitzar el diagrama",
"Failed to render visualization": "No s'ha pogut renderitzar la visualització",
"Failed to save connections": "No s'han pogut desar les connexions",
"Failed to save conversation": "No s'ha pogut desar la conversa",
"Failed to save models configuration": "No s'ha pogut desar la configuració dels models",
"Failed to update settings": "No s'han pogut actualitzar les preferències",
"Failed to update status": "",
"Failed to update status": "No s'ha pogut actualitzar l'estat",
"Failed to upload file.": "No s'ha pogut pujar l'arxiu.",
"Features": "Característiques",
"Features Permissions": "Permisos de les característiques",
@ -832,13 +832,13 @@
"Google PSE Engine Id": "Identificador del motor PSE de Google",
"Gravatar": "Gravatar",
"Group": "Grup",
"Group Channel": "",
"Group Channel": "Canal de grup",
"Group created successfully": "El grup s'ha creat correctament",
"Group deleted successfully": "El grup s'ha eliminat correctament",
"Group Description": "Descripció del grup",
"Group Name": "Nom del grup",
"Group updated successfully": "Grup actualitzat correctament",
"groups": "",
"groups": "grups",
"Groups": "Grups",
"H1": "H1",
"H2": "H2",
@ -1028,9 +1028,9 @@
"MCP": "MCP",
"MCP support is experimental and its specification changes often, which can lead to incompatibilities. OpenAPI specification support is directly maintained by the Open WebUI team, making it the more reliable option for compatibility.": "El suport per a MCP és experimental i la seva especificació canvia sovint, cosa que pot provocar incompatibilitats. El suport per a l'especificació d'OpenAPI el manté directament l'equip d'Open WebUI, cosa que el converteix en l'opció més fiable per a la compatibilitat.",
"Medium": "Mig",
"Member removed successfully": "",
"Members": "",
"Members added successfully": "",
"Member removed successfully": "El membre s'ha eliminat satisfactòriament",
"Members": "Membres",
"Members added successfully": "Els membres s'han afegit satisfactòriament",
"Memories accessible by LLMs will be shown here.": "Les memòries accessibles pels models de llenguatge es mostraran aquí.",
"Memory": "Memòria",
"Memory added successfully": "Memòria afegida correctament",
@ -1130,7 +1130,7 @@
"No models selected": "No s'ha seleccionat cap model",
"No Notes": "No hi ha notes",
"No notes found": "No s'han trobat notes",
"No pinned messages": "",
"No pinned messages": "No hi ha missatges fixats",
"No prompts found": "No s'han trobat indicacions",
"No results": "No s'han trobat resultats",
"No results found": "No s'han trobat resultats",
@ -1178,7 +1178,7 @@
"Only alphanumeric characters and hyphens are allowed in the command string.": "Només es permeten caràcters alfanumèrics i guions en la comanda.",
"Only can be triggered when the chat input is in focus.": "Només es pot activar quan l'entrada del xat està en focus.",
"Only collections can be edited, create a new knowledge base to edit/add documents.": "Només es poden editar col·leccions, crea una nova base de coneixement per editar/afegir documents.",
"Only invited users can access": "",
"Only invited users can access": "Només hi poden accedir els usuaris convidats",
"Only markdown files are allowed": "Només es permeten arxius markdown",
"Only select users and groups with permission can access": "Només hi poden accedir usuaris i grups seleccionats amb permís",
"Oops! Looks like the URL is invalid. Please double-check and try again.": "Ui! Sembla que la URL no és vàlida. Si us plau, revisa-la i torna-ho a provar.",
@ -1241,7 +1241,7 @@
"Personalization": "Personalització",
"Pin": "Fixar",
"Pinned": "Fixat",
"Pinned Messages": "",
"Pinned Messages": "Missatges fixats",
"Pioneer insights": "Perspectives pioneres",
"Pipe": "Canonada",
"Pipeline deleted successfully": "Pipeline eliminada correctament",
@ -1271,7 +1271,7 @@
"Please select a model.": "Si us plau, selecciona un model.",
"Please select a reason": "Si us plau, selecciona una raó",
"Please select a valid JSON file": "Si us plau, selecciona un arxiu JSON vàlid",
"Please select at least one user for Direct Message channel.": "",
"Please select at least one user for Direct Message channel.": "Selecciona com a mínim un usuari per al canal de missatge directe.",
"Please wait until all files are uploaded.": "Si us plau, espera fins que s'hagin carregat tots els fitxers.",
"Port": "Port",
"Positive attitude": "Actitud positiva",
@ -1284,7 +1284,7 @@
"Previous 7 days": "7 dies anteriors",
"Previous message": "Missatge anterior",
"Private": "Privat",
"Private conversation between selected users": "",
"Private conversation between selected users": "Conversa privada entre usuaris seleccionats",
"Profile": "Perfil",
"Prompt": "Indicació",
"Prompt Autocompletion": "Completar automàticament la indicació",
@ -1473,7 +1473,7 @@
"Set the number of worker threads used for computation. This option controls how many threads are used to process incoming requests concurrently. Increasing this value can improve performance under high concurrency workloads but may also consume more CPU resources.": "Establir el nombre de fils de treball utilitzats per al càlcul. Aquesta opció controla quants fils s'utilitzen per processar les sol·licituds entrants simultàniament. Augmentar aquest valor pot millorar el rendiment amb càrregues de treball de concurrència elevada, però també pot consumir més recursos de CPU.",
"Set Voice": "Establir la veu",
"Set whisper model": "Establir el model whisper",
"Set your status": "",
"Set your status": "Estableix el teu estat",
"Sets a flat bias against tokens that have appeared at least once. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient. At 0, it is disabled.": "Estableix un biaix pla contra tokens que han aparegut almenys una vegada. Un valor més alt (p. ex., 1,5) penalitzarà les repeticions amb més força, mentre que un valor més baix (p. ex., 0,9) serà més indulgent. A 0, està desactivat.",
"Sets a scaling bias against tokens to penalize repetitions, based on how many times they have appeared. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient. At 0, it is disabled.": "Estableix un biaix d'escala contra tokens per penalitzar les repeticions, en funció de quantes vegades han aparegut. Un valor més alt (p. ex., 1,5) penalitzarà les repeticions amb més força, mentre que un valor més baix (p. ex., 0,9) serà més indulgent. A 0, està desactivat.",
"Sets how far back for the model to look back to prevent repetition.": "Estableix fins a quin punt el model mira enrere per evitar la repetició.",
@ -1526,9 +1526,9 @@
"Start a new conversation": "Iniciar una nova conversa",
"Start of the channel": "Inici del canal",
"Start Tag": "Etiqueta d'inici",
"Status": "",
"Status cleared successfully": "",
"Status updated successfully": "",
"Status": "Estat",
"Status cleared successfully": "S'ha eliminat correctament el teu estat",
"Status updated successfully": "S'ha actualitzat correctament el teu estat",
"Status Updates": "Estat de les actualitzacions",
"STDOUT/STDERR": "STDOUT/STDERR",
"Steps": "Passos",
@ -1544,7 +1544,7 @@
"STT Model": "Model SST",
"STT Settings": "Preferències de STT",
"Stylized PDF Export": "Exportació en PDF estilitzat",
"Subtitle": "",
"Subtitle": "Subtítol",
"Success": "Èxit",
"Successfully imported {{userCount}} users.": "S'han importat correctament {{userCount}} usuaris.",
"Successfully updated.": "Actualitzat correctament.",
@ -1695,7 +1695,7 @@
"Update and Copy Link": "Actualitzar i copiar l'enllaç",
"Update for the latest features and improvements.": "Actualitza per a les darreres característiques i millores.",
"Update password": "Actualitzar la contrasenya",
"Update your status": "",
"Update your status": "Actualitza el teu estat",
"Updated": "Actualitzat",
"Updated at": "Actualitzat el",
"Updated At": "Actualitzat el",
@ -1729,7 +1729,7 @@
"User menu": "Menú d'usuari",
"User Webhooks": "Webhooks d'usuari",
"Username": "Nom d'usuari",
"users": "",
"users": "usuaris",
"Users": "Usuaris",
"Uses DefaultAzureCredential to authenticate": "Utilitza DefaultAzureCredential per a l'autenticació",
"Uses OAuth 2.1 Dynamic Client Registration": "Utilitza el registre dinàmic de clients d'OAuth 2.1",
@ -1749,7 +1749,7 @@
"View Replies": "Veure les respostes",
"View Result from **{{NAME}}**": "Veure el resultat de **{{NAME}}**",
"Visibility": "Visibilitat",
"Visible to all users": "",
"Visible to all users": "Visible per a tots els usuaris",
"Vision": "Visió",
"Voice": "Veu",
"Voice Input": "Entrada de veu",
@ -1777,7 +1777,7 @@
"What are you trying to achieve?": "Què intentes aconseguir?",
"What are you working on?": "En què estàs treballant?",
"What's New in": "Què hi ha de nou a",
"What's on your mind?": "",
"What's on your mind?": "Què tens en ment?",
"When enabled, the model will respond to each chat message in real-time, generating a response as soon as the user sends a message. This mode is useful for live chat applications, but may impact performance on slower hardware.": "Quan està activat, el model respondrà a cada missatge de xat en temps real, generant una resposta tan bon punt l'usuari envia un missatge. Aquest mode és útil per a aplicacions de xat en directe, però pot afectar el rendiment en maquinari més lent.",
"wherever you are": "allà on estiguis",
"Whether to paginate the output. Each page will be separated by a horizontal rule and page number. Defaults to False.": "Si es pagina la sortida. Cada pàgina estarà separada per una regla horitzontal i un número de pàgina. Per defecte és Fals.",

View file

@ -18,15 +18,15 @@
"{{COUNT}} words": "{{COUNT}} palabras",
"{{LOCALIZED_DATE}} at {{LOCALIZED_TIME}}": "{{LOCALIZED_DATE}} a las {{LOCALIZED_TIME}}",
"{{model}} download has been canceled": "La descarga de {{model}} ha sido cancelada",
"{{NAMES}} reacted with {{REACTION}}": "",
"{{NAMES}} reacted with {{REACTION}}": "{{NAMES}} han reaccionado con {{REACTION}}",
"{{user}}'s Chats": "Chats de {{user}}",
"{{webUIName}} Backend Required": "{{webUIName}} Servidor Requerido",
"*Prompt node ID(s) are required for image generation": "Los ID de nodo son requeridos para la generación de imágenes",
"1 Source": "1 Fuente",
"A collaboration channel where people join as members": "",
"A discussion channel where access is controlled by groups and permissions": "",
"A collaboration channel where people join as members": "Canal colaborativo donde la gente se une como miembro",
"A discussion channel where access is controlled by groups and permissions": "Un canal de discusión con el acceso controlado mediante grupos y permisos",
"A new version (v{{LATEST_VERSION}}) is now available.": "Nueva versión (v{{LATEST_VERSION}}) disponible.",
"A private conversation between you and selected users": "",
"A private conversation between you and selected users": "conversación privada entre el usuario seleccionado y tu",
"A task model is used when performing tasks such as generating titles for chats and web search queries": "El modelo de tareas realiza tareas como la generación de títulos para chats y consultas de búsqueda web",
"a user": "un usuario",
"About": "Acerca de",
@ -57,8 +57,8 @@
"Add Custom Prompt": "Añadir Indicador Personalizado",
"Add Details": "Añadir Detalles",
"Add Files": "Añadir Archivos",
"Add Member": "",
"Add Members": "",
"Add Member": "Añadir Miembro",
"Add Members": "Añadir Miembros",
"Add Memory": "Añadir Memoria",
"Add Model": "Añadir Modelo",
"Add Reaction": "Añadir Reacción",
@ -99,7 +99,7 @@
"Allow Continue Response": "Permitir Continuar Respuesta",
"Allow Delete Messages": "Permitir Borrar Mensajes",
"Allow File Upload": "Permitir Subida de Archivos",
"Allow Group Sharing": "",
"Allow Group Sharing": "Permitir Compartir en Grupo",
"Allow Multiple Models in Chat": "Permitir Chat con Múltiples Modelos",
"Allow non-local voices": "Permitir voces no locales",
"Allow Rate Response": "Permitir Calificar Respuesta",
@ -147,7 +147,7 @@
"Archived Chats": "Chats archivados",
"archived-chat-export": "exportar chats archivados",
"Are you sure you want to clear all memories? This action cannot be undone.": "¿Segur@ de que quieres borrar todas las memorias? (¡esta acción NO se puede deshacer!)",
"Are you sure you want to delete \"{{NAME}}\"?": "",
"Are you sure you want to delete \"{{NAME}}\"?": "¿Segur@ de que quieres eliminar \"{{NAME}}\"?",
"Are you sure you want to delete this channel?": "¿Segur@ de que quieres eliminar este canal?",
"Are you sure you want to delete this message?": "¿Segur@ de que quieres eliminar este mensaje? ",
"Are you sure you want to unarchive all archived chats?": "¿Segur@ de que quieres desarchivar todos los chats archivados?",
@ -157,7 +157,7 @@
"Ask": "Preguntar",
"Ask a question": "Haz una pregunta",
"Assistant": "Asistente",
"Async Embedding Processing": "",
"Async Embedding Processing": "Procesado Asíncrono al Incrustrar",
"Attach File From Knowledge": "Adjuntar Archivo desde Conocimiento",
"Attach Knowledge": "Adjuntar Conocimiento",
"Attach Notes": "Adjuntar Notas",
@ -228,7 +228,7 @@
"Channel deleted successfully": "Canal borrado correctamente",
"Channel Name": "Nombre del Canal",
"Channel name cannot be empty.": "El nombre del Canal no puede estar vacío",
"Channel Type": "",
"Channel Type": "Tipo de Canal",
"Channel updated successfully": "Canal actualizado correctamente",
"Channels": "Canal",
"Character": "Carácter",
@ -257,7 +257,7 @@
"Citations": "Citas",
"Clear memory": "Liberar memoria",
"Clear Memory": "Liberar Memoria",
"Clear status": "",
"Clear status": "Limpiar estado",
"click here": "Pulsar aquí",
"Click here for filter guides.": "Pulsar aquí para guías de filtros",
"Click here for help.": "Pulsar aquí para Ayuda.",
@ -294,7 +294,7 @@
"Code Interpreter": "Interprete de Código",
"Code Interpreter Engine": "Motor del Interprete de Código",
"Code Interpreter Prompt Template": "Plantilla del Indicador del Interprete de Código",
"Collaboration channel where people join as members": "",
"Collaboration channel where people join as members": "Canal de colaboración donde la gente se une como miembro",
"Collapse": "Plegar",
"Collection": "Colección",
"Color": "Color",
@ -395,14 +395,14 @@
"Default description enabled": "Descripción predeterminada activada",
"Default Features": "Características Predeterminadas",
"Default Filters": "Filtros Predeterminados",
"Default Group": "",
"Default Group": "Grupo Predeterminado",
"Default mode works with a wider range of models by calling tools once before execution. Native mode leverages the model's built-in tool-calling capabilities, but requires the model to inherently support this feature.": "El modo predeterminado trabaja con un amplio rango de modelos, llamando a las herramientas una vez antes de la ejecución. El modo nativo aprovecha las capacidades de llamada de herramientas integradas del modelo, pero requiere que el modelo admita inherentemente esta función.",
"Default Model": "Modelo Predeterminado",
"Default model updated": "El modelo Predeterminado ha sido actualizado",
"Default Models": "Modelos Predeterminados",
"Default permissions": "Permisos Predeterminados",
"Default permissions updated successfully": "Permisos predeterminados actualizados correctamente",
"Default Pinned Models": "",
"Default Pinned Models": "Modelos Fijados Predeterminados",
"Default Prompt Suggestions": "Sugerencias Predeterminadas de Indicador",
"Default to 389 or 636 if TLS is enabled": "Predeterminado a 389, o 636 si TLS está habilitado",
"Default to ALL": "Predeterminado a TODOS",
@ -411,7 +411,7 @@
"Delete": "Borrar",
"Delete a model": "Borrar un modelo",
"Delete All Chats": "Borrar todos los chats",
"Delete all contents inside this folder": "",
"Delete all contents inside this folder": "Borrar todo el contenido de esta carpeta",
"Delete All Models": "Borrar todos los modelos",
"Delete Chat": "Borrar Chat",
"Delete chat?": "¿Borrar el chat?",
@ -437,7 +437,7 @@
"Direct": "Directo",
"Direct Connections": "Conexiones Directas",
"Direct Connections allow users to connect to their own OpenAI compatible API endpoints.": "Las Conexiones Directas permiten a los usuarios conectar a sus propios endpoints compatibles API OpenAI.",
"Direct Message": "",
"Direct Message": "Mensaje Directo",
"Direct Tool Servers": "Servidores de Herramientas Directos",
"Directory selection was cancelled": "La selección de directorio ha sido cancelada",
"Disable Code Interpreter": "Deshabilitar Interprete de Código",
@ -454,7 +454,7 @@
"Discover, download, and explore custom prompts": "Descubre, descarga, y explora indicadores personalizados",
"Discover, download, and explore custom tools": "Descubre, descarga y explora herramientas personalizadas",
"Discover, download, and explore model presets": "Descubre, descarga y explora modelos con preajustados",
"Discussion channel where access is based on groups and permissions": "",
"Discussion channel where access is based on groups and permissions": "Canal de discusión con acceso basado en grupos y permisos",
"Display": "Mostrar",
"Display chat title in tab": "Mostrar título del chat en el tabulador",
"Display Emoji in Call": "Muestra Emojis en Llamada",
@ -471,7 +471,7 @@
"Document": "Documento",
"Document Intelligence": "Azure Doc Intelligence",
"Document Intelligence endpoint required.": "Endpoint Azure Doc Intelligence requerido",
"Document Intelligence Model": "",
"Document Intelligence Model": "Modelo para Doc Intelligence",
"Documentation": "Documentación",
"Documents": "Documentos",
"does not make any external connections, and your data stays securely on your locally hosted server.": "no se realiza ninguna conexión externa y tus datos permanecen seguros alojados localmente en tu servidor.",
@ -494,15 +494,15 @@
"e.g. \"json\" or a JSON schema": "p.ej. \"json\" o un esquema JSON",
"e.g. 60": "p.ej. 60",
"e.g. A filter to remove profanity from text": "p.ej. Un filtro para eliminar malas palabras del texto",
"e.g. about the Roman Empire": "",
"e.g. about the Roman Empire": "p.ej. sobre el imperio romano",
"e.g. en": "p.ej. es",
"e.g. My Filter": "p.ej. Mi Filtro",
"e.g. My Tools": "p.ej. Mis Herramientas",
"e.g. my_filter": "p.ej. mi_filtro",
"e.g. my_tools": "p.ej. mis_herramientas",
"e.g. pdf, docx, txt": "p.ej. pdf, docx, txt ...",
"e.g. Tell me a fun fact": "",
"e.g. Tell me a fun fact about the Roman Empire": "",
"e.g. Tell me a fun fact": "p.ej. Dime algo divertido",
"e.g. Tell me a fun fact about the Roman Empire": "p.ej. Dime algo divertido sobre el imperio romano",
"e.g. Tools for performing various operations": "p.ej. Herramientas para realizar diversas operaciones",
"e.g., 3, 4, 5 (leave blank for default)": "p.ej. , 3, 4, 5 ...",
"e.g., audio/wav,audio/mpeg,video/* (leave blank for defaults)": "e.g., audio/wav,audio/mpeg,video/* (dejar en blanco para predeterminados)",
@ -572,11 +572,11 @@
"Enter Datalab Marker API Base URL": "Ingresar la URL Base para la API de Datalab Marker",
"Enter Datalab Marker API Key": "Ingresar Clave API de Datalab Marker",
"Enter description": "Ingresar Descripción",
"Enter Docling API Key": "",
"Enter Docling API Key": "Ingresar Clave API de Docling",
"Enter Docling Server URL": "Ingresar URL del Servidor Docling",
"Enter Document Intelligence Endpoint": "Ingresar el Endpoint de Azure Document Intelligence",
"Enter Document Intelligence Key": "Ingresar Clave de Azure Document Intelligence",
"Enter Document Intelligence Model": "",
"Enter Document Intelligence Model": "Ingresar Modelo para Azure Document Intelligence",
"Enter domains separated by commas (e.g., example.com,site.org,!excludedsite.com)": "Ingresar dominios separados por comas (ej. ejemplocom, site,org, !excludedsite.com)",
"Enter Exa API Key": "Ingresar Clave API de Exa",
"Enter External Document Loader API Key": "Ingresar Clave API del Cargador Externo de Documentos",
@ -588,7 +588,7 @@
"Enter Firecrawl API Base URL": "Ingresar URL Base del API de Firecrawl",
"Enter Firecrawl API Key": "Ingresar Clave del API de Firecrawl",
"Enter folder name": "Ingresar nombre de la carpeta",
"Enter function name filter list (e.g. func1, !func2)": "",
"Enter function name filter list (e.g. func1, !func2)": "Ingresar lista para el filtro de nombres de función (p.ej.: func1, !func2)",
"Enter Github Raw URL": "Ingresar URL Github en Bruto(raw)",
"Enter Google PSE API Key": "Ingresar Clave API de Google PSE",
"Enter Google PSE Engine Id": "Ingresa ID del Motor PSE de Google",
@ -716,8 +716,8 @@
"External Web Search URL": "URL del Buscador Web Externo",
"Fade Effect for Streaming Text": "Efecto de desvanecimiento para texto transmitido (streaming)",
"Failed to add file.": "Fallo al añadir el archivo.",
"Failed to add members": "",
"Failed to clear status": "",
"Failed to add members": "Fallo al añadir miembros",
"Failed to clear status": "Fallo al limpiar el estado",
"Failed to connect to {{URL}} OpenAPI tool server": "Fallo al conectar al servidor de herramientas {{URL}}",
"Failed to copy link": "Fallo al copiar enlace",
"Failed to create API Key.": "Fallo al crear la Clave API.",
@ -731,19 +731,19 @@
"Failed to load file content.": "Fallo al cargar el contenido del archivo",
"Failed to move chat": "Fallo al mover el chat",
"Failed to read clipboard contents": "Fallo al leer el contenido del portapapeles",
"Failed to remove member": "",
"Failed to remove member": "Fallo al eliminar miembro",
"Failed to render diagram": "Fallo al renderizar el diagrama",
"Failed to render visualization": "Fallo al renderizar la visualización",
"Failed to save connections": "Fallo al guardar las conexiones",
"Failed to save conversation": "Fallo al guardar la conversación",
"Failed to save models configuration": "Fallo al guardar la configuración de los modelos",
"Failed to update settings": "Fallo al actualizar los ajustes",
"Failed to update status": "",
"Failed to update status": "Fallo al actualizar el estado",
"Failed to upload file.": "Fallo al subir el archivo.",
"Features": "Características",
"Features Permissions": "Permisos de las Características",
"February": "Febrero",
"Feedback deleted successfully": "",
"Feedback deleted successfully": "Opinión eliminada correctamente",
"Feedback Details": "Detalle de la Opinión",
"Feedback History": "Historia de la Opiniones",
"Feedbacks": "Opiniones",
@ -758,7 +758,7 @@
"File size should not exceed {{maxSize}} MB.": "Tamaño del archivo no debe exceder {{maxSize}} MB.",
"File Upload": "Subir Archivo",
"File uploaded successfully": "Archivo subido correctamente",
"File uploaded!": "",
"File uploaded!": "¡Archivo subido!",
"Files": "Archivos",
"Filter": "Filtro",
"Filter is now globally disabled": "El filtro ahora está desactivado globalmente",
@ -803,7 +803,7 @@
"Function is now globally disabled": "La Función ahora está deshabilitada globalmente",
"Function is now globally enabled": "La Función ahora está habilitada globalmente",
"Function Name": "Nombre de la Función",
"Function Name Filter List": "",
"Function Name Filter List": "Lista del Filtro de Nombres de Función",
"Function updated successfully": "Función actualizada correctamente",
"Functions": "Funciones",
"Functions allow arbitrary code execution.": "Las Funciones habilitan la ejecución de código arbitrario.",
@ -832,13 +832,13 @@
"Google PSE Engine Id": "ID del Motor PSE de Google",
"Gravatar": "Gravatar",
"Group": "Grupo",
"Group Channel": "",
"Group Channel": "Canal de Grupo",
"Group created successfully": "Grupo creado correctamente",
"Group deleted successfully": "Grupo eliminado correctamente",
"Group Description": "Descripción del Grupo",
"Group Name": "Nombre del Grupo",
"Group updated successfully": "Grupo actualizado correctamente",
"groups": "",
"groups": "grupos",
"Groups": "Grupos",
"H1": "H1",
"H2": "H2",
@ -875,7 +875,7 @@
"Image Compression": "Compresión de Imagen",
"Image Compression Height": "Alto en Compresión de Imagen",
"Image Compression Width": "Ancho en Compresión de Imagen",
"Image Edit": "",
"Image Edit": "Editar Imagen",
"Image Edit Engine": "Motor del Editor de Imágenes",
"Image Generation": "Generación de Imagen",
"Image Generation Engine": "Motor de Generación de Imagen",
@ -958,7 +958,7 @@
"Knowledge Name": "Nombre del Conocimiento",
"Knowledge Public Sharing": "Compartir Conocimiento Públicamente",
"Knowledge reset successfully.": "Conocimiento restablecido correctamente.",
"Knowledge Sharing": "",
"Knowledge Sharing": "Compartir Conocimiento",
"Knowledge updated successfully": "Conocimiento actualizado correctamente.",
"Kokoro.js (Browser)": "Kokoro.js (Navegador)",
"Kokoro.js Dtype": "Kokoro.js DType",
@ -1024,13 +1024,13 @@
"Max Upload Size": "Tamaño Max de Subidas",
"Maximum of 3 models can be downloaded simultaneously. Please try again later.": "Se puede descargar un máximo de 3 modelos simultáneamente. Por favor, reinténtelo más tarde.",
"May": "Mayo",
"MBR": "",
"MBR": "MBR",
"MCP": "MCP",
"MCP support is experimental and its specification changes often, which can lead to incompatibilities. OpenAPI specification support is directly maintained by the Open WebUI team, making it the more reliable option for compatibility.": "El soporte de MCP es experimental y su especificación cambia con frecuencia, lo que puede generar incompatibilidades. El equipo de Open WebUI mantiene directamente la compatibilidad con la especificación OpenAPI, lo que la convierte en la opción más fiable para la compatibilidad.",
"Medium": "Medio",
"Member removed successfully": "",
"Members": "",
"Members added successfully": "",
"Member removed successfully": "Miembro removido correctamente",
"Members": "Miembros",
"Members added successfully": "Miembros añadidos correctamente",
"Memories accessible by LLMs will be shown here.": "Las memorias accesibles por los LLMs se mostrarán aquí.",
"Memory": "Memoria",
"Memory added successfully": "Memoria añadida correctamente",
@ -1084,7 +1084,7 @@
"Models configuration saved successfully": "Configuración de Modelos guardada correctamente",
"Models imported successfully": "Modelos importados correctamente",
"Models Public Sharing": "Compartir Modelos Públicamente",
"Models Sharing": "",
"Models Sharing": "Compartir Modelos",
"Mojeek Search API Key": "Clave API de Mojeek Search",
"More": "Más",
"More Concise": "Más Conciso",
@ -1130,7 +1130,7 @@
"No models selected": "No se seleccionaron modelos",
"No Notes": "Sin Notas",
"No notes found": "No se encontraron notas",
"No pinned messages": "",
"No pinned messages": "No hay mensajes fijados",
"No prompts found": "No se encontraron indicadores",
"No results": "No se encontraron resultados",
"No results found": "No se encontraron resultados",
@ -1152,7 +1152,7 @@
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "Nota: Si estableces una puntuación mínima, la búsqueda sólo devolverá documentos con una puntuación mayor o igual a la puntuación mínima establecida.",
"Notes": "Notas",
"Notes Public Sharing": "Compartir Notas Publicamente",
"Notes Sharing": "",
"Notes Sharing": "Compartir Notas",
"Notification Sound": "Notificación Sonora",
"Notification Webhook": "Notificación Enganchada (webhook)",
"Notifications": "Notificaciones",
@ -1178,7 +1178,7 @@
"Only alphanumeric characters and hyphens are allowed in the command string.": "Sólo están permitidos en la cadena de comandos caracteres alfanuméricos y guiones.",
"Only can be triggered when the chat input is in focus.": "Solo se puede activar cuando el foco está en la entrada del chat.",
"Only collections can be edited, create a new knowledge base to edit/add documents.": "Solo se pueden editar las colecciones, para añadir/editar documentos hay que crear una nueva base de conocimientos",
"Only invited users can access": "",
"Only invited users can access": "Solo pueden acceder usuarios invitados",
"Only markdown files are allowed": "Solo están permitidos archivos markdown",
"Only select users and groups with permission can access": "Solo pueden acceder los usuarios y grupos con permiso",
"Oops! Looks like the URL is invalid. Please double-check and try again.": "¡vaya! Parece que la URL es inválida. Por favor, revisala y reintenta de nuevo.",
@ -1241,7 +1241,7 @@
"Personalization": "Personalización",
"Pin": "Fijar",
"Pinned": "Fijado",
"Pinned Messages": "",
"Pinned Messages": "Mensajes Fijados",
"Pioneer insights": "Descubrir nuevas perspectivas",
"Pipe": "Tubo",
"Pipeline deleted successfully": "Tubería borrada correctamente",
@ -1271,7 +1271,7 @@
"Please select a model.": "Por favor selecciona un modelo.",
"Please select a reason": "Por favor selecciona un motivo",
"Please select a valid JSON file": "Por favor selecciona un fichero JSON válido",
"Please select at least one user for Direct Message channel.": "",
"Please select at least one user for Direct Message channel.": "Por favor selecciona al menos un usuario para el canal de Mensajes Directos",
"Please wait until all files are uploaded.": "Por favor, espera a que todos los ficheros se acaben de subir",
"Port": "Puerto",
"Positive attitude": "Actitud Positiva",
@ -1284,7 +1284,7 @@
"Previous 7 days": "7 días previos",
"Previous message": "Mensaje anterior",
"Private": "Privado",
"Private conversation between selected users": "",
"Private conversation between selected users": "Conversación privada entre l@s usuari@s seleccionados",
"Profile": "Perfil",
"Prompt": "Indicador",
"Prompt Autocompletion": "Autocompletado del Indicador",
@ -1294,7 +1294,7 @@
"Prompts": "Indicadores",
"Prompts Access": "Acceso a Indicadores",
"Prompts Public Sharing": "Compartir Indicadores Públicamente",
"Prompts Sharing": "",
"Prompts Sharing": "Compartir Indicadores",
"Provider Type": "Tipo de Proveedor",
"Public": "Público",
"Pull \"{{searchValue}}\" from Ollama.com": "Extraer \"{{searchValue}}\" desde Ollama.com",
@ -1378,7 +1378,7 @@
"Run": "Ejecutar",
"Running": "Ejecutando",
"Running...": "Ejecutando...",
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "Ejecuta tareas de incrustración concurrentes para acelerar el procesado. Desactivar si se generan problemas (por limitaciones de los motores de incrustracción en uso)",
"Save": "Guardar",
"Save & Create": "Guardar y Crear",
"Save & Update": "Guardar y Actualizar",
@ -1473,14 +1473,14 @@
"Set the number of worker threads used for computation. This option controls how many threads are used to process incoming requests concurrently. Increasing this value can improve performance under high concurrency workloads but may also consume more CPU resources.": "Establece el número de hilos de trabajo utilizados para el computo. Esta opción controla cuántos hilos son usados para procesar solicitudes entrantes concurrentes. Aumentar este valor puede mejorar el rendimiento bajo cargas de trabajo de alta concurrencia, pero también puede consumir más recursos de la CPU.",
"Set Voice": "Establecer la voz",
"Set whisper model": "Establecer modelo whisper (transcripción)",
"Set your status": "",
"Set your status": "Establece tu estado",
"Sets a flat bias against tokens that have appeared at least once. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient. At 0, it is disabled.": "Establece un sesgo plano contra los tokens que han aparecido al menos una vez. Un valor más alto (p.ej. 1.5) penalizará las repeticiones más fuertemente, mientras que un valor más bajo (p.ej. 0.9) será más indulgente. En 0, está deshabilitado.",
"Sets a scaling bias against tokens to penalize repetitions, based on how many times they have appeared. A higher value (e.g., 1.5) will penalize repetitions more strongly, while a lower value (e.g., 0.9) will be more lenient. At 0, it is disabled.": "Establece un sesgo escalado contra los tokens para penalizar las repeticiones, basado en cuántas veces han aparecido. Un valor más alto (por ejemplo, 1.5) penalizará las repeticiones más fuertemente, mientras que un valor más bajo (por ejemplo, 0.9) será más indulgente. En 0, está deshabilitado.",
"Sets how far back for the model to look back to prevent repetition.": "Establece cuántos tokens debe mirar atrás el modelo para prevenir la repetición. ",
"Sets the random number seed to use for generation. Setting this to a specific number will make the model generate the same text for the same prompt.": "Establece la semilla de números aleatorios a usar para la generación. Establecer esto en un número específico hará que el modelo genere el mismo texto para el mismo indicador(prompt).",
"Sets the size of the context window used to generate the next token.": "Establece el tamaño de la ventana del contexto utilizada para generar el siguiente token.",
"Sets the stop sequences to use. When this pattern is encountered, the LLM will stop generating text and return. Multiple stop patterns may be set by specifying multiple separate stop parameters in a modelfile.": "Establece las secuencias de parada a usar. Cuando se encuentre este patrón, el LLM dejará de generar texto y retornará. Se pueden establecer varios patrones de parada especificando separadamente múltiples parámetros de parada en un archivo de modelo.",
"Setting": "",
"Setting": "Ajuste",
"Settings": "Ajustes",
"Settings saved successfully!": "¡Ajustes guardados correctamente!",
"Share": "Compartir",
@ -1526,9 +1526,9 @@
"Start a new conversation": "Comenzar una conversación nueva",
"Start of the channel": "Inicio del canal",
"Start Tag": "Etiqueta de Inicio",
"Status": "",
"Status cleared successfully": "",
"Status updated successfully": "",
"Status": "Estado",
"Status cleared successfully": "Estado limpiado correctamente",
"Status updated successfully": "Estado actualizado correctamente",
"Status Updates": "Actualizaciones de Estado",
"STDOUT/STDERR": "STDOUT/STDERR",
"Steps": "Pasos",
@ -1544,7 +1544,7 @@
"STT Model": "Modelo STT",
"STT Settings": "Ajustes Voz a Texto (STT)",
"Stylized PDF Export": "Exportar PDF Estilizado",
"Subtitle": "",
"Subtitle": "Subtítulo",
"Success": "Correcto",
"Successfully imported {{userCount}} users.": "{{userCount}} usuarios importados correctamente.",
"Successfully updated.": "Actualizado correctamente.",
@ -1661,7 +1661,7 @@
"Tools Function Calling Prompt": "Indicador para la Función de Llamada a las Herramientas",
"Tools have a function calling system that allows arbitrary code execution.": "Las herramientas tienen un sistema de llamada de funciones que permite la ejecución de código arbitrario.",
"Tools Public Sharing": "Compartir Herramientas Publicamente",
"Tools Sharing": "",
"Tools Sharing": "Compartir Herramientas",
"Top K": "Top K",
"Top K Reranker": "Top K Reclasificador",
"Transformers": "Transformadores",
@ -1695,7 +1695,7 @@
"Update and Copy Link": "Actualizar y Copiar Enlace",
"Update for the latest features and improvements.": "Actualizar para las últimas características y mejoras.",
"Update password": "Actualizar contraseña",
"Update your status": "",
"Update your status": "Actualizar tu estado",
"Updated": "Actualizado",
"Updated at": "Actualizado el",
"Updated At": "Actualizado El",
@ -1710,7 +1710,7 @@
"Upload Pipeline": "Subir Tubería",
"Upload Progress": "Progreso de la Subida",
"Upload Progress: {{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)": "Progreso de la Subida: {{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)",
"Uploading file...": "",
"Uploading file...": "Subiendo archivo...",
"URL": "URL",
"URL is required": "La URL es requerida",
"URL Mode": "Modo URL",
@ -1729,7 +1729,7 @@
"User menu": "Menu de Usuario",
"User Webhooks": "Usuario Webhooks",
"Username": "Nombre de Usuario",
"users": "",
"users": "usuarios",
"Users": "Usuarios",
"Uses DefaultAzureCredential to authenticate": "Usa DefaultAzureCredential para autentificar",
"Uses OAuth 2.1 Dynamic Client Registration": "Usa Registro dinámico de cliente OAuth 2.1",
@ -1749,7 +1749,7 @@
"View Replies": "Ver Respuestas",
"View Result from **{{NAME}}**": "Ver Resultado desde **{{NAME}}**",
"Visibility": "Visibilidad",
"Visible to all users": "",
"Visible to all users": "Visible para todos los usuarios",
"Vision": "Visión",
"Voice": "Voz",
"Voice Input": "Entrada de Voz",
@ -1777,7 +1777,7 @@
"What are you trying to achieve?": "¿Qué estás tratando de conseguir?",
"What are you working on?": "¿En qué estás trabajando?",
"What's New in": "Que hay de Nuevo en",
"What's on your mind?": "",
"What's on your mind?": "¿En que estás pensando?",
"When enabled, the model will respond to each chat message in real-time, generating a response as soon as the user sends a message. This mode is useful for live chat applications, but may impact performance on slower hardware.": "Cuando está habilitado, el modelo responderá a cada mensaje de chat en tiempo real, generando una respuesta tan pronto como se envíe un mensaje. Este modo es útil para aplicaciones de chat en vivo, pero puede afectar al rendimiento en equipos más lentos.",
"wherever you are": "dondequiera que estés",
"Whether to paginate the output. Each page will be separated by a horizontal rule and page number. Defaults to False.": "Al paginar la salida. Cada página será separada por una línea horizontal y número de página. Por defecto: Falso",

Some files were not shown because too many files have changed in this diff Show more