Merge branch 'dev' into fix/web-fetch-filter-redirect

This commit is contained in:
Classic298 2026-08-17 08:13:09 +00:00
commit e84a2b5d26
No known key found for this signature in database
163 changed files with 9417 additions and 2396 deletions

View file

@ -2,7 +2,6 @@ name: Bug Report
description: Create a detailed bug report to help us improve Open WebUI.
title: 'issue: '
labels: ['bug', 'triage']
assignees: []
body:
- type: markdown
attributes:
@ -55,7 +54,7 @@ body:
id: open-webui-version
attributes:
label: Open WebUI Version
description: Specify the version (e.g., v0.6.26)
description: Specify the version (e.g., v0.11.0)
validations:
required: true
@ -63,7 +62,7 @@ body:
id: ollama-version
attributes:
label: Ollama Version (if applicable)
description: Specify the version (e.g., v0.2.0, or v0.1.32-rc1)
description: Specify the version (e.g., v0.32.5, or v0.32.6-rc0)
validations:
required: false
@ -71,7 +70,7 @@ body:
id: operating-system
attributes:
label: Operating System
description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04, Debian 12)
description: Specify the OS (e.g., Windows 11, macOS Tahoe, Ubuntu 26.04, Debian 13)
validations:
required: true
@ -79,7 +78,7 @@ body:
id: browser
attributes:
label: Browser (if applicable)
description: Specify the browser/version (e.g., Chrome 100.0, Firefox 98.0)
description: Specify the browser/version (e.g., Chrome 151.0, Firefox 153.0.3)
validations:
required: false
@ -138,11 +137,11 @@ body:
placeholder: |
Example (include every detail):
1. Start with a clean Ubuntu 22.04 install.
2. Install Docker v24.0.5 and start the service.
1. Start with a clean Ubuntu 26.04 install.
2. Install Docker v29.7.1 and start the service.
3. Clone the Open WebUI repo (git clone ...).
4. Use the Docker Compose file without modifications.
5. Open browser Chrome 115.0 in incognito mode.
5. Open browser Chrome 151.0 in incognito mode.
6. Go to http://localhost:8080 and log in with user "test@example.com".
7. Set the language to "English" and theme to "Dark".
8. Attempt to connect to Ollama at "http://localhost:11434".

View file

@ -13,12 +13,16 @@ This is to ensure large feature PRs are discussed with the community first, befo
<!--
### ⚠️ Important: Your PR is a contribution, not a guarantee of merge.
We appreciate thoughtful contributions. Pull requests are for implementation-ready changes that have already been requested, confirmed, or actively discussed in a linked Issue or Discussion. Feature ideas, behavior changes, UI/UX changes, architecture changes, suspected fixes, and unconfirmed approaches should start as an Issue or Discussion instead.
Before opening a PR, make sure the change has a clear linked problem, follows nearby patterns, has been manually tested, and accounts for related or downstream behavior. PRs that are ideas, prototypes, unresolved design questions, unchecked AI-generated code, symptom-only patches, one-off patches, or changes where affected paths have not been checked will usually be closed.
The most impactful way to contribute to Open WebUI is through well-written bug reports, detailed feature discussions, and thoughtful ideas. These directly shape the project. If you do open a pull request, please know that Open WebUI is held to the highest standard of code quality, consistency, and architectural coherence, and every line merged becomes something the core team must own, maintain, and support indefinitely. Submitted code may be refactored, rewritten, or used as inspiration for a different implementation. This is not a reflection of your work's quality. It is how we ensure that a small team can deeply understand and evolve every part of the codebase.
-->
**Before submitting, make sure you've checked and filled out the following:**
- [ ] **Linked Issue/Discussion:** This PR references an existing [Issue](https://github.com/open-webui/open-webui/issues) or [Discussion](https://github.com/open-webui/open-webui/discussions) — `Closes #___` / `Relates to #___`. PRs without a linked issue or discussion will be closed without review.
- [ ] **Linked Issue/Discussion:** This PR references an existing [Issue](https://github.com/open-webui/open-webui/issues) or active, substantive [Discussion](https://github.com/open-webui/open-webui/discussions) — `Closes #___` / `Relates to #___`. Creating a discussion only to satisfy this checkbox does not count.
- [ ] **Target branch:** The pull request targets the `dev` branch. **PRs targeting `main` will be immediately closed.**
- [ ] **Description:** A concise description of the changes is provided below.
- [ ] **Changelog:** A changelog entry following [Keep a Changelog](https://keepachangelog.com/) format is included at the bottom.

View file

@ -67,3 +67,73 @@ jobs:
issue_number: issue.number,
labels: ['bug']
});
label-feature-requests:
runs-on: ubuntu-latest
steps:
- name: Add "enhancement" label to unlabeled feature requests
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
if (issue.labels.some((label) => label.name === 'enhancement')) {
return;
}
// A human (or the bug form) already classified this as a bug;
// do not stack a second, contradictory classification on it.
if (issue.labels.some((label) => label.name === 'bug')) {
return;
}
const isEdit = context.payload.action === 'edited';
const titleWasEdited = Boolean(context.payload.changes?.title);
if (isEdit && !titleWasEdited) {
return;
}
const title = issue.title ?? '';
const body = issue.body ?? '';
// Feature requests: "feat: ...", "feature: ...", "feature request: ...",
// "enhancement: ...", "enh: ...", "[Feature Request] ..." — the feature
// request form titles every submission "feat: ", so form submissions are
// covered by the same pattern.
const featureLikeTitle =
/^\s*(\[\s*(feat|feature|enhancement|enh)\b[^\]]*\]|(feat|feature( request)?|enhancement|enh)\s*[:/\-])/i.test(
title
);
// API/CLI-created issues that reproduce the feature request form structure.
// Only headings distinctive to that form.
const featureFormBody = /###\s*(Proposed Solution|Alternatives Considered)/i.test(body);
if (!featureLikeTitle && !featureFormBody) {
return;
}
if (isEdit) {
const events = await github.paginate(github.rest.issues.listEvents, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
per_page: 100
});
const enhancementLabelWasRemoved = events.some(
(event) => event.event === 'unlabeled' && event.label?.name === 'enhancement'
);
if (enhancementLabelWasRemoved) {
return;
}
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['enhancement']
});

View file

@ -162,6 +162,7 @@ RUN set -e; \
fi; \
fi; \
mkdir -p /app/backend/data; chown -R $UID:$GID /app/backend/data/; \
if [ -d /app/backend/data/cache ]; then chmod -R a+rX /app/backend/data/cache; fi; \
rm -rf /var/lib/apt/lists/*;
# Optional: PPTX parsing through unstructured may need spaCy's English model.

View file

@ -39,7 +39,7 @@ from open_webui.utils.json_codec import JSONCodec
async def seed_registered_defaults():
await Config.rename_prefix('rag.web', 'web')
await Config.repair_flattened_dict_configs()
await Config.repair_config_rows()
await Config.seed_defaults(DEFAULT_CONFIG)
@ -2097,6 +2097,7 @@ ENABLE_USER_WEBHOOKS = os.getenv('ENABLE_USER_WEBHOOKS', 'False').lower() == 'tr
# FastAPI / AnyIO settings
THREAD_POOL_SIZE = os.getenv('THREAD_POOL_SIZE', None)
THREAD_POOL_THREAD_NAME_PREFIX = os.getenv('THREAD_POOL_THREAD_NAME_PREFIX', '')
if THREAD_POOL_SIZE is not None and isinstance(THREAD_POOL_SIZE, str):
try:
@ -2187,6 +2188,8 @@ CONTEXT_COMPACTION_MODEL = os.getenv('CONTEXT_COMPACTION_MODEL', '')
ENABLE_CONTEXT_COMPACTION = os.getenv('ENABLE_CONTEXT_COMPACTION', 'False').lower() == 'true'
ENABLE_TOOL_PERMISSIONS = os.getenv('ENABLE_TOOL_PERMISSIONS', 'False').lower() == 'true'
CONTEXT_COMPACTION_TOKEN_THRESHOLD = int(os.getenv('CONTEXT_COMPACTION_TOKEN_THRESHOLD', '80000'))
_CONTEXT_COMPACTION_TOKEN_CAP = os.getenv('CONTEXT_COMPACTION_TOKEN_CAP')
@ -3119,6 +3122,7 @@ DEFAULT_CONFIG = {
'chat.context_compaction.token_cap': CONTEXT_COMPACTION_TOKEN_CAP,
'chat.context_compaction.retention_percentage': CONTEXT_COMPACTION_RETENTION_PERCENTAGE,
'chat.context_compaction.prompt_template': CONTEXT_COMPACTION_PROMPT_TEMPLATE,
'chat.tool_permissions.enable': ENABLE_TOOL_PERMISSIONS,
'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE,
'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE,
'task.image.prompt_template': IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,

View file

@ -118,6 +118,9 @@ class ERROR_MESSAGES(str, Enum):
AUTOMATION_TOO_FREQUENT = lambda interval='': f'Schedule too frequent. Minimum interval is {interval} seconds.'
AUTOMATION_INVALID_RRULE = lambda err='': f'Invalid RRULE: {err}'
AUTOMATION_NO_FUTURE_RUNS = 'RRULE has no future occurrences'
AUTOMATION_COUNT_REQUIRES_DTSTART = (
'RRULE with COUNT requires an explicit DTSTART line to anchor the occurrence window'
)
FEATURE_DISABLED = lambda name='': f'{name} is disabled'
INPUT_TOO_LONG = lambda size='': f'Input prompt exceeds maximum length of {size}'

View file

@ -411,6 +411,11 @@ class EventDefinitions(BaseModel):
description='Retrieval content was processed.',
message='Retrieval Content processed',
)
RETRIEVAL_CONTENT_PROCESS_FAILED: EventDefinition = EventDefinition(
name='retrieval.content.process_failed',
description='Retrieval content processing failed.',
message='Retrieval Content process failed',
)
RETRIEVAL_COLLECTION_DELETED: EventDefinition = EventDefinition(
name='retrieval.collection.deleted',
description='A retrieval collection was deleted.',
@ -666,6 +671,7 @@ NOTIFICATION_EVENTS = (
EVENTS.CHAT_FAILED.name,
EVENTS.CHANNEL_MESSAGE.name,
EVENTS.CALENDAR_ALERT.name,
EVENTS.RETRIEVAL_CONTENT_PROCESS_FAILED.name,
)

View file

@ -7,6 +7,7 @@ import mimetypes
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from uuid import uuid4
@ -64,6 +65,7 @@ from open_webui.config import (
ONEDRIVE_SHAREPOINT_URL,
STATIC_DIR,
THREAD_POOL_SIZE,
THREAD_POOL_THREAD_NAME_PREFIX,
WEBUI_AUTH,
WEBUI_NAME,
async_reset_config,
@ -238,6 +240,7 @@ from open_webui.utils.logger import start_logger
from open_webui.utils.middleware import (
background_tasks_handler,
build_chat_response_context,
drain_approved_tool_calls,
process_chat_payload,
process_chat_response,
)
@ -265,6 +268,11 @@ from open_webui.utils.plugin import install_tool_and_function_dependencies
from open_webui.utils.redis import get_redis_client
from open_webui.utils.security_headers import SecurityHeadersMiddleware
from open_webui.utils.session_pool import cleanup_response, get_session, stream_wrapper
from open_webui.utils.tool_approval import (
ResolveToolCallForm,
build_tool_approval_resume_payload,
resolve_tool_call_output,
)
from open_webui.utils.tools import set_terminal_servers, set_tool_servers
if SAFE_MODE:
@ -337,6 +345,16 @@ async def lifespan(app: FastAPI):
# This allows sync functions to schedule work on the main loop without blocking health checks
app.state.main_loop = asyncio.get_running_loop()
if THREAD_POOL_SIZE and THREAD_POOL_SIZE > 0:
# asyncio offloads bypass AnyIO's limiter, so configure both before the first offload.
anyio.to_thread.current_default_thread_limiter().total_tokens = THREAD_POOL_SIZE
app.state.main_loop.set_default_executor(
ThreadPoolExecutor(
max_workers=THREAD_POOL_SIZE,
thread_name_prefix=THREAD_POOL_THREAD_NAME_PREFIX,
)
)
app.state.instance_id = INSTANCE_ID
start_logger()
@ -372,16 +390,12 @@ async def lifespan(app: FastAPI):
if app.state.redis is not None:
app.state.redis_task_command_listener = asyncio.create_task(redis_task_command_listener(app))
if THREAD_POOL_SIZE and THREAD_POOL_SIZE > 0:
limiter = anyio.to_thread.current_default_thread_limiter()
limiter.total_tokens = THREAD_POOL_SIZE
asyncio.create_task(periodic_usage_pool_cleanup())
asyncio.create_task(periodic_session_pool_cleanup())
app.state.periodic_usage_pool_cleanup = asyncio.create_task(periodic_usage_pool_cleanup())
app.state.periodic_session_pool_cleanup = asyncio.create_task(periodic_session_pool_cleanup())
from open_webui.utils.automations import scheduler_worker_loop
asyncio.create_task(scheduler_worker_loop(app))
app.state.scheduler_worker_loop = asyncio.create_task(scheduler_worker_loop(app))
if await Config.get('models.base_models_cache'):
try:
@ -462,6 +476,10 @@ async def lifespan(app: FastAPI):
if hasattr(app.state, 'redis_task_command_listener'):
app.state.redis_task_command_listener.cancel()
app.state.periodic_usage_pool_cleanup.cancel()
app.state.periodic_session_pool_cleanup.cancel()
app.state.scheduler_worker_loop.cancel()
await publish_event(app, EVENTS.SYSTEM_SHUTDOWN_COMPLETED, source='system')
@ -1174,7 +1192,7 @@ async def chat_completion(
message_ids = [{'model_id': model_id, 'message_id': form_data.pop('id', None)}]
user_message = form_data.pop('user_message', None) or form_data.pop('parent_message', None)
chat_id = form_data.get('chat_id') or ''
chat_id = form_data.pop('chat_id', None) or ''
chat_variables = form_data.pop('chat_variables', None)
if chat_variables is None:
existing_chat = await Chats.get_chat_by_id(chat_id) if is_saved_chat_id(chat_id) else None
@ -1196,16 +1214,28 @@ async def chat_completion(
):
tool_servers = None
automation_id = form_data.pop('automation_id', None)
tool_approval_mode = (
'full'
if automation_id or chat_id.startswith('channel:')
else (
form_data.get('params', {}).get('tool_approval_mode')
if await Config.get('chat.tool_permissions.enable', False)
else 'full'
)
or 'full'
)
metadata = {
'user_id': user.id,
'user_agent': request.headers.get('user-agent', '') or '',
'internal': getattr(request.state, 'internal', False) is True,
'chat_id': form_data.pop('chat_id', None) or '',
'chat_id': chat_id,
'user_message': user_message,
'user_message_id': user_message.get('id') if user_message else None,
'assistant_message_id': form_data.pop('assistant_message_id', None),
'session_id': form_data.pop('session_id', None),
'automation_id': form_data.pop('automation_id', None),
'automation_id': automation_id,
'folder_id': form_data.pop('folder_id', None),
'filter_ids': form_data.pop('filter_ids', []),
'tool_ids': form_data.get('tool_ids', None),
@ -1225,6 +1255,7 @@ async def chat_completion(
or model_info_params.get('function_calling')
or 'native'
),
'tool_approval_mode': tool_approval_mode,
},
}
@ -1238,8 +1269,8 @@ async def chat_completion(
if metadata.get('chat_id') and user:
chat_id = metadata['chat_id']
# Gate channel: branch — caller needs write access on the channel
# and the supplied message_id must belong to that channel.
# Gate channel: branch — caller needs write access on the channel, and the
# supplied message_id must belong to that channel and be the caller's own.
if chat_id.startswith('channel:'):
channel_id = chat_id.removeprefix('channel:')
channel = await Channels.get_channel_by_id(channel_id)
@ -1271,7 +1302,11 @@ async def chat_completion(
if not target_message_id:
continue
target_message = await Messages.get_message_by_id(target_message_id)
if target_message and target_message.channel_id != channel.id:
if target_message and (
target_message.channel_id != channel.id
# Write access is not authorship — block cross-member edits.
or (user.role != 'admin' and target_message.user_id != user.id)
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.DEFAULT(),
@ -1528,6 +1563,8 @@ async def chat_completion(
for entry in message_ids:
target_model_id = entry['model_id']
assistant_message_id = entry['message_id']
if assistant_message_id and assistant_message_id == metadata.get('assistant_message_id'):
continue
if assistant_message_id:
assistant_message = {
'id': assistant_message_id,
@ -1576,6 +1613,9 @@ async def chat_completion(
try:
form_data, metadata, events = await process_chat_payload(request, form_data, user, metadata, model)
if await drain_approved_tool_calls(request, form_data, user, model, metadata):
return {'status': True, 'chat_id': metadata.get('chat_id'), 'paused': True}
response = await chat_completion_handler(request, form_data, user)
# When the upstream provider returns an error (e.g. HTTP 400
@ -1817,6 +1857,26 @@ async def chat_completion(
generate_chat_completions = chat_completion
generate_chat_completion = chat_completion
@app.post('/api/v1/chats/{id}/messages/{message_id}/resolve')
async def resolve_chat_message_tool_call(
request: Request,
id: str,
message_id: str,
form_data: ResolveToolCallForm,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
resolution = await resolve_tool_call_output(id, message_id, form_data, user, db=db)
payload = await build_tool_approval_resume_payload(id, message_id, chat=resolution['chat'])
result = await chat_completion(request, payload, user)
return {
'status': True,
'chat_id': id,
'message_id': message_id,
**(result if isinstance(result, dict) else {}),
}
# 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
@ -2067,6 +2127,7 @@ async def list_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De
@app.post('/api/tasks/chat/{chat_id:path}/stop')
async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=Depends(get_verified_user)):
socket_id = get_temporary_chat_session_id(chat_id)
chat = None
if socket_id:
owner_id = get_user_id_from_session_pool(socket_id)
if owner_id != user.id and user.role != 'admin':
@ -2076,6 +2137,47 @@ async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De
if chat is None or (chat.user_id != user.id and user.role != 'admin'):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
result = await stop_item_tasks(request.app.state.redis, chat_id)
if not socket_id and str(result.get('message', '')).startswith('No tasks found'):
messages_map = await Chats.get_messages_map_by_chat_id(chat_id) or {}
for message_id, message in messages_map.items():
if message.get('role') != 'assistant' or message.get('done') is not False:
continue
output = message.get('output')
if isinstance(output, list):
for item in output:
if item.get('type') == 'function_call' and item.get('status') in {
'pending',
'queued',
'requires_approval',
}:
item['status'] = 'rejected'
item.pop('approved', None)
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
message_id,
{'done': True, **({'output': output} if isinstance(output, list) else {})},
touch=False,
)
result = {
'status': True,
'message': 'Finalized pending approval message.',
}
event_emitter = await get_event_emitter(
{
'user_id': chat.user_id,
'chat_id': chat_id,
'message_id': message_id,
},
update_db=False,
)
if event_emitter:
await event_emitter({'type': 'chat:completion', 'data': {'done': True, 'output': output}})
await event_emitter({'type': 'chat:tasks:cancel'})
return result
@ -2134,6 +2236,7 @@ async def get_app_config(request: Request):
'automations.enable',
'notes.enable',
'chat.context_compaction.enable',
'chat.tool_permissions.enable',
'web.search.enable',
'web.search.confirmation.enable',
'web.search.confirmation.content',
@ -2211,6 +2314,7 @@ async def get_app_config(request: Request):
'enable_automations': config.get('automations.enable'),
'enable_notes': config.get('notes.enable'),
'enable_context_compaction': config.get('chat.context_compaction.enable'),
'enable_tool_permissions': config.get('chat.tool_permissions.enable'),
'enable_web_search': config.get('web.search.enable'),
'enable_web_search_confirmation': config.get('web.search.confirmation.enable'),
'web_search_confirmation_content': config.get('web.search.confirmation.content'),

View file

@ -839,7 +839,8 @@ class AccessGrantsTable:
):
"""
Filter for items where user has read BUT NOT write access.
Public items are NOT considered read_only.
A public (user:*) read grant counts as read access, so publicly shared
read-only items are listed rather than being reachable only by direct link.
Note: This method builds SQLAlchemy expressions and does NOT perform I/O itself,
so it remains synchronous. The caller is responsible for executing the query
@ -850,7 +851,6 @@ class AccessGrantsTable:
from sqlalchemy import exists as sa_exists
# Has read grant (not public)
read_grant_exists = (
select(AccessGrant.id)
.where(
@ -858,6 +858,10 @@ class AccessGrantsTable:
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == 'read',
or_(
and_(
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == '*',
),
*(
[
and_(
@ -884,7 +888,6 @@ class AccessGrantsTable:
.exists()
)
# Does NOT have write grant
write_grant_exists = (
select(AccessGrant.id)
.where(
@ -892,6 +895,10 @@ class AccessGrantsTable:
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == 'write',
or_(
and_(
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == '*',
),
*(
[
and_(
@ -918,21 +925,7 @@ class AccessGrantsTable:
.exists()
)
# Is NOT public
public_grant_exists = (
select(AccessGrant.id)
.where(
AccessGrant.resource_type == resource_type,
AccessGrant.resource_id == DocumentModel.id,
AccessGrant.permission == 'read',
AccessGrant.principal_type == 'user',
AccessGrant.principal_id == '*',
)
.correlate(DocumentModel)
.exists()
)
conditions = [read_grant_exists, ~write_grant_exists, ~public_grant_exists]
conditions = [read_grant_exists, ~write_grant_exists]
# Not owner
if user_id:

View file

@ -1,9 +1,10 @@
import logging
import time
from typing import Optional
from typing import Literal, Optional
from uuid import uuid4
from open_webui.internal.db import Base, get_async_db_context
from open_webui.utils.misc import json_text_variants
from pydantic import BaseModel, ConfigDict
from sqlalchemy import JSON, BigInteger, Boolean, Column, Index, String, Text, cast, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@ -64,11 +65,17 @@ class AutomationTerminalConfig(BaseModel):
cwd: Optional[str] = None
class AutomationTarget(BaseModel):
type: Literal['chat', 'channel'] = 'chat'
channel_id: Optional[str] = None
class AutomationData(BaseModel):
prompt: str
model_id: str
rrule: str
terminal: Optional[AutomationTerminalConfig] = None
target: Optional[AutomationTarget] = None
class AutomationModel(BaseModel):
@ -183,12 +190,12 @@ class AutomationTable:
stmt = stmt.filter(Automation.folder_id == folder_id)
if query:
search = f'%{query}%'
# Search in name and prompt inside JSON data
# Search the name column and the prompt inside the JSON data.
data_text = cast(Automation.data, String)
stmt = stmt.filter(
or_(
Automation.name.ilike(search),
cast(Automation.data, String).ilike(search),
Automation.name.ilike(f'%{query}%'),
*(data_text.ilike(f'%{variant}%') for variant in json_text_variants(query)),
)
)

View file

@ -902,8 +902,10 @@ class ChatTable:
if current_id is None
else messages.get(current_id, {}).get('childrenIds', [])
)
while child_ids:
visited_ids = set()
while child_ids and child_ids[-1] not in visited_ids:
current_id = child_ids[-1]
visited_ids.add(current_id)
child_ids = messages.get(current_id, {}).get('childrenIds', [])
history['currentId'] = current_id if current_id in messages else None
return deleted_ids
@ -1035,6 +1037,10 @@ class ChatTable:
return history_messages
async def get_message_by_id_and_message_id(self, id: str, message_id: str) -> dict | None:
messages_map = await ChatMessages.get_messages_map_by_chat_id(id)
if messages_map and message_id in messages_map:
return messages_map[message_id]
chat = await self.get_chat_by_id(id)
if chat is None:
return None

View file

@ -300,14 +300,16 @@ class Config(Base):
)
@staticmethod
async def repair_flattened_dict_configs() -> None:
"""Reassemble dict config values flattened by the per-key migration."""
async def repair_config_rows() -> None:
"""Repair known legacy config row shapes."""
if not Config.PERSISTENT_ENABLED:
return
async with get_async_db() as db:
repaired_keys: list[str] = []
orphan_keys: list[str] = []
default_model_keys: list[str] = []
now = int(time.time())
for config_key, aliases in DICT_CONFIG_KEY_ALIASES.items():
prefixes = (config_key, *aliases)
@ -355,14 +357,26 @@ class Config(Base):
if existing:
existing.value = repaired
existing.updated_at = int(time.time())
existing.updated_at = now
else:
db.add(Config(key=config_key, value=repaired, updated_at=int(time.time())))
db.add(Config(key=config_key, value=repaired, updated_at=now))
repaired_keys.append(config_key)
if orphan_keys:
await db.execute(delete(Config).where(Config.key.in_(orphan_keys)))
if repaired_keys or orphan_keys:
for key in ('ui.default_models', 'ui.default_pinned_models'):
row = await db.get(Config, key)
if not row or not isinstance(row.value, list):
continue
row.value = ','.join(model_id for model_id in (str(item).strip() for item in row.value) if model_id)
row.updated_at = now
default_model_keys.append(key)
if repaired_keys or orphan_keys or default_model_keys:
await db.commit()
log.info('Repaired flattened dict config rows for %s', ', '.join(repaired_keys))
if repaired_keys or orphan_keys:
log.info('Repaired flattened dict config rows for %s', ', '.join(repaired_keys))
if default_model_keys:
log.info('Repaired default model config rows for %s', ', '.join(default_model_keys))

View file

@ -230,7 +230,9 @@ class FolderTable:
async with get_async_db_context(db) as db:
# Check if folder exists
result = await db.execute(
select(Folder).filter_by(parent_id=parent_id, user_id=user_id).filter(Folder.name.ilike(name))
select(Folder)
.filter_by(parent_id=parent_id, user_id=user_id)
.filter(func.lower(Folder.name) == func.lower(name))
)
folder = result.scalars().first()

View file

@ -1,6 +1,5 @@
from __future__ import annotations
import json
import logging
import time
from copy import deepcopy
@ -10,6 +9,7 @@ from open_webui.internal.db import Base, JSONField, get_async_db_context
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.users import User, UserModel, UserResponse, Users
from open_webui.utils.misc import json_text_variants
from open_webui.utils.validate import validate_profile_image_url
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from sqlalchemy import BigInteger, Boolean, Column, String, Text, cast, delete, func, or_, select, update
@ -374,20 +374,14 @@ class ModelsTable:
tag = filter.get('tag')
if tag:
# SQLite stores JSON text via json.dumps(ensure_ascii=True),
# so non-ASCII chars are \uXXXX-escaped. PostgreSQL native JSONB
# stores literal Unicode. Use the right pattern for each.
if db.bind.dialect.name == 'sqlite':
if tag.isascii():
meta_text = func.lower(cast(Model.meta, String))
pattern = f'%{json.dumps(tag.lower())}%'
else:
meta_text = cast(Model.meta, String)
pattern = f'%{json.dumps(tag)}%'
if db.bind.dialect.name == 'sqlite' and not tag.isascii():
# SQLite's LOWER() is ASCII-only, so match non-ASCII tags exact-case.
meta_text = cast(Model.meta, String)
variants = json_text_variants(tag)
else:
meta_text = func.lower(cast(Model.meta, String))
pattern = f'%{json.dumps(tag.lower(), ensure_ascii=False)}%'
stmt = stmt.filter(meta_text.like(pattern))
variants = json_text_variants(tag.lower())
stmt = stmt.filter(or_(*(meta_text.like(f'%"{variant}"%') for variant in variants)))
order_by = filter.get('order_by')
direction = filter.get('direction')
@ -605,25 +599,16 @@ class ModelsTable:
# Update or insert models
for model in models:
model_data = {
**model.model_dump(exclude={'access_grants'}),
'user_id': user_id,
'updated_at': int(time.time()),
}
if model.id in existing_ids:
await db.execute(
update(Model)
.filter_by(id=model.id)
.values(
**model.model_dump(exclude={'access_grants'}),
user_id=user_id,
updated_at=int(time.time()),
)
)
await db.execute(update(Model).filter_by(id=model.id).values(**model_data))
else:
new_model = Model(
**{
**model.model_dump(exclude={'access_grants'}),
'user_id': user_id,
'updated_at': int(time.time()),
}
)
db.add(new_model)
db.add(Model(**model_data))
await AccessGrants.set_access_grants('model', model.id, model.access_grants, db=db)
# Remove models that are no longer present

View file

@ -14,7 +14,7 @@ from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from open_webui.models.groups import Groups
from open_webui.models.prompt_history import PromptHistories
from open_webui.models.users import User, UserModel, UserResponse, Users
from open_webui.utils.json_codec import JSONCodec
from open_webui.utils.misc import json_text_variants
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import JSON, BigInteger, Boolean, Column, String, Text, cast, delete, func, or_, select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
@ -342,9 +342,10 @@ class PromptsTable:
'EXISTS (SELECT 1 FROM json_array_elements_text(prompt.tags) t WHERE LOWER(t) = :tag_val)'
)
else:
# Fallback: LIKE on serialised JSON text (ASCII-safe only)
tag_clause = func.lower(cast(Prompt.tags, String)).like(
f'%{JSONCodec.dumps(tag_lower, ensure_ascii=False)}%'
# Fallback for dialects with no JSON array function: LIKE on the text.
tags_text = func.lower(cast(Prompt.tags, String))
tag_clause = or_(
*(tags_text.like(f'%"{variant}"%') for variant in json_text_variants(tag_lower))
)
tag_lower = None

View file

@ -27,7 +27,6 @@ from sqlalchemy import (
select,
update,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
####################
@ -360,16 +359,10 @@ class UsersTable:
sub: str,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Look up a user by OAuth provider + subject claim (dialect-aware JSON filter)."""
"""Look up a user by OAuth provider + subject claim."""
async with get_async_db_context(db) as session:
dialect = session.bind.dialect.name
query = select(User)
if dialect == 'sqlite':
oauth_match = User.oauth.contains({provider: {'sub': sub}})
query = query.where(oauth_match)
elif dialect == 'postgresql':
oauth_match = User.oauth[provider].cast(JSONB)['sub'].astext == sub
query = query.where(oauth_match)
# Subscript, never contains(): on a JSON column contains() degrades to a substring LIKE.
query = select(User).where(User.oauth[provider]['sub'].as_string() == sub)
row = (await session.execute(query)).scalars().first()
return UserModel.model_validate(row) if row else None
@ -379,16 +372,10 @@ class UsersTable:
external_id: str,
db: AsyncSession | None = None,
) -> UserModel | None:
"""Look up a user by SCIM provider + external ID (dialect-aware JSON filter)."""
"""Look up a user by SCIM provider + external ID."""
async with get_async_db_context(db) as session:
dialect = session.bind.dialect.name
query = select(User)
if dialect == 'sqlite':
scim_match = User.scim.contains({provider: {'external_id': external_id}})
query = query.where(scim_match)
elif dialect == 'postgresql':
scim_match = User.scim[provider].cast(JSONB)['external_id'].astext == external_id
query = query.where(scim_match)
# Subscript, never contains(): on a JSON column contains() degrades to a substring LIKE.
query = select(User).where(User.scim[provider]['external_id'].as_string() == external_id)
row = (await session.execute(query)).scalars().first()
return UserModel.model_validate(row) if row else None

View file

@ -217,7 +217,7 @@ async def get_content_from_url(request, url: str) -> str:
def _get_content_from_url_sync(request, url: str, loader_config):
from open_webui.retrieval.web.utils import validate_url, _SSRFSafeAdapter
from open_webui.retrieval.web.utils import validate_url, get_ssrf_safe_requests_session
# Validate URL before making any request (blocks private IPs, non-HTTP, filter list)
validate_url(url)
@ -241,9 +241,7 @@ def _get_content_from_url_sync(request, url: str, loader_config):
# cloud-metadata 169.254.169.254) via a public host that redirects internally.
try:
# Probe through the connect-time SSRF guard; bare requests.get re-resolves (DNS-rebinding gap).
session = requests.Session()
session.mount('http://', _SSRFSafeAdapter())
session.mount('https://', _SSRFSafeAdapter())
session = get_ssrf_safe_requests_session()
response = session.get(url, stream=True, timeout=30, allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS)
response.raise_for_status()
content_type = response.headers.get('Content-Type', '')

View file

@ -1,4 +1,5 @@
import asyncio
import http.cookiejar
import ipaddress
import logging
import socket
@ -11,16 +12,19 @@ from typing import (
Any,
AsyncIterator,
Dict,
Iterable,
Iterator,
List,
Literal,
Optional,
Sequence,
Tuple,
Union,
)
import aiohttp
import certifi
import requests
import urllib3.connection
import urllib3.connectionpool
import validators
@ -51,6 +55,7 @@ from open_webui.constants import ERROR_MESSAGES
from open_webui.env import (
AIOHTTP_CLIENT_ALLOW_REDIRECTS,
AIOHTTP_CLIENT_SESSION_SSL,
AIOHTTP_CLIENT_SSL_CERT_FILE,
AIOHTTP_CLIENT_TIMEOUT,
USER_AGENT,
)
@ -252,19 +257,61 @@ class _SSRFSafeConnector(aiohttp.TCPConnector):
return results
def get_ssrf_safe_session() -> aiohttp.ClientSession:
def get_ssrf_safe_session(trust_env: bool = True, store_cookies: bool = True) -> aiohttp.ClientSession:
"""A one-off aiohttp session that re-validates every connection via _SSRFSafeConnector,
defeating DNS rebinding. Use for validate_url-gated fetches of user-supplied URLs that must
not use the shared (rebinding-vulnerable) pool. Use as a context manager so it is closed:
``async with get_ssrf_safe_session() as session: ...``.
trust_env also enables environment proxies, and proxied traffic bypasses the connect-time
IP check, because the proxy resolves the hostname instead.
"""
return aiohttp.ClientSession(
connector=_SSRFSafeConnector(),
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
trust_env=True,
trust_env=trust_env,
cookie_jar=None if store_cookies else aiohttp.DummyCookieJar(),
)
def get_ssrf_safe_requests_session(trust_env: bool = True, store_cookies: bool = True) -> requests.Session:
"""The requests counterpart of get_ssrf_safe_session, with the same proxy caveat."""
session = requests.Session()
session.trust_env = trust_env
if not store_cookies:
session.cookies.set_policy(http.cookiejar.DefaultCookiePolicy(allowed_domains=[]))
session.mount('http://', _SSRFSafeAdapter())
session.mount('https://', _SSRFSafeAdapter())
return session
# accept-encoding goes because the client must advertise only codecs it can decode, the rest
# because the client derives them from the URL and body it is actually given. content-encoding
# stays: the browser's body is forwarded byte for byte, so its own labelling still applies.
_DROPPED_REQUEST_HEADERS = {'accept-encoding', 'connection', 'content-length', 'host', 'transfer-encoding'}
# The clients hand us a decoded body, so the sender's framing no longer describes it.
_DROPPED_RESPONSE_HEADERS = {'connection', 'content-encoding', 'content-length', 'transfer-encoding'}
def _forwardable_request_headers(headers: Dict[str, str]) -> Dict[str, str]:
return {name: value for name, value in headers.items() if name.lower() not in _DROPPED_REQUEST_HEADERS}
def _fulfillable_response_headers(header_pairs: Iterable[Tuple[str, str]]) -> Dict[str, str]:
"""Collapse repeated headers the way route.fulfill expects: set-cookie by newline, rest by comma.
Takes pairs rather than a mapping because reading either client's headers as a mapping loses
duplicate Set-Cookie values, leaving one malformed cookie or one of the two.
"""
collected: Dict[str, List[str]] = {}
for name, value in header_pairs:
name = name.lower() # grouping by the sender's case would split a repeated header
if name not in _DROPPED_RESPONSE_HEADERS:
collected.setdefault(name, []).append(value)
return {name: ('\n' if name == 'set-cookie' else ', ').join(values) for name, values in collected.items()}
def extract_metadata(soup, url):
metadata = {'source': url}
if title := soup.find('title'):
@ -416,7 +463,6 @@ class SafeTavilyLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
def __init__(
self,
web_paths: Union[str, List[str]],
api_base_url: str,
api_key: str,
extract_depth: Literal['basic', 'advanced'] = 'basic',
continue_on_failure: bool = True,
@ -450,7 +496,6 @@ class SafeTavilyLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
# Store parameters for creating TavilyLoader instances
self.web_paths = web_paths if isinstance(web_paths, list) else [web_paths]
self.api_base_url = api_base_url
self.api_key = api_key
self.extract_depth = extract_depth
self.continue_on_failure = continue_on_failure
@ -597,7 +642,9 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
requests_per_second (Optional[float]): Number of requests per second to limit to.
continue_on_failure (bool): If True, continue loading other URLs on failure.
headless (bool): If True, the browser will run in headless mode.
proxy (dict): Proxy override settings for the Playwright session.
proxy (dict): Proxy override settings for the Playwright session. Page requests are
issued outside the browser, so they follow the environment proxy via trust_env
rather than this setting.
playwright_ws_url (Optional[str]): WebSocket endpoint URI for remote browser connection.
playwright_timeout (Optional[int]): Maximum operation time in milliseconds.
"""
@ -642,14 +689,49 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
self.trust_env = trust_env
self.playwright_timeout = playwright_timeout
def _intercept_navigation_sync(self, route, request=None):
req = request or route.request
def _request_timeout(self) -> float:
# per-hop budget, since page.goto's timeout cannot reach into our own fetch and 0 disables
# it. aiohttp treats it as a total where requests only caps each read, so sync runs looser.
return (self.playwright_timeout or 30000) / 1000
def _requests_verify(self) -> Union[bool, str]:
"""requests takes a CA path where aiohttp takes the parsed SSLContext.
A bundle named directly in AIOHTTP_CLIENT_SESSION_SSL reaches us already parsed and
cannot be expressed here, so that form falls back to the global bundle or certifi.
"""
if not self.verify_ssl or AIOHTTP_CLIENT_SESSION_SSL is False:
return False
if AIOHTTP_CLIENT_SESSION_SSL is True:
return True # no usable global CA bundle, so both clients land on certifi
return AIOHTTP_CLIENT_SSL_CERT_FILE or True
def _intercept_navigation_sync(self, route, session):
req = route.request
hop_cookies: List[Tuple[str, str]] = []
try:
validate_url(req.url)
resp = route.fetch(max_redirects=0)
headers = _forwardable_request_headers(req.all_headers())
post_data = req.post_data_buffer
verify, timeout = self._requests_verify(), self._request_timeout()
if 300 <= resp.status < 400:
# The browser would resolve the hostname again, after the check; fetch it ourselves.
def fetch(url):
validate_url(url)
return session.request(
req.method,
url,
headers=headers,
data=post_data,
allow_redirects=False,
verify=verify,
timeout=timeout,
)
resp = fetch(req.url)
if 300 <= resp.status_code < 400:
for _ in range(20):
if not AIOHTTP_CLIENT_ALLOW_REDIRECTS:
route.abort()
@ -659,26 +741,50 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
if not location:
break
url = urllib.parse.urljoin(resp.url, location)
validate_url(url)
resp = route.fetch(url=url, max_redirects=0)
if not 300 <= resp.status < 400:
# only the last hop is fulfilled, so carry each hop's cookies to the browser
hop_cookies += [('set-cookie', v) for v in resp.raw.headers.getlist('set-cookie')]
resp = fetch(urllib.parse.urljoin(resp.url, location))
if not 300 <= resp.status_code < 400:
break
else:
route.abort()
return
except Exception:
except Exception as e:
log.debug('Playwright loader could not fetch %s: %s', req.url, e)
route.abort()
return
route.fulfill(response=resp)
route.fulfill(
status=resp.status_code,
headers=_fulfillable_response_headers(hop_cookies + list(resp.raw.headers.items())),
body=resp.content,
)
async def _intercept_navigation(self, route, request=None):
req = request or route.request
async def _intercept_navigation(self, route, session):
req = route.request
hop_cookies: List[Tuple[str, str]] = []
try:
await run_in_threadpool(validate_url, req.url)
resp = await route.fetch(max_redirects=0)
headers = _forwardable_request_headers(await req.all_headers())
post_data = req.post_data_buffer
# The browser would resolve the hostname again, after the check; fetch it ourselves.
async def fetch(url):
await run_in_threadpool(validate_url, url)
response = await session.request(
req.method,
url,
headers=headers,
data=post_data,
allow_redirects=False,
ssl=AIOHTTP_CLIENT_SESSION_SSL if self.verify_ssl else False,
timeout=aiohttp.ClientTimeout(total=self._request_timeout()),
)
# aiohttp only returns the connection to the pool once the body is buffered
return response, await response.read()
resp, body = await fetch(req.url)
if 300 <= resp.status < 400:
for _ in range(20):
@ -690,19 +796,24 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
if not location:
break
url = urllib.parse.urljoin(resp.url, location)
await run_in_threadpool(validate_url, url)
resp = await route.fetch(url=url, max_redirects=0)
# only the last hop is fulfilled, so carry each hop's cookies to the browser
hop_cookies += [('set-cookie', v) for v in resp.headers.getall('Set-Cookie', [])]
resp, body = await fetch(urllib.parse.urljoin(str(resp.url), location))
if not 300 <= resp.status < 400:
break
else:
await route.abort()
return
except Exception:
except Exception as e:
log.debug('Playwright loader could not fetch %s: %s', req.url, e)
await route.abort()
return
await route.fulfill(response=resp)
await route.fulfill(
status=resp.status,
headers=_fulfillable_response_headers(hop_cookies + list(resp.headers.items())),
body=body,
)
def lazy_load(self) -> Iterator[Document]:
"""Safely load URLs synchronously with support for remote browser."""
@ -719,8 +830,12 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
for url in self.urls:
try:
self._safe_process_url_sync(url)
with browser.new_page(service_workers='block') as page:
page.route('**/*', self._intercept_navigation_sync)
# opened before the page so it outlives any route still in flight at teardown
with (
get_ssrf_safe_requests_session(self.trust_env, store_cookies=False) as session,
browser.new_page(service_workers='block') as page,
):
page.route('**/*', lambda route: self._intercept_navigation_sync(route, session))
page.route_web_socket('**/*', lambda ws_route: ws_route.close())
response = page.goto(url, timeout=self.playwright_timeout)
if response is None:
@ -750,8 +865,12 @@ class SafePlaywrightURLLoader(PlaywrightURLLoader, RateLimitMixin, URLProcessing
for url in self.urls:
try:
await self._safe_process_url(url)
async with await browser.new_page(service_workers='block') as page:
await page.route('**/*', self._intercept_navigation)
# opened before the page so it outlives any route still in flight at teardown
async with (
get_ssrf_safe_session(self.trust_env, store_cookies=False) as session,
await browser.new_page(service_workers='block') as page,
):
await page.route('**/*', lambda route: self._intercept_navigation(route, session))
await page.route_web_socket('**/*', lambda ws_route: ws_route.close())
response = await page.goto(url, timeout=self.playwright_timeout)
if response is None:

View file

@ -1705,4 +1705,20 @@ async def token_exchange(
detail='User not found. Please sign in via the web interface first.',
)
user = await oauth_manager.update_user_role_from_oauth(
request=request,
user=user,
user_data=user_data,
provider=provider,
db=db,
)
if await Config.get('oauth.enable_group_mapping'):
await oauth_manager.update_user_groups(
request=request,
user=user,
user_data=user_data,
default_permissions=await Config.get('user.permissions'),
db=db,
)
return await create_session_response(request, user, db, source='oauth')

View file

@ -15,6 +15,8 @@ from open_webui.models.automations import (
AutomationRuns,
Automations,
)
from open_webui.models.access_grants import AccessGrants, has_public_write_access_grant
from open_webui.models.channels import Channels
from open_webui.models.config import Config
from open_webui.models.folders import Folders
from open_webui.utils.access_control import has_permission
@ -104,6 +106,44 @@ async def check_automation_folder_access(folder_id: Optional[str], user, db: Asy
)
async def check_automation_channel_access(form_data: AutomationForm, user, db: AsyncSession):
target = form_data.data.target
if not target or target.type != 'channel':
return
if not target.channel_id or not await Config.get('channels.enable'):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
channel = await Channels.get_channel_by_id(target.channel_id, db=db)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=ERROR_MESSAGES.NOT_FOUND,
)
if user.role == 'admin':
return
if not await has_permission(user.id, 'features.channels', await Config.get('user.permissions')):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.DEFAULT(),
)
if channel.type in ['group', 'dm']:
allowed = await Channels.is_user_channel_member(channel.id, user.id, db=db)
else:
allowed = has_public_write_access_grant(channel.access_grants) or await AccessGrants.has_access(
user_id=user.id, resource_type='channel', resource_id=channel.id, permission='write', db=db
)
if not allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=ERROR_MESSAGES.DEFAULT(),
)
async def enrich_automation(automation: AutomationModel, db: AsyncSession, tz: str = None) -> AutomationResponse:
"""Full enrichment for single-item views (includes next_runs computation)."""
last_run = await AutomationRuns.get_latest(automation.id, db=db)
@ -174,6 +214,7 @@ async def create_new_automation(
):
await check_automations_permission(request, user)
await check_automation_folder_access(form_data.folder_id, user, db)
await check_automation_channel_access(form_data, user, db)
try:
validate_rrule(form_data.data.rrule, tz=user.timezone)
except ValueError as e:
@ -232,6 +273,7 @@ async def update_automation_by_id(
automation = await Automations.get_by_id(id, db=db)
check_automation_access(automation, user)
await check_automation_folder_access(form_data.folder_id, user, db)
await check_automation_channel_access(form_data, user, db)
try:
validate_rrule(form_data.data.rrule, tz=user.timezone)

View file

@ -1075,16 +1075,10 @@ async def model_response_handler(request, channel, message, user, db=None):
],
]
# Resolve model config (same helpers automations use)
from open_webui.utils.automations import (
_resolve_model_features,
_resolve_model_filter_ids,
_resolve_model_tool_ids,
)
# Resolve model config (same path automations use)
from open_webui.utils.automations import _resolve_model_defaults
tool_ids = _resolve_model_tool_ids(request.app, model_id)
features = await _resolve_model_features(request.app, model_id)
filter_ids = _resolve_model_filter_ids(request.app, model_id)
tool_ids, features, filter_ids, _ = await _resolve_model_defaults(request.app, model_id)
# Build full form_data — same shape as frontend POST.
# The channel: prefix routes pipeline events to the

View file

@ -1,8 +1,6 @@
from __future__ import annotations
import asyncio
import logging
from typing import Optional
from uuid import uuid4
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response, status
@ -36,7 +34,7 @@ from open_webui.models.folders import Folders
from open_webui.models.shared_chats import SharedChatResponse, SharedChats
from open_webui.models.tags import TagModel, Tags
from open_webui.socket.main import get_event_emitter
from open_webui.tasks import has_active_tasks, stop_item_tasks
from open_webui.tasks import get_response_streams_by_chat_id, has_active_tasks, stop_item_tasks
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control.folders import has_folder_access, has_folder_write_access
from open_webui.utils.auth import bearer_security, get_admin_user, get_current_user, get_verified_user
@ -58,9 +56,36 @@ CHAT_CONFIG_KEYS = {
'CONTEXT_COMPACTION_TOKEN_CAP': 'chat.context_compaction.token_cap',
'CONTEXT_COMPACTION_RETENTION_PERCENTAGE': 'chat.context_compaction.retention_percentage',
'CONTEXT_COMPACTION_PROMPT_TEMPLATE': 'chat.context_compaction.prompt_template',
'ENABLE_TOOL_PERMISSIONS': 'chat.tool_permissions.enable',
}
def overlay_response_streams(chat_data: dict, response_streams: list[dict]) -> dict:
if not response_streams:
return chat_data
messages = chat_data.get('chat', {}).get('history', {}).get('messages')
if isinstance(messages, dict):
for stream in response_streams:
message_id = stream.get('message_id')
message = messages.get(message_id)
if isinstance(message, dict):
message['content'] = stream.get('content', '')
message['output'] = stream.get('output') or []
message['done'] = False
legacy_messages = chat_data.get('chat', {}).get('messages')
if isinstance(legacy_messages, list):
streams_by_message_id = {stream.get('message_id'): stream for stream in response_streams}
for message in legacy_messages:
if isinstance(message, dict) and (stream := streams_by_message_id.get(message.get('id'))):
message['content'] = stream.get('content', '')
message['output'] = stream.get('output') or []
message['done'] = False
return chat_data
async def get_optional_verified_user(
request: Request,
response: Response,
@ -140,6 +165,7 @@ class ChatConfigForm(BaseModel):
CONTEXT_COMPACTION_TOKEN_CAP: int | None = None
CONTEXT_COMPACTION_RETENTION_PERCENTAGE: int = 40
CONTEXT_COMPACTION_PROMPT_TEMPLATE: str
ENABLE_TOOL_PERMISSIONS: bool = False
class CompactChatForm(BaseModel):
@ -1297,7 +1323,12 @@ async def compact_chat_by_id(
@router.get('/{id}', response_model=ChatResponse | None)
async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
async def get_chat_by_id(
id: str,
request: Request,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
if not chat and user.role == 'admin':
@ -1328,6 +1359,10 @@ async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSess
if chat:
data = ChatResponse.model_validate(chat, from_attributes=True).model_dump()
data = overlay_response_streams(
data,
await get_response_streams_by_chat_id(request.app.state.redis, id),
)
data['context_usage'] = await get_chat_context_usage(chat)
return data

View file

@ -613,9 +613,12 @@ async def get_file_process_status(
id: str,
stream: bool = Query(False),
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
file = await Files.get_file_by_id(id, db=db)
# NOTE: We intentionally do NOT use Depends(get_async_session) here.
# Database operations manage their own short-lived sessions internally.
# Holding a session here would keep a connection for the entire stream
# (up to two hours) and exhaust the connection pool under concurrent load.
file = await Files.get_file_by_id(id)
if not file:
raise HTTPException(
@ -623,16 +626,13 @@ async def get_file_process_status(
detail=ERROR_MESSAGES.NOT_FOUND,
)
if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user, db=db):
if file.user_id == user.id or user.role == 'admin' or await has_access_to_file(id, 'read', user):
if stream:
MAX_FILE_PROCESSING_DURATION = 3600 * 2
async def event_stream(file_id):
# NOTE: We intentionally do NOT capture the request's db session here.
# Each poll creates its own short-lived session to avoid holding a
# connection for hours. A WebSocket push would be more efficient.
for _ in range(MAX_FILE_PROCESSING_DURATION):
file_item = await Files.get_file_by_id(file_id) # Creates own session
file_item = await Files.get_file_by_id(file_id)
if file_item:
data = file_item.model_dump().get('data', {})
status = data.get('status')

View file

@ -438,6 +438,10 @@ async def reindex_knowledge_base_metadata_embeddings(
"""
knowledge_bases = await Knowledges.get_knowledge_bases()
log.info('Reindexing embeddings for %s knowledge bases', len(knowledge_bases))
try:
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name=KNOWLEDGE_BASES_COLLECTION)
except Exception as e:
log.debug(e)
success_count = 0
for kb in knowledge_bases:
@ -641,10 +645,7 @@ async def _count_external_connection_mappings(connection_id: str, db: Optional[A
@router.get('/external/connections', response_model=ExternalKnowledgeConnectionListResponse)
async def get_external_knowledge_connections(
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
async def get_external_knowledge_connections(user=Depends(get_admin_user)):
connections = [_sanitize_external_connection(connection) for connection in await _get_external_connections()]
return ExternalKnowledgeConnectionListResponse(items=connections, total=len(connections))
@ -674,7 +675,6 @@ async def create_external_knowledge_connection(
async def get_external_knowledge_connection(
id: str,
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
connection = await _get_external_connection(id)
if not connection:
@ -741,7 +741,6 @@ async def delete_external_knowledge_connection(
async def test_external_knowledge_connection(
id: str,
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
connection = await _get_external_connection(id)
if not connection:
@ -839,7 +838,6 @@ async def test_external_knowledge_retrieval(
id: str,
form_data: ExternalKnowledgeRetrieveTestForm,
user=Depends(get_admin_user),
db: AsyncSession = Depends(get_async_session),
):
connection = await _get_external_connection(id)
if not connection:
@ -1244,7 +1242,6 @@ async def get_pending_knowledge_files(
id: str,
stream: bool = Query(False),
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
"""Return files that are being processed for this knowledge base but not yet linked.
@ -1257,7 +1254,11 @@ async def get_pending_knowledge_files(
When ``stream=true``, returns an SSE stream that polls every 3 seconds
and emits the current pending file list. Closes when no files remain.
"""
knowledge = await Knowledges.get_knowledge_by_id(id=id, db=db)
# NOTE: We intentionally do NOT use Depends(get_async_session) here.
# Database operations manage their own short-lived sessions internally.
# Holding a session here would keep a connection for the entire stream
# (up to an hour) and exhaust the connection pool under concurrent load.
knowledge = await Knowledges.get_knowledge_by_id(id=id)
if not knowledge:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@ -1272,7 +1273,6 @@ async def get_pending_knowledge_files(
resource_type='knowledge',
resource_id=knowledge.id,
permission='read',
db=db,
)
):
raise HTTPException(
@ -1281,7 +1281,7 @@ async def get_pending_knowledge_files(
)
if not stream:
return await Files.get_pending_files_for_knowledge(id, db=db)
return await Files.get_pending_files_for_knowledge(id)
async def event_stream(knowledge_id: str):
MAX_POLL_DURATION = 3600 # 1 hour max

View file

@ -11,9 +11,10 @@ from open_webui.events import EVENTS, publish_event
from open_webui.internal.db import get_async_session
from open_webui.models.config import Config
from open_webui.models.memories import Memories, MemoryModel
from open_webui.models.users import Users
from open_webui.retrieval.vector.async_client import ASYNC_VECTOR_DB_CLIENT
from open_webui.utils.access_control import has_permission
from open_webui.utils.auth import get_verified_user
from open_webui.utils.auth import get_admin_user, get_verified_user
from open_webui.utils.memory import (
clean_memory_content,
clean_memory_path,
@ -124,6 +125,61 @@ def _memory_metadata(memory: MemoryModel) -> dict:
}
async def reindex_memory_vectors_for_user(
request: Request,
user_id: str,
memories: list[MemoryModel] | None = None,
user=None,
) -> int:
collection_name = f'user-memory-{user_id}'
try:
await ASYNC_VECTOR_DB_CLIENT.delete_collection(collection_name)
except Exception as e:
log.debug(e)
memories = memories if memories is not None else await Memories.get_memories_by_user_id(user_id)
memories = memories or []
if not memories:
return 0
vectors = await asyncio.gather(
*[
request.app.state.EMBEDDING_FUNCTION(
memory_vector_text(memory.content, memory.path),
prefix=RAG_EMBEDDING_CONTENT_PREFIX,
user=user,
)
for memory in memories
]
)
await ASYNC_VECTOR_DB_CLIENT.upsert(
collection_name=collection_name,
items=[
{
'id': memory.id,
'text': memory_vector_text(memory.content, memory.path),
'vector': vectors[idx],
'metadata': _memory_metadata(memory),
}
for idx, memory in enumerate(memories)
],
)
return len(memories)
async def upsert_memory_vectors_or_reindex(request: Request, user, items: list[dict]) -> None:
try:
await ASYNC_VECTOR_DB_CLIENT.upsert(collection_name=f'user-memory-{user.id}', items=items)
except Exception as e:
message = str(e).lower()
if 'dimension' not in message or 'embedding' not in message:
raise
log.warning('Memory vector dimension mismatch for user %s; reindexing memory vectors.', user.id)
await reindex_memory_vectors_for_user(request, user.id, user=user)
@router.post('/add', response_model=MemoryModel | None)
async def add_memory(
request: Request,
@ -152,9 +208,10 @@ async def add_memory(
memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user
)
await ASYNC_VECTOR_DB_CLIENT.upsert(
collection_name=f'user-memory-{user.id}',
items=[
await upsert_memory_vectors_or_reindex(
request,
user,
[
{
'id': memory.id,
'text': memory_vector_text(memory.content, memory.path),
@ -226,7 +283,7 @@ async def update_memories(
response.append(result)
if upsert_items:
await ASYNC_VECTOR_DB_CLIENT.upsert(collection_name=f'user-memory-{user.id}', items=upsert_items)
await upsert_memory_vectors_or_reindex(request, user, upsert_items)
if delete_ids:
await ASYNC_VECTOR_DB_CLIENT.delete(collection_name=f'user-memory-{user.id}', ids=delete_ids)
@ -386,8 +443,42 @@ async def read_memory_path(
############################
# ResetMemoryFromVectorDB
# ReindexMemoryVectorDB
############################
@router.post('/reindex')
async def reindex_memories_from_vector_db(
request: Request,
user=Depends(get_admin_user),
):
memories = await Memories.get_memories()
memories = memories or []
memories_by_user_id = {}
for memory in memories:
memories_by_user_id.setdefault(memory.user_id, []).append(memory)
users_result = await Users.get_users()
users = users_result.get('users', []) if users_result else []
total_memories = 0
for memory_user in users:
total_memories += await reindex_memory_vectors_for_user(
request,
memory_user.id,
memories=memories_by_user_id.get(memory_user.id, []),
user=memory_user,
)
await publish_event(
request,
EVENTS.MEMORY_RESET,
actor=user,
subject_id='all',
subject_type='user',
data={'count': total_memories, 'user_count': len(users), 'reindex': True},
)
return {'status': True, 'total_users': len(users), 'total_memories': total_memories}
@router.post('/reset', response_model=bool)
async def reset_memory_from_vector_db(
request: Request,
@ -403,32 +494,7 @@ async def reset_memory_from_vector_db(
"""
await check_memories_permission(user)
await ASYNC_VECTOR_DB_CLIENT.delete_collection(f'user-memory-{user.id}')
memories = await Memories.get_memories_by_user_id(user.id)
# Generate vectors in parallel
vectors = await asyncio.gather(
*[
request.app.state.EMBEDDING_FUNCTION(
memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user
)
for memory in memories
]
)
await ASYNC_VECTOR_DB_CLIENT.upsert(
collection_name=f'user-memory-{user.id}',
items=[
{
'id': memory.id,
'text': memory_vector_text(memory.content, memory.path),
'vector': vectors[idx],
'metadata': _memory_metadata(memory),
}
for idx, memory in enumerate(memories)
],
)
count = await reindex_memory_vectors_for_user(request, user.id, user=user)
await publish_event(
request,
@ -436,7 +502,7 @@ async def reset_memory_from_vector_db(
actor=user,
subject_id=user.id,
subject_type='user',
data={'count': len(memories)},
data={'count': count, 'reindex': True},
)
return True
@ -512,9 +578,10 @@ async def update_memory_by_id(
memory_vector_text(memory.content, memory.path), prefix=RAG_EMBEDDING_CONTENT_PREFIX, user=user
)
await ASYNC_VECTOR_DB_CLIENT.upsert(
collection_name=f'user-memory-{user.id}',
items=[
await upsert_memory_vectors_or_reindex(
request,
user,
[
{
'id': memory.id,
'text': memory_vector_text(memory.content, memory.path),

View file

@ -471,7 +471,7 @@ async def get_filtered_models(models, user, db=None):
@router.get('/api/tags')
@router.get('/api/tags/{url_idx}')
@router.get('/api/tags/{url_idx}', dependencies=[Depends(get_admin_user)])
async def get_ollama_tags(
request: Request,
url_idx: int | None = None,
@ -534,7 +534,7 @@ async def get_ollama_loaded_models(
@router.get('/api/version')
@router.get('/api/version/{url_idx}')
@router.get('/api/version/{url_idx}', dependencies=[Depends(get_admin_user)])
async def get_ollama_versions(
request: Request,
user=Depends(get_verified_user),
@ -1053,7 +1053,7 @@ class GenerateChatCompletionForm(BaseModel):
async def validate_ollama_backend_idx(request: Request, model: str, url_idx: int | None, user) -> None:
# A caller-supplied url_idx must point to a backend the model is actually
# served from; the None path is already constrained to that allow-list.
if url_idx is None or user is None or getattr(user, 'role', None) == 'admin' or BYPASS_MODEL_ACCESS_CONTROL:
if url_idx is None or user is None or getattr(user, 'role', None) == 'admin':
return
models = request.app.state.OLLAMA_MODELS
if not models or model not in models:
@ -1472,7 +1472,7 @@ async def generate_responses(
@router.get('/v1/models')
@router.get('/v1/models/{url_idx}')
@router.get('/v1/models/{url_idx}', dependencies=[Depends(get_admin_user)])
async def get_openai_models(
request: Request,
url_idx: int | None = None,

View file

@ -246,9 +246,48 @@ router = APIRouter()
LLAMACPP_LOADED_STATES = {'loaded', 'sleeping'}
LLAMACPP_UNLOADED_STATES = {'loading', 'unloaded'}
MODEL_MANAGEMENT_ENDPOINTS = {
'llama.cpp': {
'list': '/models',
'download': '/models',
'delete': '/models',
'load': '/models/load',
'unload': '/models/unload',
'sse': '/models/sse',
},
'lmstudio': {
'list': '/api/v1/models',
'download': '/api/v1/models/download',
'download_status': '/api/v1/models/download/status/{job_id}',
'load': '/api/v1/models/load',
'unload': '/api/v1/models/unload',
},
}
def get_llamacpp_model_loaded_state(model: dict, provider: str, manual_model_ids: bool = False) -> bool | None:
def get_model_management_root_url(url: str, provider: str) -> str:
root_url = url.rstrip('/')
if provider in ('llama.cpp', 'lmstudio'):
for suffix in ('/api/v1', '/api/v0', '/v1'):
if root_url.endswith(suffix):
return root_url.removesuffix(suffix)
return root_url
def get_provider_model_loaded_state(model: dict, provider: str, manual_model_ids: bool = False) -> bool | None:
if provider == 'lmstudio':
if model.get('loaded_instances'):
return True
state = model.get('state')
if state == 'loaded':
return True
if state == 'not-loaded':
return False
return None
if provider != 'llama.cpp':
return None
@ -307,6 +346,111 @@ async def get_openai_connection(idx: int) -> tuple[str, str, dict]:
return url, key, api_config
async def clear_openai_model_cache(request: Request):
await get_all_models.cache.clear()
request.app.state.BASE_MODELS = []
request.app.state.OPENAI_MODELS = {}
models = getattr(request.app.state, 'MODELS', None)
if hasattr(models, 'clear'):
models.clear()
else:
request.app.state.MODELS = {}
async def get_model_management_connection(url_idx: int) -> tuple[str, str, dict, str]:
if not await Config.get('openai.enable'):
raise HTTPException(status_code=503, detail='OpenAI API is disabled')
try:
url, key, api_config = await get_openai_connection(url_idx)
except IndexError:
raise HTTPException(status_code=404, detail='Connection not found')
provider = api_config.get('provider', '')
if provider not in MODEL_MANAGEMENT_ENDPOINTS:
raise HTTPException(
status_code=400,
detail=f'Provider "{provider or "default"}" does not support model management',
)
return get_model_management_root_url(url, provider), key, api_config, provider
def get_model_management_path(provider: str, operation: str, path_params: dict | None = None) -> str:
try:
path = MODEL_MANAGEMENT_ENDPOINTS[provider][operation]
except KeyError:
raise HTTPException(status_code=400, detail=f'Provider "{provider}" does not support {operation}')
return path.format(**(path_params or {}))
def get_model_management_payload(provider: str, operation: str, payload: dict | None) -> dict | None:
if provider == 'lmstudio' and operation == 'unload' and payload:
return {'instance_id': payload.get('instance_id') or payload.get('model')}
return payload
async def send_model_management_request(
request: Request,
url_idx: int,
operation: str,
method: str = 'GET',
payload: dict | None = None,
query: dict | None = None,
path_params: dict | None = None,
stream: bool = False,
user: UserModel | None = None,
):
root_url, key, api_config, provider = await get_model_management_connection(url_idx)
path = get_model_management_path(provider, operation, path_params=path_params)
payload = get_model_management_payload(provider, operation, payload)
headers, cookies = await get_headers_and_cookies(request, root_url, key, api_config, user=user)
response = None
streaming = False
try:
session = await get_session()
response = await session.request(
method,
f'{root_url}{path}',
json=payload,
params=query,
headers=headers,
cookies=cookies,
ssl=AIOHTTP_CLIENT_SESSION_SSL,
timeout=get_client_timeout(stream=stream),
)
if not response.ok:
try:
error = await response.json(loads=JSONCodec.loads)
except Exception:
error = await response.text()
raise HTTPException(status_code=response.status, detail=error)
if stream:
streaming = True
return StreamingResponse(
stream_wrapper(response, passthrough=True),
status_code=response.status,
headers=_clean_proxy_headers(response.headers),
)
try:
return await response.json(loads=JSONCodec.loads)
except Exception:
return {'success': True}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=response.status if response else 500, detail=str(e))
finally:
if not streaming:
await cleanup_response(response)
async def get_anthropic_token_count_target(request: Request, form_data: dict, user: UserModel):
"""Resolve the upstream LiteLLM connection for an Anthropic token-count request."""
requested_model = form_data.get('model')
@ -697,7 +841,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
'urlIdx': idx,
}
loaded = get_llamacpp_model_loaded_state(
loaded = get_provider_model_loaded_state(
model,
provider,
manual_model_ids=bool(api_config.get('model_ids')),
@ -717,7 +861,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
@router.get('/models')
@router.get('/models/{url_idx}')
@router.get('/models/{url_idx}', dependencies=[Depends(get_admin_user)])
async def get_models(request: Request, url_idx: int | None = None, user=Depends(get_verified_user)):
if not await Config.get('openai.enable'):
raise HTTPException(status_code=503, detail='OpenAI API is disabled')
@ -793,6 +937,121 @@ async def get_models(request: Request, url_idx: int | None = None, user=Depends(
return models
class ProviderModelOperationForm(BaseModel):
model: str
model_config = ConfigDict(extra='allow')
@router.get('/models/{url_idx}/catalog')
async def get_provider_model_catalog(request: Request, url_idx: int, user=Depends(get_admin_user)):
return await send_model_management_request(request, url_idx, 'list', user=user)
@router.post('/models/{url_idx}/download')
async def download_provider_model(
request: Request,
url_idx: int,
form_data: ProviderModelOperationForm,
user=Depends(get_admin_user),
):
root_url, _, api_config, provider = await get_model_management_connection(url_idx)
payload = form_data.model_dump(exclude_none=True)
payload['model'] = strip_provider_model_prefix(payload['model'], api_config.get('prefix_id'))
result = await send_model_management_request(request, url_idx, 'download', 'POST', payload, user=user)
await clear_openai_model_cache(request)
await publish_event(
request,
EVENTS.MODEL_PROVIDER_MODEL_CREATED,
actor=user,
subject_id=payload['model'],
data={'provider': provider, 'url_idx': url_idx, 'base_url': root_url},
)
return result
@router.get('/models/{url_idx}/download/status/{job_id}')
async def get_provider_model_download_status(
request: Request,
url_idx: int,
job_id: str,
user=Depends(get_admin_user),
):
return await send_model_management_request(
request,
url_idx,
'download_status',
path_params={'job_id': job_id},
user=user,
)
@router.post('/models/{url_idx}/load')
async def load_provider_model(
request: Request,
url_idx: int,
form_data: ProviderModelOperationForm,
user=Depends(get_admin_user),
):
_, _, api_config, _ = await get_model_management_connection(url_idx)
payload = form_data.model_dump(exclude_none=True)
payload['model'] = strip_provider_model_prefix(payload['model'], api_config.get('prefix_id'))
result = await send_model_management_request(request, url_idx, 'load', 'POST', payload, user=user)
await clear_openai_model_cache(request)
return result
@router.post('/models/{url_idx}/unload')
async def unload_provider_model(
request: Request,
url_idx: int,
form_data: ProviderModelOperationForm,
user=Depends(get_admin_user),
):
_, _, api_config, _ = await get_model_management_connection(url_idx)
payload = form_data.model_dump(exclude_none=True)
payload['model'] = strip_provider_model_prefix(payload['model'], api_config.get('prefix_id'))
result = await send_model_management_request(request, url_idx, 'unload', 'POST', payload, user=user)
await clear_openai_model_cache(request)
return result
@router.get('/models/{url_idx}/sse')
async def stream_provider_model_events(request: Request, url_idx: int, user=Depends(get_admin_user)):
return await send_model_management_request(request, url_idx, 'sse', stream=True, user=user)
@router.delete('/models/{url_idx}')
async def delete_provider_model(
request: Request,
url_idx: int,
model: str,
user=Depends(get_admin_user),
):
root_url, _, api_config, provider = await get_model_management_connection(url_idx)
actual_model = strip_provider_model_prefix(model, api_config.get('prefix_id'))
result = await send_model_management_request(
request,
url_idx,
'delete',
'DELETE',
query={'model': actual_model},
user=user,
)
await clear_openai_model_cache(request)
await publish_event(
request,
EVENTS.MODEL_PROVIDER_MODEL_DELETED,
actor=user,
subject_id=actual_model,
data={'provider': provider, 'url_idx': url_idx, 'base_url': root_url},
)
return result
class ConnectionVerificationForm(BaseModel):
url: str
key: str
@ -1588,8 +1847,6 @@ async def responses(
# Enforce per-model access control
await check_model_access(user, await Models.get_model_by_id(model_id), BYPASS_MODEL_ACCESS_CONTROL)
body = JSONCodec.dumps(payload)
if model_id:
models = request.app.state.OPENAI_MODELS
if not models or model_id not in models:
@ -1600,6 +1857,9 @@ async def responses(
url, key, api_config = await get_openai_connection(idx)
payload['model'] = strip_provider_model_prefix(payload['model'], api_config.get('prefix_id'))
body = JSONCodec.dumps(payload)
r = None
streaming = False

View file

@ -490,19 +490,19 @@ async def get_embedding_config(request: Request, user=Depends(get_admin_user)):
class OpenAIConfigForm(BaseModel):
url: str
key: str
url: str | None = None
key: str | None = None
class OllamaConfigForm(BaseModel):
url: str
key: str
url: str | None = None
key: str | None = None
class AzureOpenAIConfigForm(BaseModel):
url: str
key: str
version: str
url: str | None = None
key: str | None = None
version: str | None = None
class EmbeddingModelUpdateForm(BaseModel):
@ -544,23 +544,18 @@ async def update_embedding_config(request: Request, form_data: EmbeddingModelUpd
config.ENABLE_ASYNC_EMBEDDING = form_data.ENABLE_ASYNC_EMBEDDING
config.RAG_EMBEDDING_CONCURRENT_REQUESTS = form_data.RAG_EMBEDDING_CONCURRENT_REQUESTS
if config.RAG_EMBEDDING_ENGINE in [
'ollama',
'openai',
'azure_openai',
]:
if form_data.openai_config is not None:
config.RAG_OPENAI_API_BASE_URL = form_data.openai_config.url
config.RAG_OPENAI_API_KEY = form_data.openai_config.key
if config.RAG_EMBEDDING_ENGINE == 'openai' and form_data.openai_config is not None:
config.RAG_OPENAI_API_BASE_URL = form_data.openai_config.url or ''
config.RAG_OPENAI_API_KEY = form_data.openai_config.key or ''
if form_data.ollama_config is not None:
config.RAG_OLLAMA_BASE_URL = form_data.ollama_config.url
config.RAG_OLLAMA_API_KEY = form_data.ollama_config.key
if config.RAG_EMBEDDING_ENGINE == 'ollama' and form_data.ollama_config is not None:
config.RAG_OLLAMA_BASE_URL = form_data.ollama_config.url or ''
config.RAG_OLLAMA_API_KEY = form_data.ollama_config.key or ''
if form_data.azure_openai_config is not None:
config.RAG_AZURE_OPENAI_BASE_URL = form_data.azure_openai_config.url
config.RAG_AZURE_OPENAI_API_KEY = form_data.azure_openai_config.key
config.RAG_AZURE_OPENAI_API_VERSION = form_data.azure_openai_config.version
if config.RAG_EMBEDDING_ENGINE == 'azure_openai' and form_data.azure_openai_config is not None:
config.RAG_AZURE_OPENAI_BASE_URL = form_data.azure_openai_config.url or ''
config.RAG_AZURE_OPENAI_API_KEY = form_data.azure_openai_config.key or ''
config.RAG_AZURE_OPENAI_API_VERSION = form_data.azure_openai_config.version or ''
request.app.state.ef = get_ef(
config.RAG_EMBEDDING_ENGINE,
@ -1998,7 +1993,7 @@ async def process_file(
hash = calculate_sha256_string(text_content)
if config.BYPASS_EMBEDDING_AND_RETRIEVAL:
await Files.update_file_data_by_id(file.id, {'status': 'completed'}, db=db)
await Files.update_file_data_by_id(file.id, {'status': 'completed', 'error': None}, db=db)
await Files.update_file_hash_by_id(file.id, hash, db=db)
await publish_event(
request,
@ -2057,7 +2052,7 @@ async def process_file(
await Files.update_file_data_by_id(
file.id,
{'status': 'completed'},
{'status': 'completed', 'error': None},
db=session,
)
await Files.update_file_hash_by_id(file.id, hash, db=session)
@ -2087,12 +2082,25 @@ async def process_file(
async with get_async_db() as session:
await Files.update_file_data_by_id(
file.id,
{'status': 'failed'},
{'status': 'failed', 'error': str(e)},
db=session,
)
# Clear the hash so the file can be re-uploaded after fixing the issue
await Files.update_file_hash_by_id(file.id, None, db=session)
await publish_event(
request,
EVENTS.RETRIEVAL_CONTENT_PROCESS_FAILED,
actor=user,
subject_id=file.id,
subject_type='file',
data={
'collection_name': collection_name,
'filename': file.filename,
'message': f'{file.filename}: {e}',
},
)
if 'No pandoc was found' in str(e):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@ -2397,6 +2405,7 @@ async def process_web(
'status': True,
'collection_name': collection_name,
'filename': form_data.url,
'content': content,
'file': {
'data': {
'content': content,

View file

@ -1,4 +1,5 @@
import logging
import re
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
@ -38,6 +39,7 @@ router = APIRouter()
@router.get('/', response_model=list[SkillUserResponse])
async def get_skills(
request: Request,
query: Optional[str] = None,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
@ -60,6 +62,10 @@ async def get_skills(
)
]
if query:
q = query.casefold()
skills = [skill for skill in skills if q in (skill.name or '').casefold()]
return skills
@ -175,6 +181,13 @@ async def create_new_skill(
form_data.id = form_data.id.lower().replace(' ', '-')
# The id goes into /id/{id}/... paths, so anything outside the slug charset is unreachable once stored.
if not re.fullmatch(r'[a-z0-9_-]+', form_data.id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.DEFAULT('Invalid skill ID'),
)
existing = await Skills.get_skill_by_id(form_data.id, db=db)
if existing is not None:
raise HTTPException(

View file

@ -66,6 +66,7 @@ async def get_tool_module(request, tool_id, load_from_db=True):
@router.get('/', response_model=list[ToolUserResponse])
async def get_tools(
request: Request,
query: Optional[str] = None,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
@ -165,10 +166,7 @@ async def get_tools(
)
)
if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL:
# Admin can see all tools
return tools
else:
if not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL):
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id, db=db)}
filtered_tools = []
for tool in tools:
@ -192,7 +190,13 @@ async def get_tools(
db=db,
):
filtered_tools.append(tool)
return filtered_tools
tools = filtered_tools
if query:
q = query.casefold()
tools = [tool for tool in tools if q in (tool.name or '').casefold()]
return tools
############################

View file

@ -234,6 +234,7 @@ class SharingPermissions(BaseModel):
public_notes: bool = False
folders: bool = False
public_chats: bool = False
open_chats: bool = False
public_calendars: bool = False
@ -470,7 +471,6 @@ async def get_default_user_permissions_defaults(user=Depends(get_admin_user)):
async def get_user_settings_by_session_user(
raw: bool = False,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
# user already fetched by get_verified_user — no need to refetch
if raw:
@ -507,7 +507,7 @@ async def update_user_settings_by_session_user(
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
updated_user_settings = form_data.model_dump()
updated_user_settings = form_data.model_dump(exclude_unset=True)
ui_settings = updated_user_settings.get('ui')
if (
user.role != 'admin'
@ -569,7 +569,6 @@ async def update_user_settings_by_session_user(
async def get_user_status_by_session_user(
request: Request,
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
if not await Config.get('users.enable_status'):
raise HTTPException(
@ -619,7 +618,7 @@ async def update_user_status_by_session_user(
@router.get('/user/info', response_model=dict | None)
async def get_user_info_by_session_user(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
async def get_user_info_by_session_user(user=Depends(get_verified_user)):
# user already fetched by get_verified_user — no need to refetch
return user.info

View file

@ -176,6 +176,7 @@ YDOC_MANAGER = YdocManager(
async def periodic_session_pool_cleanup():
"""Reap orphaned SESSION_POOL entries that missed heartbeats (e.g. crashed instance)."""
retry_delay = random.uniform(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, WEBSOCKET_REDIS_LOCK_TIMEOUT)
renew_interval = max(WEBSOCKET_REDIS_LOCK_TIMEOUT / 2, 0.5)
while True:
if not session_aquire_func():
log.debug('Session cleanup lock held by another node. Retrying.')
@ -197,7 +198,21 @@ async def periodic_session_pool_cleanup():
del SESSION_POOL[sid]
except KeyError:
pass
await asyncio.sleep(SESSION_POOL_TIMEOUT)
next_cleanup_at = time.monotonic() + SESSION_POOL_TIMEOUT
lock_lost = False
while True:
sleep_for = min(renew_interval, next_cleanup_at - time.monotonic())
if sleep_for <= 0:
break
await asyncio.sleep(sleep_for)
if not session_renew_func():
log.warning('Unable to renew session cleanup lock. Retrying cleanup ownership.')
lock_lost = True
break
if lock_lost:
break
finally:
session_release_func()
@ -912,7 +927,7 @@ async def _make_channel_emitter(request_info):
channel_id = request_info['chat_id'].removeprefix('channel:')
message_id = request_info['message_id']
state = {'last_emit_at': 0.0}
state = {'last_emit_at': 0.0, 'output': []}
THROTTLE_INTERVAL = 0.15 # ~6 updates/sec
async def _emit_channel_update(content: str, done: bool = False, output: list | None = None):
@ -960,11 +975,23 @@ async def _make_channel_emitter(request_info):
if not content and not output and not done:
return
now = __import__('time').time()
now = time.time()
if done or (now - state['last_emit_at']) >= THROTTLE_INTERVAL:
state['last_emit_at'] = now
await _emit_channel_update(content, done, output if isinstance(output, list) else None)
elif event_type == 'response:completion':
from open_webui.utils.middleware import handle_responses_streaming_event
data = event_data.get('data', {})
state['output'], _ = handle_responses_streaming_event(data, state['output'])
content = get_output_text(state['output'])
now = time.time()
if content and (now - state['last_emit_at']) >= THROTTLE_INTERVAL:
state['last_emit_at'] = now
await _emit_channel_update(content, False, state['output'])
elif event_type == 'chat:message:error':
error = event_data.get('data', {}).get('error', {})
error_content = error.get('content', 'An error occurred') if isinstance(error, dict) else str(error)

View file

@ -13,10 +13,12 @@ log = logging.getLogger(__name__)
# A dictionary to keep track of active tasks
tasks: dict[str, asyncio.Task] = {}
item_tasks = {}
response_streams: dict[str, dict] = {}
REDIS_TASKS_KEY = f'{REDIS_KEY_PREFIX}:tasks'
REDIS_ITEM_TASKS_KEY = f'{REDIS_KEY_PREFIX}:tasks:item'
REDIS_RESPONSE_STREAMS_KEY = f'{REDIS_KEY_PREFIX}:tasks:response_streams'
REDIS_PUBSUB_CHANNEL = f'{REDIS_KEY_PREFIX}:tasks:commands'
@ -55,6 +57,7 @@ async def redis_save_task(redis: Redis, task_id: str, item_id: str | None):
async def redis_cleanup_task(redis: Redis, task_id: str, item_id: str | None):
pipe = redis.pipeline()
pipe.hdel(REDIS_TASKS_KEY, task_id)
pipe.hdel(REDIS_RESPONSE_STREAMS_KEY, task_id)
if item_id:
pipe.srem(f'{REDIS_ITEM_TASKS_KEY}:{item_id}', task_id)
await pipe.execute()
@ -91,6 +94,7 @@ async def cleanup_task(redis, task_id: str, id=None):
await redis_cleanup_task(redis, task_id, id)
tasks.pop(task_id, None) # Remove the task if it exists
response_streams.pop(task_id, None)
# If an ID is provided, remove the task from the item_tasks dictionary
if id and task_id in item_tasks.get(id, []):
@ -140,6 +144,63 @@ async def list_task_ids_by_item_id(redis, id):
return item_tasks.get(id, [])
async def save_response_stream(
redis,
task_id: str | None,
chat_id: str | None,
message_id: str | None,
content: str,
output: list,
):
if not task_id or not chat_id or not message_id:
return
data = {
'chat_id': chat_id,
'message_id': message_id,
'content': content,
'output': output,
}
if redis:
await redis.hset(REDIS_RESPONSE_STREAMS_KEY, task_id, JSONCodec.dumps(data))
else:
response_streams[task_id] = data
async def get_response_streams_by_chat_id(redis, chat_id: str) -> list[dict]:
task_ids = await list_task_ids_by_item_id(redis, chat_id)
if not task_ids:
return []
if redis:
values = await redis.hmget(REDIS_RESPONSE_STREAMS_KEY, task_ids)
streams = []
for value in values:
if not value:
continue
try:
data = JSONCodec.loads(value)
except Exception:
continue
if data.get('chat_id') == chat_id:
streams.append(data)
return streams
return [
stream for task_id in task_ids if (stream := response_streams.get(task_id)) and stream.get('chat_id') == chat_id
]
async def clear_response_stream(redis, task_id: str | None):
if not task_id:
return
if redis:
await redis.hdel(REDIS_RESPONSE_STREAMS_KEY, task_id)
else:
response_streams.pop(task_id, None)
async def stop_task(redis, task_id: str):
"""
Cancel a running task and remove it from the global task list.

View file

@ -103,11 +103,12 @@ async def _emit_note_updated(request: Request, user: dict, note) -> None:
async def _has_read_access_to_file(
file,
user_id: str,
user_role: str,
user: dict,
model_knowledge: Optional[list[dict]] = None,
) -> bool:
"""Check if a user can read a file via ownership, admin role, model attachment, or access grants."""
user_id = user.get('id')
user_role = user.get('role', 'user')
if file.user_id == user_id or user_role == 'admin':
return True
if model_knowledge and any(item.get('type') == 'file' and item.get('id') == file.id for item in model_knowledge):
@ -117,7 +118,7 @@ async def _has_read_access_to_file(
return await has_access_to_file(
file_id=file.id,
access_type='read',
user=UserModel(**{'id': user_id, 'role': user_role}),
user=UserModel(**user),
)
@ -498,6 +499,120 @@ async def edit_image(
return JSONCodec.dumps({'error': str(e)})
# =============================================================================
# USER INPUT TOOLS
# =============================================================================
async def ask_user(
questions: list[dict],
allow_other: bool = True,
timeout_ms: int = 120_000,
__event_call__: callable = None,
) -> str:
"""
Ask the user clarifying questions before continuing.
Use this when the next step depends on user intent, preference, or a tradeoff that cannot be inferred safely.
:param questions: 1-3 question objects, each with id, header, question, and 2-3 options. Each option needs label and description.
:param allow_other: Whether users may enter a free-form answer instead of choosing one of the options
:param timeout_ms: How long the browser should keep the prompt open before cancelling it
:return: JSON with status and answers keyed by question id
"""
try:
if not isinstance(questions, list) or not 1 <= len(questions) <= 3:
raise ValueError('ask_user requires 1-3 questions.')
normalized_questions = []
seen_ids = set()
for index, question in enumerate(questions):
if not isinstance(question, dict):
raise ValueError('Each question must be an object.')
question_id = str(question.get('id') or '').strip()[:64]
if not question_id:
raise ValueError('Each question requires a non-empty id.')
if question_id in seen_ids:
raise ValueError(f'Duplicate question id: {question_id}')
seen_ids.add(question_id)
options = question.get('options')
if not isinstance(options, list) or not 2 <= len(options) <= 3:
raise ValueError('Each question requires 2-3 options.')
normalized_options = []
for option in options:
if not isinstance(option, dict):
raise ValueError('Each option must be an object.')
label = str(option.get('label') or '').strip()[:80]
description = str(option.get('description') or '').strip()[:240]
if not label or not description:
raise ValueError('Each option requires a label and description.')
normalized_options.append(
{
'label': label,
'description': description,
}
)
question_text = str(question.get('question') or '').strip()[:500]
if not question_text:
raise ValueError('Each question requires question text.')
normalized_questions.append(
{
'id': question_id,
'header': str(question.get('header') or '').strip()[:48] or f'Question {index + 1}',
'question': question_text,
'options': normalized_options,
'allow_other': bool(question.get('allow_other', allow_other)),
}
)
if isinstance(timeout_ms, bool) or not isinstance(timeout_ms, int) or not 60_000 <= timeout_ms <= 240_000:
timeout_ms = 120_000
if __event_call__ is None:
return JSONCodec.dumps(
{
'status': 'error',
'error': 'User input requires an active browser session with WebSocket connection.',
},
ensure_ascii=False,
)
output = await __event_call__(
{
'type': 'request:user_input',
'data': {
'questions': normalized_questions,
'allow_other': allow_other,
'timeout_ms': timeout_ms,
},
}
)
if not isinstance(output, dict):
return JSONCodec.dumps({'status': 'error', 'error': 'Invalid user input response.'}, ensure_ascii=False)
if output.get('error'):
return JSONCodec.dumps({'status': 'error', 'error': output.get('error')}, ensure_ascii=False)
if output.get('status') == 'cancelled':
return JSONCodec.dumps({'status': 'cancelled', 'answers': {}}, ensure_ascii=False)
return JSONCodec.dumps(
{
'status': 'answered',
'answers': output.get('answers', {}),
},
ensure_ascii=False,
)
except Exception as e:
log.exception(f'ask_user error: {e}')
return JSONCodec.dumps({'status': 'error', 'error': str(e)}, ensure_ascii=False)
# =============================================================================
# CODE INTERPRETER TOOLS
# =============================================================================
@ -2200,8 +2315,6 @@ async def _get_accessible_chat_files(
) -> list[tuple[dict, object]]:
from open_webui.models.files import Files
user_id = user.get('id')
user_role = user.get('role', 'user')
accessible = []
seen = set()
@ -2223,7 +2336,7 @@ async def _get_accessible_chat_files(
seen.add(fid)
file = await Files.get_file_by_id(fid)
if file and await _has_read_access_to_file(file, user_id, user_role):
if file and await _has_read_access_to_file(file, user):
accessible.append((normalized, file))
return accessible
@ -2445,10 +2558,7 @@ async def query_chat_files(
if not embedding_function and not full_context:
return JSONCodec.dumps({'error': 'Embedding function not configured'})
user_model = UserModel.model_construct(
id=__user__.get('id'),
role=__user__.get('role', 'user'),
)
user_model = UserModel(**__user__)
sources = await get_sources_from_items(
request=__request__,
items=file_items,
@ -2541,7 +2651,7 @@ async def grep_knowledge_files(
# Single file mode — verify access
file = await Files.get_file_by_id(file_id)
if file:
if not await _has_read_access_to_file(file, user_id, user_role, __model_knowledge__):
if not await _has_read_access_to_file(file, __user__, __model_knowledge__):
return JSONCodec.dumps({'error': 'File not found'})
files_to_search.append(file)
elif __model_knowledge__:
@ -2663,14 +2773,11 @@ async def view_file(
try:
from open_webui.models.files import Files
user_id = __user__.get('id')
user_role = __user__.get('role', 'user')
file = await Files.get_file_by_id(file_id)
if not file:
return JSONCodec.dumps({'error': 'File not found'})
if not await _has_read_access_to_file(file, user_id, user_role, __model_knowledge__):
if not await _has_read_access_to_file(file, __user__, __model_knowledge__):
return JSONCodec.dumps({'error': 'File not found'})
content = ''
@ -3078,7 +3185,7 @@ async def query_knowledge_files(
embedding_function = getattr(__request__.app.state, 'EMBEDDING_FUNCTION', None)
if not embedding_function:
return JSONCodec.dumps({'error': 'Embedding function not configured'})
user_model = UserModel.model_construct(id=user_id, role=user_role)
user_model = UserModel(**__user__)
collection_names = []
external_knowledges = []
@ -3272,7 +3379,7 @@ async def query_knowledge_bases(
embedding_function = getattr(__request__.app.state, 'EMBEDDING_FUNCTION', None)
if not embedding_function:
return JSONCodec.dumps({'error': 'Embedding function not configured'})
user_model = UserModel.model_construct(id=user_id, role=__user__.get('role', 'user'))
user_model = UserModel(**__user__)
query_embedding = await embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX, user=user_model)
# Min-heap of (distance, knowledge_base_id) - only holds top `count` results
@ -3602,7 +3709,7 @@ async def create_automation(
return JSONCodec.dumps({'error': 'User context not available'})
try:
from open_webui.models.automations import AutomationData, AutomationForm, Automations
from open_webui.models.automations import AutomationData, AutomationForm, AutomationTarget, Automations
from open_webui.models.users import Users
from open_webui.routers.automations import check_automation_limits
from open_webui.utils.automations import next_n_runs_ns, next_run_ns, validate_rrule
@ -3644,6 +3751,11 @@ async def create_automation(
prompt=prompt,
model_id=model_id,
rrule=rrule,
target=(
AutomationTarget(type='channel', channel_id=metadata.get('chat_id', '').removeprefix('channel:'))
if metadata.get('chat_id', '').startswith('channel:')
else None
),
),
is_active=True,
)
@ -3657,6 +3769,7 @@ async def create_automation(
'name': automation.name,
'folder_id': automation.folder_id,
'model_id': model_id,
'target': automation.data.get('target'),
'is_active': automation.is_active,
'next_runs': next_n_runs_ns(rrule, tz=tz),
},
@ -3673,7 +3786,7 @@ async def update_automation(
prompt: Optional[str] = None,
rrule: Optional[str] = None,
model_id: Optional[str] = None,
folder_id: Optional[str] = None,
folder_id: Optional[str] = '',
__request__: Request = None,
__user__: dict = None,
) -> str:
@ -3684,8 +3797,8 @@ async def update_automation(
:param name: New name for the automation (optional)
:param prompt: New prompt/instructions (optional)
:param rrule: New iCalendar RRULE schedule string (optional). See create_automation for format examples.
:param model_id: New model ID to use (optional)
:param folder_id: New owner-owned folder ID (optional); pass an empty string to clear
:param model_id: New model ID to use (optional); blank values are ignored
:param folder_id: New owner-owned folder ID (optional); omit or pass blank to keep unchanged, pass null to clear
:return: JSON with the updated automation details
"""
if __request__ is None:
@ -3695,7 +3808,7 @@ async def update_automation(
return JSONCodec.dumps({'error': 'User context not available'})
try:
from open_webui.models.automations import AutomationData, AutomationForm, Automations
from open_webui.models.automations import AutomationData, AutomationForm, AutomationTarget, Automations
from open_webui.models.users import Users
from open_webui.routers.automations import check_automation_limits
from open_webui.utils.automations import next_n_runs_ns, next_run_ns, validate_rrule
@ -3714,13 +3827,15 @@ async def update_automation(
# Merge provided fields with existing values
new_name = name if name is not None else automation.name
new_prompt = prompt if prompt is not None else automation.data.get('prompt', '')
new_model_id = model_id if model_id is not None else automation.data.get('model_id', '')
new_model_id = model_id.strip() if model_id and model_id.strip() else automation.data.get('model_id', '')
new_rrule = rrule if rrule is not None else automation.data.get('rrule', '')
if folder_id is None:
new_folder_id = None
elif not folder_id.strip():
new_folder_id = automation.folder_id
else:
try:
new_folder_id = await _validate_owned_automation_folder(user_id, folder_id)
new_folder_id = await _validate_owned_automation_folder(user_id, folder_id.strip())
except ValueError as e:
return JSONCodec.dumps({'error': str(e)})
@ -3744,6 +3859,7 @@ async def update_automation(
prompt=new_prompt,
model_id=new_model_id,
rrule=new_rrule,
target=AutomationTarget(**automation.data['target']) if automation.data.get('target') else None,
),
is_active=automation.is_active,
)
@ -3757,6 +3873,7 @@ async def update_automation(
'name': updated.name,
'folder_id': updated.folder_id,
'model_id': new_model_id,
'target': updated.data.get('target'),
'is_active': updated.is_active,
'next_runs': next_n_runs_ns(new_rrule, tz=tz),
},
@ -3822,6 +3939,7 @@ async def list_automations(
'folder_id': item.folder_id,
'prompt_snippet': snippet,
'model_id': item.data.get('model_id', ''),
'target': item.data.get('target'),
'rrule': rrule,
'is_active': item.is_active,
'last_run_at': item.last_run_at,
@ -3938,6 +4056,9 @@ async def delete_automation(
# =============================================================================
MAX_CALENDAR_RANGE_END_NS = 2**63 - 1
def _get_user_tz(user_dict: dict):
"""Get the user's timezone as a ZoneInfo, falling back to UTC."""
from zoneinfo import ZoneInfo
@ -4037,11 +4158,7 @@ async def search_calendar_events(
return JSONCodec.dumps({'error': f'Invalid start datetime: {e}'})
try:
end_ns = (
_dt_to_ns(end, tz)
if end
else int(time.time() * 1_000) * 1_000_000 + 365 * 86400 * 1_000_000_000_000
)
end_ns = _dt_to_ns(end, tz) if end else MAX_CALENDAR_RANGE_END_NS
except (ValueError, TypeError) as e:
return JSONCodec.dumps({'error': f'Invalid end datetime: {e}'})
@ -4306,16 +4423,17 @@ async def update_calendar_event(
if reminder_minutes is not None:
meta = {'alert_minutes': reminder_minutes}
form = CalendarEventUpdateForm(
title=title,
description=description,
start_at=start_ns,
end_at=end_ns,
all_day=all_day,
location=location,
is_cancelled=is_cancelled,
meta=meta,
)
update_fields = {
'title': title,
'description': description,
'start_at': start_ns,
'end_at': end_ns,
'all_day': all_day,
'location': location,
'is_cancelled': is_cancelled,
'meta': meta,
}
form = CalendarEventUpdateForm(**{k: v for k, v in update_fields.items() if v is not None})
updated = await CalendarEvents.update_event_by_id(event_id, form)
if not updated:

View file

@ -0,0 +1,144 @@
from collections.abc import Callable
from open_webui.utils.json_codec import JSONCodec
ASK_USER_NAME = 'ask_user'
def get_ask_user_tool_call(tool_calls: list[dict]) -> tuple[dict | None, str | None]:
ask_user_calls = [
tool_call for tool_call in tool_calls if tool_call.get('function', {}).get('name') == ASK_USER_NAME
]
if not ask_user_calls:
return None, None
if len(tool_calls) != 1:
return ask_user_calls[0], 'Error: ask_user must be called by itself after research.'
if len(ask_user_calls) != 1:
return ask_user_calls[0], 'Error: only one ask_user call is allowed per turn.'
return ask_user_calls[0], None
def normalize_ask_user_request(arguments: dict) -> dict:
questions = arguments.get('questions')
if not isinstance(questions, list) or not 1 <= len(questions) <= 3:
raise ValueError('ask_user requires 1-3 questions.')
normalized_questions = []
seen_ids = set()
allow_other = bool(arguments.get('allow_other', True))
for index, question in enumerate(questions):
if not isinstance(question, dict):
raise ValueError('Each question must be an object.')
question_id = str(question.get('id') or '').strip()[:64]
if not question_id:
raise ValueError('Each question requires a non-empty id.')
if question_id in seen_ids:
raise ValueError(f'Duplicate question id: {question_id}')
seen_ids.add(question_id)
options = question.get('options')
if not isinstance(options, list) or not 2 <= len(options) <= 3:
raise ValueError('Each question requires 2-3 options.')
normalized_options = []
for option in options:
if not isinstance(option, dict):
raise ValueError('Each option must be an object.')
label = str(option.get('label') or '').strip()[:80]
description = str(option.get('description') or '').strip()[:240]
if not label or not description:
raise ValueError('Each option requires a label and description.')
normalized_options.append({'label': label, 'description': description})
question_text = str(question.get('question') or '').strip()[:500]
if not question_text:
raise ValueError('Each question requires question text.')
normalized_questions.append(
{
'id': question_id,
'header': str(question.get('header') or '').strip()[:48] or f'Question {index + 1}',
'question': question_text,
'options': normalized_options,
'allow_other': bool(question.get('allow_other', allow_other)),
}
)
timeout_ms = arguments.get('timeout_ms', 120_000)
if isinstance(timeout_ms, bool) or not isinstance(timeout_ms, int) or not 60_000 <= timeout_ms <= 240_000:
timeout_ms = 120_000
return {
'questions': normalized_questions,
'allow_other': allow_other,
'timeout_ms': timeout_ms,
}
def stage_ask_user_tool_call(
tool_calls: list[dict],
output: list[dict],
make_output_id: Callable[[str], str],
) -> dict | None:
tool_call, error = get_ask_user_tool_call(tool_calls)
if not tool_call:
return None
call_id = tool_call.get('id') or make_output_id('fc')
raw_arguments = tool_call.get('function', {}).get('arguments', '{}')
arguments = raw_arguments
if not error:
try:
parsed_arguments = JSONCodec.loads(raw_arguments or '{}')
if not isinstance(parsed_arguments, dict):
raise ValueError('ask_user arguments must be an object.')
arguments = JSONCodec.dumps(normalize_ask_user_request(parsed_arguments))
except (JSONCodec.JSONDecodeError, TypeError, ValueError) as exc:
error = f'Error: {exc}'
item = {
'type': 'function_call',
'id': call_id or make_output_id('fc'),
'call_id': call_id,
'name': ASK_USER_NAME,
'arguments': arguments,
'status': 'completed' if error else 'pending',
}
existing_item = next(
(
existing
for existing in output
if existing.get('type') == 'function_call'
and (
existing.get('call_id') == call_id
or existing.get('id') == tool_call.get('id')
or (
not existing.get('call_id')
and existing.get('name') == ASK_USER_NAME
and existing.get('status') not in {'rejected', 'failed'}
)
)
),
None,
)
if existing_item:
existing_item.update(item)
else:
output.append(item)
if error:
output.append(
{
'type': 'function_call_output',
'id': make_output_id('fco'),
'call_id': call_id,
'output': [{'type': 'input_text', 'text': error}],
'status': 'completed',
}
)
return {'call_id': call_id, 'error': error, 'item': item}

View file

@ -36,6 +36,7 @@ from open_webui.models.automations import AutomationModel, AutomationRuns, Autom
from open_webui.models.chats import ChatForm, Chats
from open_webui.models.config import Config
from open_webui.models.folders import Folders
from open_webui.models.messages import MessageForm
from open_webui.models.users import Users
from open_webui.utils.auth import create_token
from open_webui.utils.misc import parse_duration
@ -124,6 +125,9 @@ def validate_rrule(s: str, tz: str = None) -> None:
clock so that near-future schedules are not incorrectly rejected
on servers whose system clock is ahead (e.g. UTC vs US timezones).
"""
upper = s.upper()
if 'COUNT=' in upper and 'DTSTART' not in upper:
raise ValueError(ERROR_MESSAGES.AUTOMATION_COUNT_REQUIRES_DTSTART)
zi = _resolve_tz(tz)
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
try:
@ -297,33 +301,17 @@ def _build_request(
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 []
async 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.
"""
async def _resolve_model_defaults(app, model_id: str) -> tuple[list[str], dict, list[str], Optional[str]]:
models = getattr(app.state, 'MODELS', {})
model = models.get(model_id, {})
meta = model.get('info', {}).get('meta', {})
tool_ids = list(meta.get('toolIds') or [])
filter_ids = list(meta.get('defaultFilterIds') or [])
terminal_id = meta.get('terminalId') or None
default_feature_ids = meta.get('defaultFeatureIds', [])
if not default_feature_ids:
return {}
return tool_ids, {}, filter_ids, terminal_id
capabilities = meta.get('capabilities') or {}
features = {}
@ -341,25 +329,7 @@ async def _resolve_model_features(app, model_id: str) -> dict:
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 []
def _resolve_model_terminal_id(app, model_id: str) -> Optional[str]:
"""Read model default terminal_id from model config.
The frontend does this in Chat.svelte (model.info.meta.terminalId).
"""
models = getattr(app.state, 'MODELS', {})
model = models.get(model_id, {})
return model.get('info', {}).get('meta', {}).get('terminalId') or None
return tool_ids, features, filter_ids, terminal_id
async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -> None:
@ -410,10 +380,113 @@ async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -
log.warning(f'Failed to set terminal CWD: {e}')
async def _execute_channel_automation(
app,
automation: AutomationModel,
user,
prompt: str,
model_id: str,
token: str,
) -> None:
target = automation.data.get('target') or {}
channel_id = target.get('channel_id')
if not channel_id or not await Config.get('channels.enable'):
raise ValueError('Channel not found')
model = getattr(app.state, 'MODELS', {}).get(model_id, {})
request = _build_request(app, token=token)
from open_webui.routers.channels import new_message_handler
async with get_async_db() as db:
user_message, channel = await new_message_handler(
request,
channel_id,
MessageForm(
content=prompt,
data={},
meta={'automation_id': automation.id},
),
user,
db,
)
response_parent_id = (
user_message.parent_id
if user_message.parent_id
else (user_message.id if await Config.get('channels.model_response_mode', 'thread') == 'thread' else None)
)
assistant_message, channel = await new_message_handler(
request,
channel.id,
MessageForm(
parent_id=response_parent_id,
content='',
data={},
meta={
'automation_id': automation.id,
'model_id': model_id,
'model_name': model.get('name', model_id),
},
),
user,
db,
)
tool_ids, features, filter_ids, _ = await _resolve_model_defaults(app, model_id)
form_data = {
'model': model_id,
'messages': [
{
'role': 'system',
'content': f'You are {model.get("name", model_id)}, participating in a channel conversation. Be concise and conversational.',
},
{'role': 'user', 'content': f'{user.name if user else "User"}: {prompt}'},
],
'stream': True,
'chat_id': f'channel:{channel.id}',
'id': assistant_message.id,
'session_id': f'channel:{channel.id}',
'automation_id': 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
await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user)
from open_webui.socket.main import sio
await sio.emit(
'automation:result',
{
'automation_id': automation.id,
'name': automation.name,
'chat_id': f'channel:{channel.id}',
'message_id': assistant_message.id,
'status': 'success',
},
room=f'user:{automation.user_id}',
)
await _record_run(automation.id, 'success', chat_id=f'channel:{channel.id}')
await publish_event(
app,
EVENTS.AUTOMATION_RUN_COMPLETED,
actor=user,
subject_id=automation.id,
data={'name': automation.name, 'channel_id': channel.id, 'message_id': assistant_message.id},
)
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:
Creates a real chat or channel message, 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).
"""
@ -449,6 +522,20 @@ async def execute_automation(app, automation: AutomationModel) -> None:
prompt = await prompt_template(automation.data['prompt'], user)
model_id = automation.data['model_id']
try:
expires_delta = parse_duration(str(await Config.get('automations.auth_token_expires_in', '1h')))
except ValueError:
expires_delta = None
token = create_token(
data={'id': user.id, 'typ': 'automation'},
expires_delta=expires_delta or timedelta(hours=1),
)
target = automation.data.get('target') or {}
if target.get('type') == 'channel':
await _execute_channel_automation(app, automation, user, prompt, model_id, token)
return
folder_id = automation.folder_id
if folder_id and not await Folders.get_folder_by_id_and_user_id(folder_id, automation.user_id):
await Automations.clear_folder_ids(automation.user_id, [folder_id])
@ -525,12 +612,7 @@ async def execute_automation(app, automation: AutomationModel) -> None:
)
# Resolve model defaults (frontend does this, backend doesn't)
tool_ids = _resolve_model_tool_ids(app, model_id)
features = await _resolve_model_features(app, model_id)
filter_ids = _resolve_model_filter_ids(app, model_id)
# Resolve terminal from model config
terminal_id = _resolve_model_terminal_id(app, model_id)
tool_ids, features, filter_ids, terminal_id = await _resolve_model_defaults(app, model_id)
# Build the same payload the frontend sends to /api/chat/completions
form_data = {
@ -561,14 +643,6 @@ async def execute_automation(app, automation: AutomationModel) -> None:
# Call the full chat completion pipeline (same as POST /api/chat/completions).
# The handler reference is stored on app.state to avoid circular imports.
try:
expires_delta = parse_duration(str(await Config.get('automations.auth_token_expires_in', '1h')))
except ValueError:
expires_delta = None
token = create_token(
data={'id': user.id, 'typ': 'automation'},
expires_delta=expires_delta or timedelta(hours=1),
)
request = _build_request(app, token=token)
await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user)

View file

@ -235,6 +235,23 @@ def _resolve_token_threshold(global_threshold: int, global_cap: int, metadata: d
return min(configured_threshold or global_threshold, global_cap)
def _usage_token_count(usage: dict) -> int:
prompt_tokens = int(usage.get('prompt_tokens') or usage.get('prompt_eval_count') or 0)
if not prompt_tokens and (usage.get('prompt_n') is not None or usage.get('cache_n') is not None):
prompt_tokens = int(usage.get('prompt_n') or 0) + int(usage.get('cache_n') or 0)
if not prompt_tokens:
prompt_tokens = int(usage.get('input_tokens') or 0)
completion_tokens = int(
usage.get('completion_tokens')
or usage.get('output_tokens')
or usage.get('eval_count')
or usage.get('predicted_n')
or 0
)
return prompt_tokens + completion_tokens
async def get_chat_context_usage(chat: Any, model_id: str | None = None) -> dict | None:
chat_data = chat.chat or {}
history = chat_data.get('history') or {}
@ -263,25 +280,7 @@ async def get_chat_context_usage(chat: Any, model_id: str | None = None) -> dict
for idx in range(len(messages) - 1, -1, -1):
usage = messages[idx].get('usage') or (messages[idx].get('info') or {}).get('usage')
if isinstance(usage, dict) and (
tokens := (
int(
usage.get('prompt_tokens')
or usage.get('input_tokens')
or usage.get('prompt_eval_count')
or usage.get('prompt_n')
or 0
)
+ int(
usage.get('completion_tokens')
or usage.get('output_tokens')
or usage.get('eval_count')
or usage.get('predicted_n')
or 0
)
+ int(usage.get('cache_n') or 0)
)
):
if isinstance(usage, dict) and (tokens := _usage_token_count(usage)):
tokens += _estimate_messages_tokens(messages[idx + 1 :])
return _build_context_usage(tokens, threshold)
@ -320,25 +319,7 @@ def _exceeds_token_threshold(messages: list[dict], system_prompt: str, summary:
for idx in range(len(messages) - 1, -1, -1):
usage = messages[idx].get('usage') or (messages[idx].get('info') or {}).get('usage')
if isinstance(usage, dict) and (
tokens := (
int(
usage.get('prompt_tokens')
or usage.get('input_tokens')
or usage.get('prompt_eval_count')
or usage.get('prompt_n')
or 0
)
+ int(
usage.get('completion_tokens')
or usage.get('output_tokens')
or usage.get('eval_count')
or usage.get('predicted_n')
or 0
)
+ int(usage.get('cache_n') or 0)
)
):
if isinstance(usage, dict) and (tokens := _usage_token_count(usage)):
return tokens + _estimate_messages_tokens(messages[idx + 1 :]) > threshold
estimated = _estimate_tokens(system_prompt) + _estimate_tokens(summary or '') + _estimate_messages_tokens(messages)

File diff suppressed because it is too large Load diff

View file

@ -258,6 +258,17 @@ def reconcile_tool_pairs(messages: list[dict]) -> list[dict]:
return reconciled_messages
def get_reasoning_details(payload: dict):
if not isinstance(payload, dict):
return None
provider_fields = payload.get('provider_specific_fields') or {}
provider_details = (
provider_fields.get('reasoning_details') if isinstance(provider_fields, dict) else None
)
return payload.get('reasoning_details') or provider_details
def convert_output_to_messages(
output: list,
raw: bool = False,
@ -277,8 +288,10 @@ def convert_output_to_messages(
follow-ups.
reasoning_format: How to include reasoning blocks in the output:
- None: skip reasoning (default, safe for strict providers).
- ``'thinking'``: set as ``thinking`` top-level field
(for native Ollama).
- ``'think_tags'``: wrap in ``<think>`` tags inside content
(for Ollama, which expects reasoning as tagged content).
(for legacy providers that expect reasoning as tagged content).
- ``'reasoning_content'``: set as ``reasoning_content`` top-level field
(for llama.cpp, which routes it via the chat template).
flatten_tool_images: Move tool output images into a following user
@ -290,12 +303,21 @@ def convert_output_to_messages(
messages = []
pending_tool_calls = []
pending_content = []
pending_reasoning = [] # Only populated when reasoning_format == 'reasoning_content'
pending_reasoning = [] # Only populated for top-level structured reasoning fields.
pending_reasoning_details = []
pending_tool_image_urls = []
function_call_ids = {
item.get('call_id') for item in output if item.get('type') == 'function_call' and item.get('call_id')
pending_tool_outputs = []
completed_call_ids = {
item.get('call_id')
for item in output
if item.get('type') == 'function_call'
and item.get('call_id')
and item.get('status') in {'completed', 'rejected'}
}
result_call_ids = {
item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id')
}
function_call_ids = completed_call_ids & result_call_ids
def flush_pending():
nonlocal pending_content, pending_tool_calls, pending_reasoning, pending_reasoning_details
@ -309,7 +331,10 @@ def convert_output_to_messages(
}
if pending_reasoning:
message['reasoning_content'] = '\n'.join(pending_reasoning)
if reasoning_format == 'thinking':
message['thinking'] = '\n'.join(pending_reasoning)
else:
message['reasoning_content'] = '\n'.join(pending_reasoning)
if pending_reasoning_details:
message['reasoning_details'] = pending_reasoning_details
@ -339,9 +364,60 @@ def convert_output_to_messages(
)
pending_tool_image_urls = []
def flush_tool_outputs():
nonlocal pending_tool_outputs
if not pending_tool_outputs:
return
flush_pending()
for output_item in pending_tool_outputs:
output_parts = output_item.get('output', [])
content = ''
image_urls = []
for part in output_parts:
if part.get('type') == 'input_text':
output_text = part.get('text', '')
content += str(output_text) if not isinstance(output_text, str) else output_text
elif part.get('type') == 'input_image':
url = part.get('image_url', '')
if url:
image_urls.append(url)
if flatten_tool_images:
messages.append(
{
'role': 'tool',
'tool_call_id': output_item.get('call_id', ''),
'content': content,
}
)
pending_tool_image_urls.extend(image_urls)
elif image_urls:
messages.append(
{
'role': 'tool',
'tool_call_id': output_item.get('call_id', ''),
'content': [
{'type': 'input_text', 'text': content},
*[{'type': 'input_image', 'image_url': url} for url in image_urls],
],
}
)
else:
messages.append(
{
'role': 'tool',
'tool_call_id': output_item.get('call_id', ''),
'content': content,
}
)
pending_tool_outputs = []
for item in output:
item_type = item.get('type', '')
if item_type != 'function_call_output':
if item_type not in {'function_call', 'function_call_output'}:
flush_tool_outputs()
flush_tool_images()
if item_type == 'message':
@ -355,6 +431,9 @@ def convert_output_to_messages(
pending_content.append(text)
elif item_type == 'function_call':
if item.get('call_id') not in function_call_ids:
continue
# Collect tool calls to batch into assistant message
arguments = item.get('arguments', '{}')
# Ensure arguments is always a JSON string
@ -372,54 +451,21 @@ def convert_output_to_messages(
)
elif item_type == 'function_call_output':
# Flush any pending content/tool_calls before adding tool result
flush_pending()
if item.get('call_id') not in function_call_ids:
continue
# Extract text and images from output content parts
output_parts = item.get('output', [])
content = ''
image_urls = []
for part in output_parts:
if part.get('type') == 'input_text':
output_text = part.get('text', '')
content += str(output_text) if not isinstance(output_text, str) else output_text
elif part.get('type') == 'input_image':
url = part.get('image_url', '')
if url:
image_urls.append(url)
if flatten_tool_images:
messages.append(
{
'role': 'tool',
'tool_call_id': item.get('call_id', ''),
'content': content,
}
)
if item.get('call_id') in function_call_ids:
pending_tool_image_urls.extend(image_urls)
elif image_urls:
messages.append(
{
'role': 'tool',
'tool_call_id': item.get('call_id', ''),
'content': [
{'type': 'input_text', 'text': content},
*[{'type': 'input_image', 'image_url': url} for url in image_urls],
],
}
)
else:
messages.append(
{
'role': 'tool',
'tool_call_id': item.get('call_id', ''),
'content': content,
}
)
pending_tool_outputs.append(item)
elif item_type == 'reasoning':
reasoning_details = item.get('reasoning_details') if raw else None
if reasoning_details:
reasoning_details = reasoning_details if isinstance(reasoning_details, list) else [reasoning_details]
reasoning_details = [
detail
for detail in reasoning_details
if isinstance(detail, dict)
and (detail.get('format') != 'anthropic-claude-v1' or detail.get('signature'))
]
if not reasoning_format and not reasoning_details:
continue
@ -433,18 +479,16 @@ def convert_output_to_messages(
if reasoning_text:
if reasoning_format == 'think_tags':
# Ollama: embed in content with the item's original tags
# Legacy tag replay: embed in content with the item's original tags.
start_tag = item.get('start_tag', '<think>')
end_tag = item.get('end_tag', '</think>')
pending_content.append(f'{start_tag}{reasoning_text}{end_tag}')
elif reasoning_format == 'reasoning_content':
# llama.cpp: collect for reasoning_content field
elif reasoning_format in {'thinking', 'reasoning_content'}:
# Native providers: collect for their top-level reasoning field.
pending_reasoning.append(reasoning_text)
if reasoning_details:
pending_reasoning_details.extend(
reasoning_details if isinstance(reasoning_details, list) else [reasoning_details]
)
pending_reasoning_details.extend(reasoning_details)
elif item_type == 'open_webui:code_interpreter':
# Always include code interpreter content so the LLM knows
@ -470,6 +514,7 @@ def convert_output_to_messages(
pass
# Flush remaining content/tool_calls
flush_tool_outputs()
flush_tool_images()
flush_pending()
@ -788,6 +833,17 @@ def sanitize_filename(file_name):
return final_file_name
def json_text_variants(value: str) -> list[str]:
"""Both spellings ``value`` can take inside a serialized JSON column, unquoted.
Encoders disagree on non-ASCII — stdlib escapes it to ``\\uXXXX``, orjson writes it
raw — so a LIKE against the stored text has to accept either. ASCII collapses to one.
"""
raw = JSONCodec.dumps(value, ensure_ascii=False)[1:-1]
escaped = JSONCodec.dumps(value, ensure_ascii=True)[1:-1]
return [raw] if raw == escaped else [raw, escaped]
def sanitize_text_for_db(text: str) -> str:
"""Remove null bytes and invalid UTF-8 surrogates from text for PostgreSQL storage."""
if not isinstance(text, str):

View file

@ -71,6 +71,10 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
'evaluation.arena.models',
'models.default_metadata',
)
if refresh:
await openai.get_all_models.cache.clear()
await ollama.get_all_models.cache.clear()
if (
request.app.state.MODELS
and request.app.state.BASE_MODELS

View file

@ -1519,8 +1519,8 @@ class OAuthManager:
oauth_allowed_roles = auth_config.OAUTH_ALLOWED_ROLES
oauth_admin_roles = auth_config.OAUTH_ADMIN_ROLES
oauth_roles = []
# Default/fallback role if no matching roles are found
role = auth_config.DEFAULT_USER_ROLE
# Keep existing users at their current role unless the provider sent roles.
role = user.role if user else auth_config.DEFAULT_USER_ROLE
# Next block extracts the roles from the user data, accepting nested claims of any depth
if oauth_claim and oauth_allowed_roles and oauth_admin_roles:
@ -1585,7 +1585,34 @@ class OAuthManager:
return role
async def update_user_groups(self, user, user_data, default_permissions, db=None):
async def update_user_role_from_oauth(
self,
request,
user,
user_data,
provider,
*,
db=None,
):
determined_role = await self.get_user_role(user, user_data)
if user.role == determined_role:
return user
updated_user = await Users.update_user_role_by_id(user.id, determined_role, db=db)
user = updated_user or user
user.role = determined_role
await publish_event(
request,
EVENTS.USER_ROLE_UPDATED,
actor=user,
subject_id=user.id,
source='oauth',
data={'role': determined_role, 'provider': provider},
)
return user
async def update_user_groups(self, request, user, user_data, default_permissions, db=None):
auth_config = await get_oauth_runtime_config()
log.debug('Running OAUTH Group management')
oauth_claim = auth_config.OAUTH_GROUPS_CLAIM
@ -1650,6 +1677,13 @@ class OAuthManager:
groups_created = True
# Add to local set to prevent duplicate creation attempts in this run
all_group_names.add(group_name)
await publish_event(
request,
EVENTS.GROUP_CREATED,
subject_id=created_group.id,
source='oauth',
data={'name': created_group.name},
)
else:
log.error(f"Failed to create group '{group_name}' via OAuth.")
except Exception as e:
@ -1674,7 +1708,15 @@ class OAuthManager:
):
# Remove group from user
log.debug('Removing user from group %s as it is no longer in their oauth groups', group_model.name)
await Groups.remove_users_from_group(group_model.id, [user.id], db=db)
if await Groups.remove_users_from_group(group_model.id, [user.id], db=db):
await publish_event(
request,
EVENTS.GROUP_MEMBER_REMOVED,
actor=user,
subject_id=group_model.id,
source='oauth',
data={'user_ids': [user.id]},
)
# In case a group is created, but perms are never assigned to the group by hitting "save"
group_permissions = group_model.permissions
@ -1703,7 +1745,15 @@ class OAuthManager:
# Add user to group
log.debug('Adding user to group %s as it was found in their oauth groups', group_model.name)
await Groups.add_users_to_group(group_model.id, [user.id], db=db)
if await Groups.add_users_to_group(group_model.id, [user.id], db=db):
await publish_event(
request,
EVENTS.GROUP_MEMBER_ADDED,
actor=user,
subject_id=group_model.id,
source='oauth',
data={'user_ids': [user.id]},
)
# In case a group is created, but perms are never assigned to the group by hitting "save"
group_permissions = group_model.permissions
@ -1940,29 +1990,26 @@ class OAuthManager:
await Users.update_user_oauth_by_id(user.id, provider, sub, db=db)
if user:
determined_role = await self.get_user_role(user, user_data)
if user.role != determined_role:
updated_user = await Users.update_user_role_by_id(user.id, determined_role, db=db)
# Update the user object in memory as well,
# to avoid problems with the ENABLE_OAUTH_GROUP_MANAGEMENT check below
user.role = determined_role
await publish_event(
request,
EVENTS.USER_ROLE_UPDATED,
actor=updated_user or user,
subject_id=user.id,
source='oauth',
data={'role': determined_role, 'provider': provider},
)
user = await self.update_user_role_from_oauth(
request=request,
user=user,
user_data=user_data,
provider=provider,
db=db,
)
updated_fields = []
if auth_config.OAUTH_UPDATE_NAME_ON_LOGIN:
username_claim = auth_config.OAUTH_USERNAME_CLAIM
if username_claim:
new_name = user_data.get(username_claim)
if new_name and new_name != user.name:
await Users.update_user_by_id(user.id, {'name': new_name}, db=db)
user.name = new_name
log.debug('Updated name for user %s', user.email)
updated_user = await Users.update_user_by_id(user.id, {'name': new_name}, db=db)
if updated_user:
user = updated_user
updated_fields.append('name')
log.debug('Updated name for user %s', user.email)
if auth_config.OAUTH_UPDATE_EMAIL_ON_LOGIN:
email_claim = auth_config.OAUTH_EMAIL_CLAIM
@ -1974,9 +2021,9 @@ class OAuthManager:
log.error(
f'Cannot update email to {new_email} for user {user.id} because it is already taken.'
)
else:
await Auths.update_email_by_id(user.id, new_email.lower(), db=db)
user.email = new_email.lower()
elif await Auths.update_email_by_id(user.id, new_email.lower(), db=db):
user = await Users.get_user_by_id(user.id, db=db) or user
updated_fields.append('email')
log.debug('Updated email for user %s', user.id)
# Update profile picture if enabled and different from current
@ -1991,8 +2038,23 @@ class OAuthManager:
new_picture_url, token.get('access_token')
)
if processed_picture_url != user.profile_image_url:
await Users.update_user_profile_image_url_by_id(user.id, processed_picture_url, db=db)
log.debug('Updated profile picture for user %s', user.email)
updated_user = await Users.update_user_profile_image_url_by_id(
user.id, processed_picture_url, db=db
)
if updated_user:
user = updated_user
updated_fields.append('profile_image_url')
log.debug('Updated profile picture for user %s', user.email)
if updated_fields:
await publish_event(
request,
EVENTS.USER_UPDATED,
actor=user,
subject_id=user.id,
source='oauth',
data={'updated_fields': updated_fields, 'provider': provider},
)
else:
# If the user does not exist, check if signups are enabled
if auth_config.ENABLE_OAUTH_SIGNUP:
@ -2060,6 +2122,7 @@ class OAuthManager:
)
if auth_config.ENABLE_OAUTH_GROUP_MANAGEMENT:
await self.update_user_groups(
request=request,
user=user,
user_data=user_data,
default_permissions=await Config.get('user.permissions'),

View file

@ -95,6 +95,7 @@ def apply_params_to_form_data(form_data: dict, model: dict, params: dict | None
'compact_token_threshold': int,
'system': str,
'note_id': str,
'tool_approval_mode': str,
}
for key in list(params.keys()):
@ -149,6 +150,7 @@ def remove_open_webui_params(params: dict) -> dict:
'compact_token_threshold': int,
'system': str,
'note_id': str,
'tool_approval_mode': str,
}
for key in list(params.keys()):
@ -284,6 +286,8 @@ def convert_messages_openai_to_ollama(messages: list[dict]) -> list[dict]:
# may be injected by filter inlet functions).
if 'thinking' in message:
new_message['thinking'] = message['thinking']
elif reasoning_content := (message.get('reasoning_content') or message.get('reasoning')):
new_message['thinking'] = reasoning_content
content = message.get('content', [])
tool_calls = message.get('tool_calls', None)

View file

@ -24,13 +24,9 @@ def normalize_usage(usage: dict) -> dict:
return {}
# Map various field names to standard names
input_tokens = (
usage.get('input_tokens') # Already standard
or usage.get('prompt_tokens') # OpenAI
or usage.get('prompt_eval_count') # Ollama
or usage.get('prompt_n') # llama.cpp
or 0
)
input_tokens = usage.get('input_tokens') or usage.get('prompt_tokens') or usage.get('prompt_eval_count')
if input_tokens is None:
input_tokens = int(usage.get('prompt_n') or 0) + int(usage.get('cache_n') or 0)
output_tokens = (
usage.get('output_tokens') # Already standard

View file

@ -404,7 +404,11 @@ async def execute_due_timer(app, timer_id: str, claim_id: str | None = None) ->
)
request.state.token = None
request.state.enable_api_keys = False
await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user)
try:
await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user)
except Exception as exc:
log.exception(f'Timer {timer_id} completion failed')
await _set_timer_state(timer_id, 'error', timer_error=str(exc)[:500])
async def _set_timer_state(timer_id: str, status: str, **fields) -> None:

View file

@ -0,0 +1,187 @@
from typing import Any, Literal
from fastapi import HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from open_webui.constants import ERROR_MESSAGES
from open_webui.models.chats import Chats
from open_webui.socket.main import get_event_emitter
from open_webui.utils.json_codec import JSONCodec
class ResolveToolCallForm(BaseModel):
call_id: str
action: Literal['approve', 'reject', 'answer']
answers: Any | None = None
timed_out: bool = False
async def resolve_tool_call_output(
chat_id: str,
message_id: str,
form_data: ResolveToolCallForm,
user,
db: AsyncSession | None = None,
) -> dict:
chat = await Chats.get_chat_by_id(chat_id, db=db)
if not chat or (chat.user_id != user.id and user.role != 'admin'):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
)
message = await Chats.get_message_by_id_and_message_id(chat_id, message_id)
if not message:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
output = message.get('output') or []
if not isinstance(output, list):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Message has no resolvable output.')
function_call = next(
(
item
for item in output
if item.get('type') == 'function_call'
and (item.get('call_id') or item.get('id')) == form_data.call_id
),
None,
)
if not function_call:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Tool call not found.')
function_call.setdefault('call_id', form_data.call_id)
tool_name = function_call.get('name')
if any(
item.get('type') == 'function_call_output' and item.get('call_id') == form_data.call_id for item in output
) or function_call.get('status') not in {'pending', 'queued', 'requires_approval'}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Tool call has already been resolved.')
if form_data.action == 'approve':
if tool_name == 'ask_user':
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='ask_user requires an answer or deny.')
function_call['status'] = 'queued'
function_call['approved'] = True
elif form_data.action == 'reject':
function_call['status'] = 'rejected'
output.append(
{
'type': 'function_call_output',
'id': f'fco_{form_data.call_id}',
'call_id': form_data.call_id,
'output': [{'type': 'input_text', 'text': 'Error: tool call rejected by user.'}],
'status': 'rejected',
}
)
else:
if tool_name != 'ask_user':
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Tool call does not accept answers.')
if form_data.answers is None and not form_data.timed_out:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Answers are required for ask_user.')
function_call['status'] = 'completed'
answer_payload = (
{'status': 'cancelled', 'answers': {}, 'timed_out': True}
if form_data.timed_out
else {'status': 'answered', 'answers': form_data.answers or {}}
)
output.append(
{
'type': 'function_call_output',
'id': f'fco_{form_data.call_id}',
'call_id': form_data.call_id,
'output': [{'type': 'input_text', 'text': JSONCodec.dumps(answer_payload)}],
'status': 'completed',
}
)
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
message_id,
{
'done': False,
'output': output,
},
touch=False,
)
event_emitter = await get_event_emitter(
{
'user_id': chat.user_id,
'chat_id': chat_id,
'message_id': message_id,
},
update_db=False,
)
if event_emitter:
await event_emitter({'type': 'chat:completion', 'data': {'output': output}})
result_call_ids = {
item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id')
}
paused = any(
item.get('type') == 'function_call'
and item.get('call_id')
and item.get('status') in {'pending', 'queued', 'requires_approval'}
and item.get('call_id') not in result_call_ids
for item in output
)
return {'chat': chat, 'message': message, 'output': output, 'paused': paused}
async def build_tool_approval_resume_payload(chat_id: str, message_id: str, chat=None) -> dict:
chat = chat or await Chats.get_chat_by_id(chat_id)
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
assistant_message = await Chats.get_message_by_id_and_message_id(chat_id, message_id)
if not assistant_message:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
user_message_id = assistant_message.get('parentId')
user_message = await Chats.get_message_by_id_and_message_id(chat_id, user_message_id) if user_message_id else None
if not user_message:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Tool call parent message is missing.')
chat_data = chat.chat or {}
message_meta = assistant_message.get('meta') if isinstance(assistant_message.get('meta'), dict) else {}
chat_params = chat_data.get('params') if isinstance(chat_data.get('params'), dict) else {}
params = {
**chat_params,
**(message_meta.get('params') if isinstance(message_meta.get('params'), dict) else {}),
}
current_approval_mode = chat_params.get('tool_approval_mode')
if current_approval_mode in {'ask', 'full'}:
params['tool_approval_mode'] = current_approval_mode
if 'tool_approval_mode' not in params:
params['tool_approval_mode'] = 'ask'
model_id = assistant_message.get('model') or next(iter(chat_data.get('models') or []), None)
if not model_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail='Tool call message model is missing.')
messages = []
if params.get('system'):
messages.append({'role': 'system', 'content': params.get('system')})
return {
'stream': params.get('stream_response', True),
'model': model_id,
'messages': messages,
'params': params,
'files': message_meta.get('files') or chat_data.get('files') or None,
'filter_ids': message_meta.get('filter_ids') or None,
'tool_ids': message_meta.get('tool_ids') or None,
'skill_ids': message_meta.get('skill_ids') or None,
'terminal_id': message_meta.get('terminal_id') or None,
'tool_servers': message_meta.get('tool_servers') or None,
'features': message_meta.get('features') or {},
'variables': message_meta.get('variables') or {},
'chat_variables': chat.variables,
'session_id': message_meta.get('session_id'),
'chat_id': chat_id,
'id': message_id,
'parent_id': user_message.get('parentId'),
'user_message': user_message,
'assistant_message_id': message_id,
}

View file

@ -47,6 +47,7 @@ from open_webui.models.tools import Tools
from open_webui.models.users import UserModel
from open_webui.tools.builtin import (
add_memory,
ask_user,
calculate_timestamp,
create_automation,
create_calendar_event,
@ -442,7 +443,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
)
headers.setdefault('Content-Type', 'application/json')
async def make_tool_function(function_name, tool_server_data, headers):
async def make_tool_function(function_name, tool_server_data, headers, cookies):
async def tool_function(**kwargs):
return await execute_tool_server(
url=tool_server_data['url'],
@ -455,7 +456,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr
return tool_function
tool_function = await make_tool_function(function_name, tool_server_data, headers)
tool_function = await make_tool_function(function_name, tool_server_data, headers, cookies)
callable = await get_async_tool_function_and_apply_extra_params(
tool_function,
@ -541,9 +542,9 @@ async def get_builtin_tools(
# Helper to check if a builtin tool category is enabled via meta.builtinTools
# Defaults to True if not specified (backward compatible)
def is_builtin_tool_enabled(category: str) -> bool:
def is_builtin_tool_enabled(category: str, default: bool = True) -> bool:
builtin_tools = model.get('info', {}).get('meta', {}).get('builtinTools', {})
return builtin_tools.get(category, True)
return builtin_tools.get(category, default)
# Helper to check user-level feature permission (admins always pass)
user = extra_params.get('__user__', {})
@ -583,6 +584,9 @@ async def get_builtin_tools(
if is_builtin_tool_enabled('time'):
builtin_functions.extend([get_current_timestamp, calculate_timestamp])
if is_builtin_tool_enabled('user_input', True):
builtin_functions.append(ask_user)
metadata = extra_params.get('__metadata__') or {}
chat_files = metadata.get('files') or extra_params.get('__files__') or []
has_chat_files = any(
@ -1166,15 +1170,17 @@ async def set_tool_servers(request: Request):
async def get_tool_servers(request: Request):
try:
tool_servers = []
tool_servers = None
if request.app.state.redis is not None:
try:
tool_servers = JSONCodec.loads(await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:tool_servers'))
request.app.state.TOOL_SERVERS = tool_servers
data = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:tool_servers')
if data is not None:
tool_servers = JSONCodec.loads(data)
request.app.state.TOOL_SERVERS = tool_servers
except Exception as e:
log.error(f'Error fetching tool_servers from Redis: {e}')
if not tool_servers:
if tool_servers is None:
tool_servers = await set_tool_servers(request)
return tool_servers
@ -1309,15 +1315,17 @@ async def set_terminal_servers(request: Request):
async def get_terminal_servers(request: Request):
"""Return cached terminal server specs, loading if needed."""
terminal_servers = []
terminal_servers = None
if request.app.state.redis is not None:
try:
terminal_servers = JSONCodec.loads(await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:terminal_servers'))
request.app.state.TERMINAL_SERVERS = terminal_servers
data = await request.app.state.redis.get(f'{REDIS_KEY_PREFIX}:terminal_servers')
if data is not None:
terminal_servers = JSONCodec.loads(data)
request.app.state.TERMINAL_SERVERS = terminal_servers
except Exception as e:
log.error(f'Error fetching terminal_servers from Redis: {e}')
if not terminal_servers:
if terminal_servers is None:
terminal_servers = await set_terminal_servers(request)
return terminal_servers

View file

@ -409,11 +409,15 @@ input[type='number'] {
outline: none;
}
/* unlayered, so it beats the outline-none utilities used across the app */
:focus-visible,
.ProseMirror:focus-visible {
:focus-visible:not(.ProseMirror):not([contenteditable='true']):not(input):not(textarea),
.focus-ring:focus-visible {
outline: 2px solid theme(--color-blue-500);
outline-offset: 2px;
outline-offset: -2px;
}
html.high-contrast :is(input, textarea, [contenteditable='true'], .ProseMirror):focus-visible {
outline: 2px solid theme(--color-blue-500);
outline-offset: -2px;
}
.ProseMirror p.is-editor-empty:first-child::before {

View file

@ -5,11 +5,17 @@ export type AutomationTerminalConfig = {
cwd?: string;
};
export type AutomationTarget = {
type: 'chat' | 'channel';
channel_id?: string | null;
};
export type AutomationData = {
prompt: string;
model_id: string;
rrule: string;
terminal?: AutomationTerminalConfig;
target?: AutomationTarget | null;
};
export type AutomationForm = {

View file

@ -1369,6 +1369,46 @@ export const deleteChatMessageById = async (token: string, id: string, messageId
return res;
};
export const resolveChatMessageToolCall = async (
token: string,
id: string,
messageId: string,
callId: string,
action: 'approve' | 'reject' | 'answer',
options: { answers?: unknown; timed_out?: boolean } = {}
) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/messages/${messageId}/resolve`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
},
body: JSON.stringify({
call_id: callId,
action,
...options
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = getErrorDetail(err);
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteChatById = async (token: string, id: string) => {
let error = null;

View file

@ -1758,6 +1758,7 @@ export interface ModelConfig {
export interface ModelMeta {
toolIds: never[];
description?: string;
hidden?: boolean;
capabilities?: object;
profile_image_url?: string;
}

View file

@ -955,6 +955,34 @@ export const reindexKnowledgeFiles = async (token: string) => {
return res;
};
export const reindexKnowledgeMetadata = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/knowledge/metadata/reindex`, {
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 exportKnowledgeById = async (token: string, id: string) => {
let error = null;

View file

@ -128,6 +128,34 @@ export const queryMemory = async (token: string, content: string) => {
return res;
};
export const reindexMemoryVectors = async (token: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/memories/reindex`, {
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 deleteMemoryById = async (token: string, id: string) => {
let error = null;

View file

@ -1,5 +1,18 @@
import { OPENAI_API_BASE_URL, WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
export const getErrorMessage = (err: any, fallback = 'Server connection failed') => {
const detail = err?.detail;
if (typeof detail === 'string') return detail;
return (
detail?.error?.message ??
detail?.message ??
err?.error?.message ??
err?.message ??
(typeof err === 'string' ? err : fallback)
);
};
export const getOpenAIConfig = async (token: string = '') => {
let error = null;
@ -17,11 +30,7 @@ export const getOpenAIConfig = async (token: string = '') => {
})
.catch((err) => {
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
error = 'Server connection failed';
}
error = getErrorMessage(err);
return null;
});
@ -59,11 +68,7 @@ export const updateOpenAIConfig = async (token: string = '', config: OpenAIConfi
})
.catch((err) => {
console.error(err);
if ('detail' in err) {
error = err.detail;
} else {
error = 'Server connection failed';
}
error = getErrorMessage(err);
return null;
});
@ -131,9 +136,197 @@ export const getOpenAIModels = async (token: string, urlIdx?: number) => {
return res;
};
export const getProviderModelCatalog = async (token: string, urlIdx: number) => {
let error = null;
const res = await fetch(`${OPENAI_API_BASE_URL}/models/${urlIdx}/catalog`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token && { authorization: `Bearer ${token}` })
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = getErrorMessage(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const downloadProviderModel = async (
token: string,
urlIdx: number,
model: string,
signal?: AbortSignal
) => {
let error = null;
const res = await fetch(`${OPENAI_API_BASE_URL}/models/${urlIdx}/download`, {
signal,
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ model })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = getErrorMessage(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getProviderModelDownloadStatus = async (
token: string,
urlIdx: number,
jobId: string,
signal?: AbortSignal
) => {
let error = null;
const res = await fetch(
`${OPENAI_API_BASE_URL}/models/${urlIdx}/download/status/${encodeURIComponent(jobId)}`,
{
signal,
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 = getErrorMessage(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const loadProviderModel = async (token: string, urlIdx: number, model: string) => {
let error = null;
const res = await fetch(`${OPENAI_API_BASE_URL}/models/${urlIdx}/load`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ model })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = getErrorMessage(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const unloadProviderModel = async (
token: string,
urlIdx: number,
model: string,
instanceId?: string
) => {
let error = null;
const res = await fetch(`${OPENAI_API_BASE_URL}/models/${urlIdx}/unload`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ model, ...(instanceId ? { instance_id: instanceId } : {}) })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = getErrorMessage(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteProviderModel = async (token: string, urlIdx: number, model: string) => {
let error = null;
const res = await fetch(
`${OPENAI_API_BASE_URL}/models/${urlIdx}?${new URLSearchParams({ model })}`,
{
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 = getErrorMessage(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const verifyOpenAIConnection = async (
token: string = '',
connection: dict = {},
connection: Record<string, any> = {},
direct: boolean = false
) => {
const { url, key, config } = connection;
@ -246,7 +439,7 @@ export const generateOpenAIChatCompletion = async (
return res.json();
})
.catch((err) => {
error = err?.detail ?? err;
error = getErrorMessage(err);
return null;
});

View file

@ -188,6 +188,8 @@ type OpenAIConfigForm = {
url: string;
};
type OllamaConfigForm = OpenAIConfigForm;
type AzureOpenAIConfigForm = {
key: string;
url: string;
@ -196,10 +198,13 @@ type AzureOpenAIConfigForm = {
type EmbeddingModelUpdateForm = {
openai_config?: OpenAIConfigForm;
ollama_config?: OllamaConfigForm;
azure_openai_config?: AzureOpenAIConfigForm;
embedding_engine: string;
embedding_model: string;
embedding_batch_size?: number;
RAG_EMBEDDING_ENGINE: string;
RAG_EMBEDDING_MODEL: string;
RAG_EMBEDDING_BATCH_SIZE?: number;
ENABLE_ASYNC_EMBEDDING?: boolean;
RAG_EMBEDDING_CONCURRENT_REQUESTS?: number;
};
export const updateEmbeddingConfig = async (token: string, payload: EmbeddingModelUpdateForm) => {

View file

@ -31,10 +31,13 @@ export const createNewSkill = async (token: string, skill: object) => {
return res;
};
export const getSkills = async (token: string = '') => {
export const getSkills = async (token: string = '', query: string | null = null) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/skills/`, {
const searchParams = new URLSearchParams();
if (query) searchParams.append('query', query);
const res = await fetch(`${WEBUI_API_BASE_URL}/skills/?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',

View file

@ -65,10 +65,13 @@ export const loadToolByUrl = async (token: string = '', url: string) => {
return res;
};
export const getTools = async (token: string = '') => {
export const getTools = async (token: string = '', query: string | null = null) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/tools/`, {
const searchParams = new URLSearchParams();
if (query) searchParams.append('query', query);
const res = await fetch(`${WEBUI_API_BASE_URL}/tools/?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',

View file

@ -286,7 +286,7 @@ export const getUserSettings = async (token: string, raw = false) => {
})
.catch((err) => {
console.error(err);
error = err.detail;
error = err?.detail ?? err;
return null;
});

View file

@ -606,6 +606,7 @@
<option value="">{$i18n.t('Default')}</option>
<option value="azure">{$i18n.t('Azure OpenAI')}</option>
<option value="llama.cpp">{$i18n.t('llama.cpp')}</option>
<option value="lmstudio">{$i18n.t('LM Studio')}</option>
<option value="litellm">{$i18n.t('LiteLLM')}</option>
</select>
</div>

View file

@ -8,9 +8,10 @@
import ScheduleDropdown from '$lib/components/automations/ScheduleDropdown.svelte';
import ModelDropdown from '$lib/components/automations/ModelDropdown.svelte';
import FolderDropdown from '$lib/components/automations/FolderDropdown.svelte';
import DestinationDropdown from '$lib/components/automations/DestinationDropdown.svelte';
import { getFolders } from '$lib/apis/folders';
import { folders } from '$lib/stores';
import { getChannels } from '$lib/apis/channels';
import { channels, folders } from '$lib/stores';
import {
createAutomation,
@ -30,10 +31,13 @@
let prompt = '';
let model_id = '';
let folder_id = '';
let target_type: 'chat' | 'channel' = 'chat';
let channel_id = '';
let is_active = true;
let loading = false;
let foldersLoaded = false;
let channelsLoaded = false;
// Schedule dropdown ref
let scheduleDropdown: ScheduleDropdown;
@ -43,6 +47,10 @@
toast.error($i18n.t('Name, prompt, and model are required'));
return;
}
if (target_type === 'channel' && !channel_id) {
toast.error($i18n.t('Channel is required'));
return;
}
if (scheduleDropdown?.frequency === 'ONCE') {
const scheduled = new Date(`${scheduleDropdown.onceDate}T${scheduleDropdown.onceTime}`);
if (scheduled <= new Date()) {
@ -54,11 +62,12 @@
try {
const form: AutomationForm = {
name: name.trim(),
folder_id: folder_id || null,
folder_id: target_type === 'channel' ? null : folder_id || null,
data: {
prompt: prompt.trim(),
model_id: model_id.trim(),
rrule: scheduleDropdown.buildRrule()
rrule: scheduleDropdown.buildRrule(),
target: target_type === 'channel' ? { type: 'channel', channel_id } : { type: 'chat' }
},
is_active
};
@ -88,12 +97,19 @@
if (res) folders.set(res);
foldersLoaded = true;
}
if (!channelsLoaded && ($channels ?? []).length === 0) {
const res = await getChannels(localStorage.token).catch(() => null);
if (res) channels.set(res);
channelsLoaded = true;
}
if (automation) {
name = automation.name;
prompt = automation.data.prompt;
model_id = automation.data.model_id;
folder_id = automation.folder_id ?? '';
target_type = automation.data.target?.type === 'channel' ? 'channel' : 'chat';
channel_id = automation.data.target?.channel_id ?? '';
is_active = automation.is_active;
if (scheduleDropdown) {
scheduleDropdown.parseRrule(automation.data.rrule);
@ -105,6 +121,12 @@
folder_id = ($folders ?? []).some((folder) => folder.id === cloneFrom.folder_id)
? (cloneFrom.folder_id ?? '')
: '';
target_type = cloneFrom.data.target?.type === 'channel' ? 'channel' : 'chat';
channel_id = ($channels ?? []).some(
(channel) => channel.id === cloneFrom.data.target?.channel_id
)
? (cloneFrom.data.target?.channel_id ?? '')
: '';
is_active = true;
if (scheduleDropdown) {
scheduleDropdown.parseRrule(cloneFrom.data.rrule);
@ -114,6 +136,8 @@
prompt = '';
model_id = '';
folder_id = '';
target_type = 'chat';
channel_id = '';
is_active = true;
}
};
@ -162,7 +186,15 @@
<ModelDropdown bind:model_id side="top" align="start" />
<FolderDropdown bind:folder_id side="top" align="start" />
<DestinationDropdown
bind:target_type
bind:channel_id
bind:folder_id
folders={$folders}
channels={$channels}
side="top"
align="start"
/>
</div>
<div class="flex items-center justify-end gap-2 shrink-0">

View file

@ -154,7 +154,7 @@
class="mt-[0.6em] h-1 w-1 shrink-0 rounded-full bg-gray-300 dark:bg-gray-700"
></span>
<div
class="min-w-0 markdown-prose-sm !max-w-none !text-[0.8125rem] text-gray-600 dark:text-gray-300 [&_*]:!my-0 [&_b]:!font-normal [&_strong]:!font-normal"
class="min-w-0 markdown-prose-sm list-none !max-w-none !text-[0.8125rem] text-gray-600 dark:text-gray-300 [&_*]:!my-0 [&_b]:!font-normal [&_strong]:!font-normal"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html DOMPurify.sanitize(entry?.raw)}

View file

@ -304,11 +304,10 @@
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
{#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-normal mb-1">{$i18n.t('No feedback found')}</div>
<div class="text-gray-500 text-center text-xs">
<div class="flex w-full flex-col items-center justify-center py-16 pb-24">
<div class="max-w-sm text-center text-gray-900 dark:text-gray-100">
<div class="mb-1.5 text-sm">{$i18n.t('No feedback found')}</div>
<div class="text-center text-xs leading-5 text-gray-500">
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
</div>
</div>

View file

@ -14,6 +14,7 @@
import Switch from '$lib/components/common/Switch.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ArrowPath from '$lib/components/icons/ArrowPath.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import OpenAIConnection from './Connections/OpenAIConnection.svelte';
@ -50,6 +51,7 @@
let pipelineUrls: Record<string, boolean> = {};
let showAddOpenAIConnectionModal = false;
let showAddOllamaConnectionModal = false;
let modelListRefreshing = false;
const updateOpenAIHandler = async () => {
if (ENABLE_OPENAI_API !== null) {
@ -120,6 +122,19 @@
}
};
const refreshModelListHandler = async () => {
modelListRefreshing = true;
try {
await models.set(await getModels());
toast.success($i18n.t('Model list refreshed'));
} catch (error) {
toast.error(`${error}`);
} finally {
modelListRefreshing = false;
}
};
const addOpenAIConnectionHandler = async (connection: any) => {
OPENAI_API_BASE_URLS = [...OPENAI_API_BASE_URLS, connection.url];
OPENAI_API_KEYS = [...OPENAI_API_KEYS, connection.key];
@ -373,13 +388,33 @@
)}
let:labelId
>
<Switch
bind:state={connectionsConfig.ENABLE_BASE_MODELS_CACHE}
on:change={async () => {
updateConnectionsHandler();
}}
ariaLabelledbyId={labelId}
/>
<div class="flex items-center gap-1.5">
{#if connectionsConfig.ENABLE_BASE_MODELS_CACHE}
<Tooltip content={$i18n.t('Refresh')}>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-black/5 hover:text-gray-700 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-600 dark:hover:bg-white/5 dark:hover:text-gray-300"
type="button"
disabled={modelListRefreshing}
on:click={refreshModelListHandler}
aria-label={$i18n.t('Refresh')}
>
{#if modelListRefreshing}
<Spinner className="size-3.5" />
{:else}
<ArrowPath className="size-4" />
{/if}
</button>
</Tooltip>
{/if}
<Switch
bind:state={connectionsConfig.ENABLE_BASE_MODELS_CACHE}
on:change={async () => {
updateConnectionsHandler();
}}
ariaLabelledbyId={labelId}
/>
</div>
</AdminSettingRow>
</AdminSettingSection>
{:else}

View file

@ -17,12 +17,13 @@
updateRAGConfig
} from '$lib/apis/retrieval';
import { reindexKnowledgeFiles } from '$lib/apis/knowledge';
import { reindexKnowledgeFiles, reindexKnowledgeMetadata } from '$lib/apis/knowledge';
import { reindexMemoryVectors } from '$lib/apis/memories';
import { deleteAllFiles } from '$lib/apis/files';
import ResetUploadDirConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import ResetVectorDBConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import ReindexKnowledgeFilesConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import ReindexEmbeddingDataConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
@ -121,26 +122,33 @@
});
updateEmbeddingModelLoading = true;
const res = await updateEmbeddingConfig(localStorage.token, {
const payload: Parameters<typeof updateEmbeddingConfig>[1] = {
RAG_EMBEDDING_ENGINE: RAG_EMBEDDING_ENGINE,
RAG_EMBEDDING_MODEL: RAG_EMBEDDING_MODEL,
RAG_EMBEDDING_BATCH_SIZE: RAG_EMBEDDING_BATCH_SIZE,
ENABLE_ASYNC_EMBEDDING: ENABLE_ASYNC_EMBEDDING,
RAG_EMBEDDING_CONCURRENT_REQUESTS: RAG_EMBEDDING_CONCURRENT_REQUESTS,
ollama_config: {
RAG_EMBEDDING_CONCURRENT_REQUESTS: RAG_EMBEDDING_CONCURRENT_REQUESTS
};
if (RAG_EMBEDDING_ENGINE === 'ollama') {
payload.ollama_config = {
key: OllamaKey,
url: OllamaUrl
},
openai_config: {
};
} else if (RAG_EMBEDDING_ENGINE === 'openai') {
payload.openai_config = {
key: OpenAIKey,
url: OpenAIUrl
},
azure_openai_config: {
};
} else if (RAG_EMBEDDING_ENGINE === 'azure_openai') {
payload.azure_openai_config = {
key: AzureOpenAIKey,
url: AzureOpenAIUrl,
version: AzureOpenAIVersion
}
}).catch(async (error) => {
};
}
const res = await updateEmbeddingConfig(localStorage.token, payload).catch(async (error) => {
toast.error(`${error}`);
await setEmbeddingConfig();
return null;
@ -299,15 +307,15 @@
ENABLE_ASYNC_EMBEDDING = embeddingConfig.ENABLE_ASYNC_EMBEDDING ?? true;
RAG_EMBEDDING_CONCURRENT_REQUESTS = embeddingConfig.RAG_EMBEDDING_CONCURRENT_REQUESTS ?? 0;
OpenAIKey = embeddingConfig.openai_config.key;
OpenAIUrl = embeddingConfig.openai_config.url;
OpenAIKey = embeddingConfig.openai_config.key ?? '';
OpenAIUrl = embeddingConfig.openai_config.url ?? '';
OllamaKey = embeddingConfig.ollama_config.key;
OllamaUrl = embeddingConfig.ollama_config.url;
OllamaKey = embeddingConfig.ollama_config.key ?? '';
OllamaUrl = embeddingConfig.ollama_config.url ?? '';
AzureOpenAIKey = embeddingConfig.azure_openai_config.key;
AzureOpenAIUrl = embeddingConfig.azure_openai_config.url;
AzureOpenAIVersion = embeddingConfig.azure_openai_config.version;
AzureOpenAIKey = embeddingConfig.azure_openai_config.key ?? '';
AzureOpenAIUrl = embeddingConfig.azure_openai_config.url ?? '';
AzureOpenAIVersion = embeddingConfig.azure_openai_config.version ?? '';
}
};
onMount(async () => {
@ -371,15 +379,37 @@
}}
/>
<ReindexKnowledgeFilesConfirmDialog
<ReindexEmbeddingDataConfirmDialog
bind:show={showReindexConfirm}
title={$i18n.t('Reindex Embedding Data')}
message={$i18n.t(
'Rebuild knowledge file, knowledge search, and memory vectors using the current embedding model.'
)}
on:confirm={async () => {
const res = await reindexKnowledgeFiles(localStorage.token).catch((error) => {
const knowledgeRes = await reindexKnowledgeFiles(localStorage.token).catch((error) => {
toast.error(`${error}`);
return null;
});
if (!knowledgeRes) {
return;
}
const knowledgeMetadataRes = await reindexKnowledgeMetadata(localStorage.token).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
if (!knowledgeMetadataRes) {
return;
}
const memoryRes = await reindexMemoryVectors(localStorage.token).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
if (memoryRes) {
toast.success($i18n.t('Success'));
}
}}
@ -1116,7 +1146,7 @@
</div>
<div class="mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t(
'After changing the embedding model, reindex the knowledge base for changes to take effect.'
'After changing the embedding model, reindex knowledge, knowledge search, and memory vectors for changes to take effect.'
)}
</div>
</AdminSettingField>
@ -1526,8 +1556,10 @@
</button>
</AdminSettingRow>
<AdminSettingRow
label={$i18n.t('Reindex Knowledge Base Vectors')}
description={$i18n.t('Rebuild vectors for existing knowledge files.')}
label={$i18n.t('Reindex Knowledge and Memory Vectors')}
description={$i18n.t(
'Rebuild vectors for existing knowledge files, knowledge search, and memories.'
)}
>
<button
class={actionButtonClass}

View file

@ -9,6 +9,7 @@
import Spinner from '$lib/components/common/Spinner.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ExperimentalBadge from '$lib/components/common/ExperimentalBadge.svelte';
import Cog6 from '$lib/components/icons/Cog6.svelte';
import DatabaseSettings from '$lib/components/icons/DatabaseSettings.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
@ -797,11 +798,7 @@
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-2 leading-none text-gray-600 dark:text-gray-400">
<div>{$i18n.t('External Knowledge Sources')}</div>
<span
class="inline-flex items-center text-[0.625rem] font-normal uppercase leading-none text-gray-400 dark:text-gray-600"
>
{$i18n.t('Experimental')}
</span>
<ExperimentalBadge />
</div>
<Tooltip content={$i18n.t('Add Connection')}>

View file

@ -150,8 +150,9 @@
});
if (res) {
backendConfig.set(await getBackendConfig());
if (res.ENABLE_IMAGE_GENERATION) {
backendConfig.set(await getBackendConfig());
getModels();
}

View file

@ -10,6 +10,7 @@
import Textarea from '$lib/components/common/Textarea.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import SettingsSelect from '$lib/components/common/SettingsSelect.svelte';
import ExperimentalBadge from '$lib/components/common/ExperimentalBadge.svelte';
import AdvancedParams from '$lib/components/chat/Settings/Advanced/AdvancedParams.svelte';
import AdminSettingField from './AdminSettingField.svelte';
import AdminSettingRow from './AdminSettingRow.svelte';
@ -48,7 +49,8 @@
CONTEXT_COMPACTION_TOKEN_THRESHOLD: 80000,
CONTEXT_COMPACTION_TOKEN_CAP: 80000,
CONTEXT_COMPACTION_RETENTION_PERCENTAGE: 40,
CONTEXT_COMPACTION_PROMPT_TEMPLATE: ''
CONTEXT_COMPACTION_PROMPT_TEMPLATE: '',
ENABLE_TOOL_PERMISSIONS: false
};
let showTaskParameters = false;
@ -75,7 +77,8 @@
...current,
features: {
...current.features,
enable_context_compaction: chatConfig.ENABLE_CONTEXT_COMPACTION
enable_context_compaction: chatConfig.ENABLE_CONTEXT_COMPACTION,
enable_tool_permissions: chatConfig.ENABLE_TOOL_PERMISSIONS
}
}
: current
@ -253,6 +256,18 @@
</AdminSettingSection>
<AdminSettingSection title={$i18n.t('Chat')}>
<AdminSettingRow
label={$i18n.t('Tool Permissions')}
description={$i18n.t('Show Full access and Ask for approval in the chat input menu.')}
let:labelId
>
<div slot="label" class="flex items-center gap-2">
<span>{$i18n.t('Tool Permissions')}</span>
<ExperimentalBadge />
</div>
<Switch bind:state={chatConfig.ENABLE_TOOL_PERMISSIONS} ariaLabelledbyId={labelId} />
</AdminSettingRow>
<AdminSettingRow
label={$i18n.t('Context Compaction')}
description={$i18n.t(

View file

@ -7,7 +7,14 @@
import { onMount, onDestroy, getContext, tick } from 'svelte';
const i18n = getContext('i18n');
import { config, models as _models, settings, showSettings, user } from '$lib/stores';
import {
config,
models as _models,
pinnedModels,
settings,
showSettings,
user
} from '$lib/stores';
import {
createNewModel,
deleteAllModels,
@ -607,15 +614,12 @@
};
const pinModelHandler = async (modelId) => {
let pinnedModels = $settings?.pinnedModels ?? [];
if (pinnedModels.includes(modelId)) {
pinnedModels = pinnedModels.filter((id) => id !== modelId);
} else {
pinnedModels = [...new Set([...pinnedModels, modelId])];
}
settings.set({ ...$settings, pinnedModels: pinnedModels });
settings.set({
...$settings,
pinnedModels: $pinnedModels.includes(modelId)
? $pinnedModels.filter((id) => id !== modelId)
: [...$pinnedModels, modelId]
});
await updateUserSettings(localStorage.token, { ui: $settings });
};
@ -933,7 +937,7 @@
}}
>
<div class="self-center">
<div class="flex bg-white rounded-xl">
<div class="flex rounded-xl">
<div
class="{(model?.is_active ?? true)
? ''

View file

@ -0,0 +1,56 @@
<script lang="ts">
import { getContext } from 'svelte';
import SettingsSelect from '$lib/components/common/SettingsSelect.svelte';
import ManageProviderModels from './ManageProviderModels.svelte';
const i18n: any = getContext('i18n');
export let connections: { idx: number; url: string; provider?: string }[] = [];
let selectedUrlIdx = `${connections[0]?.idx ?? 0}`;
const getProviderLabel = (provider = '') => {
if (provider === 'lmstudio') return $i18n.t('LM Studio');
if (provider === 'llama.cpp') return $i18n.t('llama.cpp');
return provider;
};
const providerSupportsDelete = (provider = '') => provider === 'llama.cpp';
$: if (
connections.length > 0 &&
!connections.some((connection) => `${connection.idx}` === selectedUrlIdx)
) {
selectedUrlIdx = `${connections[0].idx}`;
}
$: selectedConnection =
connections.find((connection) => `${connection.idx}` === selectedUrlIdx) ?? connections[0];
</script>
{#if connections.length > 0}
<div class="mb-2 text-sm font-normal">{$i18n.t('Model providers')}</div>
<div class="mb-2.5 flex-1">
<SettingsSelect
bind:value={selectedUrlIdx}
className="w-full"
placeholder={$i18n.t('Select an instance')}
>
{#each connections as connection}
<option value={`${connection.idx}`}>
{getProviderLabel(connection.provider)} - {connection.url}
</option>
{/each}
</SettingsSelect>
</div>
<div>
<ManageProviderModels
urlIdx={Number(selectedUrlIdx)}
provider={selectedConnection?.provider ?? ''}
providerLabel={getProviderLabel(selectedConnection?.provider)}
supportsDelete={providerSupportsDelete(selectedConnection?.provider)}
/>
</div>
{/if}

View file

@ -24,6 +24,7 @@
import ModelDeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import SettingsSelect from '$lib/components/common/SettingsSelect.svelte';
import ExperimentalBadge from '$lib/components/common/ExperimentalBadge.svelte';
let modelUploadInputElement: HTMLInputElement;
let showModelDeleteConfirm = false;
@ -938,7 +939,7 @@
<div class="pt-1">
<div class="flex justify-between items-center text-xs">
<div class=" text-sm font-normal">{$i18n.t('Experimental')}</div>
<div class="text-sm font-normal"><ExperimentalBadge /></div>
<button
class=" text-xs font-normal text-gray-500"
type="button"

View file

@ -0,0 +1,296 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { getContext, onMount } from 'svelte';
import {
deleteProviderModel,
downloadProviderModel,
getErrorMessage,
getProviderModelCatalog,
loadProviderModel,
unloadProviderModel
} from '$lib/apis/openai';
import { getModels } from '$lib/apis';
import { config, models, settings } from '$lib/stores';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Download from '$lib/components/icons/Download.svelte';
import Play from '$lib/components/icons/Play.svelte';
import Refresh from '$lib/components/icons/Refresh.svelte';
import Trash from '$lib/components/icons/Trash.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
const i18n: any = getContext('i18n');
export let urlIdx: number;
type ProviderModel = {
id?: string;
key?: string;
name?: string;
model?: string;
display_name?: string;
status?: string | { value?: string };
path?: string;
size?: number;
loaded_instances?: { id?: string }[];
[key: string]: unknown;
};
let loading = true;
let actionModel = '';
let modelRef = '';
let modelToDelete = '';
let showDeleteConfirm = false;
export let provider = '';
export let providerLabel = '';
export let supportsDelete = false;
let providerModels: ProviderModel[] = [];
const inputClass =
'h-7 w-full rounded-lg border border-gray-100/50 bg-gray-50/40 px-2.5 text-left text-xs text-gray-700 outline-hidden transition-colors focus:border-blue-400 disabled:opacity-50 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:focus:border-blue-500';
const iconButtonClass =
'inline-flex h-7 items-center justify-center rounded-lg border border-gray-100/50 bg-gray-50/40 px-2.5 text-gray-700 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:hover:bg-white/[0.06]';
const getModelId = (model: ProviderModel) => model.key ?? model.id ?? model.name ?? model.model ?? '';
const getDisplayName = (model: ProviderModel) => model.display_name ?? getModelId(model);
const getUnloadId = (model: ProviderModel) => model.loaded_instances?.[0]?.id ?? getModelId(model);
const getStatus = (model: ProviderModel) => {
if (model.loaded_instances?.length) {
return 'loaded';
}
if (provider === 'lmstudio') {
return 'unloaded';
}
const status = model.status;
if (typeof status === 'string') return status;
return status?.value ?? 'available';
};
const getStatusClass = (status: string) => {
if (status === 'loaded' || status === 'sleeping') {
return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
}
if (status === 'loading' || status === 'downloading') {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300';
}
return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300';
};
const normalizeModels = (response: any): ProviderModel[] => {
const entries = Array.isArray(response)
? response
: (response?.models ?? response?.data ?? response?.items ?? []);
return entries
.map((model: ProviderModel | string) =>
typeof model === 'string' ? { id: model, name: model } : model
)
.filter((model: ProviderModel) => getModelId(model) !== '')
.sort((a: ProviderModel, b: ProviderModel) => getModelId(a).localeCompare(getModelId(b)));
};
const refreshModels = async () => {
loading = true;
const res = await getProviderModelCatalog(localStorage.token, urlIdx).catch((error) => {
toast.error(getErrorMessage(error));
return null;
});
providerModels = normalizeModels(res);
loading = false;
};
const refreshGlobalModels = async () => {
await models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections ? ($settings?.directConnections ?? null) : null
)
);
};
const runModelAction = async (
modelId: string,
action: (token: string, urlIdx: number, model: string) => Promise<unknown>,
successMessage: string
) => {
actionModel = modelId;
const res = await action(localStorage.token, urlIdx, modelId).catch((error) => {
toast.error(getErrorMessage(error));
return null;
});
if (res) {
toast.success(successMessage);
await refreshModels();
await refreshGlobalModels();
}
actionModel = '';
};
const downloadModelHandler = async () => {
const model = modelRef.trim();
if (!model) return;
await runModelAction(
model,
downloadProviderModel,
$i18n.t('Model download started')
);
modelRef = '';
};
const loadModelHandler = async (model: string) => {
await runModelAction(model, loadProviderModel, $i18n.t('Model loaded successfully'));
};
const unloadModelHandler = async (model: ProviderModel) => {
const modelId = getModelId(model);
const instanceId = getUnloadId(model);
actionModel = modelId;
const res = await unloadProviderModel(localStorage.token, urlIdx, modelId, instanceId).catch(
(error) => {
toast.error(getErrorMessage(error));
return null;
}
);
if (res) {
toast.success($i18n.t('Model unloaded successfully'));
await refreshModels();
await refreshGlobalModels();
}
actionModel = '';
};
const deleteModelHandler = async () => {
await runModelAction(modelToDelete, deleteProviderModel, $i18n.t('Model deleted successfully'));
modelToDelete = '';
};
$: if (urlIdx !== undefined) {
refreshModels();
}
onMount(refreshModels);
</script>
<ConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete Model')}
message={$i18n.t('This will delete the cached model and cannot be undone.')}
onConfirm={deleteModelHandler}
/>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<div class="text-sm font-normal">{providerLabel || provider}</div>
<Tooltip content={$i18n.t('Refresh')}>
<button class={iconButtonClass} type="button" on:click={refreshModels} disabled={loading}>
<Refresh className="size-4" />
</button>
</Tooltip>
</div>
<form class="flex gap-1.5" on:submit|preventDefault={downloadModelHandler}>
<input
class={inputClass}
type="text"
bind:value={modelRef}
placeholder={$i18n.t('Type a model ref')}
autocomplete="off"
/>
<Tooltip content={$i18n.t('Download Model')}>
<button class={iconButtonClass} type="submit" disabled={actionModel !== '' || modelRef.trim() === ''}>
<Download className="size-4" />
</button>
</Tooltip>
</form>
{#if loading}
<div class="py-5">
<Spinner />
</div>
{:else if providerModels.length === 0}
<div class="py-5 text-center text-xs text-gray-400">
{$i18n.t('No models found')}
</div>
{:else}
<div class="max-h-96 overflow-y-auto rounded-lg border border-gray-100/50 dark:border-white/[0.04]">
{#each providerModels as model}
{@const modelId = getModelId(model)}
{@const displayName = getDisplayName(model)}
{@const status = getStatus(model)}
<div
class="flex items-center justify-between gap-2 border-b border-gray-100/50 px-2 py-2 last:border-b-0 dark:border-white/[0.04]"
>
<div class="min-w-0 flex-1">
<div class="truncate text-xs font-medium text-gray-700 dark:text-gray-200">
{displayName}
</div>
{#if displayName !== modelId}
<div class="truncate text-[0.65rem] text-gray-400">{modelId}</div>
{/if}
<div class="mt-1 flex items-center gap-1.5">
<span class="rounded-full px-1.5 py-0.5 text-[0.65rem] {getStatusClass(status)}">
{status}
</span>
</div>
</div>
<div class="flex shrink-0 gap-1">
<Tooltip content={$i18n.t('Load Model')}>
<button
class={iconButtonClass}
type="button"
aria-label={$i18n.t('Load Model')}
disabled={actionModel !== '' || status === 'loaded' || status === 'loading'}
on:click={() => loadModelHandler(modelId)}
>
<Play className="size-4" />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Unload Model')}>
<button
class={iconButtonClass}
type="button"
aria-label={$i18n.t('Unload Model')}
disabled={actionModel !== '' || status === 'unloaded'}
on:click={() => unloadModelHandler(model)}
>
<XMark className="size-4" />
</button>
</Tooltip>
{#if supportsDelete}
<Tooltip content={$i18n.t('Delete Model')}>
<button
class={iconButtonClass}
type="button"
aria-label={$i18n.t('Delete Model')}
disabled={actionModel !== ''}
on:click={() => {
modelToDelete = modelId;
showDeleteConfirm = true;
}}
>
<Trash className="size-4" />
</button>
</Tooltip>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -1,38 +1,72 @@
<script>
import { toast } from 'svelte-sonner';
import { createEventDispatcher, getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
<script lang="ts">
import { getContext, onMount } from 'svelte';
const i18n: any = getContext('i18n');
import { user } from '$lib/stores';
import XMark from '$lib/components/icons/XMark.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import ManageOllama from './Manage/ManageOllama.svelte';
import { getOllamaConfig } from '$lib/apis/ollama';
import { getOpenAIConfig } from '$lib/apis/openai';
import Spinner from '$lib/components/common/Spinner.svelte';
import SettingsSelect from '$lib/components/common/SettingsSelect.svelte';
import ManageMultipleOllama from './Manage/ManageMultipleOllama.svelte';
import ManageMultipleProviderModels from './Manage/ManageMultipleProviderModels.svelte';
export let show = false;
let selected = null;
let ollamaConfig = null;
type ProviderConnection = { idx: number; url: string; provider: string; config: Record<string, any> };
const MANAGEMENT_PROVIDERS = new Set(['llama.cpp', 'lmstudio']);
let selected: '' | 'ollama' | 'provider' | null = null;
let ollamaConfig: any = null;
let providerConnections: ProviderConnection[] = [];
$: hasOllamaManagement =
ollamaConfig?.ENABLE_OLLAMA_API && (ollamaConfig?.OLLAMA_BASE_URLS ?? []).length > 0;
$: hasProviderManagement = providerConnections.length > 0;
onMount(async () => {
if ($user?.role === 'admin') {
let openaiConfig: any = null;
await Promise.all([
(async () => {
ollamaConfig = await getOllamaConfig(localStorage.token);
})(),
(async () => {
openaiConfig = await getOpenAIConfig(localStorage.token);
})()
]);
if (ollamaConfig) {
providerConnections =
openaiConfig?.ENABLE_OPENAI_API
? (openaiConfig.OPENAI_API_BASE_URLS ?? [])
.map((url: string, idx: number) => ({
idx,
url,
provider:
(openaiConfig.OPENAI_API_CONFIGS?.[idx] ??
openaiConfig.OPENAI_API_CONFIGS?.[String(idx)] ??
openaiConfig.OPENAI_API_CONFIGS?.[url] ??
{})?.provider ?? '',
config:
openaiConfig.OPENAI_API_CONFIGS?.[idx] ??
openaiConfig.OPENAI_API_CONFIGS?.[String(idx)] ??
openaiConfig.OPENAI_API_CONFIGS?.[url] ??
{}
}))
.filter((connection: ProviderConnection) => MANAGEMENT_PROVIDERS.has(connection.provider))
: [];
const hasOllama = ollamaConfig?.ENABLE_OLLAMA_API && (ollamaConfig?.OLLAMA_BASE_URLS ?? []).length > 0;
const hasProvider = providerConnections.length > 0;
if (hasOllama) {
selected = 'ollama';
return;
}
selected = '';
selected = hasProvider ? 'provider' : '';
}
});
</script>
@ -64,8 +98,22 @@
{:else if selected !== null}
<div class=" flex w-full flex-col">
<div class=" px-1.5 py-1">
{#if hasOllamaManagement && hasProviderManagement}
<div class="mb-2">
<SettingsSelect
bind:value={selected}
className="w-full"
placeholder={$i18n.t('Select an engine')}
>
<option value="ollama">{$i18n.t('Ollama')}</option>
<option value="provider">{$i18n.t('Model providers')}</option>
</SettingsSelect>
</div>
{/if}
{#if selected === 'ollama'}
<ManageMultipleOllama {ollamaConfig} />
{:else if selected === 'provider'}
<ManageMultipleProviderModels connections={providerConnections} />
{/if}
</div>
</div>

View file

@ -18,7 +18,7 @@
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte';
import { config, settings } from '$lib/stores';
import { config, pinnedModels, settings } from '$lib/stores';
import Link from '$lib/components/icons/Link.svelte';
const i18n = getContext('i18n');
@ -173,14 +173,14 @@
class="select-none flex w-full gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl"
on:click={() => runAndClose(() => pinModelHandler(model?.id))}
>
{#if ($settings?.pinnedModels ?? []).includes(model?.id)}
{#if $pinnedModels.includes(model?.id)}
<PinSlash />
{:else}
<Pin />
{/if}
<div class="flex items-center">
{#if ($settings?.pinnedModels ?? []).includes(model?.id)}
{#if $pinnedModels.includes(model?.id)}
{$i18n.t('Hide from Sidebar')}
{:else}
{$i18n.t('Keep in Sidebar')}

View file

@ -184,11 +184,10 @@
{/each}
</div>
{:else}
<div class="w-full h-full flex flex-col justify-center items-center my-16 mb-24">
<div class="max-w-md text-center">
<div class="text-3xl mb-3">👥</div>
<div class="text-lg font-normal mb-1">{$i18n.t('No groups found')}</div>
<div class="text-gray-500 text-center text-xs">
<div class="flex w-full flex-col items-center justify-center py-16 pb-24">
<div class="max-w-sm text-center text-gray-900 dark:text-gray-100">
<div class="mb-1.5 text-sm">{$i18n.t('No groups found')}</div>
<div class="text-center text-xs leading-5 text-gray-500">
{$i18n.t('Use groups to organize your users and assign permissions.')}
</div>
</div>

View file

@ -7,8 +7,9 @@
import localizedFormat from 'dayjs/plugin/localizedFormat';
import type i18nType from '$lib/i18n';
import { WEBUI_NAME, folders } from '$lib/stores';
import { WEBUI_NAME, channels, folders } from '$lib/stores';
import { getFolders } from '$lib/apis/folders';
import { getChannels } from '$lib/apis/channels';
import {
getAutomationById,
@ -44,6 +45,7 @@
let hasMoreRuns = true;
let runsPage = 0;
let foldersLoaded = false;
let channelsLoaded = false;
const ensureFolders = async () => {
if (foldersLoaded || ($folders ?? []).length > 0) return;
@ -52,11 +54,29 @@
foldersLoaded = true;
};
const ensureChannels = async () => {
if (channelsLoaded || ($channels ?? []).length > 0) return;
const res = await getChannels(localStorage.token).catch(() => null);
if (res) channels.set(res);
channelsLoaded = true;
};
const getFolderName = (folderId: string | null): string =>
folderId
? (($folders ?? []).find((folder) => folder.id === folderId)?.name ?? $i18n.t('None'))
: $i18n.t('None');
const getDestinationName = (): string => {
const target = automation.data.target;
if (target?.type === 'channel') {
const channel = ($channels ?? []).find((channel) => channel.id === target.channel_id);
return channel?.name ? `#${channel.name}` : $i18n.t('Channel');
}
return automation.folder_id
? `${$i18n.t('Folder')}: ${getFolderName(automation.folder_id)}`
: $i18n.t('New chat');
};
const formatTime = (ts: number | null): string => {
if (!ts) return '-';
return new Date(ts / 1_000_000).toLocaleString(undefined, {
@ -216,6 +236,7 @@
is_active = automation.is_active;
await ensureFolders();
await ensureChannels();
await loadRuns();
});
@ -283,10 +304,10 @@
<div class="flex h-7 items-center px-3">
<span class="w-24 shrink-0 text-[0.6875rem] text-gray-400 dark:text-gray-500">
{$i18n.t('Folder')}
{$i18n.t('Destination')}
</span>
<span class="min-w-0 truncate text-xs text-gray-700 dark:text-gray-300">
{getFolderName(automation.folder_id)}
{getDestinationName()}
</span>
</div>
@ -356,11 +377,19 @@
<button
class="group flex items-center gap-1 text-[0.6875rem] text-gray-400"
on:click={() => {
goto(`/c/${run.chat_id}`);
if (run.chat_id?.startsWith('channel:')) {
goto(`/channels/${run.chat_id.replace('channel:', '')}`);
} else {
goto(`/c/${run.chat_id}`);
}
}}
type="button"
>
<span class="group-hover:underline">{$i18n.t('View chat')}</span>
<span class="group-hover:underline">
{run.chat_id?.startsWith('channel:')
? $i18n.t('View channel')
: $i18n.t('View chat')}
</span>
<ArrowRight className="size-2.5" strokeWidth="2" />
</button>
{/if}

View file

@ -0,0 +1,308 @@
<script lang="ts">
import { getContext } from 'svelte';
import { fly } from 'svelte/transition';
import { decodeString } from '$lib/utils';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import ChatBubble from '$lib/components/icons/ChatBubble.svelte';
import Check from '$lib/components/icons/Check.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import Folder from '$lib/components/icons/Folder.svelte';
import Hashtag from '$lib/components/icons/Hashtag.svelte';
import Search from '$lib/components/icons/Search.svelte';
const i18n = getContext('i18n');
export let target_type: 'chat' | 'channel' = 'chat';
export let channel_id = '';
export let folder_id = '';
export let folders: any[] = [];
export let channels: any[] = [];
export let side: 'top' | 'bottom' = 'top';
export let align: 'start' | 'end' = 'start';
export let onChange: () => void = () => {};
let show = false;
let tab: '' | 'folders' | 'channels' = '';
let folderSearch = '';
let channelSearch = '';
const folderName = (folder: any) => decodeString(folder?.name ?? $i18n.t('Folder'));
const channelName = (channel: any) => channel?.name || $i18n.t('Channel');
$: folderOptions = [...((folders ?? []) as any[])]
.filter((folder) => folder?.id && !folder?.shared)
.sort((a, b) => folderName(a).localeCompare(folderName(b)));
$: folderById = new Map(folderOptions.map((folder) => [folder.id, folder]));
$: channelOptions = (channels ?? [])
.filter((channel) => channel?.id && channel.type !== 'dm')
.sort((a, b) => channelName(a).localeCompare(channelName(b)));
$: selectedFolder = folderOptions.find((folder) => folder.id === folder_id);
$: selectedChannel = channelOptions.find((channel) => channel.id === channel_id);
const folderPath = (folder: any) => {
const names: string[] = [];
const seen = new Set<string>();
let current = folder;
while (current?.parent_id && !seen.has(current.parent_id)) {
seen.add(current.parent_id);
const parent = folderById.get(current.parent_id);
if (!parent) break;
names.unshift(folderName(parent));
current = parent;
}
return names.join(' / ');
};
$: destinationLabel =
target_type === 'channel'
? selectedChannel
? `#${channelName(selectedChannel)}`
: $i18n.t('Choose channel')
: selectedFolder
? folderName(selectedFolder)
: $i18n.t('New chat');
$: normalizedFolderSearch = folderSearch.trim().toLowerCase();
$: filteredFolderOptions = normalizedFolderSearch
? folderOptions.filter((folder) =>
`${folderName(folder)} ${folderPath(folder)}`.toLowerCase().includes(normalizedFolderSearch)
)
: folderOptions;
$: normalizedChannelSearch = channelSearch.trim().toLowerCase();
$: filteredChannelOptions = normalizedChannelSearch
? channelOptions.filter((channel) => channelName(channel).toLowerCase().includes(normalizedChannelSearch))
: channelOptions;
const selectChat = () => {
target_type = 'chat';
channel_id = '';
folder_id = '';
show = false;
tab = '';
onChange();
};
const selectFolder = (id: string) => {
target_type = 'chat';
channel_id = '';
folder_id = id;
show = false;
tab = '';
folderSearch = '';
onChange();
};
const selectChannel = (id: string) => {
target_type = 'channel';
channel_id = id;
folder_id = '';
show = false;
tab = '';
channelSearch = '';
onChange();
};
</script>
<Dropdown
bind:show
{side}
{align}
onOpenChange={(state) => {
if (!state) {
tab = '';
folderSearch = '';
channelSearch = '';
}
}}
>
<button
type="button"
class="relative h-8 max-w-[11rem] flex items-center gap-1.5 px-2.5 py-1.5 bg-transparent rounded-2xl text-xs font-normal text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
>
{#if target_type === 'channel'}
<Hashtag className="size-3.5 shrink-0" />
{:else if folder_id}
<Folder className="size-3.5 shrink-0" />
{:else}
<ChatBubble className="size-3.5 shrink-0" />
{/if}
<span class="min-w-0 truncate">{destinationLabel}</span>
<ChevronDown className="size-2.5 shrink-0" strokeWidth="2.5" />
</button>
<div slot="content">
<DropdownMenu className="w-72 max-h-72 overflow-hidden shadow-lg">
{#if tab === ''}
<div class="max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin" in:fly={{ x: -20, duration: 150 }}>
<button
type="button"
class="flex h-[1.6875rem] w-full cursor-pointer items-center justify-between gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 {target_type ===
'chat' && !folder_id
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 dark:text-gray-300'}"
on:click={selectChat}
>
<div class="flex min-w-0 items-center gap-1.5">
<ChatBubble className="size-3.5 shrink-0" />
<span class="min-w-0 truncate">{$i18n.t('New chat')}</span>
</div>
{#if target_type === 'chat' && !folder_id}
<Check className="size-3.5 shrink-0" strokeWidth="2" />
{/if}
</button>
<button
type="button"
class="flex h-[1.6875rem] w-full cursor-pointer items-center justify-between gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 {target_type ===
'chat' && folder_id
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 dark:text-gray-300'}"
on:click={() => (tab = 'folders')}
>
<div class="flex min-w-0 items-center gap-1.5">
<Folder className="size-3.5 shrink-0" />
<span class="min-w-0 truncate">
{selectedFolder ? folderName(selectedFolder) : $i18n.t('Folder')}
</span>
</div>
<div class="flex shrink-0 items-center gap-1 text-gray-500">
{#if target_type === 'chat' && folder_id}
<Check className="size-3.5" strokeWidth="2" />
{/if}
<ChevronRight />
</div>
</button>
<button
type="button"
class="flex h-[1.6875rem] w-full cursor-pointer items-center justify-between gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 {target_type ===
'channel'
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 dark:text-gray-300'}"
on:click={() => (tab = 'channels')}
>
<div class="flex min-w-0 items-center gap-1.5">
<Hashtag className="size-3.5 shrink-0" />
<span class="min-w-0 truncate">
{selectedChannel ? channelName(selectedChannel) : $i18n.t('Channel')}
</span>
</div>
<div class="flex shrink-0 items-center gap-1 text-gray-500">
{#if target_type === 'channel'}
<Check className="size-3.5" strokeWidth="2" />
{/if}
<ChevronRight />
</div>
</button>
</div>
{:else if tab === 'folders'}
<div class="flex max-h-72 flex-col overflow-hidden" in:fly={{ x: 20, duration: 150 }}>
<button
type="button"
class="flex h-[1.6875rem] w-full shrink-0 cursor-pointer items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => (tab = '')}
>
<ChevronLeft />
<span>{$i18n.t('Folders')}</span>
</button>
<div class="flex shrink-0 items-center gap-1.5 px-2 py-1">
<Search className="size-3.5 shrink-0" strokeWidth="2.5" />
<input
bind:value={folderSearch}
class="w-full bg-transparent text-[0.8125rem] outline-hidden"
placeholder={$i18n.t('Search folders')}
autocomplete="off"
on:click|stopPropagation
/>
</div>
<div class="overflow-y-auto scrollbar-thin">
{#each filteredFolderOptions as folder (folder.id)}
{@const path = folderPath(folder)}
<button
type="button"
class="flex h-[1.6875rem] w-full cursor-pointer items-center justify-between gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 {target_type ===
'chat' && folder_id === folder.id
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 dark:text-gray-300'}"
title={path ? `${path} / ${folderName(folder)}` : folderName(folder)}
on:click={() => selectFolder(folder.id)}
>
<div class="flex min-w-0 items-center gap-1.5">
<Folder className="size-3.5 shrink-0" />
<span class="min-w-0 truncate">{folderName(folder)}</span>
{#if path}
<span class="min-w-0 truncate text-[0.6875rem] text-gray-400 dark:text-gray-500">
{path}
</span>
{/if}
</div>
{#if target_type === 'chat' && folder_id === folder.id}
<Check className="size-3.5 shrink-0" strokeWidth="2" />
{/if}
</button>
{:else}
<div class="px-2 py-1 text-[0.6875rem] text-gray-500 dark:text-gray-400">
{folderOptions.length > 0 ? $i18n.t('No results found') : $i18n.t('No folders')}
</div>
{/each}
</div>
</div>
{:else if tab === 'channels'}
<div class="flex max-h-72 flex-col overflow-hidden" in:fly={{ x: 20, duration: 150 }}>
<button
type="button"
class="flex h-[1.6875rem] w-full shrink-0 cursor-pointer items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => (tab = '')}
>
<ChevronLeft />
<span>{$i18n.t('Channels')}</span>
</button>
<div class="flex shrink-0 items-center gap-1.5 px-2 py-1">
<Search className="size-3.5 shrink-0" strokeWidth="2.5" />
<input
bind:value={channelSearch}
class="w-full bg-transparent text-[0.8125rem] outline-hidden"
placeholder={$i18n.t('Search channels')}
autocomplete="off"
on:click|stopPropagation
/>
</div>
<div class="overflow-y-auto scrollbar-thin">
{#each filteredChannelOptions as channel (channel.id)}
<button
type="button"
class="flex h-[1.6875rem] w-full cursor-pointer items-center justify-between gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:bg-gray-50/40 hover:text-gray-900 dark:hover:bg-gray-800/40 dark:hover:text-gray-100 {target_type ===
'channel' && channel_id === channel.id
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-700 dark:text-gray-300'}"
on:click={() => selectChannel(channel.id)}
>
<div class="flex min-w-0 items-center gap-1.5">
<Hashtag className="size-3.5 shrink-0" />
<span class="min-w-0 truncate">{channelName(channel)}</span>
</div>
{#if target_type === 'channel' && channel_id === channel.id}
<Check className="size-3.5 shrink-0" strokeWidth="2" />
{/if}
</button>
{:else}
<div class="px-2 py-1 text-[0.6875rem] text-gray-500 dark:text-gray-400">
{channelOptions.length > 0 ? $i18n.t('No results found') : $i18n.t('No channels')}
</div>
{/each}
</div>
</div>
{/if}
</DropdownMenu>
</div>
</Dropdown>

View file

@ -121,6 +121,11 @@
return;
}
if (!startDate) {
toast.error($i18n.t('Date is required'));
return;
}
loading = true;
try {
const startNs = dateTimeToNs(startDate, allDay ? '00:00' : startTime);

View file

@ -76,7 +76,7 @@
</div>
<!-- Form -->
<div class="px-5 pb-2 flex flex-col gap-3">
<div class="px-4 pb-2 flex flex-col gap-3">
<!-- Name -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>

View file

@ -575,7 +575,8 @@
i18n,
triggerChar: '@',
modelSuggestions: true,
userSuggestions
userSuggestions,
channelId: channel?.id
})
},
...(channelSuggestions

View file

@ -2,12 +2,13 @@
import { getContext, onDestroy, onMount } from 'svelte';
const i18n = getContext('i18n');
import { channels, models, user } from '$lib/stores';
import { channels, models } from '$lib/stores';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Hashtag from '$lib/components/icons/Hashtag.svelte';
import Lock from '$lib/components/icons/Lock.svelte';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { searchUsers } from '$lib/apis/users';
import { getChannelMembersById } from '$lib/apis/channels';
export let query = '';
@ -20,28 +21,47 @@
export let modelSuggestions = false;
export let userSuggestions = false;
export let channelSuggestions = false;
export let channelId: string | null = null;
let _models = [];
let _users = [];
let _channels = [];
type UserSuggestion = { id: string; name: string };
$: filteredItems = [..._users, ..._models, ..._channels].filter(
(u) =>
u.label.toLowerCase().includes(query.toLowerCase()) ||
u.id.toLowerCase().includes(query.toLowerCase())
);
const getUserList = async () => {
const res = await searchUsers(localStorage.token, query).catch((error) => {
console.error('Error searching users:', error);
return null;
});
const toUserItems = (users: UserSuggestion[]) =>
[...users]
.map((u) => ({ type: 'user', id: u.id, label: u.name }))
.sort((a, b) => a.label.localeCompare(b.label));
if (res) {
_users = [...res.users.map((u) => ({ type: 'user', id: u.id, label: u.name }))].sort((a, b) =>
a.label.localeCompare(b.label)
);
}
const getUserList = async () => {
const [channelMembers, searchResults] = await Promise.all([
channelId
? getChannelMembersById(localStorage.token, channelId, query, 'name', 'asc').catch(
(error) => {
console.error('Error loading channel members:', error);
return null;
}
)
: Promise.resolve(null),
searchUsers(localStorage.token, query).catch((error) => {
console.error('Error searching users:', error);
return null;
})
]);
const memberUsers = (channelMembers?.users ?? []) as UserSuggestion[];
const searchedUsers = (searchResults?.users ?? []) as UserSuggestion[];
const memberIds = new Set(memberUsers.map((u) => u.id));
const globalUsers = searchedUsers.filter((u) => !memberIds.has(u.id));
_users = [...toUserItems(memberUsers), ...toUserItems(globalUsers)];
};
$: if (query !== null && userSuggestions) {

View file

@ -10,7 +10,7 @@
dayjs.extend(isYesterday);
dayjs.extend(localizedFormat);
import { getContext, onMount } from 'svelte';
import { getContext } from 'svelte';
const i18n = getContext<Writable<i18nType>>('i18n');
import { formatDate } from '$lib/utils';
@ -146,11 +146,9 @@
}
};
onMount(async () => {
if (message && message?.data === true) {
await loadMessageData();
}
});
$: if (message?.data === true) {
loadMessageData();
}
$: messageOutput = Array.isArray(message?.data?.output) ? message.data.output : [];
$: hasStructuredOutput = buildOutputDisplayItems(messageOutput).length > 0;
@ -209,7 +207,7 @@
: 'transition: transform 0.3s cubic-bezier(0.2, 0.9, 0.3, 1);'}"
>
{#if !edit && !disabled}
<div class=" absolute {showButtons ? '' : 'hover-reveal'} right-1 -top-2 z-10">
<div class=" absolute {showButtons ? '' : 'hover-reveal'} right-1 -top-7 z-30">
<div
class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100/30 dark:border-gray-850/30"
>
@ -454,7 +452,6 @@
{/if}
{#if message?.data === true}
<!-- loading indicator -->
<div class=" my-2">
<Skeleton />
</div>

View file

@ -94,7 +94,7 @@
</div>
{:else}
<div
class="flex flex-col gap-2 max-h-[60vh] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent py-2"
class="flex flex-col gap-2 max-h-[60vh] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-700 scrollbar-track-transparent pt-7 pb-2"
>
{#if pinnedMessages.length === 0}
<div class=" text-center text-xs text-gray-500 dark:text-gray-400 py-6">

View file

@ -188,7 +188,7 @@
</div>
</div>
<div class=" max-h-full w-full overflow-y-auto" bind:this={messagesContainerElement}>
<div class="flex-1 min-h-0 w-full overflow-y-auto pt-7" bind:this={messagesContainerElement}>
{#if messages !== null}
<Messages
id={threadId}
@ -225,25 +225,25 @@
<Spinner />
</div>
{/if}
</div>
<div class=" pb-[1rem] px-2.5 w-full">
<MessageInput
bind:replyToMessage
bind:chatInputElement
id={threadId}
{channel}
disabled={!channel?.write_access}
placeholder={!channel?.write_access
? $i18n.t('You do not have permission to send messages in this thread.')
: $i18n.t('Reply to thread...')}
typingUsersClassName="from-gray-50 dark:from-gray-850"
{typingUsers}
userSuggestions={true}
channelSuggestions={true}
{onChange}
onSubmit={submitHandler}
/>
</div>
<div class=" pb-[1rem] px-2.5 w-full">
<MessageInput
bind:replyToMessage
bind:chatInputElement
id={threadId}
{channel}
disabled={!channel?.write_access}
placeholder={!channel?.write_access
? $i18n.t('You do not have permission to send messages in this thread.')
: $i18n.t('Reply to thread...')}
typingUsersClassName="from-gray-50 dark:from-gray-850"
{typingUsers}
userSuggestions={true}
channelSuggestions={true}
{onChange}
onSubmit={submitHandler}
/>
</div>
</div>
{/if}

View file

@ -0,0 +1,303 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte';
import { onDestroy } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import Tooltip from '$lib/components/common/Tooltip.svelte';
const i18n: Writable<i18nType> = getContext('i18n');
const dispatch = createEventDispatcher();
type AskUserOption = {
label: string;
description: string;
};
type AskUserQuestion = {
id: string;
header: string;
question: string;
options: AskUserOption[];
allow_other?: boolean;
};
type DraftAnswer =
| {
type: 'option';
option_index: number;
label: string;
description: string;
}
| {
type: 'other';
text: string;
};
export let show = false;
export let questions: AskUserQuestion[] = [];
export let allowOther = true;
export let timeoutMs: number | null = null;
let answers: Record<string, DraftAnswer> = {};
let questionIndex = 0;
let wasOpen = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const clearAutoCancel = () => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
};
const cancel = () => {
clearAutoCancel();
show = false;
dispatch('cancel');
};
$: if (show && !wasOpen) {
answers = {};
questionIndex = 0;
wasOpen = true;
clearAutoCancel();
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
timeoutHandle = setTimeout(() => {
cancel();
}, timeoutMs);
}
}
$: if (!show && wasOpen) {
clearAutoCancel();
wasOpen = false;
}
onDestroy(clearAutoCancel);
const questionAllowsOther = (question: AskUserQuestion) => question.allow_other ?? allowOther;
$: question = questions[questionIndex];
$: selectedAnswer = question ? answers[question.id] : undefined;
const hasAnswers = (selected = answers) =>
questions.length > 0 &&
questions.every((question) => {
const answer = selected[question.id];
return answer?.type === 'option' || (answer?.type === 'other' && answer.text.trim() !== '');
});
$: complete = hasAnswers();
const submit = (selected = answers) => {
if (!hasAnswers(selected)) {
return;
}
const normalized: Record<string, DraftAnswer> = {};
for (const question of questions) {
const answer = selected[question.id];
if (answer?.type === 'option') {
normalized[question.id] = answer;
} else if (answer?.type === 'other') {
normalized[question.id] = {
type: 'other',
text: answer.text.trim()
};
}
}
show = false;
dispatch('confirm', {
status: 'answered',
answers: normalized
});
};
const advance = (selected = answers) => {
if (questionIndex < questions.length - 1) {
questionIndex += 1;
} else if (hasAnswers(selected)) {
submit(selected);
}
};
const selectOption = (question: AskUserQuestion, option: AskUserOption, index: number) => {
const selected: Record<string, DraftAnswer> = {
...answers,
[question.id]: {
type: 'option',
option_index: index,
label: option.label,
description: option.description
}
};
answers = selected;
advance(selected);
};
const selectOther = (question: AskUserQuestion) => {
const existing = answers[question.id];
answers = {
...answers,
[question.id]: {
type: 'other',
text: existing?.type === 'other' ? existing.text : ''
}
};
};
const updateOther = (question: AskUserQuestion, text: string) => {
answers = {
...answers,
[question.id]: {
type: 'other',
text
}
};
};
const isSelectedOption = (question: AskUserQuestion, index: number) => {
const answer = answers[question.id];
return answer?.type === 'option' && answer.option_index === index;
};
const isSelectedOther = (question: AskUserQuestion) => answers[question.id]?.type === 'other';
const otherText = (question: AskUserQuestion) => {
const answer = answers[question.id];
return answer?.type === 'other' ? answer.text : '';
};
</script>
{#if show}
<section class="my-1 rounded-2xl bg-gray-50/70 px-3.5 py-3 dark:bg-white/[0.035]">
<div class="space-y-2">
{#if question}
{#key question.id}
<div class="space-y-2">
<div>
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{question.header}
</div>
{#if questions.length > 1}
<div
class="shrink-0 text-right text-[0.6875rem] text-gray-500 dark:text-gray-400"
>
{questionIndex + 1}/{questions.length}
</div>
{/if}
</div>
<div class="mt-0.5 text-xs leading-relaxed text-gray-600 dark:text-gray-300">
{question.question}
</div>
</div>
<div class="space-y-1">
<div class="space-y-0.5">
{#each question.options || [] as option, optionIndex}
<button
type="button"
class="group flex w-full min-w-0 items-baseline gap-2 rounded-lg py-1.5 text-left transition-colors {isSelectedOption(
question,
optionIndex
)
? 'text-gray-950 dark:text-white'
: 'text-gray-700 hover:text-gray-950 dark:text-gray-300 dark:hover:text-white'}"
on:click={() => selectOption(question, option, optionIndex)}
>
<span class="min-w-0 shrink-0 text-xs">{option.label}</span>
<Tooltip
as="span"
className="min-w-0 flex-1"
content={option.description}
placement="top-start"
>
<span
class="block truncate text-xs leading-relaxed text-gray-500 transition-colors group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-300"
>
{option.description}
</span>
</Tooltip>
{#if optionIndex === 0}
<span
class="shrink-0 rounded-full bg-gray-200/70 px-1.5 py-0.5 text-[0.625rem] text-gray-500 dark:bg-white/[0.08] dark:text-gray-400"
>
{$i18n.t('Recommended')}
</span>
{/if}
</button>
{/each}
</div>
{#if questionAllowsOther(question)}
<div class="flex min-w-0 items-baseline gap-2 rounded-lg py-1.5">
<div
class="shrink-0 text-xs {isSelectedOther(question)
? 'text-gray-950 dark:text-white'
: 'text-gray-700 dark:text-gray-300'}"
>
{$i18n.t('Other')}
</div>
<input
class="min-w-0 flex-1 bg-transparent text-xs text-gray-800 outline-hidden placeholder:text-gray-400 dark:text-gray-100 dark:placeholder:text-gray-500"
placeholder={$i18n.t('Type your answer')}
value={otherText(question)}
on:focus={() => selectOther(question)}
on:input={(event) =>
updateOther(question, (event.currentTarget as HTMLInputElement).value)}
on:keydown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
advance();
}
}}
/>
</div>
{/if}
</div>
</div>
{/key}
{/if}
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1.5">
<button
type="button"
class="rounded-full py-1 pr-2.5 text-xs text-gray-500 transition-colors hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-100"
on:click={cancel}
>
{$i18n.t('Cancel')}
</button>
<button
type="button"
class="rounded-full px-2.5 py-1 text-xs text-gray-500 transition-colors hover:text-gray-800 disabled:opacity-30 dark:text-gray-400 dark:hover:text-gray-100"
disabled={questionIndex === 0}
on:click={() => (questionIndex -= 1)}
>
{$i18n.t('Previous')}
</button>
</div>
{#if questionIndex < questions.length - 1}
<button
type="button"
class="rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white transition hover:opacity-90 active:scale-[0.98] dark:bg-white dark:text-black"
on:click={() => (questionIndex += 1)}
>
{$i18n.t('Next')}
</button>
{:else}
<button
type="button"
class="rounded-full bg-gray-900 px-3 py-1 text-xs font-medium text-white transition hover:opacity-90 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-black"
disabled={!complete}
on:click={() => submit()}
>
{$i18n.t('Submit answers')}
</button>
{/if}
</div>
</div>
</section>
{/if}

View file

@ -66,7 +66,7 @@
} from '$lib/utils';
import { AudioQueue } from '$lib/utils/audio';
import { createTemporaryChatId, isTemporaryChatId } from '$lib/utils/chatId';
import { getOutputText } from './Messages/structuredOutput';
import { applyResponseStreamEvent, getOutputText } from './Messages/structuredOutput';
import {
archiveChatById,
@ -77,12 +77,18 @@
getAllTags,
getChatById,
getTagsById,
resolveChatMessageToolCall,
updateChatById,
updateChatFolderIdById
} from '$lib/apis/chats';
import { generateOpenAIChatCompletion } from '$lib/apis/openai';
import { processUrl, processWebSearch } from '$lib/apis/retrieval';
import { getAndUpdateUserLocation, getUserInfoById, getUserSettings } from '$lib/apis/users';
import {
getAndUpdateUserLocation,
getUserInfoById,
getUserSettings,
updateUserSettings
} from '$lib/apis/users';
import {
generateQueries,
chatAction,
@ -162,7 +168,11 @@
let eventConfirmationInputValue = '';
let eventConfirmationInputType = '';
let eventConfirmationInputOptions: ({ label?: string; value: string } | string)[] = [];
let eventCallback = null;
let eventCallback: (value: any) => void = () => {};
let showAskUserDialog = false;
let askUserQuestions: any[] = [];
let askUserAllowOther = true;
let askUserTimeoutMs: number | null = null;
let selectedModels = [''];
let atSelectedModel: Model | undefined;
@ -410,6 +420,212 @@
let loadedChatIdProp = '';
let currentDraftKey = '';
$: toolApprovalMode =
(params?.tool_approval_mode ?? $settings?.params?.tool_approval_mode) === 'ask'
? 'ask'
: 'full';
const handleToolApprovalModeChange = async (mode: string) => {
const tool_approval_mode = mode === 'ask' ? 'ask' : 'full';
params = {
...params,
tool_approval_mode
};
settings.set({
...$settings,
params: {
...($settings?.params ?? {}),
tool_approval_mode
}
});
await updateUserSettings(localStorage.token, { ui: $settings }).catch((err) => {
console.error('[tool permissions settings]', err);
});
if ($chatId && !$temporaryChatEnabled && !isTemporaryChatId($chatId)) {
const res = await updateChatById(localStorage.token, $chatId, { params }).catch((err) => {
console.error('[tool permissions chat]', err);
return null;
});
if (res) chat = res;
}
if (tool_approval_mode === 'full') {
const messages = [...Object.values(history?.messages ?? {})].reverse() as any[];
for (const message of messages) {
const output = (Array.isArray(message?.output) ? message.output : []) as any[];
const resultCallIds = new Set(
output
.filter((item: any) => item?.type === 'function_call_output' && item?.call_id)
.map((item: any) => item.call_id)
);
const pendingCall = output.find((item: any) => {
const callId = item?.call_id ?? item?.id;
return (
item?.type === 'function_call' &&
item?.name !== 'ask_user' &&
(item?.status === 'pending' || item?.status === 'requires_approval') &&
callId &&
!resultCallIds.has(callId)
);
});
const callId = pendingCall?.call_id ?? pendingCall?.id;
if (!message?.id || !callId) {
continue;
}
const res = await resolveChatMessageToolCall(
localStorage.token,
$chatId,
message.id,
callId,
'approve'
).catch(async (error) => {
toast.error(`${error}`);
await loadChat();
return null;
});
if (res) onToolCallResolved(res);
break;
}
}
};
const parseToolArguments = (args) => {
if (!args) {
return {};
}
let value = args;
while (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch {
break;
}
}
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {};
};
const getPendingAskUserFromMessage = (message) => {
if (message?.role !== 'assistant' || !Array.isArray(message.output)) {
return null;
}
const call = message.output.find(
(item) =>
item?.type === 'function_call' &&
item?.name === 'ask_user' &&
item?.status === 'pending' &&
(item?.call_id || item?.id)
);
if (call) {
return { message, call, args: parseToolArguments(call.arguments) };
}
return null;
};
const findPendingAskUser = (chatHistory) => {
if (!chatHistory?.messages) {
return null;
}
const messages = chatHistory.currentId
? createMessagesList(chatHistory, chatHistory.currentId)
: Object.values(chatHistory.messages);
for (const message of [...messages].reverse()) {
const pending = getPendingAskUserFromMessage(message);
if (pending) return pending;
}
return null;
};
const messageHasPendingAskUser = (message) => {
return !!getPendingAskUserFromMessage(message);
};
const answerPendingAskUser = async (messageId, callId, answers, timedOut = false) => {
if (!$chatId || !messageId || !callId) {
return;
}
const res = await resolveChatMessageToolCall(
localStorage.token,
$chatId,
messageId,
callId,
'answer',
{
answers,
timed_out: timedOut
}
).catch(async (error) => {
toast.error(`${error}`);
await loadChat();
});
onToolCallResolved(res);
};
const rejectPendingAskUser = async (messageId, callId) => {
if (!$chatId || !messageId || !callId) {
return;
}
const res = await resolveChatMessageToolCall(
localStorage.token,
$chatId,
messageId,
callId,
'reject'
).catch(async (error) => {
toast.error(`${error}`);
await loadChat();
});
onToolCallResolved(res);
};
$: pendingAskUser = findPendingAskUser(history);
$: savedAskUserPrompt = pendingAskUser
? {
show: true,
questions: Array.isArray(pendingAskUser.args?.questions)
? pendingAskUser.args.questions
: [],
allowOther: pendingAskUser.args?.allow_other !== false,
timeoutMs: null,
onConfirm: (value) => {
void answerPendingAskUser(
pendingAskUser.message.id,
pendingAskUser.call.call_id || pendingAskUser.call.id,
value?.answers ?? {},
false
);
},
onCancel: () => {
void rejectPendingAskUser(
pendingAskUser.message.id,
pendingAskUser.call.call_id || pendingAskUser.call.id
);
}
}
: null;
$: socketAskUserPrompt = {
show: showAskUserDialog,
questions: askUserQuestions,
allowOther: askUserAllowOther,
timeoutMs: askUserTimeoutMs,
onConfirm: (value) => {
showAskUserDialog = false;
eventCallback(value);
},
onCancel: () => {
showAskUserDialog = false;
eventCallback({ status: 'cancelled', answers: {} });
}
};
const mergeChatVariableSchemas = (modelIds = [], availableModels = []) => {
const byKey: Record<string, any> = {};
const conflicts: any[] = [];
@ -499,6 +715,13 @@
}
};
const onToolCallResolved = (res) => {
const newTaskIds = res?.task_ids ?? (res?.task_id ? [res.task_id] : []);
if (newTaskIds.length > 0) {
taskIds = [...(taskIds ?? []), ...newTaskIds];
}
};
let oldSelectedModelIds = [''];
$: if (!equal(selectedModelIds, oldSelectedModelIds)) {
onSelectedModelIdsChange();
@ -615,6 +838,9 @@
webSearchEnabled = input.webSearchEnabled;
imageGenerationEnabled = input.imageGenerationEnabled;
codeInterpreterEnabled = input.codeInterpreterEnabled;
if (input.toolApprovalMode) {
handleToolApprovalModeChange(input.toolApprovalMode);
}
}
} catch (e) {}
} else {
@ -1000,6 +1226,8 @@
updateLastReadAt($chatId);
}
}
} else if (type === 'response:completion') {
responseCompletionEventHandler(data, message);
} else if (type === 'chat:completion') {
chatCompletionEventHandler(data, message, event.chat_id);
} else if (type === 'chat:tasks:cancel') {
@ -1144,6 +1372,13 @@
eventConfirmationInputValue = data?.value ?? '';
eventConfirmationInputType = data?.input?.type ?? data?.type ?? '';
eventConfirmationInputOptions = data?.input?.options ?? data?.options ?? [];
} else if (type === 'request:user_input') {
eventCallback = cb;
askUserQuestions = data?.questions ?? [];
askUserAllowOther = data?.allow_other ?? true;
askUserTimeoutMs =
typeof data?.timeout_ms === 'number' && data.timeout_ms > 0 ? data.timeout_ms : null;
showAskUserDialog = true;
} else if (type.startsWith('terminal:')) {
terminalEventHandler(type, data);
} else {
@ -1385,6 +1620,9 @@
webSearchEnabled = input.webSearchEnabled;
imageGenerationEnabled = input.imageGenerationEnabled;
codeInterpreterEnabled = input.codeInterpreterEnabled;
if (input.toolApprovalMode) {
handleToolApprovalModeChange(input.toolApprovalMode);
}
}
} catch (e) {}
}
@ -1600,7 +1838,9 @@
fileItem.content_type = uploadedFile.meta?.content_type;
fileItem.size = uploadedFile.meta?.size;
fileItem.collection_name =
res.collection_name ?? uploadedFile.meta?.collection_name ?? uploadedFile.collection_name;
res.collection_name ??
uploadedFile.meta?.collection_name ??
uploadedFile.collection_name;
} else {
fileItem.type = 'text';
fileItem.file = {
@ -1793,6 +2033,22 @@
const defaultModels = $config?.default_models ? $config?.default_models.split(',') : [];
const openModelSelectorWithSearch = async (modelId: string) => {
const modelSelectorButton = document.getElementById('model-selector-model-button');
modelSelectorButton?.click();
await tick();
const modelSelectorInput = document.getElementById(
'model-search-input'
) as HTMLInputElement | null;
if (modelSelectorInput) {
modelSelectorInput.focus();
modelSelectorInput.value = modelId;
modelSelectorInput.dispatchEvent(new Event('input', { bubbles: true }));
}
};
if ($page.url.searchParams.get('models') || $page.url.searchParams.get('model')) {
const urlModels = (
$page.url.searchParams.get('models') ||
@ -1803,18 +2059,7 @@
if (urlModels.length === 1) {
if (!$models.find((m) => m.id === urlModels[0])) {
// Model not found; open model selector and prefill
const modelSelectorButton = document.getElementById('model-selector-0-button');
if (modelSelectorButton) {
modelSelectorButton.click();
await tick();
const modelSelectorInput = document.getElementById('model-search-input');
if (modelSelectorInput) {
modelSelectorInput.focus();
modelSelectorInput.value = urlModels[0];
modelSelectorInput.dispatchEvent(new Event('input'));
}
}
await openModelSelectorWithSearch(urlModels[0]);
} else {
// Model found; set it as selected
selectedModels = urlModels;
@ -2142,7 +2387,11 @@
} else {
taskIds = null;
// No active tasks and message incomplete → generation was interrupted
if (currentMessage?.role === 'assistant' && !currentMessage.done) {
if (
currentMessage?.role === 'assistant' &&
!currentMessage.done &&
!messageHasPendingAskUser(currentMessage)
) {
currentMessage.done = true;
}
}
@ -2236,16 +2485,24 @@
}
processingQueueChats.add(targetChatId);
const queuedMessages = [...queue];
const queuedMessageIds = new Set(queuedMessages.map((m) => m.id));
try {
const combinedPrompt = queue.map((m) => m.prompt).join('\n\n');
const combinedFiles = queue.flatMap((m) => m.files);
const combinedPrompt = queuedMessages.map((m) => m.prompt).join('\n\n');
const combinedFiles = queuedMessages.flatMap((m) => m.files);
chatRequestQueues.update((q) => {
const { [targetChatId]: _, ...rest } = q;
return rest;
});
chatRequestQueues.update((q) => ({
...q,
[targetChatId]: (q[targetChatId] ?? []).filter((m) => !queuedMessageIds.has(m.id))
}));
await submitPrompt(combinedPrompt, combinedFiles);
} catch (error) {
console.error(error);
chatRequestQueues.update((q) => ({
...q,
[targetChatId]: [...queuedMessages, ...(q[targetChatId] ?? [])]
}));
} finally {
processingQueueChats.delete(targetChatId);
}
@ -2486,6 +2743,27 @@
}
};
const responseCompletionEventHandler = (data, message) => {
message.output = applyResponseStreamEvent(message.output ?? [], data);
if (data?.type === 'response.output_text.delta') {
const value = data.delta ?? '';
if (!(message.content == '' && value == '\n')) {
message.content += value;
if (navigator.vibrate && ($settings?.hapticFeedback ?? false)) {
navigator.vibrate(5);
}
dispatchCallOverlayAudio(message);
}
} else if (data?.type === 'response.completed' || data?.type?.endsWith('.done')) {
message.content = getOutputText(message.output) || message.content;
}
history.messages[message.id] = message;
history = history;
};
const chatCompletionEventHandler = async (data, message, chatId) => {
const { id, done, choices, content, output, sources, selected_model_id, error, usage } = data;
@ -2545,6 +2823,7 @@
}
history.messages[message.id] = message;
history = history;
if (done) {
message.done = true;
@ -3376,10 +3655,7 @@
await handleOpenAIError(res.error, responseMessage);
} else {
// Backend returns task_ids (multi-model) or task_id (single model)
const newTaskIds = res.task_ids ?? (res.task_id ? [res.task_id] : []);
if (newTaskIds.length > 0) {
taskIds = [...(taskIds ?? []), ...newTaskIds];
}
onToolCallResolved(res);
// Backend returns chat_id for new chats — set store + URL.
// Only update if the user hasn't navigated to a different chat
@ -3463,7 +3739,13 @@
};
const stopResponse = async (processQueue = true) => {
if (taskIds) {
const responseMessage = history.currentId ? history.messages[history.currentId] : null;
const hasTaskIds = (taskIds?.length ?? 0) > 0;
const hasPendingAssistantResponse =
!!$chatId &&
(hasTaskIds || (responseMessage?.role === 'assistant' && responseMessage?.done !== true));
if (hasTaskIds || hasPendingAssistantResponse) {
if ($chatId) {
await stopTasksByChatId(localStorage.token, $chatId).catch((error) => {
toast.error(`${error}`);
@ -3480,15 +3762,16 @@
taskIds = null;
const responseMessage = history.messages[history.currentId];
// Set all response messages to done
if (responseMessage.parentId && history.messages[responseMessage.parentId]) {
if (responseMessage?.parentId && history.messages[responseMessage.parentId]) {
for (const messageId of history.messages[responseMessage.parentId].childrenIds) {
history.messages[messageId].done = true;
}
}
history.messages[history.currentId] = responseMessage;
if (responseMessage) {
history.messages[history.currentId] = responseMessage;
}
if (shouldAutoScrollResponse()) {
scrollToBottom();
@ -3940,7 +4223,7 @@
: 'h-screen max-h-[100dvh]'} transition-width duration-200 ease-in-out {$showSidebar &&
!embedded
? ' md:max-w-[calc(100%-var(--sidebar-width))]'
: ' '} w-full max-w-full flex flex-col"
: ' '} w-full max-w-full min-w-0 flex flex-col"
id={chatContainerId}
>
{#if !loading}
@ -4095,6 +4378,7 @@
{mergeResponses}
{chatActionHandler}
{addMessages}
{onToolCallResolved}
allowDelete={!(generating || taskIds?.length)}
forkHandler={handleForkChat}
topPadding={!embedded}
@ -4142,7 +4426,8 @@
compactHandler={handleManualCompact}
statusHandler={handleStatusCommand}
forkHandler={handleForkChat}
toolServers={$toolServers}
{toolApprovalMode}
onToolApprovalModeChange={handleToolApprovalModeChange}
{generating}
{stopResponse}
{createMessagePair}
@ -4150,6 +4435,7 @@
{onUpdate}
messageQueue={$chatRequestQueues[$chatId] ?? []}
{chatTasks}
askUser={savedAskUserPrompt ?? socketAskUserPrompt}
onQueueSendNow={sendQueuedMessageNow}
onQueueEdit={editQueuedMessage}
onQueueDelete={deleteQueuedMessage}
@ -4204,10 +4490,7 @@
</div>
</div>
{/if}
<div
id={embedded ? messageInputDropzoneId : undefined}
class="pb-2 z-10"
>
<div id={embedded ? messageInputDropzoneId : undefined} class="pb-2 z-10">
<MessageInput
bind:this={messageInput}
{history}
@ -4234,7 +4517,8 @@
compactHandler={handleManualCompact}
statusHandler={handleStatusCommand}
forkHandler={handleForkChat}
toolServers={$toolServers}
{toolApprovalMode}
onToolApprovalModeChange={handleToolApprovalModeChange}
{generating}
{stopResponse}
{createMessagePair}
@ -4242,6 +4526,7 @@
{onUpdate}
messageQueue={$chatRequestQueues[$chatId] ?? []}
{chatTasks}
askUser={savedAskUserPrompt ?? socketAskUserPrompt}
onQueueSendNow={sendQueuedMessageNow}
onQueueEdit={editQueuedMessage}
onQueueDelete={deleteQueuedMessage}
@ -4277,14 +4562,16 @@
bind:atSelectedModel
bind:showCommands
bind:dragged
{toolApprovalMode}
onToolApprovalModeChange={handleToolApprovalModeChange}
{pendingOAuthTools}
toolServers={$toolServers}
{stopResponse}
{createMessagePair}
{onSelect}
{onUpload}
{onUpdate}
messageQueue={$chatRequestQueues[$chatId] ?? []}
askUser={savedAskUserPrompt ?? socketAskUserPrompt}
onQueueSendNow={sendQueuedMessageNow}
onQueueEdit={editQueuedMessage}
onQueueDelete={deleteQueuedMessage}

View file

@ -78,6 +78,12 @@
const chatContextAvailable = (terminal: any) => chatContext(terminal) !== false;
const chatContextNeedsSavedChat = (terminal: any) =>
chatContext(terminal)?.context_id === 'chat_id';
function showFilesOnTerminalSelect(largeScreen: boolean) {
activeTab = 'files';
if (largeScreen) {
showControls.set($settings?.showFilesOnTerminalSelect ?? true);
}
}
$: selectedSystemTerminal = ($terminalServers ?? []).find(
(t) => t.id && t.id === $selectedTerminalId
);
@ -118,10 +124,7 @@
// Auto-open Files tab when a terminal is selected (suppress panel open when full-screen)
$: if ($selectedTerminalId && terminalFilesAvailable) {
activeTab = 'files';
if (largeScreen) {
showControls.set($settings?.showFilesOnTerminalSelect ?? true);
}
showFilesOnTerminalSelect(largeScreen);
}
// Clear selected direct terminal if user lost permission

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,11 @@
<script lang="ts">
import { getContext } from 'svelte';
import GarbageBin from '../../icons/GarbageBin.svelte';
import Tooltip from '../../common/Tooltip.svelte';
import Icon from './Icon.svelte';
const i18n = getContext('i18n');
export let count: number = 0;
export let hasFiles: boolean = false;
export let canDelete = true;
export let onDelete: () => void = () => {};
@ -15,81 +14,51 @@
export let onClear: () => void = () => {};
</script>
<div class="flex items-center gap-2 px-3 py-1.5 bg-gray-50 dark:bg-gray-800/50 shrink-0">
<span class="text-xs font-normal text-gray-600 dark:text-gray-400 flex-1 truncate">
<div
class="flex h-8 items-center gap-2 px-2 bg-blue-50 dark:bg-blue-500/5 shrink-0 border-t border-gray-50 dark:border-gray-850/30"
>
<span class="text-[0.6875rem] font-medium text-blue-600 dark:text-blue-400 flex-1 truncate">
{$i18n.t('{{count}} selected', { count })}
</span>
<Tooltip content={$i18n.t('Select All')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
class="flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
on:click={onSelectAll}
aria-label={$i18n.t('Select All')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
<Icon name="check" size={12} strokeWidth={1.6} />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Download')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
class="flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
on:click={onDownload}
aria-label={$i18n.t('Download')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z"
/>
<path
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
/>
</svg>
<Icon name="download" size={12} strokeWidth={1.4} />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
class="flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-red-500 disabled:opacity-30 disabled:hover:text-gray-400"
on:click={onDelete}
disabled={!canDelete}
aria-label={$i18n.t('Delete')}
>
<GarbageBin className="size-3.5" />
<Icon name="trash" size={12} strokeWidth={1.4} />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Deselect')}>
<button
class="p-1 rounded transition text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400"
class="flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
on:click={onClear}
aria-label={$i18n.t('Deselect')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg>
<Icon name="xmark" size={12} strokeWidth={1.5} />
</button>
</Tooltip>
</div>

View file

@ -6,36 +6,49 @@
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import Folder from '../../icons/Folder.svelte';
import EllipsisHorizontal from '../../icons/EllipsisHorizontal.svelte';
import GarbageBin from '../../icons/GarbageBin.svelte';
import Pencil from '../../icons/Pencil.svelte';
import Clipboard from '../../icons/Clipboard.svelte';
import FileTypeIcon from './FileTypeIcon.svelte';
import Icon from './Icon.svelte';
const i18n = getContext('i18n');
const i18n: any = getContext('i18n');
export let entry: FileEntry;
export let currentPath: string;
export let fullPath: string | null = null;
export let depth = 0;
export let rowIndex = 0;
export let expanded = false;
export let loadingChildren = false;
export let terminalUrl: string = '';
export let terminalKey: string = '';
export let onOpen: (entry: FileEntry) => void = () => {};
export let onDownload: (path: string) => void = () => {};
export let onDelete: (path: string, name: string) => void = () => {};
export let onMove: (source: string, destFolder: string) => void = () => {};
export let onMove: (sources: string[], destFolder: string) => void | Promise<void> = () => {};
export let onRename: (oldPath: string, newName: string) => void = () => {};
// ── Selection ─────────────────────────────────────────────────────────
export let selected: boolean = false;
export let selectionMode: boolean = false;
export let selectedPaths: Set<string> = new Set();
export let onSelect: (entry: FileEntry, event: MouseEvent) => void = () => {};
export let onSelect: (
entry: FileEntry,
event: MouseEvent,
path: string,
index: number
) => void = () => {};
export let onLongPress: () => void = () => {};
export let onToggleExpand: (path: string) => void = () => {};
export let showDate: boolean = false;
export let parentWritable = true;
$: entryPath =
fullPath ??
(entry.type === 'directory' ? `${currentPath}${entry.name}/` : `${currentPath}${entry.name}`);
$: directoryPath = entryPath.endsWith('/') ? entryPath : `${entryPath}/`;
$: writable = entry.writable !== false;
$: canMutate = parentWritable && writable;
$: rowIndent = `${8 + depth * 16}px`;
const formatRelativeTime = (epoch: number): string => {
const diff = Math.floor(Date.now() / 1000) - epoch;
@ -48,6 +61,14 @@
};
let dragOverFolder = false;
let expandTimer: ReturnType<typeof setTimeout> | null = null;
let menuOpen = false;
const clearExpandTimer = () => {
if (!expandTimer) return;
clearTimeout(expandTimer);
expandTimer = null;
};
// ── Rename state ─────────────────────────────────────────────────────
let renaming = false;
@ -71,7 +92,7 @@
const newName = renameValue.trim();
renaming = false;
if (!newName || newName === entry.name) return;
onRename(`${currentPath}${entry.name}`, newName);
onRename(entryPath.replace(/\/$/, ''), newName);
};
const cancelRename = () => {
@ -89,7 +110,7 @@
longPressTimer = setTimeout(() => {
didLongPress = true;
onLongPress();
onSelect(entry, e as any);
onSelect(entry, e as any, entryPath, rowIndex);
}, 500);
};
@ -109,6 +130,7 @@
onDestroy(() => {
if (longPressTimer) clearTimeout(longPressTimer);
clearExpandTimer();
});
// ── Click handler ────────────────────────────────────────────────────
@ -122,13 +144,13 @@
// Modifier click → toggle/range select
if (e.metaKey || e.ctrlKey || e.shiftKey) {
e.preventDefault();
onSelect(entry, e);
onSelect(entry, e, entryPath, rowIndex);
return;
}
// In selection mode (touch) → toggle select
if (selectionMode) {
onSelect(entry, e);
onSelect(entry, e, entryPath, rowIndex);
return;
}
@ -137,14 +159,14 @@
};
</script>
<li class="group">
<li class="group" data-file-row>
<div
class="w-full flex items-center transition
{selected ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-gray-50 dark:hover:bg-gray-800'}
class="w-full flex items-center transition-colors duration-75
{selected ? 'bg-blue-50 dark:bg-blue-500/10' : 'hover:bg-gray-50/40 dark:hover:bg-white/4'}
{dragOverFolder
? 'bg-blue-50 dark:bg-blue-900/30 ring-1 ring-blue-400 dark:ring-blue-500 ring-inset'
? 'bg-blue-50 dark:bg-blue-500/10 ring-1 ring-blue-400 dark:ring-blue-500 ring-inset'
: ''}"
role={entry.type === 'directory' ? 'button' : undefined}
role="presentation"
on:dragover={(e) => {
if (entry.type !== 'directory') return;
if (!writable) return;
@ -152,13 +174,20 @@
e.preventDefault();
e.stopPropagation();
dragOverFolder = true;
if (!expanded && !expandTimer) {
expandTimer = setTimeout(() => {
onToggleExpand(directoryPath);
expandTimer = null;
}, 600);
}
}}
on:dragleave={(e) => {
if (entry.type !== 'directory') return;
e.stopPropagation();
dragOverFolder = false;
clearExpandTimer();
}}
on:drop={(e) => {
on:drop={async (e) => {
if (entry.type !== 'directory') return;
if (!writable) return;
const raw = e.dataTransfer?.getData('application/x-terminal-file-move');
@ -166,26 +195,47 @@
e.preventDefault();
e.stopPropagation();
dragOverFolder = false;
clearExpandTimer();
try {
const data = JSON.parse(raw);
const paths = data.paths || (data.path ? [data.path] : []);
const destFolder = `${currentPath}${entry.name}/`;
for (const p of paths) {
if (p + '/' === destFolder || p === destFolder) continue;
onMove(p, destFolder);
}
const paths = (data.paths || (data.path ? [data.path] : [])) as string[];
const destFolder = directoryPath;
await onMove(
paths.filter((p) => p + '/' !== destFolder && p !== destFolder),
destFolder
);
} catch {}
}}
>
{#if entry.type === 'directory'}
<button
type="button"
class="mr-1.5 flex w-5 shrink-0 items-center self-stretch justify-center text-gray-400 dark:text-gray-600 hover:text-gray-600 dark:hover:text-gray-400"
style="margin-left: {rowIndent};"
on:click|stopPropagation={() => onToggleExpand(directoryPath)}
aria-label={expanded ? $i18n.t('Collapse') : $i18n.t('Expand')}
>
<Icon
name={expanded ? 'chevron-down' : 'chevron-right'}
size={9}
strokeWidth={1.5}
class={loadingChildren ? 'animate-pulse' : ''}
/>
</button>
{:else}
<span class="mr-1.5 w-5 shrink-0 self-stretch" style="margin-left: {rowIndent};"></span>
{/if}
<button
class="flex-1 flex items-center gap-2 px-3 py-1.5 text-left min-w-0"
type="button"
class="flex min-w-0 flex-1 items-center gap-2 py-1.5 pr-2 text-left"
draggable={canMutate}
on:dragstart={(e) => {
if (!canMutate) {
e.preventDefault();
return;
}
const filePath = `${currentPath}${entry.name}`;
const filePath = entryPath.replace(/\/$/, '');
// If dragging a selected item, drag all selected
if (selected && selectedPaths.size > 1) {
e.dataTransfer?.setData(
@ -203,7 +253,10 @@
} else {
e.dataTransfer?.setData(
'application/x-terminal-file-move',
JSON.stringify({ path: filePath, name: entry.name })
JSON.stringify({
path: entry.type === 'directory' ? directoryPath : filePath,
name: entry.name
})
);
}
if (entry.type === 'file') {
@ -235,45 +288,17 @@
: 'border-gray-300 dark:border-gray-600'}"
>
{#if selected}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-2.5"
>
<path
fill-rule="evenodd"
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
clip-rule="evenodd"
/>
</svg>
<Icon name="check" size={10} strokeWidth={2} />
{/if}
</div>
{/if}
{#if entry.type === 'directory'}
<Folder className="size-4 shrink-0 text-blue-400 dark:text-blue-300" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-4 shrink-0 text-gray-400"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg>
{/if}
<FileTypeIcon name={entry.name} type={entry.type} size={12} />
{#if renaming}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<input
bind:this={renameInput}
bind:value={renameValue}
class="flex-1 text-xs bg-transparent border border-gray-200 dark:border-gray-700 rounded px-1.5 py-0.5 outline-none focus:border-blue-400 dark:focus:border-blue-500 text-gray-800 dark:text-gray-200 min-w-0"
class="flex-1 text-xs bg-transparent border border-gray-100 dark:border-white/[0.06] rounded px-1.5 py-0.5 outline-none focus:border-blue-400 dark:focus:border-blue-500 text-gray-800 dark:text-gray-200 min-w-0"
on:keydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
@ -303,93 +328,119 @@
{/if}
<span class="text-xs text-gray-400 shrink-0">{formatFileSize(entry.size)}</span>
{:else if entry.type === 'directory' && showDate && entry.modified && !renaming}
<span class="text-[0.625rem] text-gray-400 shrink-0">{formatRelativeTime(entry.modified)}</span>
<span class="text-[0.625rem] text-gray-400 shrink-0"
>{formatRelativeTime(entry.modified)}</span
>
{/if}
</button>
<Dropdown align="end" sideOffset={4}>
<Dropdown bind:show={menuOpen} align="end" sideOffset={4}>
<button
class="shrink-0 p-0.5 mr-1 rounded-lg transition
class="shrink-0 flex h-5 w-5 items-center justify-center mr-1 rounded transition
text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-400
hover:bg-gray-100 dark:hover:bg-gray-800"
hover:bg-gray-50/40 dark:hover:bg-white/4"
aria-label={$i18n.t('More')}
>
<EllipsisHorizontal className="size-3.5" />
<Icon name="three-dots" size={12} strokeWidth={1.4} />
</button>
<div slot="content">
<DropdownMenu className="min-w-[9.375rem] z-[9999999]">
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={(e) => {
e.stopPropagation();
const path =
entry.type === 'directory'
? `${currentPath}${entry.name}/`
: `${currentPath}${entry.name}`;
onDownload(path);
menuOpen = false;
onOpen(entry);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z"
/>
<path
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
/>
</svg>
<div class="flex items-center">{$i18n.t('Download')}</div>
<Icon
name={entry.type === 'directory' ? 'folder' : 'eye'}
size={12}
strokeWidth={1.4}
/>
<div class="flex items-center">
{entry.type === 'directory' ? $i18n.t('Open Folder') : $i18n.t('Open')}
</div>
</button>
{#if entry.type === 'directory'}
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={(e) => {
e.stopPropagation();
menuOpen = false;
onToggleExpand(directoryPath);
}}
>
<Icon
name={expanded ? 'chevron-down' : 'chevron-right'}
size={12}
strokeWidth={1.4}
/>
<div class="flex items-center">
{expanded ? $i18n.t('Collapse') : $i18n.t('Expand')}
</div>
</button>
{:else}
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={(e) => {
e.stopPropagation();
menuOpen = false;
onDownload(entryPath);
}}
>
<Icon name="download" size={12} strokeWidth={1.4} />
<div class="flex items-center">{$i18n.t('Download')}</div>
</button>
{/if}
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={(e) => {
e.stopPropagation();
const path =
entry.type === 'directory'
? `${currentPath}${entry.name}/`
: `${currentPath}${entry.name}`;
navigator.clipboard.writeText(path).then(() => {
menuOpen = false;
navigator.clipboard.writeText(entryPath).then(() => {
toast.success($i18n.t('Path copied'));
});
}}
>
<Clipboard className="size-3.5" strokeWidth="1.5" />
<Icon name="copy" size={12} strokeWidth={1.4} />
<div class="flex items-center">{$i18n.t('Copy Path')}</div>
</button>
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition disabled:opacity-40 disabled:hover:bg-transparent"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition disabled:opacity-40 disabled:hover:bg-transparent"
disabled={!canMutate}
on:click={(e) => {
e.stopPropagation();
if (!canMutate) return;
menuOpen = false;
startRename();
}}
>
<Pencil className="size-3.5" strokeWidth="1.5" />
<Icon name="pencil" size={12} strokeWidth={1.4} />
<div class="flex items-center">{$i18n.t('Rename')}</div>
</button>
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition disabled:opacity-40 disabled:hover:bg-transparent"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition disabled:opacity-40 disabled:hover:bg-transparent"
disabled={!canMutate}
on:click={(e) => {
e.stopPropagation();
if (!canMutate) return;
onDelete(`${currentPath}${entry.name}`, entry.name);
menuOpen = false;
onDelete(entryPath.replace(/\/$/, ''), entry.name);
}}
>
<GarbageBin className="size-3.5" />
<Icon name="trash" size={12} strokeWidth={1.4} />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</button>
</DropdownMenu>

View file

@ -1,15 +1,12 @@
<script lang="ts">
import { getContext, afterUpdate } from 'svelte';
import { tick } from 'svelte';
import Folder from '../../icons/Folder.svelte';
import NewFolderAlt from '../../icons/NewFolderAlt.svelte';
import FilePlusAlt from '../../icons/FilePlusAlt.svelte';
import Spinner from '../../common/Spinner.svelte';
import Tooltip from '../../common/Tooltip.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import Icon from './Icon.svelte';
const i18n = getContext('i18n');
const i18n: any = getContext('i18n');
export let breadcrumbs: { label: string; path: string }[] = [];
export let selectedFile: string | null = null;
@ -22,12 +19,14 @@
export let onNewFile: () => void = () => {};
export let onUploadFiles: (files: File[]) => void = () => {};
export let onDownloadDir: () => void = () => {};
export let onMove: (source: string, destFolder: string) => void = () => {};
export let onMove: (sources: string[], destFolder: string) => void | Promise<void> = () => {};
export let showHidden = false;
export let onToggleHidden: () => void = () => {};
// Sort controls
export let sortBy: 'name' | 'date' = 'name';
export let sortBy: 'name' | 'size' | 'date' = 'name';
export let sortAsc: boolean = true;
export let onSort: (mode: 'name' | 'date') => void = () => {};
export let onSort: (mode: 'name' | 'size' | 'date') => void = () => {};
// Back / forward navigation
export let canGoBack = false;
@ -36,77 +35,64 @@
export let onGoForward: () => void = () => {};
let dragOverCrumb: number | null = null;
let sortMenuOpen = false;
let actionsMenuOpen = false;
let uploadInput: HTMLInputElement;
let breadcrumbEl: HTMLDivElement;
const showSeparator = (index: number) =>
index > 0 && (breadcrumbs[0]?.label !== '/' || index > 1);
// Scroll breadcrumb to the end after every DOM update
afterUpdate(() => {
if (breadcrumbEl) breadcrumbEl.scrollLeft = breadcrumbEl.scrollWidth;
});
</script>
<div class="flex items-center px-2 pb-1.5 shrink-0 gap-1">
<!-- Back -->
<Tooltip content={$i18n.t('Back')}>
<button
class="shrink-0 p-1 rounded transition {canGoBack
? 'text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400'
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
on:click={onGoBack}
disabled={!canGoBack}
aria-label={$i18n.t('Back')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
<div
class="m-0 flex items-center gap-1 px-1 pt-0 pb-1.5 shrink-0 border-b border-gray-50 dark:border-gray-850/30"
>
<div class="flex shrink-0 items-center gap-0.5 px-1">
<!-- Back -->
<Tooltip content={$i18n.t('Back')}>
<button
class="shrink-0 flex h-5 min-w-6 items-center justify-center rounded px-1.5 transition-colors duration-100 {canGoBack
? 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300'
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
on:click={onGoBack}
disabled={!canGoBack}
aria-label={$i18n.t('Back')}
>
<path
fill-rule="evenodd"
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<Icon name="chevron-left" size={11} strokeWidth={1.5} />
</button>
</Tooltip>
<!-- Forward -->
<Tooltip content={$i18n.t('Forward')}>
<button
class="shrink-0 p-1 rounded transition {canGoForward
? 'text-gray-400 dark:text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-600 dark:hover:text-gray-400'
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
on:click={onGoForward}
disabled={!canGoForward}
aria-label={$i18n.t('Forward')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
<!-- Forward -->
<Tooltip content={$i18n.t('Forward')}>
<button
class="shrink-0 flex h-5 min-w-6 items-center justify-center rounded px-1.5 transition-colors duration-100 {canGoForward
? 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300'
: 'text-gray-200 dark:text-gray-700 cursor-default'}"
on:click={onGoForward}
disabled={!canGoForward}
aria-label={$i18n.t('Forward')}
>
<path
fill-rule="evenodd"
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 1 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
<Icon name="chevron-right" size={11} strokeWidth={1.5} />
</button>
</Tooltip>
</div>
<div
bind:this={breadcrumbEl}
class="flex items-center flex-1 min-w-0 overflow-x-auto scrollbar-none"
class="flex items-center gap-1.5 flex-1 min-w-0 overflow-x-auto scrollbar-none"
>
{#each breadcrumbs as crumb, i}
{#if i > 1}
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0 select-none mx-0.5">/</span>
{#if showSeparator(i)}
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0 select-none">/</span>
{/if}
<button
class="text-xs shrink-0 px-1 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition
class="text-xs shrink-0 p-0 transition
{!selectedFile && i === breadcrumbs.length - 1
? 'text-gray-700 dark:text-gray-300'
: 'text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400'}
@ -124,7 +110,7 @@
on:dragleave={() => {
if (dragOverCrumb === i) dragOverCrumb = null;
}}
on:drop={(e) => {
on:drop={async (e) => {
if (!writable) return;
const raw = e.dataTransfer?.getData('application/x-terminal-file-move');
if (!raw) return;
@ -133,8 +119,8 @@
dragOverCrumb = null;
try {
const data = JSON.parse(raw);
const paths = data.paths || (data.path ? [data.path] : []);
for (const p of paths) onMove(p, crumb.path);
const paths = (data.paths || (data.path ? [data.path] : [])) as string[];
await onMove(paths, crumb.path);
} catch {}
}}
>
@ -142,56 +128,34 @@
</button>
{/each}
{#if selectedFile}
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0 select-none mx-0.5">/</span>
<span class="text-xs shrink-0 px-1.5 py-0.5 text-gray-700 dark:text-gray-300">
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0 select-none">/</span>
<span class="text-xs shrink-0 p-0 text-gray-700 dark:text-gray-300">
{selectedFile.split('/').pop()}
</span>
{/if}
</div>
{#if !writable}
<span class="text-[0.625rem] text-gray-400 dark:text-gray-500 shrink-0">
Read-only
</span>
<span class="text-[0.625rem] text-gray-400 dark:text-gray-500 shrink-0"> Read-only </span>
{/if}
<Tooltip content={$i18n.t('Refresh')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
on:click={onRefresh}
aria-label={$i18n.t('Refresh')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5 {loading ? 'animate-spin' : ''}"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.451a.75.75 0 0 0 0-1.5H4.5a.75.75 0 0 0-.75.75v3.75a.75.75 0 0 0 1.5 0v-2.127l.13.13a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.75a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 .75-.75V3.42a.75.75 0 0 0-1.5 0v2.126l-.13-.129A7 7 0 0 0 3.239 8.555a.75.75 0 0 0 1.449.39Z"
clip-rule="evenodd"
/>
</svg>
<Icon name="refresh" size={11} strokeWidth={1.4} class={loading ? 'animate-spin' : ''} />
</button>
</Tooltip>
{#if !selectedFile}
<Dropdown align="end" sideOffset={4}>
<Dropdown bind:show={sortMenuOpen} align="end" sideOffset={4}>
<Tooltip content={$i18n.t('Sort')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
aria-label={$i18n.t('Sort')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M2 3.75A.75.75 0 0 1 2.75 3h11.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 3.75ZM2 7.5a.75.75 0 0 1 .75-.75h7.508a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 7.5ZM14 7a.75.75 0 0 1 .75.75v6.69l1.72-1.72a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 1 1 1.06-1.06l1.72 1.72V7.75A.75.75 0 0 1 14 7ZM2 11.25a.75.75 0 0 1 .75-.75h4.562a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1-.75-.75Z"
/>
</svg>
<Icon name="sort" size={11} strokeWidth={1.4} />
</button>
</Tooltip>
@ -199,117 +163,140 @@
<DropdownMenu className="min-w-[9.375rem] z-[9999999]">
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
on:click={() => onSort('name')}
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={() => {
onSort('name');
sortMenuOpen = false;
}}
>
<span class="flex-1 text-left">{$i18n.t('Name')}</span>
{#if sortBy === 'name'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-3 text-gray-500 dark:text-gray-400 transition-transform {sortAsc
<Icon
name="chevron-up"
size={12}
strokeWidth={1.5}
class="text-gray-500 dark:text-gray-400 transition-transform {sortAsc
? ''
: 'rotate-180'}"
>
<path
fill-rule="evenodd"
d="M11.78 9.78a.75.75 0 0 1-1.06 0L8 7.06 5.28 9.78a.75.75 0 0 1-1.06-1.06l3.25-3.25a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06Z"
clip-rule="evenodd"
/>
</svg>
/>
{/if}
</button>
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
on:click={() => onSort('date')}
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={() => {
onSort('size');
sortMenuOpen = false;
}}
>
<span class="flex-1 text-left">{$i18n.t('Size')}</span>
{#if sortBy === 'size'}
<Icon
name="chevron-up"
size={12}
strokeWidth={1.5}
class="text-gray-500 dark:text-gray-400 transition-transform {sortAsc
? ''
: 'rotate-180'}"
/>
{/if}
</button>
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={() => {
onSort('date');
sortMenuOpen = false;
}}
>
<span class="flex-1 text-left">{$i18n.t('Date Modified')}</span>
{#if sortBy === 'date'}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-3 text-gray-500 dark:text-gray-400 transition-transform {sortAsc
<Icon
name="chevron-up"
size={12}
strokeWidth={1.5}
class="text-gray-500 dark:text-gray-400 transition-transform {sortAsc
? ''
: 'rotate-180'}"
>
<path
fill-rule="evenodd"
d="M11.78 9.78a.75.75 0 0 1-1.06 0L8 7.06 5.28 9.78a.75.75 0 0 1-1.06-1.06l3.25-3.25a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06Z"
clip-rule="evenodd"
/>
</svg>
/>
{/if}
</button>
</DropdownMenu>
</div>
</Dropdown>
<Tooltip content={$i18n.t('New Folder')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={onNewFolder}
disabled={!writable}
aria-label={$i18n.t('New Folder')}
>
<NewFolderAlt className="size-3.5" />
</button>
</Tooltip>
<Tooltip content={$i18n.t('New File')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={onNewFile}
disabled={!writable}
aria-label={$i18n.t('New File')}
>
<FilePlusAlt className="size-3.5" />
</button>
</Tooltip>
<Tooltip content={$i18n.t('Download')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
on:click={onDownloadDir}
aria-label={$i18n.t('Download')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
<Dropdown bind:show={actionsMenuOpen} align="end" sideOffset={4}>
<Tooltip content={$i18n.t('Actions')}>
<button
class="shrink-0 flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
aria-label={$i18n.t('Actions')}
>
<path
d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129V2.75Z"
/>
<path
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25v-2.5Z"
/>
</svg>
</button>
</Tooltip>
<Tooltip content={$i18n.t('Upload')}>
<button
class="shrink-0 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 disabled:opacity-30 disabled:hover:bg-transparent"
on:click={() => uploadInput?.click()}
disabled={!writable}
aria-label={$i18n.t('Upload')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-3.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"
/>
</svg>
</button>
</Tooltip>
<Icon name="three-dots" size={11} strokeWidth={1.4} />
</button>
</Tooltip>
<div slot="content">
<DropdownMenu className="min-w-[9.375rem] z-[9999999]">
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition disabled:opacity-40 disabled:hover:bg-transparent"
on:click={() => {
onNewFolder();
actionsMenuOpen = false;
}}
disabled={!writable}
>
<Icon name="folder" size={12} strokeWidth={1.4} />
<span>{$i18n.t('New Folder')}</span>
</button>
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition disabled:opacity-40 disabled:hover:bg-transparent"
on:click={() => {
onNewFile();
actionsMenuOpen = false;
}}
disabled={!writable}
>
<Icon name="empty-page" size={12} strokeWidth={1.4} />
<span>{$i18n.t('New File')}</span>
</button>
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition disabled:opacity-40 disabled:hover:bg-transparent"
on:click={() => {
actionsMenuOpen = false;
uploadInput?.click();
}}
disabled={!writable}
>
<Icon name="upload" size={12} strokeWidth={1.4} />
<span>{$i18n.t('Upload')}</span>
</button>
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={() => {
onDownloadDir();
actionsMenuOpen = false;
}}
>
<Icon name="download" size={12} strokeWidth={1.4} />
<span>{$i18n.t('Download')}</span>
</button>
<button
type="button"
class="select-none flex h-7 w-full items-center gap-2 rounded-lg px-2 text-xs hover:bg-gray-50/40 dark:hover:bg-white/4 transition"
on:click={() => {
onToggleHidden();
actionsMenuOpen = false;
}}
>
<Icon name="eye" size={12} strokeWidth={1.4} />
<span>{showHidden ? $i18n.t('Hide Hidden Files') : $i18n.t('Show Hidden Files')}</span>
</button>
</DropdownMenu>
</div>
</Dropdown>
<input
bind:this={uploadInput}
type="file"

View file

@ -265,6 +265,7 @@
let panzoomRef: PanzoomContainer;
let pptxPreviewRef: PptxPreview;
let imageZoomLevel = 1;
export const resetImageView = () => {
panzoomRef?.reset();
pptxPreviewRef?.resetView();
@ -288,6 +289,7 @@
{:else if fileImageUrl !== null}
<PanzoomContainer
bind:this={panzoomRef}
bind:zoomLevel={imageZoomLevel}
className="w-full h-full flex items-center justify-center"
options={{ zoomDoubleClickSpeed: 1 }}
>
@ -471,6 +473,57 @@
{$i18n.t('Could not read file.')}
</div>
{/if}
{#if !fileLoading && fileImageUrl !== null}
<div
class="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 items-center gap-0.5 rounded-lg border border-gray-200/60 bg-white/90 px-1 py-0.5 shadow-lg backdrop-blur-sm dark:border-gray-700/60 dark:bg-gray-850/90"
>
<button
type="button"
class="inline-flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md p-1.5 text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
on:click={() => panzoomRef?.zoomOut()}
aria-label={$i18n.t('Zoom out')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
fill-rule="evenodd"
d="M4 10a.75.75 0 0 1 .75-.75h10.5a.75.75 0 0 1 0 1.5H4.75A.75.75 0 0 1 4 10Z"
clip-rule="evenodd"
/>
</svg>
</button>
<button
type="button"
class="h-7 min-w-12 shrink-0 rounded-md px-1.5 py-1 text-center text-[0.6875rem] font-normal tabular-nums text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
on:click={() => panzoomRef?.reset()}
aria-label={$i18n.t('Reset zoom')}
>
{Math.round(imageZoomLevel * 100)}%
</button>
<button
type="button"
class="inline-flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md p-1.5 text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
on:click={() => panzoomRef?.zoomIn()}
aria-label={$i18n.t('Zoom in')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3.5"
>
<path
d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
/>
</svg>
</button>
</div>
{/if}
</div>
<style>

View file

@ -0,0 +1,15 @@
<script lang="ts">
import Icon from './Icon.svelte';
import { fileIconName } from './fileIcon';
export let name = '';
export let type = 'file';
export let size = 14;
export let strokeWidth = 1.4;
$: iconName = fileIconName(name, type);
$: tone =
type === 'directory' ? 'text-gray-500 dark:text-gray-400' : 'text-gray-400 dark:text-gray-500';
</script>
<Icon name={iconName} {size} {strokeWidth} class={`shrink-0 ${tone}`} />

View file

@ -0,0 +1,725 @@
<script lang="ts">
/**
* Unified icon component using iconoir SVG paths.
* Usage: <Icon name="folder" size={16} />
*/
interface Props {
name: string;
size?: number;
strokeWidth?: number;
class?: string;
}
let { name, size = 16, strokeWidth = 1.5, class: className = '' }: Props = $props();
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class={className}
>
{#if name === 'folder'}
<path
d="M2 11V4.6C2 4.26863 2.26863 4 2.6 4H8.77805C8.92127 4 9.05977 4.05124 9.16852 4.14445L12.3315 6.85555C12.4402 6.94876 12.5787 7 12.722 7H21.4C21.7314 7 22 7.26863 22 7.6V11M2 11V19.4C2 19.7314 2.26863 20 2.6 20H21.4C21.7314 20 22 19.7314 22 19.4V11M2 11H22"
/>
{:else if name === 'page'}
<path
d="M4 21.4V2.6C4 2.26863 4.26863 2 4.6 2H16.2515C16.4106 2 16.5632 2.06321 16.6757 2.17574L19.8243 5.32426C19.9368 5.43679 20 5.5894 20 5.74853V21.4C20 21.7314 19.7314 22 19.4 22H4.6C4.26863 22 4 21.7314 4 21.4Z"
/>
<path d="M16 2V5.4C16 5.73137 16.2686 6 16.6 6H20" />
{:else if name === 'page-text'}
<path
d="M4 21.4V2.6C4 2.26863 4.26863 2 4.6 2H16.2515C16.4106 2 16.5632 2.06321 16.6757 2.17574L19.8243 5.32426C19.9368 5.43679 20 5.5894 20 5.74853V21.4C20 21.7314 19.7314 22 19.4 22H4.6C4.26863 22 4 21.7314 4 21.4Z"
/>
<path d="M8 10L16 10" />
<path d="M8 18L16 18" />
<path d="M8 14L12 14" />
<path d="M16 2V5.4C16 5.73137 16.2686 6 16.6 6H20" />
{:else if name === 'terminal'}
<path d="M13 17H20" />
<path d="M5 7L10 12L5 17" />
{:else if name === 'tools'}
<path
d="M10.0503 10.6066L2.97923 17.6777C2.19818 18.4587 2.19818 19.7251 2.97923 20.5061C3.76027 21.2872 5.0266 21.2872 5.80765 20.5061L12.8787 13.4351"
/>
<path
d="M17.1927 13.7994L21.071 17.6777C21.8521 18.4587 21.8521 19.7251 21.071 20.5061C20.29 21.2872 19.0236 21.2872 18.2426 20.5061L12.0341 14.2977"
/>
<path
d="M6.73267 5.90381L4.61135 6.61092L2.49003 3.07539L3.90424 1.66117L7.43978 3.78249L6.73267 5.90381ZM6.73267 5.90381L9.5629 8.73404"
/>
<path
d="M10.0503 10.6066C9.2065 8.45359 9.37147 5.62861 11.111 3.8891C12.8505 2.14958 16.0607 1.76778 17.8285 2.82844L14.7878 5.86911L14.5052 8.98015L17.6162 8.69754L20.6569 5.65686C21.7176 7.42463 21.3358 10.6349 19.5963 12.3744C17.8567 14.1139 15.0318 14.2789 12.8788 13.435"
/>
{:else if name === 'search'}
<path d="M17 17L21 21" />
<path
d="M3 11C3 15.4183 6.58172 19 11 19C13.213 19 15.2161 18.1015 16.6644 16.6493C18.1077 15.2022 19 13.2053 19 11C19 6.58172 15.4183 3 11 3C6.58172 3 3 6.58172 3 11Z"
/>
{:else if name === 'git-diff'}
<path d="M9 8H15" />
<path d="M12 5V11" />
<path d="M9 16H15" />
{:else if name === 'git-branch'}
<path
d="M18 8C19.1046 8 20 7.10457 20 6C20 4.89543 19.1046 4 18 4C16.8954 4 16 4.89543 16 6C16 7.10457 16.8954 8 18 8Z"
/>
<path
d="M6 20C7.10457 20 8 19.1046 8 18C8 16.8954 7.10457 16 6 16C4.89543 16 4 16.8954 4 18C4 19.1046 4.89543 20 6 20Z"
/>
<path d="M6 16V3" />
<path d="M8 18H9C12.5 18 18 15.9 18 9.5V8" />
{:else if name === 'chat-fork'}
<path d="M4 12H9" />
<path d="M9 12C12.5 12 12.5 7 16 7H20" />
<path d="M17 4L20 7L17 10" />
<path d="M9 12C12.5 12 12.5 17 16 17H20" />
<path d="M17 14L20 17L17 20" />
{:else if name === 'chat-bubble'}
<path
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 13.8214 2.48697 15.5291 3.33782 17L2.5 21.5L7 20.6622C8.47087 21.513 10.1786 22 12 22Z"
/>
{:else if name === 'chat-plus'}
<path d="M9 12H15" />
<path d="M12 9V15" />
<path
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 13.8214 2.48697 15.5291 3.33782 17L2.5 21.5L7 20.6622C8.47087 21.513 10.1786 22 12 22Z"
/>
{:else if name === 'info'}
<path
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
/>
<path d="M12 8H12.01" />
<path d="M11 12H12V16H13" />
{:else if name === 'brain'}
<path
d="M9.5 4.5C7.9 3.4 5.6 4.3 5.2 6.2C3.7 6.6 2.7 8 2.8 9.6C1.8 10.7 1.8 12.6 2.9 13.7C2.5 15.7 4.1 17.5 6.1 17.5C6.8 19.2 9.1 19.6 10.3 18.2C10.9 17.5 11.2 16.6 11.2 15.7V6.9C11.2 5.9 10.6 5.1 9.5 4.5Z"
/>
<path
d="M14.5 4.5C16.1 3.4 18.4 4.3 18.8 6.2C20.3 6.6 21.3 8 21.2 9.6C22.2 10.7 22.2 12.6 21.1 13.7C21.5 15.7 19.9 17.5 17.9 17.5C17.2 19.2 14.9 19.6 13.7 18.2C13.1 17.5 12.8 16.6 12.8 15.7V6.9C12.8 5.9 13.4 5.1 14.5 4.5Z"
/>
<path d="M8 9.2C8.8 9.2 9.5 9.7 9.8 10.4" />
<path d="M16 9.2C15.2 9.2 14.5 9.7 14.2 10.4" />
<path d="M8.6 14.2C9.4 14.2 10.1 13.8 10.5 13.1" />
<path d="M15.4 14.2C14.6 14.2 13.9 13.8 13.5 13.1" />
{:else if name === 'spark'}
<path
d="M3 12C9.26752 12 12 9.36306 12 3C12 9.36306 14.7134 12 21 12C14.7134 12 12 14.7134 12 21C12 14.7134 9.26752 12 3 12Z"
/>
{:else if name === 'plus'}
<path d="M6 12H12M18 12H12M12 12V6M12 12V18" />
{:else if name === 'xmark'}
<path
d="M6.75827 17.2426L12.0009 12M17.2435 6.75736L12.0009 12M12.0009 12L6.75827 6.75736M12.0009 12L17.2435 17.2426"
/>
{:else if name === 'menu'}
<path d="M3 5H21" />
<path d="M3 12H21" />
<path d="M3 19H21" />
{:else if name === 'expand'}
<path d="M9 9L4 4M4 4V8M4 4H8" />
<path d="M15 9L20 4M20 4V8M20 4H16" />
<path d="M9 15L4 20M4 20V16M4 20H8" />
<path d="M15 15L20 20M20 20V16M20 20H16" />
{:else if name === 'sidebar-expand'}
<path
d="M19 21L5 21C3.89543 21 3 20.1046 3 19L3 5C3 3.89543 3.89543 3 5 3L19 3C20.1046 3 21 3.89543 21 5L21 19C21 20.1046 20.1046 21 19 21Z"
/>
<path d="M9.5 21V3" />
<path d="M5.5 10L7.25 12L5.5 14" />
{:else if name === 'settings'}
<path
d="M12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15Z"
/>
<path
d="M19.6224 10.3954L18.5247 7.7448L20 6L18 4L16.2647 5.48295L13.5578 4.36974L12.9353 2H10.981L10.3491 4.40113L7.70441 5.51596L6 4L4 6L5.45337 7.78885L4.3725 10.4463L2 11V13L4.40111 13.6555L5.51575 16.2997L4 18L6 20L7.79116 18.5403L10.397 19.6123L11 22H13L13.6045 19.6132L16.2551 18.5155C16.6969 18.8313 18 20 18 20L20 18L18.5159 16.2494L19.6139 13.598L21.9999 12.9772L22 11L19.6224 10.3954Z"
/>
{:else if name === 'usage'}
<path d="M4 19V5" />
<path d="M4 19H20" />
<path d="M8 16V12" />
<path d="M12 16V8" />
<path d="M16 16V10" />
<path d="M20 16V6" />
{:else if name === 'half-moon'}
<path
d="M3 11.5066C3 16.7497 7.25034 21 12.4934 21C16.2209 21 19.4466 18.8518 21 15.7259C12.4934 15.7259 8.27411 11.5066 8.27411 3C5.14821 4.55344 3 7.77915 3 11.5066Z"
/>
{:else if name === 'sun-light'}
<path
d="M12 18C15.3137 18 18 15.3137 18 12C18 8.68629 15.3137 6 12 6C8.68629 6 6 8.68629 6 12C6 15.3137 8.68629 18 12 18Z"
/>
<path d="M22 12L23 12" /><path d="M12 2V1" /><path d="M12 23V22" />
<path d="M20 20L19 19" /><path d="M20 4L19 5" />
<path d="M4 20L5 19" /><path d="M4 4L5 5" /><path d="M1 12L2 12" />
{:else if name === 'code'}
<path d="M13.5 6L10 18.5" />
<path d="M6.5 8.5L3 12L6.5 15.5" />
<path d="M17.5 8.5L21 12L17.5 15.5" />
{:else if name === 'python'}
<path
d="M8 10H16C17.1046 10 18 9.10457 18 8V5.5C18 4.11929 16.8807 3 15.5 3H10C8.89543 3 8 3.89543 8 5V10Z"
/>
<path
d="M16 14H8C6.89543 14 6 14.8954 6 16V18.5C6 19.8807 7.11929 21 8.5 21H14C15.1046 21 16 20.1046 16 19V14Z"
/>
<path d="M8 7H6.5C5.11929 7 4 8.11929 4 9.5V12C4 13.1046 4.89543 14 6 14H8" />
<path d="M16 10H18C19.1046 10 20 10.8954 20 12V14.5C20 15.8807 18.8807 17 17.5 17H16" />
<circle cx="11" cy="6.5" r="0.8" fill="currentColor" stroke="none" />
<circle cx="13" cy="17.5" r="0.8" fill="currentColor" stroke="none" />
{:else if name === 'javascript-logo'}
<rect x="4" y="4" width="16" height="16" rx="2" />
<text
x="12"
y="15"
text-anchor="middle"
font-size="7"
font-weight="700"
fill="currentColor"
stroke="none">JS</text
>
{:else if name === 'typescript-logo'}
<rect x="4" y="4" width="16" height="16" rx="2" />
<text
x="12"
y="15"
text-anchor="middle"
font-size="7"
font-weight="700"
fill="currentColor"
stroke="none">TS</text
>
{:else if name === 'react'}
<ellipse cx="12" cy="12" rx="9" ry="3.5" />
<ellipse cx="12" cy="12" rx="9" ry="3.5" transform="rotate(60 12 12)" />
<ellipse cx="12" cy="12" rx="9" ry="3.5" transform="rotate(120 12 12)" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none" />
{:else if name === 'rust'}
<circle cx="12" cy="12" r="6" />
<path
d="M12 3V5M12 19V21M3 12H5M19 12H21M5.6 5.6L7 7M17 17L18.4 18.4M18.4 5.6L17 7M7 17L5.6 18.4"
/>
<text
x="12"
y="14.4"
text-anchor="middle"
font-size="7"
font-weight="700"
fill="currentColor"
stroke="none">R</text
>
{:else if name === 'go-logo'}
<path d="M3 9H10" />
<path d="M2 12H9" />
<path d="M3 15H10" />
<text
x="15"
y="15"
text-anchor="middle"
font-size="8"
font-weight="700"
fill="currentColor"
stroke="none">GO</text
>
{:else if name === 'java-logo'}
<path d="M9 18H17" />
<path d="M8 14H18L17 18C16.6667 19.3333 15.5 20 12.5 20C9.5 20 8.33333 19.3333 8 18L7 14H8Z" />
<path d="M11 11C9 9 13 8 11 6" />
<path d="M14 11C12 9 16 8 14 5" />
<path d="M9 22H17" />
{:else if name === 'c-logo'}
<path d="M12 3L20 7.5V16.5L12 21L4 16.5V7.5L12 3Z" />
<text
x="12"
y="15"
text-anchor="middle"
font-size="8"
font-weight="700"
fill="currentColor"
stroke="none">C</text
>
{:else if name === 'cpp-logo'}
<path d="M12 3L20 7.5V16.5L12 21L4 16.5V7.5L12 3Z" />
<text
x="10"
y="15"
text-anchor="middle"
font-size="8"
font-weight="700"
fill="currentColor"
stroke="none">C</text
>
<path d="M14.5 10V14M12.5 12H16.5M19 10V14M17 12H21" />
{:else if name === 'c-sharp-logo'}
<path d="M12 3L20 7.5V16.5L12 21L4 16.5V7.5L12 3Z" />
<text
x="12"
y="15"
text-anchor="middle"
font-size="7"
font-weight="700"
fill="currentColor"
stroke="none">C#</text
>
{:else if name === 'ruby-logo'}
<path d="M12 3L20 8L17 18L12 21L7 18L4 8L12 3Z" />
<path d="M4 8H20L12 21L4 8Z" />
<path d="M8 8L12 3L16 8L12 21L8 8Z" />
{:else if name === 'php-logo'}
<ellipse cx="12" cy="12" rx="9" ry="5.5" />
<text
x="12"
y="14.2"
text-anchor="middle"
font-size="6"
font-weight="700"
fill="currentColor"
stroke="none">PHP</text
>
{:else if name === 'kotlin-logo'}
<path d="M5 4H19L5 18V4Z" />
<path d="M5 20L12 13L19 20H5Z" />
<path d="M5 4V20" />
{:else if name === 'svelte-logo'}
<path
d="M15.5 4.5C13.7 3.4 11.4 3.7 9.9 5.2L6.3 8.8C5 10.1 5 12.2 6.3 13.5C7.2 14.4 8.5 14.7 9.7 14.3"
/>
<path
d="M8.5 19.5C10.3 20.6 12.6 20.3 14.1 18.8L17.7 15.2C19 13.9 19 11.8 17.7 10.5C16.8 9.6 15.5 9.3 14.3 9.7"
/>
<path d="M9 12H15" />
{:else if name === 'vue-js'}
<path d="M12 20.5L22.5 4H18.5L12 14L5.5 4H1.5L12 20.5Z" />
<path d="M18.5 4H14.5L12 7.5L9.5 4H5.5" />
{:else if name === 'apple-swift'}
<path
d="M20.457 14.5892C20.9032 13.1527 21.9081 7.84019 14.5261 3.10086C14.2661 2.9345 13.9248 2.97821 13.7186 3.20043C13.5111 3.42264 13.5024 3.75778 13.6974 3.99092C13.7274 4.02614 16.4472 7.33991 15.4798 11.1248C13.8074 9.97369 7.1565 4.70249 7.1565 4.70249L11 11L3.8617 6.40006C3.8617 6.40006 8.90765 12.5953 11.9962 14.9255C10.5013 15.4622 7.25274 16.0305 2.963 13.364C2.72052 13.2122 2.40179 13.2413 2.1918 13.438C1.98431 13.6311 1.93931 13.9395 2.08555 14.1812C2.2293 14.4192 5.66784 20 12.9387 20C14.9335 20 16.0997 19.4317 17.0372 18.9764C17.6134 18.6971 18.0683 18.4749 18.5646 18.4749C19.8007 18.4749 20.6119 19.7025 20.6194 19.7134C20.7344 19.8919 20.9357 20 21.1507 20C21.1669 20 21.1844 19.9988 21.2019 19.9976C21.4356 19.9794 21.6381 19.8373 21.7281 19.6272C22.6206 17.5544 21.0832 15.359 20.457 14.5892Z"
/>
{:else if name === 'markdown-logo'}
<rect x="3" y="6" width="18" height="12" rx="1.5" />
<path d="M6 15V9L9 12L12 9V15" />
<path d="M16 9V15M14 13L16 15L18 13" />
{:else if name === 'json-logo'}
<path
d="M9 6H8C6.89543 6 6 6.89543 6 8V10C6 11.1046 5.10457 12 4 12C5.10457 12 6 12.8954 6 14V16C6 17.1046 6.89543 18 8 18H9"
/>
<path
d="M15 6H16C17.1046 6 18 6.89543 18 8V10C18 11.1046 18.8954 12 20 12C18.8954 12 18 12.8954 18 14V16C18 17.1046 17.1046 18 16 18H15"
/>
<path d="M11 9L13 15" />
{:else if name === 'yaml-logo'}
<path d="M5 5L9 12V19" />
<path d="M13 5L9 12" />
<path d="M14 9H20" />
<path d="M14 13H19" />
<path d="M14 17H18" />
{:else if name === 'xml-logo'}
<path d="M8 8L4 12L8 16" />
<path d="M16 8L20 12L16 16" />
<path d="M13.5 6L10.5 18" />
{:else if name === 'shell-logo'}
<path
d="M4 5.6C4 5.26863 4.26863 5 4.6 5H19.4C19.7314 5 20 5.26863 20 5.6V18.4C20 18.7314 19.7314 19 19.4 19H4.6C4.26863 19 4 18.7314 4 18.4V5.6Z"
/>
<path d="M7 9L10 12L7 15" />
<path d="M12 15H17" />
{:else if name === 'html5'}
<path d="M4 3L5.77778 20.0899L12 22L18.2222 20.0899L20 3H4Z" />
<path d="M17 7H7.5L8 11.5H16L15.5 17L12 18L8.5 17L8.25 14.5" />
{:else if name === 'css3'}
<path d="M4 3L5.77778 20.0899L12 22L18.2222 20.0899L20 3H4Z" />
<path d="M7 7H16.5L15.5 17L12 18L8.5 17L8.25 14.5" />
<path d="M16 11.5H7.5" />
{:else if name === 'svg-format'}
<path
d="M4.5 15H6.5C7.32843 15 8 14.3284 8 13.5C8 12.6716 7.32843 12 6.5 12H6C5.17157 12 4.5 11.3284 4.5 10.5C4.5 9.67157 5.17157 9 6 9H7.5"
/>
<path d="M10.5 9L12 15L13.5 9" />
<path d="M19.5 9H16.5V15L19.5 15V12.6" />
<path d="M4 6V3.6C4 3.26863 4.26863 3 4.6 3H19.4C19.7314 3 20 3.26863 20 3.6V6" />
<path d="M4 18V20.4C4 20.7314 4.26863 21 4.6 21H19.4C19.7314 21 20 20.7314 20 20.4V18" />
{:else if name === 'database-script'}
<path d="M22 14V6C22 4.34315 20.6569 3 19 3H9C7.34315 3 6 4.34315 6 6V13" />
<path
d="M12 21H6C3.79086 21 2 19.2091 2 17C2 14.7909 3.79086 13 6 13H18C15.7909 13 14 14.7909 14 17C14 19.2091 15.7909 21 18 21C20.2091 21 22 19.2091 22 17V14"
/>
{:else if name === 'csv-logo'}
<path
d="M4 5.6C4 5.26863 4.26863 5 4.6 5H19.4C19.7314 5 20 5.26863 20 5.6V18.4C20 18.7314 19.7314 19 19.4 19H4.6C4.26863 19 4 18.7314 4 18.4V5.6Z"
/>
<path d="M4 10H20M4 14H20M10 5V19M15 5V19" />
<text
x="12"
y="13.5"
text-anchor="middle"
font-size="5"
font-weight="700"
fill="currentColor"
stroke="none">CSV</text
>
{:else if name === 'pdf-logo'}
<path
d="M4 21.4V2.6C4 2.26863 4.26863 2 4.6 2H16.2515C16.4106 2 16.5632 2.06321 16.6757 2.17574L19.8243 5.32426C19.9368 5.43679 20 5.5894 20 5.74853V21.4C20 21.7314 19.7314 22 19.4 22H4.6C4.26863 22 4 21.7314 4 21.4Z"
/>
<path d="M16 2V5.4C16 5.73137 16.2686 6 16.6 6H20" />
<text
x="12"
y="15.2"
text-anchor="middle"
font-size="5.5"
font-weight="700"
fill="currentColor"
stroke="none">PDF</text
>
{:else if name === 'docx-logo'}
<path
d="M4 21.4V2.6C4 2.26863 4.26863 2 4.6 2H16.2515C16.4106 2 16.5632 2.06321 16.6757 2.17574L19.8243 5.32426C19.9368 5.43679 20 5.5894 20 5.74853V21.4C20 21.7314 19.7314 22 19.4 22H4.6C4.26863 22 4 21.7314 4 21.4Z"
/>
<path d="M16 2V5.4C16 5.73137 16.2686 6 16.6 6H20" />
<path d="M8 10H16" />
<path d="M8 14H16" />
<path d="M8 18H13" />
{:else if name === 'xlsx-logo'}
<path
d="M4 21.4V2.6C4 2.26863 4.26863 2 4.6 2H16.2515C16.4106 2 16.5632 2.06321 16.6757 2.17574L19.8243 5.32426C19.9368 5.43679 20 5.5894 20 5.74853V21.4C20 21.7314 19.7314 22 19.4 22H4.6C4.26863 22 4 21.7314 4 21.4Z"
/>
<path d="M16 2V5.4C16 5.73137 16.2686 6 16.6 6H20" />
<path d="M7.5 10H16.5V18H7.5V10Z" />
<path d="M7.5 14H16.5" />
<path d="M12 10V18" />
{:else if name === 'pptx-logo'}
<path
d="M4 21.4V2.6C4 2.26863 4.26863 2 4.6 2H16.2515C16.4106 2 16.5632 2.06321 16.6757 2.17574L19.8243 5.32426C19.9368 5.43679 20 5.5894 20 5.74853V21.4C20 21.7314 19.7314 22 19.4 22H4.6C4.26863 22 4 21.7314 4 21.4Z"
/>
<path d="M16 2V5.4C16 5.73137 16.2686 6 16.6 6H20" />
<path d="M8 10H16V16H8V10Z" />
{:else if name === 'npm'}
<path d="M1 8L23 8V15L11 15V17L7.5 17V15L1 15V8Z" />
<path d="M7.5 8L7.5 15" />
<path d="M13.5 8V15" />
<path d="M18 11V15" />
<path d="M5 11V15" />
<path d="M11 11V12" />
<path d="M20.5 11V15" />
{:else if name === 'archive'}
<path d="M7 6L17 6" />
<path d="M7 9L17 9" />
<path d="M9 17H15" />
<path
d="M3 12H2.6C2.26863 12 2 12.2686 2 12.6V21.4C2 21.7314 2.26863 22 2.6 22H21.4C21.7314 22 22 21.7314 22 21.4V12.6C22 12.2686 21.7314 12 21.4 12H21M3 12V2.6C3 2.26863 3.26863 2 3.6 2H20.4C20.7314 2 21 2.26863 21 2.6V12M3 12H21"
/>
{:else if name === 'package'}
<path
d="M20 5.99999L20 18C20 19.1046 19.1046 20 18 20H6C4.89543 20 4 19.1046 4 18V6C4 4.89543 4.89543 4 6 4H18C19.1045 4 19.9999 4.89543 20 5.99999Z"
/>
<path d="M12 9V4" />
{:else if name === 'package-lock'}
<path
d="M14 20H6C4.89543 20 4 19.1046 4 18V6C4 4.89543 4.89543 4 6 4H18C19.1045 4 19.9999 4.89543 20 5.99999L20 12"
/>
<path d="M12 9V4" />
<path
d="M21.1667 18.5H21.4C21.7314 18.5 22 18.7686 22 19.1V21.4C22 21.7314 21.7314 22 21.4 22H17.6C17.2686 22 17 21.7314 17 21.4V19.1C17 18.7686 17.2686 18.5 17.6 18.5H17.8333M21.1667 18.5V16.75C21.1667 16.1667 20.8333 15 19.5 15C18.1667 15 17.8333 16.1667 17.8333 16.75V18.5M21.1667 18.5H17.8333"
/>
{:else if name === 'list'}
<path d="M8 6H20" />
<path d="M4 6.01L4.01 5.99889" />
<path d="M4 12.01L4.01 11.9989" />
<path d="M4 18.01L4.01 17.9989" />
<path d="M8 12H20" />
<path d="M8 18H20" />
{:else if name === 'split-view'}
<path
d="M4 5.6C4 5.26863 4.26863 5 4.6 5H19.4C19.7314 5 20 5.26863 20 5.6V18.4C20 18.7314 19.7314 19 19.4 19H4.6C4.26863 19 4 18.7314 4 18.4V5.6Z"
/>
<path d="M12 5V19" />
{:else if name === 'chevron-right'}
<path d="M9 6L15 12L9 18" />
{:else if name === 'arrow-left'}
<path d="M21 12L3 12M3 12L11.5 3.5M3 12L11.5 20.5" />
{:else if name === 'log-out'}
<path d="M12 12H19M19 12L16 15M19 12L16 9" />
<path
d="M19 6V5C19 3.89543 18.1046 3 17 3H7C5.89543 3 5 3.89543 5 5V19C5 20.1046 5.89543 21 7 21H17C18.1046 21 19 20.1046 19 19V18"
/>
{:else if name === 'user'}
<path
d="M12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2Z"
/>
<path
d="M4.271 18.346C4.271 18.346 6.50002 15.5 12 15.5C17.5 15.5 19.729 18.346 19.729 18.346"
/>
<path
d="M12 12C13.6569 12 15 10.6569 15 9C15 7.34315 13.6569 6 12 6C10.3431 6 9 7.34315 9 9C9 10.6569 10.3431 12 12 12Z"
/>
{:else if name === 'lock'}
<path
d="M16 12H17.4C17.7314 12 18 12.2686 18 12.6V19.4C18 19.7314 17.7314 20 17.4 20H6.6C6.26863 20 6 19.7314 6 19.4V12.6C6 12.2686 6.26863 12 6.6 12H8M16 12V8C16 6.66667 15.2 4 12 4C8.8 4 8 6.66667 8 8V12M16 12H8"
/>
{:else if name === 'docker'}
<path
fill="currentColor"
stroke="none"
d="M13.983 11.078h2.119a.186.186 0 0 0 .186-.185V9.006a.186.186 0 0 0-.186-.186h-2.119a.185.185 0 0 0-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 0 0 .186-.186V3.574a.186.186 0 0 0-.186-.185h-2.118a.185.185 0 0 0-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 0 0 .186-.186V6.29a.186.186 0 0 0-.186-.185h-2.118a.185.185 0 0 0-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 0 0 .184-.186V6.29a.185.185 0 0 0-.185-.185H8.1a.185.185 0 0 0-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 0 0 .185-.186V6.29a.185.185 0 0 0-.185-.185H5.136a.186.186 0 0 0-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 0 0 .186-.185V9.006a.186.186 0 0 0-.186-.186h-2.118a.185.185 0 0 0-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 0 0 .184-.185V9.006a.185.185 0 0 0-.184-.186h-2.12a.185.185 0 0 0-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 0 0 .185-.185V9.006a.185.185 0 0 0-.184-.186h-2.12a.186.186 0 0 0-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 0 0 .184-.185V9.006a.185.185 0 0 0-.184-.186h-2.12a.185.185 0 0 0-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 0 0-.75.748 11.376 11.376 0 0 0 .692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 0 0 3.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z"
/>
{:else if name === 'empty-page'}
<path
d="M4 21.4V2.6C4 2.26863 4.26863 2 4.6 2H16.2515C16.4106 2 16.5632 2.06321 16.6757 2.17574L19.8243 5.32426C19.9368 5.43679 20 5.5894 20 5.74853V21.4C20 21.7314 19.7314 22 19.4 22H4.6C4.26863 22 4 21.7314 4 21.4Z"
/>
<path d="M16 2V5.4C16 5.73137 16.2686 6 16.6 6H20" />
{:else if name === 'monitor'}
<path d="M2 17V4.6C2 4.26863 2.26863 4 2.6 4H21.4C21.7314 4 22 4.26863 22 4.6V17" />
<path d="M1 17H23" />
<path d="M12 17V20" />
<path d="M9 20H15" />
{:else if name === 'phone'}
<path
d="M8 2.6C8 2.26863 8.26863 2 8.6 2H15.4C15.7314 2 16 2.26863 16 2.6V21.4C16 21.7314 15.7314 22 15.4 22H8.6C8.26863 22 8 21.7314 8 21.4V2.6Z"
/>
<path d="M11 18.5H13" />
{:else if name === 'chevron-left'}
<path d="M15 6L9 12L15 18" />
{:else if name === 'check'}
<path d="M5 13L9 17L19 7" />
{:else if name === 'upload'}
<path d="M6 20H18" />
<path d="M12 16V4M12 4L15.5 7.5M12 4L8.5 7.5" />
{:else if name === 'three-dots'}
<circle cx="5" cy="12" r="1.5" fill="currentColor" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
<circle cx="19" cy="12" r="1.5" fill="currentColor" />
{:else if name === 'trash'}
<path
d="M20 9L18.005 20.3463C17.8369 21.3026 17.0062 22 16.0353 22H7.96474C6.99379 22 6.1631 21.3026 5.99496 20.3463L4 9"
/>
<path
d="M21 6H15.5M3 6H8.5M8.5 6V4C8.5 3.44772 8.94772 3 9.5 3H14.5C15.0523 3 15.5 3.44772 15.5 4V6M8.5 6H15.5"
/>
{:else if name === 'pencil'}
<path
d="M14.3632 5.65156L15.8431 4.17157C16.6242 3.39052 17.8905 3.39052 18.6716 4.17157L19.8284 5.32843C20.6095 6.10948 20.6095 7.37581 19.8284 8.15685L18.3484 9.63685M14.3632 5.65156L4.74749 15.2672C4.41542 15.5993 4.21079 16.0376 4.16947 16.5054L3.92738 19.2459C3.87261 19.8659 4.13434 20.1276 4.75408 20.0729L7.49463 19.8308C7.96243 19.7894 8.40074 19.5848 8.73281 19.2527L18.3484 9.63685M14.3632 5.65156L18.3484 9.63685"
/>
{:else if name === 'download'}
<path d="M6 20H18" />
<path d="M12 4V16M12 16L15.5 12.5M12 16L8.5 12.5" />
{:else if name === 'eye'}
<path
d="M12 14C13.1046 14 14 13.1046 14 12C14 10.8954 13.1046 10 12 10C10.8954 10 10 10.8954 10 12C10 13.1046 10.8954 14 12 14Z"
/>
<path
d="M21 12C19.1114 14.991 15.7183 18 12 18C8.2817 18 4.88856 14.991 3 12C5.29855 9.15825 7.99163 6 12 6C16.0084 6 18.7015 9.15825 21 12Z"
/>
{:else if name === 'minus'}
<path d="M6 12H18" />
{:else if name === 'chevron-up'}
<path d="M6 15L12 9L18 15" />
{:else if name === 'chevron-down'}
<path d="M6 9L12 15L18 9" />
{:else if name === 'expand'}
<path d="M15 3h6v6" />
<path d="M9 21H3v-6" />
<path d="M21 3l-7 7" />
<path d="M3 21l7-7" />
{:else if name === 'collapse'}
<path d="M4 14h6v6" />
<path d="M20 10h-6V4" />
<path d="M14 10l7-7" />
<path d="M3 21l7-7" />
{:else if name === 'refresh'}
<path d="M21.168 8A10.003 10.003 0 0012 2C6.815 2 2.55 5.947 2.05 11" />
<path d="M17 8H21.4C21.7314 8 22 7.73137 22 7.4V3" />
<path d="M2.832 16c1.612 3.604 5.253 6 9.168 6 5.185 0 9.45-3.947 9.95-9" />
<path d="M7 16H2.6C2.26863 16 2 16.2686 2 16.6V21" />
{:else if name === 'copy'}
<path
d="M19.4 20H9.6C9.26863 20 9 19.7314 9 19.4V9.6C9 9.26863 9.26863 9 9.6 9H19.4C19.7314 9 20 9.26863 20 9.6V19.4C20 19.7314 19.7314 20 19.4 20Z"
/>
<path
d="M15 9V4.6C15 4.26863 14.7314 4 14.4 4H4.6C4.26863 4 4 4.26863 4 4.6V14.4C4 14.7314 4.26863 15 4.6 15H9"
/>
{:else if name === 'sort'}
<path d="M4 5H20" />
<path d="M4 12H16" />
<path d="M4 19H12" />
{:else if name === 'external-link'}
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
{:else if name === 'save'}
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline points="17 21 17 13 7 13 7 21" />
<polyline points="7 3 7 8 15 8" />
{:else if name === 'shield'}
<path d="M12 22C12 22 20 18 20 12V5L12 2L4 5V12C4 18 12 22 12 22Z" />
{:else if name === 'gateway'}
<path d="M3 19H12M21 19H12M12 19V13M12 13H18V5H6V13H12Z" />
<path d="M9 9.01L9.01 8.99889" />
<path d="M12 9.01L12.01 8.99889" />
{:else if name === 'quote'}
<path d="M10 12H5C5 7 7.5 4.5 10 3.5" />
<path d="M21 12H16C16 7 18.5 4.5 21 3.5" />
<path d="M5 12V17C5 18.1046 5.89543 19 7 19H8" />
<path d="M16 12V17C16 18.1046 16.8954 19 18 19H19" />
{:else if name === 'undo'}
<path d="M4.5 8H15C17.7614 8 20 10.2386 20 13C20 15.7614 17.7614 18 15 18H4.5" />
<path d="M7.5 11.5L4 8L7.5 4.5" />
{:else if name === 'redo'}
<path d="M19.5 8H9C6.23858 8 4 10.2386 4 13C4 15.7614 6.23858 18 9 18H19.5" />
<path d="M16.5 11.5L20 8L16.5 4.5" />
{:else if name === 'list-ordered'}
<path d="M9 6H20" />
<path d="M9 12H20" />
<path d="M9 18H20" />
<path
d="M4 16.5V16.8C4 17.1866 4.14 17.5 4.5 17.5H5.5C5.86 17.5 6 17.8 6 18C6 18.2 5.86 18.5 5.5 18.5H4.5"
/>
<path d="M5 6V4L4 4.5" />
<path d="M4 12.5H5.5L4 14H6" />
{:else if name === 'check-square'}
<path
d="M3 20.4V3.6C3 3.26863 3.26863 3 3.6 3H20.4C20.7314 3 21 3.26863 21 3.6V20.4C21 20.7314 20.7314 21 20.4 21H3.6C3.26863 21 3 20.7314 3 20.4Z"
/>
<path d="M9 12L11 14L15 10" />
{:else if name === 'table'}
<path
d="M3 20.4V3.6C3 3.26863 3.26863 3 3.6 3H20.4C20.7314 3 21 3.26863 21 3.6V20.4C21 20.7314 20.7314 21 20.4 21H3.6C3.26863 21 3 20.7314 3 20.4Z"
/>
<path d="M3 9H21" />
<path d="M3 15H21" />
<path d="M9 3V21" />
{:else if name === 'image'}
<path
d="M21 3.6V20.4C21 20.7314 20.7314 21 20.4 21H3.6C3.26863 21 3 20.7314 3 20.4V3.6C3 3.26863 3.26863 3 3.6 3H20.4C20.7314 3 21 3.26863 21 3.6Z"
/>
<path d="M3 16L10 13L21 18" />
<path
d="M16 10C14.8954 10 14 9.10457 14 8C14 6.89543 14.8954 6 16 6C17.1046 6 18 6.89543 18 8C18 9.10457 17.1046 10 16 10Z"
/>
{:else if name === 'split-horizontal'}
<!-- Two side-by-side panes with a vertical divider -->
<path
d="M3 20.4V3.6C3 3.26863 3.26863 3 3.6 3H20.4C20.7314 3 21 3.26863 21 3.6V20.4C21 20.7314 20.7314 21 20.4 21H3.6C3.26863 21 3 20.7314 3 20.4Z"
/>
<path d="M12 3V21" />
{:else if name === 'split-vertical'}
<!-- Two stacked panes with a horizontal divider -->
<path
d="M3 20.4V3.6C3 3.26863 3.26863 3 3.6 3H20.4C20.7314 3 21 3.26863 21 3.6V20.4C21 20.7314 20.7314 21 20.4 21H3.6C3.26863 21 3 20.7314 3 20.4Z"
/>
<path d="M3 12H21" />
{:else if name === 'arrow-right-from-line'}
<!-- Open to the side icon -->
<path d="M4 3V21" />
<path d="M10 12H20M20 12L16 8M20 12L16 16" />
{:else if name === 'swap'}
<path d="M7.5 4.5L4.5 7.5L7.5 10.5" />
<path d="M16.5 13.5L19.5 16.5L16.5 19.5" />
<path d="M5 7.5H19.5" />
<path d="M19 16.5H4.5" />
{:else if name === 'plug'}
<!-- Connection/plug icon -->
<path d="M12 22V18" />
<path d="M8 18H16" />
<path d="M8 14V18" />
<path d="M16 14V18" />
<path d="M7 14H17C17 14 20 13 20 9C20 7 18 6 16 6H8C6 6 4 7 4 9C4 13 7 14 7 14Z" />
<path d="M10 6V2" />
<path d="M14 6V2" />
{:else if name === 'play'}
<path
d="M6.90588 4.53682C6.50592 4.2998 6 4.58808 6 5.05299V18.947C6 19.4119 6.50592 19.7002 6.90588 19.4632L18.629 12.5162C19.0211 12.2838 19.0211 11.7162 18.629 11.4838L6.90588 4.53682Z"
/>
{:else if name === 'clock'}
<circle cx="12" cy="12" r="10" />
<path d="M12 6V12H16.5" />
{:else if name === 'cube'}
<path d="M12 2L2 7L12 12L22 7L12 2Z" />
<path d="M2 17L12 22L22 17" />
<path d="M2 12L12 17L22 12" />
{:else if name === 'telegram'}
<path d="M21 5L2 12.5L9 13.5M21 5L18.5 20L9 13.5M21 5L9 13.5M9 13.5V19L12.2488 15.7229" />
{:else if name === 'discord'}
<path d="M5.5 16C10.5 18.5 13.5 18.5 18.5 16" />
<path
d="M15.5 17.5L16.5 19.5C16.5 19.5 20.6713 18.1717 22 16C22 15 22.5301 7.85339 19 5.5C17.5 4.5 15 4 15 4L14 6H12"
/>
<path
d="M8.52832 17.5L7.52832 19.5C7.52832 19.5 3.35699 18.1717 2.02832 16C2.02832 15 1.49823 7.85339 5.02832 5.5C6.52832 4.5 9.02832 4 9.02832 4L10.0283 6H12.0283"
/>
<path
d="M8.5 14C7.67157 14 7 13.1046 7 12C7 10.8954 7.67157 10 8.5 10C9.32843 10 10 10.8954 10 12C10 13.1046 9.32843 14 8.5 14Z"
/>
<path
d="M15.5 14C14.6716 14 14 13.1046 14 12C14 10.8954 14.6716 10 15.5 10C16.3284 10 17 10.8954 17 12C17 13.1046 16.3284 14 15.5 14Z"
/>
{:else if name === 'slack'}
<path d="M10 3L6 21" />
<path d="M20.5 16H2.5" />
<path d="M22 7H4" />
<path d="M18 3L14 21" />
{:else if name === 'whatsapp'}
<path
d="M22 12C22 17.5228 17.5228 22 12 22C10.1786 22 8.47087 21.513 7 20.6622L2 21.5L2.83209 16C2.29689 14.7751 2 13.4222 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z"
/>
<path
d="M12.9604 13.8683L15.0399 13.4624L17 14.2149V16.0385C17 16.6449 16.4783 17.1073 15.8901 16.9783C14.3671 16.6444 11.5997 15.8043 9.67826 13.8683C7.84859 12.0248 7.22267 9.45734 7.01039 8.04128C6.92535 7.47406 7.3737 7 7.94306 7H9.83707L10.572 8.96888L10.1832 11.0701"
/>
{:else if name === 'signal'}
<path d="M8.5 11.5L11.5 14.5L16.5 9.5" />
<path
d="M5 18L3.13036 4.91253C3.05646 4.39524 3.39389 3.91247 3.90398 3.79912L11.5661 2.09641C11.8519 2.03291 12.1481 2.03291 12.4339 2.09641L20.096 3.79912C20.6061 3.91247 20.9435 4.39524 20.8696 4.91252L19 18C18.9293 18.495 18.5 21.5 12 21.5C5.5 21.5 5.07071 18.495 5 18Z"
/>
{:else if name === 'browser'}
<path
d="M22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12Z"
/>
<path d="M13 2.04932C13 2.04932 16 6 16 12C16 18 13 21.9507 13 21.9507" />
<path d="M11 21.9507C11 21.9507 8 18 8 12C8 6 11 2.04932 11 2.04932" />
<path d="M2 12H22" />
{:else if name === 'globe'}
<circle cx="12" cy="12" r="10" />
<path d="M2 12H22" />
<path d="M12 2C14.5 5.5 14.5 18.5 12 22" />
<path d="M12 2C9.5 5.5 9.5 18.5 12 22" />
<path d="M4 7H20" />
<path d="M4 17H20" />
{:else if name === 'microphone'}
<path
d="M12 1C10.3431 1 9 2.34315 9 4V12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12V4C15 2.34315 13.6569 1 12 1Z"
/>
<path d="M5 10V12C5 15.866 8.13401 19 12 19C15.866 19 19 15.866 19 12V10" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
{:else if name === 'speaker'}
<path d="M4 9V15H8L13 20V4L8 9H4Z" />
<path d="M16 9C16.6254 9.81968 17 10.8637 17 12C17 13.1363 16.6254 14.1803 16 15" />
<path d="M18.5 6.5C20.0645 7.86634 21 9.82395 21 12C21 14.176 20.0645 16.1337 18.5 17.5" />
{:else if name === 'stop'}
<rect x="6" y="6" width="12" height="12" rx="1" fill="currentColor" />
{:else if name === 'share'}
<path
d="M18 22C19.6569 22 21 20.6569 21 19C21 17.3431 19.6569 16 18 16C16.3431 16 15 17.3431 15 19C15 20.6569 16.3431 22 18 22Z"
/>
<path
d="M18 8C19.6569 8 21 6.65685 21 5C21 3.34315 19.6569 2 18 2C16.3431 2 15 3.34315 15 5C15 6.65685 16.3431 8 18 8Z"
/>
<path
d="M6 15C7.65685 15 9 13.6569 9 12C9 10.3431 7.65685 9 6 9C4.34315 9 3 10.3431 3 12C3 13.6569 4.34315 15 6 15Z"
/>
<path d="M15.5 6.5L8.5 10.5" />
<path d="M8.5 13.5L15.5 17.5" />
{:else if name === 'wifi-off'}
<path d="M1 1L23 23" />
<path d="M16.72 11.06C17.8106 11.5469 18.8104 12.2174 19.67 13.05" />
<path d="M5 12.55C6.97 10.6 9.47 9.48 12 9.48C12.9 9.48 13.8 9.62 14.65 9.87" />
<path d="M8.53 16.11C9.5 15.14 10.7 14.56 12 14.56C12.7 14.56 13.38 14.71 14 15" />
<path d="M2 8.82C5.23 5.72 8.5 4 12 4C13.72 4 15.4 4.4 16.97 5.15" />
<circle cx="12" cy="20" r="1" fill="currentColor" />
{/if}
</svg>

View file

@ -3,6 +3,7 @@
import type { ListeningPort } from '$lib/apis/terminal';
import { getListeningPorts, getPortProxyUrl } from '$lib/apis/terminal';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Icon from './Icon.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher<{ previewPort: number }>();
@ -55,21 +56,15 @@
<div class="px-2 py-1">
<button
class="flex items-center gap-1 w-full text-xs font-normal text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition"
class="flex items-center gap-1 w-full text-xs font-normal text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors duration-100"
on:click={() => (expanded = !expanded)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3 transition-transform {expanded ? '' : '-rotate-90'}"
>
<path
fill-rule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
<Icon
name="chevron-down"
size={12}
strokeWidth={1.4}
class="transition-transform {expanded ? '' : '-rotate-90'}"
/>
{$i18n.t('Ports')}
<span class="ml-auto flex items-center gap-1">
{#if ports.length > 0}
@ -81,22 +76,11 @@
{/if}
<Tooltip content={$i18n.t('Refresh')}>
<button
class="p-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400"
class="flex h-5 w-5 items-center justify-center rounded transition-colors duration-100 text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300"
on:click|stopPropagation={loadPorts}
aria-label={$i18n.t('Refresh')}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3 {loading ? 'animate-spin' : ''}"
>
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.451a.75.75 0 0 0 0-1.5H4.5a.75.75 0 0 0-.75.75v3.75a.75.75 0 0 0 1.5 0v-2.127l.13.13a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm-10.624-2.85a5.5 5.5 0 0 1 9.201-2.465l.312.31H11.75a.75.75 0 0 0 0 1.5h3.75a.75.75 0 0 0 .75-.75V3.42a.75.75 0 0 0-1.5 0v2.126l-.13-.129A7 7 0 0 0 3.239 8.555a.75.75 0 0 0 1.449.39Z"
clip-rule="evenodd"
/>
</svg>
<Icon name="refresh" size={11} strokeWidth={1.4} class={loading ? 'animate-spin' : ''} />
</button>
</Tooltip>
</span>
@ -111,7 +95,7 @@
{:else}
{#each ports as port}
<button
class="flex items-center w-full gap-2 px-1.5 py-1 text-xs rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition group"
class="flex h-7 items-center w-full gap-2 px-1.5 text-xs rounded-lg hover:bg-gray-50/40 dark:hover:bg-white/4 transition-colors duration-75 group"
on:click={() => previewPort(port.port)}
>
<span class="font-mono text-blue-500 dark:text-blue-400 shrink-0">
@ -125,21 +109,10 @@
<span
role="button"
tabindex="-1"
class="text-gray-400 dark:text-gray-500 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition shrink-0 p-0.5 rounded hover:bg-gray-200 dark:hover:bg-gray-700"
class="text-gray-400 dark:text-gray-500 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition shrink-0 flex h-5 w-5 items-center justify-center rounded hover:text-gray-600 dark:hover:text-gray-300"
on:click|stopPropagation={() => openPortExternal(port.port)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="size-3"
>
<path
fill-rule="evenodd"
d="M4.25 5.5a.75.75 0 0 0-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 1 1.5 0v4A2.25 2.25 0 0 1 12.75 17h-8.5A2.25 2.25 0 0 1 2 14.75v-8.5A2.25 2.25 0 0 1 4.25 4h5a.75.75 0 0 1 0 1.5h-5Zm7.5-3.5a.75.75 0 0 0 0 1.5h2.69l-4.72 4.72a.75.75 0 0 0 1.06 1.06l4.72-4.72v2.69a.75.75 0 0 0 1.5 0v-5.25a.75.75 0 0 0-.75-.75h-5.25Z"
clip-rule="evenodd"
/>
</svg>
<Icon name="external-link" size={11} strokeWidth={1.4} />
</span>
</Tooltip>
</button>

View file

@ -0,0 +1,163 @@
/**
* Shared file icon name resolver.
* Maps file names/extensions to Icon component names.
*/
export function fileIconName(name: string, type: string = 'file'): string {
if (type === 'directory') return 'folder';
const base = name.split('/').pop() ?? name;
const lower = base.toLowerCase();
const ext = base.includes('.') ? (base.split('.').pop()?.toLowerCase() ?? '') : '';
if (lower === 'dockerfile' || lower === 'containerfile') return 'docker';
if (lower === 'license' || lower === 'license.md') return 'page-text';
if (lower === 'package.json' || lower === 'npm-shrinkwrap.json' || lower === 'bunfig.toml') {
return 'npm';
}
if (
lower.endsWith('.lock') ||
lower === 'package-lock.json' ||
lower === 'pnpm-lock.yaml' ||
lower === 'yarn.lock'
) {
return 'package-lock';
}
switch (ext) {
case 'md':
case 'mdx':
case 'markdown':
return 'markdown-logo';
case 'ts':
return 'typescript-logo';
case 'tsx':
return 'react';
case 'js':
case 'mjs':
case 'cjs':
return 'javascript-logo';
case 'jsx':
return 'react';
case 'py':
case 'pyw':
return 'python';
case 'rs':
return 'rust';
case 'go':
return 'go-logo';
case 'java':
return 'java-logo';
case 'c':
case 'h':
return 'c-logo';
case 'cpp':
case 'cc':
case 'cxx':
case 'hpp':
case 'hh':
case 'hxx':
return 'cpp-logo';
case 'cs':
return 'c-sharp-logo';
case 'rb':
return 'ruby-logo';
case 'php':
return 'php-logo';
case 'swift':
return 'apple-swift';
case 'kt':
case 'kts':
return 'kotlin-logo';
case 'svelte':
return 'svelte-logo';
case 'vue':
return 'vue-js';
case 'json':
case 'jsonc':
case 'json5':
return 'json-logo';
case 'yaml':
case 'yml':
return 'yaml-logo';
case 'toml':
case 'ini':
case 'cfg':
case 'conf':
case 'env':
return 'settings';
case 'sh':
case 'bash':
case 'zsh':
case 'fish':
return 'shell-logo';
case 'html':
case 'htm':
return 'html5';
case 'css':
case 'scss':
case 'sass':
case 'less':
return 'css3';
case 'xml':
return 'xml-logo';
case 'svg':
return 'svg-format';
case 'sql':
case 'sqlite':
case 'sqlite3':
case 'db':
case 'db3':
return 'database-script';
case 'csv':
case 'tsv':
return 'csv-logo';
case 'pdf':
return 'pdf-logo';
case 'jpg':
case 'jpeg':
case 'png':
case 'gif':
case 'webp':
case 'bmp':
case 'ico':
case 'avif':
case 'tiff':
return 'image';
case 'mp4':
case 'webm':
case 'mov':
case 'ogv':
case 'avi':
case 'mkv':
return 'play';
case 'mp3':
case 'wav':
case 'ogg':
case 'flac':
case 'm4a':
case 'aac':
case 'opus':
case 'wma':
return 'speaker';
case 'zip':
case 'tar':
case 'gz':
case 'tgz':
case 'bz2':
case 'xz':
case '7z':
case 'rar':
return 'archive';
case 'docx':
return 'docx-logo';
case 'txt':
return 'page-text';
case 'xlsx':
case 'xls':
return 'xlsx-logo';
case 'pptx':
return 'pptx-logo';
default:
return 'empty-page';
}
}

View file

@ -104,6 +104,7 @@
import Knobs from '../icons/Knobs.svelte';
import ValvesModal from '../workspace/common/ValvesModal.svelte';
import Note from '../icons/Note.svelte';
import AskUserCard from './AskUserCard.svelte';
import { goto } from '$app/navigation';
import InputModal from '../common/InputModal.svelte';
import Expand from '../icons/Expand.svelte';
@ -112,6 +113,15 @@
const i18n = getContext('i18n');
type AskUserPrompt = {
show: boolean;
questions: any[];
allowOther: boolean;
timeoutMs: number | null;
onConfirm: (value: any) => void;
onCancel: () => void;
};
export let onUpload: Function = (e) => {};
export let onChange: Function = () => {};
export let onWebSearchToggle: Function = () => {};
@ -143,12 +153,28 @@
export let history;
export let taskIds = null;
export let askUser: AskUserPrompt = {
show: false,
questions: [],
allowOther: true,
timeoutMs: null,
onConfirm: (_value: any) => {},
onCancel: () => {}
};
$: isActive =
(taskIds && taskIds.length > 0) ||
(history.currentId && history.messages[history.currentId]?.done != true) ||
generating;
!askUser?.show &&
((taskIds && taskIds.length > 0) ||
(history.currentId && history.messages[history.currentId]?.done != true) ||
generating);
$: canCompact = !!history?.currentId;
$: canToggleTemporary =
!embedded &&
!chatId &&
($_user?.role === 'admin' ||
($_user?.role === 'user' &&
($_user?.permissions?.chat?.temporary ?? true) &&
!($_user?.permissions?.chat?.temporary_enforced ?? false)));
export let prompt = '';
export let files = [];
@ -160,6 +186,8 @@
export let imageGenerationEnabled = false;
export let webSearchEnabled = false;
export let codeInterpreterEnabled = false;
export let toolApprovalMode = 'full';
export let onToolApprovalModeChange: Function = () => {};
export let pendingOAuthTools = [];
@ -170,7 +198,6 @@
export let onQueueEdit: (id: string) => void = () => {};
export let onQueueDelete: (id: string) => void = () => {};
export let onUpdate: (data?: { file?: any }) => void = () => {};
export let chatTasks = [];
let inputContent = null;
@ -207,7 +234,8 @@
selectedFilterIds,
imageGenerationEnabled,
webSearchEnabled,
codeInterpreterEnabled
codeInterpreterEnabled,
toolApprovalMode
});
const inputVariableHandler = async (text: string): Promise<string> => {
@ -518,6 +546,26 @@
chatInputElement?.replaceCommandWithText(text);
};
const temporaryHandler = async () => {
if (!canToggleTemporary) return;
if (($settings?.temporaryChatByDefault ?? false) && $temporaryChatEnabled) {
await temporaryChatEnabled.set(null);
} else {
await temporaryChatEnabled.set(!$temporaryChatEnabled);
}
if (location.pathname !== '/') {
await goto('/');
}
if ($temporaryChatEnabled) {
window.history.replaceState(null, '', '?temporary-chat=true');
} else {
window.history.replaceState(null, '', location.pathname);
}
};
const insertTextAtCursor = async (text: string) => {
const chatInput = document.getElementById('chat-input');
if (!chatInput) return;
@ -1260,7 +1308,7 @@
return;
}
if (['compact', 'fork', 'status', 'model'].includes(props?.id)) {
if (['compact', 'fork', 'status', 'model', 'settings', 'temporary'].includes(props?.id)) {
editor.chain().focus().deleteRange(range).run();
return;
}
@ -1286,11 +1334,15 @@
!!history?.currentId &&
($_user?.role === 'admin' || ($_user?.permissions?.chat?.import ?? true)),
forkDisabled: () => isActive,
canTemporary: () => canToggleTemporary,
temporaryEnabled: () => $temporaryChatEnabled === true,
contextUsage: () => statusContextUsage,
onCompact: compactHandler,
onStatus: statusHandler,
onFork: forkHandler,
onModel: () => modelSelector?.open(),
onSettings: () => showSettings.set(true),
onTemporary: temporaryHandler,
onSelect: (e) => {
const { type, data } = e;
@ -1521,7 +1573,7 @@
<div
class="{($settings?.widescreenMode ?? null)
? 'max-w-full'
: 'max-w-[58rem]'} px-2.5 mx-auto inset-x-0"
: 'max-w-[58rem]'} px-2 mx-auto inset-x-0"
>
<div class="">
<input
@ -1580,6 +1632,23 @@
on:click={() => createMessagePair(prompt)}
/>
{#if askUser?.show}
<div class="mx-1">
<AskUserCard
show={askUser.show}
questions={askUser.questions}
allowOther={askUser.allowOther}
timeoutMs={askUser.timeoutMs}
on:confirm={(e) => {
askUser.onConfirm(e.detail);
}}
on:cancel={() => {
askUser.onCancel();
}}
/>
</div>
{/if}
<!-- Task list display -->
{#if isActive && chatTasks.length > 0}
<div class="mx-1">
@ -2010,6 +2079,8 @@
bind:files
selectedModels={selectedModelIds}
{fileUploadCapableModels}
{toolApprovalMode}
{onToolApprovalModeChange}
{screenCaptureHandler}
{inputFilesHandler}
uploadFilesHandler={() => {
@ -2241,7 +2312,7 @@
{/if}
{/each}
{#if webSearchEnabled}
{#if webSearchEnabled && showWebSearchButton}
<Tooltip content={$i18n.t('Web Search')} placement="top">
<button
on:click|preventDefault={() => (webSearchEnabled = !webSearchEnabled)}
@ -2259,7 +2330,7 @@
</Tooltip>
{/if}
{#if imageGenerationEnabled}
{#if imageGenerationEnabled && showImageGenerationButton}
<Tooltip content={$i18n.t('Image')} placement="top">
<button
on:click|preventDefault={() =>
@ -2277,7 +2348,7 @@
</Tooltip>
{/if}
{#if codeInterpreterEnabled}
{#if codeInterpreterEnabled && showCodeInterpreterButton}
<Tooltip content={$i18n.t('Code Interpreter')} placement="top">
<button
aria-label={codeInterpreterEnabled
@ -2536,7 +2607,7 @@
{@html DOMPurify.sanitize(marked($config?.license_metadata?.input_footer))}
</div>
{:else}
<div class="mb-1" />
<div class="mb-0.5" />
{/if}
</form>
</div>

View file

@ -21,12 +21,16 @@
export let onStatus: () => void = () => {};
export let onFork: () => void = () => {};
export let onModel: () => void = () => {};
export let onSettings: () => void = () => {};
export let onTemporary: () => void = () => {};
export let insertTextHandler: (text: string) => void = () => {};
export let canCompact: boolean | (() => boolean) = false;
export let compactDisabled: boolean | (() => boolean) = false;
export let canStatus: boolean | (() => boolean) = false;
export let canFork: boolean | (() => boolean) = false;
export let forkDisabled: boolean | (() => boolean) = false;
export let canTemporary: boolean | (() => boolean) = false;
export let temporaryEnabled: boolean | (() => boolean) = false;
export let contextUsage = null;
$: compactAvailable = typeof canCompact === 'function' ? canCompact() : canCompact;
@ -35,6 +39,9 @@
$: statusAvailable = typeof canStatus === 'function' ? canStatus() : canStatus;
$: forkAvailable = typeof canFork === 'function' ? canFork() : canFork;
$: isForkDisabled = typeof forkDisabled === 'function' ? forkDisabled() : forkDisabled;
$: temporaryAvailable = typeof canTemporary === 'function' ? canTemporary() : canTemporary;
$: isTemporaryEnabled =
typeof temporaryEnabled === 'function' ? temporaryEnabled() : temporaryEnabled;
$: resolvedContextUsage = typeof contextUsage === 'function' ? contextUsage() : contextUsage;
$: contextHasThreshold = Number(resolvedContextUsage?.threshold) > 0;
$: contextPercent = contextHasThreshold
@ -93,6 +100,8 @@
canStatus={statusAvailable}
canFork={forkAvailable}
forkDisabled={isForkDisabled}
canTemporary={temporaryAvailable}
temporaryEnabled={isTemporaryEnabled}
{contextPercent}
{contextHasThreshold}
onSelect={(e) => {
@ -117,6 +126,12 @@
} else if (type === 'command' && data.id === 'model') {
command({ id: data.id, label: data.id });
onModel();
} else if (type === 'command' && data.id === 'settings') {
command({ id: data.id, label: data.id });
onSettings();
} else if (type === 'command' && data.id === 'temporary') {
command({ id: data.id, label: data.id });
onTemporary();
} else if (type === 'skill') {
command({
id: `${data.id}|${data.name}`,

View file

@ -3,7 +3,10 @@
import { getPrompts } from '$lib/apis/prompts';
import { getSkillItems } from '$lib/apis/skills';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ChatBubbleDotted from '$lib/components/icons/ChatBubbleDotted.svelte';
import ChatBubbleDottedChecked from '$lib/components/icons/ChatBubbleDottedChecked.svelte';
import Cube from '$lib/components/icons/Cube.svelte';
import Knobs from '$lib/components/icons/Knobs.svelte';
import Sparkles from '$lib/components/icons/Sparkles.svelte';
const i18n = getContext('i18n');
@ -15,6 +18,8 @@
export let canStatus = false;
export let canFork = false;
export let forkDisabled = false;
export let canTemporary = false;
export let temporaryEnabled = false;
export let contextPercent = 0;
export let contextHasThreshold = false;
@ -31,6 +36,9 @@
$: contextCircleOffset = 50.27 * (1 - contextCirclePercent / 100);
$: commandItems = [
...(canTemporary && 'temporary'.startsWith(query.toLowerCase())
? [{ type: 'command', data: { id: 'temporary' } }]
: []),
...(canCompact && 'compact'.startsWith(query.toLowerCase())
? [{ type: 'command', data: { id: 'compact' } }]
: []),
@ -40,7 +48,12 @@
...(canStatus && 'status'.startsWith(query.toLowerCase())
? [{ type: 'command', data: { id: 'status' } }]
: []),
...('model'.startsWith(query.toLowerCase()) ? [{ type: 'command', data: { id: 'model' } }] : [])
...('model'.startsWith(query.toLowerCase())
? [{ type: 'command', data: { id: 'model' } }]
: []),
...('settings'.startsWith(query.toLowerCase())
? [{ type: 'command', data: { id: 'settings' } }]
: [])
];
$: filteredPrompts = prompts
@ -128,7 +141,39 @@
</div>
{#each commandItems as item, commandIdx}
{#if item.data.id === 'compact'}
{#if item.data.id === 'temporary'}
<Tooltip content="Toggle temporary chat for this new chat." placement="top">
<button
type="button"
aria-label="Temporary: toggle temporary chat for this new chat."
class="slash-command-row flex items-center gap-2 w-full h-6 px-2 rounded-xl text-xs text-left transition-colors duration-75
{commandIdx === selectedIdx ? 'app-interactive-active' : ''}"
on:mousedown={(e) => e.preventDefault()}
on:click={() => {
onSelect(item);
}}
on:mouseenter={() => {
selectedIdx = commandIdx;
}}
on:focus={() => {}}
data-selected={commandIdx === selectedIdx}
>
<span class="app-icon-muted flex items-center justify-center w-4 shrink-0">
{#if temporaryEnabled}
<ChatBubbleDottedChecked className="size-3.5" strokeWidth="1.6" />
{:else}
<ChatBubbleDotted className="size-3.5" strokeWidth="1.6" />
{/if}
</span>
<span class="flex-1 min-w-0 flex items-baseline gap-1.5 overflow-hidden">
<span class="truncate">Temporary</span>
<span class="app-muted text-[0.625rem] truncate shrink-0">
{temporaryEnabled ? 'On' : 'Off'}
</span>
</span>
</button>
</Tooltip>
{:else if item.data.id === 'compact'}
<Tooltip content="Shorten older messages so this chat can keep going." placement="top">
<button
type="button"
@ -294,6 +339,32 @@
</span>
</button>
</Tooltip>
{:else if item.data.id === 'settings'}
<Tooltip content="Open settings." placement="top">
<button
type="button"
aria-label="Settings: open settings."
class="slash-command-row flex items-center gap-2 w-full h-6 px-2 rounded-xl text-xs text-left transition-colors duration-75
{commandIdx === selectedIdx ? 'app-interactive-active' : ''}"
on:mousedown={(e) => e.preventDefault()}
on:click={() => {
onSelect(item);
}}
on:mouseenter={() => {
selectedIdx = commandIdx;
}}
on:focus={() => {}}
data-selected={commandIdx === selectedIdx}
>
<span class="app-icon-muted flex items-center justify-center w-4 shrink-0">
<Knobs className="size-3.5" />
</span>
<span class="flex-1 min-w-0 flex items-baseline gap-1.5 overflow-hidden">
<span class="truncate">Settings</span>
<span class="app-muted text-[0.625rem] truncate shrink-0">/settings</span>
</span>
</button>
</Tooltip>
{/if}
{/each}
{/if}

View file

@ -16,7 +16,6 @@
import Clip from '$lib/components/icons/Clip.svelte';
import ChatBubbleOval from '$lib/components/icons/ChatBubbleOval.svelte';
import Refresh from '$lib/components/icons/Refresh.svelte';
import Agile from '$lib/components/icons/Agile.svelte';
import ClockRotateRight from '$lib/components/icons/ClockRotateRight.svelte';
import Database from '$lib/components/icons/Database.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
@ -45,11 +44,25 @@
export let onUpload: Function;
export let onClose: Function;
export let toolApprovalMode = 'full';
export let onToolApprovalModeChange: Function = () => {};
let show = false;
let tab = '';
let showAttachWebpageModal = false;
const toolApprovalModes = [
{
value: 'full',
label: 'Full access',
description: 'Run tools without asking for approval.'
},
{
value: 'ask',
label: 'Ask for approval',
description: 'Stop before each tool call until you allow or deny it.'
}
];
let fileUploadEnabled = true;
$: fileUploadEnabled =
@ -58,6 +71,7 @@
let webUploadEnabled = true;
$: webUploadEnabled = $user?.role === 'admin' || ($user?.permissions?.chat?.web_upload ?? true);
$: toolPermissionsEnabled = $config?.features?.enable_tool_permissions ?? false;
$: if (!fileUploadEnabled && files.length > 0) {
files = [];
@ -129,6 +143,47 @@
class="max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin"
in:fly={{ x: -20, duration: 150 }}
>
{#if toolPermissionsEnabled}
<button
class="flex gap-2 w-full items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl"
on:click={() => {
tab = 'tool_permissions';
}}
>
<svg
class="size-3.5 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22C12 22 20 18 20 12V5L12 2L4 5V12C4 18 12 22 12 22Z" />
</svg>
<div class="flex items-center w-full justify-between min-w-0">
<div class="line-clamp-1">
{$i18n.t('Tool Permissions')}
</div>
<div class="flex items-center gap-2 min-w-0">
<div class="text-xs text-gray-500 truncate">
{$i18n.t(
toolApprovalModes.find((mode) => mode.value === toolApprovalMode)?.label ??
'Full access'
)}
</div>
<div class="text-gray-500">
<ChevronRight />
</div>
</div>
</div>
</button>
<div class="h-px mx-1 my-1 bg-gray-100 dark:bg-gray-800"></div>
{/if}
<Tooltip
content={fileUploadCapableModels.length !== selectedModels.length
? $i18n.t('Model(s) do not support file upload')
@ -487,6 +542,55 @@
{/if}
{/if}
</div>
{:else if tab === 'tool_permissions'}
<div class="flex max-h-72 flex-col overflow-hidden" in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full shrink-0 justify-between gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal select-none cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => {
tab = '';
}}
>
<ChevronLeft />
<div class="flex items-center w-full justify-between">
<div>
{$i18n.t('Tool Permissions')}
</div>
</div>
</button>
<div class="mt-1 space-y-1">
{#each toolApprovalModes as mode}
<Tooltip content={$i18n.t(mode.description)} className="w-full">
<button
class="flex gap-2 w-full items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl"
on:click={() => {
toolApprovalMode = mode.value;
onToolApprovalModeChange(mode.value);
tab = '';
}}
>
<div class="flex items-center w-full justify-between min-w-0">
<div class="line-clamp-1">{$i18n.t(mode.label)}</div>
{#if toolApprovalMode === mode.value}
<svg
class="size-3 shrink-0 text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
{/if}
</div>
</button>
</Tooltip>
{/each}
</div>
</div>
{:else if tab === 'knowledge'}
<div class="flex max-h-72 flex-col overflow-hidden" in:fly={{ x: 20, duration: 150 }}>
<button

View file

@ -1,17 +1,8 @@
<script lang="ts">
import { getContext, onMount, tick } from 'svelte';
import { getContext, onDestroy, tick } from 'svelte';
import { fly } from 'svelte/transition';
import {
config,
user,
tools as _tools,
skills as _skills,
mobile,
settings,
toolServers,
terminalServers
} from '$lib/stores';
import { user, tools as _tools, skills as _skills, toolServers } from '$lib/stores';
import { initiateOAuthRedirect } from '$lib/apis/configs';
import { deleteOAuthSession } from '$lib/apis/auths';
@ -26,6 +17,7 @@
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import SearchInput from './InputMenu/SearchInput.svelte';
import Wrench from '$lib/components/icons/Wrench.svelte';
import Cube from '$lib/components/icons/Cube.svelte';
import Sparkles from '$lib/components/icons/Sparkles.svelte';
@ -36,7 +28,19 @@
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import LinkSlash from '$lib/components/icons/LinkSlash.svelte';
const i18n = getContext('i18n');
const i18n = getContext('i18n') as any;
type IntegrationItem = {
id: string;
name: string;
description?: string;
enabled: boolean;
meta?: { description?: string };
is_active?: boolean;
authenticated?: boolean;
has_user_valves?: boolean;
[key: string]: any;
};
export let selectedToolIds: string[] = [];
export let selectedSkillIds: string[] = [];
@ -44,8 +48,13 @@
export let selectedModels: string[] = [];
export let fileUploadCapableModels: string[] = [];
export let toggleFilters: { id: string; name: string; description?: string; icon?: string }[] =
[];
export let toggleFilters: {
id: string;
name: string;
description?: string;
icon?: string;
has_user_valves?: boolean;
}[] = [];
export let selectedFilterIds: string[] = [];
export let showWebSearchButton = false;
@ -63,8 +72,27 @@
let show = false;
let tab = '';
let tools = null;
let skills = null;
let tools: Record<string, IntegrationItem> | null = null;
let skills: Record<string, IntegrationItem> | null = null;
let toolQuery = '';
let skillQuery = '';
let searchedToolQuery = '';
let searchedSkillQuery = '';
let toolSearchDebounceTimer: ReturnType<typeof setTimeout>;
let skillSearchDebounceTimer: ReturnType<typeof setTimeout>;
let toolRequestId = 0;
let skillRequestId = 0;
$: toolIds = Object.keys(tools ?? {});
$: skillIds = Object.keys(skills ?? {});
$: if (show && toolQuery !== searchedToolQuery) {
scheduleToolSearch();
}
$: if (show && skillQuery !== searchedSkillQuery) {
scheduleSkillSearch();
}
$: if (show) {
init();
@ -76,61 +104,168 @@
($user?.role === 'admin' || $user?.permissions?.chat?.file_upload);
const init = async () => {
if ($_tools === null) {
await _tools.set(await getTools(localStorage.token));
}
await Promise.all([loadTools(), loadSkills()]);
};
if ($_tools) {
tools = $_tools.reduce((a, tool, i, arr) => {
a[tool.id] = {
name: tool.name,
description: tool.meta.description,
enabled: selectedToolIds.includes(tool.id),
...tool
};
return a;
}, {});
}
const setTools = (toolItems: IntegrationItem[] | null, query = '') => {
const q = query.trim().toLowerCase();
const items = (toolItems ?? []).reduce<Record<string, IntegrationItem>>((a, tool) => {
a[tool.id] = {
...tool,
name: tool.name,
description: tool.meta?.description,
enabled: selectedToolIds.includes(tool.id)
};
return a;
}, {});
if ($toolServers) {
for (const serverIdx in $toolServers) {
const server = $toolServers[serverIdx];
if (server.info) {
tools[`direct_server:${serverIdx}`] = {
name: server?.info?.title ?? server.url,
description: server.info.description ?? '',
enabled: selectedToolIds.includes(`direct_server:${serverIdx}`)
};
for (const serverIdx in ($toolServers ?? []) as any[]) {
const server = (($toolServers ?? []) as any[])[serverIdx];
if (server.info) {
const name = server?.info?.title ?? server.url;
if (q && !name.toLowerCase().includes(q)) {
continue;
}
items[`direct_server:${serverIdx}`] = {
id: `direct_server:${serverIdx}`,
name,
description: server.info.description ?? '',
enabled: selectedToolIds.includes(`direct_server:${serverIdx}`)
};
}
}
selectedToolIds = selectedToolIds.filter((id) => Object.keys(tools).includes(id));
tools = items;
if (!q) {
selectedToolIds = selectedToolIds.filter((id) => Object.keys(tools ?? {}).includes(id));
}
};
const setSkills = (skillItems: IntegrationItem[] | null, query = '') => {
skills = (skillItems ?? [])
.filter((skill) => skill.is_active)
.reduce<Record<string, IntegrationItem>>((a, skill) => {
a[skill.id] = {
...skill,
name: skill.name,
description: skill.description,
enabled: selectedSkillIds.includes(skill.id)
};
return a;
}, {});
if (!query.trim()) {
selectedSkillIds = selectedSkillIds.filter((id) => Object.keys(skills ?? {}).includes(id));
}
};
const loadTools = async (query = toolQuery) => {
const requestId = ++toolRequestId;
const q = query.trim();
searchedToolQuery = query;
if (q) {
const toolItems = await getTools(localStorage.token, q).catch(() => []);
if (requestId !== toolRequestId) return;
setTools(toolItems, q);
return;
}
if ($_tools === null) {
await _tools.set(await getTools(localStorage.token));
}
if (requestId !== toolRequestId) return;
setTools($_tools, q);
};
const loadSkills = async (query = skillQuery) => {
const requestId = ++skillRequestId;
const q = query.trim();
searchedSkillQuery = query;
if (q) {
const skillItems = await getSkills(localStorage.token, q).catch(() => []);
if (requestId !== skillRequestId) return;
setSkills(skillItems, q);
return;
}
if ($_skills === null) {
await _skills.set(await getSkills(localStorage.token));
}
if (requestId !== skillRequestId) return;
setSkills($_skills, q);
};
if ($_skills) {
skills = $_skills
.filter((skill) => skill.is_active)
.reduce((a, skill) => {
a[skill.id] = {
name: skill.name,
description: skill.description,
enabled: selectedSkillIds.includes(skill.id),
...skill
};
return a;
}, {});
const scheduleToolSearch = () => {
clearTimeout(toolSearchDebounceTimer);
toolSearchDebounceTimer = setTimeout(() => {
loadTools();
}, 200);
};
const scheduleSkillSearch = () => {
clearTimeout(skillSearchDebounceTimer);
skillSearchDebounceTimer = setTimeout(() => {
loadSkills();
}, 200);
};
const toggleTool = async (toolId: string, e: MouseEvent) => {
const tool = tools?.[toolId];
if (!tool) return;
if (!(tool.authenticated ?? true)) {
e.preventDefault();
const parts = toolId.split(':');
initiateOAuthRedirect({
id: toolId,
serverId: parts.at(-1) ?? toolId,
authType: parts.length > 1 ? (parts[0] === 'server' ? parts[1] : parts[0]) : null
});
return;
}
selectedSkillIds = selectedSkillIds.filter((id) => Object.keys(skills ?? {}).includes(id));
tool.enabled = !tool.enabled;
const state = tool.enabled;
await tick();
if (state) {
selectedToolIds = [...selectedToolIds, toolId];
} else {
selectedToolIds = selectedToolIds.filter((id) => id !== toolId);
}
};
const toggleSkill = async (skillId: string) => {
const skill = skills?.[skillId];
if (!skill) return;
skill.enabled = !skill.enabled;
const state = skill.enabled;
await tick();
if (state) {
selectedSkillIds = [...selectedSkillIds, skillId];
} else {
selectedSkillIds = selectedSkillIds.filter((id) => id !== skillId);
}
};
onDestroy(() => {
clearTimeout(toolSearchDebounceTimer);
clearTimeout(skillSearchDebounceTimer);
});
</script>
<Dropdown
bind:show
{closeOnOutsideClick}
onOpenChange={(state) => {
if (state === false) {
onClose();
@ -141,11 +276,12 @@
<slot />
</Tooltip>
<div slot="content">
<DropdownMenu
className="min-w-70 max-w-70 max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin"
>
<DropdownMenu className="min-w-70 max-w-70 max-h-72 overflow-hidden">
{#if tab === ''}
<div in:fly={{ x: -20, duration: 150 }}>
<div
class="max-h-72 overflow-y-auto overflow-x-hidden scrollbar-thin"
in:fly={{ x: -20, duration: 150 }}
>
{#if tools}
{#if Object.keys(tools).length > 0}
<button
@ -201,6 +337,7 @@
<Tooltip content={filter?.description} placement="top-start">
<button
class="flex w-full justify-between gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
aria-pressed={selectedFilterIds.includes(filter.id)}
on:click={() => {
if (selectedFilterIds.includes(filter.id)) {
selectedFilterIds = selectedFilterIds.filter((id) => id !== filter.id);
@ -253,14 +390,8 @@
</div>
{/if}
<div class=" shrink-0">
<Switch
state={selectedFilterIds.includes(filter.id)}
on:change={async (e) => {
const state = e.detail;
await tick();
}}
/>
<div class=" shrink-0" inert>
<Switch state={selectedFilterIds.includes(filter.id)} />
</div>
</button>
</Tooltip>
@ -272,9 +403,6 @@
<button
class="flex w-full justify-between gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
aria-pressed={webSearchEnabled}
aria-label={webSearchEnabled
? $i18n.t('Disable Web Search')
: $i18n.t('Enable Web Search')}
on:click={() => {
webSearchEnabled = !webSearchEnabled;
onWebSearchToggle(webSearchEnabled);
@ -290,14 +418,8 @@
</div>
</div>
<div class=" shrink-0">
<Switch
state={webSearchEnabled}
on:change={async (e) => {
const state = e.detail;
await tick();
}}
/>
<div class=" shrink-0" inert>
<Switch state={webSearchEnabled} />
</div>
</button>
</Tooltip>
@ -308,9 +430,6 @@
<button
class="flex w-full justify-between gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
aria-pressed={imageGenerationEnabled}
aria-label={imageGenerationEnabled
? $i18n.t('Disable Image Generation')
: $i18n.t('Enable Image Generation')}
on:click={() => {
imageGenerationEnabled = !imageGenerationEnabled;
}}
@ -325,14 +444,8 @@
</div>
</div>
<div class=" shrink-0">
<Switch
state={imageGenerationEnabled}
on:change={async (e) => {
const state = e.detail;
await tick();
}}
/>
<div class=" shrink-0" inert>
<Switch state={imageGenerationEnabled} />
</div>
</button>
</Tooltip>
@ -343,9 +456,6 @@
<button
class="flex w-full justify-between gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
aria-pressed={codeInterpreterEnabled}
aria-label={codeInterpreterEnabled
? $i18n.t('Disable Code Interpreter')
: $i18n.t('Enable Code Interpreter')}
on:click={() => {
codeInterpreterEnabled = !codeInterpreterEnabled;
}}
@ -360,21 +470,15 @@
</div>
</div>
<div class=" shrink-0">
<Switch
state={codeInterpreterEnabled}
on:change={async (e) => {
const state = e.detail;
await tick();
}}
/>
<div class=" shrink-0" inert>
<Switch state={codeInterpreterEnabled} />
</div>
</button>
</Tooltip>
{/if}
</div>
{:else if tab === 'tools' && tools}
<div in:fly={{ x: 20, duration: 150 }}>
<div class="flex max-h-72 min-h-0 flex-col gap-0.5" in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => {
@ -386,118 +490,110 @@
<div class="flex items-center w-full justify-between">
<div>
{$i18n.t('Tools')}
<span class="ml-0.5 text-gray-500">{Object.keys(tools).length}</span>
<span class="ml-0.5 text-gray-500">{toolIds.length}</span>
</div>
</div>
</button>
{#each Object.keys(tools) as toolId}
<button
class="relative flex w-full justify-between gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={async (e) => {
if (!(tools[toolId]?.authenticated ?? true)) {
e.preventDefault();
<SearchInput bind:value={toolQuery} placeholder={$i18n.t('Search tools')} />
const parts = toolId.split(':');
initiateOAuthRedirect({
id: toolId,
serverId: parts.at(-1) ?? toolId,
authType:
parts.length > 1 ? (parts[0] === 'server' ? parts[1] : parts[0]) : null
});
} else {
tools[toolId].enabled = !tools[toolId].enabled;
const state = tools[toolId].enabled;
await tick();
if (state) {
selectedToolIds = [...selectedToolIds, toolId];
} else {
selectedToolIds = selectedToolIds.filter((id) => id !== toolId);
}
}
}}
>
{#if !(tools[toolId]?.authenticated ?? true)}
<!-- make it slighly darker and not clickable -->
<div class="absolute inset-0 opacity-50 rounded-xl cursor-pointer z-10" />
{/if}
<div class="flex-1 truncate">
<div class="flex flex-1 gap-2 items-center">
<Tooltip content={tools[toolId]?.name ?? ''} placement="top">
<div class="shrink-0">
<Wrench />
<div class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin">
{#if toolIds.length === 0}
<div class="text-center text-xs text-gray-500 py-3">{$i18n.t('No tools found')}</div>
{:else}
<div class="flex flex-col gap-0.5">
{#each toolIds as toolId}
<button
class="relative flex w-full justify-between gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
aria-pressed={(tools?.[toolId]?.authenticated ?? true)
? tools?.[toolId]?.enabled
: undefined}
on:click={async (e) => {
await toggleTool(toolId, e);
}}
>
{#if !(tools?.[toolId]?.authenticated ?? true)}
<!-- make it slighly darker and not clickable -->
<div class="absolute inset-0 opacity-50 rounded-xl cursor-pointer z-10"></div>
{/if}
<div class="flex-1 truncate">
<div class="flex flex-1 gap-2 items-center">
<Tooltip content={tools?.[toolId]?.name ?? ''} placement="top">
<div class="shrink-0">
<Wrench />
</div>
</Tooltip>
<Tooltip content={tools?.[toolId]?.description ?? ''} placement="top-start">
<div class=" truncate">{tools?.[toolId]?.name}</div>
</Tooltip>
</div>
</div>
</Tooltip>
<Tooltip content={tools[toolId]?.description ?? ''} placement="top-start">
<div class=" truncate">{tools[toolId].name}</div>
</Tooltip>
</div>
{#if (tools?.[toolId]?.authenticated ?? true) && toolId.startsWith('server:mcp:')}
<div class="shrink-0">
<Tooltip content={$i18n.t('Disconnect OAuth')}>
<button
class="self-center w-fit text-sm text-gray-600 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition rounded-full"
type="button"
on:click={async (e) => {
e.stopPropagation();
e.preventDefault();
const parts = toolId.split(':');
const serverId = parts.at(-1) ?? toolId;
const provider = `mcp:${serverId}`;
try {
await deleteOAuthSession(localStorage.token, provider);
toast.success($i18n.t('OAuth session disconnected'));
// Refresh tools to update authenticated state
_tools.set(await getTools(localStorage.token));
selectedToolIds = selectedToolIds.filter((id) => id !== toolId);
await init();
} catch (err) {
toast.error(err ?? $i18n.t('Failed to disconnect'));
}
}}
>
<LinkSlash className="size-3.5" />
</button>
</Tooltip>
</div>
{/if}
{#if tools?.[toolId]?.has_user_valves && ($user?.role === 'admin' || ($user?.permissions?.chat?.valves ?? true))}
<div class=" shrink-0">
<Tooltip content={$i18n.t('Valves')}>
<button
class="self-center w-fit text-sm text-gray-600 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition rounded-full"
type="button"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
onShowValves({
type: 'tool',
id: toolId
});
}}
>
<Knobs />
</button>
</Tooltip>
</div>
{/if}
<div class=" shrink-0" inert>
<Switch state={tools?.[toolId]?.enabled} />
</div>
</button>
{/each}
</div>
{#if (tools[toolId]?.authenticated ?? true) && toolId.startsWith('server:mcp:')}
<div class="shrink-0">
<Tooltip content={$i18n.t('Disconnect OAuth')}>
<button
class="self-center w-fit text-sm text-gray-600 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition rounded-full"
type="button"
on:click={async (e) => {
e.stopPropagation();
e.preventDefault();
const parts = toolId.split(':');
const serverId = parts.at(-1) ?? toolId;
const provider = `mcp:${serverId}`;
try {
await deleteOAuthSession(localStorage.token, provider);
toast.success($i18n.t('OAuth session disconnected'));
// Refresh tools to update authenticated state
_tools.set(await getTools(localStorage.token));
selectedToolIds = selectedToolIds.filter((id) => id !== toolId);
await init();
} catch (err) {
toast.error(err ?? $i18n.t('Failed to disconnect'));
}
}}
>
<LinkSlash className="size-3.5" />
</button>
</Tooltip>
</div>
{/if}
{#if tools[toolId]?.has_user_valves && ($user?.role === 'admin' || ($user?.permissions?.chat?.valves ?? true))}
<div class=" shrink-0">
<Tooltip content={$i18n.t('Valves')}>
<button
class="self-center w-fit text-sm text-gray-600 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition rounded-full"
type="button"
on:click={(e) => {
e.stopPropagation();
e.preventDefault();
onShowValves({
type: 'tool',
id: toolId
});
}}
>
<Knobs />
</button>
</Tooltip>
</div>
{/if}
<div class=" shrink-0">
<Switch state={tools[toolId].enabled} />
</div>
</button>
{/each}
{/if}
</div>
</div>
{:else if tab === 'skills' && skills}
<div in:fly={{ x: 20, duration: 150 }}>
<div class="flex max-h-72 min-h-0 flex-col gap-0.5" in:fly={{ x: 20, duration: 150 }}>
<button
class="flex w-full justify-between gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={() => {
@ -509,45 +605,50 @@
<div class="flex items-center w-full justify-between">
<div>
{$i18n.t('Skills')}
<span class="ml-0.5 text-gray-500">{Object.keys(skills).length}</span>
<span class="ml-0.5 text-gray-500">{skillIds.length}</span>
</div>
</div>
</button>
{#each Object.keys(skills) as skillId}
<button
class="relative flex w-full justify-between gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
on:click={async () => {
skills[skillId].enabled = !skills[skillId].enabled;
<SearchInput bind:value={skillQuery} placeholder={$i18n.t('Search skills')} />
const state = skills[skillId].enabled;
await tick();
if (state) {
selectedSkillIds = [...selectedSkillIds, skillId];
} else {
selectedSkillIds = selectedSkillIds.filter((id) => id !== skillId);
}
}}
>
<div class="flex-1 truncate">
<div class="flex flex-1 gap-2 items-center">
<Tooltip content={skills[skillId]?.name ?? ''} placement="top">
<div class="shrink-0">
<Cube className="size-3.5" strokeWidth="1.75" />
<div class="min-h-0 flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin">
{#if skillIds.length === 0}
<div class="text-center text-xs text-gray-500 py-3">{$i18n.t('No skills found')}</div>
{:else}
<div class="flex flex-col gap-0.5">
{#each skillIds as skillId}
<button
class="relative flex w-full justify-between gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
aria-pressed={skills?.[skillId]?.enabled}
on:click={async () => {
await toggleSkill(skillId);
}}
>
<div class="flex-1 truncate">
<div class="flex flex-1 gap-2 items-center">
<Tooltip content={skills?.[skillId]?.name ?? ''} placement="top">
<div class="shrink-0">
<Cube className="size-3.5" strokeWidth="1.75" />
</div>
</Tooltip>
<Tooltip
content={skills?.[skillId]?.description ?? ''}
placement="top-start"
>
<div class=" truncate">{skills?.[skillId]?.name}</div>
</Tooltip>
</div>
</div>
</Tooltip>
<Tooltip content={skills[skillId]?.description ?? ''} placement="top-start">
<div class=" truncate">{skills[skillId].name}</div>
</Tooltip>
</div>
</div>
<div class=" shrink-0">
<Switch state={skills[skillId].enabled} />
<div class=" shrink-0" inert>
<Switch state={skills?.[skillId]?.enabled} />
</div>
</button>
{/each}
</div>
</button>
{/each}
{/if}
</div>
</div>
{/if}
</DropdownMenu>

View file

@ -40,6 +40,7 @@
export let showMessage: Function = () => {};
export let submitMessage: Function = () => {};
export let addMessages: Function = () => {};
export let onToolCallResolved: Function = () => {};
export let forkHandler: Function | null = null;
export let readOnly = false;
@ -158,12 +159,17 @@
messagesCount = null;
buildMessages();
await tick();
if (messages.length > 0) {
const firstMessageEl = document.getElementById(`message-${messages[0].id}`);
if (firstMessageEl) {
firstMessageEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
const element = getMessagesContainer();
if (!element) return;
element.scrollTo({ top: 0, behavior: 'smooth' });
requestAnimationFrame(() => {
element.scrollTo({ top: 0, behavior: 'smooth' });
requestAnimationFrame(() => {
element.scrollTo({ top: 0, behavior: 'smooth' });
});
});
};
const updateChat = async () => {
@ -555,6 +561,7 @@
{continueResponse}
{mergeResponses}
{addMessages}
{onToolCallResolved}
{forkHandler}
{allowDelete}
{triggerScroll}

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