Merge branch 'open-webui:dev' into dev

This commit is contained in:
Kevin Rohn 2026-04-02 14:42:51 +02:00 committed by GitHub
commit a966f835b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
158 changed files with 11868 additions and 1773 deletions

View file

@ -687,7 +687,6 @@ def load_oauth_providers():
return client
OAUTH_PROVIDERS['google'] = {
'redirect_uri': GOOGLE_REDIRECT_URI.value,
'register': google_oauth_register,
}
@ -708,7 +707,6 @@ def load_oauth_providers():
return client
OAUTH_PROVIDERS['microsoft'] = {
'redirect_uri': MICROSOFT_REDIRECT_URI.value,
'picture_url': MICROSOFT_CLIENT_PICTURE_URL.value,
'register': microsoft_oauth_register,
}
@ -733,7 +731,6 @@ def load_oauth_providers():
return client
OAUTH_PROVIDERS['github'] = {
'redirect_uri': GITHUB_CLIENT_REDIRECT_URI.value,
'register': github_oauth_register,
'sub_claim': 'id',
}
@ -775,7 +772,6 @@ def load_oauth_providers():
OAUTH_PROVIDERS['oidc'] = {
'name': OAUTH_PROVIDER_NAME.value,
'redirect_uri': OPENID_REDIRECT_URI.value,
'register': oidc_oauth_register,
}
@ -1226,10 +1222,16 @@ DEFAULT_MODEL_METADATA = PersistentConfig(
{},
)
try:
default_model_params = json.loads(os.environ.get('DEFAULT_MODEL_PARAMS', '{}'))
except Exception as e:
log.exception(f'Error loading DEFAULT_MODEL_PARAMS: {e}')
default_model_params = {}
DEFAULT_MODEL_PARAMS = PersistentConfig(
'DEFAULT_MODEL_PARAMS',
'models.default_params',
{},
default_model_params,
)
DEFAULT_USER_ROLE = PersistentConfig(
@ -1435,6 +1437,10 @@ USER_PERMISSIONS_FEATURES_API_KEYS = os.environ.get('USER_PERMISSIONS_FEATURES_A
USER_PERMISSIONS_FEATURES_MEMORIES = os.environ.get('USER_PERMISSIONS_FEATURES_MEMORIES', 'True').lower() == 'true'
USER_PERMISSIONS_FEATURES_AUTOMATIONS = (
os.environ.get('USER_PERMISSIONS_FEATURES_AUTOMATIONS', 'False').lower() == 'true'
)
USER_PERMISSIONS_SETTINGS_INTERFACE = os.environ.get('USER_PERMISSIONS_SETTINGS_INTERFACE', 'True').lower() == 'true'
@ -1504,6 +1510,7 @@ DEFAULT_USER_PERMISSIONS = {
'image_generation': USER_PERMISSIONS_FEATURES_IMAGE_GENERATION,
'code_interpreter': USER_PERMISSIONS_FEATURES_CODE_INTERPRETER,
'memories': USER_PERMISSIONS_FEATURES_MEMORIES,
'automations': USER_PERMISSIONS_FEATURES_AUTOMATIONS,
},
'settings': {
'interface': USER_PERMISSIONS_SETTINGS_INTERFACE,
@ -3096,7 +3103,7 @@ WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig(
WEB_FETCH_MAX_CONTENT_LENGTH = PersistentConfig(
'WEB_FETCH_MAX_CONTENT_LENGTH',
'rag.web.search.fetch_url_max_content_length',
'rag.web.fetch.max_content_length',
(int(os.environ.get('WEB_FETCH_MAX_CONTENT_LENGTH')) if os.environ.get('WEB_FETCH_MAX_CONTENT_LENGTH') else None),
)

View file

@ -97,6 +97,7 @@ from open_webui.routers import (
utils,
scim,
terminals,
automations,
)
from open_webui.routers.retrieval import (
@ -650,6 +651,10 @@ async def lifespan(app: FastAPI):
asyncio.create_task(periodic_usage_pool_cleanup())
asyncio.create_task(periodic_session_pool_cleanup())
from open_webui.utils.automations import automation_worker_loop
asyncio.create_task(automation_worker_loop(app))
if app.state.config.ENABLE_BASE_MODELS_CACHE:
try:
await get_all_models(
@ -1526,6 +1531,7 @@ if ENABLE_ADMIN_ANALYTICS:
app.include_router(analytics.router, prefix='/api/v1/analytics', tags=['analytics'])
app.include_router(utils.router, prefix='/api/v1/utils', tags=['utils'])
app.include_router(terminals.router, prefix='/api/v1/terminals', tags=['terminals'])
app.include_router(automations.router, prefix='/api/v1/automations', tags=['automations'])
# SCIM 2.0 API for identity management
if ENABLE_SCIM:
@ -1845,13 +1851,28 @@ async def chat_completion(
except Exception:
pass
finally:
# Clean up MCP clients. Shield the entire block from
# CancelledError so disconnect() can finish even when the
# task is being stopped. Each client is isolated so one
# failure doesn't skip the rest.
try:
if mcp_clients := metadata.get('mcp_clients'):
for client in reversed(mcp_clients.values()):
await client.disconnect()
async def _cleanup_mcp():
for client in reversed(list(mcp_clients.values())):
try:
await client.disconnect()
except Exception as e:
log.debug(f'Error disconnecting MCP client: {e}')
await asyncio.wait_for(
asyncio.shield(_cleanup_mcp()),
timeout=10.0,
)
except asyncio.TimeoutError:
log.warning('MCP client cleanup timed out after 10 s')
except Exception as e:
log.debug(f'Error cleaning up: {e}')
pass
log.debug(f'Error cleaning up MCP clients: {e}')
# Emit chat:active=false when task completes
try:
if metadata.get('chat_id'):
@ -1881,6 +1902,10 @@ async def chat_completion(
generate_chat_completions = chat_completion
generate_chat_completion = chat_completion
# Expose as app.state so internal callers (e.g. automations) can
# use the full pipeline without importing from main.py (avoids circular deps).
app.state.CHAT_COMPLETION_HANDLER = chat_completion
##################################
#

View file

@ -0,0 +1,28 @@
"""Add tasks and summary columns to chat table
Revision ID: a3dd5bedd151
Revises: b2c3d4e5f6a7
Create Date: 2026-03-29 22:15:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a3dd5bedd151'
down_revision: Union[str, None] = 'b2c3d4e5f6a7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True))
op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column('chat', 'summary')
op.drop_column('chat', 'tasks')

View file

@ -0,0 +1,27 @@
"""add last_read_at to chat
Revision ID: b7c8d9e0f1a2
Revises: d4e5f6a7b8c9
Create Date: 2026-04-01 04:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b7c8d9e0f1a2'
down_revision = 'd4e5f6a7b8c9'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('chat', sa.Column('last_read_at', sa.BigInteger(), nullable=True))
# Set existing chats to be marked as read
op.execute('UPDATE chat SET last_read_at = updated_at')
def downgrade():
op.drop_column('chat', 'last_read_at')

View file

@ -0,0 +1,55 @@
"""add automation tables
Revision ID: d4e5f6a7b8c9
Revises: f1e2d3c4b5a6
Create Date: 2026-03-30
"""
from typing import Union
from alembic import op
import sqlalchemy as sa
revision: str = 'd4e5f6a7b8c9'
down_revision: Union[str, None] = 'a3dd5bedd151'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'automation',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('user_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('meta', sa.JSON(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, default=True),
sa.Column('last_run_at', sa.BigInteger(), nullable=True),
sa.Column('next_run_at', sa.BigInteger(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
sa.Column('updated_at', sa.BigInteger(), nullable=False),
)
op.create_index('ix_automation_next_run', 'automation', ['next_run_at'])
op.create_table(
'automation_run',
sa.Column('id', sa.Text(), primary_key=True),
sa.Column('automation_id', sa.Text(), nullable=False),
sa.Column('chat_id', sa.Text(), nullable=True),
sa.Column('status', sa.Text(), nullable=False),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('created_at', sa.BigInteger(), nullable=False),
)
op.create_index(
'ix_automation_run_automation_id',
'automation_run',
['automation_id'],
)
def downgrade():
op.drop_index('ix_automation_run_automation_id')
op.drop_table('automation_run')
op.drop_index('ix_automation_next_run')
op.drop_table('automation')

View file

@ -0,0 +1,337 @@
import time
import logging
from typing import Optional
from uuid import uuid4
from pydantic import BaseModel, ConfigDict
from sqlalchemy import Column, Text, JSON, Boolean, BigInteger, Index, select, or_, func, cast, String
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
log = logging.getLogger(__name__)
####################
# Automation DB Schema
####################
class Automation(Base):
__tablename__ = 'automation'
id = Column(Text, primary_key=True)
user_id = Column(Text, nullable=False)
name = Column(Text, nullable=False)
data = Column(JSON, nullable=False) # {prompt, model_id, rrule}
meta = Column(JSON, nullable=True)
is_active = Column(Boolean, nullable=False, default=True)
last_run_at = Column(BigInteger, nullable=True)
next_run_at = Column(BigInteger, nullable=True)
created_at = Column(BigInteger, nullable=False)
updated_at = Column(BigInteger, nullable=False)
__table_args__ = (Index('ix_automation_next_run', 'next_run_at'),)
class AutomationRun(Base):
__tablename__ = 'automation_run'
id = Column(Text, primary_key=True)
automation_id = Column(Text, nullable=False)
chat_id = Column(Text, nullable=True)
status = Column(Text, nullable=False) # success | error
error = Column(Text, nullable=True)
created_at = Column(BigInteger, nullable=False)
__table_args__ = (Index('ix_automation_run_automation_id', 'automation_id'),)
####################
# Pydantic Models
####################
class AutomationTerminalConfig(BaseModel):
server_id: str
cwd: Optional[str] = None
class AutomationData(BaseModel):
prompt: str
model_id: str
rrule: str
terminal: Optional[AutomationTerminalConfig] = None
class AutomationModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
user_id: str
name: str
data: dict
meta: Optional[dict] = None
is_active: bool
last_run_at: Optional[int] = None
next_run_at: Optional[int] = None
created_at: int
updated_at: int
class AutomationRunModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
automation_id: str
chat_id: Optional[str] = None
status: str
error: Optional[str] = None
created_at: int
class AutomationForm(BaseModel):
name: str
data: AutomationData
meta: Optional[dict] = None
is_active: Optional[bool] = True
class AutomationResponse(AutomationModel):
last_run: Optional[AutomationRunModel] = None
next_runs: Optional[list[int]] = None
class AutomationListResponse(BaseModel):
items: list[AutomationModel]
total: int
####################
# AutomationTable
####################
class AutomationTable:
def insert(
self,
user_id: str,
form: AutomationForm,
next_run_at: int,
db: Optional[Session] = None,
) -> AutomationModel:
with get_db_context(db) as db:
now = int(time.time_ns())
row = Automation(
id=str(uuid4()),
user_id=user_id,
name=form.name,
data=form.data.model_dump(),
meta=form.meta,
is_active=form.is_active,
next_run_at=next_run_at,
created_at=now,
updated_at=now,
)
db.add(row)
db.commit()
db.refresh(row)
return AutomationModel.model_validate(row)
def get_by_id(self, id: str, db: Optional[Session] = None) -> Optional[AutomationModel]:
with get_db_context(db) as db:
row = db.get(Automation, id)
return AutomationModel.model_validate(row) if row else None
def search_automations(
self,
user_id: str,
query: Optional[str] = None,
status: Optional[str] = None,
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> 'AutomationListResponse':
with get_db_context(db) as db:
q = db.query(Automation).filter_by(user_id=user_id)
if query:
search = f'%{query}%'
# Search in name and prompt inside JSON data
q = q.filter(
or_(
Automation.name.ilike(search),
cast(Automation.data, String).ilike(search),
)
)
if status == 'active':
q = q.filter(Automation.is_active == True)
elif status == 'paused':
q = q.filter(Automation.is_active == False)
q = q.order_by(Automation.created_at.desc())
total = q.count()
if skip:
q = q.offset(skip)
if limit:
q = q.limit(limit)
rows = q.all()
return AutomationListResponse(
items=[AutomationModel.model_validate(r) for r in rows],
total=total,
)
def update_by_id(
self,
id: str,
form: AutomationForm,
next_run_at: int,
db: Optional[Session] = None,
) -> Optional[AutomationModel]:
with get_db_context(db) as db:
row = db.get(Automation, id)
if not row:
return None
row.name = form.name
row.data = form.data.model_dump()
row.meta = form.meta
if form.is_active is not None:
row.is_active = form.is_active
row.next_run_at = next_run_at
row.updated_at = int(time.time_ns())
db.commit()
db.refresh(row)
return AutomationModel.model_validate(row)
def toggle(
self,
id: str,
next_run_at: Optional[int],
db: Optional[Session] = None,
) -> Optional[AutomationModel]:
with get_db_context(db) as db:
row = db.get(Automation, id)
if not row:
return None
row.is_active = not row.is_active
row.next_run_at = next_run_at if row.is_active else None
row.updated_at = int(time.time_ns())
db.commit()
db.refresh(row)
return AutomationModel.model_validate(row)
def delete(self, id: str, db: Optional[Session] = None) -> bool:
with get_db_context(db) as db:
row = db.get(Automation, id)
if not row:
return False
db.delete(row)
db.commit()
return True
def claim_due(self, now_ns: int, limit: int = 10, db: Optional[Session] = None) -> list[AutomationModel]:
"""
Atomically claim due automations for execution.
Advances next_run_at immediately so the row can never be
double-claimed. On PostgreSQL, uses FOR UPDATE SKIP LOCKED
for zero-contention distributed work claiming.
"""
with get_db_context(db) as db:
stmt = (
select(Automation)
.where(
Automation.is_active == True,
Automation.next_run_at <= now_ns,
)
.order_by(Automation.next_run_at)
.limit(limit)
)
if db.bind.dialect.name == 'postgresql':
stmt = stmt.with_for_update(skip_locked=True)
rows = db.execute(stmt).scalars().all()
from open_webui.utils.automations import next_run_ns
for row in rows:
row.last_run_at = now_ns
row.next_run_at = next_run_ns(row.data.get('rrule', ''))
db.commit()
return [AutomationModel.model_validate(r) for r in rows]
####################
# AutomationRunTable
####################
class AutomationRunTable:
def insert(
self,
automation_id: str,
status: str,
chat_id: Optional[str] = None,
error: Optional[str] = None,
db: Optional[Session] = None,
) -> AutomationRunModel:
with get_db_context(db) as db:
row = AutomationRun(
id=str(uuid4()),
automation_id=automation_id,
chat_id=chat_id,
status=status,
error=error,
created_at=int(time.time_ns()),
)
db.add(row)
db.commit()
db.refresh(row)
return AutomationRunModel.model_validate(row)
def get_latest(self, automation_id: str, db: Optional[Session] = None) -> Optional[AutomationRunModel]:
with get_db_context(db) as db:
row = (
db.query(AutomationRun)
.filter_by(automation_id=automation_id)
.order_by(AutomationRun.created_at.desc())
.first()
)
return AutomationRunModel.model_validate(row) if row else None
def get_by_automation(
self,
automation_id: str,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[AutomationRunModel]:
with get_db_context(db) as db:
rows = (
db.query(AutomationRun)
.filter_by(automation_id=automation_id)
.order_by(AutomationRun.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [AutomationRunModel.model_validate(r) for r in rows]
def delete_by_automation(self, automation_id: str, db: Optional[Session] = None) -> int:
with get_db_context(db) as db:
count = db.query(AutomationRun).filter_by(automation_id=automation_id).delete()
db.commit()
return count
Automations = AutomationTable()
AutomationRuns = AutomationRunTable()

View file

@ -169,7 +169,10 @@ class ChatMessageTable:
info = data.get('info', {})
usage = info.get('usage') if info else None
if usage:
existing.usage = usage
# Deep-merge: preserve existing keys not present in new data
# This prevents background tasks (follow-ups, title, tags)
# from accidentally clearing the primary response's token counts
existing.usage = {**(existing.usage or {}), **usage}
existing.updated_at = now
db.commit()
db.refresh(existing)

View file

@ -54,6 +54,11 @@ class Chat(Base):
meta = Column(JSON, server_default='{}')
folder_id = Column(Text, nullable=True)
tasks = Column(JSON, nullable=True)
summary = Column(Text, nullable=True)
last_read_at = Column(BigInteger, nullable=True)
__table_args__ = (
# Performance indexes for common queries
# WHERE folder_id = ...
@ -87,6 +92,11 @@ class ChatModel(BaseModel):
meta: dict = {}
folder_id: Optional[str] = None
tasks: Optional[list] = None
summary: Optional[str] = None
last_read_at: Optional[int] = None
class ChatFile(Base):
__tablename__ = 'chat_file'
@ -161,12 +171,16 @@ class ChatResponse(BaseModel):
meta: dict = {}
folder_id: Optional[str] = None
tasks: Optional[list] = None
summary: Optional[str] = None
class ChatTitleIdResponse(BaseModel):
id: str
title: str
updated_at: int
created_at: int
last_read_at: Optional[int] = None
class SharedChatResponse(BaseModel):
@ -388,16 +402,34 @@ class ChatTable:
except Exception:
return None
def update_chat_last_read_at_by_id(self, id: str, user_id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
chat = db.get(Chat, id)
if chat and chat.user_id == user_id:
chat.last_read_at = int(time.time())
db.commit()
return True
return False
except Exception:
return False
def update_chat_title_by_id(self, id: str, title: str) -> Optional[ChatModel]:
chat = self.get_chat_by_id(id)
if chat is None:
try:
with get_db_context() as db:
chat_item = db.get(Chat, id)
if chat_item is None:
return None
clean_title = self._clean_null_bytes(title)
chat_item.title = clean_title
chat_item.chat = {**(chat_item.chat or {}), 'title': clean_title}
chat_item.updated_at = int(time.time())
db.commit()
db.refresh(chat_item)
return ChatModel.model_validate(chat_item)
except Exception:
return None
chat = chat.chat
chat['title'] = title
return self.update_chat_by_id(id, chat)
def update_chat_tags_by_id(self, id: str, tags: list[str], user) -> Optional[ChatModel]:
with get_db_context() as db:
chat = db.get(Chat, id)
@ -770,7 +802,7 @@ class ChatTable:
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatModel]:
) -> list[ChatTitleIdResponse]:
with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id)
if not include_archived:
@ -794,13 +826,28 @@ class ChatTable:
else:
query = query.order_by(Chat.updated_at.desc(), Chat.id)
query = query.with_entities(
Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at
)
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats]
return [
ChatTitleIdResponse.model_validate(
{
'id': chat[0],
'title': chat[1],
'updated_at': chat[2],
'created_at': chat[3],
'last_read_at': chat[4],
}
)
for chat in all_chats
]
def get_chat_title_id_list_by_user_id(
self,
@ -825,7 +872,7 @@ class ChatTable:
query = query.filter_by(archived=False)
query = query.order_by(Chat.updated_at.desc(), Chat.id).with_entities(
Chat.id, Chat.title, Chat.updated_at, Chat.created_at
Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at
)
if skip:
@ -843,6 +890,7 @@ class ChatTable:
'title': chat[1],
'updated_at': chat[2],
'created_at': chat[3],
'last_read_at': chat[4],
}
)
for chat in all_chats
@ -986,7 +1034,7 @@ class ChatTable:
db.query(Chat)
.filter_by(user_id=user_id, pinned=True, archived=False)
.order_by(Chat.updated_at.desc())
.with_entities(Chat.id, Chat.title, Chat.updated_at, Chat.created_at)
.with_entities(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at)
)
return [
ChatTitleIdResponse.model_validate(
@ -995,6 +1043,7 @@ class ChatTable:
'title': chat[1],
'updated_at': chat[2],
'created_at': chat[3],
'last_read_at': chat[4],
}
)
for chat in all_chats
@ -1205,7 +1254,7 @@ class ChatTable:
skip: int = 0,
limit: int = 60,
db: Optional[Session] = None,
) -> list[ChatModel]:
) -> list[ChatTitleIdResponse]:
with get_db_context(db) as db:
query = db.query(Chat).filter_by(folder_id=folder_id, user_id=user_id)
query = query.filter(or_(Chat.pinned == False, Chat.pinned == None))
@ -1213,13 +1262,28 @@ class ChatTable:
query = query.order_by(Chat.updated_at.desc(), Chat.id)
query = query.with_entities(
Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at
)
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
all_chats = query.all()
return [ChatModel.model_validate(chat) for chat in all_chats]
return [
ChatTitleIdResponse.model_validate(
{
'id': chat[0],
'title': chat[1],
'updated_at': chat[2],
'created_at': chat[3],
'last_read_at': chat[4],
}
)
for chat in all_chats
]
def get_chats_by_folder_ids_and_user_id(
self, folder_ids: list[str], user_id: str, db: Optional[Session] = None
@ -1262,7 +1326,7 @@ class ChatTable:
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[ChatModel]:
) -> list[ChatTitleIdResponse]:
with get_db_context(db) as db:
query = db.query(Chat).filter_by(user_id=user_id)
tag_id = tag_name.replace(' ', '_').lower()
@ -1281,9 +1345,30 @@ class ChatTable:
else:
raise NotImplementedError(f'Unsupported dialect: {db.bind.dialect.name}')
query = query.order_by(Chat.updated_at.desc(), Chat.id)
query = query.with_entities(
Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at
)
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
all_chats = query.all()
log.debug(f'all_chats: {all_chats}')
return [ChatModel.model_validate(chat) for chat in all_chats]
return [
ChatTitleIdResponse.model_validate(
{
'id': chat[0],
'title': chat[1],
'updated_at': chat[2],
'created_at': chat[3],
'last_read_at': chat[4],
}
)
for chat in all_chats
]
def add_chat_tag_by_id_and_user_id_and_tag_name(
self, id: str, user_id: str, tag_name: str, db: Optional[Session] = None
@ -1461,8 +1546,8 @@ class ChatTable:
def delete_shared_chats_by_user_id(self, user_id: str, db: Optional[Session] = None) -> bool:
try:
with get_db_context(db) as db:
chats_by_user = db.query(Chat).filter_by(user_id=user_id).all()
shared_chat_ids = [f'shared-{chat.id}' for chat in chats_by_user]
id_rows = db.query(Chat.id).filter_by(user_id=user_id).all()
shared_chat_ids = [f'shared-{row[0]}' for row in id_rows]
# Use subquery to delete chat_messages for shared chats
shared_id_subq = db.query(Chat.id).filter(Chat.user_id.in_(shared_chat_ids)).subquery()
@ -1552,5 +1637,27 @@ class ChatTable:
return [ChatModel.model_validate(chat) for chat in all_chats]
def update_chat_tasks_by_id(self, id: str, tasks: list[dict]) -> Optional[ChatModel]:
"""Update the tasks list on a chat."""
try:
with get_db_context() as db:
chat = db.get(Chat, id)
if chat is None:
return None
chat.tasks = tasks
db.commit()
db.refresh(chat)
return ChatModel.model_validate(chat)
except Exception:
return None
def get_chat_tasks_by_id(self, id: str) -> list[dict]:
"""Read the tasks list from a chat (lightweight column query)."""
with get_db_context() as db:
result = db.query(Chat.tasks).filter_by(id=id).first()
if result is None or result[0] is None:
return []
return result[0]
Chats = ChatTable()

View file

@ -215,6 +215,13 @@ class FeedbackTable:
query = db.query(Feedback, User).join(User, Feedback.user_id == User.id)
if filter:
# Apply model_id filter (exact match)
model_id = filter.get('model_id')
if model_id:
query = query.filter(
Feedback.data['model_id'].as_string() == model_id
)
order_by = filter.get('order_by')
direction = filter.get('direction')
@ -288,6 +295,17 @@ class FeedbackTable:
.all()
]
def get_distinct_model_ids(self, db: Optional[Session] = None) -> list[str]:
"""Get distinct model_ids from feedback data for filter dropdowns."""
with get_db_context(db) as db:
rows = (
db.query(Feedback.data['model_id'].as_string())
.filter(Feedback.data['model_id'].as_string().isnot(None))
.distinct()
.all()
)
return sorted([row[0] for row in rows if row[0]])
def get_feedbacks_for_leaderboard(self, db: Optional[Session] = None) -> list[LeaderboardFeedbackData]:
"""Fetch only id and data for leaderboard computation (excludes snapshot/meta)."""
with get_db_context(db) as db:

View file

@ -588,18 +588,13 @@ class UsersTable:
return None
@throttle(DATABASE_USER_ACTIVE_STATUS_UPDATE_INTERVAL)
def update_last_active_by_id(self, id: str, db: Optional[Session] = None) -> Optional[UserModel]:
def update_last_active_by_id(self, id: str, db: Optional[Session] = None) -> None:
try:
with get_db_context(db) as db:
user = db.query(User).filter_by(id=id).first()
if not user:
return None
user.last_active_at = int(time.time())
db.query(User).filter_by(id=id).update({'last_active_at': int(time.time())})
db.commit()
db.refresh(user)
return UserModel.model_validate(user)
except Exception:
return None
pass
def update_user_oauth_by_id(
self, id: str, provider: str, sub: str, db: Optional[Session] = None

View file

@ -0,0 +1,272 @@
import asyncio
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from open_webui.models.automations import (
Automations,
AutomationRuns,
AutomationForm,
AutomationModel,
AutomationResponse,
AutomationRunModel,
AutomationListResponse,
)
from open_webui.utils.automations import (
validate_rrule,
next_run_ns,
next_n_runs_ns,
execute_automation,
)
from open_webui.utils.auth import get_verified_user, get_admin_user
from open_webui.utils.access_control import has_permission
from open_webui.internal.db import get_session
from open_webui.constants import ERROR_MESSAGES
log = logging.getLogger(__name__)
router = APIRouter()
PAGE_ITEM_COUNT = 30
############################
# Helpers
############################
def check_automations_permission(request, user):
if user.role != 'admin' and not has_permission(
user.id, 'features.automations', request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
def check_automation_access(automation, user):
if not automation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if user.role != 'admin' and user.id != automation.user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
def enrich_automation(automation: AutomationModel, db: Session, tz: str = None) -> AutomationResponse:
last_run = AutomationRuns.get_latest(automation.id, db=db)
return AutomationResponse(
**automation.model_dump(),
last_run=last_run,
next_runs=next_n_runs_ns(automation.data['rrule'], tz=tz),
)
############################
# GetAutomationItems (paginated)
############################
@router.get('/list')
async def get_automation_items(
request: Request,
query: Optional[str] = None,
status: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_automations_permission(request, user)
limit = PAGE_ITEM_COUNT
page = max(1, page)
skip = (page - 1) * limit
result = Automations.search_automations(
user_id=user.id,
query=query,
status=status,
skip=skip,
limit=limit,
db=db,
)
return {
'items': [enrich_automation(item, db, tz=user.timezone) for item in result.items],
'total': result.total,
}
############################
# CreateNewAutomation
############################
@router.post('/create', response_model=AutomationResponse)
async def create_new_automation(
request: Request,
form_data: AutomationForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_automations_permission(request, user)
try:
validate_rrule(form_data.data.rrule)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
# Validate terminal server exists if linked
if form_data.data.terminal and form_data.data.terminal.server_id:
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
if not any(c.get('id') == form_data.data.terminal.server_id for c in connections):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Terminal server not found',
)
tz = user.timezone
automation = Automations.insert(user.id, form_data, next_run_ns(form_data.data.rrule, tz=tz), db=db)
return enrich_automation(automation, db, tz=tz)
############################
# GetAutomationById
############################
@router.get('/{id}', response_model=AutomationResponse)
async def get_automation_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_automations_permission(request, user)
automation = Automations.get_by_id(id, db=db)
check_automation_access(automation, user)
return enrich_automation(automation, db, tz=user.timezone)
############################
# UpdateAutomationById
############################
@router.post('/{id}/update', response_model=AutomationResponse)
async def update_automation_by_id(
request: Request,
id: str,
form_data: AutomationForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_automations_permission(request, user)
automation = Automations.get_by_id(id, db=db)
check_automation_access(automation, user)
try:
validate_rrule(form_data.data.rrule)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
# Validate terminal server exists if linked
if form_data.data.terminal and form_data.data.terminal.server_id:
connections = request.app.state.config.TERMINAL_SERVER_CONNECTIONS or []
if not any(c.get('id') == form_data.data.terminal.server_id for c in connections):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Terminal server not found',
)
tz = user.timezone
updated = Automations.update_by_id(id, form_data, next_run_ns(form_data.data.rrule, tz=tz), db=db)
return enrich_automation(updated, db, tz=tz)
############################
# ToggleAutomationById
############################
@router.post('/{id}/toggle', response_model=AutomationResponse)
async def toggle_automation_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_automations_permission(request, user)
automation = Automations.get_by_id(id, db=db)
check_automation_access(automation, user)
toggled = Automations.toggle(id, next_run_ns(automation.data['rrule'], tz=user.timezone), db=db)
return enrich_automation(toggled, db, tz=user.timezone)
############################
# RunAutomationById
############################
@router.post('/{id}/run')
async def run_automation_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_automations_permission(request, user)
automation = Automations.get_by_id(id, db=db)
check_automation_access(automation, user)
asyncio.create_task(execute_automation(request.app, automation))
return enrich_automation(automation, db, tz=user.timezone)
############################
# DeleteAutomationById
############################
@router.delete('/{id}/delete')
async def delete_automation_by_id(
request: Request,
id: str,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_automations_permission(request, user)
automation = Automations.get_by_id(id, db=db)
check_automation_access(automation, user)
AutomationRuns.delete_by_automation(id, db=db)
return Automations.delete(id, db=db)
############################
# GetAutomationRuns
############################
@router.get('/{id}/runs', response_model=list[AutomationRunModel])
async def get_automation_runs(
request: Request,
id: str,
skip: int = 0,
limit: int = 50,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_automations_permission(request, user)
automation = Automations.get_by_id(id, db=db)
check_automation_access(automation, user)
return AutomationRuns.get_by_automation(id, skip=skip, limit=limit, db=db)

View file

@ -218,7 +218,7 @@ async def get_all_channels(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
if user.role == 'admin':
return Channels.get_channels(db=db)
return Channels.get_channels_by_user_id(user.id, db=db)
@ -524,7 +524,7 @@ async def update_is_active_member_by_id_and_user_id(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
@ -757,7 +757,7 @@ async def get_pinned_channel_messages(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
@ -1094,7 +1094,7 @@ async def post_new_message(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
try:
message, channel = await new_message_handler(request, id, form_data, user, db)
@ -1143,7 +1143,7 @@ async def get_channel_message(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
@ -1183,7 +1183,7 @@ async def get_channel_message_data(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
@ -1223,7 +1223,7 @@ async def pin_channel_message(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
@ -1271,7 +1271,7 @@ async def get_channel_thread_messages(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
@ -1328,7 +1328,7 @@ async def update_message_by_id(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
@ -1395,7 +1395,7 @@ async def add_reaction_to_message(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
@ -1462,7 +1462,7 @@ async def remove_reaction_by_id_and_user_id_and_name(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
@ -1529,7 +1529,7 @@ async def delete_message_by_id(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
@ -1650,7 +1650,7 @@ async def get_channel_webhooks(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
@ -1670,7 +1670,7 @@ async def create_channel_webhook(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
@ -1695,7 +1695,7 @@ async def update_channel_webhook(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
@ -1723,7 +1723,7 @@ async def delete_channel_webhook(
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
check_channels_access(request)
check_channels_access(request, user)
channel = Channels.get_channel_by_id(id, db=db)
if not channel:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)

View file

@ -643,9 +643,10 @@ async def get_chat_list_by_folder_id(
limit = 10
skip = (page - 1) * limit
chats = Chats.get_chats_by_folder_id_and_user_id(folder_id, user.id, skip=skip, limit=limit, db=db)
return [
{'title': chat.title, 'id': chat.id, 'updated_at': chat.updated_at}
for chat in Chats.get_chats_by_folder_id_and_user_id(folder_id, user.id, skip=skip, limit=limit, db=db)
{'title': chat.title, 'id': chat.id, 'updated_at': chat.updated_at, 'last_read_at': chat.last_read_at}
for chat in chats
]
except Exception as e:

View file

@ -291,6 +291,11 @@ async def update_config(
}
@router.get('/feedbacks/models', response_model=list[str])
async def get_feedback_model_ids(user=Depends(get_admin_user), db: Session = Depends(get_session)):
return Feedbacks.get_distinct_model_ids(db=db)
@router.get('/feedbacks/all', response_model=list[FeedbackResponse])
async def get_all_feedbacks(user=Depends(get_admin_user), db: Session = Depends(get_session)):
feedbacks = Feedbacks.get_all_feedbacks(db=db)
@ -309,8 +314,17 @@ async def delete_all_feedbacks(user=Depends(get_admin_user), db: Session = Depen
@router.get('/feedbacks/all/export', response_model=list[FeedbackModel])
async def export_all_feedbacks(user=Depends(get_admin_user), db: Session = Depends(get_session)):
async def export_all_feedbacks(
model_id: Optional[str] = None,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
feedbacks = Feedbacks.get_all_feedbacks(db=db)
if model_id:
feedbacks = [
f for f in feedbacks
if f.data and f.data.get('model_id') == model_id
]
return feedbacks
@ -334,6 +348,7 @@ async def get_feedbacks(
order_by: Optional[str] = None,
direction: Optional[str] = None,
page: Optional[int] = 1,
model_id: Optional[str] = None,
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
@ -347,6 +362,8 @@ async def get_feedbacks(
filter['order_by'] = order_by
if direction:
filter['direction'] = direction
if model_id:
filter['model_id'] = model_id
result = Feedbacks.get_feedback_items(filter=filter, skip=skip, limit=limit, db=db)
return result

View file

@ -196,6 +196,14 @@ async def create_new_model(
)
else:
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
'sharing.public_models',
)
model = Models.insert_new_model(form_data, user.id, db=db)
if model:
return model
@ -460,6 +468,7 @@ async def toggle_model_by_id(id: str, user=Depends(get_verified_user), db: Sessi
@router.post('/model/update', response_model=Optional[ModelModel])
async def update_model_by_id(
request: Request,
form_data: ModelForm,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
@ -487,6 +496,14 @@ async def update_model_by_id(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
'sharing.public_models',
)
model = Models.update_model_by_id(form_data.id, ModelForm(**form_data.model_dump()), db=db)
return model

View file

@ -175,6 +175,15 @@ async def create_new_note(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
'sharing.public_notes',
db=db,
)
try:
note = Notes.insert_new_note(user.id, form_data, db=db)
return note

View file

@ -168,6 +168,14 @@ async def create_new_prompt(
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
'sharing.public_prompts',
)
prompt = Prompts.get_prompt_by_command(form_data.command, db=db)
if prompt is None:
prompt = Prompts.insert_new_prompt(user.id, form_data, db=db)
@ -275,6 +283,7 @@ async def get_prompt_by_id(prompt_id: str, user=Depends(get_verified_user), db:
@router.post('/id/{prompt_id}/update', response_model=Optional[PromptModel])
async def update_prompt_by_id(
request: Request,
prompt_id: str,
form_data: PromptForm,
user=Depends(get_verified_user),
@ -314,6 +323,14 @@ async def update_prompt_by_id(
detail=f"Command '/{form_data.command}' is already in use by another prompt",
)
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
'sharing.public_prompts',
)
# Use the ID from the found prompt
updated_prompt = Prompts.update_prompt_by_id(prompt.id, form_data, user.id, db=db)
if updated_prompt:

View file

@ -581,9 +581,9 @@ class WebConfig(BaseModel):
WEB_SEARCH_TRUST_ENV: Optional[bool] = None
WEB_SEARCH_RESULT_COUNT: Optional[int] = None
WEB_SEARCH_CONCURRENT_REQUESTS: Optional[int] = None
WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = []
WEB_FETCH_MAX_CONTENT_LENGTH: Optional[int] = None
WEB_LOADER_CONCURRENT_REQUESTS: Optional[int] = None
WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = []
BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None
BYPASS_WEB_SEARCH_WEB_LOADER: Optional[bool] = None
OLLAMA_CLOUD_WEB_SEARCH_API_KEY: Optional[str] = None
@ -1190,7 +1190,7 @@ async def update_rag_config(request: Request, form_data: ConfigForm, user=Depend
'WEB_SEARCH_TRUST_ENV': request.app.state.config.WEB_SEARCH_TRUST_ENV,
'WEB_SEARCH_RESULT_COUNT': request.app.state.config.WEB_SEARCH_RESULT_COUNT,
'WEB_SEARCH_CONCURRENT_REQUESTS': request.app.state.config.WEB_SEARCH_CONCURRENT_REQUESTS,
'FETCH_URL_MAX_CONTENT_LENGTH': request.app.state.config.FETCH_URL_MAX_CONTENT_LENGTH,
'WEB_FETCH_MAX_CONTENT_LENGTH': request.app.state.config.WEB_FETCH_MAX_CONTENT_LENGTH,
'WEB_LOADER_CONCURRENT_REQUESTS': request.app.state.config.WEB_LOADER_CONCURRENT_REQUESTS,
'WEB_SEARCH_DOMAIN_FILTER_LIST': request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
'BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL': request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
@ -2598,6 +2598,7 @@ async def process_files_batch(
request: Request,
form_data: BatchProcessFilesForm,
user=Depends(get_verified_user),
db=None,
) -> BatchProcessFilesResponse:
"""
Process a batch of files and save them to the vector database.
@ -2620,7 +2621,7 @@ async def process_files_batch(
for file in form_data.files:
try:
# Ownership check: verify the requesting user owns the file or is an admin
db_file = Files.get_file_by_id(file.id)
db_file = Files.get_file_by_id(file.id, db=db)
if not db_file:
file_errors.append(
BatchProcessFilesResult(
@ -2682,7 +2683,7 @@ async def process_files_batch(
# Update all files with collection name
for file_update, file_result in zip(file_updates, file_results):
Files.update_file_by_id(id=file_result.file_id, form_data=file_update)
Files.update_file_by_id(id=file_result.file_id, form_data=file_update, db=db)
file_result.status = 'completed'
except Exception as e:

View file

@ -105,6 +105,10 @@ async def proxy_terminal(
target_url += f'?{request.query_params}'
headers = {'X-User-Id': user.id}
# Forward per-session cwd tracking header
session_id = request.headers.get('x-session-id')
if session_id:
headers['X-Session-Id'] = session_id
cookies = {}
auth_type = connection.get('auth_type', 'bearer')

View file

@ -120,7 +120,7 @@ async def get_tools(
auth_type = server.get('auth_type', 'none')
session_token = None
if auth_type == 'oauth_2.1':
if auth_type in ('oauth_2.1', 'oauth_2.1_static'):
splits = server_id.split(':')
server_id = splits[-1] if len(splits) > 1 else server_id
@ -148,7 +148,7 @@ async def get_tools(
{
'authenticated': session_token is not None,
}
if auth_type == 'oauth_2.1'
if auth_type in ('oauth_2.1', 'oauth_2.1_static')
else {}
),
}
@ -354,6 +354,14 @@ async def create_new_tools(
tools = Tools.get_tool_by_id(form_data.id, db=db)
if tools is None:
try:
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
'sharing.public_tools',
)
form_data.content = replace_imports(form_data.content)
tool_module, frontmatter = load_tool_module_by_id(form_data.id, content=form_data.content)
form_data.meta.manifest = frontmatter
@ -481,6 +489,14 @@ async def update_tools_by_id(
specs = get_tool_specs(TOOLS[id])
form_data.access_grants = filter_allowed_access_grants(
request.app.state.config.USER_PERMISSIONS,
user.id,
user.role,
form_data.access_grants,
'sharing.public_tools',
)
updated = {
**form_data.model_dump(exclude={'id'}),
'specs': specs,

View file

@ -232,6 +232,7 @@ class FeaturesPermissions(BaseModel):
image_generation: bool = True
code_interpreter: bool = True
memories: bool = True
automations: bool = False
class SettingsPermissions(BaseModel):

View file

@ -491,6 +491,19 @@ async def channel_events(sid, data):
Channels.update_member_last_read_at(data['channel_id'], user['id'])
@sio.on('events:chat')
async def chat_events(sid, data):
user = SESSION_POOL.get(sid)
if not user:
return
event_data = data.get('data', {})
event_type = event_data.get('type')
if event_type == 'last_read_at':
await asyncio.to_thread(Chats.update_chat_last_read_at_by_id, data['chat_id'], user['id'])
def normalize_document_id(document_id: str) -> str:
"""Canonicalize document IDs to prevent auth bypass via prefix variants.
@ -601,10 +614,10 @@ async def document_save_handler(document_id, data, user):
user_id=user.get('id'),
resource_type='note',
resource_id=note.id,
permission='read',
permission='write',
)
):
log.error(f'User {user.get("id")} does not have access to note {note_id}')
log.error(f'User {user.get("id")} does not have write access to note {note_id}')
return
Notes.update_note_by_id(note_id, NoteUpdateForm(data=data))

View file

@ -393,7 +393,8 @@ async def execute_code(
if CODE_INTERPRETER_BLOCKED_MODULES:
import textwrap
blocking_code = textwrap.dedent(f"""
blocking_code = textwrap.dedent(
f"""
import builtins
BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES}
@ -409,7 +410,8 @@ async def execute_code(
return _real_import(name, globals, locals, fromlist, level)
builtins.__import__ = restricted_import
""")
"""
)
code = blocking_code + '\n' + code
engine = getattr(__request__.app.state.config, 'CODE_INTERPRETER_ENGINE', 'pyodide')
@ -2272,15 +2274,15 @@ async def query_knowledge_bases(
async def view_skill(
name: str,
id: str,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Load the full instructions of a skill by its name from the available skills manifest.
Load the full instructions of a skill by its id from the available skills manifest.
Use this when you need detailed instructions for a skill listed in <available_skills>.
:param name: The name of the skill to load (as shown in the manifest)
:param id: The id of the skill to load (as shown in the manifest)
:return: The full skill instructions as markdown content
"""
if __request__ is None:
@ -2295,11 +2297,11 @@ async def view_skill(
user_id = __user__.get('id')
# Direct DB lookup by unique name
skill = Skills.get_skill_by_name(name)
# Direct DB lookup by id (case-insensitive since IDs are stored lowercase)
skill = Skills.get_skill_by_id(id.lower())
if not skill or not skill.is_active:
return json.dumps({'error': f"Skill '{name}' not found"})
return json.dumps({'error': f"Skill '{id}' not found"})
# Check user access
user_role = __user__.get('role', 'user')
@ -2324,3 +2326,174 @@ async def view_skill(
except Exception as e:
log.exception(f'view_skill error: {e}')
return json.dumps({'error': str(e)})
# =============================================================================
# TASK MANAGEMENT TOOLS
# =============================================================================
from pydantic import BaseModel, Field
from typing import Literal
VALID_TASK_STATUSES = {'pending', 'in_progress', 'completed', 'cancelled'}
class TaskItem(BaseModel):
id: Optional[str] = Field(None, description='Unique identifier for the task. Auto-generated if omitted.')
content: Optional[str] = Field(None, description='Task description. Aliases: title, name, description.')
status: Literal['pending', 'in_progress', 'completed', 'cancelled'] = Field('pending', description='Task status.')
async def tasks(
tasks: Optional[list[TaskItem]] = None,
overwrite: bool = True,
__chat_id__: str = None,
__message_id__: str = None,
__event_emitter__: callable = None,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Track progress on multi-step work by maintaining a task checklist.
Use this whenever a request involves multiple steps or could take
significant effort. Call to set the full list, then call again
with overwrite=false after completing each task to mark it
completed. Do not leave tasks in_progress when the work is done.
Each task has an id, content, and status (pending, in_progress,
completed, cancelled).
:param tasks: Optional list of task items. Each item: id (string), content (string, required for new tasks), status (pending|in_progress|completed|cancelled). Leave empty to fetch without modifying.
:param overwrite: If true (default), replaces the entire task list. If false, updates/adds tasks by id while keeping existing ones.
:return: JSON with the full task list and summary counts
"""
if __chat_id__ is None:
return json.dumps({'error': 'Chat context not available'})
try:
def _to_dict(task) -> dict:
"""Convert TaskItem or dict to plain dict."""
if hasattr(task, 'model_dump'):
d = task.model_dump(exclude_none=True)
# Include any extra fields the model sent
if hasattr(task, 'model_extra') and task.model_extra:
d.update(task.model_extra)
return d
return dict(task) if not isinstance(task, dict) else task
def _resolve_content(d: dict) -> str:
"""Accept content, title, name, or description as the task text."""
for key in ('content', 'title', 'name', 'description'):
val = str(d.get(key, '')).strip()
if val:
return val
return ''
def _resolve_id(d: dict, idx: int) -> str:
"""Use provided id, or auto-generate from index."""
item_id = str(d.get('id', '') or '').strip()
return item_id if item_id else str(idx + 1)
if tasks is None:
# Read-only - return current list
all_tasks = Chats.get_chat_tasks_by_id(__chat_id__)
elif overwrite:
# Full replacement - validate and write
all_tasks = []
for idx, task in enumerate(tasks):
d = _to_dict(task)
item_id = _resolve_id(d, idx)
content = _resolve_content(d)
if not content:
continue
status = str(d.get('status', 'pending')).strip().lower()
if status not in VALID_TASK_STATUSES:
status = 'pending'
all_tasks.append(
{
'id': item_id,
'content': content,
'status': status,
}
)
else:
# Partial update - merge by id
existing_tasks = Chats.get_chat_tasks_by_id(__chat_id__)
existing_by_id = {t['id']: t for t in existing_tasks}
seen_ids = set()
for idx, task in enumerate(tasks):
d = _to_dict(task)
item_id = _resolve_id(d, len(existing_tasks) + idx)
seen_ids.add(item_id)
if item_id in existing_by_id:
resolved = _resolve_content(d)
if resolved:
existing_by_id[item_id]['content'] = resolved
status = str(d.get('status', '')).strip().lower()
if status and status in VALID_TASK_STATUSES:
existing_by_id[item_id]['status'] = status
else:
content = _resolve_content(d)
if not content:
continue
status = str(d.get('status', 'pending')).strip().lower()
if status not in VALID_TASK_STATUSES:
status = 'pending'
existing_by_id[item_id] = {
'id': item_id,
'content': content,
'status': status,
}
# Preserve order of existing, append new
all_tasks = []
for t in existing_tasks:
if t['id'] in existing_by_id:
all_tasks.append(existing_by_id[t['id']])
for item_id in seen_ids:
if not any(t['id'] == item_id for t in existing_tasks):
all_tasks.append(existing_by_id[item_id])
# Persist to DB and emit (skip for read-only)
if tasks is not None:
Chats.update_chat_tasks_by_id(__chat_id__, all_tasks)
if __event_emitter__:
await __event_emitter__(
{
'type': 'chat:message:tasks',
'data': {
'tasks': all_tasks,
},
}
)
# Build summary counts
pending = sum(1 for t in all_tasks if t['status'] == 'pending')
in_progress = sum(1 for t in all_tasks if t['status'] == 'in_progress')
completed = sum(1 for t in all_tasks if t['status'] == 'completed')
cancelled = sum(1 for t in all_tasks if t['status'] == 'cancelled')
return json.dumps(
{
'tasks': all_tasks,
'summary': {
'total': len(all_tasks),
'pending': pending,
'in_progress': in_progress,
'completed': completed,
'cancelled': cancelled,
},
},
ensure_ascii=False,
)
except Exception as e:
log.exception(f'tasks error: {e}')
return json.dumps({'error': str(e)})

View file

@ -0,0 +1,408 @@
"""
Automation utilities.
RRULE helpers, worker loop, and execution logic.
Follows the utils/<feature>.py pattern (cf. utils/channels.py, utils/task.py).
Environment:
AUTOMATION_POLL_INTERVAL seconds between polls (default: 10)
"""
import asyncio
import logging
import os
import random
import time
from datetime import datetime
from typing import Optional
from uuid import uuid4
from zoneinfo import ZoneInfo
from dateutil.rrule import rrulestr
from fastapi import Request
from starlette.datastructures import Headers
from open_webui.models.automations import Automations, AutomationRuns, AutomationModel
from open_webui.models.chats import ChatForm, Chats
from open_webui.models.users import Users
from open_webui.utils.task import prompt_template
from open_webui.internal.db import get_db
log = logging.getLogger(__name__)
AUTOMATION_POLL_INTERVAL = int(os.getenv('AUTOMATION_POLL_INTERVAL', '10'))
####################
# RRULE Helpers
####################
def _parse_rule(s: str):
"""Parse RRULE with clock-aligned DTSTART for sub-daily frequencies.
MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00)
so intervals snap to clock boundaries (e.g. every 5min = :00, :05, :10).
"""
raw = s.replace('RRULE:', '')
parts = dict(p.split('=', 1) for p in raw.split(';') if '=' in p)
freq = parts.get('FREQ', '')
if freq in ('MINUTELY', 'HOURLY'):
epoch = datetime(2000, 1, 1, 0, 0, 0)
return rrulestr(s, dtstart=epoch, ignoretz=True)
return rrulestr(s, ignoretz=True)
def validate_rrule(s: str) -> None:
"""Raise ValueError if the RRULE is malformed or exhausted."""
try:
rule = _parse_rule(s)
except Exception as e:
raise ValueError(f'Invalid RRULE: {e}')
if rule.after(datetime.now()) is None:
raise ValueError('RRULE has no future occurrences')
def next_run_ns(s: str, tz: str = None) -> Optional[int]:
"""Next occurrence as epoch nanoseconds, respecting user timezone."""
now = datetime.now(ZoneInfo(tz)) if tz else datetime.now()
dt = _parse_rule(s).after(now.replace(tzinfo=None))
if dt is None:
return None
if tz:
dt = dt.replace(tzinfo=ZoneInfo(tz))
return int(dt.timestamp() * 1_000_000_000)
def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]:
"""Compute next N occurrences for UI preview."""
rule = _parse_rule(s)
result = []
dt = datetime.now()
for _ in range(n):
dt = rule.after(dt)
if not dt:
break
if tz:
dt_tz = dt.replace(tzinfo=ZoneInfo(tz))
result.append(int(dt_tz.timestamp() * 1_000_000_000))
else:
result.append(int(dt.timestamp() * 1_000_000_000))
return result
############################
# Worker Loop
############################
async def automation_worker_loop(app) -> None:
"""Poll for due automations, claim, fire-and-forget execute.
Runs on every instance. Poll interval is configurable via
AUTOMATION_POLL_INTERVAL env var (default: 10 seconds).
"""
log.info(f'Automation worker started (poll interval: {AUTOMATION_POLL_INTERVAL}s)')
while True:
try:
with get_db() as db:
batch = Automations.claim_due(int(time.time_ns()), limit=10, db=db)
if batch:
log.info(f'Claimed {len(batch)} due automation(s)')
for automation in batch:
asyncio.create_task(execute_automation(app, automation))
except Exception:
log.exception('Automation worker error')
# Jitter to spread load across instances
await asyncio.sleep(AUTOMATION_POLL_INTERVAL + random.uniform(0, 2))
##########################
# Execute
####################
def _build_request(app) -> Request:
"""Build a minimal ASGI Request for chat_completion.
Mirrors the mock-request pattern used in main.py lifespan
(model pre-fetch, tool server init) for consistency.
"""
scope = {
'type': 'http',
'asgi': {'version': '3.0', 'spec_version': '2.0'},
'method': 'POST',
'path': '/api/v1/automations/internal',
'query_string': b'',
'headers': Headers({}).raw,
'client': ('127.0.0.1', 0),
'server': ('127.0.0.1', 80),
'scheme': 'http',
'app': app,
}
request = Request(scope)
# Ensure request.state is initialized with required attributes
request.state.token = None
request.state.enable_api_keys = False
return request
def _resolve_model_tool_ids(app, model_id: str) -> list[str]:
"""Read model-attached tool_ids from model config.
The frontend does this in Chat.svelte (model.info.meta.toolIds).
The backend never auto-resolves them, so we must do it explicitly.
"""
models = getattr(app.state, 'MODELS', {})
model = models.get(model_id, {})
tool_ids = model.get('info', {}).get('meta', {}).get('toolIds', [])
return list(tool_ids) if tool_ids else []
def _resolve_model_features(app, model_id: str) -> dict:
"""Read model default features from model config.
The frontend does this in Chat.svelte (model.info.meta.defaultFeatureIds
+ model.info.meta.capabilities). Enables features like web_search,
code_interpreter, image_generation when the model has them as defaults
AND the capability is enabled AND the admin has enabled the feature.
"""
models = getattr(app.state, 'MODELS', {})
model = models.get(model_id, {})
meta = model.get('info', {}).get('meta', {})
default_feature_ids = meta.get('defaultFeatureIds', [])
if not default_feature_ids:
return {}
capabilities = meta.get('capabilities', {})
config = app.state.config
features = {}
# code_interpreter is excluded: it requires the frontend event emitter
# and does not work in headless backend execution.
feature_checks = {
'web_search': getattr(config, 'ENABLE_WEB_SEARCH', False),
'image_generation': getattr(config, 'ENABLE_IMAGE_GENERATION', False),
}
for feature_id in default_feature_ids:
if feature_id in feature_checks:
# Feature must be: in defaultFeatureIds + capability enabled + admin enabled
if capabilities.get(feature_id) and feature_checks[feature_id]:
features[feature_id] = True
return features
def _resolve_model_filter_ids(app, model_id: str) -> list[str]:
"""Read model default filter_ids from model config."""
models = getattr(app.state, 'MODELS', {})
model = models.get(model_id, {})
filter_ids = model.get('info', {}).get('meta', {}).get('defaultFilterIds', [])
return list(filter_ids) if filter_ids else []
async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -> None:
"""Set the working directory on a terminal server via the proxy.
Routes through the open-webui terminal proxy endpoint so that
auth headers, orchestrator policy routing, and X-User-Id are
handled correctly same path the frontend uses.
"""
import aiohttp
connections = getattr(getattr(app, 'state', None), 'config', None)
if connections is None:
return
connections = getattr(connections, 'TERMINAL_SERVER_CONNECTIONS', None) or []
connection = next((c for c in connections if c.get('id') == server_id), None)
if connection is None:
log.warning(f'Terminal server {server_id} not found for CWD set')
return
base_url = (connection.get('url') or '').rstrip('/')
if not base_url:
return
# Build target URL — route through orchestrator policy if configured
policy_id = connection.get('policy_id')
if connection.get('server_type') == 'orchestrator' and policy_id:
target_url = f'{base_url}/p/{policy_id}/files/cwd'
else:
target_url = f'{base_url}/files/cwd'
headers = {'Content-Type': 'application/json', 'X-User-Id': user.id}
if chat_id:
headers['X-Session-Id'] = chat_id
auth_type = connection.get('auth_type', 'bearer')
if auth_type == 'bearer':
headers['Authorization'] = f'Bearer {connection.get("key", "")}'
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
async with session.post(
target_url,
json={'path': cwd},
headers=headers,
) as resp:
if resp.status != 200:
body = await resp.text()
log.warning(f'Failed to set terminal CWD to {cwd}: HTTP {resp.status}{body[:200]}')
except Exception as e:
log.warning(f'Failed to set terminal CWD: {e}')
async def execute_automation(app, automation: AutomationModel) -> None:
"""Execute an automation through the full chat completion pipeline.
Creates a real chat, then calls chat_completion exactly like the frontend:
session_id + chat_id + message_id async task pipeline handles everything
(filters, model params, knowledge/RAG, tools, DB saves, webhooks).
"""
try:
user = Users.get_user_by_id(automation.user_id)
if not user:
_record_run(automation.id, 'error', error='User not found')
return
prompt = prompt_template(automation.data['prompt'], user)
model_id = automation.data['model_id']
terminal_config = automation.data.get('terminal')
# Generate proper UUIDs for messages (same as frontend)
user_msg_id = str(uuid4())
assistant_msg_id = str(uuid4())
# Create the chat with user message (same structure as frontend)
chat = Chats.insert_new_chat(
automation.user_id,
ChatForm(
chat={
'title': automation.name,
'models': [model_id],
'history': {
'currentId': assistant_msg_id,
'messages': {
user_msg_id: {
'id': user_msg_id,
'parentId': None,
'role': 'user',
'content': prompt,
'childrenIds': [assistant_msg_id],
'timestamp': int(time.time()),
'models': [model_id],
},
assistant_msg_id: {
'id': assistant_msg_id,
'parentId': user_msg_id,
'role': 'assistant',
'content': '',
'done': False,
'model': model_id,
'childrenIds': [],
'timestamp': int(time.time()),
},
},
},
'messages': [
{'role': 'user', 'content': prompt},
],
'meta': {'automation_id': automation.id},
}
),
)
if not chat:
_record_run(automation.id, 'error', error='Failed to create chat')
return
# Notify frontend to refresh chat list
from open_webui.socket.main import sio
await sio.emit(
'events',
{
'chat_id': chat.id,
'message_id': user_msg_id,
'data': {'type': 'chat:list'},
},
room=f'user:{automation.user_id}',
)
# Resolve model defaults (frontend does this, backend doesn't)
tool_ids = _resolve_model_tool_ids(app, model_id)
features = _resolve_model_features(app, model_id)
filter_ids = _resolve_model_filter_ids(app, model_id)
# If a terminal is linked, set the CWD before building the payload
terminal_id = None
if terminal_config and terminal_config.get('server_id'):
terminal_id = terminal_config['server_id']
cwd = terminal_config.get('cwd')
if cwd:
await _set_terminal_cwd(app, terminal_id, user, cwd, chat.id)
# Build the same payload the frontend sends to /api/chat/completions
form_data = {
'model': model_id,
'messages': [{'role': 'user', 'content': prompt}],
'stream': True,
'chat_id': chat.id,
'id': assistant_msg_id,
'parent_id': user_msg_id,
'session_id': f'automation:{automation.id}',
'background_tasks': {},
}
if tool_ids:
form_data['tool_ids'] = tool_ids
if features:
form_data['features'] = features
if filter_ids:
form_data['filter_ids'] = filter_ids
if terminal_id:
form_data['terminal_id'] = terminal_id
# Call the full chat completion pipeline (same as POST /api/chat/completions).
# The handler reference is stored on app.state to avoid circular imports.
request = _build_request(app)
await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user)
# Notify user
from open_webui.socket.main import sio
await sio.emit(
'automation:result',
{
'automation_id': automation.id,
'name': automation.name,
'chat_id': chat.id,
'status': 'success',
},
room=f'user:{automation.user_id}',
)
_record_run(automation.id, 'success', chat_id=chat.id)
except Exception as e:
log.exception(f'Automation {automation.id} failed')
_record_run(automation.id, 'error', error=str(e)[:4000])
####################
# Internals
####################
def _record_run(
automation_id: str,
status: str,
chat_id: str = None,
error: str = None,
):
"""Insert a run record into automation_run."""
with get_db() as db:
AutomationRuns.insert(automation_id, status, chat_id=chat_id, error=error, db=db)

View file

@ -316,6 +316,10 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
models = request.app.state.MODELS
data = form_data
if not data.get('id'):
raise Exception('Missing message id')
model_id = data['model']
if model_id not in models:
raise Exception('Model not found')
@ -327,6 +331,9 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
except Exception as e:
raise Exception(f'Error: {e}')
if not data.get('id'):
raise Exception('Missing message id')
metadata = {
'chat_id': data['chat_id'],
'message_id': data['id'],

View file

@ -1,7 +1,10 @@
import asyncio
import logging
from typing import Optional
from contextlib import AsyncExitStack
log = logging.getLogger(__name__)
import anyio
from mcp import ClientSession
@ -9,22 +12,27 @@ from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.streamable_http import streamablehttp_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
import httpx
from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
from open_webui.env import AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL, AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER
def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
"""Create an httpx AsyncClient with SSL verification disabled.
def _build_httpx_client(headers=None, timeout=None, auth=None, verify=True):
"""Create an httpx AsyncClient for MCP transport.
Note: verify=False must be passed at construction time because httpx
Falls back to AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER when the caller
(i.e. the MCP SDK) does not supply an explicit timeout.
Note: verify must be passed at construction time because httpx
configures the SSL context during __init__. Setting client.verify = False
after construction does not affect the underlying transport's SSL context.
"""
kwargs = {
'follow_redirects': True,
'verify': False,
'verify': verify,
}
if timeout is not None:
kwargs['timeout'] = timeout
elif AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER is not None:
kwargs['timeout'] = float(AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER)
if headers is not None:
kwargs['headers'] = headers
if auth is not None:
@ -32,6 +40,14 @@ def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
return httpx.AsyncClient(**kwargs)
def create_httpx_client(headers=None, timeout=None, auth=None):
return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=True)
def create_insecure_httpx_client(headers=None, timeout=None, auth=None):
return _build_httpx_client(headers=headers, timeout=timeout, auth=auth, verify=False)
class MCPClient:
def __init__(self):
self.session: Optional[ClientSession] = None
@ -40,14 +56,13 @@ class MCPClient:
async def connect(self, url: str, headers: Optional[dict] = None):
async with AsyncExitStack() as exit_stack:
try:
if AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL:
self._streams_context = streamablehttp_client(url, headers=headers)
else:
self._streams_context = streamablehttp_client(
url,
headers=headers,
httpx_client_factory=create_insecure_httpx_client,
)
self._streams_context = streamablehttp_client(
url,
headers=headers,
httpx_client_factory=create_httpx_client
if AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
else create_insecure_httpx_client,
)
transport = await exit_stack.enter_async_context(self._streams_context)
read_stream, write_stream, _ = transport
@ -124,8 +139,39 @@ class MCPClient:
return result_dict
async def disconnect(self):
# Clean up and close the session
await self.exit_stack.aclose()
"""Clean up and close the session.
This method is idempotent calling it multiple times or on a
client that was never connected is safe. It shields the close
operation from CancelledError and adds a timeout so a hung MCP
server cannot block the event loop indefinitely.
"""
exit_stack = self.exit_stack
if exit_stack is None:
return
# Prevent double-close from concurrent callers
self.exit_stack = None
self.session = None
try:
await asyncio.wait_for(
asyncio.shield(exit_stack.aclose()),
timeout=5.0,
)
except asyncio.TimeoutError:
log.warning('MCPClient.disconnect() timed out after 5 s')
except RuntimeError as exc:
# The MCP SDK's streamable_http transport uses anyio task
# groups and async generators internally. When we close
# a session that was interrupted mid-flight these can
# raise RuntimeError ("aclose(): asynchronous generator is
# already running" or "Attempted to exit cancel scope in a
# different task"). Swallowing the error here prevents the
# orphaned coroutines from spinning at 100 % CPU.
log.debug('MCPClient.disconnect() suppressed RuntimeError: %s', exc)
except Exception as exc:
log.debug('MCPClient.disconnect() error: %s', exc)
async def __aenter__(self):
await self.exit_stack.__aenter__()

View file

@ -2370,11 +2370,16 @@ async def process_chat_payload(request, form_data, user, metadata, model):
)
else:
# Native FC: tool docstring can't be dynamic, so inject
# filesystem context into messages for pyodide engine
# filesystem context into the system message for pyodide
# engine. Appending to the system prompt (instead of the
# user message) keeps it in the stable cached prefix so
# providers with prefix caching don't re-bill the full
# conversation on every turn.
if engine != 'jupyter':
form_data['messages'] = add_or_update_user_message(
form_data['messages'] = add_or_update_system_message(
CODE_INTERPRETER_PYODIDE_PROMPT,
form_data['messages'],
append=True,
)
tool_ids = form_data.pop('tool_ids', None)
@ -2412,7 +2417,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
)
else:
# Model-attached: name+description only
skill_descriptions += f'<skill>\n<name>{skill.name}</name>\n<description>{skill.description or ""}</description>\n</skill>\n'
skill_descriptions += f'<skill>\n<id>{skill.id}</id>\n<name>{skill.name}</name>\n<description>{skill.description or ""}</description>\n</skill>\n'
if skill_descriptions:
form_data['messages'] = add_or_update_system_message(
@ -2507,7 +2512,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
oauth_token = extra_params.get('__oauth_token__', None)
if oauth_token:
headers['Authorization'] = f'Bearer {oauth_token.get("access_token", "")}'
elif auth_type == 'oauth_2.1':
elif auth_type in ('oauth_2.1', 'oauth_2.1_static'):
try:
splits = server_id.split(':')
server_id = splits[-1] if len(splits) > 1 else server_id
@ -2750,16 +2755,18 @@ async def process_chat_payload(request, form_data, user, metadata, model):
def get_event_emitter_and_caller(metadata):
event_emitter = None
event_caller = None
if (
'session_id' in metadata
and metadata['session_id']
and 'chat_id' in metadata
and metadata['chat_id']
and 'message_id' in metadata
and metadata['message_id']
):
# event_emitter only needs user_id + chat_id + message_id.
# It broadcasts to user:{user_id} room AND persists to DB,
# so it works for backend-initiated calls (automations, API).
if metadata.get('chat_id') and metadata.get('message_id'):
event_emitter = get_event_emitter(metadata)
# event_caller needs session_id — it calls back to a specific
# websocket session (used by direct tools, pyodide code interpreter).
if metadata.get('session_id') and metadata.get('chat_id') and metadata.get('message_id'):
event_caller = get_event_call(metadata)
return event_emitter, event_caller
@ -3206,7 +3213,9 @@ async def streaming_chat_response_handler(response, ctx):
]
# Standard streaming response handler
if event_emitter and event_caller:
# event_caller is optional — only needed for direct (client-side) tools
# and pyodide code interpreter. Server-side tools work without it.
if event_emitter:
task_id = str(uuid4()) # Create a unique task ID.
model_id = form_data.get('model', '')
@ -3632,6 +3641,16 @@ async def streaming_chat_response_handler(response, ctx):
if not choices:
error = data.get('error', {})
if error:
try:
Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
metadata['message_id'],
{
'error': {'content': error},
},
)
except Exception:
pass
await event_emitter(
{
'type': 'chat:completion',
@ -4607,6 +4626,7 @@ async def streaming_chat_response_handler(response, ctx):
'content': serialize_output(output),
'output': output,
'title': title,
**({'usage': usage} if usage else {}),
}
if not ENABLE_REALTIME_CHAT_SAVE:

View file

@ -961,18 +961,15 @@ def stream_chunks_handler(stream: aiohttp.StreamReader):
skip_mode = False
yield line
else:
yield b'data: {}'
yield b'\n'
yield b'data: {}\n'
else:
# Normal mode: check if line exceeds limit
if len(line) > max_buffer_size:
skip_mode = True
yield b'data: {}'
yield b'\n'
yield b'data: {}\n'
log.info(f'Skip mode triggered, line size: {len(line)}')
else:
yield line
yield b'\n'
yield line + b'\n'
# Save the last incomplete fragment
buffer = lines[-1]
@ -986,7 +983,6 @@ def stream_chunks_handler(stream: aiohttp.StreamReader):
# Process remaining buffer data
if buffer and not skip_mode:
yield buffer
yield b'\n'
yield buffer + b'\n'
return yield_safe_stream_chunks()

View file

@ -1354,12 +1354,13 @@ class OAuthManager:
if provider not in OAUTH_PROVIDERS:
raise HTTPException(404)
# If the provider has a custom redirect URL, use that, otherwise automatically generate one
redirect_uri = OAUTH_PROVIDERS[provider].get('redirect_uri') or request.url_for(
'oauth_login_callback', provider=provider
)
client = self.get_client(provider)
if client is None:
raise HTTPException(404)
redirect_uri = (
(client.server_metadata or {}).get('redirect_uri')
or request.url_for('oauth_login_callback', provider=provider)
)
kwargs = {}
if auth_manager_config.OAUTH_AUDIENCE:
@ -1683,7 +1684,7 @@ class OAuthManager:
httponly=True,
samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
secure=WEBUI_AUTH_COOKIE_SECURE,
**({'max_age': cookie_max_age, 'expires': cookie_expires} if cookie_max_age is not None else {}),
**({'max_age': cookie_max_age} if cookie_max_age is not None else {}),
)
log.info(f'Stored OAuth session server-side for user {user.id}, provider {provider}')

View file

@ -19,7 +19,7 @@ def get_task_model_id(default_model_id: str, task_model: str, task_model_externa
# Set the task model
task_model_id = default_model_id
# Check if the user has a custom task model and use that model
if models[task_model_id].get('connection_type') == 'local':
if models.get(task_model_id, {}).get('connection_type') == 'local':
if task_model and task_model in models:
task_model_id = task_model
else:

View file

@ -85,6 +85,7 @@ from open_webui.tools.builtin import (
view_file,
view_knowledge_file,
view_skill,
tasks,
)
import copy
@ -503,6 +504,10 @@ def get_builtin_tools(
if extra_params.get('__skill_ids__'):
builtin_functions.append(view_skill)
# Task management - break down complex work into trackable steps
if is_builtin_tool_enabled('tasks'):
builtin_functions.append(tasks)
for func in builtin_functions:
callable = get_async_tool_function_and_apply_extra_params(
func,
@ -1004,6 +1009,13 @@ async def get_terminal_tools(
# auth_type == "none": no Authorization header
system_prompt = server_data.get('system_prompt')
# Use chat_id as the per-session key for cwd tracking
metadata = extra_params.get('__metadata__', {})
session_id = metadata.get('chat_id')
if session_id:
headers['X-Session-Id'] = session_id
terminal_cwd = await get_terminal_cwd(connection.get('url', ''), headers, cookies)
tools_dict = {}

View file

@ -6,6 +6,11 @@ _ALLOWED_STATIC_PATHS = (
'/static/favicon.png',
)
# External URL prefixes that are explicitly trusted for profile images
_ALLOWED_URL_PREFIXES = (
'https://www.gravatar.com/avatar/',
)
def validate_profile_image_url(url: str) -> str:
"""
@ -15,6 +20,7 @@ def validate_profile_image_url(url: str) -> str:
- Empty string (falls back to default avatar)
- data:image/* URIs (base64-encoded uploads from the frontend)
- Known static asset paths (/user.png, /static/favicon.png)
- Trusted external URLs (e.g. Gravatar)
Returns the url unchanged if valid, raises ValueError otherwise.
"""
@ -33,4 +39,7 @@ def validate_profile_image_url(url: str) -> str:
if url in _ALLOWED_STATIC_PATHS:
return url
if any(url.startswith(prefix) for prefix in _ALLOWED_URL_PREFIXES):
return url
raise ValueError('Invalid profile image URL: only data URIs and default avatars are allowed.')

View file

@ -243,6 +243,19 @@ select {
animation: smoothFadeIn 0.2s forwards;
}
@keyframes fade-in-token {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in-token {
animation: fade-in-token 100ms ease-out;
}
.katex-mathml {
display: none;
}

View file

@ -413,6 +413,9 @@ export const updateUserProfile = async (token: string, profile: object) => {
.catch((err) => {
console.error(err);
error = err.detail;
if (Array.isArray(error)) {
error = error.map((e: { msg?: string }) => e.msg).join("; ");
}
return null;
});

View file

@ -0,0 +1,300 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export type AutomationTerminalConfig = {
server_id: string;
cwd?: string;
};
export type AutomationData = {
prompt: string;
model_id: string;
rrule: string;
terminal?: AutomationTerminalConfig;
};
export type AutomationForm = {
name: string;
data: AutomationData;
meta?: {
system_prompt?: string;
temperature?: number;
max_tokens?: number;
webhook?: string;
};
is_active?: boolean;
};
export type AutomationRunModel = {
id: string;
automation_id: string;
chat_id: string | null;
status: string;
error: string | null;
created_at: number;
};
export type AutomationResponse = {
id: string;
user_id: string;
name: string;
data: AutomationData;
meta: Record<string, any> | null;
is_active: boolean;
last_run_at: number | null;
next_run_at: number | null;
created_at: number;
updated_at: number;
last_run: AutomationRunModel | null;
next_runs: number[] | null;
};
export const getAutomationItems = async (
token: string,
query: string | null,
status: string | null,
page: number
): Promise<{ items: AutomationResponse[]; total: number }> => {
let error = null;
const searchParams = new URLSearchParams();
if (query) {
searchParams.append('query', query);
}
if (status && status !== 'all') {
searchParams.append('status', status);
}
if (page) {
searchParams.append('page', page.toString());
}
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/list?${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();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const createAutomation = async (token: string, form: AutomationForm) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(form)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getAutomationById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/${id}`, {
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();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateAutomationById = async (token: string, id: string, form: AutomationForm) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/${id}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(form)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const toggleAutomationById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/${id}/toggle`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const runAutomationById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/${id}/run`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteAutomationById = async (token: string, id: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/${id}/delete`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getAutomationRuns = async (
token: string,
id: string,
skip: number = 0,
limit: number = 50
) => {
let error = null;
const res = await fetch(
`${WEBUI_API_BASE_URL}/automations/${id}/runs?skip=${skip}&limit=${limit}`,
{
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();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -161,13 +161,42 @@ export const getModelHistory = async (token: string = '', modelId: string, days:
return res;
};
export const getFeedbackItems = async (token: string = '', orderBy, direction, page) => {
export const getFeedbackModelIds = async (token: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/feedbacks/models`, {
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();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getFeedbackItems = async (token: string = '', orderBy, direction, page, modelId: string = '') => {
let error = null;
const searchParams = new URLSearchParams();
if (orderBy) searchParams.append('order_by', orderBy);
if (direction) searchParams.append('direction', direction);
if (page) searchParams.append('page', page.toString());
if (modelId) searchParams.append('model_id', modelId);
const res = await fetch(
`${WEBUI_API_BASE_URL}/evaluations/feedbacks/list?${searchParams.toString()}`,
@ -200,10 +229,13 @@ export const getFeedbackItems = async (token: string = '', orderBy, direction, p
return res;
};
export const exportAllFeedbacks = async (token: string = '') => {
export const exportAllFeedbacks = async (token: string = '', modelId: string = '') => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/feedbacks/all/export`, {
const searchParams = new URLSearchParams();
if (modelId) searchParams.append('model_id', modelId);
const res = await fetch(`${WEBUI_API_BASE_URL}/evaluations/feedbacks/all/export?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',

View file

@ -161,9 +161,12 @@ export const getModels = async (
type ChatCompletedForm = {
model: string;
messages: string[];
messages: Record<string, unknown>[];
chat_id: string;
session_id: string;
session_id: string | undefined;
id: string;
filter_ids?: string[];
model_item?: unknown;
};
export const chatCompleted = async (token: string, body: ChatCompletedForm) => {
@ -454,7 +457,8 @@ export const executeToolServer = async (
url: string,
name: string,
params: Record<string, any>,
serverData: { openapi: any; info: any; specs: any }
serverData: { openapi: any; info: any; specs: any },
sessionId?: string
) => {
let error = null;
@ -531,6 +535,7 @@ export const executeToolServer = async (
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
};
if (sessionId) headers['X-Session-Id'] = sessionId;
const requestOptions: RequestInit = {
method: httpMethod.toUpperCase(),
@ -556,13 +561,24 @@ export const executeToolServer = async (
responseHeaders[key] = value;
});
const text = await res.text();
let responseData;
const contentType = res.headers.get('Content-Type')?.split(';')[0]?.trim() ?? '';
try {
responseData = JSON.parse(text);
responseData = await res.clone().json();
} catch {
responseData = text;
if (contentType.startsWith('text/') || !contentType) {
responseData = await res.text();
} else {
const buf = await res.arrayBuffer();
const bytes = new Uint8Array(buf);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
const b64 = btoa(binary);
responseData = `data:${contentType};base64,${b64}`;
}
}
return [responseData, responseHeaders];
} catch (err: any) {

View file

@ -45,11 +45,15 @@ export const getTerminalConfig = async (
return res.json().catch(() => null);
};
export const getCwd = async (baseUrl: string, apiKey: string): Promise<string | null> => {
export const getCwd = async (
baseUrl: string,
apiKey: string,
sessionId?: string
): Promise<string | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
}).catch(() => null);
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, { headers }).catch(() => null);
if (!res || !res.ok) return null;
const json = await res.json().catch(() => null);
return json?.cwd ?? null;
@ -58,13 +62,14 @@ export const getCwd = async (baseUrl: string, apiKey: string): Promise<string |
export const listFiles = async (
baseUrl: string,
apiKey: string,
path: string = '/'
path: string = '/',
sessionId?: string
): Promise<FileEntry[] | null> => {
// The endpoint uses `directory` as the query param name
const url = `${baseUrl.replace(/\/$/, '')}/files/list?directory=${encodeURIComponent(path)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
})
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, { headers })
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
@ -79,12 +84,13 @@ export const listFiles = async (
export const readFile = async (
baseUrl: string,
apiKey: string,
path: string
path: string,
sessionId?: string
): Promise<string | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/read?path=${encodeURIComponent(path)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
}).catch((err) => {
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, { headers }).catch((err) => {
console.error('open-terminal readFile error:', err);
return null;
});
@ -106,12 +112,13 @@ export const readFile = async (
export const downloadFileBlob = async (
baseUrl: string,
apiKey: string,
path: string
path: string,
sessionId?: string
): Promise<{ blob: Blob; filename: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/view?path=${encodeURIComponent(path)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` }
}).catch(() => null);
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, { headers }).catch(() => null);
if (!res || !res.ok) return null;
@ -123,15 +130,18 @@ export const downloadFileBlob = async (
export const archiveFromTerminal = async (
baseUrl: string,
apiKey: string,
paths: string[]
paths: string[],
sessionId?: string
): Promise<{ blob: Blob; filename: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/archive`;
const headers: Record<string, string> = {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
};
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
headers,
body: JSON.stringify({ paths })
}).catch(() => null);
@ -148,14 +158,17 @@ export const uploadToTerminal = async (
baseUrl: string,
apiKey: string,
directory: string,
file: File
file: File,
sessionId?: string
): Promise<{ path: string; size: number } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/upload?directory=${encodeURIComponent(directory)}`;
const body = new FormData();
body.append('file', file);
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
headers,
body
})
.then(async (res) => {
@ -172,15 +185,18 @@ export const uploadToTerminal = async (
export const createDirectory = async (
baseUrl: string,
apiKey: string,
path: string
path: string,
sessionId?: string
): Promise<{ path: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/mkdir`;
const headers: Record<string, string> = {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
};
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
headers,
body: JSON.stringify({ path })
})
.then(async (res) => {
@ -197,12 +213,15 @@ export const createDirectory = async (
export const deleteEntry = async (
baseUrl: string,
apiKey: string,
path: string
path: string,
sessionId?: string
): Promise<{ path: string; type: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/delete?path=${encodeURIComponent(path)}`;
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, {
method: 'DELETE',
headers: { Authorization: `Bearer ${apiKey}` }
headers
})
.then(async (res) => {
if (!res.ok) throw await res.json();
@ -218,15 +237,18 @@ export const deleteEntry = async (
export const setCwd = async (
baseUrl: string,
apiKey: string,
path: string
path: string,
sessionId?: string
): Promise<{ cwd: string } | null> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`;
const headers: Record<string, string> = {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
};
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
headers,
body: JSON.stringify({ path })
})
.then(async (res) => {
@ -244,15 +266,18 @@ export const moveEntry = async (
baseUrl: string,
apiKey: string,
source: string,
destination: string
destination: string,
sessionId?: string
): Promise<{ source: string; destination: string } | { error: string }> => {
const url = `${baseUrl.replace(/\/$/, '')}/files/move`;
const headers: Record<string, string> = {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
};
if (sessionId) headers['X-Session-Id'] = sessionId;
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
headers,
body: JSON.stringify({ source, destination })
})
.then(async (res) => {

View file

@ -16,10 +16,14 @@ export const getGravatarUrl = async (token: string, email: string) => {
})
.catch((err) => {
console.error(err);
error = err;
error = err.detail ?? err;
return null;
});
if (error) {
throw error;
}
return res;
};

View file

@ -0,0 +1,195 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import ScheduleDropdown from '$lib/components/automations/ScheduleDropdown.svelte';
import ModelDropdown from '$lib/components/automations/ModelDropdown.svelte';
import TerminalDropdown from '$lib/components/automations/TerminalDropdown.svelte';
import {
createAutomation,
updateAutomationById,
type AutomationForm,
type AutomationResponse
} from '$lib/apis/automations';
import { getTerminalServers, type TerminalServer } from '$lib/apis/terminal/index';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let show = false;
export let automation: AutomationResponse | null = null;
let name = '';
let prompt = '';
let model_id = '';
let is_active = true;
let loading = false;
// Terminal state
let terminalServers: TerminalServer[] = [];
let terminalServerId = '';
let terminalCwd = '';
// Schedule dropdown ref
let scheduleDropdown: ScheduleDropdown;
const submitHandler = async () => {
if (!name.trim() || !prompt.trim() || !model_id.trim()) {
toast.error($i18n.t('Name, prompt, and model are required'));
return;
}
if (scheduleDropdown?.frequency === 'ONCE') {
const scheduled = new Date(`${scheduleDropdown.onceDate}T${scheduleDropdown.onceTime}`);
if (scheduled <= new Date()) {
toast.error($i18n.t('Scheduled time must be in the future'));
return;
}
}
loading = true;
try {
const form: AutomationForm = {
name: name.trim(),
data: {
prompt: prompt.trim(),
model_id: model_id.trim(),
rrule: scheduleDropdown.buildRrule(),
...(terminalServerId
? {
terminal: {
server_id: terminalServerId,
...(terminalCwd.trim() ? { cwd: terminalCwd.trim() } : {})
}
}
: {})
},
is_active
};
if (automation) {
await updateAutomationById(localStorage.token, automation.id, form);
toast.success($i18n.t('Automation updated'));
show = false;
dispatch('save', { id: automation.id });
} else {
const created = await createAutomation(localStorage.token, form);
toast.success($i18n.t('Automation created'));
show = false;
dispatch('save', { id: created?.id });
}
} catch (e: any) {
toast.error(e?.detail ?? `${e}` ?? 'Failed to save');
} finally {
loading = false;
}
};
const init = async () => {
// Load terminal servers
try {
terminalServers = await getTerminalServers(localStorage.token);
} catch {
terminalServers = [];
}
if (automation) {
name = automation.name;
prompt = automation.data.prompt;
model_id = automation.data.model_id;
is_active = automation.is_active;
terminalServerId = automation.data.terminal?.server_id || '';
terminalCwd = automation.data.terminal?.cwd || '';
if (scheduleDropdown) {
scheduleDropdown.parseRrule(automation.data.rrule);
}
} else {
name = '';
prompt = '';
model_id = '';
is_active = true;
terminalServerId = '';
terminalCwd = '';
}
};
$: if (show) {
init();
}
</script>
<Modal size="md" bind:show>
<div>
<!-- Header -->
<div class="flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<input
class="w-full text-lg font-medium bg-transparent outline-hidden font-primary placeholder:text-gray-300 dark:placeholder:text-gray-700"
type="text"
bind:value={name}
placeholder={$i18n.t('Automation title')}
/>
<button
class="self-center shrink-0 ml-2"
aria-label={$i18n.t('Close')}
on:click={() => (show = false)}
>
<XMark className="size-5" />
</button>
</div>
<!-- Prompt -->
<div class="px-5 pb-2">
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Instructions')}</div>
<textarea
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 resize-none min-h-[12rem]"
bind:value={prompt}
rows={8}
placeholder={$i18n.t('Enter prompt here.')}
/>
</div>
<!-- Bottom toolbar -->
<div class="flex items-center justify-between px-4 pb-3.5 pt-1 gap-2">
<div class="flex items-center gap-0.5 flex-wrap flex-1 min-w-0">
<ScheduleDropdown bind:this={scheduleDropdown} side="top" align="start" />
<ModelDropdown bind:model_id side="top" align="start" />
<TerminalDropdown
{terminalServers}
bind:terminalServerId
bind:terminalCwd
side="top"
align="start"
/>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
class="px-3 py-1 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-200 transition"
type="button"
on:click={() => (show = false)}
>
{$i18n.t('Cancel')}
</button>
<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 flex items-center gap-2 {loading
? 'cursor-not-allowed'
: ''}"
on:click={submitHandler}
type="button"
disabled={loading}
>
{automation ? $i18n.t('Save') : $i18n.t('Create')}
{#if loading}
<span class="shrink-0"><Spinner /></span>
{/if}
</button>
</div>
</div>
</div>
</Modal>

View file

@ -10,7 +10,12 @@
import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
import { deleteFeedbackById, exportAllFeedbacks, getFeedbackItems } from '$lib/apis/evaluations';
import {
deleteFeedbackById,
exportAllFeedbacks,
getFeedbackItems,
getFeedbackModelIds
} from '$lib/apis/evaluations';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Download from '$lib/components/icons/Download.svelte';
@ -20,12 +25,15 @@
import FeedbackMenu from './FeedbackMenu.svelte';
import FeedbackModal from './FeedbackModal.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { config } from '$lib/stores';
import Spinner from '$lib/components/common/Spinner.svelte';
import Select from '$lib/components/common/Select.svelte';
import Check from '$lib/components/icons/Check.svelte';
let page = 1;
let items = null;
@ -34,6 +42,9 @@
let orderBy: string = 'updated_at';
let direction: 'asc' | 'desc' = 'desc';
let selectedModelId: string = '';
let modelIds: string[] = [];
const setSortKey = (key) => {
if (orderBy === key) {
direction = direction === 'asc' ? 'desc' : 'asc';
@ -64,12 +75,16 @@
const getFeedbacks = async () => {
try {
const res = await getFeedbackItems(localStorage.token, orderBy, direction, page).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
const res = await getFeedbackItems(
localStorage.token,
orderBy,
direction,
page,
selectedModelId
).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
items = res.items;
@ -80,10 +95,21 @@
}
};
$: if (orderBy && direction && page) {
$: if (orderBy && direction && page !== undefined) {
getFeedbacks();
}
const loadModelIds = async () => {
try {
const res = await getFeedbackModelIds(localStorage.token);
if (res) {
modelIds = res;
}
} catch (err) {
console.error(err);
}
};
const deleteFeedbackHandler = async (feedbackId: string) => {
const response = await deleteFeedbackById(localStorage.token, feedbackId).catch((err) => {
toast.error(err);
@ -123,19 +149,64 @@
window.addEventListener('message', messageHandler, false);
};
const exportHandler = async () => {
const _feedbacks = await exportAllFeedbacks(localStorage.token).catch((err) => {
toast.error(err);
return null;
const feedbacksToCsv = (feedbacks) => {
const rows = feedbacks.map((f) => {
const { data, ...rest } = f;
return {
id: rest.id,
user_id: rest.user_id,
chat_id: data?.chat_id ?? '',
model_id: data?.model_id ?? '',
sibling_model_ids: (data?.sibling_model_ids ?? []).join(';'),
rating: data?.rating ?? '',
reason: data?.reason ?? '',
comment: data?.comment ?? '',
created_at: rest.created_at,
updated_at: rest.updated_at
};
});
if (rows.length === 0) return '';
const headers = Object.keys(rows[0]);
const escape = (val) => {
const s = String(val ?? '');
return s.includes(',') || s.includes('"') || s.includes('\n')
? `"${s.replace(/"/g, '""')}"`
: s;
};
return [
headers.join(','),
...rows.map((r) => headers.map((h) => escape(r[h])).join(','))
].join('\n');
};
const exportHandler = async (format: 'json' | 'csv' = 'json') => {
const _feedbacks = await exportAllFeedbacks(localStorage.token, selectedModelId).catch(
(err) => {
toast.error(err);
return null;
}
);
if (_feedbacks) {
let blob = new Blob([JSON.stringify(_feedbacks)], {
type: 'application/json'
});
saveAs(blob, `feedback-history-export-${Date.now()}.json`);
if (format === 'csv') {
const csv = feedbacksToCsv(_feedbacks);
let blob = new Blob([csv], { type: 'text/csv' });
saveAs(blob, `feedback-history-export-${Date.now()}.csv`);
} else {
let blob = new Blob([JSON.stringify(_feedbacks)], {
type: 'application/json'
});
saveAs(blob, `feedback-history-export-${Date.now()}.json`);
}
}
};
onMount(() => {
loadModelIds();
});
</script>
<FeedbackModal bind:show={showFeedbackModal} {selectedFeedback} onClose={closeFeedbackModal} />
@ -145,236 +216,318 @@
<Spinner className="size-5" />
</div>
{:else}
<div class="mt-0.5 mb-1 gap-1 flex flex-row justify-between">
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
<div>
{$i18n.t('Feedback History')}
<div class="flex flex-col gap-1 mt-0.5 mb-3">
<div class="flex justify-between items-center">
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
<div>
{$i18n.t('Feedback History')}
</div>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{total}
</div>
</div>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{total}
<div class="flex w-full justify-end gap-1.5">
{#if total > 0}
<Dropdown align="end">
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
>
<div class="self-center font-medium line-clamp-1">
{$i18n.t('Export')}
</div>
<ChevronDown className="size-3" strokeWidth="2.5" />
</button>
<div slot="content">
<div
class="w-[170px] rounded-2xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
>
<button
class="select-none flex w-full gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
type="button"
on:click={() => exportHandler('json')}
>
{$i18n.t('Export as JSON')}
</button>
<button
class="select-none flex w-full gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
type="button"
on:click={() => exportHandler('csv')}
>
{$i18n.t('Export as CSV')}
</button>
</div>
</div>
</Dropdown>
{/if}
</div>
</div>
</div>
{#if total > 0}
<div>
<Tooltip content={$i18n.t('Export')}>
<button
class=" p-2 rounded-xl hover:bg-gray-100 dark:bg-gray-900 dark:hover:bg-gray-850 transition font-medium text-sm flex items-center space-x-1"
on:click={() => {
exportHandler();
<div
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30"
>
{#if modelIds.length > 0}
<div
class="px-2.5 flex w-full bg-transparent overflow-x-auto scrollbar-none mb-1"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex gap-0.5 w-fit text-center text-sm rounded-full bg-transparent whitespace-nowrap"
>
<Select
bind:value={selectedModelId}
items={[
{ value: '', label: $i18n.t('All') },
...modelIds.map((mid) => ({ value: mid, label: mid }))
]}
placeholder={$i18n.t('All')}
triggerClass="relative w-full flex items-center gap-0.5 px-2.5 py-1.5 bg-gray-50 dark:bg-gray-850 rounded-xl"
onChange={() => {
page = 1;
getFeedbacks();
}}
>
<Download className="size-3" />
</button>
</Tooltip>
<svelte:fragment slot="trigger" let:selectedLabel>
<span
class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
>
{selectedLabel}
</span>
<ChevronDown className="size-3.5" strokeWidth="2.5" />
</svelte:fragment>
<svelte:fragment slot="item" let:item let:selected>
{item.label}
<div class="ml-auto {selected ? '' : 'invisible'}">
<Check />
</div>
</svelte:fragment>
</Select>
</div>
</div>
{/if}
</div>
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
{#if (items ?? []).length === 0}
<div class="text-center text-xs text-gray-500 dark:text-gray-400 py-1">
{$i18n.t('No feedback found')}
</div>
{:else}
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full"
>
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none w-3"
on:click={() => setSortKey('user')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('User')}
{#if orderBy === 'user'}
<span class="font-normal">
{#if direction === 'asc'}
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full px-2">
{#if (items ?? []).length === 0}
<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 feedback 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>
{:else}
<table
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full px-2"
>
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none w-3"
on:click={() => setSortKey('user')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('User')}
{#if orderBy === 'user'}
<span class="font-normal">
{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('model_id')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Models')}
{#if orderBy === 'model_id'}
<span class="font-normal">
{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 text-right cursor-pointer select-none w-fit"
on:click={() => setSortKey('rating')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Result')}
{#if orderBy === 'rating'}
<span class="font-normal">
{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 text-right cursor-pointer select-none w-0"
on:click={() => setSortKey('updated_at')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Updated At')}
{#if orderBy === 'updated_at'}
<span class="font-normal">
{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right cursor-pointer select-none w-0"> </th>
</tr>
</thead>
<tbody class="">
{#each items as feedback (feedback.id)}
<tr
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
on:click={() => openFeedbackModal(feedback)}
>
<td class=" py-0.5 text-right font-medium">
<div class="flex justify-center">
<Tooltip content={feedback?.user?.name}>
<div class="shrink-0">
<img
src={`${WEBUI_API_BASE_URL}/users/${feedback.user.id}/profile/image`}
alt={feedback?.user?.name}
class="size-5 rounded-full object-cover shrink-0"
/>
</div>
</Tooltip>
</span>
{/if}
</div>
</td>
</th>
<td class=" py-1 pl-3 flex flex-col">
<div class="flex flex-col items-start gap-0.5 h-full">
<div class="flex flex-col h-full">
{#if feedback.data?.sibling_model_ids}
<Tooltip content={feedback.data?.model_id} placement="top-start">
<div
class="font-medium text-gray-600 dark:text-gray-400 flex-1 line-clamp-1"
>
{feedback.data?.model_id}
</div>
</Tooltip>
<Tooltip content={feedback.data.sibling_model_ids.join(', ')}>
<div class=" text-[0.65rem] text-gray-600 dark:text-gray-400 line-clamp-1">
{#if feedback.data.sibling_model_ids.length > 2}
<!-- {$i18n.t('and {{COUNT}} more')} -->
{feedback.data.sibling_model_ids.slice(0, 2).join(', ')}, {$i18n.t(
'and {{COUNT}} more',
{ COUNT: feedback.data.sibling_model_ids.length - 2 }
)}
{:else}
{feedback.data.sibling_model_ids.join(', ')}
{/if}
</div>
</Tooltip>
{:else}
<Tooltip content={feedback.data?.model_id} placement="top-start">
<div
class="text-sm font-medium text-gray-600 dark:text-gray-400 flex-1 py-1.5 line-clamp-1"
>
{feedback.data?.model_id}
</div>
</Tooltip>
{/if}
</div>
<th
scope="col"
class="px-2.5 py-2 cursor-pointer select-none"
on:click={() => setSortKey('model_id')}
>
<div class="flex gap-1.5 items-center">
{$i18n.t('Models')}
{#if orderBy === 'model_id'}
<span class="font-normal">
{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</td>
</th>
{#if feedback?.data?.rating}
<td class="px-3 py-1 text-right font-medium text-gray-900 dark:text-white w-max">
<div class=" flex justify-end">
{#if feedback?.data?.rating.toString() === '1'}
<Badge type="info" content={$i18n.t('Won')} />
{:else if feedback?.data?.rating.toString() === '0'}
<Badge type="muted" content={$i18n.t('Draw')} />
{:else if feedback?.data?.rating.toString() === '-1'}
<Badge type="error" content={$i18n.t('Lost')} />
{/if}
<th
scope="col"
class="px-2.5 py-2 text-right cursor-pointer select-none w-fit"
on:click={() => setSortKey('rating')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Result')}
{#if orderBy === 'rating'}
<span class="font-normal">
{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th
scope="col"
class="px-2.5 py-2 text-right cursor-pointer select-none w-0"
on:click={() => setSortKey('updated_at')}
>
<div class="flex gap-1.5 items-center justify-end">
{$i18n.t('Updated At')}
{#if orderBy === 'updated_at'}
<span class="font-normal">
{#if direction === 'asc'}
<ChevronUp className="size-2" />
{:else}
<ChevronDown className="size-2" />
{/if}
</span>
{:else}
<span class="invisible">
<ChevronUp className="size-2" />
</span>
{/if}
</div>
</th>
<th scope="col" class="px-2.5 py-2 text-right cursor-pointer select-none w-0"> </th>
</tr>
</thead>
<tbody class="">
{#each items as feedback (feedback.id)}
<tr
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition rounded-xl"
on:click={() => openFeedbackModal(feedback)}
>
<td class=" py-0.5 text-right font-medium">
<div class="flex justify-center">
<Tooltip content={feedback?.user?.name}>
<div class="shrink-0">
<img
src={`${WEBUI_API_BASE_URL}/users/${feedback.user.id}/profile/image`}
alt={feedback?.user?.name}
class="size-5 rounded-full object-cover shrink-0"
/>
</div>
</Tooltip>
</div>
</td>
{/if}
<td class=" px-3 py-1 text-right font-medium">
{dayjs(feedback.updated_at * 1000).fromNow()}
</td>
<td class=" py-1 pl-3 flex flex-col">
<div class="flex flex-col items-start gap-0.5 h-full">
<div class="flex flex-col h-full">
{#if feedback.data?.sibling_model_ids}
<Tooltip content={feedback.data?.model_id} placement="top-start">
<div
class="font-medium text-gray-600 dark:text-gray-400 flex-1 line-clamp-1"
>
{feedback.data?.model_id}
</div>
</Tooltip>
<td class=" px-3 py-1 text-right font-medium" on:click={(e) => e.stopPropagation()}>
<FeedbackMenu
on:delete={(e) => {
deleteFeedbackHandler(feedback.id);
}}
>
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
<Tooltip content={feedback.data.sibling_model_ids.join(', ')}>
<div
class=" text-[0.65rem] text-gray-600 dark:text-gray-400 line-clamp-1"
>
{#if feedback.data.sibling_model_ids.length > 2}
<!-- {$i18n.t('and {{COUNT}} more')} -->
{feedback.data.sibling_model_ids.slice(0, 2).join(', ')}, {$i18n.t(
'and {{COUNT}} more',
{ COUNT: feedback.data.sibling_model_ids.length - 2 }
)}
{:else}
{feedback.data.sibling_model_ids.join(', ')}
{/if}
</div>
</Tooltip>
{:else}
<Tooltip content={feedback.data?.model_id} placement="top-start">
<div
class="text-sm font-medium text-gray-600 dark:text-gray-400 flex-1 py-1.5 line-clamp-1"
>
{feedback.data?.model_id}
</div>
</Tooltip>
{/if}
</div>
</div>
</td>
{#if feedback?.data?.rating}
<td class="px-3 py-1 text-right font-medium text-gray-900 dark:text-white w-max">
<div class=" flex justify-end">
{#if feedback?.data?.rating.toString() === '1'}
<Badge type="info" content={$i18n.t('Won')} />
{:else if feedback?.data?.rating.toString() === '0'}
<Badge type="muted" content={$i18n.t('Draw')} />
{:else if feedback?.data?.rating.toString() === '-1'}
<Badge type="error" content={$i18n.t('Lost')} />
{/if}
</div>
</td>
{/if}
<td class=" px-3 py-1 text-right font-medium">
{dayjs(feedback.updated_at * 1000).fromNow()}
</td>
<td class=" px-3 py-1 text-right font-medium" on:click={(e) => e.stopPropagation()}>
<FeedbackMenu
on:delete={(e) => {
deleteFeedbackHandler(feedback.id);
}}
>
<EllipsisHorizontal />
</button>
</FeedbackMenu>
</td>
</tr>
{/each}
</tbody>
</table>
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
>
<EllipsisHorizontal />
</button>
</FeedbackMenu>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{#if total > 30}
<Pagination bind:page count={total} perPage={30} />
{/if}
</div>
{#if total > 30}
<Pagination bind:page count={total} perPage={30} />
{/if}
{/if}

View file

@ -291,7 +291,7 @@
class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
>
<div
class="hidden md:flex w-full rounded-full px-2.5 gap-2 bg-gray-100/80 dark:bg-gray-850/80 backdrop-blur-2xl my-1 -mx-1 mt-1.5"
class="hidden lg:flex w-full rounded-full px-2.5 gap-2 bg-gray-100/80 dark:bg-gray-850/80 backdrop-blur-2xl my-1 -mx-1 mt-1.5"
id="settings-search"
>
<div class="self-center rounded-l-xl bg-transparent">

View file

@ -894,6 +894,28 @@
</div>
{/if}
</div>
<div class="flex flex-col w-full">
<Tooltip
className="flex w-full justify-between my-1"
content={$i18n.t(
'Warning: Enabling this will allow users to run scheduled prompts automatically.'
)}
placement="top-start"
>
<div class=" self-center text-xs font-medium">
{$i18n.t('Automations')}
</div>
<Switch bind:state={permissions.features.automations} />
</Tooltip>
{#if defaultPermissions?.features?.automations && !permissions.features.automations}
<div>
<div class="text-xs text-gray-500">
{$i18n.t('This is a default user permission and will remain enabled.')}
</div>
</div>
{/if}
</div>
</div>
<hr class=" border-gray-100/30 dark:border-gray-850/30" />

View file

@ -0,0 +1,498 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import { WEBUI_NAME, showSidebar } from '$lib/stores';
import {
updateAutomationById,
toggleAutomationById,
runAutomationById,
deleteAutomationById,
getAutomationRuns,
type AutomationForm,
type AutomationResponse,
type AutomationRunModel
} from '$lib/apis/automations';
import { getTerminalServers, type TerminalServer } from '$lib/apis/terminal/index';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import ScheduleDropdown from '$lib/components/automations/ScheduleDropdown.svelte';
import ModelDropdown from '$lib/components/automations/ModelDropdown.svelte';
import TerminalDropdown from '$lib/components/automations/TerminalDropdown.svelte';
dayjs.extend(relativeTime);
dayjs.extend(localizedFormat);
const i18n = getContext('i18n');
export let automation: AutomationResponse;
let name = '';
let prompt = '';
let model_id = '';
let is_active = true;
let terminalServers: TerminalServer[] = [];
let terminalServerId = '';
let terminalCwd = '';
let loading = false;
let saving = false;
let showDeleteConfirm = false;
let runs: AutomationRunModel[] = [];
let runsLoading = false;
let hasMoreRuns = true;
let runsPage = 0;
let isDirty = false;
let scheduleDropdown: ScheduleDropdown;
const formatRunTime = (ts: number): string => {
const now = Date.now();
const diff = now - ts / 1_000_000;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const years = Math.floor(days / 365);
if (years > 0) return $i18n.t('{{COUNT}}y', { COUNT: years, context: 'time_ago' });
if (weeks > 0) return $i18n.t('{{COUNT}}w', { COUNT: weeks, context: 'time_ago' });
if (days > 0) return $i18n.t('{{COUNT}}d', { COUNT: days, context: 'time_ago' });
if (hours > 0) return $i18n.t('{{COUNT}}h', { COUNT: hours, context: 'time_ago' });
if (minutes > 0) return $i18n.t('{{COUNT}}m', { COUNT: minutes, context: 'time_ago' });
return $i18n.t('1m', { context: 'time_ago' });
};
const formatNextRun = (ts: number | null): string => {
if (!ts) return $i18n.t('Not scheduled');
const d = dayjs(ts / 1_000_000);
if (d.isSame(dayjs(), 'day')) return `${$i18n.t('Today at')} ${d.format('LT')}`;
return d.format('L LT');
};
const saveHandler = async () => {
if (!name.trim() || !prompt.trim() || !model_id.trim()) {
toast.error($i18n.t('Name, prompt, and model are required'));
return;
}
saving = true;
try {
const form: AutomationForm = {
name: name.trim(),
data: {
prompt: prompt.trim(),
model_id: model_id.trim(),
rrule: scheduleDropdown.buildRrule(),
...(terminalServerId
? {
terminal: {
server_id: terminalServerId,
...(terminalCwd.trim() ? { cwd: terminalCwd.trim() } : {})
}
}
: {})
},
is_active
};
const updated = await updateAutomationById(localStorage.token, automation.id, form);
if (updated) {
automation = updated;
isDirty = false;
toast.success($i18n.t('Automation updated'));
}
} catch (e: any) {
toast.error(e?.detail ?? `${e}` ?? 'Failed to save');
} finally {
saving = false;
}
};
const toggleHandler = async () => {
const res = await toggleAutomationById(localStorage.token, automation.id).catch((err) => {
toast.error(`${err}`);
return null;
});
if (res) {
is_active = res.is_active;
automation = res;
}
};
const runNowHandler = async () => {
loading = true;
const res = await runAutomationById(localStorage.token, automation.id).catch((err) => {
toast.error(`${err}`);
return null;
});
if (res) {
toast.success($i18n.t('Automation triggered'));
setTimeout(() => loadRuns(false), 2000);
}
loading = false;
};
const deleteHandler = async () => {
const res = await deleteAutomationById(localStorage.token, automation.id).catch((err) => {
toast.error(`${err}`);
return null;
});
if (res) {
toast.success($i18n.t(`Deleted {{name}}`, { name: automation.name }));
goto('/automations');
}
};
const loadRuns = async (loadMore = false) => {
if (runsLoading || (!hasMoreRuns && loadMore)) return;
runsLoading = true;
if (!loadMore) {
runsPage = 0;
hasMoreRuns = true;
}
try {
const fetchedRuns =
(await getAutomationRuns(localStorage.token, automation.id, runsPage * 50, 50)) ?? [];
if (loadMore) {
runs = [...runs, ...fetchedRuns];
} else {
runs = fetchedRuns;
}
if (fetchedRuns.length < 50) {
hasMoreRuns = false;
}
runsPage++;
} catch {
if (!loadMore) runs = [];
}
runsLoading = false;
};
const markDirty = () => {
isDirty = true;
};
const onScroll = (e: Event) => {
const target = e.target as HTMLElement;
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 50) {
if (!runsLoading && hasMoreRuns) {
loadRuns(true);
}
}
};
onMount(async () => {
name = automation.name;
prompt = automation.data.prompt;
model_id = automation.data.model_id;
is_active = automation.is_active;
terminalServerId = automation.data.terminal?.server_id || '';
terminalCwd = automation.data.terminal?.cwd || '';
if (scheduleDropdown) {
scheduleDropdown.parseRrule(automation.data.rrule);
}
try {
terminalServers = await getTerminalServers(localStorage.token);
} catch {
terminalServers = [];
}
await loadRuns();
});
</script>
<svelte:head>
<title>{name || $i18n.t('Automation')}{$WEBUI_NAME}</title>
</svelte:head>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete automation?')}
on:confirm={deleteHandler}
>
<div class="text-sm text-gray-500 truncate">
{$i18n.t('This will delete')} <span class="">{automation.name}</span>.
</div>
</DeleteConfirmDialog>
<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%-var(--sidebar-width))]'
: ''} max-w-full"
>
<div class="flex-1 max-h-full flex flex-col pt-3 pb-1 px-3 md:px-[18px]">
<!-- Header Segment (Shrink-0 so it doesn't compress) -->
<div class="flex items-start justify-between gap-4 shrink-0 mb-0.5">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 mb-1.5">
<Tooltip content={$i18n.t('Back')}>
<button
class="text-sm p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition"
aria-label={$i18n.t('Back')}
on:click={() => goto('/automations')}
type="button"
>
<ChevronLeft strokeWidth="2.5" />
</button>
</Tooltip>
<input
class="text-2xl w-full bg-transparent outline-hidden"
placeholder={$i18n.t('Automation Name')}
bind:value={name}
on:input={markDirty}
/>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Tooltip content={$i18n.t('Delete')}>
<button
class="p-2 rounded-full bg-transparent hover:bg-gray-50 dark:hover:bg-gray-850 text-gray-500 hover:text-black dark:hover:text-white transition"
on:click={() => (showDeleteConfirm = true)}
type="button"
>
<GarbageBin />
</button>
</Tooltip>
{#if isDirty}
<button
class="px-4 py-1.5 text-sm bg-black text-white dark:bg-white dark:text-black rounded-full hover:opacity-90 transition flex items-center gap-1.5"
on:click={saveHandler}
disabled={saving}
type="button"
>
{$i18n.t('Save')}
{#if saving}
<Spinner className="size-3" />
{/if}
</button>
{/if}
<button
class="px-4 py-1.5 text-sm border border-gray-200 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-850 transition rounded-full flex items-center gap-1.5"
on:click={runNowHandler}
type="button"
disabled={loading}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M6.3 2.84A1.5 1.5 0 0 0 4 4.11v11.78a1.5 1.5 0 0 0 2.3 1.27l9.344-5.891a1.5 1.5 0 0 0 0-2.538L6.3 2.841Z"
/>
</svg>
<div class="hidden md:block">{$i18n.t('Run now')}</div>
{#if loading}
<Spinner className="size-3" />
{/if}
</button>
</div>
</div>
<!-- Content Segment: Independent Scrolling Columns based on PromptEditor -->
<div class="flex flex-col md:flex-row gap-4 flex-1 overflow-hidden pb-2 px-1">
<!-- Main Input Column -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<div class="flex items-center justify-between mb-2 shrink-0 px-1">
<div class="text-gray-500 text-xs">{$i18n.t('Instructions')}</div>
</div>
<div class="relative flex-1 min-h-0">
<div
class="bg-gray-50 dark:bg-gray-900 rounded-2xl p-4 border border-gray-100/50 dark:border-gray-850/50 h-full"
>
<textarea
class="w-full h-full text-sm bg-transparent outline-hidden resize-none placeholder:text-gray-300 dark:placeholder:text-gray-700"
bind:value={prompt}
on:input={markDirty}
placeholder={$i18n.t('Enter the prompt instructions for this automation...')}
/>
</div>
</div>
</div>
<!-- Sidebar Configuration Column -->
<div class="hidden md:flex w-full md:w-80 shrink-0 overflow-y-auto px-1 flex-col gap-5">
<div>
<div class="text-gray-500 text-xs mb-3">{$i18n.t('Configuration')}</div>
<div class="space-y-1">
<!-- Schedule -->
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">{$i18n.t('Repeats')}</span>
<ScheduleDropdown
bind:this={scheduleDropdown}
side="bottom"
align="end"
onChange={markDirty}
/>
</div>
<!-- Model -->
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">{$i18n.t('Model')}</span>
<ModelDropdown bind:model_id side="bottom" align="end" onChange={markDirty} />
</div>
<!-- Terminal -->
{#if terminalServers.length > 0}
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">{$i18n.t('Terminal')}</span>
<TerminalDropdown
{terminalServers}
bind:terminalServerId
bind:terminalCwd
side="bottom"
align="end"
onChange={markDirty}
/>
</div>
{/if}
</div>
</div>
<!-- Status section -->
<div>
<div class="text-gray-500 text-xs mb-3">{$i18n.t('Status')}</div>
<div class="space-y-2.5">
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">{$i18n.t('State')}</span>
<div
class="flex items-center gap-1.5 px-2.5 py-1 rounded-xl text-xs transition {is_active
? 'text-emerald-700 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-500/10'
: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-800'}"
>
<span
class="inline-block size-1.5 rounded-full {is_active
? 'bg-emerald-500'
: 'bg-gray-400'}"
></span>
<span>{is_active ? $i18n.t('Active') : $i18n.t('Paused')}</span>
</div>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">{$i18n.t('Next run')}</span>
<span class=" text-gray-700 dark:text-gray-300"
>{formatNextRun(automation.next_runs?.[0] ?? automation.next_run_at)}</span
>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">{$i18n.t('Last ran')}</span>
<span class=" text-gray-700 dark:text-gray-300"
>{automation.last_run_at
? formatNextRun(automation.last_run_at)
: $i18n.t('Never')}</span
>
</div>
</div>
</div>
<div class="flex-1 flex flex-col min-h-0">
<div class="text-gray-500 text-xs mb-2 shrink-0">
{$i18n.t('Execution Logs')}
</div>
<div class="flex-1 overflow-y-auto scrollbar-hidden w-full" on:scroll={onScroll}>
{#if runsLoading && runs.length === 0}
<div class="flex justify-center py-4">
<Spinner className="size-4" />
</div>
{:else if runs.length === 0}
<div class="text-xs text-gray-400 py-4 tabular-nums">
{$i18n.t('No execution logs available yet')}
</div>
{:else}
<div class="space-y-0.5 w-full">
{#each runs as run (run.id)}
<button
class="w-full text-left flex items-center gap-2.5 px-2.5 py-2 rounded-xl hover:bg-gray-100/80 dark:hover:bg-gray-850/80 transition-colors {run.chat_id
? 'cursor-pointer'
: 'cursor-default'}"
on:click={() => {
if (run.chat_id) goto(`/c/${run.chat_id}`);
}}
type="button"
>
<div class="shrink-0 flex items-center justify-center">
{#if run.status === 'success'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3 text-emerald-500"
><path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"
/></svg
>
{:else if run.status === 'error'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3 text-red-500"
><path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
clip-rule="evenodd"
/></svg
>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3 text-blue-500"
><path
d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-13a.75.75 0 00-1.5 0v5c0 .414.336.75.75.75h4a.75.75 0 000-1.5h-3.25V5z"
/></svg
>
{/if}
</div>
<div class="flex-1 min-w-0">
<div class="text-xs text-gray-800 dark:text-gray-200 truncate">
{automation.name}
</div>
</div>
<span class="shrink-0 text-[10px] text-gray-500 font-mono"
>{formatRunTime(run.created_at)}</span
>
</button>
{/each}
{#if runsLoading && runs.length > 0}
<div class="flex justify-center py-4">
<Spinner className="size-4" />
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,101 @@
<script lang="ts">
import { getContext } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
export let editHandler: Function;
export let runHandler: Function = () => {};
export let deleteHandler: Function;
export let onClose: Function = () => {};
let show = false;
</script>
<Dropdown
bind:show
onOpenChange={(state) => {
if (state === false) {
onClose();
}
}}
>
<Tooltip content={$i18n.t('More')}>
<slot />
</Tooltip>
<div slot="content">
<div
class="min-w-[170px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
>
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
draggable="false"
on:click={() => {
editHandler();
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
/>
</svg>
<div class="flex items-center">{$i18n.t('Edit')}</div>
</button>
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
draggable="false"
on:click={() => {
runHandler();
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
/>
</svg>
<div class="flex items-center">{$i18n.t('Run Now')}</div>
</button>
<hr class="border-gray-50 dark:border-gray-850/30 my-1" />
<button
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
draggable="false"
on:click={() => {
deleteHandler();
show = false;
}}
>
<GarbageBin />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</button>
</div>
</div>
</Dropdown>

View file

@ -0,0 +1,124 @@
<script lang="ts">
import { getContext } from 'svelte';
import { models } from '$lib/stores';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import Search from '$lib/components/icons/Search.svelte';
const i18n = getContext('i18n');
export let model_id = '';
export let side: 'top' | 'bottom' = 'top';
export let align: 'start' | 'end' = 'start';
/** Optional callback when selection changes */
export let onChange: () => void = () => {};
let showDropdown = false;
let modelSearch = '';
$: modelLabel = model_id
? $models.find((m) => m.id === model_id)?.name || model_id
: $i18n.t('Select model');
$: filteredModels = modelSearch
? $models.filter(
(m) =>
m.name.toLowerCase().includes(modelSearch.toLowerCase()) ||
m.id.toLowerCase().includes(modelSearch.toLowerCase())
)
: $models;
</script>
<Dropdown bind:show={showDropdown} {side} {align}>
<button
type="button"
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-2xl text-xs transition
text-gray-600 dark:text-gray-400 hover:bg-black/5 dark:hover:bg-white/5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-3.5 shrink-0"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 0 0-2.455 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z"
/>
</svg>
<span class="whitespace-nowrap max-w-32 truncate">{modelLabel}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
<div
slot="content"
class="rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-72 p-1"
>
<div class="flex items-center gap-2 px-2.5 py-1.5">
<Search className="size-3.5" strokeWidth="2.5" />
<input
bind:value={modelSearch}
class="w-full text-sm bg-transparent outline-hidden"
placeholder={$i18n.t('Search a model')}
autocomplete="off"
on:click={(e) => e.stopPropagation()}
/>
</div>
<div class="overflow-y-auto scrollbar-thin max-h-60">
<div class="px-2 text-xs text-gray-500 py-1">
{$i18n.t('Models')}
</div>
{#each filteredModels as model (model.id)}
<button
class="px-2.5 py-1.5 rounded-xl w-full text-left text-sm {model_id === model.id
? 'bg-gray-50 dark:bg-gray-800'
: ''}"
type="button"
on:click={() => {
model_id = model.id;
showDropdown = false;
modelSearch = '';
onChange();
}}
>
<div class="flex text-black dark:text-gray-100 line-clamp-1">
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${encodeURIComponent(model.id)}`}
alt={model?.name ?? model.id}
class="rounded-full size-5 items-center mr-2"
loading="lazy"
on:error={(e) => {
e.currentTarget.src = '/favicon.png';
}}
/>
<div class="truncate">
{model.name}
</div>
</div>
</button>
{:else}
<div class="block px-3 py-2 text-sm text-gray-700 dark:text-gray-100">
{$i18n.t('No results found')}
</div>
{/each}
</div>
</div>
</Dropdown>

View file

@ -0,0 +1,304 @@
<script lang="ts">
import { getContext } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
const i18n = getContext('i18n');
export let frequency = 'DAILY';
export let interval = 1;
export let hour = 9;
export let minute = 0;
export let selectedDays: string[] = [];
export let monthDay = 1;
export let onceDate = '';
export let onceTime = '09:00';
export let customRrule = '';
export let side: 'top' | 'bottom' = 'top';
export let align: 'start' | 'end' = 'start';
/** Optional callback when any value changes */
export let onChange: () => void = () => {};
let showDropdown = false;
const FREQUENCIES = [
{ key: 'ONCE', label: 'Once' },
{ key: 'HOURLY', label: 'Hourly' },
{ key: 'DAILY', label: 'Daily' },
{ key: 'WEEKLY', label: 'Weekly' },
{ key: 'MONTHLY', label: 'Monthly' },
{ key: 'CUSTOM', label: 'Custom' }
];
const DAYS = [
{ key: 'MO', label: 'Mo' },
{ key: 'TU', label: 'Tu' },
{ key: 'WE', label: 'We' },
{ key: 'TH', label: 'Th' },
{ key: 'FR', label: 'Fr' },
{ key: 'SA', label: 'Sa' },
{ key: 'SU', label: 'Su' }
];
let lastVisualFrequency = 'DAILY';
let prevFrequency = 'DAILY';
$: if (frequency !== 'CUSTOM') {
lastVisualFrequency = frequency;
}
$: if (frequency === 'ONCE' && !onceDate) {
const soon = new Date(Date.now() + 5 * 60_000);
onceDate = soon.toISOString().split('T')[0];
onceTime = `${String(soon.getHours()).padStart(2, '0')}:${String(soon.getMinutes()).padStart(2, '0')}`;
}
$: {
if (frequency === 'CUSTOM' && prevFrequency !== 'CUSTOM') {
customRrule = buildVisualRrule();
}
prevFrequency = frequency;
}
const buildVisualRrule = (): string => {
if (lastVisualFrequency === 'ONCE') {
const dt = onceDate.replace(/-/g, '') + 'T' + onceTime.replace(/:/g, '') + '00';
return `DTSTART:${dt}\nRRULE:FREQ=DAILY;COUNT=1`;
}
let parts = [`FREQ=${lastVisualFrequency}`];
if (interval > 1) parts.push(`INTERVAL=${interval}`);
if (lastVisualFrequency === 'WEEKLY' && selectedDays.length) {
parts.push(`BYDAY=${selectedDays.join(',')}`);
}
if (lastVisualFrequency === 'MONTHLY') {
parts.push(`BYMONTHDAY=${monthDay}`);
}
if (['DAILY', 'WEEKLY', 'MONTHLY'].includes(lastVisualFrequency)) {
parts.push(`BYHOUR=${hour}`);
}
parts.push(`BYMINUTE=${minute}`);
return `RRULE:${parts.join(';')}`;
};
export const buildRrule = (): string => {
if (frequency === 'CUSTOM') return customRrule;
if (frequency === 'ONCE') {
const dt = onceDate.replace(/-/g, '') + 'T' + onceTime.replace(/:/g, '') + '00';
return `DTSTART:${dt}\nRRULE:FREQ=DAILY;COUNT=1`;
}
let parts = [`FREQ=${frequency}`];
if (interval > 1) parts.push(`INTERVAL=${interval}`);
if (frequency === 'WEEKLY' && selectedDays.length) {
parts.push(`BYDAY=${selectedDays.join(',')}`);
}
if (frequency === 'MONTHLY') {
parts.push(`BYMONTHDAY=${monthDay}`);
}
if (['DAILY', 'WEEKLY', 'MONTHLY'].includes(frequency)) {
parts.push(`BYHOUR=${hour}`);
}
parts.push(`BYMINUTE=${minute}`);
return `RRULE:${parts.join(';')}`;
};
export const parseRrule = (s: string) => {
// Detect ONCE (COUNT=1 with DTSTART)
if (s.includes('COUNT=1')) {
frequency = 'ONCE';
const match = s.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
if (match) {
onceDate = `${match[1]}-${match[2]}-${match[3]}`;
onceTime = `${match[4]}:${match[5]}`;
}
return;
}
const parts: Record<string, string> = {};
s.replace('RRULE:', '')
.split(';')
.forEach((p) => {
const [k, v] = p.split('=');
if (k && v) parts[k] = v;
});
const freq = parts.FREQ || 'DAILY';
if (!['HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY'].includes(freq)) {
frequency = 'CUSTOM';
customRrule = s;
return;
}
frequency = freq;
interval = parseInt(parts.INTERVAL || '1');
hour = parseInt(parts.BYHOUR || '9');
minute = parseInt(parts.BYMINUTE || '0');
selectedDays = parts.BYDAY ? parts.BYDAY.split(',') : [];
monthDay = parseInt(parts.BYMONTHDAY || '1');
};
export const getScheduleLabel = (): string => {
if (frequency === 'ONCE') return 'Once';
if (frequency === 'HOURLY') return 'Hourly';
if (frequency === 'DAILY') return 'Daily';
if (frequency === 'WEEKLY') return 'Weekly';
if (frequency === 'MONTHLY') return 'Monthly';
if (frequency === 'CUSTOM') return 'Custom';
return 'Schedule';
};
$: scheduleLabel = (() => {
if (frequency === 'ONCE') return 'Once';
if (frequency === 'HOURLY') return 'Hourly';
if (frequency === 'DAILY') return 'Daily';
if (frequency === 'WEEKLY') return 'Weekly';
if (frequency === 'MONTHLY') return 'Monthly';
if (frequency === 'CUSTOM') return 'Custom';
return 'Schedule';
})();
</script>
<Dropdown bind:show={showDropdown} {side} {align}>
<button
type="button"
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-2xl text-xs transition
text-gray-600 dark:text-gray-400 hover:bg-black/5 dark:hover:bg-white/5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<span class="whitespace-nowrap">{scheduleLabel}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
<div
slot="content"
class="rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-48 p-1"
>
<div class="px-2 text-xs text-gray-500 py-1">
{$i18n.t('Schedule')}
</div>
<div class="px-1.5 mt-0.5 mb-2">
<select
class="w-full bg-transparent rounded-xl text-xs py-1.5 px-1.5 outline-hidden"
bind:value={frequency}
on:click={(e) => e.stopPropagation()}
on:change={onChange}
>
{#each FREQUENCIES as f}
<option value={f.key}>{f.label}</option>
{/each}
</select>
</div>
{#if frequency === 'CUSTOM'}
<div class="px-2 pb-2">
<input
type="text"
bind:value={customRrule}
placeholder="RRULE:FREQ=DAILY;BYHOUR=9;BYMINUTE=0"
class="w-full bg-transparent outline-hidden text-xs placeholder:text-gray-400 dark:placeholder:text-gray-600"
on:click={(e) => e.stopPropagation()}
on:input={onChange}
/>
</div>
{:else if frequency !== 'HOURLY'}
<div class="flex gap-2 flex-wrap items-center px-3 pb-2 text-xs">
{#if frequency === 'ONCE'}
<div class="flex items-center gap-1.5">
<input
type="date"
bind:value={onceDate}
min={new Date().toISOString().split('T')[0]}
class="bg-transparent outline-hidden text-xs dark:color-scheme-dark"
on:click={(e) => e.stopPropagation()}
on:input={onChange}
/>
</div>
<div class="flex items-center gap-1.5">
<input
type="time"
bind:value={onceTime}
class="bg-transparent outline-hidden text-xs dark:color-scheme-dark"
on:click={(e) => e.stopPropagation()}
on:input={onChange}
/>
</div>
{:else}
<div class="flex items-center gap-1.5">
<span class="text-xs text-gray-500 mr-0.5">{$i18n.t('Time')}</span>
<input
type="time"
value={`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`}
on:input={(e) => {
const [h, m] = e.currentTarget.value.split(':').map(Number);
hour = h;
minute = m;
onChange();
}}
class="bg-transparent text-center outline-hidden text-xs dark:color-scheme-dark"
on:click={(e) => e.stopPropagation()}
/>
</div>
{/if}
{#if frequency === 'MONTHLY'}
<div class="flex items-center gap-1.5">
<span class="text-xs text-gray-500">{$i18n.t('Day')}</span>
<input
type="number"
bind:value={monthDay}
min={1}
max={31}
class="w-8 bg-transparent text-center outline-hidden text-xs"
on:click={(e) => e.stopPropagation()}
on:input={onChange}
/>
</div>
{/if}
</div>
{#if frequency === 'WEEKLY'}
<div class="flex gap-1 px-2 pb-2">
{#each DAYS as d}
<button
type="button"
class="flex-1 py-1 text-xs rounded-xl transition {selectedDays.includes(d.key)
? 'bg-gray-50 dark:bg-gray-800 text-black dark:text-gray-100'
: 'text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-200'}"
on:click={() => {
if (selectedDays.includes(d.key)) {
selectedDays = selectedDays.filter((x) => x !== d.key);
} else {
selectedDays = [...selectedDays, d.key];
}
onChange();
}}
>
{d.label}
</button>
{/each}
</div>
{/if}
{/if}
</div>
</Dropdown>

View file

@ -0,0 +1,118 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { TerminalServer } from '$lib/apis/terminal/index';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import Cloud from '$lib/components/icons/Cloud.svelte';
const i18n = getContext('i18n');
export let terminalServers: TerminalServer[] = [];
export let terminalServerId = '';
export let terminalCwd = '';
export let side: 'top' | 'bottom' = 'top';
export let align: 'start' | 'end' = 'start';
/** Optional callback when selection changes */
export let onChange: () => void = () => {};
let showDropdown = false;
$: terminalLabel = terminalServerId
? terminalServers.find((s) => s.id === terminalServerId)?.name || 'Terminal'
: $i18n.t('Terminal');
</script>
{#if terminalServers.length > 0}
<Dropdown bind:show={showDropdown} {side} {align}>
<button
type="button"
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-2xl text-xs transition
{terminalServerId ? 'text-black dark:text-gray-100' : 'text-gray-600 dark:text-gray-400'}
hover:bg-black/5 dark:hover:bg-white/5"
>
<Cloud className="size-3.5 shrink-0" strokeWidth="2" />
<span class="whitespace-nowrap max-w-32 truncate">{terminalLabel}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-2.5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
<div
slot="content"
class="rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 min-w-56 max-w-56 p-1"
>
<div class="px-2 text-xs text-gray-500 py-1">
{$i18n.t('Terminal')}
</div>
{#each terminalServers as server (server.id)}
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm cursor-pointer rounded-xl {terminalServerId ===
server.id
? 'bg-gray-50 dark:bg-gray-800/50'
: 'hover:bg-gray-50 dark:hover:bg-gray-800/50'}"
type="button"
on:click={() => {
if (terminalServerId === server.id) {
terminalServerId = '';
terminalCwd = '';
} else {
terminalServerId = server.id;
}
showDropdown = false;
onChange();
}}
>
<div class="flex flex-1 gap-2 items-center truncate">
<Cloud className="size-4 shrink-0" strokeWidth="2" />
<span class="truncate">{server.name || server.id}</span>
</div>
{#if terminalServerId === server.id}
<div class="shrink-0 text-emerald-600 dark:text-emerald-400">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-4"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
clip-rule="evenodd"
/>
</svg>
</div>
{/if}
</button>
{/each}
{#if terminalServerId}
<div class="border-t border-gray-100 dark:border-gray-800 mt-1 pt-1">
<div class="px-2.5 py-1 text-xs text-gray-500">
{$i18n.t('Working Directory')}
</div>
<div class="px-2">
<input
type="text"
bind:value={terminalCwd}
placeholder="/home/user/project"
class="w-full bg-transparent outline-hidden text-xs py-1.5 placeholder:text-gray-400 dark:placeholder:text-gray-600"
on:click={(e) => e.stopPropagation()}
on:input={onChange}
/>
</div>
</div>
{/if}
</div>
</Dropdown>
{/if}

View file

@ -622,6 +622,25 @@
}
}
})
},
{
char: ':',
allowSpaces: false,
command: ({ editor, range, props }) => {
// Convert the Unicode hex codepoint (e.g. "1F44B") to the actual emoji character (👋)
const codepoint = props.id;
const emoji = String.fromCodePoint(parseInt(codepoint, 16));
editor.chain().focus().deleteRange(range).insertContent(emoji).run();
},
render: getSuggestionRenderer(CommandSuggestionList, {
i18n,
onSelect: (e) => {
document.getElementById('chat-input')?.focus();
},
insertTextHandler: insertTextAtCursor,
onUpload: () => {}
})
}
];
loaded = true;

File diff suppressed because it is too large Load diff

View file

@ -153,7 +153,9 @@
{/if}
</div>
<div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1">
<div
class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400 gap-1 shrink-0"
>
{#if channel}
<Tooltip content={$i18n.t('Pinned Messages')}>
<button
@ -164,7 +166,7 @@
showChannelPinnedMessagesModal = true;
}}
>
<div class=" flex items-center gap-0.5 m-auto self-center">
<div class=" flex items-center gap-0.5 m-auto self-center shrink-0">
<Pin className=" size-4" strokeWidth="1.5" />
</div>
</button>
@ -173,17 +175,17 @@
{#if channel?.user_count !== undefined}
<Tooltip content={$i18n.t('Users')}>
<button
class=" flex cursor-pointer py-1 px-1.5 border dark:border-gray-850 border-gray-50 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
class=" flex cursor-pointer shrink-0 py-1 px-1.5 border dark:border-gray-850 border-gray-50 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="User Count"
type="button"
on:click={() => {
showChannelInfoModal = true;
}}
>
<div class=" flex items-center gap-0.5 m-auto self-center">
<div class=" flex items-center gap-0.5 m-auto self-center shrink-0">
<UserAlt className=" size-4" strokeWidth="1.5" />
<div class="text-sm">
<div class="text-sm shrink-0">
{channel.user_count}
</div>
</div>
@ -193,30 +195,32 @@
{/if}
{#if $user !== undefined}
<UserMenu
className="w-[240px]"
role={$user?.role}
help={true}
on:show={(e) => {
if (e.detail === 'archived-chat') {
showArchivedChats.set(true);
}
}}
>
<button
class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="User Menu"
<div>
<UserMenu
className="w-[240px]"
role={$user?.role}
help={true}
on:show={(e) => {
if (e.detail === 'archived-chat') {
showArchivedChats.set(true);
}
}}
>
<div class=" self-center">
<img
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
class="size-6 object-cover rounded-full"
alt="User profile"
draggable="false"
/>
</div>
</button>
</UserMenu>
<button
class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
aria-label="User Menu"
>
<div class=" self-center">
<img
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
class="size-6 object-cover rounded-full"
alt="User profile"
draggable="false"
/>
</div>
</button>
</UserMenu>
</div>
{/if}
</div>
</div>

View file

@ -159,6 +159,8 @@
let chat = null;
let tags = [];
let chatTasks = [];
let history = {
messages: {},
currentId: null
@ -199,6 +201,11 @@
await tick();
// Mark chat read when initially loading it
if (chatIdProp && !$temporaryChatEnabled) {
updateLastReadAt(chatIdProp);
}
// Process any queued requests if the chat is idle
const lastMessage = history.currentId ? history.messages[history.currentId] : null;
const isIdle = !lastMessage || lastMessage.role !== 'assistant' || lastMessage.done;
@ -239,7 +246,7 @@
messageInput?.setText(data, async () => {
if (!($settings?.insertSuggestionPrompt ?? false)) {
await tick();
submitPrompt(prompt);
submitHandler(prompt);
}
});
}
@ -401,6 +408,13 @@
saveChatHandler(_chatId, history);
};
const updateLastReadAt = (id) => {
$socket?.emit('events:chat', {
chat_id: id,
data: { type: 'last_read_at' }
});
};
const terminalEventHandler = (type: string, data: any) => {
if (type === 'terminal:display_file') {
if (!data?.path) return;
@ -449,6 +463,8 @@
message.content = data.content;
} else if (type === 'chat:message:files' || type === 'files') {
message.files = data.files;
} else if (type === 'chat:message:tasks') {
chatTasks = data.tasks;
} else if (type === 'chat:message:embeds' || type === 'embeds') {
message.embeds = data.embeds;
@ -586,7 +602,7 @@
if (prompt !== '') {
await tick();
submitPrompt(prompt);
submitHandler(prompt);
}
}
@ -607,7 +623,7 @@
if (event.data.text !== '') {
if (isSameOrigin) {
await tick();
submitPrompt(event.data.text);
submitHandler(event.data.text);
} else {
// Cross-origin: ask user to confirm before submitting
eventConfirmationInput = false;
@ -616,7 +632,7 @@
eventCallback = async (confirmed: boolean) => {
if (confirmed) {
await tick();
submitPrompt(event.data.text);
submitHandler(event.data.text);
}
};
showEventConfirmation = true;
@ -764,6 +780,9 @@
return () => {
try {
if (chatIdProp && !$temporaryChatEnabled) {
updateLastReadAt(chatIdProp);
}
pageSubscribe();
showControlsSubscribe();
selectedFolderSubscribe();
@ -1156,6 +1175,7 @@
chatFiles = [];
params = {};
taskIds = null;
chatTasks = [];
if ($page.url.searchParams.get('youtube')) {
await uploadWeb(`https://www.youtube.com/watch?v=${$page.url.searchParams.get('youtube')}`);
@ -1212,7 +1232,7 @@
if (q) {
if (($page.url.searchParams.get('submit') ?? 'true') === 'true') {
await tick();
submitPrompt(q);
submitHandler(q);
}
}
}
@ -1268,12 +1288,20 @@
params = chatContent?.params ?? {};
chatFiles = chatContent?.files ?? [];
// Load tasks from chat-level DB field
chatTasks = chat?.tasks ?? [];
autoScroll = true;
await tick();
if (history.currentId) {
for (const message of Object.values(history.messages)) {
if (message && message.role === 'assistant' && message.done !== false) {
if (
message &&
message.role === 'assistant' &&
message.id !== history.currentId &&
message.done !== false
) {
message.done = true;
}
}
@ -1329,12 +1357,19 @@
return rest;
});
files = combinedFiles;
await tick();
await submitPrompt(combinedPrompt);
await submitPrompt(combinedPrompt, combinedFiles);
};
const chatCompletedHandler = async (_chatId, modelId, responseMessageId, messages) => {
if (!responseMessageId) {
console.error('chatCompleted: missing message id', {
chatId: _chatId,
modelId,
messageCount: messages?.length ?? 0
});
return;
}
const res = await chatCompleted(localStorage.token, {
model: modelId,
messages: messages.map((m) => ({
@ -1759,8 +1794,56 @@
// Chat functions
//////////////////////////
const submitPrompt = async (userPrompt, { _raw = false } = {}) => {
console.log('submitPrompt', userPrompt, $chatId);
const submitPrompt = async (inputContent, inputFiles) => {
const _files = structuredClone(inputFiles);
chatFiles.push(
..._files.filter(
(item) =>
['doc', 'text', 'note', 'chat', 'folder', 'collection'].includes(item.type) ||
(item.type === 'file' && !(item?.content_type ?? '').startsWith('image/'))
)
);
chatFiles = chatFiles.filter(
// Remove duplicates
(item, index, array) =>
array.findIndex((i) => JSON.stringify(i) === JSON.stringify(item)) === index
);
// Create user message
let userMessageId = uuidv4();
let userMessage = {
id: userMessageId,
parentId: history.currentId ?? null,
childrenIds: [],
role: 'user',
content: inputContent,
files: _files.length > 0 ? _files : undefined,
timestamp: Math.floor(Date.now() / 1000), // Unix epoch
models: selectedModels
};
// Add message to history and Set currentId to messageId
history.messages[userMessageId] = userMessage;
// Append messageId to childrenIds of parent message
if (history.currentId !== null) {
history.messages[history.currentId].childrenIds.push(userMessageId);
}
history.currentId = userMessageId;
// focus on chat input
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
saveSessionSelectedModels();
await sendMessage(history, userMessageId, { newChat: true });
};
const submitHandler = async (userPrompt, { _raw = false } = {}) => {
console.log('submitHandler', userPrompt, $chatId);
const _selectedModels = selectedModels.map((modelId) =>
$models.map((m) => m.id).includes(modelId) ? modelId : ''
@ -1840,57 +1923,14 @@
}
}
// Clear input and submit
messageInput?.setText('');
prompt = '';
const messages = createMessagesList(history, history.currentId);
const _files = structuredClone(files);
chatFiles.push(
..._files.filter(
(item) =>
['doc', 'text', 'note', 'chat', 'folder', 'collection'].includes(item.type) ||
(item.type === 'file' && !(item?.content_type ?? '').startsWith('image/'))
)
);
chatFiles = chatFiles.filter(
// Remove duplicates
(item, index, array) =>
array.findIndex((i) => JSON.stringify(i) === JSON.stringify(item)) === index
);
files = [];
messageInput?.setText('');
// Create user message
let userMessageId = uuidv4();
let userMessage = {
id: userMessageId,
parentId: messages.length !== 0 ? messages.at(-1).id : null,
childrenIds: [],
role: 'user',
content: userPrompt,
files: _files.length > 0 ? _files : undefined,
timestamp: Math.floor(Date.now() / 1000), // Unix epoch
models: selectedModels
};
// Add message to history and Set currentId to messageId
history.messages[userMessageId] = userMessage;
history.currentId = userMessageId;
// Append messageId to childrenIds of parent message
if (messages.length !== 0) {
history.messages[messages.at(-1).id].childrenIds.push(userMessageId);
}
// focus on chat input
const chatInput = document.getElementById('chat-input');
chatInput?.focus();
saveSessionSelectedModels();
await sendMessage(history, userMessageId, { newChat: true });
await submitPrompt(userPrompt, _files);
};
const sendMessage = async (
@ -2619,7 +2659,7 @@
const MAX_DRAFT_LENGTH = 5000;
let saveDraftTimeout: ReturnType<typeof setTimeout> | null = null;
const saveDraft = async (draft, chatId = null) => {
const saveDraft = async (draft: any, chatId: string | null = null) => {
if (saveDraftTimeout) {
clearTimeout(saveDraftTimeout);
}
@ -2636,7 +2676,7 @@
}
};
const clearDraft = async (chatId = null) => {
const clearDraft = async (chatId: string | null = null) => {
if (saveDraftTimeout) {
clearTimeout(saveDraftTimeout);
}
@ -2863,6 +2903,7 @@
{createMessagePair}
{onUpload}
messageQueue={$chatRequestQueues[$chatId] ?? []}
{chatTasks}
onQueueSendNow={async (id) => {
const queue = $chatRequestQueues[$chatId] ?? [];
const item = queue.find((m) => m.id === id);
@ -2875,10 +2916,8 @@
// Stop current generation first
await stopResponse();
await tick();
// Set files and submit
files = item.files;
await tick();
await submitPrompt(item.prompt);
// Submit queued message directly without clearing input
await submitPrompt(item.prompt, item.files);
}
}}
onQueueEdit={(id) => {
@ -2908,11 +2947,11 @@
}
}}
on:submit={async (e) => {
clearDraft();
clearDraft($chatId);
if (e.detail || files.length > 0) {
await tick();
submitPrompt(e.detail.replaceAll('\n\n', '\n'));
submitHandler(e.detail.replaceAll('\n\n', '\n'));
}
}}
/>
@ -2955,7 +2994,7 @@
clearDraft();
if (e.detail || files.length > 0) {
await tick();
submitPrompt(e.detail.replaceAll('\n\n', '\n'));
submitHandler(e.detail.replaceAll('\n\n', '\n'));
}
}}
/>
@ -2980,7 +3019,7 @@
}
return a;
}, [])}
{submitPrompt}
submitPrompt={submitHandler}
{stopResponse}
{showMessage}
{eventTarget}

View file

@ -362,7 +362,7 @@
onClose={() => showControls.set(false)}
/>
{:else if activeTab === 'files' && $selectedTerminalId}
<FileNav onAttach={handleTerminalAttach} />
<FileNav onAttach={handleTerminalAttach} {chatId} />
{:else if activeTab === 'files' && codeInterpreterEnabled}
<PyodideFileNav />
{:else}
@ -513,7 +513,7 @@
onClose={() => showControls.set(false)}
/>
{:else if activeTab === 'files' && $selectedTerminalId}
<FileNav onAttach={handleTerminalAttach} overlay={dragged} />
<FileNav onAttach={handleTerminalAttach} overlay={dragged} {chatId} />
{:else if activeTab === 'files' && codeInterpreterEnabled}
<PyodideFileNav overlay={dragged} />
{:else}

View file

@ -49,6 +49,7 @@
export let onAttach: ((blob: Blob, name: string, contentType: string) => void) | null = null;
export let overlay = false;
export let chatId: string | null = null;
// ── Terminal panel state ────────────────────────────────────────────
let terminalExpanded = false;
@ -215,30 +216,48 @@
return url ? { url, key } : null;
};
// Detect terminal changes — the explicit store references ensure
// Detect terminal or chat changes — the explicit store references ensure
// Svelte re-runs this block when any of them update.
// The `mounted` flag prevents the initial run from racing with onMount.
let prevTerminalUrl = '';
let prevChatId = chatId;
let mounted = false;
$: {
($selectedTerminalId, $terminalServers, $settings);
const terminal = getTerminal();
selectedTerminal = terminal;
if (terminal && terminal.url !== prevTerminalUrl) {
prevTerminalUrl = terminal.url;
loading = true;
error = null;
entries = [];
(async () => {
// Discover server features (terminal enabled/disabled)
const config = await getTerminalConfig(terminal.url, terminal.key);
terminalEnabled = config?.features?.terminal !== false;
const chatChanged = chatId !== prevChatId;
const oldChatId = prevChatId;
if (chatChanged) prevChatId = chatId;
const rawCwd = await getCwd(terminal.url, terminal.key);
const cwd = rawCwd ? normalizePath(rawCwd) : null;
const dir = cwd ? (cwd.endsWith('/') ? cwd : cwd + '/') : '/';
savedPath = dir;
loadDir(dir);
})();
const terminalChanged = terminal && terminal.url !== prevTerminalUrl;
if (terminalChanged) prevTerminalUrl = terminal.url;
if (mounted && terminal) {
if (chatChanged && chatId && !oldChatId) {
// Chat just got created (null → real ID): persist the current
// browsed path as the new session's cwd — don't re-fetch.
setCwd(terminal.url, terminal.key, savedPath, chatId);
} else if (terminalChanged || chatChanged) {
// Terminal switched, new chat started, or switched between
// existing chats — re-fetch the session cwd.
loading = true;
error = null;
entries = [];
(async () => {
if (terminalChanged) {
const config = await getTerminalConfig(terminal.url, terminal.key);
terminalEnabled = config?.features?.terminal !== false;
}
const rawCwd = await getCwd(terminal.url, terminal.key, chatId ?? undefined);
const cwd = rawCwd ? normalizePath(rawCwd) : null;
const dir = cwd ? (cwd.endsWith('/') ? cwd : cwd + '/') : '/';
savedPath = dir;
loadDir(dir);
})();
}
}
}
@ -274,7 +293,6 @@
// ── File preview management ──────────────────────────────────────────
const clearFilePreview = () => {
fileContent = null;
filePreviewRef?.disposePanzoom();
if (fileImageUrl) {
URL.revokeObjectURL(fileImageUrl);
fileImageUrl = null;
@ -312,11 +330,11 @@
savedPath = path;
pushNavHistory(path);
const result = await listFiles(terminal.url, terminal.key, path);
const result = await listFiles(terminal.url, terminal.key, path, chatId ?? undefined);
loading = false;
// Set working directory on the terminal server (fire-and-forget)
setCwd(terminal.url, terminal.key, path);
setCwd(terminal.url, terminal.key, path, chatId ?? undefined);
if (result === null) {
error =
@ -347,22 +365,52 @@
clearFilePreview();
if (isImage(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) fileImageUrl = URL.createObjectURL(result.blob);
} else if (isVideo(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) fileVideoUrl = URL.createObjectURL(result.blob);
} else if (isAudio(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) fileAudioUrl = URL.createObjectURL(result.blob);
} else if (isPdf(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) filePdfData = await result.blob.arrayBuffer();
} else if (isSqlite(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) fileSqliteData = await result.blob.arrayBuffer();
} else if (isOffice(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
const result = await downloadFileBlob(
terminal.url,
terminal.key,
filePath,
chatId ?? undefined
);
if (result) {
const ext = getFileExt(filePath);
const arrayBuffer = await result.blob.arrayBuffer();
@ -395,7 +443,7 @@
}
}
} else {
fileContent = await readFile(terminal.url, terminal.key, filePath);
fileContent = await readFile(terminal.url, terminal.key, filePath, chatId ?? undefined);
}
fileLoading = false;
};
@ -408,7 +456,7 @@
const isDir = path.endsWith('/');
const result = isDir
? await archiveFromTerminal(terminal.url, terminal.key, [path.replace(/\/$/, '')])
: await downloadFileBlob(terminal.url, terminal.key, path);
: await downloadFileBlob(terminal.url, terminal.key, path, chatId ?? undefined);
if (!result) return;
const url = URL.createObjectURL(result.blob);
const a = document.createElement('a');
@ -440,7 +488,7 @@
uploading = true;
for (const file of droppedFiles) {
await uploadToTerminal(terminal.url, terminal.key, currentPath, file);
await uploadToTerminal(terminal.url, terminal.key, currentPath, file, chatId ?? undefined);
}
uploading = false;
await loadDir(currentPath);
@ -452,7 +500,7 @@
uploading = true;
for (const file of files) {
await uploadToTerminal(terminal.url, terminal.key, currentPath, file);
await uploadToTerminal(terminal.url, terminal.key, currentPath, file, chatId ?? undefined);
}
uploading = false;
await loadDir(currentPath);
@ -475,7 +523,12 @@
const terminal = selectedTerminal;
if (!terminal) return;
const result = await createDirectory(terminal.url, terminal.key, `${currentPath}${name}`);
const result = await createDirectory(
terminal.url,
terminal.key,
`${currentPath}${name}`,
chatId ?? undefined
);
toast[result ? 'success' : 'error'](
$i18n.t(result ? 'Folder created' : 'Failed to create folder')
);
@ -510,7 +563,7 @@
const terminal = selectedTerminal;
if (!terminal) return;
const result = await deleteEntry(terminal.url, terminal.key, path);
const result = await deleteEntry(terminal.url, terminal.key, path, chatId ?? undefined);
toast[result ? 'success' : 'error'](
$i18n.t(result ? '{{name}} deleted' : 'Failed to delete {{name}}', { name })
);
@ -536,7 +589,13 @@
const sourceDir = source.endsWith('/') ? source : source + '/';
if (destFolder.startsWith(sourceDir)) return;
const result = await moveEntry(terminal.url, terminal.key, source, destination);
const result = await moveEntry(
terminal.url,
terminal.key,
source,
destination,
chatId ?? undefined
);
if ('error' in result) {
toast.error(result.error);
} else {
@ -555,7 +614,13 @@
if (oldPath === destination) return;
const result = await moveEntry(terminal.url, terminal.key, oldPath, destination);
const result = await moveEntry(
terminal.url,
terminal.key,
oldPath,
destination,
chatId ?? undefined
);
if ('error' in result) {
toast.error(result.error);
} else {
@ -736,14 +801,22 @@
if (!handledDisplayFile) {
loading = true;
if (savedPath === '/') {
const rawCwd = await getCwd(terminal.url, terminal.key);
// Discover server features on initial mount
const config = await getTerminalConfig(terminal.url, terminal.key);
terminalEnabled = config?.features?.terminal !== false;
if (chatId || savedPath === '/') {
// Fetch session-specific cwd from the server (or global default for new chats)
const rawCwd = await getCwd(terminal.url, terminal.key, chatId ?? undefined);
const cwd = rawCwd ? normalizePath(rawCwd) : null;
if (cwd) savedPath = cwd.endsWith('/') ? cwd : cwd + '/';
}
loadDir(savedPath);
}
mounted = true;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') shiftKey = true;
};
@ -1368,6 +1441,7 @@
overlay={overlay || isDraggingHandle}
bind:connected={terminalConnected}
bind:connecting={terminalConnecting}
{chatId}
/>
</div>
{/if}

View file

@ -1,6 +1,5 @@
<script lang="ts">
import { getContext, onDestroy, tick } from 'svelte';
import panzoom, { type PanZoom } from 'panzoom';
import { getContext, tick } from 'svelte';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { settings } from '$lib/stores';
@ -8,6 +7,7 @@
import { initMermaid, renderMermaidDiagram } from '$lib/utils';
import Spinner from '../../common/Spinner.svelte';
import PDFViewer from '../../common/PDFViewer.svelte';
import PanzoomContainer from '../../common/PanzoomContainer.svelte';
import JsonTreeView from './JsonTreeView.svelte';
import NotebookView from './NotebookView.svelte';
import SqliteView from './SqliteView.svelte';
@ -250,38 +250,14 @@
showRaw = true;
}
let pzInstance: PanZoom | null = null;
const initImagePanzoom = (node: HTMLElement) => {
pzInstance = panzoom(node, {
bounds: true,
boundsPadding: 0.1,
zoomSpeed: 0.065,
zoomDoubleClickSpeed: 1
});
};
let panzoomRef: PanzoomContainer;
export const resetImageView = () => {
if (pzInstance) {
pzInstance.moveTo(0, 0);
pzInstance.zoomAbs(0, 0, 1);
}
};
export const disposePanzoom = () => {
if (pzInstance) {
pzInstance.dispose();
pzInstance = null;
}
panzoomRef?.reset();
};
export const resetPdfView = () => {
pdfViewerRef?.resetView();
};
onDestroy(() => {
disposePanzoom();
});
</script>
<div
@ -293,14 +269,18 @@
{#if fileLoading}
<div class="flex items-center justify-center h-full"><Spinner className="size-4" /></div>
{:else if fileImageUrl !== null}
<div class="w-full h-full flex items-center justify-center" use:initImagePanzoom>
<PanzoomContainer
bind:this={panzoomRef}
className="w-full h-full flex items-center justify-center"
options={{ zoomDoubleClickSpeed: 1 }}
>
<img
src={fileImageUrl}
alt={selectedFile?.split('/').pop()}
class="max-w-full max-h-full object-contain p-3"
draggable="false"
/>
</div>
</PanzoomContainer>
{:else if fileVideoUrl !== null}
<div class="w-full h-full flex items-center justify-center bg-black">
<!-- svelte-ignore a11y-media-has-caption -->
@ -343,9 +323,10 @@
</div>
{:else if fileOfficeSlides !== null && fileOfficeSlides.length > 0}
<div class="flex flex-col h-full">
<div
class="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden"
use:initImagePanzoom
<PanzoomContainer
bind:this={panzoomRef}
className="w-full flex-1 min-h-0 flex items-center justify-center overflow-hidden"
options={{ zoomDoubleClickSpeed: 1 }}
>
<img
src={fileOfficeSlides[currentSlide]}
@ -353,7 +334,7 @@
class="max-w-full max-h-full object-contain p-3"
draggable="false"
/>
</div>
</PanzoomContainer>
{#if fileOfficeSlides.length > 1}
<div
class="flex items-center justify-center gap-3 py-2 px-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-500"

View file

@ -99,6 +99,7 @@
import InputModal from '../common/InputModal.svelte';
import Expand from '../icons/Expand.svelte';
import QueuedMessageItem from './MessageInput/QueuedMessageItem.svelte';
import TaskList from './Messages/ResponseMessage/TaskList.svelte';
const i18n = getContext('i18n');
@ -121,6 +122,11 @@
export let history;
export let taskIds = null;
$: isActive =
(taskIds && taskIds.length > 0) ||
(history.currentId && history.messages[history.currentId]?.done != true) ||
generating;
export let prompt = '';
export let files = [];
@ -140,6 +146,8 @@
export let onQueueEdit: (id: string) => void = () => {};
export let onQueueDelete: (id: string) => void = () => {};
export let chatTasks = [];
let inputContent = null;
let showInputVariablesModal = false;
@ -404,7 +412,7 @@
let command = '';
export let showCommands = false;
$: showCommands =
['/', '#', '@', '$'].includes(command?.charAt(0)) || '\\#' === command?.slice(0, 2);
['/', '#', '@', '$', ':'].includes(command?.charAt(0)) || '\\#' === command?.slice(0, 2);
let suggestions = null;
let showTools = false;
@ -1027,6 +1035,25 @@
document.getElementById('chat-input')?.focus();
},
insertTextHandler: insertTextAtCursor,
onUpload: () => {}
})
},
{
char: ':',
allowSpaces: false,
command: ({ editor, range, props }) => {
// Convert the Unicode hex codepoint (e.g. "1F44B") to the actual emoji character (👋)
const codepoint = props.id;
const emoji = String.fromCodePoint(parseInt(codepoint, 16));
editor.chain().focus().deleteRange(range).insertContent(emoji).run();
},
render: getSuggestionRenderer(CommandSuggestionList, {
i18n,
onSelect: (e) => {
document.getElementById('chat-input')?.focus();
},
insertTextHandler: insertTextAtCursor,
onUpload: () => {}
})
@ -1217,6 +1244,13 @@
on:click={() => createMessagePair(prompt)}
/>
<!-- Task list display -->
{#if isActive && chatTasks.length > 0}
<div class="mx-1">
<TaskList tasks={chatTasks} />
</div>
{/if}
<!-- Queued messages display -->
{#if messageQueue.length > 0}
<div
@ -1828,7 +1862,7 @@
</div>
<div class="self-end flex space-x-1 mr-1 shrink-0 gap-[0.5px]">
{#if (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true) || generating}
{#if isActive && prompt === '' && files.length === 0}
<div class=" flex items-center">
<Tooltip content={$i18n.t('Stop')}>
<button

View file

@ -3,6 +3,7 @@
import Knowledge from './Commands/Knowledge.svelte';
import Models from './Commands/Models.svelte';
import Skills from './Commands/Skills.svelte';
import Emojis from './Commands/Emojis.svelte';
export let char = '';
export let query = '';
@ -135,6 +136,22 @@
}
}}
/>
{:else if char === ':'}
<Emojis
bind:this={suggestionElement}
{query}
bind:filteredItems
onSelect={(e) => {
const { type, data } = e;
if (type === 'emoji') {
command({
id: data.name,
label: data.shortCodes[0]
});
}
}}
/>
{/if}
</div>
</div>

View file

@ -0,0 +1,99 @@
<script lang="ts">
import { getContext } from 'svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
import emojiShortCodes from '$lib/emoji-shortcodes.json';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n = getContext('i18n');
export let query = '';
export let onSelect = (e) => {};
let selectedIdx = 0;
export let filteredItems = [];
// Build a flat list of { name, shortCodes } for searching
const allEmojis = Object.entries(emojiShortCodes).map(([key, value]) => ({
name: key,
shortCodes: typeof value === 'string' ? [value] : (value as string[])
}));
$: {
if (query && query.length >= 2) {
const q = query.toLowerCase();
filteredItems = allEmojis
.filter(
(emoji) =>
emoji.name.toLowerCase().includes(q) ||
emoji.shortCodes.some((code) => code.toLowerCase().includes(q))
)
.sort((a, b) => {
// Score: 0 = exact match, 1 = prefix match, 2 = substring match
const score = (emoji) => {
if (emoji.shortCodes.some((c) => c.toLowerCase() === q)) return 0;
if (emoji.shortCodes.some((c) => c.toLowerCase().startsWith(q))) return 1;
return 2;
};
return score(a) - score(b);
})
.slice(0, 50);
} else {
filteredItems = [];
}
}
$: if (query) {
selectedIdx = 0;
}
export const selectUp = () => {
selectedIdx = Math.max(0, selectedIdx - 1);
};
export const selectDown = () => {
selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1);
};
export const select = async () => {
const emoji = filteredItems[selectedIdx];
if (emoji) {
onSelect({ type: 'emoji', data: emoji });
}
};
</script>
{#if filteredItems.length > 0}
<div class="px-2 text-xs text-gray-500 py-1">
{$i18n.t('Emojis')}
</div>
{#each filteredItems as emoji, emojiIdx}
<button
class="px-2.5 py-1.5 rounded-xl w-full text-left {emojiIdx === selectedIdx
? 'bg-gray-50 dark:bg-gray-800 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
onSelect({ type: 'emoji', data: emoji });
}}
on:mousemove={() => {
selectedIdx = emojiIdx;
}}
on:focus={() => {}}
data-selected={emojiIdx === selectedIdx}
>
<div class="flex items-center gap-2 text-black dark:text-gray-100">
<img
src="{WEBUI_BASE_URL}/assets/emojis/{emoji.name.toLowerCase()}.svg"
alt={emoji.name}
class="size-5 flex-shrink-0"
loading="lazy"
/>
<div class="truncate text-sm">
:{emoji.shortCodes[0]}:
</div>
</div>
</button>
{/each}
{/if}

View file

@ -22,6 +22,7 @@
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import PageEdit from '$lib/components/icons/PageEdit.svelte';
import Chats from './InputMenu/Chats.svelte';
import Files from './InputMenu/Files.svelte';
import Notes from './InputMenu/Notes.svelte';
import Knowledge from './InputMenu/Knowledge.svelte';
import AttachWebpageModal from './AttachWebpageModal.svelte';
@ -141,6 +142,7 @@
on:click={() => {
if (fileUploadEnabled) {
uploadFilesHandler();
show = false;
}
}}
>
@ -174,6 +176,7 @@
cameraInputElement.click();
}
}
show = false;
}
}}
>
@ -196,6 +199,7 @@
on:click={() => {
if (webUploadEnabled) {
showAttachWebpageModal = true;
show = false;
}
}}
>
@ -204,6 +208,38 @@
</button>
</Tooltip>
<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 select-none cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl {!fileUploadEnabled
? 'opacity-50'
: ''}"
on:click={() => {
if (fileUploadEnabled) {
tab = 'files';
}
}}
>
<DocumentArrowUp />
<div class="flex items-center w-full justify-between">
<div class="line-clamp-1">
{$i18n.t('Attach Files')}
</div>
<div class="text-gray-500">
<ChevronRight />
</div>
</div>
</button>
</Tooltip>
{#if $config?.features?.enable_notes ?? false}
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
@ -303,6 +339,7 @@
type="button"
on:click={() => {
uploadGoogleDriveHandler();
show = false;
}}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.3 78" class="w-4">
@ -481,6 +518,25 @@
<Notes {onSelect} />
</div>
{:else if tab === 'files'}
<div in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center px-3 py-1.5 text-sm select-none cursor-pointer rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800/50"
on:click={() => {
tab = '';
}}
>
<ChevronLeft />
<div class="flex items-center w-full justify-between">
<div>
{$i18n.t('Files')}
</div>
</div>
</button>
<Files {onSelect} />
</div>
{:else if tab === 'chats'}
<div in:fly={{ x: 20, duration: 150 }}>
<button
@ -523,6 +579,7 @@
type="button"
on:click={() => {
uploadOneDriveHandler('personal');
show = false;
}}
>
<div class="flex flex-col">
@ -537,6 +594,7 @@
type="button"
on:click={() => {
uploadOneDriveHandler('organizations');
show = false;
}}
>
<div class="flex flex-col">

View file

@ -0,0 +1,120 @@
<script lang="ts">
import { onMount, tick, getContext } from 'svelte';
import { searchFiles } from '$lib/apis/files';
import Tooltip from '$lib/components/common/Tooltip.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';
const i18n = getContext('i18n');
export let onSelect = (e) => {};
let loaded = false;
let items = [];
let selectedIdx = 0;
let page = 0;
let limit = 50;
let itemsLoading = false;
let allItemsLoaded = false;
const loadMoreItems = async () => {
if (allItemsLoaded) return;
page += 1;
await getItemsPage();
};
const getItemsPage = async () => {
itemsLoading = true;
let res = await searchFiles(localStorage.token, '*', page * limit, limit).catch(() => []);
if ((res ?? []).length < limit) {
allItemsLoaded = true;
}
items = [
...items,
...(res ?? []).map((file) => ({
...file,
type: file?.meta?.content_type?.startsWith('image/') ? 'image' : 'file',
name: file.filename,
url: file.id,
content_type: file?.meta?.content_type,
size: file?.meta?.size
}))
];
itemsLoading = false;
return res;
};
onMount(async () => {
await getItemsPage();
await tick();
loaded = true;
});
</script>
{#if loaded}
{#if items.length === 0}
<div class="text-center text-xs text-gray-500 py-3">{$i18n.t('No files found')}</div>
{:else}
<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={() => {
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.5 overflow-hidden">
<Tooltip content={$i18n.t('File')} placement="top">
<DocumentPage className="size-4 shrink-0" />
</Tooltip>
<Tooltip content={item?.name} placement="top-start">
<div class="line-clamp-1 flex-1">
{item?.name}
</div>
</Tooltip>
</div>
</button>
{/each}
{#if !allItemsLoaded}
<Loader
on:visible={(e) => {
if (!itemsLoading) {
loadMoreItems();
}
}}
>
<div class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2">
<Spinner className=" size-4" />
<div class=" ">{$i18n.t('Loading...')}</div>
</div>
</Loader>
{/if}
</div>
{/if}
{:else}
<div class="py-4.5">
<Spinner />
</div>
{/if}

View file

@ -22,12 +22,16 @@
const submitHandler = async () => {
// Normalize Windows CRLF (\r\n) to LF (\n) for all string values
// Build a new object to avoid mutating the reactive variableValues proxy
const result = {};
for (const key of Object.keys(variableValues)) {
if (typeof variableValues[key] === 'string') {
variableValues[key] = variableValues[key].replace(/\r\n/g, '\n');
result[key] = variableValues[key].replace(/\r\n/g, '\n');
} else {
result[key] = variableValues[key];
}
}
onSave(variableValues);
onSave(result);
show = false;
};
@ -102,20 +106,17 @@
<div class="flex mt-0.5 mb-0.5 space-x-2">
<div class=" flex-1">
{#if variables[variable]?.type === 'select'}
{@const options = variableAttributes?.options ?? []}
{@const placeholder = variableAttributes?.placeholder ?? ''}
<select
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
bind:value={variableValues[variable]}
id="input-variable-{idx}"
>
{#if placeholder}
{#if variables[variable]?.placeholder}
<option value="" disabled selected>
{placeholder}
{variables[variable].placeholder}
</option>
{/if}
{#each options as option}
{#each variables[variable]?.options ?? [] as option}
<option value={option} selected={option === variableValues[variable]}>
{option}
</option>

View file

@ -58,6 +58,34 @@
return `${minutes}:${formattedSeconds}`;
};
let wakeLock = null;
const requestWakeLock = async () => {
if ('wakeLock' in navigator) {
try {
wakeLock = await navigator.wakeLock.request('screen');
console.log('Wake Lock acquired');
wakeLock.addEventListener('release', () => {
console.log('Wake Lock released');
});
} catch (err) {
console.log('Wake Lock request failed:', err);
}
}
};
const releaseWakeLock = async () => {
if (wakeLock) {
try {
await wakeLock.release();
} catch (err) {
console.log('Wake Lock release failed:', err);
}
wakeLock = null;
}
};
let stream;
let speechRecognition;
@ -216,11 +244,13 @@
mimeType: mineTypes.find((type) => MediaRecorder.isTypeSupported(type))
});
mediaRecorder.onstart = () => {
mediaRecorder.onstart = async () => {
console.log('Recording started');
loading = false;
startDurationCounter();
await requestWakeLock();
audioChunks = [];
analyseAudio(stream);
};
@ -333,6 +363,8 @@
speechRecognition.stop();
}
await releaseWakeLock();
stopDurationCounter();
audioChunks = [];
visualizerData = Array(VISUALIZER_BUFFER_LENGTH).fill(0);
@ -354,6 +386,8 @@
}
clearInterval(durationCounter);
await releaseWakeLock();
if (stream) {
const tracks = stream.getTracks();
tracks.forEach((track) => track.stop());
@ -376,8 +410,15 @@
}
};
const handleVisibilityChange = async () => {
if (recording && document.visibilityState === 'visible') {
await requestWakeLock();
}
};
onMount(() => {
window.addEventListener('keydown', handleKeyDown);
document.addEventListener('visibilitychange', handleVisibilityChange);
// listen to width changes
resizeObserver = new ResizeObserver(() => {
@ -396,6 +437,8 @@
onDestroy(() => {
window.removeEventListener('keydown', handleKeyDown);
document.removeEventListener('visibilitychange', handleVisibilityChange);
releaseWakeLock();
// remove resize observer
resizeObserver.disconnect();
});

View file

@ -469,7 +469,7 @@
{/if}
{:else}
<div
class="sticky {stickyButtonsClassName} left-0 right-0 py-1.5 px-3 gap-2 flex items-center justify-end w-full z-10 text-xs text-black dark:text-white bg-white dark:bg-black rounded-t-2xl"
class="sticky {stickyButtonsClassName} left-0 right-0 py-1.5 px-3.5 gap-2 flex items-center justify-end w-full z-10 text-xs text-black dark:text-white bg-white dark:bg-black rounded-t-2xl"
>
<div class="flex-1 truncate">
<Tooltip content={lang} placement="top-start">
@ -599,17 +599,17 @@
{#if executing || stdout || stderr || result || files}
<div
class="bg-gray-50 dark:bg-black dark:text-white rounded-b-2xl! py-4 px-4 flex flex-col gap-2"
class="bg-gray-50 dark:bg-black dark:text-white rounded-b-2xl! pt-2 pb-3 px-3.5 flex flex-col gap-2"
>
{#if executing}
<div class=" ">
<div class=" text-gray-500 text-sm mb-1">{$i18n.t('STDOUT/STDERR')}</div>
<div class=" text-gray-500 text-xs mb-1">{$i18n.t('STDOUT/STDERR')}</div>
<div class="text-sm">{$i18n.t('Running...')}</div>
</div>
{:else}
{#if stdout || stderr}
<div class=" ">
<div class=" text-gray-500 text-sm mb-1">{$i18n.t('STDOUT/STDERR')}</div>
<div class=" text-gray-500 text-xs mb-1">{$i18n.t('STDOUT/STDERR')}</div>
<div
class="text-sm font-mono whitespace-pre-wrap {stdout?.split('\n')?.length > 100
? `max-h-96`
@ -621,7 +621,7 @@
{/if}
{#if result || files}
<div class=" ">
<div class=" text-gray-500 text-sm mb-1">{$i18n.t('RESULT')}</div>
<div class=" text-gray-500 text-xs mb-1">{$i18n.t('RESULT')}</div>
{#if result}
<div class="text-sm">{`${JSON.stringify(result)}`}</div>
{/if}

View file

@ -42,6 +42,7 @@
let contentContainerElement;
let floatingButtonsElement;
let sourceIds = [];
$: getSourceIds(sources);
@ -160,7 +161,9 @@
<div bind:this={contentContainerElement}>
<Markdown
{id}
{content}
content={model?.info?.meta?.capabilities?.citations == false
? content.replace(/\s*(\[(?:\d+(?:#[^,\]\s]+)?(?:,\s*\d+(?:#[^,\]\s]+)?)*)\])+/g, '')
: content}
{model}
{save}
{preview}

View file

@ -31,7 +31,7 @@
export let messageDone = true;
let open = false;
let open = $settings?.expandDetails ?? false;
function parseJSONString(str: string) {
try {

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { copyToClipboard, unescapeHtml } from '$lib/utils';
import { toast } from 'svelte-sonner';
import { fade } from 'svelte/transition';
import { getContext } from 'svelte';
@ -13,21 +12,10 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
{#if done}
<code
class="codespan cursor-pointer"
on:click={() => {
copyToClipboard(unescapeHtml(token.text));
toast.success($i18n.t('Copied to clipboard'));
}}>{unescapeHtml(token.text)}</code
>
{:else}
<code
transition:fade={{ duration: 100 }}
class="codespan cursor-pointer"
on:click={() => {
copyToClipboard(unescapeHtml(token.text));
toast.success($i18n.t('Copied to clipboard'));
}}>{unescapeHtml(token.text)}</code
>
{/if}
<code
class="codespan cursor-pointer {!done ? 'fade-in-token' : ''}"
on:click={() => {
copyToClipboard(unescapeHtml(token.text));
toast.success($i18n.t('Copied to clipboard'));
}}>{unescapeHtml(token.text)}</code
>

View file

@ -1,18 +1,13 @@
<script lang="ts">
import { fade } from 'svelte/transition';
export let token;
export let done = true;
let texts = [];
$: texts = (token?.raw ?? '').split(' ');
</script>
{#if done}
{token?.raw}
{:else}
{#each texts as text}
<span class="" transition:fade={{ duration: 100 }}>
{#each (token?.raw ?? '').split(' ') as text}
<span class="fade-in-token">
{text}{' '}
</span>
{/each}

View file

@ -381,7 +381,7 @@
id={`${id}-${tokenIdx}-${detailIdx}-tc`}
attributes={detailToken.attributes}
grouped={true}
open={false}
open={$settings?.expandDetails ?? false}
className="w-full space-y-1"
/>
{:else if textContent.length > 0}
@ -428,7 +428,7 @@
<ToolCallDisplay
id={`${id}-${tokenIdx}-tc`}
attributes={token.attributes}
open={false}
open={$settings?.expandDetails ?? false}
className="w-full space-y-1"
/>
{:else if textContent.length > 0}

View file

@ -0,0 +1,111 @@
<script lang="ts">
import { getContext } from 'svelte';
import { slide } from 'svelte/transition';
import TaskListIcon from '$lib/components/icons/TaskList.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
const i18n = getContext('i18n');
export let tasks: Array<{ id: string; content: string; status: string }> = [];
let collapsed = false;
$: completedCount = tasks.filter((t) => t.status === 'completed').length;
$: totalCount = tasks.length;
$: hasActive = tasks.some((t) => t.status === 'pending' || t.status === 'in_progress');
</script>
{#if tasks.length > 0 && hasActive}
<div
class="my-2 rounded-2xl border border-gray-50 dark:border-gray-850 bg-white dark:bg-gray-900"
transition:slide={{ duration: 200 }}
>
<!-- Header -->
<div class="flex items-center justify-between px-3.5 py-2">
<div class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<TaskListIcon className="w-3.5 h-3.5" />
<span>
{completedCount}
{$i18n.t('out of')}
{totalCount}
{$i18n.t('tasks completed')}
</span>
</div>
<button
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
on:click={() => (collapsed = !collapsed)}
aria-label={collapsed ? 'Expand' : 'Collapse'}
>
{#if collapsed}
<ChevronDown className="w-2.5 h-2.5" />
{:else}
<ChevronUp className="w-2.5 h-2.5" />
{/if}
</button>
</div>
<!-- Task list -->
{#if !collapsed}
<div class="px-3.5 pb-2.5 space-y-0.5" transition:slide={{ duration: 150 }}>
{#each tasks as task, idx (task.id)}
<div class="flex items-start gap-2 py-0.5 text-xs">
<span class="flex-shrink-0 mt-0.5 text-gray-400 dark:text-gray-500">
{#if task.status === 'completed'}
<svg
class="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path d="M5 13l4 4L19 7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{:else if task.status === 'in_progress'}
<svg
class="w-3.5 h-3.5 animate-spin"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path d="M12 3a9 9 0 1 0 9 9" stroke-linecap="round" />
</svg>
{:else if task.status === 'cancelled'}
<svg
class="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="9" stroke-dasharray="4 3" />
</svg>
{:else}
<svg
class="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="9" />
</svg>
{/if}
</span>
<span
class="line-clamp-2 {task.status === 'completed'
? 'line-through text-gray-400 dark:text-gray-500'
: task.status === 'cancelled'
? 'line-through text-gray-400 dark:text-gray-600'
: 'text-gray-700 dark:text-gray-300'}"
>
{idx + 1}. {task.content}
</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}

View file

@ -26,6 +26,7 @@
export let unloadModelHandler: (modelValue: string) => void = () => {};
export let pinModelHandler: (modelId: string) => void = () => {};
export let deleteModelHandler: (model: any) => void = () => {};
export let onClick: () => void = () => {};
@ -255,6 +256,7 @@
bind:show={showMenu}
model={item.model}
{pinModelHandler}
{deleteModelHandler}
copyLinkHandler={() => {
copyLinkHandler(item.model);
}}

View file

@ -18,6 +18,7 @@
export let pinModelHandler: (modelId: string) => void = () => {};
export let copyLinkHandler: Function = () => {};
export let deleteModelHandler: Function = () => {};
export let onClose: Function = () => {};
</script>
@ -66,6 +67,37 @@
<div class="flex items-center">{$i18n.t('Edit')}</div>
</button>
{#if $user?.role === 'admin' && model?.owned_by === 'ollama'}
<button
type="button"
class="select-none flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition items-center gap-2"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
deleteModelHandler(model);
show = false;
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
<div class="flex items-center">{$i18n.t('Delete')}</div>
</button>
{/if}
<hr class="border-gray-50 dark:border-gray-800/30 my-1" />
{/if}

View file

@ -8,6 +8,7 @@
dayjs.extend(relativeTime);
import Spinner from '$lib/components/common/Spinner.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import { flyAndScale } from '$lib/utils/transitions';
import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
@ -375,6 +376,43 @@
}
};
let showDeleteConfirm = false;
let deleteModelTarget: any = null;
const deleteModelHandler = async (model: any) => {
deleteModelTarget = model;
showDeleteConfirm = true;
};
const confirmDeleteModel = async () => {
const model = deleteModelTarget;
if (!model) return;
const res = await deleteModel(localStorage.token, model.id).catch((error) => {
toast.error($i18n.t('Error deleting model: {{error}}', { error }));
});
if (res) {
toast.success(
$i18n.t('Model {{modelName}} deleted successfully', { modelName: model.name ?? model.id })
);
// If the deleted model was selected, clear the selection
if (value === model.id) {
value = '';
}
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections && ($settings?.directConnections ?? null)
)
);
}
deleteModelTarget = null;
};
const ITEM_HEIGHT = 42;
const OVERSCAN = 10;
@ -388,6 +426,17 @@
);
</script>
<ConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete Model')}
message={$i18n.t('Are you sure you want to delete **{{modelName}}**?', {
modelName: deleteModelTarget?.name ?? deleteModelTarget?.id ?? ''
})}
on:confirm={() => {
confirmDeleteModel();
}}
/>
<DropdownMenu.Root
bind:open={show}
onOpenChange={async () => {
@ -448,7 +497,7 @@
>
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div {...wrapperProps} style="{wrapperProps.style ?? ''}{$mobile ? '; left: 0.5rem !important; width: calc(100vw - 1rem) !important;' : ''}">
<div
{...props}
class="{props.class} z-40 {$mobile
@ -646,6 +695,7 @@
{value}
{pinModelHandler}
{unloadModelHandler}
{deleteModelHandler}
onClick={() => {
value = item.value;
selectedModelIdx = index;

View file

@ -12,6 +12,7 @@
const i18n = getContext('i18n');
export let overlay = false;
export let chatId: string | null = null;
let terminalEl: HTMLDivElement;
let term: Terminal | null = null;
@ -67,9 +68,11 @@
authToken = apiKey;
// Create session
const createHeaders: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
if (chatId) createHeaders['X-Session-Id'] = chatId;
const res = await fetch(`${base}/api/terminals`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` }
headers: createHeaders
});
if (!res.ok) throw new Error(`Failed to create session: ${res.status}`);
const session = await res.json();
@ -83,9 +86,11 @@
authToken = token;
// Create session via proxy
const proxyHeaders: Record<string, string> = { Authorization: `Bearer ${token}` };
if (chatId) proxyHeaders['X-Session-Id'] = chatId;
const res = await fetch(`${base}/terminals/${info.serverId}/api/terminals`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
headers: proxyHeaders
});
if (!res.ok) throw new Error(`Failed to create session: ${res.status}`);
const session = await res.json();

View file

@ -175,7 +175,8 @@
bind:this={contentEl}
class={contentClass}
transition:flyAndScale
on:click|stopPropagation
on:click={(e) => e.stopPropagation()}
on:pointerdown={(e) => e.stopPropagation()}
>
<slot name="content" />
</div>

View file

@ -11,6 +11,9 @@
import emojiGroups from '$lib/emoji-groups.json';
import emojiShortCodes from '$lib/emoji-shortcodes.json';
import { settings } from '$lib/stores';
import { updateUserSettings } from '$lib/apis/users';
const i18n = getContext('i18n');
export let onClose = () => {};
@ -20,12 +23,37 @@
export let user = null;
export let selected = null;
const MAX_RECENT = 30;
let show = false;
let emojis = emojiShortCodes;
let search = '';
let flattenedEmojis = [];
let emojiRows = [];
let saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
$: recentEmojiNames = ($settings?.recentEmojis ?? [])
.filter((name) => emojiShortCodes[name])
.slice(0, MAX_RECENT);
function saveRecentEmoji(emojiName: string) {
// Remove if already present, then prepend
const updated = [emojiName, ...recentEmojiNames.filter((n) => n !== emojiName)].slice(
0,
MAX_RECENT
);
// Update store immediately (reactive UI)
settings.set({ ...$settings, recentEmojis: updated });
// Debounce backend save (avoid API spam on rapid picks)
if (saveDebounceTimer) clearTimeout(saveDebounceTimer);
saveDebounceTimer = setTimeout(async () => {
await updateUserSettings(localStorage.token, { ui: { ...$settings, recentEmojis: updated } });
}, 1000);
}
// Reactive statement to filter the emojis based on search query
$: {
if (search) {
@ -55,6 +83,22 @@
// Flatten emoji groups and group them into rows of 8 for virtual scrolling
$: {
flattenedEmojis = [];
// Add "Recently Used" group first (only when not searching)
if (!search && recentEmojiNames.length > 0) {
flattenedEmojis.push({ type: 'group', label: $i18n.t('Recently Used') });
flattenedEmojis.push(
...recentEmojiNames.map((emoji) => ({
type: 'emoji',
name: emoji,
shortCodes:
typeof emojiShortCodes[emoji] === 'string'
? [emojiShortCodes[emoji]]
: emojiShortCodes[emoji]
}))
);
}
Object.keys(emojiGroups).forEach((group) => {
const groupEmojis = emojiGroups[group].filter((emoji) => emojis[emoji]);
if (groupEmojis.length > 0) {
@ -97,6 +141,7 @@
// Handle emoji selection
function selectEmoji(emoji) {
const selectedCode = emoji.shortCodes[0];
saveRecentEmoji(emoji.name);
if (selected === selectedCode) {
onSubmit(null);
} else {
@ -140,10 +185,10 @@
{:else}
<div class="w-full flex ml-0.5">
<VirtualList rowHeight={ROW_HEIGHT} items={emojiRows} height={384} let:item>
<div class="w-full">
<div class="w-full mb-2.5">
{#if item.length === 1 && item[0].type === 'group'}
<!-- Render group header -->
<div class="text-xs font-medium mb-2 text-gray-500 dark:text-gray-400">
<div class="text-xs font-medium -mb-1 text-gray-500 dark:text-gray-400">
{item[0].label}
</div>
{:else}

View file

@ -25,10 +25,9 @@
import dayjs from 'dayjs';
import Spinner from './Spinner.svelte';
import PDFViewer from './PDFViewer.svelte';
import PanzoomContainer from './PanzoomContainer.svelte';
import Reset from '../icons/Reset.svelte';
import panzoom, { type PanZoom } from 'panzoom';
export let item;
export let show = false;
export let edit = false;
@ -60,21 +59,9 @@
let pptxCurrentSlide = 0;
let pptxError = '';
let pzInstance: PanZoom | null = null;
const initImagePanzoom = (node: HTMLElement) => {
pzInstance = panzoom(node, {
bounds: true,
boundsPadding: 0.1,
zoomSpeed: 0.065
});
};
let panzoomRef: PanzoomContainer;
const resetImageView = () => {
if (pzInstance) {
pzInstance.moveTo(0, 0);
pzInstance.zoomAbs(0, 0, 1);
}
panzoomRef?.reset();
};
$: isPDF =
@ -266,10 +253,6 @@
if (item?.context === 'full') {
enableFullContent = true;
}
return () => {
pzInstance?.dispose();
};
});
</script>
@ -445,7 +428,7 @@
</button>
</Tooltip>
</div>
<div use:initImagePanzoom>
<PanzoomContainer bind:this={panzoomRef}>
<img
src={`${WEBUI_API_BASE_URL}/files/${item.id}/content`}
alt={item?.name ?? 'Image'}
@ -453,7 +436,7 @@
loading="lazy"
draggable="false"
/>
</div>
</PanzoomContainer>
</div>
{:else if selectedTab === ''}
{#if item?.file?.data}

View file

@ -1,10 +1,10 @@
<script lang="ts">
import { onDestroy, onMount, getContext } from 'svelte';
import panzoom, { type PanZoom } from 'panzoom';
import { onDestroy, getContext } from 'svelte';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import PanzoomContainer from '$lib/components/common/PanzoomContainer.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
export let show = false;
@ -13,29 +13,8 @@
const i18n = getContext('i18n');
let mounted = false;
let previewElement = null;
let instance: PanZoom;
let sceneParentElement: HTMLElement;
let sceneElement: HTMLElement;
$: if (sceneElement) {
instance = panzoom(sceneElement, {
bounds: true,
boundsPadding: 0.1,
zoomSpeed: 0.065
});
}
const resetPanZoomViewport = () => {
instance.moveTo(0, 0);
instance.zoomAbs(0, 0, 1);
console.log(instance.getTransform());
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
console.log('Escape');
@ -43,10 +22,6 @@
}
};
onMount(() => {
mounted = true;
});
$: if (show && previewElement) {
document.body.appendChild(previewElement);
window.addEventListener('keydown', handleKeyDown);
@ -58,11 +33,15 @@
}
onDestroy(() => {
window.removeEventListener('keydown', handleKeyDown);
show = false;
if (previewElement) {
if (previewElement && previewElement.parentNode === document.body) {
document.body.removeChild(previewElement);
}
// NOTE: If multiple modals can stack in the future, direct "unset" may
// re-enable page scroll too early. Consider a shared body-scroll lock manager.
document.body.style.overflow = 'unset';
});
</script>
@ -181,14 +160,8 @@
</button>
</div>
</div>
<div class="flex h-full max-h-full justify-center items-center z-0">
<img
bind:this={sceneElement}
{src}
{alt}
class=" mx-auto h-full object-scale-down select-none"
draggable="false"
/>
</div>
<PanzoomContainer className="flex h-full max-h-full justify-center items-center z-0">
<img {src} {alt} class=" mx-auto h-full object-scale-down select-none" draggable="false" />
</PanzoomContainer>
</div>
{/if}

View file

@ -55,6 +55,9 @@
mounted = true;
});
let handleOutsidePointerDown;
let handleModalFocusIn;
$: if (show && modalElement) {
document.body.appendChild(modalElement);
focusTrap = FocusTrap.createFocusTrap(modalElement, {
@ -66,10 +69,34 @@
}
});
focusTrap.activate();
// Auto-pause focus trap when interacting with portaled content (e.g. Dropdown)
handleOutsidePointerDown = (e) => {
if (focusTrap && modalElement && !modalElement.contains(e.target)) {
focusTrap.pause();
}
};
handleModalFocusIn = () => {
if (focusTrap) {
focusTrap.unpause();
}
};
document.addEventListener('pointerdown', handleOutsidePointerDown, true);
modalElement.addEventListener('focusin', handleModalFocusIn);
window.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
} else if (modalElement) {
focusTrap.deactivate();
if (focusTrap) {
focusTrap.deactivate();
focusTrap = null;
}
if (handleOutsidePointerDown) {
document.removeEventListener('pointerdown', handleOutsidePointerDown, true);
}
if (handleModalFocusIn) {
modalElement.removeEventListener('focusin', handleModalFocusIn);
}
window.removeEventListener('keydown', handleKeyDown);
document.body.removeChild(modalElement);
document.body.style.overflow = 'unset';

View file

@ -0,0 +1,33 @@
<script lang="ts">
import { onMount } from 'svelte';
import panzoom, { type PanZoom, type PanZoomOptions } from 'panzoom';
const defaultOpts: PanZoomOptions = {
bounds: true,
boundsPadding: 0.1,
zoomSpeed: 0.065
};
export let className = '';
export let options: Partial<PanZoomOptions> = {};
let containerElement: HTMLElement;
let instance: PanZoom | undefined;
export const reset = () => {
instance?.moveTo(0, 0);
instance?.zoomAbs(0, 0, 1);
};
onMount(() => {
const localInstance = panzoom(containerElement, { ...defaultOpts, ...options });
instance = localInstance;
return () => {
localInstance.dispose();
};
});
</script>
<div bind:this={containerElement} class={className}>
<slot />
</div>

View file

@ -109,7 +109,7 @@ export function getSuggestionRenderer(Component: any, ComponentProps = {}) {
popup = null;
try {
component.$destroy();
component?.$destroy();
} catch (e) {
console.error('Error unmounting component:', e);
}

View file

@ -4,15 +4,14 @@
import { toast } from 'svelte-sonner';
import panzoom, { type PanZoom } from 'panzoom';
import DOMPurify from 'dompurify';
import { onMount, getContext } from 'svelte';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
import { copyToClipboard } from '$lib/utils';
import DocumentDuplicate from '../icons/DocumentDuplicate.svelte';
import PanzoomContainer from './PanzoomContainer.svelte';
import Tooltip from './Tooltip.svelte';
import Clipboard from '../icons/Clipboard.svelte';
import Reset from '../icons/Reset.svelte';
@ -22,23 +21,9 @@
export let svg = '';
export let content = '';
let instance: PanZoom;
let sceneParentElement: HTMLElement;
let sceneElement: HTMLElement;
$: if (sceneElement) {
instance = panzoom(sceneElement, {
bounds: true,
boundsPadding: 0.1,
zoomSpeed: 0.065
});
}
let panzoomRef: PanzoomContainer;
const resetPanZoomViewport = () => {
instance.moveTo(0, 0);
instance.zoomAbs(0, 0, 1);
console.log(instance.getTransform());
panzoomRef?.reset();
};
const downloadAsSVG = () => {
@ -47,8 +32,11 @@
};
</script>
<div bind:this={sceneParentElement} class="relative {className}">
<div bind:this={sceneElement} class="flex h-full max-h-full justify-center items-center">
<div class="relative {className}">
<PanzoomContainer
bind:this={panzoomRef}
className="flex h-full max-h-full justify-center items-center"
>
{@html DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true }, // allow <svg>, <defs>, <filter>, etc.
WHOLE_DOCUMENT: false,
@ -88,7 +76,7 @@
],
SANITIZE_DOM: true
})}
</div>
</PanzoomContainer>
{#if content}
<div class=" absolute top-2.5 right-2.5">

View file

@ -0,0 +1,41 @@
<script lang="ts">
export let className = 'w-4 h-4';
</script>
<svg
class={className}
stroke-width="1.5"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M20 20L15 15M15 15V19M15 15H19"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4 20L9 15M9 15V19M9 15H5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M20 4L15 9M15 9V5M15 9H19"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4 4L9 9M9 9V5M9 9H5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

View file

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

View file

@ -0,0 +1,54 @@
<script lang="ts">
export let className = 'w-4 h-4';
</script>
<svg
class={className}
stroke-width="1.5"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9 6L20 6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M3.80002 5.79999L4.60002 6.59998L6.60001 4.59999"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M3.80002 11.8L4.60002 12.6L6.60001 10.6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M3.80002 17.8L4.60002 18.6L6.60001 16.6"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M9 12L20 12"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M9 18L20 18"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

View file

@ -536,7 +536,7 @@
};
});
// Handler for chat:active events (defined outside onMount for proper cleanup)
// Handler for chat events (defined outside onMount for proper cleanup)
const chatActiveEventHandler = (event: {
chat_id: string;
message_id: string;
@ -553,6 +553,8 @@
}
return newSet;
});
} else if (event.data?.type === 'chat:list') {
initChatList();
}
};
@ -1288,6 +1290,8 @@
id={chat.id}
title={chat.title}
createdAt={chat.created_at}
updatedAt={chat.updated_at}
lastReadAt={chat.last_read_at}
{shiftKey}
selected={selectedChatId === chat.id}
on:select={() => {
@ -1349,6 +1353,8 @@
id={chat.id}
title={chat.title}
createdAt={chat.created_at}
updatedAt={chat.updated_at}
lastReadAt={chat.last_read_at}
{shiftKey}
selected={selectedChatId === chat.id}
on:select={() => {

View file

@ -1,7 +1,14 @@
<script context="module" lang="ts">
/** Shared 1×1 transparent drag preview; avoids one Image per sidebar row */
const invisibleDragImage = new Image();
invisibleDragImage.src =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
</script>
<script lang="ts">
import { toast } from 'svelte-sonner';
import { goto, invalidate, invalidateAll } from '$app/navigation';
import { onMount, getContext, createEventDispatcher, tick, onDestroy } from 'svelte';
import { onMount, getContext, createEventDispatcher, tick } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
@ -50,6 +57,8 @@
export let id;
export let title;
export let createdAt: number | null = null;
export let updatedAt: number | null = null;
export let lastReadAt: number | null = null;
export let selected = false;
export let shiftKey = false;
@ -79,6 +88,11 @@
let mouseOver = false;
$: unread =
id !== $chatId &&
!$activeChatIds.has(id) &&
(lastReadAt === null || (updatedAt !== null && updatedAt > lastReadAt));
const loadChat = async () => {
if (!chat) {
draggable = false;
@ -133,7 +147,12 @@
}
};
let deleting = false;
const deleteChatHandler = async (id) => {
if (deleting) return;
deleting = true;
const res = await deleteChatById(localStorage.token, id).catch((error) => {
toast.error(`${error}`);
return null;
@ -150,9 +169,16 @@
dispatch('change');
}
deleting = false;
};
let archiving = false;
const archiveChatHandler = async (id) => {
if (archiving) return;
archiving = true;
try {
await archiveChatById(localStorage.token, id);
@ -166,6 +192,8 @@
} catch (error) {
console.error('Error archiving chat:', error);
toast.error($i18n.t('Failed to archive chat.'));
} finally {
archiving = false;
}
};
@ -203,14 +231,10 @@
let x = 0;
let y = 0;
const dragImage = new Image();
dragImage.src =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const onDragStart = (event) => {
event.stopPropagation();
event.dataTransfer.setDragImage(dragImage, 0, 0);
event.dataTransfer.setDragImage(invisibleDragImage, 0, 0);
// Set the data to be transferred
event.dataTransfer.setData(
@ -255,26 +279,20 @@
};
onMount(() => {
if (itemElement) {
document.addEventListener('click', onClickOutside, true);
const el = itemElement;
if (!el) return;
// Event listener for when dragging starts
itemElement.addEventListener('dragstart', onDragStart);
// Event listener for when dragging occurs (optional)
itemElement.addEventListener('drag', onDrag);
// Event listener for when dragging ends
itemElement.addEventListener('dragend', onDragEndHandler);
}
});
document.addEventListener('click', onClickOutside, true);
el.addEventListener('dragstart', onDragStart);
el.addEventListener('drag', onDrag);
el.addEventListener('dragend', onDragEndHandler);
onDestroy(() => {
if (itemElement) {
return () => {
document.removeEventListener('click', onClickOutside, true);
itemElement.removeEventListener('dragstart', onDragStart);
itemElement.removeEventListener('drag', onDrag);
itemElement.removeEventListener('dragend', onDragEndHandler);
}
el.removeEventListener('dragstart', onDragStart);
el.removeEventListener('drag', onDrag);
el.removeEventListener('dragend', onDragEndHandler);
};
});
let showDeleteConfirm = false;
@ -435,6 +453,10 @@
if ($mobile) {
showSidebar.set(false);
}
// Optimistically mark as read in UI when clicked
unread = false;
lastReadAt = Date.now() / 1000;
}}
on:dblclick={async (e) => {
e.preventDefault();
@ -460,7 +482,17 @@
{/if}
<div class="flex self-center flex-1 w-full min-w-0">
<div dir="auto" class="text-left self-center overflow-hidden w-full h-[20px] truncate">
{#if unread}
<div class="shrink-0 self-center pr-2.5 flex transition-opacity duration-300">
<div class="size-1.5 bg-sky-500 rounded-full" />
</div>
{/if}
<div
dir="auto"
class="text-left self-center overflow-hidden w-full h-[20px] truncate {unread
? 'font-medium text-gray-900 dark:text-gray-100'
: ''}"
>
{title}
</div>
</div>
@ -516,7 +548,8 @@
<div class=" flex items-center self-center space-x-1.5">
<Tooltip content={$i18n.t('Archive')} className="flex items-center">
<button
class=" self-center dark:hover:text-white transition"
class=" self-center dark:hover:text-white transition disabled:cursor-not-allowed"
disabled={archiving}
on:click={() => {
archiveChatHandler(id);
}}
@ -528,7 +561,8 @@
<Tooltip content={$i18n.t('Delete')}>
<button
class=" self-center dark:hover:text-white transition"
class=" self-center dark:hover:text-white transition disabled:cursor-not-allowed"
disabled={deleting}
on:click={() => {
deleteChatHandler(id);
}}

View file

@ -682,6 +682,8 @@
id={chat.id}
title={chat.title}
createdAt={chat.created_at}
updatedAt={chat.updated_at}
lastReadAt={chat.last_read_at}
{shiftKey}
on:change={(e) => {
dispatch('change', e.detail);

View file

@ -214,26 +214,41 @@
<div class=" self-center truncate">{$i18n.t('Settings')}</div>
</button>
<button
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
type="button"
on:click={async () => {
show = false;
dispatch('show', 'archived-chat');
if ($mobile) {
await tick();
showSidebar.set(false);
}
}}
>
<div class=" self-center mr-3">
<ArchiveBox className="size-5" strokeWidth="1.5" />
</div>
<div class=" self-center truncate">{$i18n.t('Archived Chats')}</div>
</button>
{#if $user?.role === 'admin' || $user?.permissions?.features?.automations}
<a
href="/automations"
draggable="false"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
show = false;
goto('/automations');
if ($mobile) {
await tick();
showSidebar.set(false);
}
}}
>
<div class="self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</div>
<div class="self-center truncate">{$i18n.t('Automations')}</div>
</a>
{/if}
{#if role === 'admin'}
<a
@ -258,6 +273,30 @@
</div>
<div class=" self-center truncate">{$i18n.t('Playground')}</div>
</a>
{/if}
<button
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
type="button"
on:click={async () => {
show = false;
dispatch('show', 'archived-chat');
if ($mobile) {
await tick();
showSidebar.set(false);
}
}}
>
<div class=" self-center mr-3">
<ArchiveBox className="size-5" strokeWidth="1.5" />
</div>
<div class=" self-center truncate">{$i18n.t('Archived Chats')}</div>
</button>
{#if role === 'admin'}
<a
href="/admin"
draggable="false"

View file

@ -42,6 +42,10 @@
code_interpreter: {
label: $i18n.t('Code Interpreter'),
description: $i18n.t('Execute code')
},
tasks: {
label: $i18n.t('Task Management'),
description: $i18n.t('Break down complex requests into trackable steps')
}
};

View file

@ -60,7 +60,8 @@ export const DEFAULT_PERMISSIONS = {
web_search: true,
image_generation: true,
code_interpreter: true,
memories: true
memories: true,
automations: false
},
settings: {
interface: true

View file

@ -186,6 +186,7 @@
"Are you sure you want to archive all chats? This action cannot be undone.": "",
"Are you sure you want to clear all memories? This action cannot be undone.": "",
"Are you sure you want to delete \"{{NAME}}\"?": "",
"Are you sure you want to delete **{{modelName}}**?": "",
"Are you sure you want to delete all chats? This action cannot be undone.": "",
"Are you sure you want to delete this channel?": "",
"Are you sure you want to delete this connection? This action cannot be undone.": "",
@ -202,6 +203,7 @@
"Assistant": "",
"Async Embedding Processing": "",
"Attach File From Knowledge": "",
"Attach Files": "",
"Attach Knowledge": "",
"Attach Notes": "",
"Attach Webpage": "",
@ -224,6 +226,13 @@
"AUTOMATIC1111 Base URL": "AUTOMATIC1111 الرابط الرئيسي",
"AUTOMATIC1111 Base URL is required.": "AUTOMATIC1111 الرابط مطلوب",
"Automatically inject system tools in native function calling mode (e.g., timestamps, memory, chat history, notes, etc.)": "",
"Automation": "",
"Automation created": "",
"Automation Name": "",
"Automation title": "",
"Automation triggered": "",
"Automation updated": "",
"Automations": "",
"Available list": "",
"Available models": "",
"Available Tools": "",
@ -254,6 +263,7 @@
"Boosting or penalizing specific tokens for constrained responses. Bias values will be clamped between -100 and 100 (inclusive). (Default: none)": "",
"Brave": "",
"Brave Search API Key": "مفتاح واجهة برمجة تطبيقات البحث الشجاع",
"Break down complex requests into trackable steps": "",
"Browse and query knowledge bases": "",
"Builtin Tools": "",
"Bullet List": "",
@ -387,6 +397,7 @@
"Concurrent Requests": "الطلبات المتزامنة",
"Config": "",
"Config imported successfully": "",
"Configuration": "",
"Configure": "",
"Confirm": "",
"Confirm Password": "تأكيد كلمة المرور",
@ -456,6 +467,7 @@
"Create new secret key": "عمل سر جديد",
"Create note": "",
"Create Note": "",
"Create scheduled prompts that run automatically on a recurring basis.": "",
"Create your first note by clicking on the plus button below.": "",
"Created at": "أنشئت في",
"Created At": "أنشئت من",
@ -477,6 +489,7 @@
"Data Controls": "",
"Database": "قاعدة البيانات",
"Datalab Marker API": "",
"Day": "",
"DD/MM/YYYY": "",
"DDGS Backend": "",
"December": "ديسمبر",
@ -507,6 +520,7 @@
"Delete All": "",
"Delete All Chats": "حذف جميع الدردشات",
"Delete all contents inside this folder": "",
"Delete automation?": "",
"Delete Chat": "حذف المحادثه.",
"Delete chat?": "",
"Delete File": "",
@ -651,6 +665,7 @@
"Embedding Concurrent Requests": "",
"Embedding Model": "نموذج التضمين",
"Embedding Model Engine": "تضمين محرك النموذج",
"Emojis": "",
"Empty message": "",
"Enable All": "",
"Enable API Keys": "",
@ -742,6 +757,7 @@
"Enter Perplexity Search API URL": "",
"Enter Playwright Timeout": "",
"Enter Playwright WebSocket URL": "",
"Enter prompt here.": "",
"Enter proxy URL (e.g. https://user:password@host:port)": "",
"Enter reasoning effort": "",
"Enter Score": "أدخل النتيجة",
@ -766,6 +782,7 @@
"Enter system prompt here": "",
"Enter Tavily API Key": "",
"Enter Tavily Extract Depth": "",
"Enter the prompt instructions for this automation...": "",
"Enter the public URL of your WebUI. This URL will be used to generate links in the notifications.": "",
"Enter the URL of the function to import": "",
"Enter the URL to import": "",
@ -805,6 +822,7 @@
"Error accessing directory": "",
"Error accessing Google Drive: {{error}}": "",
"Error accessing media devices.": "",
"Error deleting model: {{error}}": "",
"Error starting recording.": "",
"Error unloading model: {{error}}": "",
"Error uploading file: {{error}}": "",
@ -822,6 +840,7 @@
"Execute code": "",
"Execute code for analysis": "",
"Executing **{{NAME}}**...": "",
"Execution Logs": "",
"Expand": "",
"Experimental": "تجريبي",
"Explain": "",
@ -1085,6 +1104,7 @@
"Insert Suggestion Prompt to Input": "",
"Install from Github URL": "التثبيت من عنوان URL لجيثب",
"Instant Auto-Send After Voice Transcription": "",
"Instructions": "",
"Integration": "",
"Integrations": "",
"Interface": "واجهه المستخدم",
@ -1144,6 +1164,7 @@
"Last 90 days": "",
"Last Active": "آخر نشاط",
"Last Modified": "",
"Last ran": "",
"Last reply": "",
"LDAP": "",
"LDAP server updated": "",
@ -1250,6 +1271,7 @@
"Model '{{modelName}}' has been successfully downloaded.": "تم تحميل النموذج '{{modelName}}' بنجاح",
"Model '{{modelTag}}' is already in queue for downloading.": "النموذج '{{modelTag}}' موجود بالفعل في قائمة الانتظار للتحميل",
"Model {{modelId}} not found": "لم يتم العثور على النموذج {{modelId}}.",
"Model {{modelName}} deleted successfully": "",
"Model {{modelName}} is not vision capable": "نموذج {{modelName}} غير قادر على الرؤية",
"Model {{name}} is now {{status}}": "نموذج {{name}} هو الآن {{status}}",
"Model {{name}} is now hidden": "",
@ -1299,8 +1321,11 @@
"Name": "الأسم",
"Name and ID are required, please fill them out": "",
"Name your knowledge base": "",
"Name, prompt, and model are required": "",
"Native": "",
"Never": "",
"New": "",
"New Automation": "",
"New Button": "",
"New Chat": "دردشة جديدة",
"New File": "",
@ -1319,9 +1344,11 @@
"New Webhook": "",
"new-channel": "",
"Next message": "",
"Next run": "",
"No access grants. Private to you.": "",
"No activity data": "",
"No authentication": "",
"No automations found": "",
"No chats found": "",
"No chats found for this user.": "",
"No chats found.": "",
@ -1332,6 +1359,7 @@
"No data": "",
"No data found": "",
"No distance available": "",
"No execution logs available yet": "",
"No expiration can pose security risks.": "",
"No feedback found": "",
"No file selected": "",
@ -1378,6 +1406,7 @@
"Not factually correct": "ليس صحيحا من حيث الواقع",
"Not helpful": "",
"Not Registered": "",
"Not scheduled": "",
"Note": "",
"Note deleted successfully": "",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "ملاحظة: إذا قمت بتعيين الحد الأدنى من النقاط، فلن يؤدي البحث إلا إلى إرجاع المستندات التي لها نقاط أكبر من أو تساوي الحد الأدنى من النقاط.",
@ -1451,6 +1480,7 @@
"or": "أو",
"Ordered List": "",
"Other": "آخر",
"out of": "",
"Output": "",
"OUTPUT": "",
"Output format": "",
@ -1466,6 +1496,7 @@
"Password": "الباسورد",
"Passwords do not match.": "",
"Paste Large Text as File": "",
"Paused": "",
"PDF document (.pdf)": "PDF ملف (.pdf)",
"PDF Extract Images (OCR)": "PDF أستخرج الصور (OCR)",
"PDF Loader Mode": "",
@ -1570,6 +1601,7 @@
"Reason": "",
"Reasoning Effort": "",
"Reasoning Tags": "",
"Recently Used": "",
"Record": "",
"Record voice": "سجل صوت",
"Redirecting you to Open WebUI Community": "OpenWebUI إعادة توجيهك إلى مجتمع ",
@ -1605,6 +1637,7 @@
"Renamed to {{name}}": "",
"Render Markdown in Previews": "",
"Reorder Models": "",
"Repeats": "",
"Reply": "",
"Reply in Thread": "",
"Reply to thread...": "",
@ -1641,6 +1674,8 @@
"RTL": "من اليمين إلى اليسار",
"Run": "",
"Run All": "",
"Run now": "",
"Run Now": "",
"Running": "",
"Running...": "جارٍ التنفيذ...",
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
@ -1651,12 +1686,15 @@
"Save Chat": "",
"Saved": "",
"Saving chat logs directly to your browser's storage is no longer supported. Please take a moment to download and delete your chat logs by clicking the button below. Don't worry, you can easily re-import your chat logs to the backend through": "لم يعد حفظ سجلات الدردشة مباشرة في مساحة تخزين متصفحك مدعومًا. يرجى تخصيص بعض الوقت لتنزيل وحذف سجلات الدردشة الخاصة بك عن طريق النقر على الزر أدناه. لا تقلق، يمكنك بسهولة إعادة استيراد سجلات الدردشة الخاصة بك إلى الواجهة الخلفية من خلاله",
"Schedule": "",
"Scheduled time must be in the future": "",
"Scroll On Branch Change": "",
"Search": "البحث",
"Search a model": "البحث عن موديل",
"Search all emojis": "",
"Search and manage user memories": "",
"Search and view user chat history": "",
"Search Automations": "",
"Search Base": "",
"Search channels and channel messages": "",
"Search Chats": "البحث في الدردشات",
@ -1726,6 +1764,7 @@
"Select how to split message text for TTS requests": "",
"Select Knowledge": "",
"Select Method": "",
"Select model": "",
"Select only one model to call": "",
"Select view": "",
"Selected model: {{modelName}}": "",
@ -1835,6 +1874,7 @@
"Start of the channel": "بداية القناة",
"Start Tag": "",
"Starting kernel...": "",
"State": "",
"Status": "",
"Status cleared successfully": "",
"Status updated successfully": "",
@ -1885,8 +1925,10 @@
"Talk to Model": "",
"Tap to interrupt": "",
"Task List": "",
"Task Management": "",
"Task Model": "",
"Tasks": "",
"tasks completed": "",
"Tavily API Key": "",
"Tavily Extract Depth": "",
"Tell us more:": "أخبرنا المزيد:",
@ -1953,6 +1995,7 @@
"Tika": "",
"Tika Server URL required.": "",
"Tiktoken": "",
"Time": "",
"Time & Calculation": "",
"Timeout": "",
"Title": "العنوان",
@ -1970,6 +2013,7 @@
"To select toolkits here, add them to the \"Tools\" workspace first.": "",
"Toast notifications for new updates": "",
"Today": "اليوم",
"Today at": "",
"Today at {{LOCALIZED_TIME}}": "",
"Toggle {{COUNT}} sources": "",
"Toggle 1 source": "",
@ -2108,6 +2152,7 @@
"Waiting for upload...": "",
"Warning": "تحذير",
"Warning:": "تحذير:",
"Warning: Enabling this will allow users to run scheduled prompts automatically.": "",
"Warning: Enabling this will allow users to upload arbitrary code on the server.": "",
"Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.": "",
"Web": "Web",
@ -2142,6 +2187,7 @@
"Width": "",
"Wikipedia": "",
"Won": "",
"Working Directory": "",
"Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text.": "",
"Workspace": "مساحة العمل",
"Workspace Permissions": "",

View file

@ -186,6 +186,7 @@
"Are you sure you want to archive all chats? This action cannot be undone.": "",
"Are you sure you want to clear all memories? This action cannot be undone.": "هل أنت متأكد من رغبتك في مسح جميع الذكريات؟ لا يمكن التراجع عن هذا الإجراء.",
"Are you sure you want to delete \"{{NAME}}\"?": "",
"Are you sure you want to delete **{{modelName}}**?": "",
"Are you sure you want to delete all chats? This action cannot be undone.": "",
"Are you sure you want to delete this channel?": "هل أنت متأكد من رغبتك في حذف هذه القناة؟",
"Are you sure you want to delete this connection? This action cannot be undone.": "",
@ -202,6 +203,7 @@
"Assistant": "المساعد",
"Async Embedding Processing": "",
"Attach File From Knowledge": "",
"Attach Files": "",
"Attach Knowledge": "",
"Attach Notes": "",
"Attach Webpage": "",
@ -224,6 +226,13 @@
"AUTOMATIC1111 Base URL": "الرابط الأساسي لـ AUTOMATIC1111",
"AUTOMATIC1111 Base URL is required.": "الرابط الأساسي لـ AUTOMATIC1111 مطلوب.",
"Automatically inject system tools in native function calling mode (e.g., timestamps, memory, chat history, notes, etc.)": "",
"Automation": "",
"Automation created": "",
"Automation Name": "",
"Automation title": "",
"Automation triggered": "",
"Automation updated": "",
"Automations": "",
"Available list": "القائمة المتاحة",
"Available models": "",
"Available Tools": "",
@ -254,6 +263,7 @@
"Boosting or penalizing specific tokens for constrained responses. Bias values will be clamped between -100 and 100 (inclusive). (Default: none)": "تعزيز أو معاقبة رموز محددة لردود مقيدة. ستتراوح قيم التحيز بين -100 و100 (شاملة). (افتراضي: لا شيء)",
"Brave": "",
"Brave Search API Key": "مفتاح API لـ Brave Search",
"Break down complex requests into trackable steps": "",
"Browse and query knowledge bases": "",
"Builtin Tools": "",
"Bullet List": "",
@ -387,6 +397,7 @@
"Concurrent Requests": "الطلبات المتزامنة",
"Config": "",
"Config imported successfully": "",
"Configuration": "",
"Configure": "تكوين",
"Confirm": "تأكيد",
"Confirm Password": "تأكيد كلمة المرور",
@ -456,6 +467,7 @@
"Create new secret key": "إنشاء مفتاح سري جديد",
"Create note": "",
"Create Note": "",
"Create scheduled prompts that run automatically on a recurring basis.": "",
"Create your first note by clicking on the plus button below.": "",
"Created at": "تم الإنشاء في",
"Created At": "تاريخ الإنشاء",
@ -477,6 +489,7 @@
"Data Controls": "",
"Database": "قاعدة البيانات",
"Datalab Marker API": "",
"Day": "",
"DD/MM/YYYY": "",
"DDGS Backend": "",
"December": "ديسمبر",
@ -507,6 +520,7 @@
"Delete All": "",
"Delete All Chats": "حذف جميع الدردشات",
"Delete all contents inside this folder": "",
"Delete automation?": "",
"Delete Chat": "حذف المحادثه.",
"Delete chat?": "هل تريد حذف المحادثة؟",
"Delete File": "",
@ -651,6 +665,7 @@
"Embedding Concurrent Requests": "",
"Embedding Model": "نموذج التضمين",
"Embedding Model Engine": "تضمين محرك النموذج",
"Emojis": "",
"Empty message": "",
"Enable All": "",
"Enable API Keys": "",
@ -742,6 +757,7 @@
"Enter Perplexity Search API URL": "",
"Enter Playwright Timeout": "",
"Enter Playwright WebSocket URL": "",
"Enter prompt here.": "",
"Enter proxy URL (e.g. https://user:password@host:port)": "أدخل عنوان البروكسي (مثال: https://user:password@host:port)",
"Enter reasoning effort": "أدخل مستوى الجهد في الاستدلال",
"Enter Score": "أدخل النتيجة",
@ -766,6 +782,7 @@
"Enter system prompt here": "",
"Enter Tavily API Key": "أدخل مفتاح API لـ Tavily",
"Enter Tavily Extract Depth": "",
"Enter the prompt instructions for this automation...": "",
"Enter the public URL of your WebUI. This URL will be used to generate links in the notifications.": "أدخل الرابط العلني لـ WebUI الخاص بك. سيتم استخدام هذا الرابط لإنشاء روابط داخل الإشعارات.",
"Enter the URL of the function to import": "",
"Enter the URL to import": "",
@ -805,6 +822,7 @@
"Error accessing directory": "",
"Error accessing Google Drive: {{error}}": "حدث خطأ أثناء الوصول إلى Google Drive: {{error}}",
"Error accessing media devices.": "",
"Error deleting model: {{error}}": "",
"Error starting recording.": "",
"Error unloading model: {{error}}": "",
"Error uploading file: {{error}}": "حدث خطأ أثناء تحميل الملف: {{error}}",
@ -822,6 +840,7 @@
"Execute code": "",
"Execute code for analysis": "تنفيذ الكود للتحليل",
"Executing **{{NAME}}**...": "",
"Execution Logs": "",
"Expand": "توسيع",
"Experimental": "تجريبي",
"Explain": "شرح",
@ -1085,6 +1104,7 @@
"Insert Suggestion Prompt to Input": "",
"Install from Github URL": "التثبيت من عنوان URL لجيثب",
"Instant Auto-Send After Voice Transcription": "إرسال تلقائي فوري بعد تحويل الصوت إلى نص",
"Instructions": "",
"Integration": "التكامل",
"Integrations": "",
"Interface": "واجهه المستخدم",
@ -1144,6 +1164,7 @@
"Last 90 days": "",
"Last Active": "آخر نشاط",
"Last Modified": "آخر تعديل",
"Last ran": "",
"Last reply": "آخر رد",
"LDAP": "LDAP",
"LDAP server updated": "تم تحديث خادم LDAP",
@ -1250,6 +1271,7 @@
"Model '{{modelName}}' has been successfully downloaded.": "تم تحميل النموذج '{{modelName}}' بنجاح",
"Model '{{modelTag}}' is already in queue for downloading.": "النموذج '{{modelTag}}' موجود بالفعل في قائمة الانتظار للتحميل",
"Model {{modelId}} not found": "لم يتم العثور على النموذج {{modelId}}.",
"Model {{modelName}} deleted successfully": "",
"Model {{modelName}} is not vision capable": "نموذج {{modelName}} غير قادر على الرؤية",
"Model {{name}} is now {{status}}": "نموذج {{name}} هو الآن {{status}}",
"Model {{name}} is now hidden": "",
@ -1299,8 +1321,11 @@
"Name": "الأسم",
"Name and ID are required, please fill them out": "",
"Name your knowledge base": "قم بتسمية قاعدة معرفتك",
"Name, prompt, and model are required": "",
"Native": "أصلي",
"Never": "",
"New": "",
"New Automation": "",
"New Button": "",
"New Chat": "دردشة جديدة",
"New File": "",
@ -1319,9 +1344,11 @@
"New Webhook": "",
"new-channel": "قناة جديدة",
"Next message": "",
"Next run": "",
"No access grants. Private to you.": "",
"No activity data": "",
"No authentication": "",
"No automations found": "",
"No chats found": "",
"No chats found for this user.": "",
"No chats found.": "",
@ -1332,6 +1359,7 @@
"No data": "",
"No data found": "",
"No distance available": "لا توجد مسافة متاحة",
"No execution logs available yet": "",
"No expiration can pose security risks.": "",
"No feedback found": "",
"No file selected": "لم يتم تحديد ملف",
@ -1378,6 +1406,7 @@
"Not factually correct": "ليس صحيحا من حيث الواقع",
"Not helpful": "غير مفيد",
"Not Registered": "",
"Not scheduled": "",
"Note": "",
"Note deleted successfully": "",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "ملاحظة: إذا قمت بتعيين الحد الأدنى من النقاط، فلن يؤدي البحث إلا إلى إرجاع المستندات التي لها نقاط أكبر من أو تساوي الحد الأدنى من النقاط.",
@ -1451,6 +1480,7 @@
"or": "أو",
"Ordered List": "",
"Other": "آخر",
"out of": "",
"Output": "",
"OUTPUT": "الإخراج",
"Output format": "تنسيق الإخراج",
@ -1466,6 +1496,7 @@
"Password": "الباسورد",
"Passwords do not match.": "",
"Paste Large Text as File": "الصق نصًا كبيرًا كملف",
"Paused": "",
"PDF document (.pdf)": "PDF ملف (.pdf)",
"PDF Extract Images (OCR)": "PDF أستخرج الصور (OCR)",
"PDF Loader Mode": "",
@ -1570,6 +1601,7 @@
"Reason": "",
"Reasoning Effort": "جهد الاستدلال",
"Reasoning Tags": "",
"Recently Used": "",
"Record": "",
"Record voice": "سجل صوت",
"Redirecting you to Open WebUI Community": "OpenWebUI إعادة توجيهك إلى مجتمع ",
@ -1605,6 +1637,7 @@
"Renamed to {{name}}": "",
"Render Markdown in Previews": "",
"Reorder Models": "إعادة ترتيب النماذج",
"Repeats": "",
"Reply": "",
"Reply in Thread": "الرد داخل سلسلة الرسائل",
"Reply to thread...": "",
@ -1641,6 +1674,8 @@
"RTL": "من اليمين إلى اليسار",
"Run": "تنفيذ",
"Run All": "",
"Run now": "",
"Run Now": "",
"Running": "جارٍ التنفيذ",
"Running...": "جارٍ التنفيذ...",
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
@ -1651,12 +1686,15 @@
"Save Chat": "",
"Saved": "تم الحفظ",
"Saving chat logs directly to your browser's storage is no longer supported. Please take a moment to download and delete your chat logs by clicking the button below. Don't worry, you can easily re-import your chat logs to the backend through": "لم يعد حفظ سجلات الدردشة مباشرة في مساحة تخزين متصفحك مدعومًا. يرجى تخصيص بعض الوقت لتنزيل وحذف سجلات الدردشة الخاصة بك عن طريق النقر على الزر أدناه. لا تقلق، يمكنك بسهولة إعادة استيراد سجلات الدردشة الخاصة بك إلى الواجهة الخلفية من خلاله",
"Schedule": "",
"Scheduled time must be in the future": "",
"Scroll On Branch Change": "",
"Search": "البحث",
"Search a model": "البحث عن موديل",
"Search all emojis": "",
"Search and manage user memories": "",
"Search and view user chat history": "",
"Search Automations": "",
"Search Base": "قاعدة البحث",
"Search channels and channel messages": "",
"Search Chats": "البحث في الدردشات",
@ -1726,6 +1764,7 @@
"Select how to split message text for TTS requests": "",
"Select Knowledge": "اختر المعرفة",
"Select Method": "",
"Select model": "",
"Select only one model to call": "اختر نموذجًا واحدًا فقط للاستدعاء",
"Select view": "",
"Selected model: {{modelName}}": "",
@ -1835,6 +1874,7 @@
"Start of the channel": "بداية القناة",
"Start Tag": "",
"Starting kernel...": "",
"State": "",
"Status": "",
"Status cleared successfully": "",
"Status updated successfully": "",
@ -1885,8 +1925,10 @@
"Talk to Model": "",
"Tap to interrupt": "اضغط للمقاطعة",
"Task List": "",
"Task Management": "",
"Task Model": "",
"Tasks": "المهام",
"tasks completed": "",
"Tavily API Key": "مفتاح API لـ Tavily",
"Tavily Extract Depth": "",
"Tell us more:": "أخبرنا المزيد:",
@ -1953,6 +1995,7 @@
"Tika": "Tika",
"Tika Server URL required.": "عنوان خادم Tika مطلوب.",
"Tiktoken": "Tiktoken",
"Time": "",
"Time & Calculation": "",
"Timeout": "",
"Title": "العنوان",
@ -1970,6 +2013,7 @@
"To select toolkits here, add them to the \"Tools\" workspace first.": "لاختيار الأدوات هنا، أضفها أولاً إلى مساحة العمل \"الأدوات\".",
"Toast notifications for new updates": "إشعارات منبثقة للتحديثات الجديدة",
"Today": "اليوم",
"Today at": "",
"Today at {{LOCALIZED_TIME}}": "",
"Toggle {{COUNT}} sources": "",
"Toggle 1 source": "",
@ -2108,6 +2152,7 @@
"Waiting for upload...": "",
"Warning": "تحذير",
"Warning:": "تحذير:",
"Warning: Enabling this will allow users to run scheduled prompts automatically.": "",
"Warning: Enabling this will allow users to upload arbitrary code on the server.": "تحذير: تفعيل هذا الخيار سيسمح للمستخدمين برفع كود عشوائي على الخادم.",
"Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.": "تحذير: تنفيذ كود Jupyter يتيح تنفيذ كود عشوائي مما يشكل مخاطر أمنية جسيمة—تابع بحذر شديد.",
"Web": "Web",
@ -2142,6 +2187,7 @@
"Width": "",
"Wikipedia": "",
"Won": "فاز",
"Working Directory": "",
"Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text.": "يعمل جنبًا إلى جنب مع top-k. القيمة الأعلى (مثلاً 0.95) تنتج نصًا أكثر تنوعًا، بينما القيمة الأقل (مثلاً 0.5) تنتج نصًا أكثر تركيزًا وتحفظًا.",
"Workspace": "مساحة العمل",
"Workspace Permissions": "صلاحيات مساحة العمل",

View file

@ -182,6 +182,7 @@
"Are you sure you want to archive all chats? This action cannot be undone.": "Bütün çatları arxivləşdirmək istədiyinizə əminsiniz? Bu əməliyyat geri qaytarıla bilməz.",
"Are you sure you want to clear all memories? This action cannot be undone.": "Bütün yaddaşı təmizləmək istədiyinizə əminsiniz? Bu əməliyyat geri qaytarıla bilməz.",
"Are you sure you want to delete \"{{NAME}}\"?": "\"{{NAME}}\" elementini silmək istədiyinizə əminsiniz?",
"Are you sure you want to delete **{{modelName}}**?": "",
"Are you sure you want to delete all chats? This action cannot be undone.": "Bütün çatları silmək istədiyinizə əminsiniz? Bu əməliyyat geri qaytarıla bilməz.",
"Are you sure you want to delete this channel?": "Bu kanalı silmək istədiyinizə əminsiniz?",
"Are you sure you want to delete this connection? This action cannot be undone.": "",
@ -198,6 +199,7 @@
"Assistant": "Köməkçi",
"Async Embedding Processing": "Asinxron Yerləşdirmə (Embedding) Emalı",
"Attach File From Knowledge": "Bilik bazasından fayl əlavə et",
"Attach Files": "",
"Attach Knowledge": "Bilik əlavə et",
"Attach Notes": "Qeydlər əlavə et",
"Attach Webpage": "Veb səhifə əlavə et",
@ -220,6 +222,13 @@
"AUTOMATIC1111 Base URL": "AUTOMATIC1111 Baza URL-i",
"AUTOMATIC1111 Base URL is required.": "AUTOMATIC1111 Baza URL-i tələb olunur.",
"Automatically inject system tools in native function calling mode (e.g., timestamps, memory, chat history, notes, etc.)": "Sistem alətlərini yerli funksiya çağırma rejimində avtomatik daxil et (məsələn: vaxt möhürləri, yaddaş, çat tarixçəsi, qeydlər və s.)",
"Automation": "",
"Automation created": "",
"Automation Name": "",
"Automation title": "",
"Automation triggered": "",
"Automation updated": "",
"Automations": "",
"Available list": "Mövcud siyahı",
"Available models": "Mövcud modellər",
"Available Tools": "Mövcud alətlər",
@ -250,6 +259,7 @@
"Boosting or penalizing specific tokens for constrained responses. Bias values will be clamped between -100 and 100 (inclusive). (Default: none)": "Məhdudlaşdırılmış cavablar üçün müəyyən tokenlərin stimullaşdırılması və ya cəzalandırılması. Meyillilik (bias) dəyərləri -100 ilə 100 arasında (daxil olmaqla) məhdudlaşdırılacaq. (Standart: yoxdur)",
"Brave": "Brave",
"Brave Search API Key": "Brave Search API Açarı",
"Break down complex requests into trackable steps": "",
"Browse and query knowledge bases": "Bilik bazalarına baxın və sorğu göndərin",
"Builtin Tools": "Daxili Alətlər",
"Bullet List": "Markerli Siyahı",
@ -383,6 +393,7 @@
"Concurrent Requests": "Eyni vaxtda olan sorğular",
"Config": "Konfiqurasiya",
"Config imported successfully": "Konfiqurasiya uğurla idxal edildi",
"Configuration": "",
"Configure": "Konfiqurasiya et",
"Confirm": "Təsdiqlə",
"Confirm Password": "Şifrəni təsdiqlə",
@ -452,6 +463,7 @@
"Create new secret key": "Yeni gizli açar yarat",
"Create note": "Qeyd yarat",
"Create Note": "Qeyd Yarat",
"Create scheduled prompts that run automatically on a recurring basis.": "",
"Create your first note by clicking on the plus button below.": "Aşağıdakı 'plus' düyməsinə klikləyərək ilk qeydinizi yaradın.",
"Created at": "Yaradılma vaxtı",
"Created At": "Yaradılma Tarixi",
@ -473,6 +485,7 @@
"Data Controls": "Məlumat idarəetmələri",
"Database": "Verilənlər bazası",
"Datalab Marker API": "Datalab Marker API",
"Day": "",
"DD/MM/YYYY": "GG/AA/İİİİ",
"DDGS Backend": "DDGS Backend",
"December": "Dekabr",
@ -503,6 +516,7 @@
"Delete All": "Hamısını sil",
"Delete All Chats": "Bütün çatları sil",
"Delete all contents inside this folder": "Bu qovluğun daxilindəki bütün məzmunu sil",
"Delete automation?": "",
"Delete Chat": "Çatı sil",
"Delete chat?": "Çat silinsin?",
"Delete File": "Faylı sil",
@ -647,6 +661,7 @@
"Embedding Concurrent Requests": "Eyni vaxtda olan yerləşdirmə sorğuları",
"Embedding Model": "Yerləşdirmə modeli",
"Embedding Model Engine": "Yerləşdirmə modeli mühərriki",
"Emojis": "",
"Empty message": "Boş mesaj",
"Enable All": "Hamısını aktiv et",
"Enable API Keys": "API açarlarını aktiv et",
@ -738,6 +753,7 @@
"Enter Perplexity Search API URL": "Perplexity axtarış API URL-ini daxil edin",
"Enter Playwright Timeout": "Playwright vaxt aşımını daxil edin",
"Enter Playwright WebSocket URL": "Playwright WebSocket URL-ini daxil edin",
"Enter prompt here.": "",
"Enter proxy URL (e.g. https://user:password@host:port)": "Proksi URL-ini daxil edin (məs. https://istifadəçi:şifrə@host:port)",
"Enter reasoning effort": "Mühakimə səyini (reasoning effort) daxil edin",
"Enter Score": "Bal daxil edin",
@ -762,6 +778,7 @@
"Enter system prompt here": "Sistem göstərişini bura daxil edin",
"Enter Tavily API Key": "Tavily API açarını daxil edin",
"Enter Tavily Extract Depth": "Tavily çıxarış dərinliyini daxil edin",
"Enter the prompt instructions for this automation...": "",
"Enter the public URL of your WebUI. This URL will be used to generate links in the notifications.": "WebUI-nizin ictimai URL-ini daxil edin. Bu URL bildirişlərdəki linkləri yaratmaq üçün istifadə olunacaq.",
"Enter the URL of the function to import": "İdxal ediləcək funksiyanın URL-ini daxil edin",
"Enter the URL to import": "İdxal ediləcək URL-i daxil edin",
@ -801,6 +818,7 @@
"Error accessing directory": "Kataloqa giriş xətası",
"Error accessing Google Drive: {{error}}": "Google Drive-a giriş xətası: {{error}}",
"Error accessing media devices.": "Media cihazlarına giriş xətası.",
"Error deleting model: {{error}}": "",
"Error starting recording.": "Yazılışa başlama xətası.",
"Error unloading model: {{error}}": "Modeli yaddaşdan çıxarma xətası: {{error}}",
"Error uploading file: {{error}}": "Fayl yükləmə xətası: {{error}}",
@ -818,6 +836,7 @@
"Execute code": "Kodu icra et",
"Execute code for analysis": "Analiz üçün kodu icra et",
"Executing **{{NAME}}**...": "**{{NAME}}** icra edilir...",
"Execution Logs": "",
"Expand": "Genişləndir",
"Experimental": "Eksperimental",
"Explain": "İzah et",
@ -1081,6 +1100,7 @@
"Insert Suggestion Prompt to Input": "Təklif göstərişini girişə daxil et",
"Install from Github URL": "Github URL-dən quraşdır",
"Instant Auto-Send After Voice Transcription": "Səs yazıldıqdan sonra anında avtomatik göndər",
"Instructions": "",
"Integration": "İnteqrasiya",
"Integrations": "İnteqrasiyalar",
"Interface": "İnterfeys",
@ -1140,6 +1160,7 @@
"Last 90 days": "Son 90 gün",
"Last Active": "Son fəallıq",
"Last Modified": "Son dəyişiklik",
"Last ran": "",
"Last reply": "Son cavab",
"LDAP": "LDAP",
"LDAP server updated": "LDAP serveri yeniləndi",
@ -1246,6 +1267,7 @@
"Model '{{modelName}}' has been successfully downloaded.": "'{{modelName}}' modeli uğurla yükləndi.",
"Model '{{modelTag}}' is already in queue for downloading.": "'{{modelTag}}' modeli artıq yükləmə növbəsindədir.",
"Model {{modelId}} not found": "{{modelId}} modeli tapılmadı",
"Model {{modelName}} deleted successfully": "",
"Model {{modelName}} is not vision capable": "{{modelName}} modeli görüntünü tanıma (vision) qabiliyyətinə malik deyil",
"Model {{name}} is now {{status}}": "{{name}} modeli indi {{status}} statusundadır",
"Model {{name}} is now hidden": "{{name}} modeli artıq gizlidir",
@ -1295,8 +1317,11 @@
"Name": "Ad",
"Name and ID are required, please fill them out": "Ad və ID tələb olunur, zəhmət olmasa doldurun",
"Name your knowledge base": "Bilik bazanızı adlandırın",
"Name, prompt, and model are required": "",
"Native": "Yerli (Native)",
"Never": "",
"New": "Yeni",
"New Automation": "",
"New Button": "Yeni Düymə",
"New Chat": "Yeni Çat",
"New File": "Yeni Fayl",
@ -1315,9 +1340,11 @@
"New Webhook": "Yeni Webhook",
"new-channel": "yeni-kanal",
"Next message": "Növbəti mesaj",
"Next run": "",
"No access grants. Private to you.": "Giriş icazəsi yoxdur. Sizin üçün özəldir.",
"No activity data": "Fəaliyyət məlumatı yoxdur",
"No authentication": "Autentifikasiya yoxdur",
"No automations found": "",
"No chats found": "Heç bir çat tapılmadı",
"No chats found for this user.": "Bu istifadəçi üçün çat tapılmadı.",
"No chats found.": "Çat tapılmadı.",
@ -1328,6 +1355,7 @@
"No data": "Məlumat yoxdur",
"No data found": "Məlumat tapılmadı",
"No distance available": "Məsafə məlumatı yoxdur",
"No execution logs available yet": "",
"No expiration can pose security risks.": "Müddətin bitməməsi təhlükəsizlik riski yarada bilər.",
"No feedback found": "Rəy tapılmadı",
"No file selected": "Fayl seçilməyib",
@ -1374,6 +1402,7 @@
"Not factually correct": "Faktiki olaraq doğru deyil",
"Not helpful": "Faydalı deyil",
"Not Registered": "Qeydiyyatdan keçməyib",
"Not scheduled": "",
"Note": "Qeyd",
"Note deleted successfully": "Qeyd uğurla silindi",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "Qeyd: Əgər minimum bal təyin etsəniz, axtarış yalnız balı həmin minimuma bərabər və ya ondan böyük olan sənədləri qaytaracaq.",
@ -1447,6 +1476,7 @@
"or": "və ya",
"Ordered List": "Sıralanmış siyahı",
"Other": "Digər",
"out of": "",
"Output": ıxış",
"OUTPUT": "ÇIXIŞ",
"Output format": ıxış formatı",
@ -1462,6 +1492,7 @@
"Password": "Şifrə",
"Passwords do not match.": "Şifrələr uyğun gəlmir.",
"Paste Large Text as File": "Böyük mətni fayl kimi yapışdır",
"Paused": "",
"PDF document (.pdf)": "PDF sənədi (.pdf)",
"PDF Extract Images (OCR)": "PDF-dən şəkillərin çıxarılması (OCR)",
"PDF Loader Mode": "PDF yükləyici rejimi",
@ -1566,6 +1597,7 @@
"Reason": "Səbəb",
"Reasoning Effort": "Mühakimə səyi",
"Reasoning Tags": "Mühakimə etiketləri",
"Recently Used": "",
"Record": "Yaz (səs)",
"Record voice": "Səsi yaz",
"Redirecting you to Open WebUI Community": "Open WebUI İcmasına yönləndirilirsiniz",
@ -1601,6 +1633,7 @@
"Renamed to {{name}}": "",
"Render Markdown in Previews": "Önizləmələrdə Markdown-u emal et",
"Reorder Models": "Modelləri yenidən sırala",
"Repeats": "",
"Reply": "Cavabla",
"Reply in Thread": "Mövzu daxilində cavabla",
"Reply to thread...": "Mövzuya cavab yaz...",
@ -1633,6 +1666,8 @@
"RTL": "Sağdan sola (RTL)",
"Run": "İcra et",
"Run All": "Hamısını icra et",
"Run now": "",
"Run Now": "",
"Running": "İcra edilir",
"Running...": "İcra edilir...",
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "Emalı sürətləndirmək üçün yerləşdirmə (embedding) tapşırıqlarını eyni vaxtda icra edir. Əgər sorğu limiti problemi yaranarsa, bunu söndürün.",
@ -1643,12 +1678,15 @@
"Save Chat": "Çatı saxla",
"Saved": "Yadda saxlanıldı",
"Saving chat logs directly to your browser's storage is no longer supported. Please take a moment to download and delete your chat logs by clicking the button below. Don't worry, you can easily re-import your chat logs to the backend through": "Çat tarixçəsinin birbaşa brauzer yaddaşına saxlanılması artıq dəstəklənmir. Zəhmət olmasa, aşağıdakı düyməyə klikləyərək çat jurnalını yükləyin və silin. Narahat olmayın, çat jurnalınızı arxa plana (backend) asanlıqla yenidən idxal edə bilərsiniz:",
"Schedule": "",
"Scheduled time must be in the future": "",
"Scroll On Branch Change": "Şaxə dəyişdikdə sürüşdür",
"Search": "Axtar",
"Search a model": "Model axtar",
"Search all emojis": "Bütün emojilərdə axtar",
"Search and manage user memories": "İstifadəçi yaddaşını axtarın və idarə edin",
"Search and view user chat history": "İstifadəçi çat tarixçəsini axtarın və baxın",
"Search Automations": "",
"Search Base": "Axtarış bazası",
"Search channels and channel messages": "Kanalları və kanal mesajlarını axtar",
"Search Chats": "Çatları axtar",
@ -1718,6 +1756,7 @@
"Select how to split message text for TTS requests": "TTS sorğuları üçün mesaj mətninin necə bölünəcəyini seçin",
"Select Knowledge": "Bilik seçin",
"Select Method": "Üsul seçin",
"Select model": "",
"Select only one model to call": "Çağırmaq üçün yalnız bir model seçin",
"Select view": "Görünüşü seçin",
"Selected model: {{modelName}}": "Seçilmiş model: {{modelName}}",
@ -1827,6 +1866,7 @@
"Start of the channel": "Kanalın başlanğıcı",
"Start Tag": "Start Tag",
"Starting kernel...": "Starting kernel...",
"State": "",
"Status": "Status",
"Status cleared successfully": "Status uğurla təmizləndi",
"Status updated successfully": "Status uğurla yeniləndi",
@ -1877,8 +1917,10 @@
"Talk to Model": "Modellə danış",
"Tap to interrupt": "Durdurmaq üçün toxun",
"Task List": "Tapşırıq siyahısı",
"Task Management": "",
"Task Model": "Tapşırıq modeli",
"Tasks": "Tapşırıqlar",
"tasks completed": "",
"Tavily API Key": "Tavily API key",
"Tavily Extract Depth": "Tavily çıxarış dərinliyi",
"Tell us more:": "Bizə daha çox məlumat verin:",
@ -1945,6 +1987,7 @@
"Tika": "Tika",
"Tika Server URL required.": "Tika Server URL-i tələb olunur.",
"Tiktoken": "Tiktoken",
"Time": "",
"Time & Calculation": "Vaxt və Hesablama",
"Timeout": "Vaxt aşımı",
"Title": "Başlıq",
@ -1962,6 +2005,7 @@
"To select toolkits here, add them to the \"Tools\" workspace first.": "Bura alət dəstləri seçmək üçün əvvəlcə onları \"Alətlər\" (Tools) iş sahəsinə əlavə edin.",
"Toast notifications for new updates": "Yeni yeniləmələr üçün bildirişlər",
"Today": "Bu gün",
"Today at": "",
"Today at {{LOCALIZED_TIME}}": "Bu gün saat {{LOCALIZED_TIME}}",
"Toggle {{COUNT}} sources": "{{COUNT}} mənbəni göstər/gizlə",
"Toggle 1 source": "1 mənbəni göstər/gizlə",
@ -2100,6 +2144,7 @@
"Waiting for upload...": "Yüklənmə gözlənilir...",
"Warning": "Xəbərdarlıq",
"Warning:": "Xəbərdarlıq:",
"Warning: Enabling this will allow users to run scheduled prompts automatically.": "",
"Warning: Enabling this will allow users to upload arbitrary code on the server.": "Xəbərdarlıq: Bunun aktiv edilməsi istifadəçilərə serverə ixtiyari kod yükləməyə icazə verəcək.",
"Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.": "Xəbərdarlıq: Jupyter icrası ixtiyari kodun işlədilməsinə imkan verir və ciddi təhlükəsizlik riskləri yaradır — son dərəcə ehtiyatlı olun.",
"Web": "Veb",
@ -2134,6 +2179,7 @@
"Width": "En",
"Wikipedia": "Vikipediya",
"Won": "Qazandı",
"Working Directory": "",
"Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text.": "Top-k ilə birlikdə işləyir. Daha yüksək dəyər (məs. 0.95) daha müxtəlif mətnlərə, daha aşağı dəyər isə (məs. 0.5) daha fokuslanmış və mühafizəkar mətnlərin yaranmasına səbəb olacaq.",
"Workspace": "İş sahəsi",
"Workspace Permissions": "İş sahəsi icazələri",

View file

@ -182,6 +182,7 @@
"Are you sure you want to archive all chats? This action cannot be undone.": "",
"Are you sure you want to clear all memories? This action cannot be undone.": "Сигурни ли сте, че исткате да изчистите всички спомени? Това е необратимо.",
"Are you sure you want to delete \"{{NAME}}\"?": "",
"Are you sure you want to delete **{{modelName}}**?": "",
"Are you sure you want to delete all chats? This action cannot be undone.": "",
"Are you sure you want to delete this channel?": "Сигурни ли сте, че искате да изтриете този канал?",
"Are you sure you want to delete this connection? This action cannot be undone.": "",
@ -198,6 +199,7 @@
"Assistant": "Асистент",
"Async Embedding Processing": "",
"Attach File From Knowledge": "",
"Attach Files": "",
"Attach Knowledge": "",
"Attach Notes": "",
"Attach Webpage": "",
@ -220,6 +222,13 @@
"AUTOMATIC1111 Base URL": "AUTOMATIC1111 Базов URL",
"AUTOMATIC1111 Base URL is required.": "AUTOMATIC1111 Базов URL е задължителен.",
"Automatically inject system tools in native function calling mode (e.g., timestamps, memory, chat history, notes, etc.)": "",
"Automation": "",
"Automation created": "",
"Automation Name": "",
"Automation title": "",
"Automation triggered": "",
"Automation updated": "",
"Automations": "",
"Available list": "Наличен списък",
"Available models": "",
"Available Tools": "Налични инструменти",
@ -250,6 +259,7 @@
"Boosting or penalizing specific tokens for constrained responses. Bias values will be clamped between -100 and 100 (inclusive). (Default: none)": "",
"Brave": "",
"Brave Search API Key": "API ключ за Brave Search",
"Break down complex requests into trackable steps": "",
"Browse and query knowledge bases": "",
"Builtin Tools": "",
"Bullet List": "",
@ -383,6 +393,7 @@
"Concurrent Requests": "Едновременни заявки",
"Config": "",
"Config imported successfully": "",
"Configuration": "",
"Configure": "Конфигуриране",
"Confirm": "Потвърди",
"Confirm Password": "Потвърди Парола",
@ -452,6 +463,7 @@
"Create new secret key": "Създаване на нов секретен ключ",
"Create note": "",
"Create Note": "",
"Create scheduled prompts that run automatically on a recurring basis.": "",
"Create your first note by clicking on the plus button below.": "",
"Created at": "Създадено на",
"Created At": "Създадено на",
@ -473,6 +485,7 @@
"Data Controls": "",
"Database": "База данни",
"Datalab Marker API": "",
"Day": "",
"DD/MM/YYYY": "",
"DDGS Backend": "",
"December": "Декември",
@ -503,6 +516,7 @@
"Delete All": "",
"Delete All Chats": "Изтриване на всички чатове",
"Delete all contents inside this folder": "",
"Delete automation?": "",
"Delete Chat": "Изтриване на Чат",
"Delete chat?": "Изтриване на чата?",
"Delete File": "",
@ -647,6 +661,7 @@
"Embedding Concurrent Requests": "",
"Embedding Model": "Модел за вграждане",
"Embedding Model Engine": "Двигател на модела за вграждане",
"Emojis": "",
"Empty message": "",
"Enable All": "",
"Enable API Keys": "",
@ -738,6 +753,7 @@
"Enter Perplexity Search API URL": "",
"Enter Playwright Timeout": "",
"Enter Playwright WebSocket URL": "",
"Enter prompt here.": "",
"Enter proxy URL (e.g. https://user:password@host:port)": "Въведете URL адрес на прокси (напр. https://user:password@host:port)",
"Enter reasoning effort": "Въведете усилие за разсъждение",
"Enter Score": "Въведете оценка",
@ -762,6 +778,7 @@
"Enter system prompt here": "",
"Enter Tavily API Key": "Въведете API ключ за Tavily",
"Enter Tavily Extract Depth": "",
"Enter the prompt instructions for this automation...": "",
"Enter the public URL of your WebUI. This URL will be used to generate links in the notifications.": "Въведете публичния URL адрес на вашия WebUI. Този URL адрес ще бъде използван за генериране на връзки в известията.",
"Enter the URL of the function to import": "",
"Enter the URL to import": "",
@ -801,6 +818,7 @@
"Error accessing directory": "",
"Error accessing Google Drive: {{error}}": "Грешка при достъп до Google Drive: {{error}}",
"Error accessing media devices.": "",
"Error deleting model: {{error}}": "",
"Error starting recording.": "",
"Error unloading model: {{error}}": "",
"Error uploading file: {{error}}": "Грешка при качване на файла: {{error}}",
@ -818,6 +836,7 @@
"Execute code": "",
"Execute code for analysis": "Изпълнете кода за анализ",
"Executing **{{NAME}}**...": "",
"Execution Logs": "",
"Expand": "",
"Experimental": "Експериментално",
"Explain": "",
@ -1081,6 +1100,7 @@
"Insert Suggestion Prompt to Input": "",
"Install from Github URL": "Инсталиране от URL адреса на Github",
"Instant Auto-Send After Voice Transcription": "Незабавно автоматично изпращане след гласова транскрипция",
"Instructions": "",
"Integration": "",
"Integrations": "",
"Interface": "Интерфейс",
@ -1140,6 +1160,7 @@
"Last 90 days": "",
"Last Active": "Последни активни",
"Last Modified": "Последно модифицирано",
"Last ran": "",
"Last reply": "Последен отговор",
"LDAP": "LDAP",
"LDAP server updated": "LDAP сървърът е актуализиран",
@ -1246,6 +1267,7 @@
"Model '{{modelName}}' has been successfully downloaded.": "Моделът '{{modelName}}' беше успешно свален.",
"Model '{{modelTag}}' is already in queue for downloading.": "Моделът '{{modelTag}}' е вече в очакване за сваляне.",
"Model {{modelId}} not found": "Моделът {{modelId}} не е намерен",
"Model {{modelName}} deleted successfully": "",
"Model {{modelName}} is not vision capable": "Моделът {{modelName}} не поддържа визуални възможности",
"Model {{name}} is now {{status}}": "Моделът {{name}} сега е {{status}}",
"Model {{name}} is now hidden": "",
@ -1295,8 +1317,11 @@
"Name": "Име",
"Name and ID are required, please fill them out": "",
"Name your knowledge base": "Именувайте вашата база от знания",
"Name, prompt, and model are required": "",
"Native": "Нативен",
"Never": "",
"New": "",
"New Automation": "",
"New Button": "",
"New Chat": "Нов чат",
"New File": "",
@ -1315,9 +1340,11 @@
"New Webhook": "",
"new-channel": "нов-канал",
"Next message": "",
"Next run": "",
"No access grants. Private to you.": "",
"No activity data": "",
"No authentication": "",
"No automations found": "",
"No chats found": "",
"No chats found for this user.": "",
"No chats found.": "",
@ -1328,6 +1355,7 @@
"No data": "",
"No data found": "",
"No distance available": "Няма налично разстояние",
"No execution logs available yet": "",
"No expiration can pose security risks.": "",
"No feedback found": "",
"No file selected": "Не е избран файл",
@ -1374,6 +1402,7 @@
"Not factually correct": "Не е фактологически правилно",
"Not helpful": "Не е полезно",
"Not Registered": "",
"Not scheduled": "",
"Note": "",
"Note deleted successfully": "",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "Забележка: Ако зададете минимален резултат, търсенето ще върне само документи с резултат, по-голям или равен на минималния резултат.",
@ -1447,6 +1476,7 @@
"or": "или",
"Ordered List": "",
"Other": "Друго",
"out of": "",
"Output": "",
"OUTPUT": "ИЗХОД",
"Output format": "Изходен формат",
@ -1462,6 +1492,7 @@
"Password": "Парола",
"Passwords do not match.": "",
"Paste Large Text as File": "Поставете голям текст като файл",
"Paused": "",
"PDF document (.pdf)": "PDF документ (.pdf)",
"PDF Extract Images (OCR)": "Извличане на изображения от PDF (OCR)",
"PDF Loader Mode": "",
@ -1566,6 +1597,7 @@
"Reason": "",
"Reasoning Effort": "Усилие за разсъждение",
"Reasoning Tags": "",
"Recently Used": "",
"Record": "Запиши",
"Record voice": "Записване на глас",
"Redirecting you to Open WebUI Community": "Пренасочване към OpenWebUI общността",
@ -1601,6 +1633,7 @@
"Renamed to {{name}}": "",
"Render Markdown in Previews": "",
"Reorder Models": "Преорганизиране на моделите",
"Repeats": "",
"Reply": "",
"Reply in Thread": "Отговори в тред",
"Reply to thread...": "",
@ -1633,6 +1666,8 @@
"RTL": "RTL",
"Run": "Изпълни",
"Run All": "",
"Run now": "",
"Run Now": "",
"Running": "Изпълнява се",
"Running...": "Изпълнява се...",
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
@ -1643,12 +1678,15 @@
"Save Chat": "",
"Saved": "Запазено",
"Saving chat logs directly to your browser's storage is no longer supported. Please take a moment to download and delete your chat logs by clicking the button below. Don't worry, you can easily re-import your chat logs to the backend through": "Запазването на чат логове директно в хранилището на вашия браузър вече не се поддържа. Моля, отделете малко време, за да изтеглите и изтриете чат логовете си, като щракнете върху бутона по-долу. Не се притеснявайте, можете лесно да импортирате отново чат логовете си в бекенда чрез",
"Schedule": "",
"Scheduled time must be in the future": "",
"Scroll On Branch Change": "",
"Search": "Търси",
"Search a model": "Търси модел",
"Search all emojis": "",
"Search and manage user memories": "",
"Search and view user chat history": "",
"Search Automations": "",
"Search Base": "База за търсене",
"Search channels and channel messages": "",
"Search Chats": "Търсене на чатове",
@ -1718,6 +1756,7 @@
"Select how to split message text for TTS requests": "",
"Select Knowledge": "Изберете знание",
"Select Method": "",
"Select model": "",
"Select only one model to call": "Изберете само един модел за извикване",
"Select view": "",
"Selected model: {{modelName}}": "",
@ -1827,6 +1866,7 @@
"Start of the channel": "Начало на канала",
"Start Tag": "",
"Starting kernel...": "",
"State": "",
"Status": "",
"Status cleared successfully": "",
"Status updated successfully": "",
@ -1877,8 +1917,10 @@
"Talk to Model": "",
"Tap to interrupt": "Докоснете за прекъсване",
"Task List": "",
"Task Management": "",
"Task Model": "",
"Tasks": "Задачи",
"tasks completed": "",
"Tavily API Key": "Tavily API Ключ",
"Tavily Extract Depth": "",
"Tell us more:": "Повече информация:",
@ -1945,6 +1987,7 @@
"Tika": "Тика",
"Tika Server URL required.": "Изисква се URL адрес на Тика сървъра.",
"Tiktoken": "Tiktoken",
"Time": "",
"Time & Calculation": "",
"Timeout": "",
"Title": "Заглавие",
@ -1962,6 +2005,7 @@
"To select toolkits here, add them to the \"Tools\" workspace first.": "За да изберете инструменти тук, първо ги добавете към работното пространство \"Инструменти\".",
"Toast notifications for new updates": "Изскачащи известия за нови актуализации",
"Today": "Днес",
"Today at": "",
"Today at {{LOCALIZED_TIME}}": "",
"Toggle {{COUNT}} sources": "",
"Toggle 1 source": "",
@ -2100,6 +2144,7 @@
"Waiting for upload...": "",
"Warning": "Предупреждение",
"Warning:": "Предупреждение:",
"Warning: Enabling this will allow users to run scheduled prompts automatically.": "",
"Warning: Enabling this will allow users to upload arbitrary code on the server.": "Предупреждение: Активирането на това ще позволи на потребителите да качват произволен код на сървъра.",
"Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.": "Предупреждение: Изпълнението на Jupyter позволява произволно изпълнение на код, което представлява сериозни рискове за сигурността-продължете с изключително внимание.",
"Web": "Уеб",
@ -2134,6 +2179,7 @@
"Width": "",
"Wikipedia": "",
"Won": "Спечелено",
"Working Directory": "",
"Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text.": "",
"Workspace": "Работно пространство",
"Workspace Permissions": "Разрешения за работното пространство",

View file

@ -182,6 +182,7 @@
"Are you sure you want to archive all chats? This action cannot be undone.": "",
"Are you sure you want to clear all memories? This action cannot be undone.": "",
"Are you sure you want to delete \"{{NAME}}\"?": "",
"Are you sure you want to delete **{{modelName}}**?": "",
"Are you sure you want to delete all chats? This action cannot be undone.": "",
"Are you sure you want to delete this channel?": "",
"Are you sure you want to delete this connection? This action cannot be undone.": "",
@ -198,6 +199,7 @@
"Assistant": "",
"Async Embedding Processing": "",
"Attach File From Knowledge": "",
"Attach Files": "",
"Attach Knowledge": "",
"Attach Notes": "",
"Attach Webpage": "",
@ -220,6 +222,13 @@
"AUTOMATIC1111 Base URL": "AUTOMATIC1111 বেজ ইউআরএল",
"AUTOMATIC1111 Base URL is required.": "AUTOMATIC1111 বেজ ইউআরএল আবশ্যক",
"Automatically inject system tools in native function calling mode (e.g., timestamps, memory, chat history, notes, etc.)": "",
"Automation": "",
"Automation created": "",
"Automation Name": "",
"Automation title": "",
"Automation triggered": "",
"Automation updated": "",
"Automations": "",
"Available list": "",
"Available models": "",
"Available Tools": "",
@ -250,6 +259,7 @@
"Boosting or penalizing specific tokens for constrained responses. Bias values will be clamped between -100 and 100 (inclusive). (Default: none)": "",
"Brave": "",
"Brave Search API Key": "সাহসী অনুসন্ধান API কী",
"Break down complex requests into trackable steps": "",
"Browse and query knowledge bases": "",
"Builtin Tools": "",
"Bullet List": "",
@ -383,6 +393,7 @@
"Concurrent Requests": "সমকালীন অনুরোধ",
"Config": "",
"Config imported successfully": "",
"Configuration": "",
"Configure": "",
"Confirm": "",
"Confirm Password": "পাসওয়ার্ড নিশ্চিত করুন",
@ -452,6 +463,7 @@
"Create new secret key": "একটি নতুন সিক্রেট কী তৈরি করুন",
"Create note": "",
"Create Note": "",
"Create scheduled prompts that run automatically on a recurring basis.": "",
"Create your first note by clicking on the plus button below.": "",
"Created at": "নির্মানকাল",
"Created At": "নির্মানকাল",
@ -473,6 +485,7 @@
"Data Controls": "",
"Database": "ডেটাবেজ",
"Datalab Marker API": "",
"Day": "",
"DD/MM/YYYY": "",
"DDGS Backend": "",
"December": "ডেসেম্বর",
@ -503,6 +516,7 @@
"Delete All": "",
"Delete All Chats": "সব চ্যাট মুছে ফেলুন",
"Delete all contents inside this folder": "",
"Delete automation?": "",
"Delete Chat": "চ্যাট মুছে ফেলুন",
"Delete chat?": "",
"Delete File": "",
@ -647,6 +661,7 @@
"Embedding Concurrent Requests": "",
"Embedding Model": "ইমেজ ইমেবডিং মডেল",
"Embedding Model Engine": "ইমেজ ইমেবডিং মডেল ইঞ্জিন",
"Emojis": "",
"Empty message": "",
"Enable All": "",
"Enable API Keys": "",
@ -738,6 +753,7 @@
"Enter Perplexity Search API URL": "",
"Enter Playwright Timeout": "",
"Enter Playwright WebSocket URL": "",
"Enter prompt here.": "",
"Enter proxy URL (e.g. https://user:password@host:port)": "",
"Enter reasoning effort": "",
"Enter Score": "স্কোর দিন",
@ -762,6 +778,7 @@
"Enter system prompt here": "",
"Enter Tavily API Key": "",
"Enter Tavily Extract Depth": "",
"Enter the prompt instructions for this automation...": "",
"Enter the public URL of your WebUI. This URL will be used to generate links in the notifications.": "",
"Enter the URL of the function to import": "",
"Enter the URL to import": "",
@ -801,6 +818,7 @@
"Error accessing directory": "",
"Error accessing Google Drive: {{error}}": "",
"Error accessing media devices.": "",
"Error deleting model: {{error}}": "",
"Error starting recording.": "",
"Error unloading model: {{error}}": "",
"Error uploading file: {{error}}": "",
@ -818,6 +836,7 @@
"Execute code": "",
"Execute code for analysis": "",
"Executing **{{NAME}}**...": "",
"Execution Logs": "",
"Expand": "",
"Experimental": "পরিক্ষামূলক",
"Explain": "",
@ -1081,6 +1100,7 @@
"Insert Suggestion Prompt to Input": "",
"Install from Github URL": "Github URL থেকে ইনস্টল করুন",
"Instant Auto-Send After Voice Transcription": "",
"Instructions": "",
"Integration": "",
"Integrations": "",
"Interface": "ইন্টারফেস",
@ -1140,6 +1160,7 @@
"Last 90 days": "",
"Last Active": "সর্বশেষ সক্রিয়",
"Last Modified": "",
"Last ran": "",
"Last reply": "",
"LDAP": "",
"LDAP server updated": "",
@ -1246,6 +1267,7 @@
"Model '{{modelName}}' has been successfully downloaded.": "'{{modelName}}' মডেল সফলভাবে ডাউনলোড হয়েছে।",
"Model '{{modelTag}}' is already in queue for downloading.": "{{modelTag}} ডাউনলোডের জন্য আগে থেকেই অপেক্ষমান আছে।",
"Model {{modelId}} not found": "{{modelId}} মডেল পাওয়া যায়নি",
"Model {{modelName}} deleted successfully": "",
"Model {{modelName}} is not vision capable": "মডেল {{modelName}} দৃষ্টি সক্ষম নয়",
"Model {{name}} is now {{status}}": "মডেল {{name}} এখন {{status}}",
"Model {{name}} is now hidden": "",
@ -1295,8 +1317,11 @@
"Name": "নাম",
"Name and ID are required, please fill them out": "",
"Name your knowledge base": "",
"Name, prompt, and model are required": "",
"Native": "",
"Never": "",
"New": "",
"New Automation": "",
"New Button": "",
"New Chat": "নতুন চ্যাট",
"New File": "",
@ -1315,9 +1340,11 @@
"New Webhook": "",
"new-channel": "",
"Next message": "",
"Next run": "",
"No access grants. Private to you.": "",
"No activity data": "",
"No authentication": "",
"No automations found": "",
"No chats found": "",
"No chats found for this user.": "",
"No chats found.": "",
@ -1328,6 +1355,7 @@
"No data": "",
"No data found": "",
"No distance available": "",
"No execution logs available yet": "",
"No expiration can pose security risks.": "",
"No feedback found": "",
"No file selected": "",
@ -1374,6 +1402,7 @@
"Not factually correct": "তথ্যগত দিক থেকে সঠিক নয়",
"Not helpful": "",
"Not Registered": "",
"Not scheduled": "",
"Note": "",
"Note deleted successfully": "",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "দ্রষ্টব্য: আপনি যদি ন্যূনতম স্কোর সেট করেন তবে অনুসন্ধানটি কেবলমাত্র ন্যূনতম স্কোরের চেয়ে বেশি বা সমান স্কোর সহ নথিগুলি ফেরত দেবে।",
@ -1447,6 +1476,7 @@
"or": "অথবা",
"Ordered List": "",
"Other": "অন্যান্য",
"out of": "",
"Output": "",
"OUTPUT": "",
"Output format": "",
@ -1462,6 +1492,7 @@
"Password": "পাসওয়ার্ড",
"Passwords do not match.": "",
"Paste Large Text as File": "",
"Paused": "",
"PDF document (.pdf)": "PDF ডকুমেন্ট (.pdf)",
"PDF Extract Images (OCR)": "পিডিএফ এর ছবি থেকে লেখা বের করুন (OCR)",
"PDF Loader Mode": "",
@ -1566,6 +1597,7 @@
"Reason": "",
"Reasoning Effort": "",
"Reasoning Tags": "",
"Recently Used": "",
"Record": "",
"Record voice": "ভয়েস রেকর্ড করুন",
"Redirecting you to Open WebUI Community": "আপনাকে OpenWebUI কমিউনিটিতে পাঠানো হচ্ছে",
@ -1601,6 +1633,7 @@
"Renamed to {{name}}": "",
"Render Markdown in Previews": "",
"Reorder Models": "",
"Repeats": "",
"Reply": "",
"Reply in Thread": "",
"Reply to thread...": "",
@ -1633,6 +1666,8 @@
"RTL": "RTL",
"Run": "",
"Run All": "",
"Run now": "",
"Run Now": "",
"Running": "",
"Running...": "চলমান...",
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
@ -1643,12 +1678,15 @@
"Save Chat": "",
"Saved": "",
"Saving chat logs directly to your browser's storage is no longer supported. Please take a moment to download and delete your chat logs by clicking the button below. Don't worry, you can easily re-import your chat logs to the backend through": "মাধ্যমে",
"Schedule": "",
"Scheduled time must be in the future": "",
"Scroll On Branch Change": "",
"Search": "অনুসন্ধান",
"Search a model": "মডেল অনুসন্ধান করুন",
"Search all emojis": "",
"Search and manage user memories": "",
"Search and view user chat history": "",
"Search Automations": "",
"Search Base": "",
"Search channels and channel messages": "",
"Search Chats": "চ্যাট অনুসন্ধান করুন",
@ -1718,6 +1756,7 @@
"Select how to split message text for TTS requests": "",
"Select Knowledge": "",
"Select Method": "",
"Select model": "",
"Select only one model to call": "",
"Select view": "",
"Selected model: {{modelName}}": "",
@ -1827,6 +1866,7 @@
"Start of the channel": "চ্যানেলের শুরু",
"Start Tag": "",
"Starting kernel...": "",
"State": "",
"Status": "",
"Status cleared successfully": "",
"Status updated successfully": "",
@ -1877,8 +1917,10 @@
"Talk to Model": "",
"Tap to interrupt": "",
"Task List": "",
"Task Management": "",
"Task Model": "",
"Tasks": "",
"tasks completed": "",
"Tavily API Key": "",
"Tavily Extract Depth": "",
"Tell us more:": "আরও বলুন:",
@ -1945,6 +1987,7 @@
"Tika": "",
"Tika Server URL required.": "",
"Tiktoken": "",
"Time": "",
"Time & Calculation": "",
"Timeout": "",
"Title": "শিরোনাম",
@ -1962,6 +2005,7 @@
"To select toolkits here, add them to the \"Tools\" workspace first.": "",
"Toast notifications for new updates": "",
"Today": "আজ",
"Today at": "",
"Today at {{LOCALIZED_TIME}}": "",
"Toggle {{COUNT}} sources": "",
"Toggle 1 source": "",
@ -2100,6 +2144,7 @@
"Waiting for upload...": "",
"Warning": "সতর্কীকরণ",
"Warning:": "সতর্কতা:",
"Warning: Enabling this will allow users to run scheduled prompts automatically.": "",
"Warning: Enabling this will allow users to upload arbitrary code on the server.": "",
"Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.": "",
"Web": "ওয়েব",
@ -2134,6 +2179,7 @@
"Width": "",
"Wikipedia": "",
"Won": "",
"Working Directory": "",
"Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text.": "",
"Workspace": "ওয়ার্কস্পেস",
"Workspace Permissions": "",

View file

@ -181,6 +181,7 @@
"Are you sure you want to archive all chats? This action cannot be undone.": "",
"Are you sure you want to clear all memories? This action cannot be undone.": "དྲན་ཤེས་ཡོངས་རྫོགས་བསུབ་འདོད་ཡོད་དམ། བྱ་སྤྱོད་འདི་ཕྱིར་ལྡོག་བྱེད་མི་ཐུབ།",
"Are you sure you want to delete \"{{NAME}}\"?": "",
"Are you sure you want to delete **{{modelName}}**?": "",
"Are you sure you want to delete all chats? This action cannot be undone.": "",
"Are you sure you want to delete this channel?": "ཁྱེད་ཀྱིས་བགྲོ་གླེང་འདི་བསུབ་འདོད་ངེས་ཡིན་ནམ།",
"Are you sure you want to delete this connection? This action cannot be undone.": "",
@ -197,6 +198,7 @@
"Assistant": "ལག་རོགས་པ།",
"Async Embedding Processing": "",
"Attach File From Knowledge": "",
"Attach Files": "",
"Attach Knowledge": "",
"Attach Notes": "",
"Attach Webpage": "",
@ -219,6 +221,13 @@
"AUTOMATIC1111 Base URL": "AUTOMATIC1111 གཞི་རྩའི་ URL",
"AUTOMATIC1111 Base URL is required.": "AUTOMATIC1111 གཞི་རྩའི་ URL ངེས་པར་དུ་དགོས།",
"Automatically inject system tools in native function calling mode (e.g., timestamps, memory, chat history, notes, etc.)": "",
"Automation": "",
"Automation created": "",
"Automation Name": "",
"Automation title": "",
"Automation triggered": "",
"Automation updated": "",
"Automations": "",
"Available list": "ཡོད་པའི་ཐོ་གཞུང་།",
"Available models": "",
"Available Tools": "",
@ -249,6 +258,7 @@
"Boosting or penalizing specific tokens for constrained responses. Bias values will be clamped between -100 and 100 (inclusive). (Default: none)": "ཚད་བཀག་ལན་གྱི་ཆེད་དུ་ཊོཀ་ཀེན་ངེས་ཅན་ལ་ཤུགས་སྣོན་ནམ་ཉེས་ཆད་གཏོང་བ། ཕྱོགས་ཞེན་གྱི་རིན་ཐང་ -100 ནས་ 100 བར་བཙིར་ངེས། (ཚུད་པ།) (སྔོན་སྒྲིག་མེད།)",
"Brave": "",
"Brave Search API Key": "Brave Search API ལྡེ་མིག",
"Break down complex requests into trackable steps": "",
"Browse and query knowledge bases": "",
"Builtin Tools": "",
"Bullet List": "",
@ -382,6 +392,7 @@
"Concurrent Requests": "མཉམ་ལས་རེ་ཞུ།",
"Config": "",
"Config imported successfully": "",
"Configuration": "",
"Configure": "སྒྲིག་འགོད།",
"Confirm": "གཏན་འཁེལ།",
"Confirm Password": "གསང་གྲངས་གཏན་འཁེལ།",
@ -451,6 +462,7 @@
"Create new secret key": "གསང་བའི་ལྡེ་མིག་གསར་པ་བཟོ་བ།",
"Create note": "",
"Create Note": "",
"Create scheduled prompts that run automatically on a recurring basis.": "",
"Create your first note by clicking on the plus button below.": "",
"Created at": "གསར་བཟོ་བྱེད་དུས།",
"Created At": "གསར་བཟོ་བྱེད་དུས།",
@ -472,6 +484,7 @@
"Data Controls": "",
"Database": "གནས་ཚུལ་མཛོད།",
"Datalab Marker API": "",
"Day": "",
"DD/MM/YYYY": "",
"DDGS Backend": "",
"December": "ཟླ་བ་བཅུ་གཉིས་པ།",
@ -502,6 +515,7 @@
"Delete All": "",
"Delete All Chats": "ཁ་བརྡ་ཡོངས་རྫོགས་བསུབ་པ།",
"Delete all contents inside this folder": "",
"Delete automation?": "",
"Delete Chat": "ཁ་བརྡ་བསུབ་པ།",
"Delete chat?": "ཁ་བརྡ་བསུབ་པ།?",
"Delete File": "",
@ -646,6 +660,7 @@
"Embedding Concurrent Requests": "",
"Embedding Model": "ཚུད་འཇུག་དཔེ་དབྱིབས།",
"Embedding Model Engine": "ཚུད་འཇུག་དཔེ་དབྱིབས་འཕྲུལ་འཁོར།",
"Emojis": "",
"Empty message": "",
"Enable All": "",
"Enable API Keys": "",
@ -737,6 +752,7 @@
"Enter Perplexity Search API URL": "",
"Enter Playwright Timeout": "",
"Enter Playwright WebSocket URL": "",
"Enter prompt here.": "",
"Enter proxy URL (e.g. https://user:password@host:port)": "Proxy URL འཇུག་པ། (དཔེར་ན། https://user:password@host:port)",
"Enter reasoning effort": "རྒྱུ་མཚན་འདྲེན་པའི་འབད་བརྩོན་འཇུག་པ།",
"Enter Score": "སྐར་མ་འཇུག་པ།",
@ -761,6 +777,7 @@
"Enter system prompt here": "",
"Enter Tavily API Key": "Tavily API ལྡེ་མིག་འཇུག་པ།",
"Enter Tavily Extract Depth": "",
"Enter the prompt instructions for this automation...": "",
"Enter the public URL of your WebUI. This URL will be used to generate links in the notifications.": "ཁྱེད་ཀྱི་ WebUI ཡི་སྤྱི་སྤྱོད་ URL འཇུག་པ། URL འདི་བརྡ་ཁྱབ་ནང་སྦྲེལ་ཐག་བཟོ་བར་བེད་སྤྱོད་བྱེད་ངེས།",
"Enter the URL of the function to import": "",
"Enter the URL to import": "",
@ -800,6 +817,7 @@
"Error accessing directory": "",
"Error accessing Google Drive: {{error}}": "Google Drive འཛུལ་སྤྱོད་སྐབས་ནོར་འཁྲུལ།: {{error}}",
"Error accessing media devices.": "",
"Error deleting model: {{error}}": "",
"Error starting recording.": "",
"Error unloading model: {{error}}": "",
"Error uploading file: {{error}}": "ཡིག་ཆ་སྤར་སྐབས་ནོར་འཁྲུལ།: {{error}}",
@ -817,6 +835,7 @@
"Execute code": "",
"Execute code for analysis": "དབྱེ་ཞིབ་ཆེད་དུ་ཀོཌ་ལག་བསྟར་བྱེད་པ།",
"Executing **{{NAME}}**...": "",
"Execution Logs": "",
"Expand": "རྒྱ་བསྐྱེད་པ།",
"Experimental": "ཚོད་ལྟའི་རང་བཞིན།",
"Explain": "འགྲེལ་བཤད།",
@ -1080,6 +1099,7 @@
"Insert Suggestion Prompt to Input": "",
"Install from Github URL": "Github URL ནས་སྒྲིག་སྦྱོར་བྱེད་པ།",
"Instant Auto-Send After Voice Transcription": "སྐད་ཆ་ཡིག་འབེབས་བྱས་རྗེས་ལམ་སང་རང་འགུལ་གཏོང་བ།",
"Instructions": "",
"Integration": "མཉམ་འདྲེས།",
"Integrations": "",
"Interface": "ངོས་འཛིན།",
@ -1139,6 +1159,7 @@
"Last 90 days": "",
"Last Active": "མཐའ་མའི་ལས་བྱེད།",
"Last Modified": "མཐའ་མའི་བཟོ་བཅོས།",
"Last ran": "",
"Last reply": "ལན་མཐའ་མ།",
"LDAP": "LDAP",
"LDAP server updated": "LDAP སར་བར་གསར་སྒྱུར་བྱས།",
@ -1245,6 +1266,7 @@
"Model '{{modelName}}' has been successfully downloaded.": "དཔེ་དབྱིབས། '{{modelName}}' ལེགས་པར་ཕབ་ལེན་བྱས་ཟིན།",
"Model '{{modelTag}}' is already in queue for downloading.": "དཔེ་དབྱིབས། '{{modelTag}}' ཕབ་ལེན་གྱི་སྒུག་ཐོ་ནང་ཡོད་ཟིན།",
"Model {{modelId}} not found": "དཔེ་དབྱིབས། {{modelId}} མ་རྙེད།",
"Model {{modelName}} deleted successfully": "",
"Model {{modelName}} is not vision capable": "དཔེ་དབྱིབས། {{modelName}} ལ་མཐོང་ནུས་མེད།",
"Model {{name}} is now {{status}}": "དཔེ་དབྱིབས། {{name}} ད་ལྟ་ {{status}} ཡིན།",
"Model {{name}} is now hidden": "དཔེ་དབྱིབས། {{name}} ད་ལྟ་སྦས་ཡོད།",
@ -1294,8 +1316,11 @@
"Name": "མིང་།",
"Name and ID are required, please fill them out": "",
"Name your knowledge base": "ཁྱེད་ཀྱི་ཤེས་བྱའི་རྟེན་གཞི་ལ་མིང་ཐོགས།",
"Name, prompt, and model are required": "",
"Native": "ས་སྐྱེས།",
"Never": "",
"New": "",
"New Automation": "",
"New Button": "",
"New Chat": "ཁ་བརྡ་གསར་པ།",
"New File": "",
@ -1314,9 +1339,11 @@
"New Webhook": "",
"new-channel": "བགྲོ་གླེང་གསར་པ།",
"Next message": "",
"Next run": "",
"No access grants. Private to you.": "",
"No activity data": "",
"No authentication": "",
"No automations found": "",
"No chats found": "",
"No chats found for this user.": "",
"No chats found.": "",
@ -1327,6 +1354,7 @@
"No data": "",
"No data found": "",
"No distance available": "ཐག་རིང་ཚད་མེད།",
"No execution logs available yet": "",
"No expiration can pose security risks.": "",
"No feedback found": "",
"No file selected": "ཡིག་ཆ་གདམ་ག་མ་བྱས།",
@ -1373,6 +1401,7 @@
"Not factually correct": "དོན་དངོས་དང་མི་མཐུན།",
"Not helpful": "ཕན་ཐོགས་མེད།",
"Not Registered": "",
"Not scheduled": "",
"Note": "",
"Note deleted successfully": "",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "དོ་སྣང་།: གལ་ཏེ་ཁྱེད་ཀྱིས་སྐར་མ་ཉུང་ཤོས་ཤིག་བཀོད་སྒྲིག་བྱས་ན། འཚོལ་བཤེར་གྱིས་སྐར་མ་ཉུང་ཤོས་དེ་དང་མཉམ་པའམ་དེ་ལས་ཆེ་བའི་ཡིག་ཆ་ཁོ་ན་ཕྱིར་སློག་བྱེད་ངེས།",
@ -1446,6 +1475,7 @@
"or": "ཡང་ན།",
"Ordered List": "",
"Other": "གཞན།",
"out of": "",
"Output": "",
"OUTPUT": "ཐོན་འབྲས།",
"Output format": "ཐོན་འབྲས་ཀྱི་བཀོད་པ།",
@ -1461,6 +1491,7 @@
"Password": "གསང་གྲངས།",
"Passwords do not match.": "",
"Paste Large Text as File": "ཡིག་རྐྱང་ཆེན་པོ་ཡིག་ཆ་ལྟར་སྦྱོར་བ།",
"Paused": "",
"PDF document (.pdf)": "PDF ཡིག་ཆ། (.pdf)",
"PDF Extract Images (OCR)": "PDF པར་འདོན་སྤེལ། (OCR)",
"PDF Loader Mode": "",
@ -1565,6 +1596,7 @@
"Reason": "",
"Reasoning Effort": "རྒྱུ་མཚན་འདྲེན་པའི་འབད་བརྩོན།",
"Reasoning Tags": "",
"Recently Used": "",
"Record": "",
"Record voice": "སྐད་སྒྲ་ཕབ་པ།",
"Redirecting you to Open WebUI Community": "ཁྱེད་ Open WebUI སྤྱི་ཚོགས་ལ་ཁ་ཕྱོགས་སྒྱུར་བཞིན་པ།",
@ -1600,6 +1632,7 @@
"Renamed to {{name}}": "",
"Render Markdown in Previews": "",
"Reorder Models": "དཔེ་དབྱིབས་བསྐྱར་སྒྲིག",
"Repeats": "",
"Reply": "",
"Reply in Thread": "བརྗོད་གཞིའི་ནང་ལན་འདེབས།",
"Reply to thread...": "",
@ -1631,6 +1664,8 @@
"RTL": "RTL",
"Run": "ལག་བསྟར།",
"Run All": "",
"Run now": "",
"Run Now": "",
"Running": "ལག་བསྟར་བྱེད་བཞིན་པ།",
"Running...": "ལག་བསྟར་བྱེད་བཞིན་པ།...",
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
@ -1641,12 +1676,15 @@
"Save Chat": "",
"Saved": "ཉར་ཚགས་བྱས།",
"Saving chat logs directly to your browser's storage is no longer supported. Please take a moment to download and delete your chat logs by clicking the button below. Don't worry, you can easily re-import your chat logs to the backend through": "ཁ་བརྡའི་ཟིན་ཐོ་ཐད་ཀར་ཁྱེད་ཀྱི་བརྡ་འཚོལ་ཆས་ཀྱི་གསོག་ཆས་སུ་ཉར་ཚགས་བྱེད་པར་ད་ནས་བཟུང་རྒྱབ་སྐྱོར་མེད། གཤམ་གྱི་མཐེབ་གནོན་མནན་ནས་ཁྱེད་ཀྱི་ཁ་བརྡའི་ཟིན་ཐོ་ཕབ་ལེན་དང་བསུབ་པར་དུས་ཚོད་ཅུང་ཟད་བླང་རོགས། སེམས་ཁྲལ་མེད། ཁྱེད་ཀྱིས་སྟབས་བདེ་པོར་ཁྱེད་ཀྱི་ཁ་བརྡའི་ཟིན་ཐོ་རྒྱབ་སྣེ་ལ་བསྐྱར་དུ་ནང་འདྲེན་བྱེད་ཐུབ།",
"Schedule": "",
"Scheduled time must be in the future": "",
"Scroll On Branch Change": "",
"Search": "འཚོལ་བཤེར།",
"Search a model": "དཔེ་དབྱིབས་ཤིག་འཚོལ་བ།",
"Search all emojis": "",
"Search and manage user memories": "",
"Search and view user chat history": "",
"Search Automations": "",
"Search Base": "འཚོལ་བཤེར་གཞི་རྩ།",
"Search channels and channel messages": "",
"Search Chats": "ཁ་བརྡ་འཚོལ་བཤེར།",
@ -1716,6 +1754,7 @@
"Select how to split message text for TTS requests": "",
"Select Knowledge": "ཤེས་བྱ་གདམ་པ།",
"Select Method": "",
"Select model": "",
"Select only one model to call": "འབོད་པར་དཔེ་དབྱིབས་གཅིག་ཁོ་ན་གདམ་པ།",
"Select view": "",
"Selected model: {{modelName}}": "",
@ -1825,6 +1864,7 @@
"Start of the channel": "རྒྱས་ལམ་འགོ་རིམ་",
"Start Tag": "",
"Starting kernel...": "",
"State": "",
"Status": "",
"Status cleared successfully": "",
"Status updated successfully": "",
@ -1875,8 +1915,10 @@
"Talk to Model": "",
"Tap to interrupt": "བར་ཆད་བྱེད་པར་མནན་པ།",
"Task List": "",
"Task Management": "",
"Task Model": "",
"Tasks": "ལས་འགན།",
"tasks completed": "",
"Tavily API Key": "Tavily API ལྡེ་མིག",
"Tavily Extract Depth": "",
"Tell us more:": "ང་ཚོ་ལ་མང་ཙམ་ཤོད།:",
@ -1943,6 +1985,7 @@
"Tika": "Tika",
"Tika Server URL required.": "Tika Server URL དགོས་ངེས།",
"Tiktoken": "Tiktoken",
"Time": "",
"Time & Calculation": "",
"Timeout": "",
"Title": "ཁ་བྱང་།",
@ -1960,6 +2003,7 @@
"To select toolkits here, add them to the \"Tools\" workspace first.": "ལག་ཆའི་ཚོགས་སྡེ་འདིར་གདམ་ག་བྱེད་པར། ཐོག་མར་དེ་དག་ \"ལག་ཆའི་\" ལས་ཡུལ་དུ་སྣོན་པ།",
"Toast notifications for new updates": "གསར་སྒྱུར་གསར་པའི་ཆེད་དུ་ Toast བརྡ་ཁྱབ།",
"Today": "དེ་རིང་།",
"Today at": "",
"Today at {{LOCALIZED_TIME}}": "",
"Toggle {{COUNT}} sources": "",
"Toggle 1 source": "",
@ -2098,6 +2142,7 @@
"Waiting for upload...": "",
"Warning": "ཉེན་བརྡ།",
"Warning:": "ཉེན་བརྡ།:",
"Warning: Enabling this will allow users to run scheduled prompts automatically.": "",
"Warning: Enabling this will allow users to upload arbitrary code on the server.": "ཉེན་བརྡ།: འདི་སྒུལ་བསྐྱོད་བྱས་ན་བེད་སྤྱོད་མཁན་ཚོས་སར་བར་སྟེང་གང་འདོད་ཀྱི་ཀོཌ་སྤར་བར་གནང་བ་སྤྲོད་ངེས།",
"Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.": "ཉེན་བརྡ།: Jupyter ལག་བསྟར་གྱིས་གང་འདོད་ཀྱི་ཀོཌ་ལག་བསྟར་སྒུལ་བསྐྱོད་བྱས་ནས། བདེ་འཇགས་ཀྱི་ཉེན་ཁ་ཚབས་ཆེན་བཟོ་གི་ཡོད།—ཧ་ཅང་གཟབ་ནན་གྱིས་སྔོན་སྐྱོད་བྱེད་རོགས།",
"Web": "དྲ་བ།",
@ -2132,6 +2177,7 @@
"Width": "",
"Wikipedia": "",
"Won": "ཐོབ།",
"Working Directory": "",
"Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text.": "top-k དང་མཉམ་ལས་བྱེད། རིན་ཐང་མཐོ་བ་ (དཔེར་ན། 0.95) ཡིས་ཡིག་རྐྱང་སྣ་ཚོགས་ཆེ་བ་ཡོང་ངེས། དེ་བཞིན་དུ་རིན་ཐང་དམའ་བ་ (དཔེར་ན། 0.5) ཡིས་ཡིག་རྐྱང་དམིགས་ཚད་དང་སྲུང་འཛིན་ཆེ་བ་བཟོ་ངེས།",
"Workspace": "ལས་ཡུལ།",
"Workspace Permissions": "ལས་ཡུལ་གྱི་དབང་ཚད།",

View file

@ -183,6 +183,7 @@
"Are you sure you want to archive all chats? This action cannot be undone.": "",
"Are you sure you want to clear all memories? This action cannot be undone.": "",
"Are you sure you want to delete \"{{NAME}}\"?": "",
"Are you sure you want to delete **{{modelName}}**?": "",
"Are you sure you want to delete all chats? This action cannot be undone.": "",
"Are you sure you want to delete this channel?": "",
"Are you sure you want to delete this connection? This action cannot be undone.": "",
@ -199,6 +200,7 @@
"Assistant": "Asistent",
"Async Embedding Processing": "",
"Attach File From Knowledge": "",
"Attach Files": "",
"Attach Knowledge": "Prikazi znanje",
"Attach Notes": "Prikazi zapise",
"Attach Webpage": "",
@ -221,6 +223,13 @@
"AUTOMATIC1111 Base URL": "AUTOMATIC1111 osnovni URL",
"AUTOMATIC1111 Base URL is required.": "Potreban je AUTOMATIC1111 osnovni URL.",
"Automatically inject system tools in native function calling mode (e.g., timestamps, memory, chat history, notes, etc.)": "",
"Automation": "",
"Automation created": "",
"Automation Name": "",
"Automation title": "",
"Automation triggered": "",
"Automation updated": "",
"Automations": "",
"Available list": "",
"Available models": "",
"Available Tools": "",
@ -251,6 +260,7 @@
"Boosting or penalizing specific tokens for constrained responses. Bias values will be clamped between -100 and 100 (inclusive). (Default: none)": "",
"Brave": "",
"Brave Search API Key": "Brave tražilica - API ključ",
"Break down complex requests into trackable steps": "",
"Browse and query knowledge bases": "",
"Builtin Tools": "",
"Bullet List": "",
@ -384,6 +394,7 @@
"Concurrent Requests": "Istodobni zahtjevi",
"Config": "",
"Config imported successfully": "",
"Configuration": "",
"Configure": "Promijeni",
"Confirm": "Potvrdi",
"Confirm Password": "Potvrdite lozinku",
@ -453,6 +464,7 @@
"Create new secret key": "Stvori novi tajni ključ",
"Create note": "",
"Create Note": "",
"Create scheduled prompts that run automatically on a recurring basis.": "",
"Create your first note by clicking on the plus button below.": "",
"Created at": "Stvoreno",
"Created At": "Stvoreno",
@ -474,6 +486,7 @@
"Data Controls": "",
"Database": "Baza podataka",
"Datalab Marker API": "",
"Day": "",
"DD/MM/YYYY": "",
"DDGS Backend": "",
"December": "Decembar",
@ -504,6 +517,7 @@
"Delete All": "",
"Delete All Chats": "Izbriši sve razgovore",
"Delete all contents inside this folder": "",
"Delete automation?": "",
"Delete Chat": "Izbriši razgovor",
"Delete chat?": "",
"Delete File": "",
@ -648,6 +662,7 @@
"Embedding Concurrent Requests": "",
"Embedding Model": "Embedding model",
"Embedding Model Engine": "Embedding model pogon",
"Emojis": "",
"Empty message": "",
"Enable All": "",
"Enable API Keys": "",
@ -739,6 +754,7 @@
"Enter Perplexity Search API URL": "",
"Enter Playwright Timeout": "",
"Enter Playwright WebSocket URL": "",
"Enter prompt here.": "",
"Enter proxy URL (e.g. https://user:password@host:port)": "",
"Enter reasoning effort": "",
"Enter Score": "Unesite ocjenu",
@ -763,6 +779,7 @@
"Enter system prompt here": "",
"Enter Tavily API Key": "",
"Enter Tavily Extract Depth": "",
"Enter the prompt instructions for this automation...": "",
"Enter the public URL of your WebUI. This URL will be used to generate links in the notifications.": "",
"Enter the URL of the function to import": "",
"Enter the URL to import": "",
@ -802,6 +819,7 @@
"Error accessing directory": "",
"Error accessing Google Drive: {{error}}": "",
"Error accessing media devices.": "",
"Error deleting model: {{error}}": "",
"Error starting recording.": "",
"Error unloading model: {{error}}": "",
"Error uploading file: {{error}}": "",
@ -819,6 +837,7 @@
"Execute code": "",
"Execute code for analysis": "",
"Executing **{{NAME}}**...": "",
"Execution Logs": "",
"Expand": "",
"Experimental": "Eksperimentalno",
"Explain": "",
@ -1082,6 +1101,7 @@
"Insert Suggestion Prompt to Input": "",
"Install from Github URL": "Instaliraj s Github URL-a",
"Instant Auto-Send After Voice Transcription": "Trenutačno automatsko slanje nakon glasovne transkripcije",
"Instructions": "",
"Integration": "",
"Integrations": "",
"Interface": "Sučelje",
@ -1141,6 +1161,7 @@
"Last 90 days": "",
"Last Active": "Zadnja aktivnost",
"Last Modified": "",
"Last ran": "",
"Last reply": "",
"LDAP": "",
"LDAP server updated": "",
@ -1247,6 +1268,7 @@
"Model '{{modelName}}' has been successfully downloaded.": "Model '{{modelName}}' je uspješno preuzet.",
"Model '{{modelTag}}' is already in queue for downloading.": "Model '{{modelTag}}' je već u redu za preuzimanje.",
"Model {{modelId}} not found": "Model {{modelId}} nije pronađen",
"Model {{modelName}} deleted successfully": "",
"Model {{modelName}} is not vision capable": "Model {{modelName}} ne čita vizualne impute",
"Model {{name}} is now {{status}}": "Model {{name}} sada je {{status}}",
"Model {{name}} is now hidden": "",
@ -1296,8 +1318,11 @@
"Name": "Ime",
"Name and ID are required, please fill them out": "",
"Name your knowledge base": "",
"Name, prompt, and model are required": "",
"Native": "",
"Never": "",
"New": "",
"New Automation": "",
"New Button": "",
"New Chat": "Novi razgovor",
"New File": "",
@ -1316,9 +1341,11 @@
"New Webhook": "",
"new-channel": "",
"Next message": "",
"Next run": "",
"No access grants. Private to you.": "",
"No activity data": "",
"No authentication": "",
"No automations found": "",
"No chats found": "",
"No chats found for this user.": "",
"No chats found.": "",
@ -1329,6 +1356,7 @@
"No data": "",
"No data found": "",
"No distance available": "",
"No execution logs available yet": "",
"No expiration can pose security risks.": "",
"No feedback found": "",
"No file selected": "",
@ -1375,6 +1403,7 @@
"Not factually correct": "Nije činjenično točno",
"Not helpful": "",
"Not Registered": "",
"Not scheduled": "",
"Note": "",
"Note deleted successfully": "",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "Napomena: Ako postavite minimalnu ocjenu, pretraga će vratiti samo dokumente s ocjenom većom ili jednakom minimalnoj ocjeni.",
@ -1448,6 +1477,7 @@
"or": "ili",
"Ordered List": "",
"Other": "Ostalo",
"out of": "",
"Output": "",
"OUTPUT": "",
"Output format": "",
@ -1463,6 +1493,7 @@
"Password": "Lozinka",
"Passwords do not match.": "",
"Paste Large Text as File": "",
"Paused": "",
"PDF document (.pdf)": "PDF dokument (.pdf)",
"PDF Extract Images (OCR)": "PDF izdvajanje slika (OCR)",
"PDF Loader Mode": "",
@ -1567,6 +1598,7 @@
"Reason": "",
"Reasoning Effort": "",
"Reasoning Tags": "",
"Recently Used": "",
"Record": "",
"Record voice": "Snimanje glasa",
"Redirecting you to Open WebUI Community": "Preusmjeravanje na OpenWebUI zajednicu",
@ -1602,6 +1634,7 @@
"Renamed to {{name}}": "",
"Render Markdown in Previews": "",
"Reorder Models": "",
"Repeats": "",
"Reply": "",
"Reply in Thread": "",
"Reply to thread...": "",
@ -1635,6 +1668,8 @@
"RTL": "RTL",
"Run": "",
"Run All": "",
"Run now": "",
"Run Now": "",
"Running": "Pokrenuto",
"Running...": "Pokrenuto...",
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
@ -1645,12 +1680,15 @@
"Save Chat": "",
"Saved": "",
"Saving chat logs directly to your browser's storage is no longer supported. Please take a moment to download and delete your chat logs by clicking the button below. Don't worry, you can easily re-import your chat logs to the backend through": "Spremanje zapisnika razgovora izravno u pohranu vašeg preglednika više nije podržano. Molimo vas da odvojite trenutak za preuzimanje i brisanje zapisnika razgovora klikom na gumb ispod. Ne brinite, možete lako ponovno uvesti zapisnike razgovora u backend putem",
"Schedule": "",
"Scheduled time must be in the future": "",
"Scroll On Branch Change": "",
"Search": "Pretraga",
"Search a model": "Pretraži model",
"Search all emojis": "",
"Search and manage user memories": "",
"Search and view user chat history": "",
"Search Automations": "",
"Search Base": "",
"Search channels and channel messages": "",
"Search Chats": "Pretraži razgovore",
@ -1720,6 +1758,7 @@
"Select how to split message text for TTS requests": "",
"Select Knowledge": "",
"Select Method": "",
"Select model": "",
"Select only one model to call": "Odaberite samo jedan model za poziv",
"Select view": "",
"Selected model: {{modelName}}": "",
@ -1829,6 +1868,7 @@
"Start of the channel": "Početak kanala",
"Start Tag": "",
"Starting kernel...": "",
"State": "",
"Status": "",
"Status cleared successfully": "",
"Status updated successfully": "",
@ -1879,8 +1919,10 @@
"Talk to Model": "",
"Tap to interrupt": "",
"Task List": "",
"Task Management": "",
"Task Model": "",
"Tasks": "",
"tasks completed": "",
"Tavily API Key": "",
"Tavily Extract Depth": "",
"Tell us more:": "Recite nam više:",
@ -1947,6 +1989,7 @@
"Tika": "",
"Tika Server URL required.": "",
"Tiktoken": "",
"Time": "",
"Time & Calculation": "",
"Timeout": "",
"Title": "Naslov",
@ -1964,6 +2007,7 @@
"To select toolkits here, add them to the \"Tools\" workspace first.": "",
"Toast notifications for new updates": "",
"Today": "Danas",
"Today at": "",
"Today at {{LOCALIZED_TIME}}": "",
"Toggle {{COUNT}} sources": "",
"Toggle 1 source": "",
@ -2102,6 +2146,7 @@
"Waiting for upload...": "",
"Warning": "Upozorenje",
"Warning:": "Upozorenje:",
"Warning: Enabling this will allow users to run scheduled prompts automatically.": "",
"Warning: Enabling this will allow users to upload arbitrary code on the server.": "",
"Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.": "",
"Web": "Web",
@ -2136,6 +2181,7 @@
"Width": "",
"Wikipedia": "",
"Won": "",
"Working Directory": "",
"Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text.": "",
"Workspace": "Radna ploča",
"Workspace Permissions": "",

View file

@ -183,6 +183,7 @@
"Are you sure you want to archive all chats? This action cannot be undone.": "Estàs segur que vols arxivar tots els xats? Aquesta acció no es pot desfer.",
"Are you sure you want to clear all memories? This action cannot be undone.": "Estàs segur que vols netejar totes les memòries? Aquesta acció no es pot desfer.",
"Are you sure you want to delete \"{{NAME}}\"?": "Estàs segur que vols eliminar \"{{NAME}}\"?",
"Are you sure you want to delete **{{modelName}}**?": "",
"Are you sure you want to delete all chats? This action cannot be undone.": "Estàs segur que vols suprimir tots els xats? Aquesta acció no es pot desfer.",
"Are you sure you want to delete this channel?": "Estàs segur que vols eliminar aquest canal?",
"Are you sure you want to delete this connection? This action cannot be undone.": "Estàs segur que vols suprimir aquesta connexió? Aquesta acció no es pot desfer.",
@ -199,6 +200,7 @@
"Assistant": "Assistent",
"Async Embedding Processing": "Procés d'incrustat asíncron",
"Attach File From Knowledge": "Adjuntar arxiu del coneixement",
"Attach Files": "",
"Attach Knowledge": "Adjuntar coneixement",
"Attach Notes": "Adjuntar notes",
"Attach Webpage": "Adjuntar pàgina web",
@ -221,6 +223,13 @@
"AUTOMATIC1111 Base URL": "URL Base d'AUTOMATIC1111",
"AUTOMATIC1111 Base URL is required.": "Es requereix la URL Base d'AUTOMATIC1111.",
"Automatically inject system tools in native function calling mode (e.g., timestamps, memory, chat history, notes, etc.)": "Injecta automàticament les eines del sistema en el mode de crida de funcions natives (per exemple, marques de temps, memòria, historial de xat, notes, etc.)",
"Automation": "",
"Automation created": "",
"Automation Name": "",
"Automation title": "",
"Automation triggered": "",
"Automation updated": "",
"Automations": "",
"Available list": "Llista de disponibles",
"Available models": "Models disponibles",
"Available Tools": "Eines disponibles",
@ -251,6 +260,7 @@
"Boosting or penalizing specific tokens for constrained responses. Bias values will be clamped between -100 and 100 (inclusive). (Default: none)": "Potenciar o penalitzar tokens específics per a respostes limitades. Els valors de biaix es fixaran entre -100 i 100 (inclosos). (Per defecte: cap)",
"Brave": "Brave",
"Brave Search API Key": "Clau API de Brave Search",
"Break down complex requests into trackable steps": "",
"Browse and query knowledge bases": "Cerca i fes preguntes a una base de coneixement",
"Builtin Tools": "Eines integrades",
"Bullet List": "Llista indexada",
@ -384,6 +394,7 @@
"Concurrent Requests": "Peticions simultànies",
"Config": "Configuració",
"Config imported successfully": "Configuració importada correctament",
"Configuration": "",
"Configure": "Configurar",
"Confirm": "Confirmar",
"Confirm Password": "Confirmar la contrasenya",
@ -453,6 +464,7 @@
"Create new secret key": "Crear una nova clau secreta",
"Create note": "Crear una nota",
"Create Note": "Crea nota",
"Create scheduled prompts that run automatically on a recurring basis.": "",
"Create your first note by clicking on the plus button below.": "Crea la teva primera nota prement sobre el botó 'més' inferior",
"Created at": "Creat el",
"Created At": "Creat el",
@ -474,6 +486,7 @@
"Data Controls": "Controls de dades",
"Database": "Base de dades",
"Datalab Marker API": "API de Datalab Marker",
"Day": "",
"DD/MM/YYYY": "DD/MM/YYYY",
"DDGS Backend": "Backend DDGS",
"December": "Desembre",
@ -504,6 +517,7 @@
"Delete All": "Eliminar tot",
"Delete All Chats": "Eliminar tots els xats",
"Delete all contents inside this folder": "Eliminar tot el contingut d'aquesta carpeta",
"Delete automation?": "",
"Delete Chat": "Eliminar xat",
"Delete chat?": "Eliminar el xat?",
"Delete File": "Eliminar el fitxer",
@ -648,6 +662,7 @@
"Embedding Concurrent Requests": "Peticions concurrents d'incrustació",
"Embedding Model": "Model d'incrustació",
"Embedding Model Engine": "Motor de model d'incrustació",
"Emojis": "",
"Empty message": "Missatge buit",
"Enable All": "Habilitar tot",
"Enable API Keys": "Permetre claus API",
@ -739,6 +754,7 @@
"Enter Perplexity Search API URL": "Introduïu l'URL de l'API de cerca de Perplexity",
"Enter Playwright Timeout": "Introdueix el temps d'espera de Playwright",
"Enter Playwright WebSocket URL": "Introdueix la URL de Playwright WebSocket",
"Enter prompt here.": "",
"Enter proxy URL (e.g. https://user:password@host:port)": "Entra la URL (p. ex. https://user:password@host:port)",
"Enter reasoning effort": "Introdueix l'esforç de raonament",
"Enter Score": "Introdueix la puntuació",
@ -763,6 +779,7 @@
"Enter system prompt here": "Entra la indicació de sistema aquí",
"Enter Tavily API Key": "Introdueix la clau API de Tavily",
"Enter Tavily Extract Depth": "Introdueix la profunditat d'extracció de Tavily",
"Enter the prompt instructions for this automation...": "",
"Enter the public URL of your WebUI. This URL will be used to generate links in the notifications.": "Entra la URL pública de WebUI. Aquesta URL s'utilitzarà per generar els enllaços en les notificacions.",
"Enter the URL of the function to import": "Introdueix la URL de la funció a importar",
"Enter the URL to import": "Introdueix la URL a importar",
@ -802,6 +819,7 @@
"Error accessing directory": "Error en accedir al directori",
"Error accessing Google Drive: {{error}}": "Error en accedir a Google Drive: {{error}}",
"Error accessing media devices.": "Error en accedir als dispositius multimèdia",
"Error deleting model: {{error}}": "",
"Error starting recording.": "Error en començar a enregistrar",
"Error unloading model: {{error}}": "Error en descarregar el model: {{error}}",
"Error uploading file: {{error}}": "Error en pujar l'arxiu: {{error}}",
@ -819,6 +837,7 @@
"Execute code": "Executa el codi",
"Execute code for analysis": "Executar el codi per analitzar-lo",
"Executing **{{NAME}}**...": "Executant **{{NAME}}**...",
"Execution Logs": "",
"Expand": "Expandir",
"Experimental": "Experimental",
"Explain": "Explicar",
@ -1082,6 +1101,7 @@
"Insert Suggestion Prompt to Input": "Insereix un suggeriment per introduir",
"Install from Github URL": "Instal·lar des de la URL de Github",
"Instant Auto-Send After Voice Transcription": "Enviament automàtic després de la transcripció de veu",
"Instructions": "",
"Integration": "Integració",
"Integrations": "Integracions",
"Interface": "Interfície",
@ -1141,6 +1161,7 @@
"Last 90 days": "Darrers 90 dies",
"Last Active": "Activitat recent",
"Last Modified": "Modificació",
"Last ran": "",
"Last reply": "Darrera resposta",
"LDAP": "LDAP",
"LDAP server updated": "Servidor LDAP actualitzat",
@ -1247,6 +1268,7 @@
"Model '{{modelName}}' has been successfully downloaded.": "El model '{{modelName}}' s'ha descarregat correctament.",
"Model '{{modelTag}}' is already in queue for downloading.": "El model '{{modelTag}}' ja està en cua per ser descarregat.",
"Model {{modelId}} not found": "No s'ha trobat el model {{modelId}}",
"Model {{modelName}} deleted successfully": "",
"Model {{modelName}} is not vision capable": "El model {{modelName}} no és capaç de visió",
"Model {{name}} is now {{status}}": "El model {{name}} ara és {{status}}",
"Model {{name}} is now hidden": "El model {{name}} està ara amagat",
@ -1296,8 +1318,11 @@
"Name": "Nom",
"Name and ID are required, please fill them out": "El nom i l'ID són necessaris, emplena'ls, si us plau",
"Name your knowledge base": "Anomena la teva base de coneixement",
"Name, prompt, and model are required": "",
"Native": "Natiu",
"Never": "",
"New": "Nou",
"New Automation": "",
"New Button": "Botó nou",
"New Chat": "Nou xat",
"New File": "Nou arxiu",
@ -1316,9 +1341,11 @@
"New Webhook": "Nou webhook",
"new-channel": "nou-canal",
"Next message": "Missatge següent",
"Next run": "",
"No access grants. Private to you.": "Sense permisos d'accés. Privat per a tu.",
"No activity data": "No hi ha dades d'activitat",
"No authentication": "Sense autenticació",
"No automations found": "",
"No chats found": "No s'han trobat xats",
"No chats found for this user.": "No s'han trobat xats per a aquest usuari.",
"No chats found.": "No s'ha trobat xats.",
@ -1329,6 +1356,7 @@
"No data": "No hi ha dades",
"No data found": "No s'han trobat dades",
"No distance available": "No hi ha distància disponible",
"No execution logs available yet": "",
"No expiration can pose security risks.": "No posar expiració pot suposar problemes de seguretat.",
"No feedback found": "No s'ha trobat cap retorn",
"No file selected": "No s'ha escollit cap fitxer",
@ -1375,6 +1403,7 @@
"Not factually correct": "No és clarament correcte",
"Not helpful": "No ajuda",
"Not Registered": "No registrat",
"Not scheduled": "",
"Note": "Nota",
"Note deleted successfully": "La nota s'ha eliminat correctament",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "Nota: Si s'estableix una puntuació mínima, la cerca només retornarà documents amb una puntuació major o igual a la puntuació mínima.",
@ -1448,6 +1477,7 @@
"or": "o",
"Ordered List": "Llista ordenada",
"Other": "Altres",
"out of": "",
"Output": "Sortida",
"OUTPUT": "SORTIDA",
"Output format": "Format de sortida",
@ -1463,6 +1493,7 @@
"Password": "Contrasenya",
"Passwords do not match.": "Les contrasenyes no coincideixen",
"Paste Large Text as File": "Enganxa un text llarg com a fitxer",
"Paused": "",
"PDF document (.pdf)": "Document PDF (.pdf)",
"PDF Extract Images (OCR)": "Extreu imatges del PDF (OCR)",
"PDF Loader Mode": "Mode de càrrega de PDF",
@ -1567,6 +1598,7 @@
"Reason": "Raó",
"Reasoning Effort": "Esforç de raonament",
"Reasoning Tags": "Etiqueta de raonament",
"Recently Used": "",
"Record": "Enregistrar",
"Record voice": "Enregistrar la veu",
"Redirecting you to Open WebUI Community": "Redirigint-te a la comunitat OpenWebUI",
@ -1602,6 +1634,7 @@
"Renamed to {{name}}": "S'ha renombrat a {{name}}",
"Render Markdown in Previews": "Compila el Markdown a les previsualitzacions",
"Reorder Models": "Reordenar els models",
"Repeats": "",
"Reply": "Respondre",
"Reply in Thread": "Respondre al fil",
"Reply to thread...": "Respondra al fil...",
@ -1635,6 +1668,8 @@
"RTL": "RTL",
"Run": "Executar",
"Run All": "Executar tot",
"Run now": "",
"Run Now": "",
"Running": "S'està executant",
"Running...": "S'està executant...",
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "Executa tasques d'incrustació simultàniament per accelerar el processament. Desactiva-ho si els límits de velocitat es converteixen en un problema.",
@ -1645,12 +1680,15 @@
"Save Chat": "Dear el xat",
"Saved": "Desat",
"Saving chat logs directly to your browser's storage is no longer supported. Please take a moment to download and delete your chat logs by clicking the button below. Don't worry, you can easily re-import your chat logs to the backend through": "Desar els registres de xat directament a l'emmagatzematge del teu navegador ja no està suportat. Si us plau, descarregr i elimina els registres de xat fent clic al botó de sota. No et preocupis, pots tornar a importar fàcilment els teus registres de xat al backend a través de",
"Schedule": "",
"Scheduled time must be in the future": "",
"Scroll On Branch Change": "Fer scroll en canviar de branca",
"Search": "Cercar",
"Search a model": "Cercar un model",
"Search all emojis": "Cercar tots els emojis",
"Search and manage user memories": "Cerca i gestiona les memòries d'usuari",
"Search and view user chat history": "Cerca i mostra l'historial de xats",
"Search Automations": "",
"Search Base": "Base de cerca",
"Search channels and channel messages": "Cerca els canals i els missatges als canals",
"Search Chats": "Cercar xats",
@ -1720,6 +1758,7 @@
"Select how to split message text for TTS requests": "Seleccionar com separar un missatge per a peticions TTS",
"Select Knowledge": "Seleccionar coneixement",
"Select Method": "Escollir el mètode",
"Select model": "",
"Select only one model to call": "Seleccionar només un model per trucar",
"Select view": "Seleccionar una vista",
"Selected model: {{modelName}}": "Model seleccionat: {{modelName}}",
@ -1829,6 +1868,7 @@
"Start of the channel": "Inici del canal",
"Start Tag": "Etiqueta d'inici",
"Starting kernel...": "Iniciant el kernel...",
"State": "",
"Status": "Estat",
"Status cleared successfully": "S'ha eliminat correctament el teu estat",
"Status updated successfully": "S'ha actualitzat correctament el teu estat",
@ -1879,8 +1919,10 @@
"Talk to Model": "Parlar amb el model",
"Tap to interrupt": "Prem per interrompre",
"Task List": "Llista de tasques",
"Task Management": "",
"Task Model": "Model de tasques",
"Tasks": "Tasques",
"tasks completed": "",
"Tavily API Key": "Clau API de Tavily",
"Tavily Extract Depth": "Profunditat d'extracció de Tavily",
"Tell us more:": "Dona'ns més informació:",
@ -1947,6 +1989,7 @@
"Tika": "Tika",
"Tika Server URL required.": "La URL del servidor Tika és obligatòria.",
"Tiktoken": "Tiktoken",
"Time": "",
"Time & Calculation": "Temps i càlculs",
"Timeout": "Temps d'espera",
"Title": "Títol",
@ -1964,6 +2007,7 @@
"To select toolkits here, add them to the \"Tools\" workspace first.": "Per seleccionar kits d'eines aquí, afegeix-los primer a l'espai de treball \"Eines\".",
"Toast notifications for new updates": "Notificacions Toast de noves actualitzacions",
"Today": "Avui",
"Today at": "",
"Today at {{LOCALIZED_TIME}}": "Avui a les {{LOCALIZED_TIME}}",
"Toggle {{COUNT}} sources": "Activa/Desactiva {{COUNT}} fonts",
"Toggle 1 source": "Activa/Desactiva 1 font",
@ -2102,6 +2146,7 @@
"Waiting for upload...": "Esperant per pujar...",
"Warning": "Avís",
"Warning:": "Avís:",
"Warning: Enabling this will allow users to run scheduled prompts automatically.": "",
"Warning: Enabling this will allow users to upload arbitrary code on the server.": "Avís: Habilitar això permetrà als usuaris penjar codi arbitrari al servidor.",
"Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.": "Avís: l'execució de Jupyter permet l'execució de codi arbitrari, la qual cosa comporta greus riscos de seguretat; procediu amb extrema precaució.",
"Web": "Web",
@ -2136,6 +2181,7 @@
"Width": "amplada",
"Wikipedia": "Wikipedia",
"Won": "Ha guanyat",
"Working Directory": "",
"Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text.": "Funciona juntament amb top-k. Un valor més alt (p. ex., 0,95) donarà lloc a un text més divers, mentre que un valor més baix (p. ex., 0,5) generarà un text més concentrat i conservador.",
"Workspace": "Espai de treball",
"Workspace Permissions": "Permisos de l'espai de treball",

View file

@ -182,6 +182,7 @@
"Are you sure you want to archive all chats? This action cannot be undone.": "",
"Are you sure you want to clear all memories? This action cannot be undone.": "",
"Are you sure you want to delete \"{{NAME}}\"?": "",
"Are you sure you want to delete **{{modelName}}**?": "",
"Are you sure you want to delete all chats? This action cannot be undone.": "",
"Are you sure you want to delete this channel?": "",
"Are you sure you want to delete this connection? This action cannot be undone.": "",
@ -198,6 +199,7 @@
"Assistant": "",
"Async Embedding Processing": "",
"Attach File From Knowledge": "",
"Attach Files": "",
"Attach Knowledge": "",
"Attach Notes": "",
"Attach Webpage": "",
@ -220,6 +222,13 @@
"AUTOMATIC1111 Base URL": "Base URL AUTOMATIC1111",
"AUTOMATIC1111 Base URL is required.": "Ang AUTOMATIC1111 base URL gikinahanglan.",
"Automatically inject system tools in native function calling mode (e.g., timestamps, memory, chat history, notes, etc.)": "",
"Automation": "",
"Automation created": "",
"Automation Name": "",
"Automation title": "",
"Automation triggered": "",
"Automation updated": "",
"Automations": "",
"Available list": "",
"Available models": "",
"Available Tools": "",
@ -250,6 +259,7 @@
"Boosting or penalizing specific tokens for constrained responses. Bias values will be clamped between -100 and 100 (inclusive). (Default: none)": "",
"Brave": "",
"Brave Search API Key": "",
"Break down complex requests into trackable steps": "",
"Browse and query knowledge bases": "",
"Builtin Tools": "",
"Bullet List": "",
@ -383,6 +393,7 @@
"Concurrent Requests": "",
"Config": "",
"Config imported successfully": "",
"Configuration": "",
"Configure": "",
"Confirm": "",
"Confirm Password": "Kumpirma ang password",
@ -452,6 +463,7 @@
"Create new secret key": "",
"Create note": "",
"Create Note": "",
"Create scheduled prompts that run automatically on a recurring basis.": "",
"Create your first note by clicking on the plus button below.": "",
"Created at": "Gihimo ang",
"Created At": "",
@ -473,6 +485,7 @@
"Data Controls": "",
"Database": "Database",
"Datalab Marker API": "",
"Day": "",
"DD/MM/YYYY": "",
"DDGS Backend": "",
"December": "",
@ -503,6 +516,7 @@
"Delete All": "",
"Delete All Chats": "",
"Delete all contents inside this folder": "",
"Delete automation?": "",
"Delete Chat": "",
"Delete chat?": "",
"Delete File": "",
@ -647,6 +661,7 @@
"Embedding Concurrent Requests": "",
"Embedding Model": "",
"Embedding Model Engine": "",
"Emojis": "",
"Empty message": "",
"Enable All": "",
"Enable API Keys": "",
@ -738,6 +753,7 @@
"Enter Perplexity Search API URL": "",
"Enter Playwright Timeout": "",
"Enter Playwright WebSocket URL": "",
"Enter prompt here.": "",
"Enter proxy URL (e.g. https://user:password@host:port)": "",
"Enter reasoning effort": "",
"Enter Score": "",
@ -762,6 +778,7 @@
"Enter system prompt here": "",
"Enter Tavily API Key": "",
"Enter Tavily Extract Depth": "",
"Enter the prompt instructions for this automation...": "",
"Enter the public URL of your WebUI. This URL will be used to generate links in the notifications.": "",
"Enter the URL of the function to import": "",
"Enter the URL to import": "",
@ -801,6 +818,7 @@
"Error accessing directory": "",
"Error accessing Google Drive: {{error}}": "",
"Error accessing media devices.": "",
"Error deleting model: {{error}}": "",
"Error starting recording.": "",
"Error unloading model: {{error}}": "",
"Error uploading file: {{error}}": "",
@ -818,6 +836,7 @@
"Execute code": "",
"Execute code for analysis": "",
"Executing **{{NAME}}**...": "",
"Execution Logs": "",
"Expand": "",
"Experimental": "Eksperimento",
"Explain": "",
@ -1081,6 +1100,7 @@
"Insert Suggestion Prompt to Input": "",
"Install from Github URL": "",
"Instant Auto-Send After Voice Transcription": "",
"Instructions": "",
"Integration": "",
"Integrations": "",
"Interface": "Interface",
@ -1140,6 +1160,7 @@
"Last 90 days": "",
"Last Active": "",
"Last Modified": "",
"Last ran": "",
"Last reply": "",
"LDAP": "",
"LDAP server updated": "",
@ -1246,6 +1267,7 @@
"Model '{{modelName}}' has been successfully downloaded.": "Ang modelo'{{modelName}}' malampuson nga na-download.",
"Model '{{modelTag}}' is already in queue for downloading.": "Ang modelo'{{modelTag}}' naa na sa pila para ma-download.",
"Model {{modelId}} not found": "Modelo {{modelId}} wala makit-an",
"Model {{modelName}} deleted successfully": "",
"Model {{modelName}} is not vision capable": "",
"Model {{name}} is now {{status}}": "",
"Model {{name}} is now hidden": "",
@ -1295,8 +1317,11 @@
"Name": "Ngalan",
"Name and ID are required, please fill them out": "",
"Name your knowledge base": "",
"Name, prompt, and model are required": "",
"Native": "",
"Never": "",
"New": "",
"New Automation": "",
"New Button": "",
"New Chat": "Bag-ong diskusyon",
"New File": "",
@ -1315,9 +1340,11 @@
"New Webhook": "",
"new-channel": "",
"Next message": "",
"Next run": "",
"No access grants. Private to you.": "",
"No activity data": "",
"No authentication": "",
"No automations found": "",
"No chats found": "",
"No chats found for this user.": "",
"No chats found.": "",
@ -1328,6 +1355,7 @@
"No data": "",
"No data found": "",
"No distance available": "",
"No execution logs available yet": "",
"No expiration can pose security risks.": "",
"No feedback found": "",
"No file selected": "",
@ -1374,6 +1402,7 @@
"Not factually correct": "",
"Not helpful": "",
"Not Registered": "",
"Not scheduled": "",
"Note": "",
"Note deleted successfully": "",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "",
@ -1447,6 +1476,7 @@
"or": "O",
"Ordered List": "",
"Other": "",
"out of": "",
"Output": "",
"OUTPUT": "",
"Output format": "",
@ -1462,6 +1492,7 @@
"Password": "Password",
"Passwords do not match.": "",
"Paste Large Text as File": "",
"Paused": "",
"PDF document (.pdf)": "",
"PDF Extract Images (OCR)": "PDF Image Extraction (OCR)",
"PDF Loader Mode": "",
@ -1566,6 +1597,7 @@
"Reason": "",
"Reasoning Effort": "",
"Reasoning Tags": "",
"Recently Used": "",
"Record": "",
"Record voice": "Irekord ang tingog",
"Redirecting you to Open WebUI Community": "Gi-redirect ka sa komunidad sa OpenWebUI",
@ -1601,6 +1633,7 @@
"Renamed to {{name}}": "",
"Render Markdown in Previews": "",
"Reorder Models": "",
"Repeats": "",
"Reply": "",
"Reply in Thread": "",
"Reply to thread...": "",
@ -1633,6 +1666,8 @@
"RTL": "",
"Run": "",
"Run All": "",
"Run now": "",
"Run Now": "",
"Running": "",
"Running...": "Nagdagan...",
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
@ -1643,12 +1678,15 @@
"Save Chat": "",
"Saved": "",
"Saving chat logs directly to your browser's storage is no longer supported. Please take a moment to download and delete your chat logs by clicking the button below. Don't worry, you can easily re-import your chat logs to the backend through": "Ang pag-save sa mga chat log direkta sa imong browser storage dili na suportado. ",
"Schedule": "",
"Scheduled time must be in the future": "",
"Scroll On Branch Change": "",
"Search": "Pagpanukiduki",
"Search a model": "",
"Search all emojis": "",
"Search and manage user memories": "",
"Search and view user chat history": "",
"Search Automations": "",
"Search Base": "",
"Search channels and channel messages": "",
"Search Chats": "",
@ -1718,6 +1756,7 @@
"Select how to split message text for TTS requests": "",
"Select Knowledge": "",
"Select Method": "",
"Select model": "",
"Select only one model to call": "",
"Select view": "",
"Selected model: {{modelName}}": "",
@ -1827,6 +1866,7 @@
"Start of the channel": "Sinugdan sa channel",
"Start Tag": "",
"Starting kernel...": "",
"State": "",
"Status": "",
"Status cleared successfully": "",
"Status updated successfully": "",
@ -1877,8 +1917,10 @@
"Talk to Model": "",
"Tap to interrupt": "",
"Task List": "",
"Task Management": "",
"Task Model": "",
"Tasks": "",
"tasks completed": "",
"Tavily API Key": "",
"Tavily Extract Depth": "",
"Tell us more:": "",
@ -1945,6 +1987,7 @@
"Tika": "",
"Tika Server URL required.": "",
"Tiktoken": "",
"Time": "",
"Time & Calculation": "",
"Timeout": "",
"Title": "Titulo",
@ -1962,6 +2005,7 @@
"To select toolkits here, add them to the \"Tools\" workspace first.": "",
"Toast notifications for new updates": "",
"Today": "",
"Today at": "",
"Today at {{LOCALIZED_TIME}}": "",
"Toggle {{COUNT}} sources": "",
"Toggle 1 source": "",
@ -2100,6 +2144,7 @@
"Waiting for upload...": "",
"Warning": "",
"Warning:": "Pahimangno:",
"Warning: Enabling this will allow users to run scheduled prompts automatically.": "",
"Warning: Enabling this will allow users to upload arbitrary code on the server.": "",
"Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.": "",
"Web": "Web",
@ -2134,6 +2179,7 @@
"Width": "",
"Wikipedia": "",
"Won": "",
"Working Directory": "",
"Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text.": "",
"Workspace": "",
"Workspace Permissions": "",

View file

@ -184,6 +184,7 @@
"Are you sure you want to archive all chats? This action cannot be undone.": "",
"Are you sure you want to clear all memories? This action cannot be undone.": "Opravdu si přejete vymazat všechny vzpomínky? Tuto akci nelze vrátit zpět.",
"Are you sure you want to delete \"{{NAME}}\"?": "",
"Are you sure you want to delete **{{modelName}}**?": "",
"Are you sure you want to delete all chats? This action cannot be undone.": "",
"Are you sure you want to delete this channel?": "Opravdu chcete smazat tento kanál?",
"Are you sure you want to delete this connection? This action cannot be undone.": "",
@ -200,6 +201,7 @@
"Assistant": "Asistent",
"Async Embedding Processing": "",
"Attach File From Knowledge": "",
"Attach Files": "",
"Attach Knowledge": "Připojit znalosti",
"Attach Notes": "Přiojit poznámky",
"Attach Webpage": "Připojit web",
@ -222,6 +224,13 @@
"AUTOMATIC1111 Base URL": "Základní URL pro AUTOMATIC1111",
"AUTOMATIC1111 Base URL is required.": "Je vyžadována základní URL pro AUTOMATIC1111.",
"Automatically inject system tools in native function calling mode (e.g., timestamps, memory, chat history, notes, etc.)": "",
"Automation": "",
"Automation created": "",
"Automation Name": "",
"Automation title": "",
"Automation triggered": "",
"Automation updated": "",
"Automations": "",
"Available list": "Seznam dostupných",
"Available models": "",
"Available Tools": "Dostupné nástroje",
@ -252,6 +261,7 @@
"Boosting or penalizing specific tokens for constrained responses. Bias values will be clamped between -100 and 100 (inclusive). (Default: none)": "Zvýhodňování nebo penalizace specifických tokenů pro omezené odpovědi. Hodnoty odchylky budou omezeny v rozmezí -100 až 100 (včetně). (Výchozí: žádné)",
"Brave": "",
"Brave Search API Key": "Klíč API pro Brave Search",
"Break down complex requests into trackable steps": "",
"Browse and query knowledge bases": "",
"Builtin Tools": "",
"Bullet List": "Seznam s odrážkami",
@ -385,6 +395,7 @@
"Concurrent Requests": "Souběžné požadavky",
"Config": "",
"Config imported successfully": "Konfigurace byla úspěšně importována",
"Configuration": "",
"Configure": "Konfigurovat",
"Confirm": "Potvrdit",
"Confirm Password": "Potvrdit heslo",
@ -454,6 +465,7 @@
"Create new secret key": "Vytvořit nový tajný klíč",
"Create note": "",
"Create Note": "Vytvořit poznámku",
"Create scheduled prompts that run automatically on a recurring basis.": "",
"Create your first note by clicking on the plus button below.": "Vytvořte svou první poznámku kliknutím na tlačítko plus níže.",
"Created at": "Vytvořeno",
"Created At": "Vytvořeno",
@ -475,6 +487,7 @@
"Data Controls": "Správa dat",
"Database": "Databáze",
"Datalab Marker API": "Datalab Marker API",
"Day": "",
"DD/MM/YYYY": "DD.MM.RRRR",
"DDGS Backend": "",
"December": "Prosinec",
@ -505,6 +518,7 @@
"Delete All": "",
"Delete All Chats": "Smazat všechny konverzace",
"Delete all contents inside this folder": "",
"Delete automation?": "",
"Delete Chat": "Smazat konverzaci",
"Delete chat?": "Smazat konverzaci?",
"Delete File": "",
@ -649,6 +663,7 @@
"Embedding Concurrent Requests": "",
"Embedding Model": "Model pro vektorizaci",
"Embedding Model Engine": "Jádro modelu pro vektorizaci",
"Emojis": "",
"Empty message": "",
"Enable All": "",
"Enable API Keys": "",
@ -740,6 +755,7 @@
"Enter Perplexity Search API URL": "",
"Enter Playwright Timeout": "Zadejte časový limit pro Playwright",
"Enter Playwright WebSocket URL": "Zadejte WebSocket URL pro Playwright",
"Enter prompt here.": "",
"Enter proxy URL (e.g. https://user:password@host:port)": "Zadejte URL proxy (např. https://uzivatel:heslo@hostitel:port)",
"Enter reasoning effort": "Zadejte úsilí pro uvažování",
"Enter Score": "Zadejte skóre",
@ -764,6 +780,7 @@
"Enter system prompt here": "Zde zadejte systémové instrukce",
"Enter Tavily API Key": "Zadejte API klíč pro Tavily",
"Enter Tavily Extract Depth": "Zadejte hloubku extrakce pro Tavily",
"Enter the prompt instructions for this automation...": "",
"Enter the public URL of your WebUI. This URL will be used to generate links in the notifications.": "Zadejte veřejnou URL adresu vašeho WebUI. Tato URL bude použita k generování odkazů v oznámeních.",
"Enter the URL of the function to import": "Zadejte URL funkce k importu",
"Enter the URL to import": "Zadejte URL pro import",
@ -803,6 +820,7 @@
"Error accessing directory": "Chyba při přístupu k adresáři",
"Error accessing Google Drive: {{error}}": "Chyba při přístupu ke Google Drive: {{error}}",
"Error accessing media devices.": "Chyba při přístupu k mediálním zařízením.",
"Error deleting model: {{error}}": "",
"Error starting recording.": "Chyba při spuštění nahrávání.",
"Error unloading model: {{error}}": "Chyba při uvolňování modelu: {{error}}",
"Error uploading file: {{error}}": "Chyba při nahrávání souboru: {{error}}",
@ -820,6 +838,7 @@
"Execute code": "",
"Execute code for analysis": "Spustit kód pro analýzu",
"Executing **{{NAME}}**...": "Spouštím **{{NAME}}**...",
"Execution Logs": "",
"Expand": "Rozbalit",
"Experimental": "Experimentální",
"Explain": "Vysvětlit",
@ -1083,6 +1102,7 @@
"Insert Suggestion Prompt to Input": "",
"Install from Github URL": "Instalovat z URL na Githubu",
"Instant Auto-Send After Voice Transcription": "Okamžité automatické odeslání po přepisu hlasu",
"Instructions": "",
"Integration": "Integrace",
"Integrations": "Integrace",
"Interface": "Rozhraní",
@ -1142,6 +1162,7 @@
"Last 90 days": "",
"Last Active": "Naposledy aktivní",
"Last Modified": "Poslední úprava",
"Last ran": "",
"Last reply": "Poslední odpověď",
"LDAP": "LDAP",
"LDAP server updated": "LDAP server byl aktualizován",
@ -1248,6 +1269,7 @@
"Model '{{modelName}}' has been successfully downloaded.": "Model '{{modelName}}' byl úspěšně stažen.",
"Model '{{modelTag}}' is already in queue for downloading.": "Model '{{modelTag}}' je již ve frontě na stažení.",
"Model {{modelId}} not found": "Model {{modelId}} nenalezen",
"Model {{modelName}} deleted successfully": "",
"Model {{modelName}} is not vision capable": "Model {{modelName}} nemá schopnost zpracování obrazu.",
"Model {{name}} is now {{status}}": "Model {{name}} je nyní {{status}}.",
"Model {{name}} is now hidden": "Model {{name}} je nyní skrytý",
@ -1297,8 +1319,11 @@
"Name": "Jméno",
"Name and ID are required, please fill them out": "Jméno a ID jsou povinné, prosím vyplňte je",
"Name your knowledge base": "Pojmenujte svou znalostní bázi",
"Name, prompt, and model are required": "",
"Native": "Nativní",
"Never": "",
"New": "",
"New Automation": "",
"New Button": "Nové tlačítko",
"New Chat": "Nová konverzace",
"New File": "",
@ -1317,9 +1342,11 @@
"New Webhook": "",
"new-channel": "novy-kanal",
"Next message": "Další zpráva",
"Next run": "",
"No access grants. Private to you.": "",
"No activity data": "",
"No authentication": "",
"No automations found": "",
"No chats found": "Nebyly nalezeny žádné konverzace",
"No chats found for this user.": "Pro tohoto uživatele nebyly nalezeny žádné konverzace.",
"No chats found.": "Nebyly nalezeny žádné konverzace.",
@ -1330,6 +1357,7 @@
"No data": "",
"No data found": "",
"No distance available": "Vzdálenost není k dispozici",
"No execution logs available yet": "",
"No expiration can pose security risks.": "",
"No feedback found": "",
"No file selected": "Nebyl vybrán žádný soubor",
@ -1376,6 +1404,7 @@
"Not factually correct": "Fakticky nesprávné",
"Not helpful": "Nepomohlo",
"Not Registered": "",
"Not scheduled": "",
"Note": "Poznámka",
"Note deleted successfully": "Poznámka byla úspěšně smazána",
"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "Poznámka: Pokud nastavíte minimální skóre, vyhledávání vrátí pouze dokumenty se skóre vyšším nebo rovným minimálnímu skóre.",
@ -1449,6 +1478,7 @@
"or": "nebo",
"Ordered List": "Číslovaný seznam",
"Other": "Jiné",
"out of": "",
"Output": "",
"OUTPUT": "VÝSTUP",
"Output format": "Formát výstupu",
@ -1464,6 +1494,7 @@
"Password": "Heslo",
"Passwords do not match.": "Hesla se neshodují.",
"Paste Large Text as File": "Vložit velký text jako soubor",
"Paused": "",
"PDF document (.pdf)": "Dokument PDF (.pdf)",
"PDF Extract Images (OCR)": "Extrahovat obrázky z PDF (OCR)",
"PDF Loader Mode": "",
@ -1568,6 +1599,7 @@
"Reason": "Důvod",
"Reasoning Effort": "reasoning effort",
"Reasoning Tags": "reasoning tags",
"Recently Used": "",
"Record": "Nahrát",
"Record voice": "Nahrát hlas",
"Redirecting you to Open WebUI Community": "Přesměrovávám vás do komunity Open WebUI",
@ -1603,6 +1635,7 @@
"Renamed to {{name}}": "",
"Render Markdown in Previews": "",
"Reorder Models": "Změnit pořadí modelů",
"Repeats": "",
"Reply": "",
"Reply in Thread": "Odpovědět ve vlákně",
"Reply to thread...": "",
@ -1637,6 +1670,8 @@
"RTL": "RTL",
"Run": "Spustit",
"Run All": "",
"Run now": "",
"Run Now": "",
"Running": "Běží",
"Running...": "Běží...",
"Runs embedding tasks concurrently to speed up processing. Turn off if rate limits become an issue.": "",
@ -1647,12 +1682,15 @@
"Save Chat": "",
"Saved": "Uloženo",
"Saving chat logs directly to your browser's storage is no longer supported. Please take a moment to download and delete your chat logs by clicking the button below. Don't worry, you can easily re-import your chat logs to the backend through": "Ukládání záznamů konverzací přímo do úložiště vašeho prohlížeče již není podporováno. Věnujte prosím chvíli stažení a smazání svých záznamů konverzací kliknutím na tlačítko níže. Nemějte obavy, své záznamy konverzací můžete snadno znovu importovat do backendu prostřednictvím",
"Schedule": "",
"Scheduled time must be in the future": "",
"Scroll On Branch Change": "Posouvat při změně větve",
"Search": "Hledat",
"Search a model": "Hledat model",
"Search all emojis": "Hledat všechny emoji",
"Search and manage user memories": "",
"Search and view user chat history": "",
"Search Automations": "",
"Search Base": "Základ pro vyhledávání",
"Search channels and channel messages": "",
"Search Chats": "Hledat v konverzacích",
@ -1722,6 +1760,7 @@
"Select how to split message text for TTS requests": "Vyberte, jak dělit text zprávy pro požadavky TTS",
"Select Knowledge": "Vybrat znalosti",
"Select Method": "",
"Select model": "",
"Select only one model to call": "Vyberte pouze jeden model k volání",
"Select view": "",
"Selected model: {{modelName}}": "",
@ -1831,6 +1870,7 @@
"Start of the channel": "Začátek kanálu",
"Start Tag": "",
"Starting kernel...": "",
"State": "",
"Status": "",
"Status cleared successfully": "",
"Status updated successfully": "",
@ -1881,8 +1921,10 @@
"Talk to Model": "",
"Tap to interrupt": "Klepnutím přerušíte",
"Task List": "Seznam úkolů",
"Task Management": "",
"Task Model": "Model pro úkoly",
"Tasks": "Úkoly",
"tasks completed": "",
"Tavily API Key": "API klíč pro Tavily",
"Tavily Extract Depth": "Hloubka extrakce Tavily",
"Tell us more:": "Řekněte nám více:",
@ -1949,6 +1991,7 @@
"Tika": "Tika",
"Tika Server URL required.": "Je vyžadována URL serveru Tika.",
"Tiktoken": "Tiktoken",
"Time": "",
"Time & Calculation": "",
"Timeout": "",
"Title": "Název",
@ -1966,6 +2009,7 @@
"To select toolkits here, add them to the \"Tools\" workspace first.": "Pro výběr sad nástrojů zde je nejprve přidejte do pracovního prostoru \"Nástroje\".",
"Toast notifications for new updates": "Vyskakovací oznámení o nových aktualizacích",
"Today": "Dnes",
"Today at": "",
"Today at {{LOCALIZED_TIME}}": "",
"Toggle {{COUNT}} sources": "",
"Toggle 1 source": "",
@ -2104,6 +2148,7 @@
"Waiting for upload...": "",
"Warning": "Varování",
"Warning:": "Varování:",
"Warning: Enabling this will allow users to run scheduled prompts automatically.": "",
"Warning: Enabling this will allow users to upload arbitrary code on the server.": "Varování: Povolení této volby umožní uživatelům nahrávat na server libovolný kód.",
"Warning: Jupyter execution enables arbitrary code execution, posing severe security risks—proceed with extreme caution.": "Varování: Spouštění Jupyteru umožňuje provádění libovolného kódu, což představuje vážná bezpečnostní rizika postupujte s maximální opatrností.",
"Web": "Web",
@ -2138,6 +2183,7 @@
"Width": "Šířka",
"Wikipedia": "",
"Won": "Vyhrál",
"Working Directory": "",
"Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text.": "Funguje společně s top-k. Vyšší hodnota (např. 0,95) povede k rozmanitějšímu textu, zatímco nižší hodnota (např. 0,5) vygeneruje soustředěnější a konzervativnější text.",
"Workspace": "Pracovní prostor",
"Workspace Permissions": "Oprávnění pracovního prostoru",

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