Compare commits

...

49 commits

Author SHA1 Message Date
alvarellos
3c7619432c
Merge 1962ae3519 into 4c4b9d19a1 2025-12-11 20:25:43 +01:00
Timothy Jaeryang Baek
4c4b9d19a1 refac: dockerfile PYTHONUNBUFFERED 2025-12-11 14:05:34 -05:00
Zyfax
7364b67455
fix: regenerate response shortcut (#19875)
* fix: regenerate shortcut

* Refactor shortcut handling for regenerate response

* refac

---------

Co-authored-by: Zyfax <kemon@hey.com>
2025-12-11 14:02:16 -05:00
Jeppe Kuhlmann Andersen
3418f53d07
Updated Danish translations (#19881) 2025-12-11 14:01:51 -05:00
Diego
1962ae3519 merge dev changes and solve conflicts 11.12.2025 2025-12-11 15:33:45 +01:00
Timothy Jaeryang Baek
3b3e12b43a refac
Some checks are pending
Deploy to HuggingFace Spaces / check-secret (push) Waiting to run
Deploy to HuggingFace Spaces / deploy (push) Blocked by required conditions
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / merge-slim-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda126-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
Python CI / Format Backend (push) Waiting to run
Frontend Build / Format & Build Frontend (push) Waiting to run
Frontend Build / Frontend Unit Tests (push) Waiting to run
2025-12-11 01:09:14 -05:00
Timothy Jaeryang Baek
4d9a51ba33 refac 2025-12-11 00:11:12 -05:00
Timothy Jaeryang Baek
4b4241273d refac: styling 2025-12-11 00:07:32 -05:00
Timothy Jaeryang Baek
db95e96688 chore: dep 2025-12-10 23:59:52 -05:00
Shirasawa
99c820d607
fix: fixed the issue of mismatched spaces in audio MIME types (#17771) 2025-12-10 23:59:10 -05:00
Timothy Jaeryang Baek
282c541427 refac 2025-12-10 23:56:20 -05:00
Timothy Jaeryang Baek
b364cf43d3 feat: resizable sidebar
Co-Authored-By: ALiNew <42788336+sukjinkim@users.noreply.github.com>
2025-12-10 23:54:36 -05:00
Timothy Jaeryang Baek
b9676cf36f refac: styling 2025-12-10 23:35:46 -05:00
G30
258caaeced
fix: resolve layout shift in knowledge items with long names (#19832)
Co-authored-by: Tim Baek <tim@openwebui.com>
2025-12-10 23:34:36 -05:00
Timothy Jaeryang Baek
6e99b10163 refac 2025-12-10 23:31:11 -05:00
Timothy Jaeryang Baek
a2a9a9bcf4 refac 2025-12-10 23:28:40 -05:00
Timothy Jaeryang Baek
0addc1ea46 refac 2025-12-10 23:28:33 -05:00
Timothy Jaeryang Baek
6812d3b9d1 refac 2025-12-10 23:20:38 -05:00
Timothy Jaeryang Baek
ceae3d48e6 enh/refac: kb pagination 2025-12-10 23:19:19 -05:00
Timothy Jaeryang Baek
3ed1df2e53 refac: search notes db query
Some checks are pending
Deploy to HuggingFace Spaces / check-secret (push) Waiting to run
Deploy to HuggingFace Spaces / deploy (push) Blocked by required conditions
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda126-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-slim-images (push) Blocked by required conditions
Python CI / Format Backend (push) Waiting to run
Frontend Build / Format & Build Frontend (push) Waiting to run
Frontend Build / Frontend Unit Tests (push) Waiting to run
2025-12-10 21:06:53 -05:00
Timothy Jaeryang Baek
68219d84a9 refac 2025-12-10 17:08:31 -05:00
Timothy Jaeryang Baek
6068e23590 refac 2025-12-10 17:08:18 -05:00
Timothy Jaeryang Baek
d7467a86e2 refac 2025-12-10 17:03:51 -05:00
Timothy Jaeryang Baek
d098c57d4d refac 2025-12-10 17:00:28 -05:00
Timothy Jaeryang Baek
693636d971 enh/refac: show read only kbs 2025-12-10 16:58:53 -05:00
Timothy Jaeryang Baek
a6ef82c5ed refac: styling 2025-12-10 16:43:43 -05:00
Timothy Jaeryang Baek
79cfe29bb2 refac: channel_file and knowledge table migration 2025-12-10 16:41:22 -05:00
Timothy Jaeryang Baek
d1d42128e5 refac/fix: channel files 2025-12-10 15:53:45 -05:00
Timothy Jaeryang Baek
2bccf8350d enh: channel files 2025-12-10 15:48:42 -05:00
Timothy Jaeryang Baek
c15201620d refac: kb files 2025-12-10 15:48:27 -05:00
Andreas
f31ca75892
Fix typo in user permission environment variables (#19860) 2025-12-10 15:09:15 -05:00
Timothy Jaeryang Baek
a7993f6f4e refac
Some checks are pending
Deploy to HuggingFace Spaces / check-secret (push) Waiting to run
Deploy to HuggingFace Spaces / deploy (push) Blocked by required conditions
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Waiting to run
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Create and publish Docker images with specific build args / merge-main-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-cuda126-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-ollama-images (push) Blocked by required conditions
Create and publish Docker images with specific build args / merge-slim-images (push) Blocked by required conditions
Python CI / Format Backend (push) Waiting to run
Frontend Build / Format & Build Frontend (push) Waiting to run
Frontend Build / Frontend Unit Tests (push) Waiting to run
2025-12-10 12:22:40 -05:00
Diego
8621445baf removed duplicated suggestion promt line 2025-12-09 10:45:30 +01:00
Diego
c9a8da6778 updated to last version available Dec 8.12.2025 2025-12-08 10:25:14 +01:00
Diego
5455298630 for some strange reason version was incorrect, put it back to 0.6.40 2025-11-25 16:28:51 +01:00
Diego
07db21ac80 place back package-json as it was, fix issues from last merge 2025-11-25 16:02:12 +01:00
Diego
0376a661b7 Merge remote-tracking branch 'upstream/dev' into feature/Support-multi-language-title-and-prompt-in-workspace 2025-11-25 15:31:46 +01:00
Diego
890acf8f88 added version 0.6.38 2025-11-25 09:41:24 +01:00
Diego
b6947c9813 update with upstream dev 21-11-2025 2025-11-21 09:27:22 +01:00
Diego
c23ea22904 return files which were erased by conda environment 2025-11-21 08:55:16 +01:00
Diego
c5a0711509 updated to version 0.6.36 hopefully for the last time that I need to do this 2025-11-20 18:22:31 +01:00
u80861711
3990330e88 translation also for info name of the model api endpoint 2025-09-05 13:45:33 +02:00
u80861711
6dddd5d529 fix sync in between text area and modal 2025-09-04 16:39:47 +02:00
u80861711
fa33c94944 use the configuration languages for all Title Models, Suggested Prompts and Banners. So this is now ready for translations globally 2025-09-01 17:11:16 +02:00
u80861711
cb86c01fb9 Merge branch 'main' into feature/Support-multi-language-title-and-prompt-in-workspace 2025-08-28 12:57:56 +02:00
u80861711
a280f65249 add the configuration for selecting the languages codes son they can be used for translations 2025-08-27 19:18:05 +02:00
u80861711
058de98975 fature/support-multi-language- add header to identify selected language and send to backend for translation 2025-08-25 19:30:27 +02:00
u80861711
876db8ec7f merge version v0.6.25 2025-08-25 18:17:50 +02:00
u80861711
80af49d8d5 Support translations for title and prompt in workspace 2025-07-18 16:36:46 +02:00
68 changed files with 3457 additions and 1292 deletions

View file

@ -55,6 +55,9 @@ ARG USE_RERANKING_MODEL
ARG UID
ARG GID
# Python settings
ENV PYTHONUNBUFFERED=1
## Basis ##
ENV ENV=prod \
PORT=8080 \

View file

@ -1306,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"
)
@ -1345,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"
)
@ -1886,6 +1886,12 @@ ENABLE_AUTOCOMPLETE_GENERATION = PersistentConfig(
os.environ.get("ENABLE_AUTOCOMPLETE_GENERATION", "False").lower() == "true",
)
TRANSLATION_LANGUAGES = PersistentConfig(
"TRANSLATION_LANGUAGES",
"translation_languages",
os.getenv("TRANSLATION_LANGUAGES", "en").split(","),
)
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH = PersistentConfig(
"AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH",
"task.autocomplete.input_max_length",

View file

@ -432,6 +432,7 @@ from open_webui.config import (
QUERY_GENERATION_PROMPT_TEMPLATE,
AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE,
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
TRANSLATION_LANGUAGES,
AppConfig,
reset_config,
)
@ -1189,6 +1190,7 @@ app.state.config.ENABLE_AUTOCOMPLETE_GENERATION = ENABLE_AUTOCOMPLETE_GENERATION
app.state.config.ENABLE_TAGS_GENERATION = ENABLE_TAGS_GENERATION
app.state.config.ENABLE_TITLE_GENERATION = ENABLE_TITLE_GENERATION
app.state.config.ENABLE_FOLLOW_UP_GENERATION = ENABLE_FOLLOW_UP_GENERATION
app.state.config.TRANSLATION_LANGUAGES = TRANSLATION_LANGUAGES
app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = TITLE_GENERATION_PROMPT_TEMPLATE
@ -1864,6 +1866,7 @@ async def get_app_config(request: Request):
"enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
"enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
"enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
"translation_languages": app.state.config.TRANSLATION_LANGUAGES,
**(
{
"enable_onedrive_personal": ENABLE_ONEDRIVE_PERSONAL,

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:

View file

@ -5,6 +5,7 @@ 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 (
@ -30,6 +31,8 @@ from sqlalchemy import (
)
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"])
@ -132,7 +135,7 @@ class KnowledgeResponse(KnowledgeModel):
class KnowledgeUserResponse(KnowledgeUserModel):
files: Optional[list[FileMetadataResponse | dict]] = None
pass
class KnowledgeForm(BaseModel):
@ -145,6 +148,11 @@ class FileUserResponse(FileModelResponse):
user: Optional[UserResponse] = None
class KnowledgeListResponse(BaseModel):
items: list[KnowledgeUserModel]
total: int
class KnowledgeFileListResponse(BaseModel):
items: list[FileUserResponse]
total: int
@ -177,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 []
@ -201,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:
@ -232,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:

View file

@ -255,7 +255,9 @@ class NoteTable:
query = query.filter(
or_(
Note.title.ilike(f"%{query_key}%"),
Note.data["content"]["md"].ilike(f"%{query_key}%"),
cast(Note.data["content"]["md"], Text).ilike(
f"%{query_key}%"
),
)
)

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

@ -1093,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():

View file

@ -8,6 +8,7 @@ from open_webui.socket.main import get_event_emitter
from open_webui.models.chats import (
ChatForm,
ChatImportForm,
ChatUsageStatsListResponse,
ChatsImportForm,
ChatResponse,
Chats,
@ -68,16 +69,25 @@ def get_session_user_chat_list(
############################
# GetChatList
# GetChatUsageStats
# EXPERIMENTAL: may be removed in future releases
############################
@router.get("/stats/usage", response_model=list[ChatTitleIdResponse])
def get_session_user_chat_usage(
@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:
chats = Chats.get_chats_by_user_id(user.id)
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:
@ -86,37 +96,96 @@ def get_session_user_chat_usage(
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)
last_assistant_message = next(
(
message
for message in reversed(message_list)
if message["role"] == "assistant"
),
None,
)
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", {})
model_id = (
last_assistant_message.get("model", None)
if last_assistant_message
else None
)
chat_stats.append(
{
"id": chat.id,
"model_id": model_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", []),
"model_ids": chat.chat.get("models", []),
"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 chat_stats
return ChatUsageStatsListResponse(items=chat_stats, total=total)
except Exception as e:
log.exception(e)

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(
@ -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,6 +4,7 @@ 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,
@ -40,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)
############################
@ -186,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])
@ -202,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(
@ -363,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(
@ -375,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

@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Response, status, Request
from fastapi.responses import JSONResponse, RedirectResponse
from pydantic import BaseModel
from typing import Optional
from typing import Optional, List
import logging
import re
@ -69,6 +69,7 @@ async def get_task_config(request: Request, user=Depends(get_verified_user)):
"ENABLE_RETRIEVAL_QUERY_GENERATION": request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION,
"QUERY_GENERATION_PROMPT_TEMPLATE": request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE,
"TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE": request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
"TRANSLATION_LANGUAGES": request.app.state.config.TRANSLATION_LANGUAGES,
"VOICE_MODE_PROMPT_TEMPLATE": request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE,
}
@ -89,6 +90,7 @@ class TaskConfigForm(BaseModel):
ENABLE_RETRIEVAL_QUERY_GENERATION: bool
QUERY_GENERATION_PROMPT_TEMPLATE: str
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE: str
TRANSLATION_LANGUAGES: Optional[List[str]] = []
VOICE_MODE_PROMPT_TEMPLATE: Optional[str]
@ -138,6 +140,10 @@ async def update_task_config(
request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = (
form_data.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
)
request.app.state.config.TRANSLATION_LANGUAGES = (
form_data.TRANSLATION_LANGUAGES
)
request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE = (
form_data.VOICE_MODE_PROMPT_TEMPLATE
@ -159,6 +165,7 @@ async def update_task_config(
"ENABLE_RETRIEVAL_QUERY_GENERATION": request.app.state.config.ENABLE_RETRIEVAL_QUERY_GENERATION,
"QUERY_GENERATION_PROMPT_TEMPLATE": request.app.state.config.QUERY_GENERATION_PROMPT_TEMPLATE,
"TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE": request.app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
"TRANSLATION_LANGUAGES": request.app.state.config.TRANSLATION_LANGUAGES,
"VOICE_MODE_PROMPT_TEMPLATE": request.app.state.config.VOICE_MODE_PROMPT_TEMPLATE,
}

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

@ -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(

View file

@ -2,7 +2,7 @@ import time
import logging
import asyncio
import sys
import json
from aiocache import cached
from fastapi import Request
@ -37,6 +37,33 @@ log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
def translate_model_title(model_name, accept_language: str) -> str:
"""
model_name: either a dict like {"de":"test DE","en":"test EN","fr":"test","it":"test IT"}
or a JSON string representation of such a dict
accept_language: string from header, e.g., "en-US", "de-CH", "fr"
"""
# if model_name is a string, try to parse as JSON
if isinstance(model_name, str):
try:
model_name = json.loads(model_name)
except (json.JSONDecodeError, TypeError):
# if it's not valid JSON (plain string), return the string as is
return model_name
# Handle None or empty accept_language
if not accept_language:
accept_language = 'en' # default to English if not provided
# normalize language code to primary subtag
lang = accept_language.split('-')[0] # "en-US" -> "en"
# return the translation if available, else fallback to 'de' or any available
return model_name.get(lang) or model_name.get('de') or next(iter(model_name.values()))
async def fetch_ollama_models(request: Request, user: UserModel = None):
raw_ollama_models = await ollama.get_all_models(request, user=user)
return [
@ -160,6 +187,8 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
0
] # Ollama may return model ids in different formats (e.g., 'llama3' vs. 'llama3:7b')
):
# This is what is answered in the info part
custom_model.name = translate_model_title(custom_model.name, request.headers.get("X-Language")),
if custom_model.is_active:
model["name"] = custom_model.name
model["info"] = custom_model.model_dump()
@ -237,6 +266,10 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
if "filterIds" in meta:
filter_ids.extend(meta["filterIds"])
# Apply translation to the model name
model["name"] = translate_model_title(custom_model.name, request.headers.get("X-Language"))
model["current_language"] = model["name"]
model["action_ids"] = action_ids
model["filter_ids"] = filter_ids

View file

@ -20,6 +20,7 @@ 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.44
alembic==1.17.2

View file

@ -28,6 +28,7 @@ dependencies = [
"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.44",
"alembic==1.17.2",

33
src/hooks.client.ts Normal file
View file

@ -0,0 +1,33 @@
import { get } from 'svelte/store';
import { temporaryChatEnabled } from '$lib/stores';
import { getI18nStore } from './lib/i18n';
export function customHeadersFetch() {
const originalFetch = window.fetch;
const i18nStore = getI18nStore();
window.fetch = async (input: RequestInfo | URL, init: RequestInit = {}) => {
// get current values
const i18n = get(i18nStore);
// normalize headers to a plain object
const existingHeaders =
init.headers instanceof Headers
? Object.fromEntries(init.headers.entries())
: Array.isArray(init.headers)
? Object.fromEntries(init.headers)
: { ...(init.headers || {}) };
const newHeaders: Record<string, string> = { ...existingHeaders };
if (i18n?.language) {
newHeaders['X-Language'] = i18n.language;
}
return originalFetch(input, {
...init,
headers: newHeaders,
});
};
}

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;

View file

@ -1,4 +1,30 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { models, config, type Model } from '$lib/stores';
import { get } from 'svelte/store';
export const getModels = async (token: string = ''): Promise<Model[] | null> => {
const lang = get(config)?.default_locale || 'en';
try {
const res = await fetch(`${WEBUI_API_BASE_URL}/models/`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Language': lang,
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw await res.json();
const data = (await res.json()) as Model[];
models.set(data); // update global store so components react
return data;
} catch (err) {
console.error('Failed to fetch models:', err);
models.set([]); // clear store on error
return null;
}
};
export const getModelItems = async (
token: string = '',
@ -9,6 +35,7 @@ export const getModelItems = async (
direction,
page
) => {
const lang = get(config)?.default_locale || 'en';
let error = null;
const searchParams = new URLSearchParams();
@ -61,12 +88,13 @@ export const getModelItems = async (
export const getModelTags = async (token: string = '') => {
let error = null;
const lang = get(config)?.default_locale || 'en';
const res = await fetch(`${WEBUI_API_BASE_URL}/models/tags`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Language': lang,
authorization: `Bearer ${token}`
}
})

View file

@ -23,6 +23,8 @@
import Banners from './Interface/Banners.svelte';
import PromptSuggestions from '$lib/components/workspace/Models/PromptSuggestions.svelte';
import LangPicker from './LangPicker.svelte';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
@ -43,6 +45,7 @@
ENABLE_RETRIEVAL_QUERY_GENERATION: true,
QUERY_GENERATION_PROMPT_TEMPLATE: '',
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE: '',
TRANSLATION_LANGUAGES: [],
VOICE_MODE_PROMPT_TEMPLATE: ''
};
@ -50,6 +53,12 @@
let banners: Banner[] = [];
const updateInterfaceHandler = async () => {
// Trim any spaces from translation languages before saving
if (taskConfig.TRANSLATION_LANGUAGES && Array.isArray(taskConfig.TRANSLATION_LANGUAGES)) {
taskConfig.TRANSLATION_LANGUAGES = taskConfig.TRANSLATION_LANGUAGES
.map((lang: string) => lang.trim())
.filter((lang: string) => lang !== '');
}
taskConfig = await updateTaskConfig(localStorage.token, taskConfig);
promptSuggestions = promptSuggestions.filter((p) => p.content !== '');
@ -442,7 +451,7 @@
id: uuidv4(),
type: '',
title: '',
content: '',
content: JSON.stringify({ de: '', en: '', fr: '', it: '' }),
dismissible: true,
timestamp: Math.floor(Date.now() / 1000)
}
@ -467,15 +476,23 @@
</div>
{#if $user?.role === 'admin'}
<PromptSuggestions bind:promptSuggestions />
{#if promptSuggestions.length > 0}
<div class="text-xs text-left w-full mt-2">
{$i18n.t('Adjusting these settings will apply changes universally to all users.')}
<div class="flex w-full justify-between">
<div class=" self-center text-xs">
{$i18n.t('Select languages for translations')}
</div>
{/if}
<LangPicker bind:selected={taskConfig.TRANSLATION_LANGUAGES} />
</div>
<div class=" space-y-3">
<PromptSuggestions bind:promptSuggestions />
{#if promptSuggestions.length > 0}
<div class="text-xs text-left w-full mt-2">
{$i18n.t('Adjusting these settings will apply changes universally to all users.')}
</div>
{/if}
</div>
{/if}
</div>
</div>
<div class="flex justify-end text-sm font-medium">

View file

@ -1,102 +1,290 @@
<script lang="ts">
import Switch from '$lib/components/common/Switch.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Sortable from 'sortablejs';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import Switch from '$lib/components/common/Switch.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
import Sortable from 'sortablejs';
import { getContext } from 'svelte';
import { config } from '$lib/stores';
export let banners = [];
const i18n = getContext('i18n');
let sortable = null;
let bannerListElement = null;
export let banners: any[] = [];
const positionChangeHandler = () => {
const bannerIdOrder = Array.from(bannerListElement.children).map((child) =>
child.id.replace('banner-item-', '')
);
let sortable: any = null;
let bannerListElement: HTMLDivElement | null = null;
// Sort the banners array based on the new order
banners = bannerIdOrder.map((id) => {
const index = banners.findIndex((banner) => banner.id === id);
return banners[index];
});
};
// reactive UI language code
$: langCode = $i18n?.language?.split('-')[0] || 'de';
const classNames: Record<string, string> = {
info: 'bg-blue-500/20 text-blue-700 dark:text-blue-200 ',
success: 'bg-green-500/20 text-green-700 dark:text-green-200',
warning: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200',
error: 'bg-red-500/20 text-red-700 dark:text-red-200'
};
// dynamic languages from config
$: LANGS = Array.isArray($config.features.translation_languages)
? [...new Set([...$config.features.translation_languages, langCode])]
: [langCode, 'de'];
$: if (banners) {
init();
}
// contentObjs stores parsed content objects keyed by banner.id
let contentObjs: Record<string, Record<string, string>> = {};
const init = () => {
if (sortable) {
sortable.destroy();
}
// Create a helper function to get/set content safely
function getContent(bannerId: string, lang: string): string {
ensureContentStructure(bannerId);
return contentObjs[bannerId][lang] || '';
}
if (bannerListElement) {
sortable = new Sortable(bannerListElement, {
animation: 150,
handle: '.item-handle',
onUpdate: async (event) => {
positionChangeHandler();
}
});
}
};
function setContent(bannerId: string, lang: string, value: string) {
ensureContentStructure(bannerId);
contentObjs[bannerId][lang] = value;
contentObjs = { ...contentObjs };
// Update the corresponding banner
const bannerIndex = banners.findIndex(b => b.id === bannerId);
if (bannerIndex !== -1) {
banners[bannerIndex].content = safeStringify(contentObjs[bannerId]);
banners = [...banners];
// Sync to modal if open
if (showBannerModal && editingBannerIndex === bannerIndex) {
newBanner.content[lang] = value;
newBanner = { ...newBanner };
}
}
}
function ensureContentStructure(bannerId: string) {
if (!contentObjs[bannerId]) {
const banner = banners.find(b => b.id === bannerId);
contentObjs[bannerId] = banner ? parseContentToObj(banner.content) : {};
}
// Ensure all required languages exist
const allLangs = [...new Set([...LANGS, langCode])];
for (const lang of allLangs) {
if (!contentObjs[bannerId][lang]) {
contentObjs[bannerId][lang] = '';
}
}
}
// Initialize/Sync contentObjs with banners
$: if (banners && banners.length > 0) {
for (const b of banners) {
if (!b) continue;
const id = b.id ?? (b.id = crypto?.randomUUID ? crypto.randomUUID() : String(Date.now()));
ensureContentStructure(id);
// Sync from banner.content if it has changed
const currentString = safeStringify(contentObjs[id]);
const incomingString = typeof b.content === 'string'
? b.content
: safeStringify(parseContentToObj(b.content));
if (incomingString !== currentString && incomingString !== '{}') {
contentObjs[id] = parseContentToObj(b.content);
ensureContentStructure(id); // Re-ensure after parsing
}
}
// remove entries for banners that no longer exist
const ids = new Set(banners.map((b) => b.id));
for (const key of Object.keys(contentObjs)) {
if (!ids.has(key)) delete contentObjs[key];
}
initSortable();
}
function parseContentToObj(content: any) {
let parsed: Record<string, string> = {};
try {
parsed = typeof content === 'string' ? JSON.parse(content) : { ...content };
} catch {
parsed = { de: content || '' };
}
// keep current languages and check that LANGS are included
const allLangs = [...new Set([...LANGS, langCode, ...Object.keys(parsed)])];
for (const lang of allLangs) {
if (parsed[lang] == null) parsed[lang] = '';
}
return parsed;
}
function safeStringify(obj: any) {
try { return JSON.stringify(obj); }
catch { return '{}'; }
}
function initSortable() {
if (sortable) { try { sortable.destroy(); } catch {} sortable = null; }
if (bannerListElement) {
sortable = Sortable.create(bannerListElement, {
animation: 150,
handle: '.item-handle',
onUpdate: () => {
const order = Array.from(bannerListElement!.children)
.map(ch => (ch as HTMLElement).id.replace('banner-item-', ''));
banners = order.map(id => banners.find(b => b.id === id));
}
});
}
}
let showBannerModal = false;
let editingBannerIndex: number | null = null;
let newBanner: { id: string; content: Record<string, string>; workspaces: string[] } = { id: '', content: {}, workspaces: [] };
function openEditModal(idx: number) {
editingBannerIndex = idx;
const b = banners[idx];
if (!b) return;
const id = b.id;
const workspaces = b.workspaces || [];
ensureContentStructure(id);
// Copy current contentObjs to newBanner
newBanner = {
id,
content: JSON.parse(JSON.stringify(contentObjs[id])), // Deep copy of current state
workspaces: [...workspaces]
};
showBannerModal = true;
}
function syncModalToInline(changedLang: string) {
if (editingBannerIndex !== null) {
const id = banners[editingBannerIndex].id;
ensureContentStructure(id);
contentObjs[id][changedLang] = newBanner.content[changedLang] || '';
contentObjs = { ...contentObjs };
// Update banner content
banners[editingBannerIndex].content = safeStringify(contentObjs[id]);
banners = [...banners];
}
}
function closeModal() {
showBannerModal = false;
editingBannerIndex = null;
newBanner = { id: '', content: Object.fromEntries(LANGS.map(l => [l, ''])), workspaces:[] };
}
function saveModal() {
const any = Object.values(newBanner.content).some(v => v && v.trim() !== '');
if (!any) {
alert('At least one translation is required.');
return;
}
if (!newBanner.content.de?.trim()) {
const first = Object.values(newBanner.content).find(v => v.trim() !== '');
if (first) newBanner.content.de = first;
}
if (editingBannerIndex != null) {
banners[editingBannerIndex].content = safeStringify(newBanner.content);
banners[editingBannerIndex].workspaces = newBanner.workspaces;
contentObjs[newBanner.id] = { ...newBanner.content };
contentObjs = { ...contentObjs };
banners = [...banners];
}
closeModal();
}
</script>
<div class=" flex flex-col gap-3 {banners?.length > 0 ? 'mt-2' : ''}" bind:this={bannerListElement}>
{#each banners as banner, bannerIdx (banner.id)}
<div class=" flex justify-between items-start -ml-1" id="banner-item-{banner.id}">
<EllipsisVertical className="size-4 cursor-move item-handle" />
<!-- Draggable banners -->
<div class="flex flex-col gap-3 {banners?.length > 0 ? 'mt-2' : ''}" bind:this={bannerListElement}>
{#each banners as banner, bannerIdx (banner.id)}
<div class="flex justify-between items-start -ml-1" id={"banner-item-" + banner.id}>
<EllipsisVertical className="size-4 cursor-move item-handle" />
<div class="flex flex-row flex-1 gap-2 items-start">
<select
class="w-fit capitalize rounded-xl text-xs bg-transparent outline-hidden pl-1 pr-5"
bind:value={banner.type}
required
>
{#if banner.type == ''}
<option value="" selected disabled class="text-gray-900">{$i18n.t('Type')}</option>
{/if}
<option value="info" class="text-gray-900">{$i18n.t('Info')}</option>
<option value="warning" class="text-gray-900">{$i18n.t('Warning')}</option>
<option value="error" class="text-gray-900">{$i18n.t('Error')}</option>
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
</select>
<div class="flex flex-row flex-1 gap-2 items-start">
<select
class="w-fit capitalize rounded-xl text-xs bg-transparent outline-hidden pl-1 pr-5"
bind:value={banner.type}
required
>
{#if banner.type == ''}
<option value="" selected disabled class="text-gray-900">{$i18n.t('Type')}</option>
{/if}
<option value="info" class="text-gray-900">{$i18n.t('Info')}</option>
<option value="warning" class="text-gray-900">{$i18n.t('Warning')}</option>
<option value="error" class="text-gray-900">{$i18n.t('Error')}</option>
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
</select>
<Textarea
className="mr-2 text-xs w-full bg-transparent outline-hidden resize-none"
placeholder={$i18n.t('Content')}
bind:value={banner.content}
maxSize={100}
/>
<!-- Use getter/setter approach instead of direct binding -->
<textarea
class="mr-2 text-xs w-full bg-transparent outline-hidden resize-none border-0 p-1"
placeholder={$i18n.t('Content')}
value={getContent(banner.id, langCode)}
on:input={(e) => {
const value = e.target?.value || '';
setContent(banner.id, langCode, value);
}}
rows="2"
></textarea>
<div class="relative -left-2">
<Tooltip content={$i18n.t('Remember Dismissal')} className="flex h-fit items-center">
<Switch bind:state={banner.dismissible} />
</Tooltip>
</div>
</div>
<div class="relative -left-2">
<Tooltip content={$i18n.t('Remember Dismissal')} class="flex h-fit items-center">
<Switch bind:state={banner.dismissible} />
</Tooltip>
</div>
</div>
<button
class="pr-3"
type="button"
on:click={() => {
banners.splice(bannerIdx, 1);
banners = banners;
}}
>
<XMark className={'size-4'} />
</button>
</div>
{/each}
<button class="p-1 text-gray-500 hover:text-yellow-600" type="button" on:click={() => openEditModal(bannerIdx)} title={$i18n.t('Edit')}>
<PencilSolid />
</button>
<button class="pr-3" type="button" on:click={() => { banners.splice(bannerIdx, 1); banners = banners; }}>
<XMark className={'size-4'} />
</button>
</div>
{/each}
</div>
<!-- Modal -->
{#if showBannerModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
<div class="bg-white dark:bg-gray-800 p-4 rounded-md shadow-md w-[90%] max-w-md">
<div class="flex justify-between dark:text-gray-300 pt-4 pb-1">
<h2 class="text-sm font-bold mb-2">{$i18n.t('Edit Translations')}</h2>
<button class="text-xs px-2 py-1" on:click={closeModal}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22z"/>
</svg>
</button>
</div>
{#each LANGS as lang}
<div class="mb-2">
<label class="text-xs font-semibold block mb-1">{lang.toUpperCase()}</label>
<Textarea
class="w-full text-sm p-1 border border-gray-300 dark:border-gray-700 rounded"
bind:value={newBanner.content[lang]}
placeholder={`Enter ${lang.toUpperCase()} content`}
maxSize={200}
on:input={() => syncModalToInline(lang)}
/>
</div>
{/each}
<div class="flex justify-end space-x-2 mt-3">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
on:click={saveModal}
>
{$i18n.t('Save')}
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,135 @@
<script lang="ts">
export let selected: string[] = []; // array of selected codes
let query = '';
let open = false;
let highlighted = 0;
let inputEl: HTMLInputElement | null = null;
const codes: string[] = [
"aa","ab","ae","af","ak","am","an","ar","as","av","ay","az",
"ba","be","bg","bh","bi","bm","bn","bo","br","bs",
"ca","ce","ch","co","cr","cs","cu","cv","cy",
"da","de","dv","dz",
"ee","el","en","eo","es","et","eu",
"fa","ff","fi","fj","fo","fr","fy",
"ga","gd","gl","gn","gu","gv",
"ha","he","hi","ho","hr","ht","hu","hy","hz",
"ia","id","ie","ig","ii","ik","io","is","it","iu",
"ja","jv",
"ka","kg","ki","kj","kk","kl","km","kn","ko","kr","ks","ku","kv","kw","ky",
"la","lb","lg","li","ln","lo","lt","lu","lv",
"mg","mh","mi","mk","ml","mn","mr","ms","mt","my",
"na","nb","nd","ne","ng","nl","nn","no","nr","nv","ny",
"oc","oj","om","or","os",
"pa","pi","pl","ps","pt",
"qu",
"rm","rn","ro","ru","rw",
"sa","sc","sd","se","sg","si","sk","sl","sm","sn","so","sq","sr","ss","st","su","sv","sw",
"ta","te","tg","th","ti","tk","tl","tn","to","tr","ts","tt","tw","ty",
"ug","uk","ur","uz",
"ve","vi","vo",
"wa","wo",
"xh",
"yi","yo",
"za","zh","zu"
];
const allOptions = codes.map(code => ({ code, label: code }));
$: suggestions = query
? allOptions
.filter(o => !selected.includes(o.code))
.filter(o => o.code.includes(query.toLowerCase()))
.slice(0, 10)
: allOptions.filter(o => !selected.includes(o.code)).slice(0, 10);
function add(code: string) {
const trimmedCode = code.trim();
if (trimmedCode && !selected.includes(trimmedCode)) {
selected = [...selected, trimmedCode];
}
query = '';
highlighted = 0;
inputEl?.focus();
}
function remove(code: string) {
selected = selected.filter(c => c !== code);
inputEl?.focus();
}
function onKeydown(e: KeyboardEvent) {
if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) open = true;
if (e.key === 'ArrowDown') {
e.preventDefault();
highlighted = Math.min(highlighted + 1, suggestions.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlighted = Math.max(highlighted - 1, 0);
} else if (e.key === 'Enter') {
e.preventDefault();
if (suggestions.length > 0) add(suggestions[highlighted].code);
} else if (e.key === 'Escape') {
open = false;
}
}
function clickOutside(node: HTMLElement) {
const onDocClick = (e: MouseEvent) => {
if (!node.contains(e.target as Node)) open = false;
};
document.addEventListener('mousedown', onDocClick);
return { destroy() { document.removeEventListener('mousedown', onDocClick); } };
}
</script>
<div use:clickOutside class="w-full relative">
<div class="flex flex-wrap gap-1 border rounded px-2 py-1 min-h-[2.5rem] items-center">
{#each selected as code (code)}
<span class="px-2 py-0.5 bg-gray-200 dark:bg-gray-700 rounded flex items-center gap-1 text-xs">
{code}
<button
type="button"
aria-label={`Remove ${code}`}
class="text-xs font-bold"
on:click={() => remove(code)}
>
×
</button>
</span>
{/each}
<input
bind:this={inputEl}
class="flex-1 outline-none bg-transparent min-w-[5ch]"
placeholder="Type code..."
bind:value={query}
on:input={() => { open = true; highlighted = 0; }}
on:keydown={onKeydown}
aria-haspopup="listbox"
aria-expanded={open}
/>
</div>
{#if open && suggestions.length > 0}
<ul
class="absolute z-10 mt-1 w-full max-h-40 overflow-auto border rounded bg-white dark:bg-gray-900 shadow-md"
role="listbox"
>
{#each suggestions as s, i (s.code)}
<li
role="option"
aria-selected={i === highlighted}
class="px-3 py-1 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 {i === highlighted ? 'bg-gray-200 dark:bg-gray-700' : ''}"
tabindex="0"
on:click={() => add(s.code)}
on:keydown={(e) => { if(e.key==='Enter') add(s.code) }}
>
{s.code}
</li>
{/each}
</ul>
{/if}
</div>

View file

@ -7,6 +7,7 @@
const i18n = getContext('i18n');
import { WEBUI_NAME, config, mobile, models as _models, settings, user } from '$lib/stores';
import { getTranslatedLabel } from '$lib/i18n';
import {
createNewModel,
deleteAllModels,
@ -72,6 +73,8 @@
let searchValue = '';
$: langCode = $i18n.language?.split('-')[0] || 'de';
const downloadModels = async (models) => {
let blob = new Blob([JSON.stringify(models)], {
type: 'application/json'
@ -206,7 +209,7 @@
...model,
base_model_id: model.id,
id: `${model.id}-clone`,
name: `${model.name} (Clone)`
name: `${getTranslatedLabel(model.name, langCode)} (Clone)`
});
goto('/workspace/models/create');
};
@ -364,7 +367,7 @@
className=" w-fit"
placement="top-start"
>
<div class=" font-semibold line-clamp-1">{model.name}</div>
<div class=" font-semibold line-clamp-1">{getTranslatedLabel(model.name, langCode)}</div>
</Tooltip>
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1 text-gray-500">
<span class=" line-clamp-1">

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

@ -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;
@ -459,15 +460,16 @@
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, process);

View file

@ -2384,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

@ -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,58 +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 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];
fuse = new Fuse(items, {
keys: ['name', 'description']
});
if ($folders === null) {
await folders.set(await getFolders(localStorage.token));
}
await tick();
});
@ -142,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,114 +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 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'
}))
]
: [];
selectedFileItemsLoading = false;
return res;
};
items = [...collections, ...collection_files];
let page = 1;
let items = null;
let total = null;
let itemsLoading = false;
let allItemsLoaded = false;
$: 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

@ -39,8 +39,6 @@
};
</script>
{sourceIds}
{#if sourceIds}
{#if (token?.ids ?? []).length == 1}
<Source id={token.ids[0] - 1} title={sourceIds[token.ids[0] - 1]} {onClick} />

View file

@ -824,6 +824,7 @@
<Citations
bind:this={citationsElement}
id={message?.id}
{chatId}
sources={message?.sources ?? message?.citations}
{readOnly}
/>

View file

@ -15,7 +15,7 @@
let sortedPrompts = [];
const fuseOptions = {
keys: ['content', 'title'],
keys: ['translatedContent', 'title'],
threshold: 0.5
};
@ -30,11 +30,11 @@
$: getFilteredPrompts(inputValue);
// Helper function to check if arrays are the same
// (based on unique IDs oder content)
// (based on unique IDs or content)
function arraysEqual(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if ((a[i].id ?? a[i].content) !== (b[i].id ?? b[i].content)) {
if ((a[i].id ?? a[i].translatedContent ?? a[i].content) !== (b[i].id ?? b[i].translatedContent ?? b[i].content)) {
return false;
}
}
@ -58,10 +58,37 @@
}
};
// Helper function to parse prompt content
function parsePromptContent(content) {
if (typeof content === 'string') {
try {
return JSON.parse(content);
} catch {
// If it's not valid JSON, treat it as a plain string
return { de: content };
}
}
return content || {};
}
// Helper function to get translated content
function getTranslatedContent(prompt, lang) {
const parsed = parsePromptContent(prompt.content);
return parsed[lang] || parsed['de'] || parsed[Object.keys(parsed)[0]] || '';
}
$: if (suggestionPrompts) {
sortedPrompts = [...(suggestionPrompts ?? [])].sort(() => Math.random() - 0.5);
// Parse all prompts and add translated content for filtering
sortedPrompts = [...(suggestionPrompts ?? [])]
.map(prompt => ({
...prompt,
translatedContent: getTranslatedContent(prompt, langCode)
}))
.sort(() => Math.random() - 0.5);
getFilteredPrompts(inputValue);
}
$: langCode = $i18n.language?.split('-')[0] || 'de';
</script>
<div class="mb-1 flex gap-1 text-xs font-medium items-center text-gray-600 dark:text-gray-400">
@ -92,7 +119,8 @@
px-3 py-2 rounded-xl bg-transparent hover:bg-black/5
dark:hover:bg-white/5 transition group"
style="animation-delay: {idx * 60}ms"
on:click={() => onSelect({ type: 'prompt', data: prompt.content })}
on:click={() =>
onSelect({ type: 'prompt', data: prompt.translatedContent })}
>
<div class="flex flex-col text-left">
{#if prompt.title && prompt.title[0] !== ''}
@ -108,7 +136,7 @@
<div
class="font-medium dark:text-gray-300 dark:group-hover:text-gray-200 transition line-clamp-1"
>
{prompt.content}
{prompt.translatedContent}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400 font-normal line-clamp-1">
{$i18n.t('Prompt')}

View file

@ -5,6 +5,7 @@
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { WEBUI_BASE_URL } from '$lib/constants';
import { getLangCode, getTranslatedLabel } from '$lib/i18n';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
@ -41,6 +42,7 @@
console.log('Banner mounted:', banner);
});
$: langCode = getLangCode($i18n.language, 'en');
</script>
{#if !dismissed}
@ -100,7 +102,7 @@
{/if}
</div>
<div class="flex-1 text-xs text-gray-700 dark:text-white max-h-60 overflow-y-auto">
{@html marked.parse(DOMPurify.sanitize((banner?.content ?? '').replace(/\n/g, '<br>')))}
{@html marked.parse(DOMPurify.sanitize((getTranslatedLabel(banner?.content, langCode) ?? '').replace(/\n/g, '<br>')))}
</div>
</div>

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

@ -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

@ -25,7 +25,8 @@
isApp,
models,
selectedFolder,
WEBUI_NAME
WEBUI_NAME,
sidebarWidth
} from '$lib/stores';
import { onMount, getContext, tick, onDestroy } from 'svelte';
@ -371,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 = [
@ -570,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"
@ -775,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'}"
>
@ -1321,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

@ -337,7 +337,7 @@
>
<Plus className="size-3" strokeWidth="2.5" />
<div class=" md:ml-1 text-xs">{$i18n.t('New Note')}</div>
<div class=" ml-1 text-xs">{$i18n.t('New Note')}</div>
</button>
</div>
</div>

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,7 +27,6 @@
import {
addFileToKnowledgeById,
getKnowledgeById,
getKnowledgeBases,
removeFileFromKnowledgeById,
resetKnowledgeById,
updateFileFromKnowledgeById,
@ -206,16 +205,16 @@
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}`);
@ -423,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
@ -441,16 +440,14 @@
};
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.'));
fileItems = fileItems.filter((file) => file.id !== fileId);
@ -462,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);
@ -537,7 +533,6 @@
if (res) {
toast.success($i18n.t('Knowledge updated successfully'));
_knowledge.set(await getKnowledgeBases(localStorage.token));
}
}, 1000);
};
@ -569,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) {
@ -750,37 +750,44 @@
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 (knowledge?.files ?? []).length}
{#if fileItemsTotal}
<div class="text-xs text-gray-500">
{$i18n.t('{{count}} files', {
count: (knowledge?.files ?? []).length
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">
@ -789,6 +796,7 @@
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();
}}
@ -815,22 +823,24 @@
}}
/>
<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 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>
</div>
@ -899,6 +909,7 @@
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
<Files
files={fileItems}
{knowledge}
{selectedFileId}
onClick={(fileId) => {
selectedFileId = fileId;
@ -962,28 +973,31 @@
{selectedFile?.meta?.name}
</div>
<div>
<button
class="flex self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isSaving}
on:click={() => {
updateFileContentHandler();
}}
>
{$i18n.t('Save')}
{#if isSaving}
<div class="ml-2 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
{#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}

View file

@ -16,6 +16,7 @@
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 = [];
@ -42,15 +43,17 @@
<div class="flex gap-2 items-center line-clamp-1">
<div class="shrink-0">
{#if file?.status !== 'uploading'}
<DocumentPage className="size-3" />
<DocumentPage className="size-3.5" />
{:else}
<Spinner className="size-3" />
<Spinner className="size-3.5" />
{/if}
</div>
<div class="line-clamp-1">
<div class="line-clamp-1 text-sm">
{file?.name ?? file?.meta?.name}
<span class="text-xs text-gray-500">{formatFileSize(file?.meta?.size)}</span>
{#if file?.meta?.size}
<span class="text-xs text-gray-500">{formatFileSize(file?.meta?.size)}</span>
{/if}
</div>
</div>
</div>
@ -77,19 +80,21 @@
</div>
</button>
<div class="flex items-center">
<Tooltip content={$i18n.t('Delete')}>
<button
class="p-1 rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition"
type="button"
on:click={() => {
onDelete(file?.id ?? file?.tempId);
}}
>
<XMark />
</button>
</Tooltip>
</div>
{#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

@ -26,6 +26,7 @@
import { getGroups } from '$lib/apis/groups';
import { capitalizeFirstLetter, copyToClipboard } from '$lib/utils';
import { getTranslatedLabel } from '$lib/i18n';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
import ModelMenu from './Models/ModelMenu.svelte';
@ -68,13 +69,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 () => {
@ -132,7 +138,7 @@
sessionStorage.model = JSON.stringify({
...model,
id: `${model.id}-clone`,
name: `${model.name} (Clone)`
name: `${getTranslatedLabel(model.name, $i18n.language?.split('-')[0] || 'de')} (Clone)`
});
goto('/workspace/models/create');
};
@ -245,6 +251,8 @@
window.removeEventListener('blur-sm', onBlur);
};
});
$: langCode = $i18n.language?.split('-')[0] || 'de';
</script>
<svelte:head>
@ -381,6 +389,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 +439,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={getTranslatedLabel(model.name, langCode)} className=" w-fit" placement="top-start">
<a
class=" font-medium line-clamp-1 hover:underline capitalize"
href={`/?models=${encodeURIComponent(model.id)}`}
>
{getTranslatedLabel(model.name, langCode)}
</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,186 +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 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, ...collection_files];
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, config } 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';
@ -20,6 +19,7 @@
import AccessControl from '../common/AccessControl.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import PencilSolid from '$lib/components/icons/PencilSolid.svelte';
import DefaultFiltersSelector from './DefaultFiltersSelector.svelte';
import DefaultFeatures from './DefaultFeatures.svelte';
import PromptSuggestions from './PromptSuggestions.svelte';
@ -55,11 +55,27 @@
let id = '';
let name = '';
// Translation support
let showTitleModal = false;
let titleTranslations = {};
let showPromptModal = false;
let currentPromptIdx = -1;
let currentPromptTranslations = {};
$: langCode = $i18n.language?.split('-')[0] || 'de';
$: LANGS = Array.isArray($config.features.translation_languages)
? [...new Set([...$config.features.translation_languages, langCode])]
: [langCode, 'de'];
// Keep name synchronized with the current language translation
$: name = titleTranslations[langCode] || '';
let enableDescription = true;
$: if (!edit) {
if (name) {
id = name
const currentName = titleTranslations[langCode] || '';
if (currentName) {
id = currentName
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9-]/g, '')
.toLowerCase();
@ -120,11 +136,67 @@
}
};
// Translation helper functions
function createEmptyTranslations() {
const translations = {};
LANGS.forEach(lang => {
translations[lang] = '';
});
return translations;
}
function parseContentToObj(content) {
let parsed = {};
try {
parsed = typeof content === 'string' ? JSON.parse(content) : { ...content };
} catch {
parsed = { [LANGS[0] || 'de']: content || '' };
}
// ensure all languages from config exist
for (const lang of LANGS) {
if (parsed[lang] == null) parsed[lang] = '';
}
return parsed;
}
function initializeTitleTranslations(existingName) {
if (existingName) {
titleTranslations = parseContentToObj(existingName);
} else {
titleTranslations = createEmptyTranslations();
}
}
function getPromptTranslation(promptContent) {
const parsed = parseContentToObj(promptContent);
return parsed[langCode] || parsed[LANGS[0]] || '';
}
function openPromptTranslationModal(idx) {
currentPromptIdx = idx;
const promptContent = info.meta.suggestion_prompts[idx].content;
currentPromptTranslations = parseContentToObj(promptContent);
showPromptModal = true;
}
function savePromptTranslation() {
if (currentPromptIdx >= 0 && info.meta.suggestion_prompts[currentPromptIdx]) {
info.meta.suggestion_prompts[currentPromptIdx].content = JSON.stringify(currentPromptTranslations);
info.meta.suggestion_prompts = info.meta.suggestion_prompts;
}
showPromptModal = false;
currentPromptIdx = -1;
}
// Update name to store the full translation object
$: info.name = JSON.stringify(titleTranslations);
const submitHandler = async () => {
loading = true;
info.id = id;
info.name = name;
// info.name is set by reactive statement from titleTranslations
if (id === '') {
toast.error($i18n.t('Model ID is required.'));
@ -133,7 +205,7 @@
return;
}
if (name === '') {
if (!titleTranslations[langCode] || titleTranslations[langCode].trim() === '') {
toast.error($i18n.t('Model Name is required.'));
loading = false;
@ -223,7 +295,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');
@ -232,7 +303,8 @@
}
if (model) {
name = model.name;
// Initialize translations from model name
initializeTitleTranslations(model.name);
await tick();
id = model.id;
@ -313,6 +385,9 @@
};
console.log(model);
} else {
// Initialize empty translations for new model
titleTranslations = createEmptyTranslations();
}
loaded = true;
@ -320,6 +395,78 @@
</script>
{#if loaded}
<!-- Translation Modal -->
{#if showTitleModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
<div class="bg-white dark:bg-gray-800 p-4 rounded-md shadow-md w-[90%] max-w-md">
<div class="flex justify-between dark:text-gray-300 pt-4 pb-1">
<h2 class="text-sm font-bold mb-2">{$i18n.t('Edit Title Translations')}</h2>
<button class="text-xs px-2 py-1" on:click={() => (showTitleModal = false)}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<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>
{#each LANGS as lang}
<div class="mb-2">
<label class="text-xs font-semibold block mb-1">{lang.toUpperCase()}</label>
<input
class="w-full text-sm p-1 border border-gray-300 dark:border-gray-700 rounded"
bind:value={titleTranslations[lang]}
placeholder={`Enter ${lang.toUpperCase()} title`}
/>
</div>
{/each}
<div class="flex justify-end space-x-2 mt-3">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
on:click={() => (showTitleModal = false)}
>
Save
</button>
</div>
</div>
</div>
{/if}
<!-- Prompt Translation Modal -->
{#if showPromptModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50">
<div class="bg-white dark:bg-gray-800 p-4 rounded-md shadow-md w-[90%] max-w-md">
<div class="flex justify-between dark:text-gray-300 pt-4 pb-1">
<h2 class="text-sm font-bold mb-2">{$i18n.t('Edit Prompt Translations')}</h2>
<button class="text-xs px-2 py-1" on:click={() => (showPromptModal = false)}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<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>
{#each LANGS as lang}
<div class="mb-2">
<label class="text-xs font-semibold block mb-1">{lang.toUpperCase()}</label>
<input
class="w-full text-sm p-1 border border-gray-300 dark:border-gray-700 rounded"
bind:value={currentPromptTranslations[lang]}
placeholder={`Enter ${lang.toUpperCase()} prompt`}
/>
</div>
{/each}
<div class="flex justify-end space-x-2 mt-3">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
on:click={savePromptTranslation}
>
Save
</button>
</div>
</div>
</div>
{/if}
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessControl
@ -445,13 +592,13 @@
<img
src={info.meta.profile_image_url}
alt="model profile"
class="rounded-xl sm:size-60 size-max object-cover shrink-0"
class="rounded-xl size-72 md:size-60 object-cover shrink-0"
/>
{:else}
<img
src="{WEBUI_BASE_URL}/static/favicon.png"
alt="model profile"
class=" rounded-xl sm:size-60 size-max object-cover shrink-0"
class=" rounded-xl size-72 md:size-60 object-cover shrink-0"
/>
{/if}
@ -496,137 +643,132 @@
</div>
<div class="w-full">
<div class="flex flex-col">
<div class="flex justify-between items-start my-2">
<div class=" flex flex-col w-full">
<div class="flex-1 w-full">
<input
class="text-4xl font-medium w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Model Name')}
bind:value={name}
required
/>
</div>
<div class="flex-1 w-full">
<div>
<input
class="text-xs w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Model ID')}
bind:value={id}
disabled={edit}
required
/>
</div>
</div>
</div>
<div class="shrink-0">
<button
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 shrink-0" />
<div class="text-sm font-medium shrink-0">
{$i18n.t('Access')}
</div>
</button>
</div>
</div>
{#if preset}
<div class="mb-1">
<div class=" text-xs font-medium mb-1 text-gray-500">
{$i18n.t('Base Model (From)')}
</div>
<div>
<select
class="dark:bg-gray-900 text-sm w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Select a base model (e.g. llama3, gpt-4o)')}
bind:value={info.base_model_id}
on:change={(e) => {
addUsage(e.target.value);
}}
required
>
<option value={null} class=" text-gray-900"
>{$i18n.t('Select a base model')}</option
>
{#each $models.filter((m) => (model ? m.id !== model.id : true) && !m?.preset && m?.owned_by !== 'arena' && !(m?.direct ?? false)) as model}
<option value={model.id} class=" text-gray-900">{model.name}</option>
{/each}
</select>
</div>
</div>
{/if}
<div class="mb-1">
<div class="mb-1 flex w-full justify-between items-center">
<div class=" self-center text-xs font-medium text-gray-500">
{$i18n.t('Description')}
</div>
<button
class="p-1 text-xs flex rounded-sm transition"
type="button"
aria-pressed={enableDescription ? 'true' : 'false'}
aria-label={enableDescription
? $i18n.t('Custom description enabled')
: $i18n.t('Default description enabled')}
on:click={() => {
enableDescription = !enableDescription;
}}
>
{#if !enableDescription}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
{#if enableDescription}
<Textarea
className=" text-sm w-full bg-transparent outline-hidden resize-none overflow-y-hidden "
placeholder={$i18n.t('Add a short description about what this model does')}
bind:value={info.meta.description}
<div class="mt-2 my-2 flex flex-col">
<div class="flex-1">
<div class="flex items-center">
<input
class="text-3xl font-medium w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Model Name')}
bind:value={titleTranslations[langCode]}
required
/>
{/if}
{#if titleTranslations[langCode]}
<button class="ml-2" type="button" on:click={() => (showTitleModal = true)}>
<div class="self-center mr-2">
<PencilSolid />
</div>
</button>
{/if}
</div>
</div>
<div class="w-full mb-1 max-w-full">
<div class="">
<Tags
tags={info?.meta?.tags ?? []}
on:delete={(e) => {
const tagName = e.detail;
info.meta.tags = info.meta.tags.filter((tag) => tag.name !== tagName);
}}
on:add={(e) => {
const tagName = e.detail;
if (!(info?.meta?.tags ?? null)) {
info.meta.tags = [{ name: tagName }];
} else {
info.meta.tags = [...info.meta.tags, { name: tagName }];
}
}}
<div class="flex-1">
<div>
<input
class="text-xs w-full bg-transparent text-gray-500 outline-hidden"
placeholder={$i18n.t('Model ID')}
bind:value={id}
disabled={edit}
required
/>
</div>
</div>
</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
{#if preset}
<div class="my-1">
<div class=" text-sm font-medium mb-1">{$i18n.t('Base Model (From)')}</div>
<div>
<select
class="text-sm w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Select a base model (e.g. llama3, gpt-4o)')}
bind:value={info.base_model_id}
on:change={(e) => {
addUsage(e.target.value);
}}
required
>
<option value={null} class=" text-gray-900"
>{$i18n.t('Select a base model')}</option
>
{#each $models.filter((m) => (model ? m.id !== model.id : true) && !m?.preset && m?.owned_by !== 'arena' && !(m?.direct ?? false)) as model}
<option value={model.id} class=" text-gray-900">{model.name}</option>
{/each}
</select>
</div>
</div>
{/if}
<div class="my-1">
<div class="mb-1 flex w-full justify-between items-center">
<div class=" self-center text-sm font-medium">{$i18n.t('Description')}</div>
<button
class="p-1 text-xs flex rounded-sm transition"
type="button"
aria-pressed={enableDescription ? 'true' : 'false'}
aria-label={enableDescription
? $i18n.t('Custom description enabled')
: $i18n.t('Default description enabled')}
on:click={() => {
enableDescription = !enableDescription;
}}
>
{#if !enableDescription}
<span class="ml-2 self-center">{$i18n.t('Default')}</span>
{:else}
<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
{/if}
</button>
</div>
{#if enableDescription}
<Textarea
className=" text-sm w-full bg-transparent outline-hidden resize-none overflow-y-hidden "
placeholder={$i18n.t('Add a short description about what this model does')}
bind:value={info.meta.description}
/>
{/if}
</div>
<div class=" mt-2 my-1">
<div class="">
<Tags
tags={info?.meta?.tags ?? []}
on:delete={(e) => {
const tagName = e.detail;
info.meta.tags = info.meta.tags.filter((tag) => tag.name !== tagName);
}}
on:add={(e) => {
const tagName = e.detail;
if (!(info?.meta?.tags ?? null)) {
info.meta.tags = [{ name: tagName }];
} else {
info.meta.tags = [...info.meta.tags, { name: tagName }];
}
}}
/>
</div>
</div>
<div class="my-2">
<div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
<AccessControl
bind:accessControl
accessRoles={['read', 'write']}
share={$user?.permissions?.sharing?.models || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_models || $user?.role === 'admin'}
/>
</div>
</div>
<hr class=" border-gray-100 dark:border-gray-850 my-1.5" />
<div class="my-2">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-medium text-gray-500">
{$i18n.t('Model Params')}
</div>
<div class=" self-center text-sm font-medium">{$i18n.t('Model Params')}</div>
</div>
<div class="mt-2">
@ -672,12 +814,12 @@
</div>
</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<hr class=" border-gray-100 dark:border-gray-850 my-2" />
<div class="my-2">
<div class="flex w-full justify-between items-center">
<div class="flex w-full justify-between items-center">
<div class=" self-center text-xs font-medium text-gray-500">
<div class=" self-center text-sm font-medium">
{$i18n.t('Prompts')}
</div>
@ -686,7 +828,7 @@
type="button"
on:click={() => {
if ((info?.meta?.suggestion_prompts ?? null) === null) {
info.meta.suggestion_prompts = [{ content: '', title: ['', ''] }];
info.meta.suggestion_prompts = [{ content: JSON.stringify(createEmptyTranslations()), title: ['', ''] }];
} else {
info.meta.suggestion_prompts = null;
}
@ -699,66 +841,125 @@
{/if}
</button>
</div>
{#if (info?.meta?.suggestion_prompts ?? null) !== null}
<button
class="p-1 px-2 text-xs flex rounded-sm transition"
type="button"
aria-label={$i18n.t('Add prompt suggestion')}
on:click={() => {
if (
info.meta.suggestion_prompts.length === 0 ||
getPromptTranslation(info.meta.suggestion_prompts.at(-1).content) !== ''
) {
info.meta.suggestion_prompts = [
...info.meta.suggestion_prompts,
{ content: JSON.stringify(createEmptyTranslations()), title: ['', ''] }
];
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z"
/>
</svg>
</button>
{/if}
</div>
{#if info?.meta?.suggestion_prompts}
<PromptSuggestions bind:promptSuggestions={info.meta.suggestion_prompts} />
<div class="flex flex-col space-y-1 mt-1 mb-3">
{#if info.meta.suggestion_prompts.length > 0}
{#each info.meta.suggestion_prompts as prompt, promptIdx}
<div class=" flex rounded-lg items-center">
<input
class=" text-sm w-full bg-transparent outline-hidden border-r border-gray-100 dark:border-gray-850"
placeholder={$i18n.t('Write a prompt suggestion (e.g. Who are you?)')}
value={getPromptTranslation(prompt.content)}
on:input={(e) => {
const translations = parseContentToObj(prompt.content);
translations[langCode] = e.target.value;
prompt.content = JSON.stringify(translations);
info.meta.suggestion_prompts = info.meta.suggestion_prompts;
}}
/>
{#if getPromptTranslation(prompt.content)}
<button
class="px-2"
type="button"
on:click={() => openPromptTranslationModal(promptIdx)}
>
<PencilSolid />
</button>
{/if}
<button
class="px-2"
type="button"
on:click={() => {
info.meta.suggestion_prompts.splice(promptIdx, 1);
info.meta.suggestion_prompts = info.meta.suggestion_prompts;
}}
>
<XMark className={'size-4'} />
</button>
</div>
{/each}
{:else}
<div class="text-xs text-center">{$i18n.t('No suggestion prompts')}</div>
{/if}
</div>
{/if}
</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<hr class=" border-gray-100 dark:border-gray-850 my-1.5" />
<div class="my-2">
<Knowledge bind:selectedItems={knowledge} />
</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class="my-2">
<ToolsSelector bind:selectedToolIds={toolIds} tools={$tools} />
</div>
{#if $functions.filter((func) => func.type === 'filter').length > 0 || $functions.filter((func) => func.type === 'action').length > 0}
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class="my-2">
<FiltersSelector
bind:selectedFilterIds={filterIds}
filters={$functions.filter((func) => func.type === 'filter')}
/>
</div>
{#if $functions.filter((func) => func.type === 'filter').length > 0}
{#if filterIds.length > 0}
{@const toggleableFilters = $functions.filter(
(func) =>
func.type === 'filter' &&
(filterIds.includes(func.id) || func?.is_global) &&
func?.meta?.toggle
)}
{#if toggleableFilters.length > 0}
<div class="my-2">
<FiltersSelector
bind:selectedFilterIds={filterIds}
filters={$functions.filter((func) => func.type === 'filter')}
/>
</div>
{#if filterIds.length > 0}
{@const toggleableFilters = $functions.filter(
(func) =>
func.type === 'filter' &&
(filterIds.includes(func.id) || func?.is_global) &&
func?.meta?.toggle
)}
{#if toggleableFilters.length > 0}
<div class="my-2">
<DefaultFiltersSelector
bind:selectedFilterIds={defaultFilterIds}
filters={toggleableFilters}
/>
</div>
{/if}
{/if}
{/if}
{#if $functions.filter((func) => func.type === 'action').length > 0}
<div class="my-2">
<ActionsSelector
bind:selectedActionIds={actionIds}
actions={$functions.filter((func) => func.type === 'action')}
<DefaultFiltersSelector
bind:selectedFilterIds={defaultFilterIds}
filters={toggleableFilters}
/>
</div>
{/if}
{/if}
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class="my-2">
<ActionsSelector
bind:selectedActionIds={actionIds}
actions={$functions.filter((func) => func.type === 'action')}
/>
</div>
<div class="my-2">
<Capabilities bind:capabilities />
@ -779,33 +980,7 @@
{/if}
{/if}
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-2" />
<div class="my-2 flex justify-end">
<button
class=" text-sm px-3 py-2 transition rounded-lg {loading
? ' cursor-not-allowed bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'
: 'bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'} flex w-full justify-center"
type="submit"
disabled={loading}
>
<div class=" self-center font-medium">
{#if edit}
{$i18n.t('Save & Update')}
{:else}
{$i18n.t('Save & Create')}
{/if}
</div>
{#if loading}
<div class="ml-1.5 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
<div class="my-2 text-gray-300 dark:text-gray-700 pb-20">
<div class="my-2 text-gray-300 dark:text-gray-700">
<div class="flex w-full justify-between mb-2">
<div class=" self-center text-sm font-medium">{$i18n.t('JSON Preview')}</div>
@ -836,6 +1011,30 @@
</div>
{/if}
</div>
<div class="my-2 flex justify-end pb-20">
<button
class=" text-sm px-3 py-2 transition rounded-lg {loading
? ' cursor-not-allowed bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'
: 'bg-black hover:bg-gray-900 text-white dark:bg-white dark:hover:bg-gray-100 dark:text-black'} flex w-full justify-center"
type="submit"
disabled={loading}
>
<div class=" self-center font-medium">
{#if edit}
{$i18n.t('Save & Update')}
{:else}
{$i18n.t('Save & Create')}
{/if}
</div>
{#if loading}
<div class="ml-1.5 self-center">
<Spinner />
</div>
{/if}
</button>
</div>
</div>
</form>
{/if}

View file

@ -2,7 +2,10 @@ import i18next from 'i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
import type { i18n as i18nType } from 'i18next';
import { writable } from 'svelte/store';
import { writable, type Writable } from 'svelte/store';
import { setContext } from 'svelte';
let i18nStore: Writable<any> | undefined;
const createI18nStore = (i18n: i18nType) => {
const i18nWritable = writable(i18n);
@ -69,10 +72,25 @@ export const initI18n = (defaultLocale?: string | undefined) => {
const lang = i18next?.language || defaultLocale || 'en-US';
document.documentElement.setAttribute('lang', lang);
if (!i18nStore) {
i18nStore = createI18nStore(i18next);
}
return i18nStore;
};
// ---- accessors ----
export const getI18nStore = () => {
if (!i18nStore) {
// fallback dummy store until initI18n runs
i18nStore = writable(i18next);
}
return i18nStore;
};
const i18n = createI18nStore(i18next);
const isLoadingStore = createIsLoadingStore(i18next);
export const getLanguages = async () => {
const languages = (await import(`./locales/languages.json`)).default;
@ -83,5 +101,145 @@ export const changeLanguage = (lang: string) => {
i18next.changeLanguage(lang);
};
export default i18n;
export const isLoading = isLoadingStore;
// ---- context support (optional) ----
export function initI18nContext() {
const store = writable({
locale: i18next.language ?? 'de',
t: (key: string) => key
});
setContext('i18n', store);
return store;
}
// ---- exports ----
export const isLoading = createIsLoadingStore(i18next);
export default getI18nStore();
// Utils for translations
export interface Translations {
[key: string]: string;
}
/**
* Extracts the language code from a locale string (e.g., 'en-US' -> 'en')
* @param language - Full language code (e.g., 'en-US', 'es-ES')
* @param fallback - Default language code if parsing fails
* @returns Language code (e.g., 'en', 'es', 'de')
*/
export function getLangCode(language?: string, fallback: string = 'de'): string {
return language?.split('-')[0] || fallback;
}
// Language family definitions for fallback translations
const LANGUAGE_FAMILIES: Record<string, string[]> = {
en: ['en', 'en-US', 'en-GB'],
de: ['de', 'de-DE', 'de-CH'],
fr: ['fr', 'fr-CH', 'fr-CA', 'fr-FR'],
it: ['it', 'it-IT', 'it-CH']
};
// Default fallback order when user's language is not found
const DEFAULT_FALLBACK = [
'de', 'de-DE', 'de-CH',
'en', 'en-US', 'en-GB',
'fr', 'fr-CH', 'fr-CA', 'fr-FR',
'it', 'it-IT', 'it-CH'
];
/**
* Gets translated label from translation object or JSON string
* @param label - Translation object or JSON string containing translations
* @param langCode - Target language code (e.g., 'en', 'es', 'de')
* @returns Translated string or empty string if not found
*/
export function getTranslatedLabel(
label: string | Translations | null | undefined,
langCode: string
): string {
if (!label) return '';
try {
// If it's a plain string (not JSON), return it as-is
if (typeof label === 'string' && !label.trim().startsWith('{')) {
return label;
}
// If it's already an object, use it directly
const rawTranslations: Translations = typeof label === 'object' ? label : JSON.parse(label);
// Normalize translations object by trimming all keys and values
const translations: Translations = {};
for (const [key, value] of Object.entries(rawTranslations)) {
const trimmedKey = key.trim();
const trimmedValue = typeof value === 'string' ? value.trim() : value;
if (trimmedKey) {
translations[trimmedKey] = trimmedValue;
}
}
// Extract base language code (e.g., 'it' from 'it-CH', 'fr' from 'fr-CA')
// Also trim the langCode to handle any spaces
const cleanLangCode = langCode.trim();
const baseLangCode = cleanLangCode.split('-')[0];
// Build priority list for lookups
const priorityList: string[] = [];
// 1. Add exact match first
priorityList.push(cleanLangCode);
// 2. Add user's language family (if it exists)
const userFamily = LANGUAGE_FAMILIES[baseLangCode];
if (userFamily) {
// Add all variants from user's family, excluding the exact match already added
userFamily.forEach(variant => {
if (variant !== cleanLangCode && !priorityList.includes(variant)) {
priorityList.push(variant);
}
});
}
// 3. Add default fallback order, excluding already added languages
DEFAULT_FALLBACK.forEach(fallbackLang => {
if (!priorityList.includes(fallbackLang)) {
priorityList.push(fallbackLang);
}
});
// Try each language in priority order and return first non-empty translation
for (const lang of priorityList) {
const translation = translations[lang];
if (translation && translation.trim() !== '') {
return translation;
}
}
// If nothing found, return empty string
return '';
} catch (error) {
// Log parsing errors for debugging in development
if (process.env.NODE_ENV === 'development') {
console.warn('Failed to parse translation label:', label, error);
}
// If parsing fails, return the original value if it's a string
return typeof label === 'string' ? label : '';
}
}
/**
* Convenience function that combines getLangCode and getTranslatedLabel
* @param label - Translation object or JSON string
* @param language - Full language code (e.g., 'en-US')
* @param fallback - Fallback language code
* @returns Translated string
*/
export function translate(
label: string | Translations | null | undefined,
language?: string,
fallback: string = 'de'
): string {
const langCode = getLangCode(language, fallback);
return getTranslatedLabel(label, langCode);
}

View file

@ -18,15 +18,15 @@
"{{COUNT}} words": "{{COUNT}} ord",
"{{LOCALIZED_DATE}} at {{LOCALIZED_TIME}}": "{{LOCALIZED_DATE}} klokken {{LOCALIZED_TIME}}",
"{{model}} download has been canceled": "Download af {{model}} er blevet annulleret",
"{{NAMES}} reacted with {{REACTION}}": "",
"{{NAMES}} reacted with {{REACTION}}": "{{NAMES}} reagerede med {{REACTION}}",
"{{user}}'s Chats": "{{user}}s chats",
"{{webUIName}} Backend Required": "{{webUIName}} Backend kræves",
"*Prompt node ID(s) are required for image generation": "*Prompt node ID(s) er påkrævet for at kunne generere billeder",
"1 Source": "1 kilde",
"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": "En samarbejdskanal hvor folk tilmelder sig som medlemmer",
"A discussion channel where access is controlled by groups and permissions": "En diskussionskanal hvor adgang styres af grupper og tilladelser",
"A new version (v{{LATEST_VERSION}}) is now available.": "En ny version (v{{LATEST_VERSION}}) er nu tilgængelig.",
"A private conversation between you and selected users": "",
"A private conversation between you and selected users": "En privat samtale mellem dig og udvalgte brugere",
"A task model is used when performing tasks such as generating titles for chats and web search queries": "En 'task model' bliver brugt til at opgaver såsom at generere overskrifter til chats eller internetsøgninger",
"a user": "en bruger",
"About": "Information",
@ -57,8 +57,8 @@
"Add Custom Prompt": "Tilføj brugerdefineret prompt",
"Add Details": "Tilføj detaljer",
"Add Files": "Tilføj filer",
"Add Member": "",
"Add Members": "",
"Add Member": "Tilføj medlem",
"Add Members": "Tilføj medlemmer",
"Add Memory": "Tilføj hukommelse",
"Add Model": "Tilføj model",
"Add Reaction": "Tilføj reaktion",
@ -70,7 +70,7 @@
"Additional Config": "Yderligere konfiguration",
"Additional configuration options for marker. This should be a JSON string with key-value pairs. For example, '{\"key\": \"value\"}'. Supported keys include: disable_links, keep_pageheader_in_output, keep_pagefooter_in_output, filter_blank_pages, drop_repeated_text, layout_coverage_threshold, merge_threshold, height_tolerance, gap_threshold, image_threshold, min_line_length, level_count, default_level": "Yderligere konfigurationsmuligheder for Marker. Dette skal være en JSON streng med key-value pairs. For eksempel, '{\"key\": \"value\"}'. Tilladte keys omfatter: disable_links, keep_pageheader_in_output, keep_pagefooter_in_output, filter_blank_pages, drop_repeated_text, layout_coverage_threshold, merge_threshold, height_tolerance, gap_threshold, image_threshold, min_line_length, level_count, default_level",
"Additional Parameters": "Yderligere parametre",
"Adds filenames, titles, sections, and snippets into the BM25 text to improve lexical recall.": "",
"Adds filenames, titles, sections, and snippets into the BM25 text to improve lexical recall.": "Tilføjer filnavne, titler, afsnit og uddrag til BM25-teksten for at forbedre leksikalsk genkaldelse.",
"Adjusting these settings will apply changes universally to all users.": "Ændringer af disse indstillinger har konsekvenser for alle brugere.",
"admin": "administrator",
"Admin": "Administrator",
@ -99,7 +99,7 @@
"Allow Continue Response": "Tillad fortsættelse af svar",
"Allow Delete Messages": "Tillad sletning af beskeder",
"Allow File Upload": "Tillad upload af fil",
"Allow Group Sharing": "",
"Allow Group Sharing": "Tillad gruppedeling",
"Allow Multiple Models in Chat": "Tillad flere modeller i chats",
"Allow non-local voices": "Tillad ikke-lokale stemmer",
"Allow Rate Response": "Tillad vurdering af svar",
@ -134,7 +134,7 @@
"API Key created.": "API nøgle lavet",
"API Key Endpoint Restrictions": "API nøgler endpoint forbehold",
"API keys": "API nøgler",
"API Keys": "",
"API Keys": "API nøgler",
"API Mode": "API tilstand",
"API Version": "API Version",
"API Version is required": "API version er påkrævet",
@ -147,7 +147,7 @@
"Archived Chats": "Arkiverede chats",
"archived-chat-export": "arkiveret-chat-eksport",
"Are you sure you want to clear all memories? This action cannot be undone.": "Er du sikker på du vil rydde hele hukommelsen? Dette kan ikke gøres om.",
"Are you sure you want to delete \"{{NAME}}\"?": "",
"Are you sure you want to delete \"{{NAME}}\"?": "Er du sikker på at du vil slette \"{{NAME}}\"?",
"Are you sure you want to delete this channel?": "Er du sikker på du vil slette denne kanal?",
"Are you sure you want to delete this message?": "Er du sikker på du vil slette denne besked?",
"Are you sure you want to unarchive all archived chats?": "Er du sikker på du vil fjerne alle arkiverede chats?",
@ -157,7 +157,7 @@
"Ask": "Spørg",
"Ask a question": "Stil et spørgsmål",
"Assistant": "Assistent",
"Async Embedding Processing": "",
"Async Embedding Processing": "Asynkron embedding processering",
"Attach File From Knowledge": "Vedhæft fil fra viden",
"Attach Knowledge": "Vedhæft viden",
"Attach Notes": "Vedhæft noter",
@ -228,7 +228,7 @@
"Channel deleted successfully": "Kanal slettet",
"Channel Name": "Kanalnavn",
"Channel name cannot be empty.": "Kanalnavn må ikke være tom.",
"Channel Type": "",
"Channel Type": "Kanaltype",
"Channel updated successfully": "Kanal redigeret",
"Channels": "Kanaler",
"Character": "Karakterer",
@ -257,7 +257,7 @@
"Citations": "Citater",
"Clear memory": "Slet hukommelse",
"Clear Memory": "Slet hukommelse",
"Clear status": "",
"Clear status": "Slet status",
"click here": "klik her",
"Click here for filter guides.": "Klik her for filter guider",
"Click here for help.": "Klik her for hjælp",
@ -294,7 +294,7 @@
"Code Interpreter": "Kode interpreter",
"Code Interpreter Engine": "Kode interpreter engine",
"Code Interpreter Prompt Template": "Kode interpreter prompt template",
"Collaboration channel where people join as members": "",
"Collaboration channel where people join as members": "Samarbejdskanal hvor folk tilmelder sig som medlemmer",
"Collapse": "Kollapse",
"Collection": "Samling",
"Color": "Farve",
@ -386,7 +386,7 @@
"Datalab Marker API": "Datalab Marker API",
"DD/MM/YYYY": "DD/MM/ÅÅÅÅ",
"December": "december",
"Decrease UI Scale": "",
"Decrease UI Scale": "Nedjuster UI-skalering",
"Deepgram": "Deepgram",
"Default": "Standard",
"Default (Open AI)": "Standard (Open AI)",
@ -395,14 +395,14 @@
"Default description enabled": "Standardbeskrivelse aktiveret",
"Default Features": "Standardfunktioner",
"Default Filters": "Standardfiltre",
"Default Group": "",
"Default Group": "Standardgruppe",
"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.": "Standardtilstand fungerer med et bredere udvalg af modeller ved at kalde værktøjer én gang før udførelse. Native tilstand udnytter modellens indbyggede værktøjskald-funktioner, men kræver at modellen i sagens natur understøtter denne funktion.",
"Default Model": "Standard model",
"Default model updated": "Standard model opdateret",
"Default Models": "Standard modeller",
"Default permissions": "Standard tilladelser",
"Default permissions updated successfully": "Standard tilladelser opdateret",
"Default Pinned Models": "",
"Default Pinned Models": "Standard fastgjorte modeller",
"Default Prompt Suggestions": "Standardforslag til prompt",
"Default to 389 or 636 if TLS is enabled": "Standard til 389 eller 636 hvis TLS er aktiveret",
"Default to ALL": "Standard til ALLE",
@ -411,7 +411,7 @@
"Delete": "Slet",
"Delete a model": "Slet en model",
"Delete All Chats": "Slet alle chats",
"Delete all contents inside this folder": "",
"Delete all contents inside this folder": "Slet alt indhold i denne mappe",
"Delete All Models": "Slet alle modeller",
"Delete Chat": "Slet chat",
"Delete chat?": "Slet chat?",
@ -437,7 +437,7 @@
"Direct": "Direkte",
"Direct Connections": "Direkte forbindelser",
"Direct Connections allow users to connect to their own OpenAI compatible API endpoints.": "Direkte forbindelser tillader brugere at oprette forbindelse til deres egen OpenAI kompatible API endpoints.",
"Direct Message": "",
"Direct Message": "Direkte besked",
"Direct Tool Servers": "Direkte værktøjsservere",
"Directory selection was cancelled": "Valg af mappe annulleret",
"Disable Code Interpreter": "Deaktiver kode interpreter",
@ -454,7 +454,7 @@
"Discover, download, and explore custom prompts": "Find, download og udforsk unikke prompts",
"Discover, download, and explore custom tools": "Find, download og udforsk unikke værktøjer",
"Discover, download, and explore model presets": "Find, download og udforsk modelindstillinger",
"Discussion channel where access is based on groups and permissions": "",
"Discussion channel where access is based on groups and permissions": "Diskussionskanal hvor adgang styres af grupper og tilladelser",
"Display": "Vis",
"Display chat title in tab": "Vis chattitel i fane",
"Display Emoji in Call": "Vis emoji i chat",
@ -466,12 +466,12 @@
"Do not install functions from sources you do not fully trust.": "Lad være med at installere funktioner fra kilder, som du ikke stoler på.",
"Do not install tools from sources you do not fully trust.": "Lad være med at installere værktøjer fra kilder, som du ikke stoler på.",
"Docling": "Docling",
"Docling Parameters": "",
"Docling Parameters": "Docling parametre",
"Docling Server URL required.": "Docling Server URL påkrævet.",
"Document": "Dokument",
"Document Intelligence": "Document Intelligence",
"Document Intelligence endpoint required.": "Document Intelligence endpoint påkrævet",
"Document Intelligence Model": "",
"Document Intelligence Model": "Document Intelligence model",
"Documentation": "Dokumentation",
"Documents": "Dokumenter",
"does not make any external connections, and your data stays securely on your locally hosted server.": "laver ikke eksterne kald, og din data bliver sikkert på din egen lokalt hostede server.",
@ -494,15 +494,15 @@
"e.g. \"json\" or a JSON schema": "f.eks. \"json\" eller en JSON schema",
"e.g. 60": "f.eks. 60",
"e.g. A filter to remove profanity from text": "f.eks. Et filter til at fjerne upassende ord fra tekst",
"e.g. about the Roman Empire": "",
"e.g. about the Roman Empire": "f.eks. om Romerriget",
"e.g. en": "f.eks. en",
"e.g. My Filter": "f.eks. Mit Filter",
"e.g. My Tools": "f.eks. Mine Værktøjer",
"e.g. my_filter": "f.eks. mit_filter",
"e.g. my_tools": "f.eks. mine_værktøjer",
"e.g. pdf, docx, txt": "f.eks. 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": "f.eks. fortæl mig en fun fact",
"e.g. Tell me a fun fact about the Roman Empire": "f.eks. fortæl mig en fun fact om Romerriget",
"e.g. Tools for performing various operations": "f.eks. Værktøjer til at udføre forskellige operationer",
"e.g., 3, 4, 5 (leave blank for default)": "f.eks. 3, 4, 5 (lad være tom for standard)",
"e.g., audio/wav,audio/mpeg,video/* (leave blank for defaults)": "f.eks. audio/wav,audio/mpeg,video/* (lad være tom for standarder)",
@ -531,7 +531,7 @@
"Embedding Batch Size": "Embedding Batch størrelse",
"Embedding Model": "Embedding Model",
"Embedding Model Engine": "Embedding Model engine",
"Enable API Keys": "",
"Enable API Keys": "Aktiver API nøgler",
"Enable autocomplete generation for chat messages": "Aktiver autofuldførsel for chatbeskeder",
"Enable Code Execution": "Aktiver kodekørsel",
"Enable Code Interpreter": "Aktiver kode interpreter",
@ -547,14 +547,14 @@
"Endpoint URL": "Endpoint URL",
"Enforce Temporary Chat": "Gennemtving midlertidig chat",
"Enhance": "Forbedre",
"Enrich Hybrid Search Text": "",
"Enrich Hybrid Search Text": "Berig hybrid søgetekst",
"Ensure your CSV file includes 4 columns in this order: Name, Email, Password, Role.": "Sørg for at din CSV-fil indeholder 4 kolonner i denne rækkefølge: Name, Email, Password, Role.",
"Enter {{role}} message here": "Indtast {{role}} besked her",
"Enter a detail about yourself for your LLMs to recall": "Indtast en detalje om dig selv, som dine LLMs kan huske",
"Enter a title for the pending user info overlay. Leave empty for default.": "Indtast en titel til afventende bruger informations overlay. Lad være tom for standard.",
"Enter a watermark for the response. Leave empty for none.": "Indtast et vandmærke til svaret. Lad være tom for ingen.",
"Enter additional headers in JSON format": "Indtast yderligere headers i JSON format",
"Enter additional headers in JSON format (e.g. {\"X-Custom-Header\": \"value\"}": "",
"Enter additional headers in JSON format (e.g. {\"X-Custom-Header\": \"value\"}": "Indtast yderligere headers i JSON format (f.eks. {\"X-Custom-Header\": \"value\"})",
"Enter additional parameters in JSON format": "Indtast yderligere parametre i JSON format",
"Enter api auth string (e.g. username:password)": "Indtast api-godkendelsesstreng (f.eks. brugernavn:adgangskode)",
"Enter Application DN": "Indtast Application DN",
@ -572,12 +572,12 @@
"Enter Datalab Marker API Base URL": "Indtast Datalab Marker API base URL",
"Enter Datalab Marker API Key": "Indtast Datalab Marker API nøgle",
"Enter description": "Indtast beskrivelse",
"Enter Docling API Key": "",
"Enter Docling API Key": "Indtast Docling API nøgler",
"Enter Docling Server URL": "Indtast Docling Server URL",
"Enter Document Intelligence Endpoint": "Indtast Dokument Intelligence Endpoint",
"Enter Document Intelligence Key": "Indtast Dokument Intelligence nøgle",
"Enter Document Intelligence Model": "",
"Enter domains separated by commas (e.g., example.com,site.org,!excludedsite.com)": "",
"Enter Document Intelligence Model": "Indtast Dokument Intelligence model",
"Enter domains separated by commas (e.g., example.com,site.org,!excludedsite.com)": "Indtast domæner separeret af kommaer (f.eks. example.com,site.org,!excludedsite.com)",
"Enter Exa API Key": "Indtast Exa API nøgle",
"Enter External Document Loader API Key": "Indtast External Dokument Loader API nøgle",
"Enter External Document Loader URL": "Indtast External Dokument Loader URL",
@ -588,7 +588,7 @@
"Enter Firecrawl API Base URL": "Indtast Firecrawl API base URL",
"Enter Firecrawl API Key": "Indtast Firecrawl API nøgle",
"Enter folder name": "Indtast mappenavn",
"Enter function name filter list (e.g. func1, !func2)": "",
"Enter function name filter list (e.g. func1, !func2)": "Indtast funktionsnavn filterliste (f.eks. func1, !func2)",
"Enter Github Raw URL": "Indtast Github Raw URL",
"Enter Google PSE API Key": "Indtast Google PSE API-nøgle",
"Enter Google PSE Engine Id": "Indtast Google PSE Engine ID",
@ -614,7 +614,7 @@
"Enter Number of Steps (e.g. 50)": "Indtast antal trin (f.eks. 50)",
"Enter Ollama Cloud API Key": "Indtast Ollama Cloud API nøgle",
"Enter Perplexity API Key": "Indtast Perplexity API nøgle",
"Enter Perplexity Search API URL": "",
"Enter Perplexity Search API URL": "Indtast Perplexity Search API URL",
"Enter Playwright Timeout": "Indtast Playwright timeout",
"Enter Playwright WebSocket URL": "Indtast Playwright WebSocket URL",
"Enter proxy URL (e.g. https://user:password@host:port)": "Indtast proxy URL (f.eks. https://bruger:adgangskode@host:port)",
@ -700,11 +700,11 @@
"Export chat (.json)": "Eksportér chat (.json)",
"Export Chats": "Eksportér chats",
"Export Config to JSON File": "Eksportér konfiguration til JSON-fil",
"Export Models": "",
"Export Models": "Eksportér modeller",
"Export Presets": "Eksportér indstillinger",
"Export Prompts": "",
"Export Prompts": "Eksportér prompter",
"Export to CSV": "Eksportér til CSV",
"Export Tools": "",
"Export Tools": "Eksportér værktøjer",
"Export Users": "Eksportér brugere",
"External": "Ekstern",
"External Document Loader URL required.": "External Dokument Loader URL påkrævet.",
@ -716,8 +716,8 @@
"External Web Search URL": "Ekstern Web Search URL",
"Fade Effect for Streaming Text": "Fade-effekt for streaming tekst",
"Failed to add file.": "Kunne ikke tilføje fil.",
"Failed to add members": "",
"Failed to clear status": "",
"Failed to add members": "Kunne ikke tilføje medlemmer",
"Failed to clear status": "Kunne ikke fjerne status",
"Failed to connect to {{URL}} OpenAPI tool server": "Kunne ikke forbinde til {{URL}} OpenAPI tool server",
"Failed to copy link": "Kunne ikke kopiere link",
"Failed to create API Key.": "Kunne ikke oprette API-nøgle.",
@ -731,19 +731,19 @@
"Failed to load file content.": "Kunne ikke indlæse filindhold.",
"Failed to move chat": "Kunne ikke flytte chat",
"Failed to read clipboard contents": "Kunne ikke læse indholdet af udklipsholderen",
"Failed to remove member": "",
"Failed to remove member": "Kunne ikke fjerne medlem",
"Failed to render diagram": "Kunne ikke rendere diagram",
"Failed to render visualization": "Kunne ikke rendere visualisering",
"Failed to save connections": "Kunne ikke gemme forbindelser",
"Failed to save conversation": "Kunne ikke gemme samtalen",
"Failed to save models configuration": "Kunne ikke gemme modeller konfiguration",
"Failed to update settings": "Kunne ikke opdatere indstillinger",
"Failed to update status": "",
"Failed to update status": "Kunne ikke opdatere status",
"Failed to upload file.": "Kunne ikke uploade fil.",
"Features": "Features",
"Features Permissions": "Features tilladelser",
"February": "Februar",
"Feedback deleted successfully": "",
"Feedback deleted successfully": "Feedback slettet",
"Feedback Details": "Feedback detaljer",
"Feedback History": "Feedback historik",
"Feedbacks": "Feedback",
@ -758,7 +758,7 @@
"File size should not exceed {{maxSize}} MB.": "Filstørrelsen må ikke overstige {{maxSize}} MB.",
"File Upload": "Fil upload",
"File uploaded successfully": "Fil uploadet.",
"File uploaded!": "",
"File uploaded!": "Fil uploadet!",
"Files": "Filer",
"Filter": "Filter",
"Filter is now globally disabled": "Filter er nu globalt deaktiveret",
@ -803,7 +803,7 @@
"Function is now globally disabled": "Funktionen er nu globalt deaktiveret",
"Function is now globally enabled": "Funktionen er nu globalt aktiveret",
"Function Name": "Funktionsnavn",
"Function Name Filter List": "",
"Function Name Filter List": "Funktionsnavn filterliste",
"Function updated successfully": "Funktion opdateret.",
"Functions": "Funktioner",
"Functions allow arbitrary code execution.": "Funktioner tillader kørsel af vilkårlig kode.",
@ -832,13 +832,13 @@
"Google PSE Engine Id": "Google PSE Engine-ID",
"Gravatar": "Gravatar",
"Group": "Gruppe",
"Group Channel": "",
"Group Channel": "Gruppekanal",
"Group created successfully": "Gruppe oprettet.",
"Group deleted successfully": "Gruppe slettet.",
"Group Description": "Gruppe beskrivelse",
"Group Name": "Gruppenavn",
"Group updated successfully": "Gruppe opdateret.",
"groups": "",
"groups": "grupper",
"Groups": "Grupper",
"H1": "H1",
"H2": "H2",
@ -875,7 +875,7 @@
"Image Compression": "Billedkomprimering",
"Image Compression Height": "Billedkomprimering højde",
"Image Compression Width": "Billedkomprimering bredde",
"Image Edit": "",
"Image Edit": "Billederedigering",
"Image Edit Engine": "Billederedigeringsmotor",
"Image Generation": "Billedgenerering",
"Image Generation Engine": "Billedgenereringsmotor",
@ -890,18 +890,18 @@
"Import Chats": "Importer chats",
"Import Config from JSON File": "Importer konfiguration fra JSON-fil",
"Import From Link": "Importer fra et link",
"Import Models": "",
"Import Models": "Importer modeller",
"Import Notes": "Importer noter",
"Import Presets": "Importer Presets",
"Import Prompts": "",
"Import Prompts": "Importer prompter",
"Import successful": "Importeret",
"Import Tools": "",
"Import Tools": "Importer værktøjer",
"Important Update": "Vigtig opdatering",
"Include": "Inkluder",
"Include `--api-auth` flag when running stable-diffusion-webui": "Inkluder `--api-auth` flag, når du kører stable-diffusion-webui",
"Include `--api` flag when running stable-diffusion-webui": "Inkluder `--api` flag, når du kører stable-diffusion-webui",
"Includes SharePoint": "Inkluderer SharePoint",
"Increase UI Scale": "",
"Increase UI Scale": "Forøg UI-skalering",
"Influences how quickly the algorithm responds to feedback from the generated text. A lower learning rate will result in slower adjustments, while a higher learning rate will make the algorithm more responsive.": "Påvirker hvor hurtigt algoritmen reagerer på feedback fra den genererede tekst. En lavere indlæringshastighed vil resultere i langsommere justeringer, mens en højere indlæringshastighed vil gøre algoritmen mere responsiv.",
"Info": "Info",
"Initials": "Initialer",
@ -924,7 +924,7 @@
"Invalid JSON format for ComfyUI Edit Workflow.": "Ugyldigt JSON format for ComfyUI Edit Workflow.",
"Invalid JSON format for ComfyUI Workflow.": "Ugyldigt JSON format for ComfyUI Workflow.",
"Invalid JSON format for Parameters": "Ugyldigt JSON format for parametre",
"Invalid JSON format in {{NAME}}": "",
"Invalid JSON format in {{NAME}}": "Ugyldigt JSON format i {{NAME}}",
"Invalid JSON format in Additional Config": "Ugyldigt JSON format for yderligere konfiguration",
"Invalid JSON format in MinerU Parameters": "Ugyldigt JSON format for MinerU parametre",
"Invalid Tag": "Ugyldigt tag",
@ -958,7 +958,7 @@
"Knowledge Name": "Vidensnavn",
"Knowledge Public Sharing": "Viden offentlig deling",
"Knowledge reset successfully.": "Viden nulstillet.",
"Knowledge Sharing": "",
"Knowledge Sharing": "Vidensdeling",
"Knowledge updated successfully": "Viden opdateret.",
"Kokoro.js (Browser)": "Kokoro.js (Browser)",
"Kokoro.js Dtype": "Kokoro.js Dtype",
@ -1024,13 +1024,13 @@
"Max Upload Size": "Maks. uploadstørrelse",
"Maximum of 3 models can be downloaded simultaneously. Please try again later.": "Højst 3 modeller kan downloades samtidigt. Prøv igen senere.",
"May": "Maj",
"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.": "MCP understøttelse er eksperimentel og dens specifikationer ændres ofte hvilket kan medføre inkompatibilitet. OpenAI specifikationsunderstøttelse er vedligeholdt af Open WebUI-teamet hvilket gør det til den mest pålidelige mulighed for understøttelse.",
"Medium": "Medium",
"Member removed successfully": "",
"Members": "",
"Members added successfully": "",
"Member removed successfully": "Medlem fjernet",
"Members": "Medlemmer",
"Members added successfully": "Medlemmer tilføjet",
"Memories accessible by LLMs will be shown here.": "Minder, der er tilgængelige for LLM'er, vises her.",
"Memory": "Hukommelse",
"Memory added successfully": "Hukommelse tilføjet.",
@ -1084,7 +1084,7 @@
"Models configuration saved successfully": "Modeller konfiguration gemt",
"Models imported successfully": "Modeller importeret",
"Models Public Sharing": "Modeller offentlig deling",
"Models Sharing": "",
"Models Sharing": "Modeldeling",
"Mojeek Search API Key": "Mojeek Search API nøgle",
"More": "Mere",
"More Concise": "Mere kortfattet",
@ -1130,7 +1130,7 @@
"No models selected": "Ingen modeller valgt",
"No Notes": "Ingen noter",
"No notes found": "Ingen noter fundet",
"No pinned messages": "",
"No pinned messages": "Ingen fastgjorte beskeder",
"No prompts found": "Ingen prompts fundet",
"No results": "Ingen resultater fundet",
"No results found": "Ingen resultater fundet",
@ -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.": "Bemærk: Hvis du angiver en minimumscore, returnerer søgningen kun dokumenter med en score, der er større end eller lig med minimumscoren.",
"Notes": "Noter",
"Notes Public Sharing": "Noter offentlig deling",
"Notes Sharing": "",
"Notes Sharing": "Notedeling",
"Notification Sound": "Notifikationslyd",
"Notification Webhook": "Notifikations webhook",
"Notifications": "Notifikationer",
@ -1178,7 +1178,7 @@
"Only alphanumeric characters and hyphens are allowed in the command string.": "Kun alfanumeriske tegn og bindestreger er tilladt i kommandostrengen.",
"Only can be triggered when the chat input is in focus.": "Kan kun udløses når chat-input er fokuseret.",
"Only collections can be edited, create a new knowledge base to edit/add documents.": "Kun samlinger kan redigeres, opret en ny vidensbase for at redigere/tilføje dokumenter.",
"Only invited users can access": "",
"Only invited users can access": "Kun inviterede brugere kan få adgang",
"Only markdown files are allowed": "Kun markdown-filer er tilladt",
"Only select users and groups with permission can access": "Kun valgte brugere og grupper med tilladelse kan tilgå",
"Oops! Looks like the URL is invalid. Please double-check and try again.": "Ups! URL'en ser ud til at være ugyldig. Tjek den igen, og prøv igen.",
@ -1236,12 +1236,12 @@
"Permissions": "Tilladelser",
"Perplexity API Key": "Perplexity API nøgle",
"Perplexity Model": "Perplexity model",
"Perplexity Search API URL": "",
"Perplexity Search API URL": "Perplexity Search API URL",
"Perplexity Search Context Usage": "Perplexity søgekontekst brug",
"Personalization": "Personalisering",
"Pin": "Fastgør",
"Pinned": "Fastgjort",
"Pinned Messages": "",
"Pinned Messages": "Fastgjorte beskeder",
"Pioneer insights": "Banebrydende indsigter",
"Pipe": "Pipe",
"Pipeline deleted successfully": "Pipeline slettet.",
@ -1271,7 +1271,7 @@
"Please select a model.": "Vælg en model.",
"Please select a reason": "Vælg en årsag",
"Please select a valid JSON file": "Vælg en valid JSON-fil",
"Please select at least one user for Direct Message channel.": "",
"Please select at least one user for Direct Message channel.": "Vælg mindst én bruger til direkte besked-kanal.",
"Please wait until all files are uploaded.": "Vent venligst indtil alle filerne er uploadet.",
"Port": "Port",
"Positive attitude": "Positiv holdning",
@ -1284,7 +1284,7 @@
"Previous 7 days": "Seneste 7 dage",
"Previous message": "Forrige besked",
"Private": "Privat",
"Private conversation between selected users": "",
"Private conversation between selected users": "Privat samtale mellem valgte brugere",
"Profile": "Profil",
"Prompt": "Prompt",
"Prompt Autocompletion": "Prompt autofuldførelse",
@ -1294,7 +1294,7 @@
"Prompts": "Prompts",
"Prompts Access": "Prompts adgang",
"Prompts Public Sharing": "Prompts offentlig deling",
"Prompts Sharing": "",
"Prompts Sharing": "Promptdeling",
"Provider Type": "Udbyder type",
"Public": "Offentlig",
"Pull \"{{searchValue}}\" from Ollama.com": "Hent \"{{searchValue}}\" fra Ollama.com",
@ -1365,8 +1365,8 @@
"Retrieval": "Hentning",
"Retrieval Query Generation": "Hentnings forespørgsel generering",
"Retrieved {{count}} sources": "Fandt en kildehenvisning",
"Retrieved {{count}} sources_one": "Fandt {{count}} sources_one",
"Retrieved {{count}} sources_other": "Fandt {{count}} sources_other",
"Retrieved {{count}} sources_one": "Fandt {{count}} kildehenvisning",
"Retrieved {{count}} sources_other": "Fandt {{count}} kildehenvisninger",
"Retrieved 1 source": "Fandt en kildehenvisning",
"Rich Text Input for Chat": "Rich text input til chat",
"RK": "RK",
@ -1377,7 +1377,7 @@
"Run": "Kør",
"Running": "Kører",
"Running...": "Kører...",
"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.": "Kører embedding-opgaver sideløbende for at fremskynde behandlingen. Slå fra hvis hastighedsbegrænsninger bliver et problem.",
"Save": "Gem",
"Save & Create": "Gem og opret",
"Save & Update": "Gem og opdater",
@ -1472,14 +1472,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.": "Indstil antallet af arbejdstråde brugt til beregning. Denne mulighed styrer, hvor mange tråde der bruges til at behandle indkommende forespørgsler samtidigt. At øge denne værdi kan forbedre ydeevnen under høj samtidighedsbelastning, men kan også forbruge flere CPU-ressourcer.",
"Set Voice": "Indstil stemme",
"Set whisper model": "Indstil whisper model",
"Set your status": "",
"Set your status": "Indstil din status",
"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.": "Indstiller en flad bias mod tokens, der er forekommet mindst én gang. En højere værdi (f.eks. 1,5) vil straffe gentagelser stærkere, mens en lavere værdi (f.eks. 0,9) vil være mere lemfældig. Ved 0 er det deaktiveret.",
"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.": "Indstiller en skalerende bias mod tokens for at straffe gentagelser, baseret på hvor mange gange de er forekommet. En højere værdi (f.eks. 1,5) vil straffe gentagelser stærkere, mens en lavere værdi (f.eks. 0,9) vil være mere lemfældig. Ved 0 er det deaktiveret.",
"Sets how far back for the model to look back to prevent repetition.": "Indstiller hvor langt tilbage modellen skal se for at forhindre gentagelse.",
"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.": "Indstiller det tilfældige tal seed til brug for generering. At indstille dette til et specifikt tal vil få modellen til at generere den samme tekst for den samme prompt.",
"Sets the size of the context window used to generate the next token.": "Indstiller størrelsen af kontekstvinduet brugt til at generere det næste 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.": "Indstiller stop-sekvenserne til brug. Når dette mønster stødes på, vil LLM'en stoppe med at generere tekst og returnere. Flere stop-mønstre kan indstilles ved at specificere flere separate stop-parametre i en modelfil.",
"Setting": "",
"Setting": "Indstilling",
"Settings": "Indstillinger",
"Settings saved successfully!": "Indstillinger gemt!",
"Share": "Del",
@ -1525,9 +1525,9 @@
"Start a new conversation": "Start en ny samtale",
"Start of the channel": "Kanalens start",
"Start Tag": "Start tag",
"Status": "",
"Status cleared successfully": "",
"Status updated successfully": "",
"Status": "Status",
"Status cleared successfully": "Status slettet",
"Status updated successfully": "Status opdateret",
"Status Updates": "Statusopdateringer",
"STDOUT/STDERR": "STDOUT/STDERR",
"Steps": "Trin",
@ -1543,7 +1543,7 @@
"STT Model": "STT-model",
"STT Settings": "STT-indstillinger",
"Stylized PDF Export": "Stiliseret PDF eksport",
"Subtitle": "",
"Subtitle": "Undertekst",
"Success": "Succes",
"Successfully imported {{userCount}} users.": "Importerede {{userCount}} brugere.",
"Successfully updated.": "Opdateret.",
@ -1660,7 +1660,7 @@
"Tools Function Calling Prompt": "Værktøjs Funktionkaldprompt",
"Tools have a function calling system that allows arbitrary code execution.": "Værktøjer har et funktionkaldssystem, der tillader vilkårlig kodeudførelse.",
"Tools Public Sharing": "Værktøjer Offentlig Deling",
"Tools Sharing": "",
"Tools Sharing": "Værktøjsdeling",
"Top K": "Top K",
"Top K Reranker": "Top K omarrangering",
"Transformers": "Transformers",
@ -1676,7 +1676,7 @@
"Type Hugging Face Resolve (Download) URL": "Indtast Hugging Face Resolve (Download) URL",
"Uh-oh! There was an issue with the response.": "Uh-oh! Der var et problem det det svar.",
"UI": "UI",
"UI Scale": "",
"UI Scale": "UI-skalering",
"Unarchive All": "Udpak alle arkiver",
"Unarchive All Archived Chats": "Udpak alle arkiverede chats",
"Unarchive Chat": "Fjern chat fra arkiv",
@ -1694,7 +1694,7 @@
"Update and Copy Link": "Opdater og kopier link",
"Update for the latest features and improvements.": "Opdater for at få de nyeste funktioner og forbedringer.",
"Update password": "Opdater adgangskode",
"Update your status": "",
"Update your status": "Opdater din status",
"Updated": "Opdateret",
"Updated at": "Opdateret kl.",
"Updated At": "Opdateret Klokken.",
@ -1709,7 +1709,7 @@
"Upload Pipeline": "Upload pipeline",
"Upload Progress": "Uploadfremdrift",
"Upload Progress: {{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)": "Uploadfremdrift: {{uploadedFiles}}/{{totalFiles}} ({{percentage}}%)",
"Uploading file...": "",
"Uploading file...": "Uploader fil...",
"URL": "URL",
"URL is required": "URL er påkrævet",
"URL Mode": "URL-tilstand",
@ -1728,7 +1728,7 @@
"User menu": "Brugermenu",
"User Webhooks": "Bruger Webhooks",
"Username": "Brugernavn",
"users": "",
"users": "brugere",
"Users": "Brugere",
"Uses DefaultAzureCredential to authenticate": "Bruger DefaultAzureCredential til at autentificere",
"Uses OAuth 2.1 Dynamic Client Registration": "Bruger OAuth 2.1 Dynamic Client Registration",
@ -1748,13 +1748,13 @@
"View Replies": "Vis svar",
"View Result from **{{NAME}}**": "Vis resultat fra **{{NAME}}**",
"Visibility": "Synlighed",
"Visible to all users": "",
"Visible to all users": "Synlig for alle brugere",
"Vision": "Vision",
"Voice": "Stemme",
"Voice Input": "Stemme Input",
"Voice mode": "Stemme tilstand",
"Voice Mode Custom Prompt": "",
"Voice Mode Prompt": "",
"Voice mode": "Stemmetilstand",
"Voice Mode Custom Prompt": "Brugerdefineret prompt til stemmetilstand",
"Voice Mode Prompt": "Prompt til stemmetilstand",
"Warning": "Advarsel",
"Warning:": "Advarsel:",
"Warning: Enabling this will allow users to upload arbitrary code on the server.": "Advarsel: Hvis du aktiverer dette, vil brugerne kunne uploade vilkårlig kode på serveren.",
@ -1776,7 +1776,7 @@
"What are you trying to achieve?": "Hvad prøver du at opnå?",
"What are you working on?": "Hvad arbejder du på?",
"What's New in": "Nyheder i",
"What's on your mind?": "",
"What's on your mind?": "Hvad har du på hjertet?",
"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.": "Når aktiveret, vil modellen reagere på hver chatbesked i realtid og generere et svar, så snart brugeren sender en besked. Denne tilstand er nyttig til live chat-applikationer, men kan påvirke ydeevnen på langsommere hardware.",
"wherever you are": "hvad end du er",
"Whether to paginate the output. Each page will be separated by a horizontal rule and page number. Defaults to False.": "Om outputtet skal pagineres. Hver side vil være adskilt af en vandret streg og sidetal. Standard er False.",

View file

@ -75,6 +75,8 @@ export const settings: Writable<Settings> = writable({});
export const audioQueue = writable(null);
export const sidebarWidth = writable(260);
export const showSidebar = writable(false);
export const showSearch = writable(false);
export const showSettings = writable(false);
@ -274,6 +276,7 @@ type Config = {
enable_autocomplete_generation: boolean;
enable_direct_connections: boolean;
enable_version_update_check: boolean;
translation_languages: string[];
};
oauth: {
providers: {

View file

@ -16,6 +16,7 @@
import { WEBUI_VERSION } from '$lib/constants';
import { compareVersion } from '$lib/utils';
import { customHeadersFetch } from '../../hooks.client';
import {
config,
@ -156,6 +157,7 @@
setBanners(),
setTools(),
setUserSettings(async () => {
customHeadersFetch();
await Promise.all([setModels(), setToolServers()]);
})
]);
@ -246,7 +248,10 @@
console.log('Shortcut triggered: GENERATE_MESSAGE_PAIR');
event.preventDefault();
document.getElementById('generate-message-pair-button')?.click();
} else if (isShortcutMatch(event, shortcuts[Shortcut.REGENERATE_RESPONSE])) {
} else if (
isShortcutMatch(event, shortcuts[Shortcut.REGENERATE_RESPONSE]) &&
document.activeElement?.id === 'chat-input'
) {
console.log('Shortcut triggered: REGENERATE_RESPONSE');
event.preventDefault();
[...document.getElementsByClassName('regenerate-response-button')]?.at(-1)?.click();
@ -383,7 +388,7 @@
{:else}
<div
class="w-full flex-1 h-full flex items-center justify-center {$showSidebar
? ' md:max-w-[calc(100%-260px)]'
? ' md:max-w-[calc(100%-var(--sidebar-width))]'
: ' '}"
>
<Spinner className="size-5" />

View file

@ -29,7 +29,7 @@
{#if loaded}
<div
class=" flex flex-col h-screen max-h-[100dvh] flex-1 transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-260px)]'
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
: ' md:max-w-[calc(100%-49px)]'} w-full max-w-full"
>
<nav class=" px-2.5 pt-1.5 backdrop-blur-xl drag-region">

View file

@ -18,7 +18,7 @@
<div
class=" flex flex-col w-full 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))]'
: ''} max-w-full"
>
<nav class=" px-2.5 pt-1.5 backdrop-blur-xl w-full drag-region">

View file

@ -41,7 +41,7 @@
{#if loaded}
<div
class=" flex flex-col w-full 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))]'
: ''} max-w-full"
>
<nav class=" px-2 pt-1.5 backdrop-blur-xl w-full drag-region">

View file

@ -20,7 +20,7 @@
{#if loaded}
<div
id="note-container"
class="w-full h-full {$showSidebar ? 'md:max-w-[calc(100%-260px)]' : ''}"
class="w-full h-full {$showSidebar ? 'md:max-w-[calc(100%-var(--sidebar-width))]' : ''}"
>
<NoteEditor id={$page.params.id} />
</div>

View file

@ -18,7 +18,7 @@
<div
class=" flex flex-col w-full 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))]'
: ''} max-w-full"
>
<nav class=" px-2.5 pt-1.5 backdrop-blur-xl w-full drag-region">

View file

@ -52,7 +52,7 @@
{#if loaded}
<div
class=" relative flex flex-col w-full 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))]'
: ''} max-w-full"
>
<nav class=" px-2.5 pt-1.5 backdrop-blur-xl drag-region">

View file

@ -2,6 +2,7 @@
import { io } from 'socket.io-client';
import { spring } from 'svelte/motion';
import PyodideWorker from '$lib/workers/pyodide.worker?worker';
import { customHeadersFetch } from '../hooks.client';
import { Toaster, toast } from 'svelte-sonner';
let loadingProgress = spring(0, {
@ -730,6 +731,7 @@
// Initialize i18n even if we didn't get a backend config,
// so `/error` can show something that's not `undefined`.
customHeadersFetch();
initI18n(localStorage?.locale);
if (!localStorage.locale) {
const languages = await getLanguages();