mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-07 02:58:21 +00:00
Merge branch 'dev' into fix/web-fetch-filter-redirect
This commit is contained in:
commit
e84a2b5d26
163 changed files with 9417 additions and 2396 deletions
15
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
15
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
|
|
@ -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".
|
||||
|
|
|
|||
6
.github/pull_request_template.md
vendored
6
.github/pull_request_template.md
vendored
|
|
@ -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.
|
||||
|
|
|
|||
70
.github/workflows/issue-label.yaml
vendored
70
.github/workflows/issue-label.yaml
vendored
|
|
@ -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']
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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', '')
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
144
backend/open_webui/utils/ask_user.py
Normal file
144
backend/open_webui/utils/ask_user.py
Normal 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}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
187
backend/open_webui/utils/tool_approval.py
Normal file
187
backend/open_webui/utils/tool_approval.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
12
src/app.css
12
src/app.css
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1758,6 +1758,7 @@ export interface ModelConfig {
|
|||
export interface ModelMeta {
|
||||
toolIds: never[];
|
||||
description?: string;
|
||||
hidden?: boolean;
|
||||
capabilities?: object;
|
||||
profile_image_url?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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')}>
|
||||
|
|
|
|||
|
|
@ -150,8 +150,9 @@
|
|||
});
|
||||
|
||||
if (res) {
|
||||
backendConfig.set(await getBackendConfig());
|
||||
|
||||
if (res.ENABLE_IMAGE_GENERATION) {
|
||||
backendConfig.set(await getBackendConfig());
|
||||
getModels();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
? ''
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
308
src/lib/components/automations/DestinationDropdown.svelte
Normal file
308
src/lib/components/automations/DestinationDropdown.svelte
Normal 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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -575,7 +575,8 @@
|
|||
i18n,
|
||||
triggerChar: '@',
|
||||
modelSuggestions: true,
|
||||
userSuggestions
|
||||
userSuggestions,
|
||||
channelId: channel?.id
|
||||
})
|
||||
},
|
||||
...(channelSuggestions
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
303
src/lib/components/chat/AskUserCard.svelte
Normal file
303
src/lib/components/chat/AskUserCard.svelte
Normal 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}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
15
src/lib/components/chat/FileNav/FileTypeIcon.svelte
Normal file
15
src/lib/components/chat/FileNav/FileTypeIcon.svelte
Normal 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}`} />
|
||||
725
src/lib/components/chat/FileNav/Icon.svelte
Normal file
725
src/lib/components/chat/FileNav/Icon.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
163
src/lib/components/chat/FileNav/fileIcon.ts
Normal file
163
src/lib/components/chat/FileNav/fileIcon.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue