mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-24 05:44:07 +00:00
Merge remote-tracking branch 'upstream/dev' into HEAD
# Conflicts: # backend/open_webui/utils/middleware.py
This commit is contained in:
commit
68d5a0eec6
544 changed files with 64420 additions and 29865 deletions
|
|
@ -16,6 +16,9 @@ CORS_ALLOW_ORIGIN='*'
|
|||
# Set to false to keep memory tools enabled without adding memory context to the system context.
|
||||
ENABLE_MEMORY_SYSTEM_CONTEXT=true
|
||||
|
||||
# Set to false to disable workspace Tools and Functions.
|
||||
ENABLE_PLUGINS=true
|
||||
|
||||
# For production you should set this to match the proxy configuration (127.0.0.1)
|
||||
FORWARDED_ALLOW_IPS='*'
|
||||
|
||||
|
|
|
|||
|
|
@ -93,11 +93,9 @@ Want to learn more about Open WebUI's features? Check out our [Open WebUI docume
|
|||
|
||||
Open WebUI is the core, surrounded by companion apps and infrastructure that extend what your AI can do, where it can reach, and how you run it:
|
||||
|
||||
- ⚡ **Open Terminal** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat.
|
||||
- 💻 **Open WebUI Computer** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more.
|
||||
|
||||
- 🔒 **Terminals** · Enterprise ([open-webui/terminals](https://github.com/open-webui/terminals)): Per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes.
|
||||
|
||||
- 💻 **cptr** ([open-webui/computer](https://github.com/open-webui/computer)): A standalone, mobile-first computer and coding agent that runs on the machine you own. Files, terminal, and git in a browser tab, reachable from your phone. Connect it into Open WebUI as a model, or reach it from Telegram, WhatsApp, and more.
|
||||
- ⚡ **Open Terminal** and **Terminals (Enterprise)** ([open-webui/open-terminal](https://github.com/open-webui/open-terminal) & [open-webui/terminals](https://github.com/open-webui/terminals)): A self-hosted computing environment that plugs into Open WebUI, giving the AI a place to write code, run it, read output, fix errors, and iterate inside the chat. Terminals gives you per-user isolated containers with separate credentials, resource limits, and network rules. Automatic lifecycle management on Docker or Kubernetes.
|
||||
|
||||
- 🔄 **oikb** ([open-webui/oikb](https://github.com/open-webui/oikb)): Feed your Knowledge Bases from 45+ sources (GitHub, Confluence, ServiceNow, Salesforce, Jira, Slack, SharePoint, Notion, and more), keeping the tools your team already uses continuously in sync.
|
||||
|
||||
|
|
|
|||
|
|
@ -1806,6 +1806,9 @@ USER_PERMISSIONS_CALENDAR_ALLOW_PUBLIC_SHARING = (
|
|||
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS = (
|
||||
os.getenv('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS', 'True').lower() == 'true'
|
||||
)
|
||||
USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS = (
|
||||
os.getenv('USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS', 'True').lower() == 'true'
|
||||
)
|
||||
|
||||
|
||||
USER_PERMISSIONS_CHAT_CONTROLS = os.getenv('USER_PERMISSIONS_CHAT_CONTROLS', 'True').lower() == 'true'
|
||||
|
|
@ -1930,6 +1933,7 @@ DEFAULT_USER_PERMISSIONS = {
|
|||
},
|
||||
'access_grants': {
|
||||
'allow_users': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_USERS,
|
||||
'allow_groups': USER_PERMISSIONS_ACCESS_GRANTS_ALLOW_GROUPS,
|
||||
},
|
||||
'chat': {
|
||||
'controls': USER_PERMISSIONS_CHAT_CONTROLS,
|
||||
|
|
@ -1987,6 +1991,14 @@ ENABLE_CALENDAR = os.getenv('ENABLE_CALENDAR', 'True').lower() == 'true'
|
|||
|
||||
ENABLE_AUTOMATIONS = os.getenv('ENABLE_AUTOMATIONS', 'True').lower() == 'true'
|
||||
|
||||
ENABLE_SUBAGENTS = os.getenv('ENABLE_SUBAGENTS', 'False').lower() == 'true'
|
||||
SUBAGENTS_BACKGROUND_ENABLED = os.getenv('SUBAGENTS_BACKGROUND_ENABLED', 'False').lower() == 'true'
|
||||
SUBAGENTS_MAX_CONCURRENT = int(os.getenv('SUBAGENTS_MAX_CONCURRENT', '20'))
|
||||
SUBAGENTS_MAX_ASYNC = int(os.getenv('SUBAGENTS_MAX_ASYNC', '20'))
|
||||
SUBAGENTS_MAX_ITERATIONS = int(os.getenv('SUBAGENTS_MAX_ITERATIONS', '30'))
|
||||
SUBAGENTS_MAX_OUTPUT = int(os.getenv('SUBAGENTS_MAX_OUTPUT', '30000'))
|
||||
SUBAGENTS_SYSTEM_PROMPT = os.getenv('SUBAGENTS_SYSTEM_PROMPT', '')
|
||||
|
||||
AUTOMATION_MAX_COUNT = os.getenv('AUTOMATION_MAX_COUNT', '')
|
||||
|
||||
AUTOMATION_MIN_INTERVAL = os.getenv('AUTOMATION_MIN_INTERVAL', '')
|
||||
|
|
@ -2128,29 +2140,31 @@ ENABLE_CONTEXT_COMPACTION = os.getenv('ENABLE_CONTEXT_COMPACTION', 'False').lowe
|
|||
|
||||
CONTEXT_COMPACTION_TOKEN_THRESHOLD = int(os.getenv('CONTEXT_COMPACTION_TOKEN_THRESHOLD', '80000'))
|
||||
|
||||
_CONTEXT_COMPACTION_TOKEN_CAP = os.getenv('CONTEXT_COMPACTION_TOKEN_CAP')
|
||||
CONTEXT_COMPACTION_TOKEN_CAP = int(_CONTEXT_COMPACTION_TOKEN_CAP) if _CONTEXT_COMPACTION_TOKEN_CAP else None
|
||||
|
||||
CONTEXT_COMPACTION_PROMPT_TEMPLATE = os.getenv('CONTEXT_COMPACTION_PROMPT_TEMPLATE', '')
|
||||
|
||||
TITLE_GENERATION_PROMPT_TEMPLATE = os.getenv('TITLE_GENERATION_PROMPT_TEMPLATE', '')
|
||||
|
||||
DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = """### Task:
|
||||
Generate a concise, 3-5 word title with an emoji summarizing the chat history.
|
||||
Generate a concise title summarizing the chat history.
|
||||
### Guidelines:
|
||||
- The title should clearly represent the main theme or subject of the conversation.
|
||||
- Use emojis that enhance understanding of the topic, but avoid quotation marks or special formatting.
|
||||
- Keep it short: 2-4 words is best.
|
||||
- Do not use emojis, quotation marks, or special formatting.
|
||||
- Write the title in the chat's primary language; default to English if multilingual.
|
||||
- Prioritize accuracy over excessive creativity; keep it clear and simple.
|
||||
- Prioritize accuracy over creativity.
|
||||
- Your entire response must consist solely of the JSON object, without any introductory or concluding text.
|
||||
- The output must be a single, raw JSON object, without any markdown code fences or other encapsulating text.
|
||||
- Ensure no conversational text, affirmations, or explanations precede or follow the raw JSON output, as this will cause direct parsing failure.
|
||||
### Output:
|
||||
JSON format: { "title": "your concise title here" }
|
||||
### Examples:
|
||||
- { "title": "📉 Stock Market Trends" },
|
||||
- { "title": "🍪 Perfect Chocolate Chip Recipe" },
|
||||
- { "title": "Evolution of Music Streaming" },
|
||||
- { "title": "Remote Work Productivity Tips" },
|
||||
- { "title": "Artificial Intelligence in Healthcare" },
|
||||
- { "title": "🎮 Video Game Development Insights" }
|
||||
- { "title": "Stock Trends" },
|
||||
- { "title": "Chocolate Chip Cookies" },
|
||||
- { "title": "Music Streaming" },
|
||||
- { "title": "Remote Work" }
|
||||
### Chat History:
|
||||
<chat_history>
|
||||
{{MESSAGES:END:2}}
|
||||
|
|
@ -3016,6 +3030,13 @@ DEFAULT_CONFIG = {
|
|||
'channels.enable': ENABLE_CHANNELS,
|
||||
'calendar.enable': ENABLE_CALENDAR,
|
||||
'automations.enable': ENABLE_AUTOMATIONS,
|
||||
'subagents.enable': ENABLE_SUBAGENTS,
|
||||
'subagents.background_enabled': SUBAGENTS_BACKGROUND_ENABLED,
|
||||
'subagents.max_concurrent': SUBAGENTS_MAX_CONCURRENT,
|
||||
'subagents.max_async': SUBAGENTS_MAX_ASYNC,
|
||||
'subagents.max_iterations': SUBAGENTS_MAX_ITERATIONS,
|
||||
'subagents.max_output': SUBAGENTS_MAX_OUTPUT,
|
||||
'subagents.system_prompt': SUBAGENTS_SYSTEM_PROMPT,
|
||||
'automations.max_count': AUTOMATION_MAX_COUNT,
|
||||
'automations.min_interval': AUTOMATION_MIN_INTERVAL,
|
||||
'automations.auth_token_expires_in': AUTOMATION_AUTH_TOKEN_EXPIRES_IN,
|
||||
|
|
@ -3034,6 +3055,7 @@ DEFAULT_CONFIG = {
|
|||
'task.model.external': TASK_MODEL_EXTERNAL,
|
||||
'chat.context_compaction.enable': ENABLE_CONTEXT_COMPACTION,
|
||||
'chat.context_compaction.token_threshold': CONTEXT_COMPACTION_TOKEN_THRESHOLD,
|
||||
'chat.context_compaction.token_cap': CONTEXT_COMPACTION_TOKEN_CAP,
|
||||
'chat.context_compaction.prompt_template': CONTEXT_COMPACTION_PROMPT_TEMPLATE,
|
||||
'task.title.prompt_template': TITLE_GENERATION_PROMPT_TEMPLATE,
|
||||
'task.tags.prompt_template': TAGS_GENERATION_PROMPT_TEMPLATE,
|
||||
|
|
|
|||
|
|
@ -1038,6 +1038,8 @@ SENTENCE_TRANSFORMERS_CROSS_ENCODER_SIGMOID_ACTIVATION_FUNCTION = (
|
|||
# TOOLS/FUNCTIONS PIP OPTIONS
|
||||
####################################
|
||||
|
||||
ENABLE_PLUGINS = os.getenv('ENABLE_PLUGINS', 'True').lower() == 'true'
|
||||
|
||||
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS = (
|
||||
os.getenv('ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS', 'True').lower() == 'true'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import uuid
|
|||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from open_webui.env import VERSION
|
||||
from open_webui.env import ENABLE_PLUGINS, VERSION
|
||||
from open_webui.models.config import Config
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from open_webui.retrieval.web.utils import validate_url
|
||||
|
|
@ -161,6 +161,12 @@ class EventDefinitions(BaseModel):
|
|||
CHAT_CREATED: EventDefinition = EventDefinition(
|
||||
name='chat.created', description='A chat was created.', message='Chat created'
|
||||
)
|
||||
CHAT_FINISHED: EventDefinition = EventDefinition(
|
||||
name='chat.finished', description='A chat response finished.', message='Chat finished'
|
||||
)
|
||||
CHAT_FAILED: EventDefinition = EventDefinition(
|
||||
name='chat.failed', description='A chat response failed.', message='Chat failed'
|
||||
)
|
||||
CHAT_IMPORTED: EventDefinition = EventDefinition(
|
||||
name='chat.imported', description='A chat was imported.', message='Chat imported'
|
||||
)
|
||||
|
|
@ -258,6 +264,11 @@ class EventDefinitions(BaseModel):
|
|||
description='A channel member active state was updated.',
|
||||
message='Channel member active updated',
|
||||
)
|
||||
CHANNEL_MESSAGE: EventDefinition = EventDefinition(
|
||||
name='channel.message',
|
||||
description='A channel message was posted.',
|
||||
message='Channel message',
|
||||
)
|
||||
CHANNEL_WEBHOOK_CREATED: EventDefinition = EventDefinition(
|
||||
name='channel.webhook.created',
|
||||
description='A channel incoming webhook was created.',
|
||||
|
|
@ -566,6 +577,11 @@ class EventDefinitions(BaseModel):
|
|||
description='A calendar event RSVP was updated.',
|
||||
message='Calendar Event rsvp updated',
|
||||
)
|
||||
CALENDAR_ALERT: EventDefinition = EventDefinition(
|
||||
name='calendar.alert',
|
||||
description='A calendar event alert was triggered.',
|
||||
message='Calendar alert',
|
||||
)
|
||||
AUTOMATION_CREATED: EventDefinition = EventDefinition(
|
||||
name='automation.created', description='An automation was created.', message='Automation created'
|
||||
)
|
||||
|
|
@ -622,6 +638,12 @@ class EventDefinitions(BaseModel):
|
|||
TERMINAL_SESSION_CLOSED: EventDefinition = EventDefinition(
|
||||
name='terminal.session.closed', description='A terminal session was closed.', message='Terminal Session closed'
|
||||
)
|
||||
NOTIFICATION_TEST: EventDefinition = EventDefinition(
|
||||
name='notification.test', description='A notification target test was sent.', message='Notification test'
|
||||
)
|
||||
NOTIFICATION_MANUAL: EventDefinition = EventDefinition(
|
||||
name='notification.manual', description='A manual notification was sent.', message='Notification sent'
|
||||
)
|
||||
|
||||
|
||||
EVENTS = EventDefinitions()
|
||||
|
|
@ -629,6 +651,12 @@ EVENT_DEFINITIONS = tuple(getattr(EVENTS, field_name) for field_name in EventDef
|
|||
EVENT_DEFINITIONS_BY_NAME = {definition.name: definition for definition in EVENT_DEFINITIONS}
|
||||
EVENT_CATALOG = tuple(definition.name for definition in EVENT_DEFINITIONS)
|
||||
EVENT_CATALOG_SET = set(EVENT_CATALOG)
|
||||
NOTIFICATION_EVENTS = (
|
||||
EVENTS.CHAT_FINISHED.name,
|
||||
EVENTS.CHAT_FAILED.name,
|
||||
EVENTS.CHANNEL_MESSAGE.name,
|
||||
EVENTS.CALENDAR_ALERT.name,
|
||||
)
|
||||
|
||||
|
||||
def get_event_catalog() -> list[dict[str, str]]:
|
||||
|
|
@ -1024,7 +1052,25 @@ class WebhookEventSink:
|
|||
schedule_webhook_dispatch(app, event)
|
||||
|
||||
|
||||
def schedule_notification_dispatch(app: Any, event: Event) -> None:
|
||||
try:
|
||||
from open_webui.utils.notifications import dispatch_notification_event
|
||||
|
||||
asyncio.create_task(dispatch_notification_event(app, event))
|
||||
except RuntimeError:
|
||||
log.exception('Notification delivery could not be scheduled for %s', event.event)
|
||||
|
||||
|
||||
class NotificationEventSink:
|
||||
async def handle_event(self, app: Any, event: Event, request: Any | None = None) -> None:
|
||||
if event.event in NOTIFICATION_EVENTS:
|
||||
schedule_notification_dispatch(app, event)
|
||||
|
||||
|
||||
async def dispatch_event_functions(app: Any, event: Event, request: Any | None = None) -> None:
|
||||
if not ENABLE_PLUGINS:
|
||||
return
|
||||
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.utils.plugin import get_function_module_from_cache
|
||||
|
||||
|
|
@ -1081,7 +1127,7 @@ class EventFunctionSink:
|
|||
schedule_event_function_dispatch(app, event, request)
|
||||
|
||||
|
||||
EVENT_SINKS = [EventFunctionSink(), WebhookEventSink()]
|
||||
EVENT_SINKS = [EventFunctionSink(), WebhookEventSink(), NotificationEventSink()]
|
||||
|
||||
|
||||
async def publish_event(
|
||||
|
|
@ -1147,6 +1193,20 @@ async def publish_model_provider_request_failed(
|
|||
else 'upstream_error'
|
||||
)
|
||||
|
||||
# Server-log only; the upstream error body is otherwise invisible to admins
|
||||
# (event sinks require an event function or webhook to be configured).
|
||||
log.log(
|
||||
logging.ERROR if status >= 500 else logging.WARNING,
|
||||
'Upstream %s request failed: HTTP %d (%s) url=%s model=%s code=%s message=%s',
|
||||
provider,
|
||||
status,
|
||||
error_type,
|
||||
base_url,
|
||||
requested_model or '-',
|
||||
error_code or '-',
|
||||
error_text[:MAX_STRING_LENGTH] or '-',
|
||||
)
|
||||
|
||||
data = {
|
||||
'error_type': error_type,
|
||||
'status': status,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from starlette.responses import Response, StreamingResponse
|
|||
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.users import UserModel
|
||||
|
|
@ -69,6 +69,9 @@ async def get_function_module_by_id(request: Request, pipe_id: str):
|
|||
|
||||
|
||||
async def get_function_models(request):
|
||||
if not ENABLE_PLUGINS:
|
||||
return []
|
||||
|
||||
pipes = await Functions.get_functions_by_type('pipe', active_only=True)
|
||||
pipe_models = []
|
||||
|
||||
|
|
@ -144,7 +147,10 @@ async def get_function_models(request):
|
|||
return pipe_models
|
||||
|
||||
|
||||
async def generate_function_chat_completion(request, form_data, user, models: dict = {}):
|
||||
async def generate_function_chat_completion(request, form_data, user, models: dict | None = None):
|
||||
if models is None:
|
||||
models = {}
|
||||
|
||||
async def execute_pipe(pipe, params):
|
||||
if inspect.iscoroutinefunction(pipe):
|
||||
return await pipe(**params)
|
||||
|
|
@ -203,6 +209,10 @@ async def generate_function_chat_completion(request, form_data, user, models: di
|
|||
|
||||
return params
|
||||
|
||||
# Copy so the base-model substitution below doesn't leak into the caller's
|
||||
# payload, which the tool-call continuation re-submits. Mirrors the routers.
|
||||
form_data = {**form_data}
|
||||
|
||||
model_id = form_data.get('model')
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ from open_webui.env import (
|
|||
ENABLE_COMPRESSION_MIDDLEWARE,
|
||||
ENABLE_CUSTOM_MODEL_FALLBACK,
|
||||
ENABLE_EASTER_EGGS,
|
||||
ENABLE_PLUGINS,
|
||||
EXTERNAL_PWA_MANIFEST_URL,
|
||||
# OAuth Back-Channel Logout
|
||||
ENABLE_OAUTH_BACKCHANNEL_LOGOUT,
|
||||
|
|
@ -153,6 +154,7 @@ from open_webui.routers import (
|
|||
knowledge,
|
||||
memories,
|
||||
models,
|
||||
notifications,
|
||||
notes,
|
||||
ollama,
|
||||
openai,
|
||||
|
|
@ -258,6 +260,15 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def emit_chat_list_event(metadata: dict, chat_id: str):
|
||||
if not chat_id or chat_id.startswith(('channel:', 'local:')):
|
||||
return
|
||||
|
||||
event_emitter = await get_event_emitter(metadata, update_db=False)
|
||||
if event_emitter:
|
||||
await event_emitter({'type': 'chat:list', 'data': {'chat_id': chat_id}})
|
||||
|
||||
|
||||
class SPAStaticFiles(StaticFiles):
|
||||
async def get_response(self, path: str, scope):
|
||||
try:
|
||||
|
|
@ -319,8 +330,9 @@ async def lifespan(app: FastAPI):
|
|||
await migrate_legacy_webhook_config()
|
||||
await publish_event(app, EVENTS.SYSTEM_STARTUP_STARTED, source='system')
|
||||
|
||||
license_task = None
|
||||
if LICENSE_KEY:
|
||||
get_license_data(app, LICENSE_KEY)
|
||||
license_task = asyncio.create_task(asyncio.to_thread(get_license_data, app, LICENSE_KEY))
|
||||
|
||||
# Create admin account from env vars if specified and no users exist
|
||||
if WEBUI_ADMIN_EMAIL and WEBUI_ADMIN_PASSWORD:
|
||||
|
|
@ -408,6 +420,14 @@ async def lifespan(app: FastAPI):
|
|||
log.warning(f'Failed to initialize terminal servers at startup: {e}')
|
||||
|
||||
# Mark application as ready to accept traffic from a startup perspective.
|
||||
if license_task:
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(license_task), timeout=2)
|
||||
except asyncio.TimeoutError:
|
||||
log.warning('License data retrieval is still pending; continuing startup without it')
|
||||
except Exception as e:
|
||||
log.warning(f'License data retrieval failed during startup: {e}')
|
||||
|
||||
app.state.startup_complete = True
|
||||
await publish_event(app, EVENTS.SYSTEM_STARTUP_COMPLETED, source='system')
|
||||
|
||||
|
|
@ -751,6 +771,7 @@ app.include_router(notes.router, prefix='/api/v1/notes', tags=['notes'])
|
|||
|
||||
|
||||
app.include_router(models.router, prefix='/api/v1/models', tags=['models'])
|
||||
app.include_router(notifications.router, prefix='/api/v1/notifications', tags=['notifications'])
|
||||
app.include_router(knowledge.router, prefix='/api/v1/knowledge', tags=['knowledge'])
|
||||
app.include_router(prompts.router, prefix='/api/v1/prompts', tags=['prompts'])
|
||||
app.include_router(tools.router, prefix='/api/v1/tools', tags=['tools'])
|
||||
|
|
@ -1130,6 +1151,7 @@ async def chat_completion(
|
|||
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 '',
|
||||
'user_message': user_message,
|
||||
'user_message_id': user_message.get('id') if user_message else None,
|
||||
|
|
@ -1266,6 +1288,7 @@ async def chat_completion(
|
|||
subject_id=chat_id,
|
||||
data={'title': 'New Chat'},
|
||||
)
|
||||
await emit_chat_list_event(metadata, chat_id)
|
||||
if user_message_id:
|
||||
await publish_event(
|
||||
request,
|
||||
|
|
@ -1367,6 +1390,7 @@ async def chat_completion(
|
|||
user_message['id'],
|
||||
user_message,
|
||||
)
|
||||
await emit_chat_list_event({**metadata, 'message_id': user_message['id']}, chat_id)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.MESSAGE_CREATED,
|
||||
|
|
@ -1378,6 +1402,15 @@ async def chat_completion(
|
|||
'content_preview': user_message.get('content', '')[:300],
|
||||
},
|
||||
)
|
||||
if not getattr(request.state, 'internal', False) and not (user_message.get('meta') or {}).get(
|
||||
'internal'
|
||||
):
|
||||
try:
|
||||
from open_webui.utils.timers import cancel_timers_for_chat
|
||||
|
||||
await cancel_timers_for_chat(chat_id, 'chat.user_message')
|
||||
except Exception:
|
||||
log.exception('Failed to cancel chat.user_message timers for chat %s', chat_id)
|
||||
|
||||
# Link grandparent → user message (childrenIds)
|
||||
grandparent_id = user_message.get('parentId')
|
||||
|
|
@ -1589,9 +1622,39 @@ async def chat_completion(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
chat_id = metadata.get('chat_id')
|
||||
if (
|
||||
chat_id
|
||||
and getattr(request.state, 'internal', False) is not True
|
||||
and not await has_active_tasks(request.app.state.redis, chat_id)
|
||||
):
|
||||
from open_webui.utils.subagents import process_pending_internal_messages
|
||||
|
||||
await process_pending_internal_messages(
|
||||
request,
|
||||
chat_id,
|
||||
user.id,
|
||||
{
|
||||
'model_id': metadata.get('model_id') or form_data.get('model'),
|
||||
'session_id': metadata.get('session_id'),
|
||||
'tool_ids': metadata.get('tool_ids') or [],
|
||||
'skill_ids': metadata.get('skill_ids') or [],
|
||||
'system_prompt': metadata.get('system_prompt'),
|
||||
'filter_ids': metadata.get('filter_ids') or [],
|
||||
'terminal_id': metadata.get('terminal_id'),
|
||||
'features': metadata.get('features') or {},
|
||||
'variables': metadata.get('variables') or {},
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
log.exception('Failed to process pending internal messages for chat %s', metadata.get('chat_id'))
|
||||
|
||||
# Fan out: one task per model
|
||||
if metadata.get('session_id') and metadata.get('chat_id'):
|
||||
task_ids = []
|
||||
subagent_results = []
|
||||
is_internal = getattr(request.state, 'internal', False) is True
|
||||
chat_id = metadata['chat_id']
|
||||
|
||||
for idx, entry in enumerate(message_ids):
|
||||
|
|
@ -1618,28 +1681,39 @@ async def chat_completion(
|
|||
|
||||
# Only the first model runs chat-level background tasks;
|
||||
# subsequent models only run follow-ups.
|
||||
process = process_chat(
|
||||
request,
|
||||
model_form_data,
|
||||
user,
|
||||
per_model_metadata,
|
||||
resolved_model,
|
||||
tasks
|
||||
if idx == 0
|
||||
else {
|
||||
k: v for k, v in (tasks or {}).items() if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION)
|
||||
}
|
||||
or None,
|
||||
)
|
||||
if is_internal:
|
||||
subagent_results.append(await process)
|
||||
continue
|
||||
|
||||
task_id, _ = await create_task(
|
||||
request.app.state.redis,
|
||||
process_chat(
|
||||
request,
|
||||
model_form_data,
|
||||
user,
|
||||
per_model_metadata,
|
||||
resolved_model,
|
||||
tasks
|
||||
if idx == 0
|
||||
else {
|
||||
k: v
|
||||
for k, v in (tasks or {}).items()
|
||||
if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION)
|
||||
}
|
||||
or None,
|
||||
),
|
||||
process,
|
||||
id=chat_id,
|
||||
)
|
||||
per_model_metadata['task_id'] = task_id
|
||||
task_ids.append(task_id)
|
||||
|
||||
if is_internal:
|
||||
return {
|
||||
'status': True,
|
||||
'task_ids': [],
|
||||
'chat_id': chat_id,
|
||||
'results': subagent_results,
|
||||
}
|
||||
|
||||
# Emit chat:active=true
|
||||
if task_ids:
|
||||
event_emitter = await get_event_emitter(
|
||||
|
|
@ -1683,6 +1757,16 @@ from open_webui.utils.anthropic import (
|
|||
)
|
||||
|
||||
|
||||
@app.post('/api/message/count_tokens')
|
||||
@app.post('/api/v1/messages/count_tokens') # Anthropic Messages token-count endpoint
|
||||
async def count_message_tokens(
|
||||
request: Request,
|
||||
form_data: dict,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
return {'input_tokens': await openai.count_anthropic_tokens(request, form_data, user)}
|
||||
|
||||
|
||||
@app.post('/api/message')
|
||||
@app.post('/api/v1/messages') # Anthropic Messages API compatible endpoint
|
||||
async def generate_messages(
|
||||
|
|
@ -1703,10 +1787,31 @@ async def generate_messages(
|
|||
Authentication: Supports both standard Authorization header and
|
||||
Anthropic's x-api-key header (via middleware translation).
|
||||
"""
|
||||
# Convert Anthropic payload to OpenAI format
|
||||
requested_model = form_data.get('model', '')
|
||||
input_tokens = None
|
||||
try:
|
||||
input_tokens = await openai.count_anthropic_tokens(request, form_data, user)
|
||||
except Exception:
|
||||
# Counting must not turn a compatible generation request into an outage.
|
||||
log.warning('Unable to count Anthropic input tokens for model %s', requested_model, exc_info=True)
|
||||
|
||||
openai_payload = convert_anthropic_to_openai_payload(form_data)
|
||||
model_id = requested_model
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
if model_info and model_info.base_model_id:
|
||||
model_id = model_info.base_model_id
|
||||
|
||||
passthrough_params = []
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
if not models or model_id not in models:
|
||||
await openai.get_all_models(request, user=user)
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
model = models.get(model_id)
|
||||
if model:
|
||||
_, _, api_config = await openai.get_openai_connection(model['urlIdx'])
|
||||
passthrough_params = api_config.get('passthrough_params') or []
|
||||
|
||||
# Convert Anthropic payload to OpenAI format
|
||||
openai_payload = convert_anthropic_to_openai_payload(form_data, passthrough_params)
|
||||
|
||||
# Route through the existing chat_completion handler
|
||||
response = await chat_completion(request, openai_payload, user)
|
||||
|
|
@ -1715,7 +1820,9 @@ async def generate_messages(
|
|||
if isinstance(response, StreamingResponse):
|
||||
# Streaming response: wrap the generator to convert SSE format
|
||||
return StreamingResponse(
|
||||
openai_stream_to_anthropic_stream(response.body_iterator, model=requested_model),
|
||||
openai_stream_to_anthropic_stream(
|
||||
response.body_iterator, model=requested_model, input_tokens=input_tokens or 0
|
||||
),
|
||||
media_type='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
|
|
@ -1723,7 +1830,7 @@ async def generate_messages(
|
|||
},
|
||||
)
|
||||
elif isinstance(response, dict):
|
||||
return convert_openai_to_anthropic_response(response, model=requested_model)
|
||||
return convert_openai_to_anthropic_response(response, model=requested_model, input_tokens=input_tokens)
|
||||
else:
|
||||
# Passthrough for error responses (JSONResponse, PlainTextResponse, etc.)
|
||||
return response
|
||||
|
|
@ -1865,6 +1972,7 @@ async def get_app_config(request: Request):
|
|||
'calendar.enable',
|
||||
'automations.enable',
|
||||
'notes.enable',
|
||||
'chat.context_compaction.enable',
|
||||
'web.search.enable',
|
||||
'web.search.confirmation.enable',
|
||||
'web.search.confirmation.content',
|
||||
|
|
@ -1927,12 +2035,14 @@ async def get_app_config(request: Request):
|
|||
'enable_public_active_users_count': ENABLE_PUBLIC_ACTIVE_USERS_COUNT,
|
||||
'enable_easter_eggs': ENABLE_EASTER_EGGS,
|
||||
'enable_direct_connections': config.get('direct.enable'),
|
||||
'enable_plugins': ENABLE_PLUGINS,
|
||||
'enable_folders': config.get('folders.enable'),
|
||||
'folder_max_file_count': config.get('folders.max_file_count'),
|
||||
'enable_channels': config.get('channels.enable'),
|
||||
'enable_calendar': config.get('calendar.enable'),
|
||||
'enable_automations': config.get('automations.enable'),
|
||||
'enable_notes': config.get('notes.enable'),
|
||||
'enable_context_compaction': config.get('chat.context_compaction.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'),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ from open_webui.env import DATABASE_PASSWORD, DATABASE_URL, LOG_FORMAT
|
|||
from open_webui.internal.db import enable_iam_token_auth, extract_ssl_params_from_url, reattach_ssl_params_to_url
|
||||
from open_webui.models.auths import Auth
|
||||
from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401
|
||||
from open_webui.models.chat_messages import ChatMessage # noqa: F401
|
||||
from open_webui.models.chats import Chat # noqa: F401
|
||||
from sqlalchemy import create_engine, engine_from_config, pool
|
||||
|
||||
alembic_config = alembic.context.config
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
"""add chat message meta
|
||||
|
||||
Revision ID: 856c5b02fb54
|
||||
Revises: 42e2978c7933
|
||||
Create Date: 2026-07-16 01:39:39.291935
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '856c5b02fb54'
|
||||
down_revision: Union[str, None] = '42e2978c7933'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('chat_message', sa.Column('meta', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('chat_message', 'meta')
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
"""add current_message_id to chat
|
||||
|
||||
Revision ID: 9a1b2c3d4e5f
|
||||
Revises: 856c5b02fb54
|
||||
Create Date: 2026-07-23 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '9a1b2c3d4e5f'
|
||||
down_revision: Union[str, None] = '856c5b02fb54'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
BATCH_SIZE = 150
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
columns = [col['name'] for col in inspector.get_columns('chat')]
|
||||
if 'current_message_id' not in columns:
|
||||
op.add_column('chat', sa.Column('current_message_id', sa.Text(), nullable=True))
|
||||
|
||||
chat = sa.table(
|
||||
'chat',
|
||||
sa.column('id', sa.String()),
|
||||
sa.column('chat', sa.Text()),
|
||||
sa.column('current_message_id', sa.Text()),
|
||||
)
|
||||
chat_message = sa.table(
|
||||
'chat_message',
|
||||
sa.column('id', sa.Text()),
|
||||
sa.column('chat_id', sa.Text()),
|
||||
sa.column('parent_id', sa.Text()),
|
||||
sa.column('created_at', sa.BigInteger()),
|
||||
)
|
||||
|
||||
has_chat_message = 'chat_message' in inspector.get_table_names()
|
||||
result = conn.execute(
|
||||
sa.select(chat.c.id, chat.c.chat, chat.c.current_message_id).execution_options(
|
||||
yield_per=BATCH_SIZE,
|
||||
stream_results=True,
|
||||
)
|
||||
)
|
||||
|
||||
while True:
|
||||
rows = result.fetchmany(BATCH_SIZE)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
batch_chat_ids: list[str] = []
|
||||
candidates_by_chat: dict[str, list[str]] = {}
|
||||
current_by_chat: dict[str, str | None] = {}
|
||||
json_messages_by_chat: dict[str, dict[str, dict]] = {}
|
||||
|
||||
for row in rows:
|
||||
values = row._mapping
|
||||
chat_id = values['id']
|
||||
prefix = f'{chat_id}-'
|
||||
batch_chat_ids.append(chat_id)
|
||||
current_by_chat[chat_id] = values['current_message_id']
|
||||
|
||||
chat_data = {}
|
||||
if isinstance(values['chat'], dict):
|
||||
chat_data = values['chat']
|
||||
elif isinstance(values['chat'], str):
|
||||
try:
|
||||
parsed = json.loads(values['chat'])
|
||||
chat_data = parsed if isinstance(parsed, dict) else {}
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
history = chat_data.get('history') if isinstance(chat_data.get('history'), dict) else {}
|
||||
candidates_by_chat[chat_id] = []
|
||||
for candidate in (
|
||||
values['current_message_id'],
|
||||
history.get('currentId'),
|
||||
chat_data.get('currentId'),
|
||||
chat_data.get('branchPointMessageId'),
|
||||
):
|
||||
if not isinstance(candidate, str) or not candidate:
|
||||
continue
|
||||
candidate = candidate[len(prefix) :] if candidate.startswith(prefix) else candidate
|
||||
if candidate not in candidates_by_chat[chat_id]:
|
||||
candidates_by_chat[chat_id].append(candidate)
|
||||
|
||||
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
|
||||
if not messages and isinstance(chat_data.get('messages'), list):
|
||||
messages = {
|
||||
message['id']: message
|
||||
for message in chat_data['messages']
|
||||
if isinstance(message, dict) and message.get('id')
|
||||
}
|
||||
if messages:
|
||||
json_messages_by_chat[chat_id] = {
|
||||
message_id: {
|
||||
'parent_id': message.get('parentId') if isinstance(message, dict) else None,
|
||||
'created_at': message.get('timestamp', 0) if isinstance(message, dict) else 0,
|
||||
}
|
||||
for message_id, message in messages.items()
|
||||
}
|
||||
|
||||
resolved: dict[str, str] = {}
|
||||
|
||||
if has_chat_message:
|
||||
candidate_ids = {
|
||||
f'{chat_id}-{candidate}'
|
||||
for chat_id, candidates in candidates_by_chat.items()
|
||||
for candidate in candidates
|
||||
}
|
||||
if candidate_ids:
|
||||
valid_by_chat: dict[str, set[str]] = {}
|
||||
for row in conn.execute(
|
||||
sa.select(chat_message.c.chat_id, chat_message.c.id).where(
|
||||
chat_message.c.chat_id.in_(batch_chat_ids),
|
||||
chat_message.c.id.in_(candidate_ids),
|
||||
)
|
||||
):
|
||||
values = row._mapping
|
||||
chat_id = values['chat_id']
|
||||
prefix = f'{chat_id}-'
|
||||
message_id = values['id']
|
||||
if message_id and message_id.startswith(prefix):
|
||||
message_id = message_id[len(prefix) :]
|
||||
if message_id:
|
||||
valid_by_chat.setdefault(chat_id, set()).add(message_id)
|
||||
for chat_id, candidates in candidates_by_chat.items():
|
||||
valid_ids = valid_by_chat.get(chat_id, set())
|
||||
for candidate in candidates:
|
||||
if candidate in valid_ids:
|
||||
resolved[chat_id] = candidate
|
||||
break
|
||||
|
||||
unresolved_chat_ids = [chat_id for chat_id in batch_chat_ids if chat_id not in resolved]
|
||||
messages_by_chat: dict[str, dict[str, dict]] = {}
|
||||
if unresolved_chat_ids:
|
||||
for row in conn.execute(
|
||||
sa.select(
|
||||
chat_message.c.chat_id,
|
||||
chat_message.c.id,
|
||||
chat_message.c.parent_id,
|
||||
chat_message.c.created_at,
|
||||
).where(chat_message.c.chat_id.in_(unresolved_chat_ids))
|
||||
):
|
||||
values = row._mapping
|
||||
chat_id = values['chat_id']
|
||||
prefix = f'{chat_id}-'
|
||||
message_id = values['id']
|
||||
if message_id and message_id.startswith(prefix):
|
||||
message_id = message_id[len(prefix) :]
|
||||
if not message_id:
|
||||
continue
|
||||
parent_id = values['parent_id']
|
||||
if parent_id and parent_id.startswith(prefix):
|
||||
parent_id = parent_id[len(prefix) :]
|
||||
messages_by_chat.setdefault(chat_id, {})[message_id] = {
|
||||
'parent_id': parent_id,
|
||||
'created_at': values['created_at'] or 0,
|
||||
}
|
||||
|
||||
for chat_id, messages in messages_by_chat.items():
|
||||
parent_ids = {
|
||||
message['parent_id']
|
||||
for message in messages.values()
|
||||
if message.get('parent_id') in messages
|
||||
}
|
||||
leaf_ids = [message_id for message_id in messages if message_id not in parent_ids]
|
||||
resolved[chat_id] = max(
|
||||
leaf_ids or list(messages),
|
||||
key=lambda message_id: messages[message_id].get('created_at') or 0,
|
||||
)
|
||||
|
||||
for chat_id in batch_chat_ids:
|
||||
if chat_id in resolved:
|
||||
continue
|
||||
|
||||
messages = json_messages_by_chat.get(chat_id, {})
|
||||
valid_candidate = next(
|
||||
(candidate for candidate in candidates_by_chat[chat_id] if candidate in messages),
|
||||
None,
|
||||
)
|
||||
if valid_candidate:
|
||||
resolved[chat_id] = valid_candidate
|
||||
elif messages:
|
||||
parent_ids = {
|
||||
message['parent_id']
|
||||
for message in messages.values()
|
||||
if message.get('parent_id') in messages
|
||||
}
|
||||
leaf_ids = [message_id for message_id in messages if message_id not in parent_ids]
|
||||
resolved[chat_id] = max(
|
||||
leaf_ids or list(messages),
|
||||
key=lambda message_id: messages[message_id].get('created_at') or 0,
|
||||
)
|
||||
|
||||
updates = [
|
||||
{'chat_id': chat_id, 'current_message_id': message_id}
|
||||
for chat_id, message_id in resolved.items()
|
||||
if message_id and message_id != current_by_chat.get(chat_id)
|
||||
]
|
||||
if updates:
|
||||
conn.execute(
|
||||
sa.update(chat)
|
||||
.where(chat.c.id == sa.bindparam('update_chat_id'))
|
||||
.values(current_message_id=sa.bindparam('update_current_message_id')),
|
||||
[
|
||||
{
|
||||
'update_chat_id': row['chat_id'],
|
||||
'update_current_message_id': row['current_message_id'],
|
||||
}
|
||||
for row in updates
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('chat', 'current_message_id')
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import json
|
||||
import time
|
||||
import uuid
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from sqlalchemy import select, delete, func, cast, Integer, distinct
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -47,6 +50,17 @@ def _normalize_timestamp(timestamp: int) -> float:
|
|||
return timestamp
|
||||
|
||||
|
||||
def _timezone(tz: Optional[str]) -> ZoneInfo:
|
||||
try:
|
||||
return ZoneInfo(tz or 'UTC')
|
||||
except ZoneInfoNotFoundError:
|
||||
return ZoneInfo('UTC')
|
||||
|
||||
|
||||
def _date_key(timestamp: int, tz: ZoneInfo) -> str:
|
||||
return datetime.fromtimestamp(_normalize_timestamp(timestamp), tz=tz).strftime('%Y-%m-%d')
|
||||
|
||||
|
||||
def get_usage(data: dict) -> Optional[dict]:
|
||||
"""Extract and normalize usage from message data."""
|
||||
usage = data.get('usage') or (data.get('info') or {}).get('usage')
|
||||
|
|
@ -72,6 +86,40 @@ def _token_columns(dialect: str):
|
|||
)
|
||||
|
||||
|
||||
def _extract_tool_names(value: Any) -> list[str]:
|
||||
names: list[str] = []
|
||||
|
||||
def add(name: Any):
|
||||
if isinstance(name, str):
|
||||
cleaned = name.strip()
|
||||
if cleaned and len(cleaned) <= 128:
|
||||
names.append(cleaned)
|
||||
|
||||
def walk(item: Any):
|
||||
if isinstance(item, list):
|
||||
for child in item:
|
||||
walk(child)
|
||||
return
|
||||
|
||||
if not isinstance(item, dict):
|
||||
return
|
||||
|
||||
item_type = str(item.get('type') or '')
|
||||
looks_like_tool = 'tool' in item_type or item_type in {'function_call', 'function_call_output'}
|
||||
if looks_like_tool:
|
||||
add(item.get('name') or item.get('tool_name'))
|
||||
function = item.get('function')
|
||||
if isinstance(function, dict):
|
||||
add(function.get('name'))
|
||||
|
||||
for key in ('tool_calls', 'tools', 'output', 'meta'):
|
||||
if key in item:
|
||||
walk(item.get(key))
|
||||
|
||||
walk(value)
|
||||
return names
|
||||
|
||||
|
||||
####################
|
||||
# ChatMessage DB Schema
|
||||
####################
|
||||
|
|
@ -100,6 +148,7 @@ class ChatMessage(Base):
|
|||
files = Column(JSON, nullable=True)
|
||||
sources = Column(JSON, nullable=True)
|
||||
embeds = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
|
||||
# Status
|
||||
done = Column(Boolean, default=True)
|
||||
|
|
@ -142,6 +191,7 @@ class ChatMessageModel(BaseModel):
|
|||
files: Optional[list] = None
|
||||
sources: Optional[list] = None
|
||||
embeds: Optional[list] = None
|
||||
meta: Optional[dict] = None
|
||||
done: bool = True
|
||||
status_history: Optional[list] = None
|
||||
error: Optional[dict | str] = None
|
||||
|
|
@ -192,6 +242,8 @@ class ChatMessageTable:
|
|||
existing.sources = data.get('sources')
|
||||
if 'embeds' in data:
|
||||
existing.embeds = data.get('embeds')
|
||||
if 'meta' in data:
|
||||
existing.meta = data.get('meta')
|
||||
if 'done' in data:
|
||||
existing.done = data.get('done', True)
|
||||
if 'status_history' in data or 'statusHistory' in data:
|
||||
|
|
@ -225,6 +277,7 @@ class ChatMessageTable:
|
|||
files=data.get('files'),
|
||||
sources=data.get('sources'),
|
||||
embeds=data.get('embeds'),
|
||||
meta=data.get('meta'),
|
||||
done=data.get('done', True),
|
||||
status_history=data.get('status_history') or data.get('statusHistory'),
|
||||
error=data.get('error'),
|
||||
|
|
@ -243,6 +296,21 @@ class ChatMessageTable:
|
|||
message = await db.get(ChatMessage, id)
|
||||
return ChatMessageModel.model_validate(message) if message else None
|
||||
|
||||
async def has_unfinished_assistant_by_chat_id(
|
||||
self,
|
||||
chat_id: str,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> bool:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(ChatMessage.id)
|
||||
.where(ChatMessage.chat_id == chat_id)
|
||||
.where(ChatMessage.role == 'assistant')
|
||||
.where(ChatMessage.done.is_(False))
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
async def get_messages_by_chat_id(self, chat_id: str, db: Optional[AsyncSession] = None) -> list[ChatMessageModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
|
|
@ -584,6 +652,233 @@ class ChatMessageTable:
|
|||
for row in result.all()
|
||||
}
|
||||
|
||||
async def get_user_usage_summary(
|
||||
self,
|
||||
user_id: str,
|
||||
start_date: Optional[int] = None,
|
||||
end_date: Optional[int] = None,
|
||||
include_active_days: bool = True,
|
||||
timezone: Optional[str] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> dict:
|
||||
async with get_async_db_context(db) as db:
|
||||
bind = await db.connection()
|
||||
dialect = bind.dialect.name
|
||||
input_tokens, output_tokens = _token_columns(dialect)
|
||||
|
||||
messages_stmt = select(ChatMessage.role, func.count(ChatMessage.id).label('count')).filter(
|
||||
ChatMessage.user_id == user_id,
|
||||
)
|
||||
token_stmt = select(
|
||||
func.coalesce(func.sum(input_tokens), 0).label('input_tokens'),
|
||||
func.coalesce(func.sum(output_tokens), 0).label('output_tokens'),
|
||||
).filter(
|
||||
ChatMessage.user_id == user_id,
|
||||
ChatMessage.role == 'assistant',
|
||||
ChatMessage.usage.isnot(None),
|
||||
)
|
||||
models_stmt = select(func.count(distinct(ChatMessage.model_id)).label('models_used')).filter(
|
||||
ChatMessage.user_id == user_id,
|
||||
ChatMessage.role == 'assistant',
|
||||
ChatMessage.model_id.isnot(None),
|
||||
)
|
||||
if start_date:
|
||||
messages_stmt = messages_stmt.filter(ChatMessage.created_at >= start_date)
|
||||
token_stmt = token_stmt.filter(ChatMessage.created_at >= start_date)
|
||||
models_stmt = models_stmt.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
messages_stmt = messages_stmt.filter(ChatMessage.created_at <= end_date)
|
||||
token_stmt = token_stmt.filter(ChatMessage.created_at <= end_date)
|
||||
models_stmt = models_stmt.filter(ChatMessage.created_at <= end_date)
|
||||
|
||||
messages_result = await db.execute(messages_stmt.group_by(ChatMessage.role))
|
||||
message_counts = {row.role: row.count for row in messages_result.all()}
|
||||
|
||||
token_result = (await db.execute(token_stmt)).one()
|
||||
models_used = (await db.execute(models_stmt)).scalar() or 0
|
||||
|
||||
active_days = set()
|
||||
if include_active_days:
|
||||
tz = _timezone(timezone)
|
||||
day_stmt = select(ChatMessage.created_at).filter(ChatMessage.user_id == user_id)
|
||||
if start_date:
|
||||
day_stmt = day_stmt.filter(ChatMessage.created_at >= start_date)
|
||||
if end_date:
|
||||
day_stmt = day_stmt.filter(ChatMessage.created_at <= end_date)
|
||||
day_result = await db.execute(day_stmt)
|
||||
active_days = {_date_key(row.created_at, tz) for row in day_result.all()}
|
||||
|
||||
input_total = int(token_result.input_tokens or 0)
|
||||
output_total = int(token_result.output_tokens or 0)
|
||||
|
||||
return {
|
||||
'messages': sum(message_counts.values()),
|
||||
'user_messages': message_counts.get('user', 0),
|
||||
'assistant_messages': message_counts.get('assistant', 0),
|
||||
'input_tokens': input_total,
|
||||
'output_tokens': output_total,
|
||||
'total_tokens': input_total + output_total,
|
||||
'models_used': int(models_used),
|
||||
'active_days': len(active_days),
|
||||
}
|
||||
|
||||
async def get_user_first_message_created_at(
|
||||
self,
|
||||
user_id: str,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> Optional[int]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(
|
||||
select(func.min(ChatMessage.created_at)).filter(
|
||||
ChatMessage.user_id == user_id,
|
||||
ChatMessage.created_at.isnot(None),
|
||||
)
|
||||
)
|
||||
value = result.scalar()
|
||||
return int(value) if value else None
|
||||
|
||||
async def get_user_daily_usage(
|
||||
self,
|
||||
user_id: str,
|
||||
start_date: int,
|
||||
end_date: int,
|
||||
timezone: Optional[str] = None,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[dict]:
|
||||
async with get_async_db_context(db) as db:
|
||||
tz = _timezone(timezone)
|
||||
bind = await db.connection()
|
||||
dialect = bind.dialect.name
|
||||
input_tokens, output_tokens = _token_columns(dialect)
|
||||
|
||||
stmt = select(
|
||||
ChatMessage.created_at,
|
||||
ChatMessage.chat_id,
|
||||
ChatMessage.role,
|
||||
ChatMessage.model_id,
|
||||
ChatMessage.usage,
|
||||
input_tokens.label('input_tokens'),
|
||||
output_tokens.label('output_tokens'),
|
||||
).filter(
|
||||
ChatMessage.user_id == user_id,
|
||||
ChatMessage.created_at >= start_date,
|
||||
ChatMessage.created_at <= end_date,
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
daily: dict[str, dict] = {}
|
||||
for row in result.all():
|
||||
date = _date_key(row.created_at, tz)
|
||||
entry = daily.setdefault(
|
||||
date,
|
||||
{
|
||||
'date': date,
|
||||
'messages': 0,
|
||||
'chat_ids': set(),
|
||||
'tokens': 0,
|
||||
'models': Counter(),
|
||||
},
|
||||
)
|
||||
entry['messages'] += 1
|
||||
entry['chat_ids'].add(row.chat_id)
|
||||
if row.role == 'assistant' and row.model_id:
|
||||
entry['models'][row.model_id] += 1
|
||||
if row.usage:
|
||||
entry['tokens'] += int(row.input_tokens or 0) + int(row.output_tokens or 0)
|
||||
|
||||
current = datetime.fromtimestamp(_normalize_timestamp(start_date), tz=tz).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
end_dt = datetime.fromtimestamp(_normalize_timestamp(end_date), tz=tz).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
while current <= end_dt:
|
||||
date = current.strftime('%Y-%m-%d')
|
||||
daily.setdefault(
|
||||
date,
|
||||
{'date': date, 'messages': 0, 'chat_ids': set(), 'tokens': 0, 'models': Counter()},
|
||||
)
|
||||
current += timedelta(days=1)
|
||||
|
||||
return [
|
||||
{
|
||||
'date': item['date'],
|
||||
'messages': item['messages'],
|
||||
'chats': len(item['chat_ids']),
|
||||
'tokens': item['tokens'],
|
||||
'models': dict(item['models']),
|
||||
}
|
||||
for item in sorted(daily.values(), key=lambda x: x['date'])
|
||||
]
|
||||
|
||||
async def get_user_top_models(
|
||||
self,
|
||||
user_id: str,
|
||||
start_date: int,
|
||||
end_date: int,
|
||||
limit: int = 5,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[dict]:
|
||||
async with get_async_db_context(db) as db:
|
||||
bind = await db.connection()
|
||||
dialect = bind.dialect.name
|
||||
input_tokens, output_tokens = _token_columns(dialect)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
ChatMessage.model_id,
|
||||
func.count(ChatMessage.id).label('messages'),
|
||||
func.coalesce(func.sum(input_tokens), 0).label('input_tokens'),
|
||||
func.coalesce(func.sum(output_tokens), 0).label('output_tokens'),
|
||||
)
|
||||
.filter(
|
||||
ChatMessage.user_id == user_id,
|
||||
ChatMessage.role == 'assistant',
|
||||
ChatMessage.model_id.isnot(None),
|
||||
ChatMessage.created_at >= start_date,
|
||||
ChatMessage.created_at <= end_date,
|
||||
)
|
||||
.group_by(ChatMessage.model_id)
|
||||
.order_by(func.count(ChatMessage.id).desc())
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return [
|
||||
{
|
||||
'model_id': row.model_id,
|
||||
'messages': row.messages,
|
||||
'input_tokens': int(row.input_tokens or 0),
|
||||
'output_tokens': int(row.output_tokens or 0),
|
||||
'total_tokens': int(row.input_tokens or 0) + int(row.output_tokens or 0),
|
||||
}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
async def get_user_top_tools(
|
||||
self,
|
||||
user_id: str,
|
||||
start_date: int,
|
||||
end_date: int,
|
||||
limit: int = 5,
|
||||
db: Optional[AsyncSession] = None,
|
||||
) -> list[dict]:
|
||||
async with get_async_db_context(db) as db:
|
||||
stmt = select(ChatMessage.output, ChatMessage.meta).filter(
|
||||
ChatMessage.user_id == user_id,
|
||||
ChatMessage.created_at >= start_date,
|
||||
ChatMessage.created_at <= end_date,
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
|
||||
counts: Counter[str] = Counter()
|
||||
for output, meta in result.all():
|
||||
for name in _extract_tool_names(output):
|
||||
counts[name] += 1
|
||||
for name in _extract_tool_names(meta):
|
||||
counts[name] += 1
|
||||
|
||||
return [{'name': name, 'count': count} for name, count in counts.most_common(limit)]
|
||||
|
||||
async def get_message_count_by_user(
|
||||
self,
|
||||
start_date: Optional[int] = None,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ from sqlalchemy.sql import exists
|
|||
from sqlalchemy.sql.expression import bindparam
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
ACTIVE_CHAT_GAP_SECONDS = 30 * 60
|
||||
|
||||
|
||||
class Chat(Base): # database table mapping for chat entity
|
||||
|
|
@ -61,6 +62,7 @@ class Chat(Base): # database table mapping for chat entity
|
|||
|
||||
tasks = Column(JSON, nullable=True)
|
||||
summary = Column(Text, nullable=True)
|
||||
current_message_id = Column(Text, nullable=True)
|
||||
|
||||
last_read_at = Column(BigInteger, nullable=True)
|
||||
|
||||
|
|
@ -74,6 +76,10 @@ class Chat(Base): # database table mapping for chat entity
|
|||
)
|
||||
|
||||
|
||||
def is_internal_chat(meta: dict | None) -> bool:
|
||||
return bool(meta and meta.get('internal') is True)
|
||||
|
||||
|
||||
class ChatModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True) # allows ORM model binding
|
||||
id: str
|
||||
|
|
@ -93,6 +99,7 @@ class ChatModel(BaseModel):
|
|||
|
||||
tasks: list | None = None
|
||||
summary: str | None = None
|
||||
current_message_id: str | None = None
|
||||
|
||||
last_read_at: int | None = None
|
||||
|
||||
|
|
@ -140,6 +147,7 @@ class ChatForm(BaseModel):
|
|||
class ChatImportForm(ChatForm):
|
||||
meta: dict | None = {}
|
||||
pinned: bool | None = False
|
||||
current_message_id: str | None = None
|
||||
created_at: int | None = None
|
||||
updated_at: int | None = None
|
||||
|
||||
|
|
@ -172,6 +180,8 @@ class ChatResponse(BaseModel):
|
|||
|
||||
tasks: list | None = None
|
||||
summary: str | None = None
|
||||
current_message_id: str | None = None
|
||||
context_usage: dict | None = None
|
||||
|
||||
|
||||
class ChatTitleIdResponse(BaseModel):
|
||||
|
|
@ -181,6 +191,7 @@ class ChatTitleIdResponse(BaseModel):
|
|||
created_at: int
|
||||
last_read_at: int | None = None
|
||||
snippet: str | None = None
|
||||
active: bool = False
|
||||
|
||||
|
||||
class SharedChatResponse(BaseModel):
|
||||
|
|
@ -273,6 +284,21 @@ class ChatTable:
|
|||
"""Recursively remove null bytes from strings in dict/list structures."""
|
||||
return sanitize_data_for_db(obj)
|
||||
|
||||
def get_current_message_id(self, chat: dict | None) -> str | None:
|
||||
chat = chat or {}
|
||||
history = chat.get('history') if isinstance(chat.get('history'), dict) else {}
|
||||
current_id = history.get('currentId') or chat.get('currentId') or chat.get('branchPointMessageId')
|
||||
if current_id:
|
||||
return current_id
|
||||
|
||||
messages = chat.get('messages')
|
||||
if isinstance(messages, list):
|
||||
for message in reversed(messages):
|
||||
if isinstance(message, dict) and message.get('id'):
|
||||
return message['id']
|
||||
|
||||
return None
|
||||
|
||||
def _sanitize_chat_row(self, chat_item):
|
||||
"""
|
||||
Clean a Chat SQLAlchemy model's title + chat JSON,
|
||||
|
|
@ -349,7 +375,13 @@ class ChatTable:
|
|||
return True
|
||||
|
||||
async def insert_new_chat(
|
||||
self, id: str, user_id: str, form_data: ChatForm, db: AsyncSession | None = None
|
||||
self,
|
||||
id: str,
|
||||
user_id: str,
|
||||
form_data: ChatForm,
|
||||
db: AsyncSession | None = None,
|
||||
*,
|
||||
internal_meta: dict | None = None,
|
||||
) -> ChatModel | None:
|
||||
async with get_async_db_context(db) as session:
|
||||
chat = ChatModel(
|
||||
|
|
@ -361,6 +393,8 @@ class ChatTable:
|
|||
),
|
||||
'chat': self._clean_null_bytes(form_data.chat),
|
||||
'folder_id': form_data.folder_id,
|
||||
'meta': internal_meta or {},
|
||||
'current_message_id': self.get_current_message_id(form_data.chat),
|
||||
'created_at': int(time.time()),
|
||||
'updated_at': int(time.time()),
|
||||
'last_read_at': int(time.time()),
|
||||
|
|
@ -374,8 +408,14 @@ class ChatTable:
|
|||
|
||||
# Dual-write initial messages to chat_message table
|
||||
try:
|
||||
history = form_data.chat.get('history', {})
|
||||
messages = history.get('messages', {})
|
||||
history = form_data.chat.get('history') if isinstance(form_data.chat.get('history'), dict) else {}
|
||||
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
|
||||
if not messages and isinstance(form_data.chat.get('messages'), list):
|
||||
messages = {
|
||||
message.get('id'): message
|
||||
for message in form_data.chat['messages']
|
||||
if isinstance(message, dict) and message.get('id')
|
||||
}
|
||||
for message_id, message in messages.items():
|
||||
if isinstance(message, dict) and message.get('role'):
|
||||
await ChatMessages.upsert_message(
|
||||
|
|
@ -389,6 +429,50 @@ class ChatTable:
|
|||
|
||||
return ChatModel.model_validate(chat_item) if chat_item else None
|
||||
|
||||
async def get_internal_chat_ids_by_parent_id(self, parent_chat_id: str, user_id: str) -> list[str]:
|
||||
async with get_async_db_context() as session:
|
||||
result = await session.execute(
|
||||
select(Chat.id).where(
|
||||
Chat.user_id == user_id,
|
||||
Chat.meta['internal'].as_boolean().is_(True),
|
||||
Chat.meta['parent_chat_id'].as_string() == parent_chat_id,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_internal_chat_by_note_id(
|
||||
self, note_id: str, user_id: str, db: AsyncSession | None = None
|
||||
) -> ChatModel | None:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(
|
||||
select(Chat)
|
||||
.where(
|
||||
Chat.user_id == user_id,
|
||||
Chat.meta['internal'].as_boolean().is_(True),
|
||||
Chat.meta['type'].as_string() == 'note',
|
||||
Chat.meta['note_id'].as_string() == note_id,
|
||||
)
|
||||
.order_by(Chat.updated_at.desc(), Chat.created_at.desc())
|
||||
)
|
||||
chat = result.scalars().first()
|
||||
return ChatModel.model_validate(chat) if chat else None
|
||||
|
||||
async def get_internal_chats_by_note_id(
|
||||
self, note_id: str, user_id: str, db: AsyncSession | None = None
|
||||
) -> list[ChatModel]:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(
|
||||
select(Chat)
|
||||
.where(
|
||||
Chat.user_id == user_id,
|
||||
Chat.meta['internal'].as_boolean().is_(True),
|
||||
Chat.meta['type'].as_string() == 'note',
|
||||
Chat.meta['note_id'].as_string() == note_id,
|
||||
)
|
||||
.order_by(Chat.updated_at.desc(), Chat.created_at.desc())
|
||||
)
|
||||
return [ChatModel.model_validate(chat) for chat in result.scalars().all()]
|
||||
|
||||
def _chat_import_form_to_chat_model(self, user_id: str, form_data: ChatImportForm) -> ChatModel:
|
||||
id = str(uuid.uuid4())
|
||||
chat = ChatModel(
|
||||
|
|
@ -400,6 +484,7 @@ class ChatTable:
|
|||
'meta': form_data.meta,
|
||||
'pinned': form_data.pinned,
|
||||
'folder_id': form_data.folder_id,
|
||||
'current_message_id': form_data.current_message_id or self.get_current_message_id(form_data.chat),
|
||||
'created_at': (form_data.created_at if form_data.created_at else int(time.time())),
|
||||
'updated_at': (form_data.updated_at if form_data.updated_at else int(time.time())),
|
||||
}
|
||||
|
|
@ -439,8 +524,14 @@ class ChatTable:
|
|||
|
||||
# Dual-write messages to chat_message table
|
||||
for form_data, chat_obj in zip(chat_import_forms, chats):
|
||||
history = form_data.chat.get('history', {})
|
||||
messages = history.get('messages', {})
|
||||
history = form_data.chat.get('history') if isinstance(form_data.chat.get('history'), dict) else {}
|
||||
messages = history.get('messages') if isinstance(history.get('messages'), dict) else {}
|
||||
if not messages and isinstance(form_data.chat.get('messages'), list):
|
||||
messages = {
|
||||
message.get('id'): message
|
||||
for message in form_data.chat['messages']
|
||||
if isinstance(message, dict) and message.get('id')
|
||||
}
|
||||
for message_id, message in messages.items():
|
||||
if isinstance(message, dict) and message.get('role'):
|
||||
try:
|
||||
|
|
@ -460,6 +551,8 @@ class ChatTable:
|
|||
id: str,
|
||||
chat: dict,
|
||||
db: AsyncSession | None = None,
|
||||
*,
|
||||
touch: bool = True,
|
||||
) -> ChatModel | None:
|
||||
"""Persist updated chat content, sanitizing null bytes."""
|
||||
try: # load the chat record for in-place mutation
|
||||
|
|
@ -470,8 +563,11 @@ class ChatTable:
|
|||
|
||||
chat_item.chat = self._clean_null_bytes(chat)
|
||||
chat_item.title = self._clean_null_bytes(chat['title']) if 'title' in chat else 'New Chat'
|
||||
if any(key in chat for key in ('history', 'messages', 'currentId', 'branchPointMessageId')):
|
||||
chat_item.current_message_id = self.get_current_message_id(chat)
|
||||
|
||||
chat_item.updated_at = int(time.time())
|
||||
if touch:
|
||||
chat_item.updated_at = int(time.time())
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
|
@ -702,7 +798,7 @@ class ChatTable:
|
|||
return chat.chat.get('history', {}).get('messages', {}).get(message_id, {})
|
||||
|
||||
async def upsert_message_to_chat_by_id_and_message_id(
|
||||
self, id: str, message_id: str, message: dict
|
||||
self, id: str, message_id: str, message: dict, *, touch: bool = True
|
||||
) -> ChatModel | None:
|
||||
chat = await self.get_chat_by_id(id)
|
||||
if chat is None:
|
||||
|
|
@ -771,7 +867,7 @@ class ChatTable:
|
|||
except Exception as e:
|
||||
log.warning(f'Failed to write to chat_message table: {e}')
|
||||
|
||||
return await self.update_chat_by_id(id, chat)
|
||||
return await self.update_chat_by_id(id, chat, touch=touch)
|
||||
|
||||
async def delete_message_from_chat_by_id_and_message_id(self, id: str, message_id: str) -> ChatModel | None:
|
||||
chat_model = await self.get_chat_by_id(id)
|
||||
|
|
@ -809,7 +905,7 @@ class ChatTable:
|
|||
history['messages'][message_id]['statusHistory'] = status_history
|
||||
|
||||
chat['history'] = history
|
||||
return await self.update_chat_by_id(id, chat)
|
||||
return await self.update_chat_by_id(id, chat, touch=False)
|
||||
|
||||
async def add_message_files_by_id_and_message_id(self, id: str, message_id: str, files: list[dict]) -> list[dict]:
|
||||
async with get_async_db_context() as session:
|
||||
|
|
@ -951,6 +1047,7 @@ class ChatTable:
|
|||
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at).filter_by(
|
||||
user_id=user_id, archived=True
|
||||
)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
|
||||
if filter:
|
||||
query_key = filter.get('query')
|
||||
|
|
@ -998,7 +1095,8 @@ class ChatTable:
|
|||
db: AsyncSession | None = None,
|
||||
) -> int:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(select(func.count(Chat.id)).filter_by(user_id=user_id, archived=True))
|
||||
stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, archived=True)
|
||||
result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True)))
|
||||
return result.scalar() or 0
|
||||
|
||||
async def get_shared_chat_list_by_user_id(
|
||||
|
|
@ -1027,6 +1125,7 @@ class ChatTable:
|
|||
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
|
||||
user_id=user_id
|
||||
)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
if not include_archived:
|
||||
stmt = stmt.filter_by(archived=False)
|
||||
|
||||
|
|
@ -1082,6 +1181,7 @@ class ChatTable:
|
|||
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
|
||||
user_id=user_id
|
||||
)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
|
||||
if not include_folders:
|
||||
stmt = stmt.filter_by(folder_id=None)
|
||||
|
|
@ -1123,9 +1223,9 @@ class ChatTable:
|
|||
db: AsyncSession | None = None,
|
||||
) -> list[ChatModel]:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(
|
||||
select(Chat).filter(Chat.id.in_(chat_ids)).filter_by(archived=False).order_by(Chat.updated_at.desc())
|
||||
)
|
||||
stmt = select(Chat).filter(Chat.id.in_(chat_ids)).filter_by(archived=False)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
|
||||
all_chats = result.scalars().all()
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
|
||||
|
|
@ -1170,6 +1270,7 @@ class ChatTable:
|
|||
select(Chat.id, Chat.user_id, Chat.title, Chat.updated_at, User.name.label('user_name'))
|
||||
.join(chat_ids, chat_ids.c.chat_id == Chat.id)
|
||||
.outerjoin(User, User.id == Chat.user_id)
|
||||
.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
)
|
||||
|
||||
order_by = filter.get('order_by') if filter else None
|
||||
|
|
@ -1301,10 +1402,41 @@ class ChatTable:
|
|||
|
||||
async def get_chats(self, skip: int = 0, limit: int = 50, db: AsyncSession | None = None) -> list[ChatModel]:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(select(Chat).order_by(Chat.updated_at.desc()))
|
||||
stmt = select(Chat).where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
|
||||
all_chats = result.scalars().all()
|
||||
return [ChatModel.model_validate(chat) for chat in all_chats]
|
||||
|
||||
async def get_user_usage_chat_stats(self, user_id: str, db: AsyncSession | None = None) -> dict:
|
||||
async with get_async_db_context(db) as session:
|
||||
chat_filter = (Chat.user_id == user_id, Chat.meta['internal'].as_boolean().is_not(True))
|
||||
result = await session.execute(select(func.count(Chat.id).label('total_chats')).where(*chat_filter))
|
||||
total_chats = int(result.scalar() or 0)
|
||||
|
||||
messages_stmt = (
|
||||
select(ChatMessage.chat_id, ChatMessage.created_at)
|
||||
.join(Chat, Chat.id == ChatMessage.chat_id)
|
||||
.where(*chat_filter, ChatMessage.created_at.isnot(None))
|
||||
.order_by(ChatMessage.chat_id, ChatMessage.created_at.asc())
|
||||
)
|
||||
messages_result = await session.execute(messages_stmt)
|
||||
last_message_at_by_chat: dict[str, int] = {}
|
||||
active_seconds_by_chat: dict[str, int] = {}
|
||||
|
||||
for chat_id, created_at in messages_result.all():
|
||||
timestamp = int(created_at / 1000) if created_at > 10_000_000_000 else int(created_at)
|
||||
last_message_at = last_message_at_by_chat.get(chat_id)
|
||||
if last_message_at is not None:
|
||||
delta = timestamp - last_message_at
|
||||
if 0 < delta <= ACTIVE_CHAT_GAP_SECONDS:
|
||||
active_seconds_by_chat[chat_id] = active_seconds_by_chat.get(chat_id, 0) + delta
|
||||
last_message_at_by_chat[chat_id] = timestamp
|
||||
|
||||
return {
|
||||
'total_chats': total_chats,
|
||||
'longest_chat_seconds': max(active_seconds_by_chat.values(), default=0),
|
||||
}
|
||||
|
||||
# list user conversations
|
||||
async def get_chats_by_user_id(
|
||||
self,
|
||||
|
|
@ -1316,6 +1448,7 @@ class ChatTable:
|
|||
) -> ChatListResponse:
|
||||
async with get_async_db_context(db) as session:
|
||||
stmt = select(Chat).filter_by(user_id=user_id)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
|
||||
if filter:
|
||||
if filter.get('updated_at'):
|
||||
|
|
@ -1359,11 +1492,11 @@ class ChatTable:
|
|||
self, user_id: str, db: AsyncSession | None = None
|
||||
) -> list[ChatTitleIdResponse]:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(
|
||||
select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at)
|
||||
.filter_by(user_id=user_id, pinned=True, archived=False)
|
||||
.order_by(Chat.updated_at.desc())
|
||||
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
|
||||
user_id=user_id, pinned=True, archived=False
|
||||
)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
|
||||
all_chats = result.all()
|
||||
return [
|
||||
ChatTitleIdResponse.model_validate(
|
||||
|
|
@ -1380,9 +1513,9 @@ class ChatTable:
|
|||
|
||||
async def get_archived_chats_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> list[ChatModel]:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(
|
||||
select(Chat).filter_by(user_id=user_id, archived=True).order_by(Chat.updated_at.desc())
|
||||
)
|
||||
stmt = select(Chat).filter_by(user_id=user_id, archived=True)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
|
||||
return [ChatModel.model_validate(chat) for chat in result.scalars().all()]
|
||||
|
||||
# search user conversations
|
||||
|
|
@ -1453,6 +1586,7 @@ class ChatTable:
|
|||
|
||||
async with get_async_db_context(db) as session:
|
||||
stmt = select(Chat).filter(Chat.user_id == user_id)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
|
||||
if is_archived is not None:
|
||||
stmt = stmt.filter(Chat.archived == is_archived)
|
||||
|
|
@ -1528,9 +1662,23 @@ class ChatTable:
|
|||
postgres_content_sql = """
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM json_array_elements(Chat.chat->'messages') AS message
|
||||
WHERE json_typeof(message->'content') = 'string'
|
||||
AND LOWER(message->>'content') LIKE '%' || :content_key || '%'
|
||||
FROM chat_message AS message
|
||||
WHERE message.chat_id = Chat.id
|
||||
AND message.user_id = Chat.user_id
|
||||
AND json_typeof(message.content) = 'string'
|
||||
AND LOWER(message.content #>> '{}') LIKE '%' || :content_key || '%'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(Chat.chat#>'{history,messages}') AS history_message
|
||||
WHERE json_typeof(history_message.value->'content') = 'string'
|
||||
AND LOWER(history_message.value->>'content') LIKE '%' || :content_key || '%'
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM json_array_elements(Chat.chat->'messages') AS legacy_message
|
||||
WHERE json_typeof(legacy_message->'content') = 'string'
|
||||
AND LOWER(legacy_message->>'content') LIKE '%' || :content_key || '%'
|
||||
)
|
||||
"""
|
||||
|
||||
|
|
@ -1594,6 +1742,7 @@ class ChatTable:
|
|||
.filter_by(folder_id=folder_id, user_id=user_id)
|
||||
.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
||||
.filter_by(archived=False)
|
||||
.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
.order_by(Chat.updated_at.desc(), Chat.id)
|
||||
)
|
||||
|
||||
|
|
@ -1622,16 +1771,21 @@ class ChatTable:
|
|||
folder_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 60,
|
||||
sort_by: str = 'updated_at',
|
||||
sort_dir: str = 'desc',
|
||||
db: AsyncSession | None = None,
|
||||
) -> list[dict]:
|
||||
"""Get chats in a folder across ALL users. Returns dicts with user_id."""
|
||||
async with get_async_db_context(db) as session:
|
||||
sort_column = Chat.title if sort_by == 'title' else Chat.updated_at
|
||||
order_clause = sort_column.asc() if sort_dir == 'asc' else sort_column.desc()
|
||||
stmt = (
|
||||
select(Chat.id, Chat.title, Chat.user_id, Chat.updated_at, Chat.created_at, Chat.last_read_at)
|
||||
.filter_by(folder_id=folder_id)
|
||||
.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
||||
.filter_by(archived=False)
|
||||
.order_by(Chat.updated_at.desc(), Chat.id)
|
||||
.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
.order_by(order_clause, Chat.id)
|
||||
)
|
||||
|
||||
if skip:
|
||||
|
|
@ -1653,6 +1807,22 @@ class ChatTable:
|
|||
for chat in all_chats
|
||||
]
|
||||
|
||||
async def count_all_chats_by_folder_id(
|
||||
self,
|
||||
folder_id: str,
|
||||
db: AsyncSession | None = None,
|
||||
) -> int:
|
||||
async with get_async_db_context(db) as session:
|
||||
stmt = (
|
||||
select(func.count(Chat.id))
|
||||
.filter_by(folder_id=folder_id)
|
||||
.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
||||
.filter_by(archived=False)
|
||||
.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one()
|
||||
|
||||
async def get_chats_by_folder_ids_and_user_id(
|
||||
self, folder_ids: list[str], user_id: str, db: AsyncSession | None = None
|
||||
) -> list[ChatModel]:
|
||||
|
|
@ -1662,6 +1832,7 @@ class ChatTable:
|
|||
.filter(Chat.folder_id.in_(folder_ids), Chat.user_id == user_id)
|
||||
.filter(or_(Chat.pinned == False, Chat.pinned == None))
|
||||
.filter_by(archived=False)
|
||||
.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
.order_by(Chat.updated_at.desc())
|
||||
)
|
||||
|
||||
|
|
@ -1707,6 +1878,7 @@ class ChatTable:
|
|||
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
|
||||
user_id=user_id
|
||||
)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
tag_id = tag_name.replace(' ', '_').lower()
|
||||
|
||||
bind = await session.connection()
|
||||
|
|
@ -1769,6 +1941,7 @@ class ChatTable:
|
|||
) -> int:
|
||||
async with get_async_db_context(db) as session:
|
||||
stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, archived=False)
|
||||
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
|
||||
tag_id = tag_name.replace(' ', '_').lower()
|
||||
|
||||
bind = await session.connection()
|
||||
|
|
@ -1816,7 +1989,8 @@ class ChatTable:
|
|||
self, folder_id: str, user_id: str, db: AsyncSession | None = None
|
||||
) -> int:
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(select(func.count(Chat.id)).filter_by(user_id=user_id, folder_id=folder_id))
|
||||
stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, folder_id=folder_id)
|
||||
result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True)))
|
||||
count = result.scalar()
|
||||
|
||||
log.info(f"Count of chats for folder '{folder_id}': {count}")
|
||||
|
|
@ -1829,9 +2003,8 @@ class ChatTable:
|
|||
return 0
|
||||
|
||||
async with get_async_db_context(db) as session:
|
||||
result = await session.execute(
|
||||
select(func.count(Chat.id)).filter(Chat.user_id == user_id, Chat.folder_id.in_(folder_ids))
|
||||
)
|
||||
stmt = select(func.count(Chat.id)).filter(Chat.user_id == user_id, Chat.folder_id.in_(folder_ids))
|
||||
result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True)))
|
||||
count = result.scalar()
|
||||
|
||||
log.info(f"Count of chats for folders '{folder_ids}': {count}")
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ API_CONFIG_FIELDS = (
|
|||
'azure',
|
||||
'api_version',
|
||||
'extra_params',
|
||||
'passthrough_params',
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import time
|
|||
|
||||
# local imports
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.models.users import UserResponse, Users
|
||||
from open_webui.models.users import User, UserResponse, Users, UserSettings
|
||||
from open_webui.utils.valves import decrypt_valves, encrypt_valves
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Boolean, Column, Index, String, Text, delete, select, update
|
||||
|
|
@ -275,6 +275,12 @@ class FunctionsTable:
|
|||
result = await db.execute(select(Function).filter_by(type='filter', is_active=True, is_global=True))
|
||||
return [FunctionModel.model_validate(function) for function in result.scalars().all()]
|
||||
|
||||
async def get_active_filter_ids(self, db: AsyncSession | None = None) -> list[tuple[str, bool]]:
|
||||
"""Return (id, is_global) for active filters without fetching plugin source."""
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Function.id, Function.is_global).filter_by(type='filter', is_active=True))
|
||||
return [(id, bool(is_global)) for id, is_global in result.all()]
|
||||
|
||||
async def get_global_action_functions(self, db: AsyncSession | None = None) -> list[FunctionModel]:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Function).filter_by(type='action', is_active=True, is_global=True))
|
||||
|
|
@ -283,13 +289,15 @@ class FunctionsTable:
|
|||
async def get_function_valves_by_id(self, id: str, db: AsyncSession | None = None) -> dict | None:
|
||||
async with get_async_db_context(db) as db:
|
||||
try:
|
||||
function = await db.get(Function, id)
|
||||
return decrypt_valves(function.valves if function else None)
|
||||
result = await db.execute(select(Function.valves).filter_by(id=id))
|
||||
return decrypt_valves(result.scalar_one_or_none())
|
||||
except Exception as e:
|
||||
log.exception(f'Error getting function valves by id {id}: {e}')
|
||||
return None
|
||||
|
||||
async def get_function_valves_by_ids(self, ids: list[str], db: AsyncSession | None = None) -> dict[str, dict]:
|
||||
async def get_function_valves_by_ids(
|
||||
self, ids: list[str], db: AsyncSession | None = None
|
||||
) -> dict[str, dict]:
|
||||
"""
|
||||
Batch fetch valves for multiple functions in a single query.
|
||||
Returns a dict mapping function_id -> valves dict.
|
||||
|
|
@ -300,8 +308,7 @@ class FunctionsTable:
|
|||
try:
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(Function.id, Function.valves).filter(Function.id.in_(ids)))
|
||||
functions = result.all()
|
||||
return {f.id: decrypt_valves(f.valves) for f in functions}
|
||||
return {id: decrypt_valves(valves) for id, valves in result.all()}
|
||||
except Exception as e:
|
||||
log.exception(f'Error batch-fetching function valves: {e}')
|
||||
return {}
|
||||
|
|
@ -347,8 +354,11 @@ class FunctionsTable:
|
|||
self, id: str, user_id: str, db: AsyncSession | None = None
|
||||
) -> dict | None:
|
||||
try:
|
||||
user = await Users.get_user_by_id(user_id, db=db)
|
||||
user_settings = user.settings.model_dump() if user.settings else {}
|
||||
async with get_async_db_context(db) as db:
|
||||
result = await db.execute(select(User.settings).filter_by(id=user_id))
|
||||
settings = result.scalar_one_or_none()
|
||||
|
||||
user_settings = UserSettings(**settings).model_dump() if settings else {}
|
||||
|
||||
# Check if user has "functions" and "valves" settings
|
||||
if 'functions' not in user_settings:
|
||||
|
|
|
|||
|
|
@ -149,6 +149,7 @@ class KnowledgeDirectoryForm(BaseModel):
|
|||
####################
|
||||
class KnowledgeUserModel(KnowledgeModel):
|
||||
user: Optional[UserResponse] = None
|
||||
file_count: int | None = None
|
||||
|
||||
|
||||
class KnowledgeResponse(KnowledgeModel):
|
||||
|
|
@ -322,6 +323,14 @@ class KnowledgeTable:
|
|||
|
||||
knowledge_ids = [kb.id for kb, _ in items]
|
||||
grants_map = await AccessGrants.get_grants_by_resources('knowledge', knowledge_ids, db=db)
|
||||
file_counts = {}
|
||||
if knowledge_ids:
|
||||
file_count_result = await db.execute(
|
||||
select(KnowledgeFile.knowledge_id, func.count(KnowledgeFile.id))
|
||||
.where(KnowledgeFile.knowledge_id.in_(knowledge_ids))
|
||||
.group_by(KnowledgeFile.knowledge_id)
|
||||
)
|
||||
file_counts = dict(file_count_result.all())
|
||||
|
||||
knowledge_bases = []
|
||||
for knowledge_base, user in items:
|
||||
|
|
@ -336,6 +345,7 @@ class KnowledgeTable:
|
|||
)
|
||||
).model_dump(),
|
||||
'user': (UserModel.model_validate(user).model_dump() if user else None),
|
||||
'file_count': file_counts.get(knowledge_base.id, 0),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ from __future__ import annotations
|
|||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
from copy import deepcopy
|
||||
from typing import Any, Optional
|
||||
|
||||
from open_webui.internal.db import Base, JSONField, get_async_db_context
|
||||
from open_webui.models.access_grants import AccessGrantModel, AccessGrants
|
||||
|
|
@ -22,6 +23,38 @@ log = logging.getLogger(__name__)
|
|||
_warned_profile_urls: set[str] = set()
|
||||
|
||||
|
||||
def strip_extracted_content_from_model_knowledge(knowledge: Any) -> Any:
|
||||
"""Drop duplicated extracted text from ModelMeta.knowledge."""
|
||||
if not isinstance(knowledge, list):
|
||||
return knowledge
|
||||
|
||||
sanitized = []
|
||||
|
||||
for item in knowledge:
|
||||
if not isinstance(item, dict):
|
||||
sanitized.append(item)
|
||||
continue
|
||||
|
||||
next_item = item
|
||||
data = item.get('data')
|
||||
if isinstance(data, dict) and 'content' in data:
|
||||
next_item = deepcopy(item)
|
||||
next_item.get('data', {}).pop('content', None)
|
||||
|
||||
file = next_item.get('file')
|
||||
file_data = file.get('data') if isinstance(file, dict) else None
|
||||
if isinstance(file_data, dict) and 'content' in file_data:
|
||||
if next_item is item:
|
||||
next_item = deepcopy(item)
|
||||
file = next_item.get('file')
|
||||
file_data = file.get('data') if isinstance(file, dict) else None
|
||||
file_data.pop('content', None)
|
||||
|
||||
sanitized.append(next_item)
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
# --- Models DB Schema ---
|
||||
|
||||
|
||||
|
|
@ -37,6 +70,7 @@ class ModelMeta(BaseModel):
|
|||
profile_image_url: str | None = None
|
||||
description: str | None = Field(default=None, description='User-facing description of the model.')
|
||||
capabilities: dict | None = None
|
||||
knowledge: list[Any] | None = None
|
||||
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
|
|
@ -56,6 +90,11 @@ class ModelMeta(BaseModel):
|
|||
)
|
||||
return None
|
||||
|
||||
@field_validator('knowledge', mode='before')
|
||||
@classmethod
|
||||
def strip_knowledge_content(cls, v):
|
||||
return strip_extracted_content_from_model_knowledge(v)
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def normalize_tags(cls, data):
|
||||
|
|
@ -152,6 +191,14 @@ class ModelsTable:
|
|||
access_grants: list[AccessGrantModel | None] = None,
|
||||
db: AsyncSession | None = None,
|
||||
) -> ModelModel:
|
||||
if isinstance(model.meta, dict):
|
||||
knowledge = model.meta.get('knowledge')
|
||||
stripped_knowledge = strip_extracted_content_from_model_knowledge(knowledge)
|
||||
if stripped_knowledge != knowledge:
|
||||
model.meta = {**model.meta, 'knowledge': stripped_knowledge}
|
||||
if db is not None:
|
||||
await db.commit()
|
||||
|
||||
model_data = ModelModel.model_validate(model).model_dump(exclude={'access_grants'})
|
||||
model_data['access_grants'] = (
|
||||
access_grants if access_grants is not None else await self._get_access_grants(model_data['id'], db=db)
|
||||
|
|
|
|||
|
|
@ -312,6 +312,9 @@ class NoteTable:
|
|||
if 'meta' in form_data:
|
||||
note.meta = {**note.meta, **form_data['meta']}
|
||||
|
||||
if not db.is_modified(note) and 'access_grants' not in form_data:
|
||||
return await self._to_note_model(note, db=db)
|
||||
|
||||
if 'access_grants' in form_data:
|
||||
await AccessGrants.set_access_grants('note', id, form_data['access_grants'], db=db)
|
||||
|
||||
|
|
|
|||
|
|
@ -259,7 +259,26 @@ class SkillsTable:
|
|||
permission='read',
|
||||
)
|
||||
|
||||
stmt = stmt.order_by(Skill.updated_at.desc())
|
||||
order_by = filter.get('order_by')
|
||||
direction = filter.get('direction')
|
||||
|
||||
if order_by == 'name':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(Skill.name.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(Skill.name.desc())
|
||||
elif order_by == 'created_at':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(Skill.created_at.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(Skill.created_at.desc())
|
||||
elif order_by == 'updated_at':
|
||||
if direction == 'asc':
|
||||
stmt = stmt.order_by(Skill.updated_at.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(Skill.updated_at.desc())
|
||||
else:
|
||||
stmt = stmt.order_by(Skill.updated_at.desc())
|
||||
|
||||
# Count BEFORE pagination
|
||||
count_result = await db.execute(select(func.count()).select_from(stmt.subquery()))
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ from open_webui.env import (
|
|||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.files import Files
|
||||
from open_webui.models.folders import Folders
|
||||
from open_webui.models.knowledge import Knowledges
|
||||
from open_webui.models.notes import Notes
|
||||
from open_webui.models.config import Config
|
||||
|
|
@ -48,6 +49,7 @@ from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
|||
from open_webui.retrieval.vector.main import GetResult, SearchResult
|
||||
from open_webui.retrieval.web.utils import get_web_loader
|
||||
from open_webui.utils.access_control.files import has_access_to_file
|
||||
from open_webui.utils.access_control.folders import has_folder_access
|
||||
from open_webui.utils.headers import include_user_info_headers
|
||||
from open_webui.utils.misc import get_message_list
|
||||
|
||||
|
|
@ -1323,6 +1325,23 @@ async def get_sources_from_items(
|
|||
bypass_embedding_and_retrieval = await Config.get('rag.bypass_embedding_and_retrieval')
|
||||
extracted_collections = []
|
||||
query_results = []
|
||||
folder_items = set()
|
||||
expanded_folders = set()
|
||||
|
||||
items = list(items)
|
||||
for item in items:
|
||||
if item.get('type') != 'folder' or not user:
|
||||
continue
|
||||
folder_id = item.get('id')
|
||||
if not folder_id or folder_id in expanded_folders:
|
||||
continue
|
||||
expanded_folders.add(folder_id)
|
||||
|
||||
folder = await Folders.get_folder_by_id(folder_id)
|
||||
if folder and (user.role == 'admin' or await has_folder_access(user.id, folder, 'read', db=None)):
|
||||
files = (folder.data or {}).get('files', [])
|
||||
folder_items.update((entry.get('type'), entry.get('id')) for entry in files if isinstance(entry, dict))
|
||||
items.extend(files)
|
||||
|
||||
for item in items:
|
||||
query_result = None
|
||||
|
|
@ -1429,6 +1448,7 @@ async def get_sources_from_items(
|
|||
user.role == 'admin'
|
||||
or file_object.user_id == user.id
|
||||
or await has_access_to_file(item.get('id'), 'read', user)
|
||||
or ('file', item.get('id')) in folder_items
|
||||
):
|
||||
query_result = {
|
||||
'documents': [[file_object.data.get('content', '')]],
|
||||
|
|
@ -1459,6 +1479,7 @@ async def get_sources_from_items(
|
|||
user.role == 'admin'
|
||||
or file_object.user_id == user.id
|
||||
or await has_access_to_file(file_id, 'read', user)
|
||||
or ('file', file_id) in folder_items
|
||||
):
|
||||
if item.get('legacy'):
|
||||
collection_names.append(f'{file_id}')
|
||||
|
|
@ -1478,6 +1499,7 @@ async def get_sources_from_items(
|
|||
resource_id=knowledge_base.id,
|
||||
permission='read',
|
||||
)
|
||||
or ('collection', item.get('id')) in folder_items
|
||||
):
|
||||
if (knowledge_base.meta or {}).get('source') == 'external':
|
||||
query_result = await retrieve_external_knowledge(
|
||||
|
|
@ -1500,6 +1522,7 @@ async def get_sources_from_items(
|
|||
resource_id=knowledge_base.id,
|
||||
permission='read',
|
||||
)
|
||||
or ('collection', item.get('id')) in folder_items
|
||||
):
|
||||
files = await Knowledges.get_files_by_id(knowledge_base.id)
|
||||
|
||||
|
|
@ -1570,7 +1593,7 @@ async def get_sources_from_items(
|
|||
continue
|
||||
|
||||
# Filter out collections the user cannot read
|
||||
if user:
|
||||
if user and (item.get('type'), item.get('id')) not in folder_items:
|
||||
collection_names = await filter_accessible_collections(collection_names, user)
|
||||
if not collection_names:
|
||||
log.debug(f'access denied for all collections in item {item}')
|
||||
|
|
|
|||
|
|
@ -146,7 +146,17 @@ class MilvusClient(VectorDBBase):
|
|||
index_params['params'] = {'nlist': MILVUS_IVF_FLAT_NLIST}
|
||||
|
||||
collection.create_index('vector', index_params)
|
||||
collection.create_index(RESOURCE_ID_FIELD)
|
||||
try:
|
||||
# A Milvus server auto-selects the scalar index type; embedded
|
||||
# Milvus Lite requires an explicit one.
|
||||
collection.create_index(RESOURCE_ID_FIELD)
|
||||
except MilvusException:
|
||||
try:
|
||||
collection.create_index(RESOURCE_ID_FIELD, {'index_type': 'INVERTED'})
|
||||
except MilvusException as e:
|
||||
# The index only accelerates resource_id filters; never fail
|
||||
# collection creation over it.
|
||||
log.warning(f'Could not create {RESOURCE_ID_FIELD} index on {mt_collection_name}: {e}')
|
||||
log.info(f'Created shared collection: {mt_collection_name}')
|
||||
return collection
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ from open_webui.utils.models import (
|
|||
get_all_models,
|
||||
get_filtered_models,
|
||||
)
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
from pydantic import BaseModel, field_validator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
|
@ -915,7 +914,6 @@ async def get_pinned_channel_messages(
|
|||
|
||||
|
||||
async def send_notification(request, channel, message, active_user_ids, db=None):
|
||||
name = request.app.state.WEBUI_NAME
|
||||
webui_url = await Config.get('webui.url')
|
||||
enable_user_webhooks = await Config.get('ui.enable_user_webhooks')
|
||||
|
||||
|
|
@ -923,23 +921,29 @@ async def send_notification(request, channel, message, active_user_ids, db=None)
|
|||
|
||||
# Batch fetch channel members in 1 query (fixes N+1)
|
||||
member_ids = {m.user_id for m in await Channels.get_members_by_channel_id(channel.id, db=db)}
|
||||
url = f'{webui_url}/channels/{channel.id}'
|
||||
|
||||
for u in users:
|
||||
if (u.id not in active_user_ids) and u.id in member_ids:
|
||||
if enable_user_webhooks and u.settings:
|
||||
webhook_url = u.settings.ui.get('notifications', {}).get('webhook_url', None)
|
||||
if webhook_url:
|
||||
await post_webhook(
|
||||
name,
|
||||
webhook_url,
|
||||
f'#{channel.name} - {webui_url}/channels/{channel.id}\n\n{message.content}',
|
||||
{
|
||||
'action': 'channel',
|
||||
'message': message.content,
|
||||
'title': channel.name,
|
||||
'url': f'{webui_url}/channels/{channel.id}',
|
||||
},
|
||||
)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CHANNEL_MESSAGE,
|
||||
subject_id=channel.id,
|
||||
subject_type='channel',
|
||||
data={
|
||||
'user_id': u.id,
|
||||
'channel_id': channel.id,
|
||||
'message_id': message.id,
|
||||
'sender_id': message.user_id,
|
||||
'content': message.content,
|
||||
'message': f'#{channel.name} - {url}\n\n{message.content}',
|
||||
'content_preview': message.content[:300],
|
||||
'title': channel.name,
|
||||
'url': url,
|
||||
},
|
||||
message=channel.name,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from open_webui.events import EVENTS, publish_event
|
|||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.chat_messages import ChatMessages
|
||||
from open_webui.models.chats import (
|
||||
AggregateChatStats,
|
||||
ChatBody,
|
||||
|
|
@ -26,6 +27,7 @@ from open_webui.models.chats import (
|
|||
ChatStatsExport,
|
||||
ChatTitleIdResponse,
|
||||
ChatUsageStatsListResponse,
|
||||
is_internal_chat,
|
||||
MessageStats,
|
||||
)
|
||||
from open_webui.models.folders import Folders
|
||||
|
|
@ -36,7 +38,8 @@ from open_webui.tasks import 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
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.context_compaction import compact_chat_branch
|
||||
from open_webui.utils.chat_fork import build_fork_history
|
||||
from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage
|
||||
from open_webui.utils.misc import get_message_list
|
||||
from open_webui.utils.models import get_all_models
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -51,13 +54,28 @@ SEARCH_FILTER_PREFIXES = ('tag:', 'folder:', 'pinned:', 'archived:', 'shared:')
|
|||
CHAT_CONFIG_KEYS = {
|
||||
'ENABLE_CONTEXT_COMPACTION': 'chat.context_compaction.enable',
|
||||
'CONTEXT_COMPACTION_TOKEN_THRESHOLD': 'chat.context_compaction.token_threshold',
|
||||
'CONTEXT_COMPACTION_TOKEN_CAP': 'chat.context_compaction.token_cap',
|
||||
'CONTEXT_COMPACTION_PROMPT_TEMPLATE': 'chat.context_compaction.prompt_template',
|
||||
}
|
||||
|
||||
|
||||
async def add_active_state_to_chat_list(
|
||||
request: Request, chat_list: list[ChatTitleIdResponse]
|
||||
) -> list[ChatTitleIdResponse]:
|
||||
for chat in chat_list:
|
||||
chat.active = False
|
||||
if not await has_active_tasks(request.app.state.redis, chat.id):
|
||||
continue
|
||||
|
||||
chat.active = await ChatMessages.has_unfinished_assistant_by_chat_id(chat.id)
|
||||
|
||||
return chat_list
|
||||
|
||||
|
||||
class ChatConfigForm(BaseModel):
|
||||
ENABLE_CONTEXT_COMPACTION: bool
|
||||
CONTEXT_COMPACTION_TOKEN_THRESHOLD: int
|
||||
CONTEXT_COMPACTION_TOKEN_CAP: int | None = None
|
||||
CONTEXT_COMPACTION_PROMPT_TEMPLATE: str
|
||||
|
||||
|
||||
|
|
@ -104,7 +122,10 @@ def chat_search_snippet(chat: dict, search_text: str, max_length: int = 200) ->
|
|||
|
||||
async def get_chat_config_values() -> dict:
|
||||
values = await Config.get_many(*CHAT_CONFIG_KEYS.values())
|
||||
return {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values}
|
||||
config = {field: values[storage_key] for field, storage_key in CHAT_CONFIG_KEYS.items() if storage_key in values}
|
||||
if config.get('CONTEXT_COMPACTION_TOKEN_CAP') is None:
|
||||
config['CONTEXT_COMPACTION_TOKEN_CAP'] = config.get('CONTEXT_COMPACTION_TOKEN_THRESHOLD', 80000)
|
||||
return config
|
||||
|
||||
|
||||
def chat_config_updates(data: dict) -> dict:
|
||||
|
|
@ -131,6 +152,7 @@ async def require_chat_import_permission(request: Request, user, db: AsyncSessio
|
|||
@router.get('/', response_model=list[ChatTitleIdResponse])
|
||||
@router.get('/list', response_model=list[ChatTitleIdResponse])
|
||||
async def get_session_user_chat_list(
|
||||
request: Request,
|
||||
user=Depends(get_verified_user),
|
||||
page: int | None = None,
|
||||
include_pinned: bool | None = False,
|
||||
|
|
@ -142,7 +164,7 @@ async def get_session_user_chat_list(
|
|||
limit = 60
|
||||
skip = (page - 1) * limit
|
||||
|
||||
return await Chats.get_chat_title_id_list_by_user_id(
|
||||
chats = await Chats.get_chat_title_id_list_by_user_id(
|
||||
user.id,
|
||||
include_folders=include_folders,
|
||||
include_pinned=include_pinned,
|
||||
|
|
@ -151,12 +173,13 @@ async def get_session_user_chat_list(
|
|||
db=db,
|
||||
)
|
||||
else:
|
||||
return await Chats.get_chat_title_id_list_by_user_id(
|
||||
chats = await Chats.get_chat_title_id_list_by_user_id(
|
||||
user.id,
|
||||
include_folders=include_folders,
|
||||
include_pinned=include_pinned,
|
||||
db=db,
|
||||
)
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
|
@ -599,6 +622,7 @@ async def delete_all_user_chats(
|
|||
|
||||
@router.get('/list/user/{user_id}', response_model=list[ChatTitleIdResponse])
|
||||
async def get_user_chat_list_by_user_id(
|
||||
request: Request,
|
||||
user_id: str,
|
||||
page: int | None = None,
|
||||
query: str | None = None,
|
||||
|
|
@ -623,9 +647,10 @@ async def get_user_chat_list_by_user_id(
|
|||
if direction:
|
||||
filter['direction'] = direction
|
||||
|
||||
return await Chats.get_chat_list_by_user_id(
|
||||
chats = await Chats.get_chat_list_by_user_id(
|
||||
user_id, include_archived=True, filter=filter, skip=skip, limit=limit, db=db
|
||||
)
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -712,11 +737,13 @@ async def get_chat_config(user=Depends(get_admin_user)):
|
|||
@router.post('/config', response_model=ChatConfigForm)
|
||||
async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user)):
|
||||
threshold = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_THRESHOLD))
|
||||
token_cap = max(1, int(form_data.CONTEXT_COMPACTION_TOKEN_CAP or threshold))
|
||||
await Config.upsert(
|
||||
chat_config_updates(
|
||||
{
|
||||
**form_data.model_dump(),
|
||||
'CONTEXT_COMPACTION_TOKEN_THRESHOLD': threshold,
|
||||
'CONTEXT_COMPACTION_TOKEN_CAP': token_cap,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
@ -730,6 +757,7 @@ async def set_chat_config(form_data: ChatConfigForm, user=Depends(get_admin_user
|
|||
|
||||
@router.get('/search', response_model=list[ChatTitleIdResponse])
|
||||
async def search_user_chats(
|
||||
request: Request,
|
||||
text: str,
|
||||
page: int | None = None,
|
||||
user=Depends(get_verified_user),
|
||||
|
|
@ -755,7 +783,7 @@ async def search_user_chats(
|
|||
log.debug(f'deleting tag: {tag_id}')
|
||||
await Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db)
|
||||
|
||||
return chat_list
|
||||
return await add_active_state_to_chat_list(request, chat_list)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -778,8 +806,9 @@ async def get_chats_by_folder_id(
|
|||
]
|
||||
|
||||
|
||||
@router.get('/folder/{folder_id}/list')
|
||||
@router.get('/folder/{folder_id}/list', response_model=list[ChatTitleIdResponse])
|
||||
async def get_chat_list_by_folder_id(
|
||||
request: Request,
|
||||
folder_id: str,
|
||||
page: int | None = 1,
|
||||
user=Depends(get_verified_user),
|
||||
|
|
@ -790,10 +819,7 @@ async def get_chat_list_by_folder_id(
|
|||
skip = (page - 1) * limit
|
||||
|
||||
chats = await Chats.get_chats_by_folder_id_and_user_id(folder_id, user.id, skip=skip, limit=limit, db=db)
|
||||
return [
|
||||
{'title': chat.title, 'id': chat.id, 'updated_at': chat.updated_at, 'last_read_at': chat.last_read_at}
|
||||
for chat in chats
|
||||
]
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
|
@ -806,8 +832,11 @@ async def get_chat_list_by_folder_id(
|
|||
|
||||
|
||||
@router.get('/pinned', response_model=list[ChatTitleIdResponse])
|
||||
async def get_user_pinned_chats(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
return await Chats.get_pinned_chats_by_user_id(user.id, db=db)
|
||||
async def get_user_pinned_chats(
|
||||
request: Request, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)
|
||||
):
|
||||
chats = await Chats.get_pinned_chats_by_user_id(user.id, db=db)
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -900,6 +929,7 @@ async def get_all_user_chats_in_db(user=Depends(get_admin_user), db: AsyncSessio
|
|||
|
||||
@router.get('/archived', response_model=list[ChatTitleIdResponse])
|
||||
async def get_archived_session_user_chat_list(
|
||||
request: Request,
|
||||
page: int | None = None,
|
||||
query: str | None = None,
|
||||
order_by: str | None = None,
|
||||
|
|
@ -921,13 +951,14 @@ async def get_archived_session_user_chat_list(
|
|||
if direction:
|
||||
filter['direction'] = direction
|
||||
|
||||
return await Chats.get_archived_chat_list_by_user_id(
|
||||
chats = await Chats.get_archived_chat_list_by_user_id(
|
||||
user.id,
|
||||
filter=filter,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
db=db,
|
||||
)
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -1095,6 +1126,7 @@ class TagFilterForm(TagForm):
|
|||
|
||||
@router.post('/tags', response_model=list[ChatTitleIdResponse])
|
||||
async def get_user_chat_list_by_tag_name(
|
||||
request: Request,
|
||||
form_data: TagFilterForm,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
|
|
@ -1105,7 +1137,7 @@ async def get_user_chat_list_by_tag_name(
|
|||
if len(chats) == 0:
|
||||
await Tags.delete_tag_by_name_and_user_id(form_data.name, user.id, db=db)
|
||||
|
||||
return chats
|
||||
return await add_active_state_to_chat_list(request, chats)
|
||||
|
||||
|
||||
############################
|
||||
|
|
@ -1136,7 +1168,8 @@ async def compact_chat_by_id(
|
|||
|
||||
history = (chat.chat or {}).get('history') or {}
|
||||
messages_map = await Chats.get_messages_map_by_chat_id(id)
|
||||
message_list = get_message_list(messages_map or history.get('messages') or {}, history.get('currentId'))
|
||||
current_message_id = chat.current_message_id or history.get('currentId')
|
||||
message_list = get_message_list(messages_map or history.get('messages') or {}, current_message_id)
|
||||
model_id = (form_data.model if form_data else None) or next(
|
||||
(message.get('model') for message in reversed(message_list) if message.get('model')),
|
||||
None,
|
||||
|
|
@ -1149,6 +1182,7 @@ async def compact_chat_by_id(
|
|||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='No model found for context compaction.')
|
||||
|
||||
result = await compact_chat_branch(request, user, chat, model_id, request.app.state.MODELS)
|
||||
result['context_usage'] = await get_chat_context_usage(chat, model_id)
|
||||
if result.get('compacted'):
|
||||
await publish_event(
|
||||
request,
|
||||
|
|
@ -1171,8 +1205,10 @@ async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSess
|
|||
|
||||
if not chat:
|
||||
# Check if user has access via access grants (shared_chat grants)
|
||||
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
if user.role == 'admin':
|
||||
candidate = await Chats.get_chat_by_id(id, db=db)
|
||||
if ENABLE_ADMIN_CHAT_ACCESS or (candidate and is_internal_chat(candidate.meta)):
|
||||
chat = candidate
|
||||
else:
|
||||
has_grant = await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
|
|
@ -1193,7 +1229,9 @@ async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSess
|
|||
chat = candidate
|
||||
|
||||
if chat:
|
||||
return ChatResponse(**chat.model_dump())
|
||||
data = ChatResponse(**chat.model_dump()).model_dump()
|
||||
data['context_usage'] = await get_chat_context_usage(chat)
|
||||
return data
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
|
|
@ -1423,6 +1461,13 @@ async def delete_chat_by_id(
|
|||
# before deleting the chat to prevent orphaned requests.
|
||||
await stop_item_tasks(request.app.state.redis, id)
|
||||
|
||||
async def delete_internal_children(owner_id: str) -> None:
|
||||
child_ids = await Chats.get_internal_chat_ids_by_parent_id(id, owner_id)
|
||||
for child_id in child_ids:
|
||||
await stop_item_tasks(request.app.state.redis, child_id)
|
||||
await Chats.delete_chat_by_id_and_user_id(child_id, owner_id)
|
||||
await stop_item_tasks(request.app.state.redis, id)
|
||||
|
||||
if user.role == 'admin':
|
||||
chat = await Chats.get_chat_by_id(id, db=db)
|
||||
if not chat:
|
||||
|
|
@ -1431,6 +1476,7 @@ async def delete_chat_by_id(
|
|||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
|
||||
await delete_internal_children(chat.user_id)
|
||||
|
||||
result = await Chats.delete_chat_by_id(id, db=db)
|
||||
|
||||
|
|
@ -1457,6 +1503,7 @@ async def delete_chat_by_id(
|
|||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
|
||||
await delete_internal_children(user.id)
|
||||
|
||||
result = await Chats.delete_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if result:
|
||||
|
|
@ -1519,6 +1566,96 @@ class CloneForm(BaseModel):
|
|||
title: str | None = None
|
||||
|
||||
|
||||
class ForkForm(BaseModel):
|
||||
message_id: str | None = None
|
||||
|
||||
|
||||
@router.post('/{id}/fork', response_model=ChatResponse | None)
|
||||
async def fork_chat_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
form_data: ForkForm | None = None,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
await require_chat_import_permission(request, user, db)
|
||||
|
||||
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
|
||||
if not chat:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
if await has_active_tasks(request.app.state.redis, id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Wait for the current response to finish before forking.',
|
||||
)
|
||||
|
||||
history = (chat.chat or {}).get('history') or {}
|
||||
messages_map = await Chats.get_messages_map_by_chat_id(id) or history.get('messages') or {}
|
||||
if any(
|
||||
message.get('role') == 'assistant' and message.get('done') is False
|
||||
for message in messages_map.values()
|
||||
if isinstance(message, dict)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail='Wait for the current response to finish before forking.',
|
||||
)
|
||||
|
||||
source_message_id = (form_data.message_id if form_data else None) or chat.current_message_id or history.get('currentId')
|
||||
if not source_message_id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='chat has no messages to fork')
|
||||
|
||||
try:
|
||||
fork_history, fork_messages = build_fork_history(messages_map, source_message_id)
|
||||
except ValueError as exc:
|
||||
detail = str(exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND if detail == 'message not found' else status.HTTP_400_BAD_REQUEST,
|
||||
detail=detail,
|
||||
) from exc
|
||||
|
||||
updated_chat = {**(chat.chat or {})}
|
||||
updated_chat.pop('currentId', None)
|
||||
updated_chat.update(
|
||||
{
|
||||
'originalChatId': chat.id,
|
||||
'branchPointMessageId': source_message_id,
|
||||
'title': f'{chat.title} (fork)',
|
||||
'history': fork_history,
|
||||
'messages': fork_messages,
|
||||
}
|
||||
)
|
||||
meta = {
|
||||
**(chat.meta or {}),
|
||||
'forked_from': chat.id,
|
||||
'forked_from_message_id': source_message_id,
|
||||
}
|
||||
|
||||
fork = await Chats.insert_new_chat(
|
||||
str(uuid4()),
|
||||
user.id,
|
||||
ChatForm(chat=updated_chat, folder_id=chat.folder_id),
|
||||
db=db,
|
||||
internal_meta=meta,
|
||||
)
|
||||
|
||||
if not fork:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
if chat.pinned:
|
||||
fork = await Chats.toggle_chat_pinned_by_id(fork.id, db=db) or fork
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CHAT_CLONED,
|
||||
actor=user,
|
||||
subject_id=fork.id,
|
||||
data={'original_chat_id': id, 'forked_from_message_id': source_message_id},
|
||||
)
|
||||
return ChatResponse(**fork.model_dump())
|
||||
|
||||
|
||||
@router.post('/{id}/clone', response_model=ChatResponse | None)
|
||||
async def clone_chat_by_id(
|
||||
request: Request,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,15 @@ MODELS_CONFIG_KEYS = {
|
|||
'DEFAULT_MODEL_METADATA': 'models.default_metadata',
|
||||
'DEFAULT_MODEL_PARAMS': 'models.default_params',
|
||||
}
|
||||
SUBAGENTS_CONFIG_KEYS = {
|
||||
'ENABLE_SUBAGENTS': 'subagents.enable',
|
||||
'SUBAGENTS_BACKGROUND_ENABLED': 'subagents.background_enabled',
|
||||
'SUBAGENTS_MAX_CONCURRENT': 'subagents.max_concurrent',
|
||||
'SUBAGENTS_MAX_ASYNC': 'subagents.max_async',
|
||||
'SUBAGENTS_MAX_ITERATIONS': 'subagents.max_iterations',
|
||||
'SUBAGENTS_MAX_OUTPUT': 'subagents.max_output',
|
||||
'SUBAGENTS_SYSTEM_PROMPT': 'subagents.system_prompt',
|
||||
}
|
||||
|
||||
|
||||
async def get_config_values(key_map: dict[str, str]) -> dict:
|
||||
|
|
@ -298,10 +307,8 @@ class TerminalServerConnection(BaseModel):
|
|||
|
||||
config: dict | None = None
|
||||
|
||||
# Orchestrator policy fields
|
||||
server_type: str | None = None # "orchestrator", "terminal"
|
||||
server_type: str | None = None
|
||||
policy_id: str | None = None
|
||||
policy: dict | None = None # cached policy data
|
||||
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
|
|
@ -321,7 +328,9 @@ async def set_terminal_servers_config(
|
|||
form_data: TerminalServersConfigForm,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
connections = [connection.model_dump() for connection in form_data.TERMINAL_SERVER_CONNECTIONS]
|
||||
connections = [
|
||||
connection.model_dump(exclude={'policy', 'lifecycle'}) for connection in form_data.TERMINAL_SERVER_CONNECTIONS
|
||||
]
|
||||
await Config.upsert({'terminal_server.connections': connections})
|
||||
|
||||
await set_terminal_servers(request)
|
||||
|
|
@ -391,7 +400,7 @@ class TerminalServerPolicyForm(BaseModel):
|
|||
key: str | None = ''
|
||||
auth_type: str | None = 'bearer'
|
||||
policy_id: str
|
||||
policy_data: dict
|
||||
policy_data: dict | None = None
|
||||
|
||||
|
||||
class TerminalServerLifecycleForm(BaseModel):
|
||||
|
|
@ -399,7 +408,7 @@ class TerminalServerLifecycleForm(BaseModel):
|
|||
key: str | None = ''
|
||||
auth_type: str | None = 'bearer'
|
||||
policy_id: str
|
||||
lifecycle_data: dict
|
||||
lifecycle_data: dict | None = None
|
||||
|
||||
|
||||
class TerminalServerRefreshForm(BaseModel):
|
||||
|
|
@ -416,9 +425,7 @@ class TerminalServerRefreshForm(BaseModel):
|
|||
async def put_terminal_server_policy(
|
||||
request: Request, form_data: TerminalServerPolicyForm, user=Depends(get_admin_user)
|
||||
):
|
||||
"""
|
||||
Proxy a policy PUT to an orchestrator terminal server.
|
||||
"""
|
||||
"""Proxy a policy read or update to an orchestrator terminal server."""
|
||||
base_url = (form_data.url or '').rstrip('/')
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail='Terminal server URL is required')
|
||||
|
|
@ -433,8 +440,12 @@ async def put_terminal_server_policy(
|
|||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
) as session:
|
||||
policy_url = f'{base_url}/api/v1/policies/{form_data.policy_id}'
|
||||
async with session.put(
|
||||
policy_url, headers=headers, json=form_data.policy_data, ssl=AIOHTTP_CLIENT_SESSION_SSL
|
||||
async with session.request(
|
||||
'GET' if form_data.policy_data is None else 'PUT',
|
||||
policy_url,
|
||||
headers=headers,
|
||||
json=form_data.policy_data,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
) as resp:
|
||||
if resp.ok:
|
||||
return await resp.json()
|
||||
|
|
@ -443,17 +454,15 @@ async def put_terminal_server_policy(
|
|||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to save policy to terminal server: {e}')
|
||||
raise HTTPException(status_code=400, detail='Failed to save policy to terminal server')
|
||||
log.debug(f'Failed to access policy on terminal server: {e}')
|
||||
raise HTTPException(status_code=400, detail='Failed to access policy on terminal server')
|
||||
|
||||
|
||||
@router.post('/terminal_servers/lifecycle')
|
||||
async def put_terminal_server_lifecycle(
|
||||
request: Request, form_data: TerminalServerLifecycleForm, user=Depends(get_admin_user)
|
||||
):
|
||||
"""
|
||||
Proxy a policy lifecycle PUT to an orchestrator terminal server.
|
||||
"""
|
||||
"""Proxy a lifecycle read or update to an orchestrator terminal server."""
|
||||
base_url = (form_data.url or '').rstrip('/')
|
||||
if not base_url:
|
||||
raise HTTPException(status_code=400, detail='Terminal server URL is required')
|
||||
|
|
@ -468,7 +477,8 @@ async def put_terminal_server_lifecycle(
|
|||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
) as session:
|
||||
lifecycle_url = f'{base_url}/api/v1/policies/{form_data.policy_id}/lifecycle'
|
||||
async with session.put(
|
||||
async with session.request(
|
||||
'GET' if form_data.lifecycle_data is None else 'PUT',
|
||||
lifecycle_url,
|
||||
headers=headers,
|
||||
json=form_data.lifecycle_data,
|
||||
|
|
@ -481,8 +491,8 @@ async def put_terminal_server_lifecycle(
|
|||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
log.debug(f'Failed to save lifecycle to terminal server: {e}')
|
||||
raise HTTPException(status_code=400, detail='Failed to save lifecycle to terminal server')
|
||||
log.debug(f'Failed to access lifecycle on terminal server: {e}')
|
||||
raise HTTPException(status_code=400, detail='Failed to access lifecycle on terminal server')
|
||||
|
||||
|
||||
@router.post('/terminal_servers/refresh')
|
||||
|
|
@ -754,6 +764,40 @@ async def set_models_config(request: Request, form_data: ModelsConfigForm, user=
|
|||
return values
|
||||
|
||||
|
||||
class SubagentsConfigForm(BaseModel):
|
||||
ENABLE_SUBAGENTS: bool
|
||||
SUBAGENTS_BACKGROUND_ENABLED: bool
|
||||
SUBAGENTS_MAX_CONCURRENT: int
|
||||
SUBAGENTS_MAX_ASYNC: int
|
||||
SUBAGENTS_MAX_ITERATIONS: int
|
||||
SUBAGENTS_MAX_OUTPUT: int
|
||||
SUBAGENTS_SYSTEM_PROMPT: str
|
||||
|
||||
|
||||
@router.get('/subagents', response_model=SubagentsConfigForm)
|
||||
async def get_subagents_config(user=Depends(get_admin_user)):
|
||||
return await get_config_values(SUBAGENTS_CONFIG_KEYS)
|
||||
|
||||
|
||||
@router.post('/subagents', response_model=SubagentsConfigForm)
|
||||
async def set_subagents_config(
|
||||
request: Request,
|
||||
form_data: SubagentsConfigForm,
|
||||
user=Depends(get_admin_user),
|
||||
):
|
||||
await Config.upsert(config_updates(form_data.model_dump(), SUBAGENTS_CONFIG_KEYS))
|
||||
values = await get_config_values(SUBAGENTS_CONFIG_KEYS)
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CONFIG_UPDATED,
|
||||
actor=user,
|
||||
subject_id='subagents',
|
||||
subject_type='config',
|
||||
data={'enabled': values.get('ENABLE_SUBAGENTS')},
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
class PromptSuggestion(BaseModel):
|
||||
title: list[str]
|
||||
content: str
|
||||
|
|
|
|||
|
|
@ -201,21 +201,28 @@ async def process_uploaded_file(
|
|||
f'{knowledge_id}: user {user.id} lacks write access'
|
||||
)
|
||||
else:
|
||||
await Knowledges.add_file_to_knowledge_by_id(
|
||||
knowledge_id=knowledge_id,
|
||||
file_id=file_item.id,
|
||||
user_id=user.id,
|
||||
directory_id=file_metadata.get('directory_id'),
|
||||
)
|
||||
# Keep the generic file status stream open until the
|
||||
# KB-specific vector write and durable link both finish.
|
||||
await Files.update_file_data_by_id(file_item.id, {'status': 'processing'}, db=db_session)
|
||||
await process_file(
|
||||
request,
|
||||
ProcessFileForm(file_id=file_item.id, collection_name=knowledge_id),
|
||||
user=user,
|
||||
db=db_session,
|
||||
)
|
||||
knowledge_file = await Knowledges.add_file_to_knowledge_by_id(
|
||||
knowledge_id=knowledge_id,
|
||||
file_id=file_item.id,
|
||||
user_id=user.id,
|
||||
directory_id=file_metadata.get('directory_id'),
|
||||
db=db_session,
|
||||
)
|
||||
if not knowledge_file:
|
||||
raise Exception(f'Failed to link file {file_item.id} to knowledge {knowledge_id}')
|
||||
log.info(f'Linked file {file_item.id} to knowledge {knowledge_id}')
|
||||
except Exception as e:
|
||||
log.warning(f'Failed to link file {file_item.id} to knowledge {knowledge_id}: {e}')
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
log.error(f'Error processing file: {file_item.id}')
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import uuid
|
|||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from open_webui.config import UPLOAD_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
|
|
@ -390,6 +390,11 @@ async def update_folder_is_expanded_by_id(
|
|||
):
|
||||
await check_folders_permission(request, user, db=db)
|
||||
folder = await Folders.get_folder_by_id_and_user_id(id, user.id, db=db)
|
||||
if not folder:
|
||||
folder = await Folders.get_folder_by_id(id, db=db)
|
||||
if folder and (user.role == 'admin' or await _has_folder_access(user.id, folder, 'read', db)):
|
||||
return folder
|
||||
|
||||
if folder:
|
||||
try:
|
||||
folder = await Folders.update_folder_is_expanded_by_id_and_user_id(
|
||||
|
|
@ -477,6 +482,9 @@ async def update_folder_access_by_id(
|
|||
async def get_shared_folder_chats(
|
||||
request: Request,
|
||||
id: str,
|
||||
page: int | None = Query(None, ge=1),
|
||||
sort_by: str = Query('updated_at'),
|
||||
sort_dir: str = Query('desc'),
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
|
|
@ -500,7 +508,17 @@ async def get_shared_folder_chats(
|
|||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
chats = await Chats.get_all_chats_by_folder_id(id, db=db)
|
||||
limit = 10
|
||||
skip = (page - 1) * limit if page is not None else 0
|
||||
chats = await Chats.get_all_chats_by_folder_id(
|
||||
id,
|
||||
skip=skip,
|
||||
limit=limit if page is not None else 60,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir,
|
||||
db=db,
|
||||
)
|
||||
total = await Chats.count_all_chats_by_folder_id(id, db=db) if page is not None else len(chats)
|
||||
|
||||
# Resolve owner names for display (avatar URLs are constructed client-side)
|
||||
owner_cache: dict[str, str] = {}
|
||||
|
|
@ -511,10 +529,13 @@ async def get_shared_folder_chats(
|
|||
owner_cache[uid] = u.name if u else 'Unknown'
|
||||
chat['owner_name'] = owner_cache[uid]
|
||||
|
||||
return {
|
||||
response = {
|
||||
'chats': [{**chat, 'readonly': chat['user_id'] != user.id} for chat in chats],
|
||||
'folder_permission': 'write' if has_write else 'read',
|
||||
}
|
||||
if page is not None:
|
||||
response.update({'total': total, 'has_more': skip + limit < total})
|
||||
return response
|
||||
|
||||
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import aiohttp
|
|||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from open_webui.config import CACHE_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_PLUGINS
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.functions import (
|
||||
|
|
@ -46,11 +46,17 @@ router = APIRouter()
|
|||
|
||||
@router.get('/', response_model=list[FunctionResponse])
|
||||
async def get_functions(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
if not ENABLE_PLUGINS:
|
||||
return []
|
||||
|
||||
return await Functions.get_functions(db=db)
|
||||
|
||||
|
||||
@router.get('/list', response_model=list[FunctionUserResponse])
|
||||
async def get_function_list(user=Depends(get_admin_user), db: AsyncSession = Depends(get_async_session)):
|
||||
if not ENABLE_PLUGINS:
|
||||
return []
|
||||
|
||||
return await Functions.get_function_list(db=db)
|
||||
|
||||
|
||||
|
|
@ -65,6 +71,9 @@ async def get_functions(
|
|||
user=Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if not ENABLE_PLUGINS:
|
||||
return []
|
||||
|
||||
return await Functions.get_functions(include_valves=include_valves, db=db)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -785,7 +785,11 @@ async def image_generations(
|
|||
images.append({'url': url})
|
||||
return images
|
||||
elif image_config.IMAGE_GENERATION_ENGINE == 'automatic1111' or image_config.IMAGE_GENERATION_ENGINE == '':
|
||||
if form_data.model:
|
||||
# Automatic1111 holds one checkpoint instance-wide, so set_image_model
|
||||
# persists the global default and switches the shared backend. Only an
|
||||
# admin may do that; a non-admin generates on the currently configured
|
||||
# checkpoint. The model field is not a per-user selection on this backend.
|
||||
if form_data.model and user.role == 'admin':
|
||||
await set_image_model(request, form_data.model)
|
||||
|
||||
data = {
|
||||
|
|
|
|||
|
|
@ -119,7 +119,11 @@ async def _verify_knowledge_file_access(
|
|||
PAGE_ITEM_COUNT = 30
|
||||
|
||||
|
||||
@router.get('/list', response_model=ModelAccessListResponse) # do NOT use "/" as path, conflicts with main.py
|
||||
@router.get(
|
||||
'/list',
|
||||
response_model=ModelAccessListResponse,
|
||||
response_model_exclude={'items': {'__all__': {'meta': {'profile_image_url'}}}},
|
||||
) # do NOT use "/" as path, conflicts with main.py
|
||||
async def get_models(
|
||||
query: str | None = None,
|
||||
view_option: str | None = None,
|
||||
|
|
@ -415,7 +419,10 @@ async def import_models(
|
|||
continue
|
||||
|
||||
# Update existing model
|
||||
model_data['meta'] = model_data.get('meta', {})
|
||||
model_data['meta'] = {
|
||||
**existing_model.meta.model_dump(),
|
||||
**(model_data.get('meta') or {}),
|
||||
}
|
||||
model_data['params'] = model_data.get('params', {})
|
||||
|
||||
updated_model = ModelForm(**{**existing_model.model_dump(), **model_data})
|
||||
|
|
@ -721,6 +728,9 @@ async def update_model_by_id(
|
|||
if 'base_model_id' not in form_data.model_fields_set:
|
||||
form_data.base_model_id = model.base_model_id
|
||||
|
||||
if 'profile_image_url' not in form_data.meta.model_fields_set:
|
||||
form_data.meta.profile_image_url = model.meta.profile_image_url
|
||||
|
||||
form_data.access_grants = await filter_allowed_access_grants(
|
||||
await Config.get('user.permissions'),
|
||||
user.id,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
|
||||
from open_webui.config import (
|
||||
|
|
@ -12,6 +13,7 @@ from open_webui.constants import ERROR_MESSAGES
|
|||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.chats import ChatForm, ChatResponse, Chats
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.notes import (
|
||||
|
|
@ -303,6 +305,229 @@ async def get_note_by_id(
|
|||
)
|
||||
|
||||
|
||||
@router.get('/{id}/chat', response_model=ChatResponse)
|
||||
async def get_note_chat_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
log.info('[note-chat] get-or-create requested note_id=%s user_id=%s', id, user.id)
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
note = await Notes.get_note_by_id(id, db=db)
|
||||
if not note:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
if user.role != 'admin' and (
|
||||
user.id != note.user_id
|
||||
and not await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
chat = await Chats.get_internal_chat_by_note_id(note.id, user.id, db=db)
|
||||
if chat:
|
||||
log.info('[note-chat] reusing hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
|
||||
payload = {**(chat.chat or {})}
|
||||
params = {**(payload.get('params') or {})}
|
||||
changed = False
|
||||
|
||||
if params.pop('note_id', None) is not None:
|
||||
changed = True
|
||||
|
||||
system = (
|
||||
f'CONTEXT:\nCurrent note id: {note.id}\n'
|
||||
'This chat is attached to the current note.\n'
|
||||
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
|
||||
'Do not say an edit is done unless replace_note_content succeeds.'
|
||||
)
|
||||
if params.get('system') != system:
|
||||
params['system'] = system
|
||||
changed = True
|
||||
|
||||
if payload.pop('system', None) is not None:
|
||||
changed = True
|
||||
|
||||
payload['params'] = params
|
||||
if changed:
|
||||
updated_chat = await Chats.update_chat_by_id(chat.id, payload, db=db, touch=False)
|
||||
if updated_chat:
|
||||
return updated_chat
|
||||
|
||||
return chat
|
||||
|
||||
chat_id = str(uuid4())
|
||||
chat = await Chats.insert_new_chat(
|
||||
chat_id,
|
||||
user.id,
|
||||
ChatForm(
|
||||
chat={
|
||||
'id': chat_id,
|
||||
'title': 'Chat',
|
||||
'models': [''],
|
||||
'params': {
|
||||
'system': (
|
||||
f'CONTEXT:\nCurrent note id: {note.id}\n'
|
||||
'This chat is attached to the current note.\n'
|
||||
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
|
||||
'Do not say an edit is done unless replace_note_content succeeds.'
|
||||
)
|
||||
},
|
||||
'history': {'messages': {}, 'currentId': None},
|
||||
'messages': [],
|
||||
'tags': [],
|
||||
}
|
||||
),
|
||||
db=db,
|
||||
internal_meta={'internal': True, 'type': 'note', 'note_id': note.id},
|
||||
)
|
||||
if not chat:
|
||||
log.error('[note-chat] failed creating hidden chat note_id=%s user_id=%s', note.id, user.id)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
log.info('[note-chat] created hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
|
||||
return chat
|
||||
|
||||
|
||||
@router.get('/{id}/chats', response_model=list[ChatResponse])
|
||||
async def get_note_chats_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
note = await Notes.get_note_by_id(id, db=db)
|
||||
if not note:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
if user.role != 'admin' and (
|
||||
user.id != note.user_id
|
||||
and not await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
chats = await Chats.get_internal_chats_by_note_id(note.id, user.id, db=db)
|
||||
normalized_chats = []
|
||||
for chat in chats:
|
||||
payload = {**(chat.chat or {})}
|
||||
params = {**(payload.get('params') or {})}
|
||||
changed = False
|
||||
|
||||
if params.pop('note_id', None) is not None:
|
||||
changed = True
|
||||
|
||||
system = (
|
||||
f'CONTEXT:\nCurrent note id: {note.id}\n'
|
||||
'This chat is attached to the current note.\n'
|
||||
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
|
||||
'Do not say an edit is done unless replace_note_content succeeds.'
|
||||
)
|
||||
if params.get('system') != system:
|
||||
params['system'] = system
|
||||
changed = True
|
||||
|
||||
if payload.pop('system', None) is not None:
|
||||
changed = True
|
||||
|
||||
payload['params'] = params
|
||||
if changed:
|
||||
chat = await Chats.update_chat_by_id(chat.id, payload, db=db, touch=False) or chat
|
||||
|
||||
normalized_chats.append(chat)
|
||||
|
||||
return normalized_chats
|
||||
|
||||
|
||||
@router.post('/{id}/chat', response_model=ChatResponse)
|
||||
async def create_note_chat_by_id(
|
||||
request: Request,
|
||||
id: str,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.notes', await Config.get('user.permissions'), db=db
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
)
|
||||
|
||||
note = await Notes.get_note_by_id(id, db=db)
|
||||
if not note:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
if user.role != 'admin' and (
|
||||
user.id != note.user_id
|
||||
and not await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='read',
|
||||
db=db,
|
||||
)
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
chat_id = str(uuid4())
|
||||
chat = await Chats.insert_new_chat(
|
||||
chat_id,
|
||||
user.id,
|
||||
ChatForm(
|
||||
chat={
|
||||
'id': chat_id,
|
||||
'title': 'Chat',
|
||||
'models': [''],
|
||||
'params': {
|
||||
'system': (
|
||||
f'CONTEXT:\nCurrent note id: {note.id}\n'
|
||||
'This chat is attached to the current note.\n'
|
||||
'For edit requests like make this concise, rewrite, enhance, shorten, or update: call view_note then replace_note_content.\n'
|
||||
'Do not say an edit is done unless replace_note_content succeeds.'
|
||||
)
|
||||
},
|
||||
'history': {'messages': {}, 'currentId': None},
|
||||
'messages': [],
|
||||
'tags': [],
|
||||
}
|
||||
),
|
||||
db=db,
|
||||
internal_meta={'internal': True, 'type': 'note', 'note_id': note.id},
|
||||
)
|
||||
if not chat:
|
||||
log.error('[note-chat] failed creating hidden chat note_id=%s user_id=%s', note.id, user.id)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT())
|
||||
|
||||
log.info('[note-chat] created hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id)
|
||||
return chat
|
||||
|
||||
|
||||
############################
|
||||
# UpdateNoteById
|
||||
############################
|
||||
|
|
@ -354,9 +579,15 @@ async def update_note_by_id(
|
|||
pinned_note_ids = await Notes.get_pinned_note_ids(user.id, db=db)
|
||||
note.is_pinned = note.id in pinned_note_ids
|
||||
|
||||
event_data = note.model_dump()
|
||||
if form_data.data is not None:
|
||||
event_data['data'] = {
|
||||
key: note.data.get(key) for key in form_data.data.keys() if note.data is not None and key in note.data
|
||||
}
|
||||
|
||||
await sio.emit(
|
||||
'note-events',
|
||||
note.model_dump(),
|
||||
'events:note',
|
||||
event_data,
|
||||
to=f'note:{note.id}',
|
||||
)
|
||||
|
||||
|
|
|
|||
100
backend/open_webui/routers/notifications.py
Normal file
100
backend/open_webui/routers/notifications.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.utils.access_control import has_permission
|
||||
from open_webui.utils.auth import get_verified_user
|
||||
from open_webui.utils.notifications import (
|
||||
create_target,
|
||||
delete_target,
|
||||
get_notification_event_catalog,
|
||||
list_targets,
|
||||
set_default_target,
|
||||
test_target,
|
||||
update_target,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class NotificationTargetForm(BaseModel):
|
||||
id: str | None = None
|
||||
type: str | None = None
|
||||
enabled: bool | None = None
|
||||
events: list[str] | None = None
|
||||
delivery: str | None = None
|
||||
config: dict[str, Any] | None = None
|
||||
|
||||
|
||||
async def _check_notifications_access(user) -> None:
|
||||
if not await Config.get('ui.enable_user_webhooks'):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
if user.role != 'admin' and not await has_permission(
|
||||
user.id, 'features.webhooks', await Config.get('user.permissions')
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
|
||||
|
||||
|
||||
@router.get('/events')
|
||||
async def get_notification_events(user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
return {'events': get_notification_event_catalog()}
|
||||
|
||||
|
||||
@router.get('/targets')
|
||||
async def get_notification_targets(user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
return await list_targets(user.id)
|
||||
|
||||
|
||||
@router.post('/targets')
|
||||
async def create_notification_target(form_data: NotificationTargetForm, user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
try:
|
||||
return await create_target(user.id, form_data.model_dump(exclude_unset=True))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.put('/targets/{target_id}')
|
||||
async def update_notification_target(
|
||||
target_id: str, form_data: NotificationTargetForm, user=Depends(get_verified_user)
|
||||
):
|
||||
await _check_notifications_access(user)
|
||||
try:
|
||||
return await update_target(user.id, target_id, form_data.model_dump(exclude_unset=True))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.delete('/targets/{target_id}')
|
||||
async def delete_notification_target(target_id: str, user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
if not await delete_target(user.id, target_id):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
@router.put('/targets/{target_id}/default')
|
||||
async def set_default_notification_target(target_id: str, user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
try:
|
||||
return await set_default_target(user.id, target_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
|
||||
@router.post('/targets/{target_id}/test')
|
||||
async def test_notification_target(request: Request, target_id: str, user=Depends(get_verified_user)):
|
||||
await _check_notifications_access(user)
|
||||
try:
|
||||
app_name = getattr(request.app.state, 'WEBUI_NAME', 'Open WebUI')
|
||||
return await test_target(user.id, target_id, app_name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
|
@ -313,6 +313,16 @@ async def update_config(
|
|||
'ollama.api_configs': api_configs,
|
||||
}
|
||||
)
|
||||
|
||||
await get_all_models.cache.clear()
|
||||
request.app.state.BASE_MODELS = []
|
||||
request.app.state.OLLAMA_MODELS = {}
|
||||
models = getattr(request.app.state, 'MODELS', None)
|
||||
if hasattr(models, 'clear'):
|
||||
models.clear()
|
||||
else:
|
||||
request.app.state.MODELS = {}
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.MODEL_PROVIDER_CONFIG_UPDATED,
|
||||
|
|
@ -355,6 +365,12 @@ def resolve_api_config(api_configs: dict, idx: int, url: str) -> dict:
|
|||
return api_configs.get(str(idx), api_configs.get(url, {}))
|
||||
|
||||
|
||||
async def get_ollama_connection_config() -> tuple[list, dict]:
|
||||
"""Base URLs and per-connection API configs in one batched SELECT."""
|
||||
config = await Config.get_many('ollama.base_urls', 'ollama.api_configs')
|
||||
return config.get('ollama.base_urls', []), config.get('ollama.api_configs', {})
|
||||
|
||||
|
||||
@cached(
|
||||
ttl=MODELS_CACHE_TTL,
|
||||
# key_builder (not key) is the per-call hook in aiocache 0.12; `key=` is a
|
||||
|
|
@ -871,12 +887,10 @@ async def embed(
|
|||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
|
||||
url_idx = random.choice(models[model]['urls'])
|
||||
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}),
|
||||
)
|
||||
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
|
||||
base_urls, api_configs = await get_ollama_connection_config()
|
||||
url = base_urls[url_idx]
|
||||
api_config = api_configs.get(str(url_idx), api_configs.get(url, {}))
|
||||
key = get_api_key(url_idx, url, api_configs)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
|
|
@ -925,12 +939,10 @@ async def embeddings(
|
|||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
|
||||
url_idx = random.choice(models[model]['urls'])
|
||||
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}),
|
||||
)
|
||||
key = get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {})))
|
||||
base_urls, api_configs = await get_ollama_connection_config()
|
||||
url = base_urls[url_idx]
|
||||
api_config = api_configs.get(str(url_idx), api_configs.get(url, {}))
|
||||
key = get_api_key(url_idx, url, api_configs)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
|
|
@ -984,11 +996,9 @@ async def generate_completion(
|
|||
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.MODEL_NOT_FOUND(form_data.model))
|
||||
url_idx = random.choice(models[model]['urls'])
|
||||
|
||||
url = (await Config.get('ollama.base_urls', []))[url_idx]
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}),
|
||||
)
|
||||
base_urls, api_configs = await get_ollama_connection_config()
|
||||
url = base_urls[url_idx]
|
||||
api_config = api_configs.get(str(url_idx), api_configs.get(url, {}))
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
|
|
@ -997,7 +1007,7 @@ async def generate_completion(
|
|||
return await send_request(
|
||||
f'{url}/api/generate',
|
||||
payload=form_data.model_dump_json(exclude_none=True).encode(),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=True,
|
||||
)
|
||||
|
|
@ -1119,7 +1129,8 @@ async def generate_chat_completion(
|
|||
await check_model_access(user, None, bypass_filter)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
|
||||
api_configs = await Config.get('ollama.api_configs', {})
|
||||
api_config = resolve_api_config(api_configs, url_idx, url)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
|
|
@ -1128,7 +1139,7 @@ async def generate_chat_completion(
|
|||
return await send_request(
|
||||
f'{url}/api/chat',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=form_data.stream,
|
||||
content_type='application/x-ndjson',
|
||||
|
|
@ -1170,6 +1181,14 @@ class OpenAICompletionForm(BaseModel):
|
|||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
|
||||
class OpenAIEmbeddingsForm(BaseModel):
|
||||
"""Payload for the OpenAI-compatible /v1/embeddings proxy."""
|
||||
|
||||
model: str
|
||||
input: object
|
||||
model_config = ConfigDict(extra='allow')
|
||||
|
||||
|
||||
@router.post('/v1/completions')
|
||||
@router.post('/v1/completions/{url_idx}')
|
||||
async def generate_openai_completion(
|
||||
|
|
@ -1207,7 +1226,8 @@ async def generate_openai_completion(
|
|||
await check_model_access(user, None)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
|
||||
api_configs = await Config.get('ollama.api_configs', {})
|
||||
api_config = resolve_api_config(api_configs, url_idx, url)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
|
|
@ -1216,7 +1236,7 @@ async def generate_openai_completion(
|
|||
return await send_request(
|
||||
f'{url}/v1/completions',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=payload.get('stream', False),
|
||||
metadata=metadata,
|
||||
|
|
@ -1225,6 +1245,56 @@ async def generate_openai_completion(
|
|||
)
|
||||
|
||||
|
||||
@router.post('/v1/embeddings')
|
||||
@router.post('/v1/embeddings/{url_idx}')
|
||||
async def generate_openai_embeddings(
|
||||
request: Request,
|
||||
form_data: dict,
|
||||
url_idx: int | None = None,
|
||||
user=Depends(get_verified_user), # noqa: B008
|
||||
):
|
||||
"""Forward an embeddings request via the OpenAI-compatible proxy."""
|
||||
if not await Config.get('ollama.enable'):
|
||||
raise HTTPException(status_code=503, detail=ERROR_MESSAGES.OLLAMA_API_DISABLED)
|
||||
|
||||
metadata = form_data.pop('metadata', None)
|
||||
|
||||
try:
|
||||
form_data = OpenAIEmbeddingsForm(**form_data)
|
||||
except Exception as exc:
|
||||
log.exception(exc)
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
payload = {**form_data.model_dump(exclude_none=True)}
|
||||
payload.pop('metadata', None)
|
||||
|
||||
model_id = form_data.model
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
if model_info is not None:
|
||||
if model_info.base_model_id:
|
||||
payload['model'] = model_info.base_model_id
|
||||
await check_model_access(user, model_info)
|
||||
else:
|
||||
await check_model_access(user, None)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
|
||||
|
||||
return await send_request(
|
||||
f'{url}/v1/embeddings',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
user=user,
|
||||
metadata=metadata,
|
||||
api_config=api_config,
|
||||
request=request,
|
||||
)
|
||||
|
||||
|
||||
@router.post('/v1/chat/completions')
|
||||
@router.post('/v1/chat/completions/{url_idx}')
|
||||
async def generate_openai_chat_completion(
|
||||
|
|
@ -1266,7 +1336,8 @@ async def generate_openai_chat_completion(
|
|||
await check_model_access(user, None)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = resolve_api_config((await Config.get('ollama.api_configs', {})), url_idx, url)
|
||||
api_configs = await Config.get('ollama.api_configs', {})
|
||||
api_config = resolve_api_config(api_configs, url_idx, url)
|
||||
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
|
|
@ -1275,7 +1346,7 @@ async def generate_openai_chat_completion(
|
|||
return await send_request(
|
||||
f'{url}/v1/chat/completions',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=payload.get('stream', False),
|
||||
metadata=metadata,
|
||||
|
|
@ -1317,10 +1388,8 @@ async def generate_anthropic_messages(
|
|||
await check_model_access(user, None)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support
|
||||
)
|
||||
api_configs = await Config.get('ollama.api_configs', {})
|
||||
api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) # Legacy support
|
||||
|
||||
prefix_id = api_config.get('prefix_id', None)
|
||||
if prefix_id:
|
||||
|
|
@ -1329,7 +1398,7 @@ async def generate_anthropic_messages(
|
|||
return await send_request(
|
||||
f'{url}/v1/messages',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=payload.get('stream', False),
|
||||
content_type='text/event-stream' if payload.get('stream', False) else None,
|
||||
|
|
@ -1377,10 +1446,8 @@ async def generate_responses(
|
|||
await check_model_access(user, None)
|
||||
|
||||
url, url_idx = await get_ollama_url(request, payload['model'], url_idx, user)
|
||||
api_config = (await Config.get('ollama.api_configs', {})).get(
|
||||
str(url_idx),
|
||||
(await Config.get('ollama.api_configs', {})).get(url, {}), # Legacy support
|
||||
)
|
||||
api_configs = await Config.get('ollama.api_configs', {})
|
||||
api_config = api_configs.get(str(url_idx), api_configs.get(url, {})) # Legacy support
|
||||
|
||||
prefix_id = api_config.get('prefix_id', None)
|
||||
if prefix_id:
|
||||
|
|
@ -1389,7 +1456,7 @@ async def generate_responses(
|
|||
return await send_request(
|
||||
f'{url}/v1/responses',
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url_idx, url, (await Config.get('ollama.api_configs', {}))),
|
||||
key=get_api_key(url_idx, url, api_configs),
|
||||
user=user,
|
||||
stream=payload.get('stream', False),
|
||||
content_type='text/event-stream' if payload.get('stream', False) else None,
|
||||
|
|
|
|||
|
|
@ -301,6 +301,91 @@ async def get_openai_connection(idx: int) -> tuple[str, str, dict]:
|
|||
return url, key, api_config
|
||||
|
||||
|
||||
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')
|
||||
if not requested_model:
|
||||
raise HTTPException(status_code=400, detail='model is required')
|
||||
|
||||
payload = {**form_data}
|
||||
model_id = requested_model
|
||||
model_info = await Models.get_model_by_id(model_id)
|
||||
await check_model_access(user, model_info, BYPASS_MODEL_ACCESS_CONTROL)
|
||||
|
||||
if model_info and model_info.base_model_id:
|
||||
model_id = model_info.base_model_id
|
||||
payload['model'] = model_id
|
||||
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
if not models or model_id not in models:
|
||||
await get_all_models(request, user=user)
|
||||
models = request.app.state.OPENAI_MODELS
|
||||
|
||||
model = models.get(model_id)
|
||||
if not model or 'urlIdx' not in model:
|
||||
raise HTTPException(status_code=404, detail=ERROR_MESSAGES.MODEL_NOT_FOUND())
|
||||
|
||||
url, key, api_config = await get_openai_connection(model['urlIdx'])
|
||||
prefix_id = api_config.get('prefix_id')
|
||||
if prefix_id:
|
||||
payload['model'] = payload['model'].replace(f'{prefix_id}.', '')
|
||||
|
||||
headers, cookies = await get_headers_and_cookies(request, url, key, api_config, user=user)
|
||||
return requested_model, payload, url, key, headers, cookies
|
||||
|
||||
|
||||
async def count_anthropic_tokens(request: Request, form_data: dict, user: UserModel) -> int:
|
||||
"""Forward an Anthropic token-count request through an OpenAI-compatible connection."""
|
||||
requested_model, payload, url, key, headers, cookies = await get_anthropic_token_count_target(
|
||||
request, form_data, user
|
||||
)
|
||||
request_url = f'{url.rstrip("/")}/messages/count_tokens'
|
||||
response = None
|
||||
|
||||
try:
|
||||
session = await get_session()
|
||||
response = await session.request(
|
||||
method='POST',
|
||||
url=request_url,
|
||||
data=json.dumps(payload),
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
||||
timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT),
|
||||
)
|
||||
|
||||
try:
|
||||
response_data = await response.json()
|
||||
except Exception:
|
||||
response_data = await response.text()
|
||||
|
||||
if response.status >= 400:
|
||||
await publish_model_provider_request_failed(
|
||||
request,
|
||||
actor=user,
|
||||
provider='openai-compatible',
|
||||
base_url=url,
|
||||
api_key=key,
|
||||
status=response.status,
|
||||
requested_model=requested_model,
|
||||
upstream_error=response_data,
|
||||
)
|
||||
raise HTTPException(status_code=response.status, detail=response_data)
|
||||
|
||||
input_tokens = response_data.get('input_tokens') if isinstance(response_data, dict) else None
|
||||
if isinstance(input_tokens, bool) or not isinstance(input_tokens, int) or input_tokens < 0:
|
||||
raise HTTPException(status_code=502, detail='Invalid token-count response from upstream provider')
|
||||
|
||||
return input_tokens
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
log.exception('Failed to count Anthropic tokens for model %s', requested_model)
|
||||
raise HTTPException(status_code=502, detail=ERROR_MESSAGES.SERVER_CONNECTION_ERROR)
|
||||
finally:
|
||||
await cleanup_response(response)
|
||||
|
||||
|
||||
@router.get('/config')
|
||||
async def get_config(request: Request, user=Depends(get_admin_user)):
|
||||
return await get_openai_config()
|
||||
|
|
@ -333,6 +418,16 @@ async def update_config(request: Request, form_data: OpenAIConfigForm, user=Depe
|
|||
'openai.api_configs': api_configs,
|
||||
}
|
||||
)
|
||||
|
||||
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 = {}
|
||||
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.MODEL_PROVIDER_CONFIG_UPDATED,
|
||||
|
|
@ -551,6 +646,7 @@ async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
|
|||
|
||||
enable_openai_api, api_base_urls, _, api_configs = await get_openai_runtime_config()
|
||||
if not enable_openai_api:
|
||||
request.app.state.OPENAI_MODELS = {}
|
||||
return {'data': []}
|
||||
|
||||
responses = await get_all_models_responses(request, user=user)
|
||||
|
|
@ -1107,6 +1203,9 @@ async def generate_chat_completion(
|
|||
form_data: dict,
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
if not await Config.get('openai.enable'):
|
||||
raise HTTPException(status_code=503, detail='OpenAI API is disabled')
|
||||
|
||||
# NOTE: We intentionally do NOT use Depends(get_async_session) here.
|
||||
# Database operations (get_model_by_id, AccessGrants.has_access) manage their own short-lived sessions.
|
||||
# This prevents holding a connection during the entire LLM call (30-60+ seconds),
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ async def get_skills(
|
|||
async def get_skill_list(
|
||||
query: Optional[str] = None,
|
||||
view_option: Optional[str] = None,
|
||||
order_by: Optional[str] = None,
|
||||
direction: Optional[str] = None,
|
||||
page: Optional[int] = 1,
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
|
|
@ -86,6 +88,10 @@ async def get_skill_list(
|
|||
filter['query'] = query
|
||||
if view_option:
|
||||
filter['view_option'] = view_option
|
||||
if order_by:
|
||||
filter['order_by'] = order_by
|
||||
if direction:
|
||||
filter['direction'] = direction
|
||||
|
||||
if not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL):
|
||||
groups = await Groups.get_groups_by_member_id(user.id, db=db)
|
||||
|
|
|
|||
|
|
@ -75,19 +75,6 @@ def config_updates(data: dict, key_map: dict[str, str]) -> dict:
|
|||
##################################
|
||||
|
||||
|
||||
class ActiveChatsForm(BaseModel):
|
||||
chat_ids: list[str]
|
||||
|
||||
|
||||
@router.post('/active/chats')
|
||||
async def check_active_chats(request: Request, form_data: ActiveChatsForm, user=Depends(get_verified_user)):
|
||||
"""Check which chat IDs have active tasks."""
|
||||
from open_webui.tasks import get_active_chat_ids
|
||||
|
||||
active = await get_active_chat_ids(request.app.state.redis, form_data.chat_ids)
|
||||
return {'active_chat_ids': active}
|
||||
|
||||
|
||||
@router.get('/config')
|
||||
async def get_task_config(request: Request, user=Depends(get_verified_user)):
|
||||
return await get_config_values(TASK_CONFIG_KEYS)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from open_webui.models.groups import Groups
|
|||
from open_webui.models.users import Users
|
||||
from open_webui.utils.access_control import has_connection_access
|
||||
from open_webui.utils.auth import get_verified_user
|
||||
from open_webui.utils.terminals import get_terminal_server_url
|
||||
from open_webui.utils.tools import bearer_auth_header, normalize_bearer_token
|
||||
from starlette.background import BackgroundTask
|
||||
|
||||
|
|
@ -100,7 +101,7 @@ async def proxy_terminal(
|
|||
if not await has_connection_access(user, connection, user_group_ids):
|
||||
return JSONResponse({'error': 'Access denied'}, status_code=403)
|
||||
|
||||
base_url = (connection.get('url') or '').rstrip('/')
|
||||
base_url = get_terminal_server_url(connection)
|
||||
if not base_url:
|
||||
return JSONResponse({'error': 'Terminal server URL not configured'}, status_code=503)
|
||||
|
||||
|
|
@ -110,11 +111,6 @@ async def proxy_terminal(
|
|||
|
||||
target_url = f'{base_url}/{safe_path}'
|
||||
|
||||
# Route through orchestrator policy endpoint if policy_id is set
|
||||
policy_id = connection.get('policy_id')
|
||||
if policy_id:
|
||||
target_url = f'{base_url}/p/{policy_id}/{safe_path}'
|
||||
|
||||
if request.query_params:
|
||||
target_url += f'?{request.query_params}'
|
||||
|
||||
|
|
@ -272,7 +268,7 @@ async def ws_terminal(
|
|||
return
|
||||
user, connection = result
|
||||
|
||||
base_url = (connection.get('url') or '').rstrip('/')
|
||||
base_url = get_terminal_server_url(connection)
|
||||
if not base_url:
|
||||
await ws.close(code=4003, reason='Terminal server URL not configured')
|
||||
return
|
||||
|
|
@ -280,8 +276,6 @@ async def ws_terminal(
|
|||
# Build upstream WebSocket URL (no token in URL)
|
||||
ws_base = base_url.replace('https://', 'wss://').replace('http://', 'ws://')
|
||||
|
||||
# Route through orchestrator policy endpoint if policy_id is set
|
||||
policy_id = connection.get('policy_id')
|
||||
upstream_params = {}
|
||||
# For orchestrator-backed servers, pass user_id
|
||||
upstream_params['user_id'] = user.id
|
||||
|
|
@ -292,10 +286,7 @@ async def ws_terminal(
|
|||
# decode depth) and inject an attacker-chosen user_id ahead of the one appended below.
|
||||
safe_session_id = urllib.parse.quote(session_id, safe='')
|
||||
|
||||
if policy_id:
|
||||
upstream_url = f'{ws_base}/p/{policy_id}/api/terminals/{safe_session_id}'
|
||||
else:
|
||||
upstream_url = f'{ws_base}/api/terminals/{safe_session_id}'
|
||||
upstream_url = f'{ws_base}/api/terminals/{safe_session_id}'
|
||||
if upstream_params:
|
||||
upstream_url += f'?{urllib.parse.urlencode(upstream_params)}'
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import aiohttp
|
|||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, CACHE_DIR
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT
|
||||
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL, AIOHTTP_CLIENT_TIMEOUT, ENABLE_PLUGINS
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
|
@ -72,20 +72,23 @@ async def get_tools(
|
|||
tools = []
|
||||
|
||||
# Local Tools
|
||||
tools_cache = get_tools_cache(request)
|
||||
for tool in await Tools.get_tools(defer_content=True, db=db):
|
||||
tool_module = tools_cache.get(tool.id)
|
||||
has_user_valves = (
|
||||
hasattr(tool_module, 'UserValves') if tool_module else (tool.meta.has_user_valves if tool.meta else False)
|
||||
)
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
**{
|
||||
**tool.model_dump(),
|
||||
'has_user_valves': has_user_valves,
|
||||
}
|
||||
if ENABLE_PLUGINS:
|
||||
tools_cache = get_tools_cache(request)
|
||||
for tool in await Tools.get_tools(defer_content=True, db=db):
|
||||
tool_module = tools_cache.get(tool.id)
|
||||
has_user_valves = (
|
||||
hasattr(tool_module, 'UserValves')
|
||||
if tool_module
|
||||
else (tool.meta.has_user_valves if tool.meta else False)
|
||||
)
|
||||
tools.append(
|
||||
ToolUserResponse(
|
||||
**{
|
||||
**tool.model_dump(),
|
||||
'has_user_valves': has_user_valves,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# OpenAPI Tool Servers
|
||||
server_access_grants = {}
|
||||
|
|
@ -199,6 +202,9 @@ async def get_tools(
|
|||
|
||||
@router.get('/list', response_model=list[ToolAccessResponse])
|
||||
async def get_tool_list(user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session)):
|
||||
if not ENABLE_PLUGINS:
|
||||
return []
|
||||
|
||||
if user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL:
|
||||
tools = await Tools.get_tools(defer_content=True, db=db)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ import base64
|
|||
import io
|
||||
import logging
|
||||
import time
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
|
|
@ -14,6 +16,8 @@ from open_webui.env import ENABLE_PROFILE_IMAGE_URL_FORWARDING, PROFILE_IMAGE_AL
|
|||
from open_webui.internal.db import get_async_session
|
||||
from open_webui.models.auths import Auths
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.chat_messages import ChatMessages
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
from open_webui.models.users import (
|
||||
|
|
@ -196,6 +200,7 @@ class SharingPermissions(BaseModel):
|
|||
public_skills: bool = False
|
||||
notes: bool = False
|
||||
public_notes: bool = True
|
||||
folders: bool = False
|
||||
public_chats: bool = False
|
||||
public_calendars: bool = False
|
||||
|
||||
|
|
@ -259,6 +264,129 @@ class UserPermissions(BaseModel):
|
|||
settings: SettingsPermissions
|
||||
|
||||
|
||||
class UserUsageTotals(BaseModel):
|
||||
lifetime_tokens: int = 0
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
peak_daily_tokens: int = 0
|
||||
longest_chat_seconds: int = 0
|
||||
current_streak: int = 0
|
||||
longest_streak: int = 0
|
||||
total_chats: int = 0
|
||||
active_days: int = 0
|
||||
models_used: int = 0
|
||||
messages: int = 0
|
||||
user_messages: int = 0
|
||||
assistant_messages: int = 0
|
||||
|
||||
|
||||
class UserUsageHeatmapEntry(BaseModel):
|
||||
date: str
|
||||
messages: int = 0
|
||||
chats: int = 0
|
||||
tokens: int = 0
|
||||
models: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class UserUsageModelEntry(BaseModel):
|
||||
model_id: str
|
||||
messages: int = 0
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
|
||||
|
||||
class UserUsageToolEntry(BaseModel):
|
||||
name: str
|
||||
count: int
|
||||
|
||||
|
||||
class UserUsageInsights(BaseModel):
|
||||
most_used_model: Optional[str] = None
|
||||
average_tokens_per_chat: float = 0
|
||||
average_messages_per_active_day: float = 0
|
||||
user_message_share: float = 0
|
||||
assistant_message_share: float = 0
|
||||
|
||||
|
||||
class UserUsagePeriod(BaseModel):
|
||||
start_date: int
|
||||
end_date: int
|
||||
days: int
|
||||
|
||||
|
||||
class UserUsageResponse(BaseModel):
|
||||
totals: UserUsageTotals
|
||||
heatmap: list[UserUsageHeatmapEntry]
|
||||
weekly_heatmap: list[UserUsageHeatmapEntry]
|
||||
cumulative_heatmap: list[UserUsageHeatmapEntry]
|
||||
insights: UserUsageInsights
|
||||
top_models: list[UserUsageModelEntry]
|
||||
top_tools: list[UserUsageToolEntry] = []
|
||||
period: UserUsagePeriod
|
||||
|
||||
|
||||
def _week_start(date: datetime) -> datetime:
|
||||
return date - timedelta(days=date.weekday())
|
||||
|
||||
|
||||
def _build_weekly_heatmap(heatmap: list[dict]) -> list[dict]:
|
||||
weeks: dict[str, dict] = {}
|
||||
for day in heatmap:
|
||||
week = _week_start(datetime.strptime(day['date'], '%Y-%m-%d')).strftime('%Y-%m-%d')
|
||||
entry = weeks.setdefault(week, {'date': week, 'messages': 0, 'chats': 0, 'tokens': 0, 'models': Counter()})
|
||||
entry['messages'] += day.get('messages', 0)
|
||||
entry['chats'] += day.get('chats', 0)
|
||||
entry['tokens'] += day.get('tokens', 0)
|
||||
entry['models'].update(day.get('models', {}))
|
||||
|
||||
return [
|
||||
{
|
||||
**weeks[key],
|
||||
'models': dict(weeks[key]['models']),
|
||||
}
|
||||
for key in sorted(weeks)
|
||||
]
|
||||
|
||||
|
||||
def _build_cumulative_heatmap(heatmap: list[dict]) -> list[dict]:
|
||||
totals = {'messages': 0, 'chats': 0, 'tokens': 0}
|
||||
models: Counter[str] = Counter()
|
||||
cumulative = []
|
||||
for day in heatmap:
|
||||
totals['messages'] += day.get('messages', 0)
|
||||
totals['chats'] += day.get('chats', 0)
|
||||
totals['tokens'] += day.get('tokens', 0)
|
||||
models.update(day.get('models', {}))
|
||||
cumulative.append(
|
||||
{
|
||||
'date': day['date'],
|
||||
**totals,
|
||||
'models': dict(models),
|
||||
}
|
||||
)
|
||||
return cumulative
|
||||
|
||||
|
||||
def _calculate_streaks(heatmap: list[dict]) -> dict[str, int]:
|
||||
longest = 0
|
||||
current_run = 0
|
||||
for day in heatmap:
|
||||
if day.get('messages', 0) > 0:
|
||||
current_run += 1
|
||||
longest = max(longest, current_run)
|
||||
else:
|
||||
current_run = 0
|
||||
|
||||
current = 0
|
||||
for day in reversed(heatmap):
|
||||
if day.get('messages', 0) <= 0:
|
||||
break
|
||||
current += 1
|
||||
|
||||
return {'current': current, 'longest': longest}
|
||||
|
||||
|
||||
@router.get('/default/permissions', response_model=UserPermissions)
|
||||
async def get_default_user_permissions(request: Request, user=Depends(get_admin_user)):
|
||||
user_permissions = await Config.get('user.permissions')
|
||||
|
|
@ -348,6 +476,23 @@ async def update_user_settings_by_session_user(
|
|||
# If the user is not an admin and does not have permission to use tool servers, remove the key
|
||||
updated_user_settings['ui'].pop('toolServers', None)
|
||||
|
||||
ui_notifications = ui_settings.get('notifications') if isinstance(ui_settings, dict) else None
|
||||
if (
|
||||
user.role != 'admin'
|
||||
and (
|
||||
'notifications' in updated_user_settings
|
||||
or (isinstance(ui_notifications, dict) and 'webhook_url' in ui_notifications)
|
||||
)
|
||||
and not await has_permission(
|
||||
user.id,
|
||||
'features.webhooks',
|
||||
await Config.get('user.permissions'),
|
||||
)
|
||||
):
|
||||
updated_user_settings.pop('notifications', None)
|
||||
if isinstance(ui_notifications, dict):
|
||||
ui_notifications.pop('webhook_url', None)
|
||||
|
||||
user = await Users.update_user_settings_by_id(user.id, updated_user_settings, db=db)
|
||||
if user:
|
||||
await publish_event(
|
||||
|
|
@ -455,6 +600,86 @@ async def update_user_info_by_session_user( # PATCH-style merge
|
|||
return updated.info
|
||||
|
||||
|
||||
############################
|
||||
# GetUserUsageBySessionUser
|
||||
############################
|
||||
|
||||
|
||||
@router.get('/usage', response_model=UserUsageResponse)
|
||||
async def get_user_usage_by_session_user(
|
||||
days: Optional[int] = Query(None, ge=7, le=732),
|
||||
start_date: Optional[int] = Query(None),
|
||||
end_date: Optional[int] = Query(None),
|
||||
user=Depends(get_verified_user),
|
||||
db: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
now = int(time.time())
|
||||
period_end = end_date or now
|
||||
if start_date is not None:
|
||||
period_start = start_date
|
||||
elif days is not None:
|
||||
period_start = period_end - ((days - 1) * 86400)
|
||||
else:
|
||||
period_start = max(user.created_at or (period_end - (364 * 86400)), period_end - (729 * 86400))
|
||||
|
||||
if period_start > period_end:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='start_date must be before end_date',
|
||||
)
|
||||
|
||||
period_days = max(1, int((period_end - period_start) / 86400) + 1)
|
||||
|
||||
lifetime_summary = await ChatMessages.get_user_usage_summary(user.id, include_active_days=False, db=db)
|
||||
period_summary = await ChatMessages.get_user_usage_summary(
|
||||
user.id, period_start, period_end, timezone=user.timezone, db=db
|
||||
)
|
||||
chat_stats = await Chats.get_user_usage_chat_stats(user.id, db=db)
|
||||
heatmap = await ChatMessages.get_user_daily_usage(user.id, period_start, period_end, timezone=user.timezone, db=db)
|
||||
top_models = await ChatMessages.get_user_top_models(user.id, period_start, period_end, db=db)
|
||||
top_tools = await ChatMessages.get_user_top_tools(user.id, period_start, period_end, db=db)
|
||||
|
||||
streaks = _calculate_streaks(heatmap)
|
||||
total_messages = period_summary.get('messages', 0)
|
||||
total_chats = chat_stats.get('total_chats', 0)
|
||||
active_days = period_summary.get('active_days', 0)
|
||||
assistant_messages = period_summary.get('assistant_messages', 0)
|
||||
user_messages = period_summary.get('user_messages', 0)
|
||||
|
||||
return UserUsageResponse(
|
||||
totals=UserUsageTotals(
|
||||
lifetime_tokens=lifetime_summary.get('total_tokens', 0),
|
||||
input_tokens=lifetime_summary.get('input_tokens', 0),
|
||||
output_tokens=lifetime_summary.get('output_tokens', 0),
|
||||
peak_daily_tokens=max((day.get('tokens', 0) for day in heatmap), default=0),
|
||||
longest_chat_seconds=chat_stats.get('longest_chat_seconds', 0),
|
||||
current_streak=streaks['current'],
|
||||
longest_streak=streaks['longest'],
|
||||
total_chats=total_chats,
|
||||
active_days=active_days,
|
||||
models_used=lifetime_summary.get('models_used', 0),
|
||||
messages=total_messages,
|
||||
user_messages=user_messages,
|
||||
assistant_messages=assistant_messages,
|
||||
),
|
||||
heatmap=heatmap,
|
||||
weekly_heatmap=_build_weekly_heatmap(heatmap),
|
||||
cumulative_heatmap=_build_cumulative_heatmap(heatmap),
|
||||
insights=UserUsageInsights(
|
||||
most_used_model=top_models[0]['model_id'] if top_models else None,
|
||||
average_tokens_per_chat=(
|
||||
round(lifetime_summary.get('total_tokens', 0) / total_chats, 1) if total_chats else 0
|
||||
),
|
||||
average_messages_per_active_day=round(total_messages / active_days, 1) if active_days else 0,
|
||||
user_message_share=round((user_messages / total_messages) * 100, 1) if total_messages else 0,
|
||||
assistant_message_share=round((assistant_messages / total_messages) * 100, 1) if total_messages else 0,
|
||||
),
|
||||
top_models=top_models,
|
||||
top_tools=top_tools,
|
||||
period=UserUsagePeriod(start_date=period_start, end_date=period_end, days=period_days),
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# GetUserById
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -57,6 +57,29 @@ REDIS = None
|
|||
# Configure CORS for Socket.IO
|
||||
SOCKETIO_CORS_ORIGINS = '*' if CORS_ALLOW_ORIGIN == ['*'] else CORS_ALLOW_ORIGIN
|
||||
|
||||
class LocalFilteredRedisManager(socketio.AsyncRedisManager):
|
||||
"""AsyncRedisManager that drops pub/sub emits with no local recipients.
|
||||
|
||||
Every instance subscribed to the shared Socket.IO channel receives every
|
||||
emit published by the whole fleet. Upstream ``_handle_emit`` re-encodes
|
||||
the full packet before discovering the target room has no participants on
|
||||
this instance, so each instance burns CPU serializing payloads addressed
|
||||
to sessions it does not host — a cost that grows with instance count.
|
||||
Bail out before that work when the room is empty here. Only string
|
||||
rooms take the fast path; broadcasts (``room=None``) and any other
|
||||
room shape (e.g. lists, which upstream indexes before validating)
|
||||
always pass through unchanged.
|
||||
"""
|
||||
|
||||
async def _handle_emit(self, message):
|
||||
room = message.get('room')
|
||||
if isinstance(room, str):
|
||||
namespace = message.get('namespace') or '/'
|
||||
if next(self.get_participants(namespace, room), None) is None:
|
||||
return
|
||||
await super()._handle_emit(message)
|
||||
|
||||
|
||||
if WEBSOCKET_MANAGER == 'redis':
|
||||
sentinel_hosts = WEBSOCKET_SENTINEL_HOSTS or ''
|
||||
ws_redis_url = (
|
||||
|
|
@ -64,7 +87,7 @@ if WEBSOCKET_MANAGER == 'redis':
|
|||
if sentinel_hosts
|
||||
else WEBSOCKET_REDIS_URL
|
||||
)
|
||||
redis_manager = socketio.AsyncRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS)
|
||||
redis_manager = LocalFilteredRedisManager(ws_redis_url, redis_options=WEBSOCKET_REDIS_OPTIONS)
|
||||
sio = socketio.AsyncServer(
|
||||
cors_allowed_origins=SOCKETIO_CORS_ORIGINS,
|
||||
async_mode='asgi',
|
||||
|
|
@ -529,6 +552,12 @@ async def chat_events(sid, data):
|
|||
|
||||
if event_type == 'last_read_at':
|
||||
await Chats.update_chat_last_read_at_by_id(data['chat_id'], user['id'])
|
||||
try:
|
||||
from open_webui.utils.timers import cancel_timers_for_chat
|
||||
|
||||
await cancel_timers_for_chat(data['chat_id'], 'chat.read')
|
||||
except Exception:
|
||||
log.exception('Failed to cancel chat.read timers for chat %s', data.get('chat_id'))
|
||||
|
||||
|
||||
def normalize_document_id(document_id: str) -> str:
|
||||
|
|
@ -925,12 +954,17 @@ async def get_event_emitter(request_info, update_db=True):
|
|||
user_id = request_info['user_id']
|
||||
chat_id = request_info['chat_id']
|
||||
message_id = request_info['message_id']
|
||||
internal = request_info.get('internal') is True
|
||||
|
||||
if internal and event_data.get('type') == 'notification':
|
||||
return
|
||||
|
||||
await sio.emit(
|
||||
'events',
|
||||
{
|
||||
'chat_id': chat_id,
|
||||
'message_id': message_id,
|
||||
**({'internal': True} if internal else {}),
|
||||
'data': event_data,
|
||||
},
|
||||
room=f'user:{user_id}',
|
||||
|
|
@ -992,6 +1026,7 @@ async def get_event_emitter(request_info, update_db=True):
|
|||
{
|
||||
'embeds': embeds,
|
||||
},
|
||||
touch=False,
|
||||
)
|
||||
|
||||
elif event_type == 'files':
|
||||
|
|
@ -1009,6 +1044,7 @@ async def get_event_emitter(request_info, update_db=True):
|
|||
{
|
||||
'files': files,
|
||||
},
|
||||
touch=False,
|
||||
)
|
||||
|
||||
elif event_type in ('source', 'citation'):
|
||||
|
|
@ -1028,6 +1064,7 @@ async def get_event_emitter(request_info, update_db=True):
|
|||
{
|
||||
'sources': sources,
|
||||
},
|
||||
touch=False,
|
||||
)
|
||||
|
||||
if 'user_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info:
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
|
|
@ -199,12 +199,3 @@ async def has_active_tasks(redis, chat_id: str) -> bool:
|
|||
"""Check if a chat has any active tasks."""
|
||||
task_ids = await list_task_ids_by_item_id(redis, chat_id)
|
||||
return len(task_ids) > 0
|
||||
|
||||
|
||||
async def get_active_chat_ids(redis, chat_ids: List[str]) -> List[str]:
|
||||
"""Filter a list of chat_ids to only those with active tasks."""
|
||||
active = []
|
||||
for chat_id in chat_ids:
|
||||
if await has_active_tasks(redis, chat_id):
|
||||
active.append(chat_id)
|
||||
return active
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import asyncio
|
|||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
|
|
@ -49,6 +49,10 @@ from open_webui.routers.memories import (
|
|||
add_memory as _add_memory,
|
||||
)
|
||||
from open_webui.routers.retrieval import search_web as _search_web
|
||||
from open_webui.tasks import stop_item_tasks
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.socket.main import sio
|
||||
from open_webui.utils.notifications import notify_target
|
||||
from open_webui.utils.sanitize import sanitize_code
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
@ -56,6 +60,33 @@ log = logging.getLogger(__name__)
|
|||
MAX_KNOWLEDGE_BASE_SEARCH_ITEMS = 10_000
|
||||
|
||||
|
||||
async def _has_write_access_to_note(note, user_id: str) -> bool:
|
||||
if note.user_id == user_id:
|
||||
return True
|
||||
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
|
||||
return await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='write',
|
||||
user_group_ids=set(user_group_ids),
|
||||
)
|
||||
|
||||
|
||||
async def _emit_note_updated(request: Request, user: dict, note) -> None:
|
||||
await sio.emit('events:note', note.model_dump(), to=f'note:{note.id}')
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.NOTE_UPDATED,
|
||||
actor=user,
|
||||
subject_id=note.id,
|
||||
data={'title': note.title},
|
||||
)
|
||||
|
||||
|
||||
async def _has_read_access_to_file(
|
||||
file,
|
||||
user_id: str,
|
||||
|
|
@ -81,6 +112,33 @@ async def _has_read_access_to_file(
|
|||
# =============================================================================
|
||||
|
||||
|
||||
async def notify(
|
||||
message: str,
|
||||
target: str = '',
|
||||
title: str = '',
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
) -> str:
|
||||
"""
|
||||
Send a notification to the user's configured notification target.
|
||||
|
||||
:param message: Notification body.
|
||||
:param target: Optional target id or name. Empty uses the default target.
|
||||
:param title: Optional notification title.
|
||||
"""
|
||||
user_id = (__user__ or {}).get('id')
|
||||
if not user_id:
|
||||
return 'Notification failed: user not found.'
|
||||
|
||||
app_name = getattr(getattr(__request__, 'app', None), 'state', None)
|
||||
app_name = getattr(app_name, 'WEBUI_NAME', 'Open WebUI')
|
||||
try:
|
||||
result = await notify_target(user_id, message, target=target, title=title, app_name=app_name)
|
||||
return f'Notification sent to {result.get("target_id")}.'
|
||||
except Exception as e:
|
||||
return f'Notification failed: {e}'
|
||||
|
||||
|
||||
async def get_current_timestamp(
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
|
|
@ -1048,12 +1106,16 @@ async def view_note(
|
|||
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
|
||||
if note.user_id != user_id and not await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='read',
|
||||
user_group_ids=set(user_group_ids),
|
||||
if (
|
||||
__user__.get('role') != 'admin'
|
||||
and note.user_id != user_id
|
||||
and not await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='read',
|
||||
user_group_ids=set(user_group_ids),
|
||||
)
|
||||
):
|
||||
return json.dumps({'error': 'Access denied'})
|
||||
|
||||
|
|
@ -1128,16 +1190,20 @@ async def write_note(
|
|||
|
||||
async def replace_note_content(
|
||||
note_id: str,
|
||||
content: str,
|
||||
content: Optional[str] = None,
|
||||
operations: Optional[list[dict]] = None,
|
||||
title: Optional[str] = None,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
) -> str:
|
||||
"""
|
||||
Update the markdown content, and optionally the title, of an existing note.
|
||||
Update an existing note by replacing the whole markdown content or applying range operations.
|
||||
|
||||
:param note_id: The ID of the note to update
|
||||
:param content: The new markdown content for the note
|
||||
:param content: The new markdown content for a whole-note update
|
||||
:param operations: Optional note operations:
|
||||
- {"action": "replace", "content": "..."}
|
||||
- {"action": "replace_range", "start": 0, "end": 10, "content": "...", "expected": "..."}
|
||||
:param title: Optional new title for the note
|
||||
:return: JSON with success status and updated note info
|
||||
"""
|
||||
|
|
@ -1153,25 +1219,113 @@ async def replace_note_content(
|
|||
note = await Notes.get_note_by_id(note_id)
|
||||
|
||||
if not note:
|
||||
return json.dumps({'error': 'Note not found'})
|
||||
return json.dumps({'error': 'Note not found', 'code': 'not_found'})
|
||||
|
||||
# Check write permission
|
||||
user_id = __user__.get('id')
|
||||
user_group_ids = [group.id for group in await Groups.get_groups_by_member_id(user_id)]
|
||||
if __user__.get('role') != 'admin' and not await _has_write_access_to_note(note, user_id):
|
||||
return json.dumps({'error': 'Write access denied', 'code': 'write_access_denied'})
|
||||
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
current_content = ((note.data or {}).get('content') or {}).get('md') or ''
|
||||
applied_operation_count = 0
|
||||
if operations is not None:
|
||||
if not isinstance(operations, list) or len(operations) == 0:
|
||||
return json.dumps({'error': 'operations must be a non-empty list', 'code': 'invalid_operations'})
|
||||
|
||||
if note.user_id != user_id and not await AccessGrants.has_access(
|
||||
user_id=user_id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='write',
|
||||
user_group_ids=set(user_group_ids),
|
||||
):
|
||||
return json.dumps({'error': 'Write access denied'})
|
||||
range_operations = []
|
||||
for idx, operation in enumerate(operations):
|
||||
if not isinstance(operation, dict):
|
||||
return json.dumps(
|
||||
{'error': 'each operation must be an object', 'code': 'invalid_operation', 'index': idx}
|
||||
)
|
||||
|
||||
# Build update form
|
||||
update_data = {'data': {'content': {'md': content}}}
|
||||
action = operation.get('action')
|
||||
replacement = operation.get('content')
|
||||
|
||||
if action == 'replace':
|
||||
if len(operations) != 1:
|
||||
return json.dumps(
|
||||
{
|
||||
'error': 'replace operation must be the only operation',
|
||||
'code': 'invalid_operations',
|
||||
'index': idx,
|
||||
}
|
||||
)
|
||||
if not isinstance(replacement, str):
|
||||
return json.dumps(
|
||||
{
|
||||
'error': 'replace operation content must be a string',
|
||||
'code': 'invalid_content',
|
||||
'index': idx,
|
||||
}
|
||||
)
|
||||
content = replacement
|
||||
applied_operation_count = 1
|
||||
break
|
||||
|
||||
if action != 'replace_range':
|
||||
return json.dumps(
|
||||
{'error': 'unknown operation action', 'code': 'invalid_action', 'index': idx, 'action': action}
|
||||
)
|
||||
|
||||
start = operation.get('start')
|
||||
end = operation.get('end')
|
||||
expected = operation.get('expected')
|
||||
if not isinstance(start, int) or not isinstance(end, int):
|
||||
return json.dumps(
|
||||
{'error': 'operation start and end must be integers', 'code': 'invalid_range', 'index': idx}
|
||||
)
|
||||
if not isinstance(replacement, str):
|
||||
return json.dumps(
|
||||
{'error': 'operation content must be a string', 'code': 'invalid_content', 'index': idx}
|
||||
)
|
||||
if start < 0 or end < start or end > len(current_content):
|
||||
return json.dumps(
|
||||
{'error': 'operation range is out of bounds', 'code': 'range_out_of_bounds', 'index': idx}
|
||||
)
|
||||
if expected is not None and current_content[start:end] != expected:
|
||||
return json.dumps(
|
||||
{
|
||||
'error': 'operation expected text does not match current content',
|
||||
'code': 'expected_mismatch',
|
||||
'index': idx,
|
||||
}
|
||||
)
|
||||
|
||||
range_operations.append({'start': start, 'end': end, 'content': replacement})
|
||||
|
||||
range_operations.sort(key=lambda operation: operation['start'])
|
||||
previous_end = 0
|
||||
for idx, operation in enumerate(range_operations):
|
||||
if operation['start'] < previous_end:
|
||||
return json.dumps(
|
||||
{'error': 'operation ranges must not overlap', 'code': 'overlapping_operations', 'index': idx}
|
||||
)
|
||||
previous_end = operation['end']
|
||||
|
||||
if range_operations:
|
||||
content = current_content
|
||||
for operation in reversed(range_operations):
|
||||
content = content[: operation['start']] + operation['content'] + content[operation['end'] :]
|
||||
applied_operation_count = len(range_operations)
|
||||
elif content is None:
|
||||
return json.dumps({'error': 'content or operations is required', 'code': 'content_required'})
|
||||
|
||||
try:
|
||||
await stop_item_tasks(__request__.app.state.redis, f'note:{note_id}')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
update_data = {
|
||||
'data': {
|
||||
**(note.data or {}),
|
||||
'content': {
|
||||
**((note.data or {}).get('content') or {}),
|
||||
'json': None,
|
||||
'html': '',
|
||||
'md': content,
|
||||
},
|
||||
}
|
||||
}
|
||||
if title:
|
||||
update_data['title'] = title
|
||||
|
||||
|
|
@ -1179,7 +1333,9 @@ async def replace_note_content(
|
|||
updated_note = await Notes.update_note_by_id(note_id, form)
|
||||
|
||||
if not updated_note:
|
||||
return json.dumps({'error': 'Failed to update note'})
|
||||
return json.dumps({'error': 'Failed to update note', 'code': 'update_failed'})
|
||||
|
||||
await _emit_note_updated(__request__, __user__, updated_note)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
|
|
@ -1187,12 +1343,13 @@ async def replace_note_content(
|
|||
'id': updated_note.id,
|
||||
'title': updated_note.title,
|
||||
'updated_at': updated_note.updated_at,
|
||||
'applied_operation_count': applied_operation_count,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
log.exception(f'replace_note_content error: {e}')
|
||||
return json.dumps({'error': str(e)})
|
||||
return json.dumps({'error': str(e), 'code': 'unexpected_error'})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -1348,6 +1505,85 @@ async def view_chat(
|
|||
return json.dumps({'error': str(e)})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SUB-AGENT TOOL
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def delegate_task(
|
||||
task: str,
|
||||
context: str = '',
|
||||
background: bool = False,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
__metadata__: dict = None,
|
||||
__chat_id__: str = None,
|
||||
__message_id__: str = None,
|
||||
) -> str:
|
||||
"""
|
||||
Delegate focused work to a parallel sub-agent using the current model and tools.
|
||||
|
||||
:param task: The specific task for the sub-agent to complete
|
||||
:param context: Relevant context, decisions, or file paths for the task
|
||||
:param background: Return immediately and continue this chat when the sub-agent finishes
|
||||
:return: Foreground result text, or a JSON dispatch handle for background work
|
||||
"""
|
||||
if __request__ is None:
|
||||
return 'Error: request context not available.'
|
||||
if getattr(__request__.state, 'internal', False) is True:
|
||||
return 'Error: sub-agents cannot delegate recursively.'
|
||||
|
||||
from open_webui.utils.subagents import delegate
|
||||
|
||||
return await delegate(
|
||||
task,
|
||||
context,
|
||||
background,
|
||||
request=__request__,
|
||||
user_data=__user__ or {},
|
||||
metadata=__metadata__ or {},
|
||||
parent_chat_id=__chat_id__ or '',
|
||||
parent_message_id=__message_id__,
|
||||
)
|
||||
|
||||
|
||||
async def timer(
|
||||
prompt: str,
|
||||
at: str,
|
||||
cancel_on: list[Literal['chat.read', 'chat.user_message']] | None = None,
|
||||
__request__: Request = None,
|
||||
__user__: dict = None,
|
||||
__metadata__: dict = None,
|
||||
__chat_id__: str = None,
|
||||
__message_id__: str = None,
|
||||
) -> str:
|
||||
"""
|
||||
Set a one-shot timer for this chat.
|
||||
|
||||
:param prompt: The prompt to send back into this chat when the timer fires
|
||||
:param at: Relative time like 10s, 5m, 1h, 2d, or a timezone-aware RFC 3339 timestamp
|
||||
:param cancel_on: Optional events that cancel the timer before it fires
|
||||
:return: JSON status with the scheduled time, or an error string
|
||||
"""
|
||||
if __request__ is None:
|
||||
return 'Error: request context not available.'
|
||||
if getattr(__request__.state, 'internal', False) is True:
|
||||
return 'Error: timers cannot be set from internal chats.'
|
||||
|
||||
from open_webui.utils.timers import create_timer
|
||||
|
||||
return await create_timer(
|
||||
prompt=prompt,
|
||||
at=at,
|
||||
cancel_on=cancel_on,
|
||||
request=__request__,
|
||||
user_data=__user__ or {},
|
||||
metadata=__metadata__ or {},
|
||||
parent_chat_id=__chat_id__ or '',
|
||||
parent_message_id=__message_id__,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CHANNELS TOOLS
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -686,12 +686,12 @@ async def _kb_grep(
|
|||
|
||||
# Grep on piped input
|
||||
if piped_input is not None:
|
||||
lines = piped_input.split('\\n')
|
||||
lines = piped_input.split('\n')
|
||||
matched = []
|
||||
for i, line in enumerate(lines, 1):
|
||||
if _matches(line):
|
||||
matched.append(f'{i}: {line}')
|
||||
return '\\n'.join(matched) if matched else f'No matches for "{pattern}"'
|
||||
return '\n'.join(matched) if matched else f'No matches for "{pattern}"'
|
||||
|
||||
# Single file grep
|
||||
if file_ref and not dir_scope:
|
||||
|
|
@ -702,7 +702,7 @@ async def _kb_grep(
|
|||
elif 'error' in resolved:
|
||||
return resolved['error']
|
||||
else:
|
||||
lines = resolved['content'].split('\\n')
|
||||
lines = resolved['content'].split('\n')
|
||||
matched = []
|
||||
for i, line in enumerate(lines, 1):
|
||||
if _matches(line):
|
||||
|
|
@ -715,7 +715,7 @@ async def _kb_grep(
|
|||
|
||||
if not matched:
|
||||
return f'No matches for "{pattern}" in {resolved["filename"]}'
|
||||
return '\\n'.join(matched)
|
||||
return '\n'.join(matched)
|
||||
|
||||
# Cross-file grep (optionally scoped to directory)
|
||||
accessible = await _get_accessible_files(user, model_knowledge)
|
||||
|
|
|
|||
|
|
@ -253,6 +253,22 @@ async def filter_allowed_access_grants(
|
|||
):
|
||||
access_grants = strip_user_access_grants(access_grants)
|
||||
|
||||
if any(
|
||||
(grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None)) == 'group'
|
||||
for grant in access_grants
|
||||
) and not await has_permission(
|
||||
user_id,
|
||||
'access_grants.allow_groups',
|
||||
default_permissions,
|
||||
db=db,
|
||||
):
|
||||
access_grants = [
|
||||
grant
|
||||
for grant in access_grants
|
||||
if (grant.get('principal_type') if isinstance(grant, dict) else getattr(grant, 'principal_type', None))
|
||||
!= 'group'
|
||||
]
|
||||
|
||||
return access_grants
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import sys
|
|||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from open_webui.env import GLOBAL_LOG_LEVEL
|
||||
from open_webui.env import ENABLE_PLUGINS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.users import UserModel
|
||||
from open_webui.socket.main import get_event_call, get_event_emitter
|
||||
from open_webui.utils.middleware import process_tool_result
|
||||
from open_webui.utils.models import get_all_models
|
||||
from open_webui.utils.models import check_model_access, get_all_models
|
||||
from open_webui.utils.plugin import get_function_module_from_cache
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
|
|
@ -17,6 +17,9 @@ log = logging.getLogger(__name__)
|
|||
|
||||
|
||||
async def chat_action(request: Request, action_id: str, form_data: dict, user: Any):
|
||||
if not ENABLE_PLUGINS:
|
||||
raise Exception('Plugins are disabled by ENABLE_PLUGINS=false')
|
||||
|
||||
if '.' in action_id:
|
||||
action_id, sub_action_id = action_id.split('.')
|
||||
else:
|
||||
|
|
@ -43,6 +46,23 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
|
|||
raise Exception('Model not found')
|
||||
model = models[model_id]
|
||||
|
||||
# Availability gate — keep this route consistent with the actions a model
|
||||
# actually surfaces to the client. Executing admin-authored Function code is
|
||||
# intended; this only stops a disabled, unassigned, or access-restricted
|
||||
# action from being reached by calling the route with a raw action_id.
|
||||
if action.type != 'action' or not action.is_active:
|
||||
raise Exception(f'Action not available: {action_id}')
|
||||
|
||||
# Direct connections carry a client-supplied model the caller already owns,
|
||||
# so scope the model-bound checks to server-resolved models.
|
||||
if not getattr(request.state, 'direct', False) and user.role != 'admin':
|
||||
await check_model_access(user, model)
|
||||
# model['actions'] entries are '<function_id>' or '<function_id>.<sub_id>';
|
||||
# the function id is always the prefix.
|
||||
surfaced_action_ids = {item.get('id', '').split('.', 1)[0] for item in model.get('actions', [])}
|
||||
if action_id not in surfaced_action_ids:
|
||||
raise Exception(f'Action not available: {action_id}')
|
||||
|
||||
__event_emitter__ = await get_event_emitter(
|
||||
{
|
||||
'chat_id': data['chat_id'],
|
||||
|
|
|
|||
|
|
@ -12,6 +12,23 @@ from open_webui.utils.headers import include_user_info_headers
|
|||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ANTHROPIC_CONVERTED_REQUEST_PARAMS = {
|
||||
'model',
|
||||
'messages',
|
||||
'system',
|
||||
'max_tokens',
|
||||
'temperature',
|
||||
'top_p',
|
||||
'top_k',
|
||||
'stop_sequences',
|
||||
'stream',
|
||||
'metadata',
|
||||
'service_tier',
|
||||
'tools',
|
||||
'tool_choice',
|
||||
'reasoning_effort',
|
||||
}
|
||||
|
||||
|
||||
def is_anthropic_url(url: str) -> bool:
|
||||
"""Check if the URL is an Anthropic API endpoint."""
|
||||
|
|
@ -109,7 +126,9 @@ def _finalize_openai_content(blocks: list) -> str | list:
|
|||
return blocks
|
||||
|
||||
|
||||
def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
||||
def convert_anthropic_to_openai_payload(
|
||||
anthropic_payload: dict, passthrough_params: list[str] | str | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Convert an Anthropic Messages API request to OpenAI Chat Completions format.
|
||||
|
||||
|
|
@ -350,6 +369,48 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
|||
if 'max_tokens' in anthropic_payload:
|
||||
openai_payload['max_tokens'] = anthropic_payload['max_tokens']
|
||||
|
||||
captured_passthrough_params = {
|
||||
param: value for param, value in anthropic_payload.items() if param not in ANTHROPIC_CONVERTED_REQUEST_PARAMS
|
||||
}
|
||||
if isinstance(passthrough_params, str):
|
||||
passthrough_params = passthrough_params.split(',')
|
||||
elif not isinstance(passthrough_params, (list, tuple, set)):
|
||||
passthrough_params = []
|
||||
passthrough_param_names = {str(item).strip() for item in passthrough_params if str(item).strip()}
|
||||
if '*' in passthrough_param_names:
|
||||
openai_payload.update(captured_passthrough_params)
|
||||
else:
|
||||
for param in passthrough_param_names:
|
||||
if param in captured_passthrough_params:
|
||||
openai_payload[param] = captured_passthrough_params[param]
|
||||
|
||||
output_config = anthropic_payload.get('output_config')
|
||||
if isinstance(output_config, dict):
|
||||
if 'effort' in output_config and 'reasoning_effort' not in anthropic_payload:
|
||||
openai_payload['reasoning_effort'] = output_config['effort']
|
||||
|
||||
format_config = output_config.get('format')
|
||||
if isinstance(format_config, dict):
|
||||
format_type = format_config.get('type')
|
||||
if format_type == 'json_schema':
|
||||
json_schema = {
|
||||
'name': format_config.get('name', 'response_schema'),
|
||||
'schema': format_config.get('schema', {}),
|
||||
}
|
||||
if 'description' in format_config:
|
||||
json_schema['description'] = format_config['description']
|
||||
if 'strict' in format_config:
|
||||
json_schema['strict'] = format_config['strict']
|
||||
openai_payload['response_format'] = {
|
||||
'type': 'json_schema',
|
||||
'json_schema': json_schema,
|
||||
}
|
||||
elif format_type == 'json_object':
|
||||
openai_payload['response_format'] = {'type': format_type}
|
||||
|
||||
if 'reasoning_effort' in anthropic_payload:
|
||||
openai_payload['reasoning_effort'] = anthropic_payload['reasoning_effort']
|
||||
|
||||
# Common parameters
|
||||
for param in ('temperature', 'top_p', 'top_k', 'stop_sequences', 'stream', 'metadata', 'service_tier'):
|
||||
if param in anthropic_payload:
|
||||
|
|
@ -395,7 +456,9 @@ def convert_anthropic_to_openai_payload(anthropic_payload: dict) -> dict:
|
|||
return openai_payload
|
||||
|
||||
|
||||
def convert_openai_to_anthropic_response(openai_response: dict, model: str = '') -> dict:
|
||||
def convert_openai_to_anthropic_response(
|
||||
openai_response: dict, model: str = '', input_tokens: int | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Convert a non-streaming OpenAI Chat Completions response to Anthropic Messages format.
|
||||
"""
|
||||
|
|
@ -419,6 +482,32 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
|
|||
|
||||
# Build content blocks
|
||||
content = []
|
||||
message_thinking = message.get('thinking')
|
||||
thinking_blocks = message.get('thinking_blocks') or []
|
||||
if not thinking_blocks and isinstance(message_thinking, dict):
|
||||
thinking_blocks = message_thinking.get('blocks') or []
|
||||
|
||||
has_thinking = False
|
||||
for block in thinking_blocks:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
|
||||
thinking = block.get('thinking') or block.get('content') or block.get('text')
|
||||
if not thinking:
|
||||
continue
|
||||
|
||||
thinking_block = {'type': 'thinking', 'thinking': thinking}
|
||||
if block.get('signature'):
|
||||
thinking_block['signature'] = block['signature']
|
||||
content.append(thinking_block)
|
||||
has_thinking = True
|
||||
|
||||
reasoning_content = message.get('reasoning_content') or message.get('reasoning')
|
||||
if not reasoning_content and isinstance(message_thinking, str):
|
||||
reasoning_content = message_thinking
|
||||
if reasoning_content and not has_thinking:
|
||||
content.append({'type': 'thinking', 'thinking': reasoning_content})
|
||||
|
||||
message_content = message.get('content')
|
||||
if message_content:
|
||||
content.append({'type': 'text', 'text': message_content})
|
||||
|
|
@ -443,8 +532,12 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
|
|||
# Usage
|
||||
openai_usage = openai_response.get('usage', {})
|
||||
usage = {
|
||||
'input_tokens': openai_usage.get('prompt_tokens', 0),
|
||||
'output_tokens': openai_usage.get('completion_tokens', 0),
|
||||
'input_tokens': (
|
||||
input_tokens
|
||||
if input_tokens is not None
|
||||
else openai_usage.get('input_tokens', openai_usage.get('prompt_tokens', 0))
|
||||
),
|
||||
'output_tokens': openai_usage.get('output_tokens', openai_usage.get('completion_tokens', 0)),
|
||||
}
|
||||
if 'cache_creation_input_tokens' in openai_usage:
|
||||
usage['cache_creation_input_tokens'] = openai_usage['cache_creation_input_tokens']
|
||||
|
|
@ -463,7 +556,7 @@ def convert_openai_to_anthropic_response(openai_response: dict, model: str = '')
|
|||
}
|
||||
|
||||
|
||||
async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = ''):
|
||||
async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str = '', input_tokens: int = 0):
|
||||
"""
|
||||
Convert an OpenAI SSE streaming response to Anthropic Messages SSE format.
|
||||
|
||||
|
|
@ -480,13 +573,13 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
|||
import uuid as _uuid
|
||||
|
||||
message_id = f'msg_{_uuid.uuid4().hex[:24]}'
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
stop_reason = 'end_turn'
|
||||
|
||||
# Track content blocks with a running index.
|
||||
# Each text block or tool_use block gets its own index.
|
||||
current_block_index = 0
|
||||
thinking_block_open = False
|
||||
text_block_open = False
|
||||
|
||||
# Accumulated state for each tool call, keyed by tool call id.
|
||||
|
|
@ -510,7 +603,7 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
|||
'model': model,
|
||||
'stop_reason': None,
|
||||
'stop_sequence': None,
|
||||
'usage': {'input_tokens': 0, 'output_tokens': 0},
|
||||
'usage': {'input_tokens': input_tokens, 'output_tokens': 0},
|
||||
},
|
||||
}
|
||||
yield f'event: message_start\ndata: {json.dumps(message_start)}\n\n'.encode()
|
||||
|
|
@ -541,8 +634,12 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
|||
if not choices:
|
||||
# Check for usage in the final chunk
|
||||
if data.get('usage'):
|
||||
input_tokens = data['usage'].get('prompt_tokens', input_tokens)
|
||||
output_tokens = data['usage'].get('completion_tokens', output_tokens)
|
||||
input_tokens = data['usage'].get(
|
||||
'input_tokens', data['usage'].get('prompt_tokens', input_tokens)
|
||||
)
|
||||
output_tokens = data['usage'].get(
|
||||
'output_tokens', data['usage'].get('completion_tokens', output_tokens)
|
||||
)
|
||||
continue
|
||||
|
||||
delta = choices[0].get('delta', {})
|
||||
|
|
@ -551,14 +648,57 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
|||
|
||||
# Update usage if present
|
||||
if data.get('usage'):
|
||||
input_tokens = data['usage'].get('prompt_tokens', input_tokens)
|
||||
output_tokens = data['usage'].get('completion_tokens', output_tokens)
|
||||
input_tokens = data['usage'].get('input_tokens', data['usage'].get('prompt_tokens', input_tokens))
|
||||
output_tokens = data['usage'].get(
|
||||
'output_tokens', data['usage'].get('completion_tokens', output_tokens)
|
||||
)
|
||||
|
||||
reasoning_content = (
|
||||
delta.get('reasoning_content')
|
||||
or delta.get('reasoning')
|
||||
or delta.get('thinking')
|
||||
or message.get('reasoning_content')
|
||||
or message.get('reasoning')
|
||||
)
|
||||
if not reasoning_content:
|
||||
thinking_blocks = delta.get('thinking_blocks') or message.get('thinking_blocks') or []
|
||||
for block in thinking_blocks:
|
||||
if isinstance(block, dict):
|
||||
reasoning_content = block.get('thinking') or block.get('content') or block.get('text')
|
||||
if reasoning_content:
|
||||
break
|
||||
|
||||
if reasoning_content and not text_block_open and not has_tool_calls:
|
||||
if not thinking_block_open:
|
||||
block_start = {
|
||||
'type': 'content_block_start',
|
||||
'index': current_block_index,
|
||||
'content_block': {'type': 'thinking', 'thinking': ''},
|
||||
}
|
||||
yield f'event: content_block_start\ndata: {json.dumps(block_start)}\n\n'.encode()
|
||||
thinking_block_open = True
|
||||
|
||||
block_delta = {
|
||||
'type': 'content_block_delta',
|
||||
'index': current_block_index,
|
||||
'delta': {'type': 'thinking_delta', 'thinking': reasoning_content},
|
||||
}
|
||||
yield f'event: content_block_delta\ndata: {json.dumps(block_delta)}\n\n'.encode()
|
||||
|
||||
# --- Handle text content ---
|
||||
# Anthropic expects text blocks before tool blocks, so skip
|
||||
# text deltas once any tool call has started.
|
||||
content = delta.get('content')
|
||||
if content and not has_tool_calls:
|
||||
if thinking_block_open:
|
||||
block_stop = {
|
||||
'type': 'content_block_stop',
|
||||
'index': current_block_index,
|
||||
}
|
||||
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
|
||||
thinking_block_open = False
|
||||
current_block_index += 1
|
||||
|
||||
if not text_block_open:
|
||||
block_start = {
|
||||
'type': 'content_block_start',
|
||||
|
|
@ -584,6 +724,15 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
|||
|
||||
if tool_calls:
|
||||
# Close text block if one is open (text comes before tools)
|
||||
if thinking_block_open:
|
||||
block_stop = {
|
||||
'type': 'content_block_stop',
|
||||
'index': current_block_index,
|
||||
}
|
||||
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
|
||||
thinking_block_open = False
|
||||
current_block_index += 1
|
||||
|
||||
if text_block_open:
|
||||
block_stop = {
|
||||
'type': 'content_block_stop',
|
||||
|
|
@ -695,6 +844,12 @@ async def openai_stream_to_anthropic_stream(openai_stream_generator, model: str
|
|||
except Exception as e:
|
||||
log.error(f'Error in Anthropic stream conversion: {e}')
|
||||
|
||||
# Close any open thinking block
|
||||
if thinking_block_open:
|
||||
block_stop = {'type': 'content_block_stop', 'index': current_block_index}
|
||||
yield f'event: content_block_stop\ndata: {json.dumps(block_stop)}\n\n'.encode()
|
||||
current_block_index += 1
|
||||
|
||||
# Flush any tools that buffered arguments but never emitted a block
|
||||
for tool in tracked_tool_calls.values():
|
||||
if not tool['started'] and tool['name']:
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ from fastapi.responses import JSONResponse, RedirectResponse
|
|||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from open_webui.env import CUSTOM_API_KEY_HEADER
|
||||
from open_webui.internal.db import ScopedSession
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.utils.auth import get_http_authorization_cred
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.requests import Request
|
||||
|
|
@ -138,9 +137,7 @@ class AuthTokenMiddleware:
|
|||
the middleware checks that instead and avoids the 401 short-circuit.
|
||||
|
||||
Routes that depend on `get_verified_user` etc. read this state.
|
||||
Also exposes `request.state.enable_api_keys` (snapshotted at request
|
||||
entry from runtime config) and stamps an `X-Process-Time` response
|
||||
header.
|
||||
Also stamps an `X-Process-Time` response header.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp, *, fastapi_app) -> None:
|
||||
|
|
@ -166,7 +163,6 @@ class AuthTokenMiddleware:
|
|||
token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=api_key)
|
||||
|
||||
request.state.token = token
|
||||
request.state.enable_api_keys = await Config.get('auth.enable_api_keys')
|
||||
|
||||
async def send_with_timing(message: Message) -> None:
|
||||
if message['type'] == 'http.response.start':
|
||||
|
|
@ -231,7 +227,15 @@ class RedirectMiddleware:
|
|||
return
|
||||
|
||||
path = scope.get('path', '')
|
||||
query_string = scope.get('query_string', b'').decode('latin-1', errors='replace')
|
||||
raw_query = scope.get('query_string', b'')
|
||||
# This middleware only acts on /watch?v= and ?shared= URLs; skip the
|
||||
# decode + parse_qs work for every other GET. (A false positive on the
|
||||
# substring check just falls through to the full parse below.)
|
||||
if not (path.endswith('/watch') or b'shared' in raw_query):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
query_string = raw_query.decode('latin-1', errors='replace')
|
||||
query_params = parse_qs(query_string)
|
||||
|
||||
redirect_params: dict[str, str] = {}
|
||||
|
|
|
|||
|
|
@ -96,11 +96,15 @@ def get_license_data(app, key):
|
|||
setattr(app.state, 'LICENSE_METADATA', v)
|
||||
|
||||
def handler(u):
|
||||
res = requests.post(
|
||||
f'{u}/api/v1/license/',
|
||||
json={'key': key, 'version': '1'},
|
||||
timeout=5,
|
||||
)
|
||||
try:
|
||||
res = requests.post(
|
||||
f'{u}/api/v1/license/',
|
||||
json={'key': key, 'version': '1'},
|
||||
timeout=5,
|
||||
)
|
||||
except Exception as ex:
|
||||
log.error(f'License: retrieval issue from {u}: {ex}')
|
||||
return False
|
||||
|
||||
if getattr(res, 'ok', False):
|
||||
payload = getattr(res, 'json', lambda: {})()
|
||||
|
|
@ -399,8 +403,6 @@ async def get_current_user(
|
|||
|
||||
# Refresh the user's last active timestamp
|
||||
# Fire-and-forget via asyncio.create_task to avoid blocking
|
||||
import asyncio
|
||||
|
||||
asyncio.create_task(Users.update_last_active_by_id(user.id))
|
||||
return user
|
||||
else:
|
||||
|
|
@ -433,24 +435,30 @@ async def get_current_user_by_api_key(request, api_key: str):
|
|||
detail=ERROR_MESSAGES.INVALID_TOKEN,
|
||||
)
|
||||
|
||||
user_permissions = await Config.get('user.permissions')
|
||||
enable_endpoint_restrictions = await Config.get('auth.api_key.endpoint_restrictions')
|
||||
allowed_endpoints = await Config.get('auth.api_key.allowed_endpoints', '')
|
||||
config_values = await Config.get_many(
|
||||
'auth.enable_api_keys',
|
||||
'user.permissions',
|
||||
'auth.api_key.endpoint_restrictions',
|
||||
'auth.api_key.allowed_endpoints',
|
||||
)
|
||||
|
||||
if not request.state.enable_api_keys or (
|
||||
user.role != 'admin'
|
||||
and not await has_permission(
|
||||
if not config_values.get('auth.enable_api_keys'):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
|
||||
|
||||
if user.role != 'admin':
|
||||
user_permissions = config_values.get('user.permissions')
|
||||
if not await has_permission(
|
||||
user.id,
|
||||
'features.api_keys',
|
||||
user_permissions,
|
||||
)
|
||||
):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
|
||||
):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED)
|
||||
|
||||
# Enforce endpoint restrictions — checked here (not in middleware)
|
||||
# so it applies regardless of how the API key was transported
|
||||
# (Authorization header, cookie, x-api-key header, etc.).
|
||||
if enable_endpoint_restrictions:
|
||||
if config_values.get('auth.api_key.endpoint_restrictions'):
|
||||
allowed_endpoints = config_values.get('auth.api_key.allowed_endpoints', '')
|
||||
allowed_paths = [path.strip() for path in str(allowed_endpoints).split(',') if path.strip()]
|
||||
request_path = request.scope['path'] # Use raw ASGI path — not spoofable via Host header (CVE-2026-48710)
|
||||
is_allowed = any(request_path == allowed or request_path.startswith(allowed + '/') for allowed in allowed_paths)
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ Follows the utils/<feature>.py pattern (cf. utils/channels.py, utils/task.py).
|
|||
The scheduler_worker_loop handles all time-based background work:
|
||||
- Automation execution (claim_due → execute)
|
||||
- Calendar event alerts (upcoming events → socket + webhook notifications)
|
||||
- One-shot chat timers
|
||||
|
||||
Environment:
|
||||
SCHEDULER_POLL_INTERVAL – seconds between polls (default: 10)
|
||||
TIMER_POLL_INTERVAL – seconds between timer polls (default: 1)
|
||||
CALENDAR_ALERT_LOOKAHEAD_MINUTES – default alert window (default: 5)
|
||||
"""
|
||||
|
||||
|
|
@ -36,11 +38,13 @@ from open_webui.models.users import Users
|
|||
from open_webui.utils.auth import create_token
|
||||
from open_webui.utils.misc import parse_duration
|
||||
from open_webui.utils.task import prompt_template
|
||||
from open_webui.utils.terminals import get_terminal_server_url
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SCHEDULER_POLL_INTERVAL = int(os.getenv('SCHEDULER_POLL_INTERVAL', os.getenv('AUTOMATION_POLL_INTERVAL', '10')))
|
||||
TIMER_POLL_INTERVAL = int(os.getenv('TIMER_POLL_INTERVAL', '1'))
|
||||
CALENDAR_ALERT_LOOKAHEAD_MINUTES = int(os.getenv('CALENDAR_ALERT_LOOKAHEAD_MINUTES', '10'))
|
||||
|
||||
|
||||
|
|
@ -65,7 +69,7 @@ def _resolve_tz(tz: str = None) -> Optional[ZoneInfo]:
|
|||
return None
|
||||
|
||||
|
||||
def _parse_rule(s: str):
|
||||
def _parse_rule(s: str, now: Optional[datetime] = None):
|
||||
"""Parse RRULE with clock-aligned DTSTART for sub-daily frequencies.
|
||||
|
||||
MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00)
|
||||
|
|
@ -77,6 +81,20 @@ def _parse_rule(s: str):
|
|||
|
||||
if freq in ('MINUTELY', 'HOURLY'):
|
||||
epoch = datetime(2000, 1, 1, 0, 0, 0)
|
||||
if (
|
||||
now is not None
|
||||
and s.startswith('RRULE:')
|
||||
and '\n' not in s
|
||||
and '\r' not in s
|
||||
and set(parts) <= {'FREQ', 'INTERVAL', 'BYMINUTE', 'BYSECOND'}
|
||||
):
|
||||
try:
|
||||
interval = int(parts.get('INTERVAL', '1'))
|
||||
if interval > 0:
|
||||
step = timedelta(minutes=interval) if freq == 'MINUTELY' else timedelta(hours=interval)
|
||||
return rrulestr(s, dtstart=epoch + ((now - epoch) // step) * step, ignoretz=True)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return rrulestr(s, dtstart=epoch, ignoretz=True)
|
||||
return rrulestr(s, ignoretz=True)
|
||||
|
||||
|
|
@ -88,12 +106,12 @@ 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).
|
||||
"""
|
||||
try:
|
||||
rule = _parse_rule(s)
|
||||
except Exception as e:
|
||||
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
|
||||
zi = _resolve_tz(tz)
|
||||
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
|
||||
try:
|
||||
rule = _parse_rule(s, now)
|
||||
except Exception as e:
|
||||
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
|
||||
if rule.after(now) is None:
|
||||
raise ValueError(ERROR_MESSAGES.AUTOMATION_NO_FUTURE_RUNS)
|
||||
|
||||
|
|
@ -102,7 +120,8 @@ def next_run_ns(s: str, tz: str = None) -> Optional[int]:
|
|||
"""Next occurrence as epoch nanoseconds, respecting user timezone."""
|
||||
zi = _resolve_tz(tz)
|
||||
now = datetime.now(zi) if zi else datetime.now()
|
||||
dt = _parse_rule(s).after(now.replace(tzinfo=None))
|
||||
now_naive = now.replace(tzinfo=None)
|
||||
dt = _parse_rule(s, now_naive).after(now_naive)
|
||||
if dt is None:
|
||||
return None
|
||||
if zi:
|
||||
|
|
@ -117,9 +136,9 @@ def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]:
|
|||
preview matches the user's local clock (same as next_run_ns).
|
||||
"""
|
||||
zi = _resolve_tz(tz)
|
||||
rule = _parse_rule(s)
|
||||
result = []
|
||||
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
|
||||
rule = _parse_rule(s, now)
|
||||
dt = now
|
||||
for _ in range(n):
|
||||
dt = rule.after(dt)
|
||||
|
|
@ -141,8 +160,8 @@ def rrule_interval_seconds(s: str) -> Optional[int]:
|
|||
"""
|
||||
if 'COUNT=1' in s:
|
||||
return None
|
||||
rule = _parse_rule(s)
|
||||
now = datetime.now()
|
||||
rule = _parse_rule(s, now)
|
||||
first = rule.after(now)
|
||||
if first is None:
|
||||
return None
|
||||
|
|
@ -173,9 +192,30 @@ async def scheduler_worker_loop(app) -> None:
|
|||
Runs on every instance. Poll interval is configurable via
|
||||
SCHEDULER_POLL_INTERVAL env var (default: 10 seconds).
|
||||
"""
|
||||
log.info(f'Scheduler worker started (poll interval: {SCHEDULER_POLL_INTERVAL}s)')
|
||||
log.info(
|
||||
f'Scheduler worker started (timer poll interval: {TIMER_POLL_INTERVAL}s, '
|
||||
f'scheduler poll interval: {SCHEDULER_POLL_INTERVAL}s)'
|
||||
)
|
||||
next_scheduler_poll = 0.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
now = time.monotonic()
|
||||
# ── Timers ──
|
||||
try:
|
||||
from open_webui.utils.timers import claim_due_timers, execute_due_timer
|
||||
|
||||
for timer_id, claim_id in await claim_due_timers(int(time.time_ns()), limit=10):
|
||||
asyncio.create_task(execute_due_timer(app, timer_id, claim_id))
|
||||
except Exception:
|
||||
log.exception('Scheduler: timer error')
|
||||
|
||||
if now < next_scheduler_poll:
|
||||
await asyncio.sleep(max(1, TIMER_POLL_INTERVAL))
|
||||
continue
|
||||
# Jitter to spread automation/calendar load across instances; timers keep a tight poll.
|
||||
next_scheduler_poll = now + SCHEDULER_POLL_INTERVAL + random.uniform(0, 2)
|
||||
|
||||
# ── Automations ──
|
||||
if await Config.get('automations.enable'):
|
||||
try:
|
||||
|
|
@ -198,8 +238,7 @@ async def scheduler_worker_loop(app) -> None:
|
|||
except Exception:
|
||||
log.exception('Scheduler worker error')
|
||||
|
||||
# Jitter to spread load across instances
|
||||
await asyncio.sleep(SCHEDULER_POLL_INTERVAL + random.uniform(0, 2))
|
||||
await asyncio.sleep(max(1, TIMER_POLL_INTERVAL))
|
||||
|
||||
|
||||
##########################
|
||||
|
|
@ -323,16 +362,11 @@ async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -
|
|||
log.warning(f'Terminal server {server_id} not found for CWD set')
|
||||
return
|
||||
|
||||
base_url = (connection.get('url') or '').rstrip('/')
|
||||
base_url = get_terminal_server_url(connection)
|
||||
if not base_url:
|
||||
return
|
||||
|
||||
# Build target URL — route through orchestrator policy if configured
|
||||
policy_id = connection.get('policy_id')
|
||||
if connection.get('server_type') == 'orchestrator' and policy_id:
|
||||
target_url = f'{base_url}/p/{policy_id}/files/cwd'
|
||||
else:
|
||||
target_url = f'{base_url}/files/cwd'
|
||||
target_url = f'{base_url}/files/cwd'
|
||||
|
||||
headers = {'Content-Type': 'application/json', 'X-User-Id': user.id}
|
||||
if chat_id:
|
||||
|
|
@ -613,40 +647,25 @@ async def _check_calendar_alerts(app) -> None:
|
|||
except Exception:
|
||||
log.debug(f'Failed to mark event {event.id} as alerted', exc_info=True)
|
||||
|
||||
# Send webhook notification if user has one configured
|
||||
# Send target notification if user has one configured
|
||||
try:
|
||||
webui_name = getattr(app.state, 'WEBUI_NAME', 'Open WebUI')
|
||||
enable_user_webhooks = await Config.get('ui.enable_user_webhooks')
|
||||
|
||||
if enable_user_webhooks:
|
||||
user = await Users.get_user_by_id(event.user_id)
|
||||
if user and user.settings:
|
||||
webhook_url = (
|
||||
user.settings.get('ui', {}).get('notifications', {}).get('webhook_url', None)
|
||||
if isinstance(user.settings, dict)
|
||||
else getattr(getattr(user.settings, 'ui', None), 'get', lambda *a: None)(
|
||||
'notifications', {}
|
||||
).get('webhook_url', None)
|
||||
if hasattr(user.settings, 'ui')
|
||||
else None
|
||||
)
|
||||
if webhook_url:
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
|
||||
time_str = f'in {minutes_until} min' if minutes_until > 0 else 'now'
|
||||
await post_webhook(
|
||||
webui_name,
|
||||
webhook_url,
|
||||
f'{event.title} — starting {time_str}',
|
||||
{
|
||||
'action': 'calendar_alert',
|
||||
'title': event.title,
|
||||
'minutes_until': minutes_until,
|
||||
'event_id': event.id,
|
||||
},
|
||||
)
|
||||
time_str = f'in {minutes_until} min' if minutes_until > 0 else 'now'
|
||||
await publish_event(
|
||||
app,
|
||||
EVENTS.CALENDAR_ALERT,
|
||||
subject_id=event.id,
|
||||
subject_type='calendar.event',
|
||||
source='scheduler',
|
||||
data={
|
||||
**alert_data,
|
||||
'user_id': event.user_id,
|
||||
'starts_in': time_str,
|
||||
'message': f'{event.title}: starting {time_str}',
|
||||
},
|
||||
message=event.title,
|
||||
)
|
||||
except Exception:
|
||||
log.debug(f'Failed to send webhook for calendar alert {event.id}', exc_info=True)
|
||||
log.debug(f'Failed to send notification for calendar alert {event.id}', exc_info=True)
|
||||
|
||||
|
||||
async def _record_run(
|
||||
|
|
|
|||
41
backend/open_webui/utils/chat_fork.py
Normal file
41
backend/open_webui/utils/chat_fork.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from copy import deepcopy
|
||||
|
||||
|
||||
def build_fork_history(messages_map: dict, source_message_id: str) -> tuple[dict, list[dict]]:
|
||||
if not messages_map:
|
||||
raise ValueError('chat has no messages to fork')
|
||||
|
||||
branch: list[tuple[str, dict]] = []
|
||||
seen: set[str] = set()
|
||||
message_id = source_message_id
|
||||
|
||||
while message_id:
|
||||
if message_id in seen:
|
||||
raise ValueError('message branch contains a cycle')
|
||||
seen.add(message_id)
|
||||
|
||||
message = messages_map.get(message_id)
|
||||
if not isinstance(message, dict):
|
||||
raise ValueError('message not found')
|
||||
|
||||
branch.append((message_id, message))
|
||||
message_id = message.get('parentId')
|
||||
|
||||
fork_messages: dict[str, dict] = {}
|
||||
ordered_messages: list[dict] = []
|
||||
parent_id = None
|
||||
|
||||
for message_id, message in reversed(branch):
|
||||
copied = deepcopy(message)
|
||||
copied['id'] = message_id
|
||||
copied['parentId'] = parent_id
|
||||
copied['childrenIds'] = []
|
||||
|
||||
if parent_id:
|
||||
fork_messages[parent_id]['childrenIds'] = [message_id]
|
||||
|
||||
fork_messages[message_id] = copied
|
||||
ordered_messages.append(copied)
|
||||
parent_id = message_id
|
||||
|
||||
return {'messages': fork_messages, 'currentId': source_message_id}, ordered_messages
|
||||
|
|
@ -5,7 +5,6 @@ import logging
|
|||
from typing import Any
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.utils.misc import get_content_from_message, get_last_user_message, get_message_list
|
||||
|
|
@ -53,7 +52,7 @@ async def compact_messages_for_request(
|
|||
return messages, None, False
|
||||
|
||||
messages, previous_summary = _apply_latest_summary_checkpoint(messages)
|
||||
token_threshold = _resolve_token_threshold(config['token_threshold'], metadata)
|
||||
token_threshold = _resolve_token_threshold(config['token_threshold'], config['token_cap'], metadata)
|
||||
if not _exceeds_token_threshold(messages, system_prompt, previous_summary, token_threshold) or len(messages) <= 3:
|
||||
return messages, previous_summary, False
|
||||
|
||||
|
|
@ -108,12 +107,15 @@ async def compact_messages_for_request(
|
|||
raise
|
||||
|
||||
chat_id = metadata.get('chat_id')
|
||||
checkpoint_message_id = metadata.get('user_message_id') or metadata.get('message_id')
|
||||
checkpoint_message_id = (
|
||||
recent_messages[0].get('id') or metadata.get('user_message_id') or metadata.get('message_id')
|
||||
)
|
||||
if chat_id and checkpoint_message_id and not chat_id.startswith(('local:', 'channel:')):
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
chat_id,
|
||||
checkpoint_message_id,
|
||||
{'contextSummary': summary},
|
||||
touch=False,
|
||||
)
|
||||
|
||||
log.info(
|
||||
|
|
@ -146,8 +148,13 @@ async def compact_chat_branch(request, user, chat: Any, model_id: str, models: d
|
|||
if not config['enable']:
|
||||
return {'ok': True, 'compacted': False, 'reason': 'disabled'}
|
||||
|
||||
history = (chat.chat or {}).get('history') or {}
|
||||
current_id = history.get('currentId')
|
||||
chat_data = chat.chat or {}
|
||||
history = chat_data.get('history') or {}
|
||||
current_id = getattr(chat, 'current_message_id', None) or history.get('currentId')
|
||||
if not current_id:
|
||||
current_id = chat_data.get('currentId') or chat_data.get('branchPointMessageId')
|
||||
if not current_id and isinstance(chat_data.get('messages'), list) and chat_data['messages']:
|
||||
current_id = chat_data['messages'][-1].get('id')
|
||||
if not current_id:
|
||||
return {'ok': True, 'compacted': False, 'reason': 'empty'}
|
||||
|
||||
|
|
@ -156,11 +163,11 @@ async def compact_chat_branch(request, user, chat: Any, model_id: str, models: d
|
|||
messages_map = history.get('messages') or {}
|
||||
|
||||
messages, previous_summary = _apply_latest_summary_checkpoint(get_message_list(messages_map, current_id))
|
||||
if len(messages) <= 2:
|
||||
return {'ok': True, 'compacted': False, 'reason': 'too_short'}
|
||||
|
||||
compacted_messages = messages[:-1]
|
||||
recent_messages = messages[-1:]
|
||||
if not compacted_messages or not recent_messages:
|
||||
return {'ok': True, 'compacted': False, 'reason': 'too_short'}
|
||||
|
||||
summary = await _generate_summary(
|
||||
request,
|
||||
user,
|
||||
|
|
@ -171,7 +178,9 @@ async def compact_chat_branch(request, user, chat: Any, model_id: str, models: d
|
|||
previous_summary,
|
||||
config['prompt_template'],
|
||||
)
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(chat.id, current_id, {'contextSummary': summary})
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
chat.id, current_id, {'contextSummary': summary}, touch=False
|
||||
)
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
|
|
@ -186,11 +195,14 @@ async def _load_config() -> dict:
|
|||
values = await Config.get_many(
|
||||
'chat.context_compaction.enable',
|
||||
'chat.context_compaction.token_threshold',
|
||||
'chat.context_compaction.token_cap',
|
||||
'chat.context_compaction.prompt_template',
|
||||
)
|
||||
token_threshold = _parse_positive_int(values.get('chat.context_compaction.token_threshold')) or 80000
|
||||
return {
|
||||
'enable': bool(values.get('chat.context_compaction.enable', False)),
|
||||
'token_threshold': int(values.get('chat.context_compaction.token_threshold', 80000) or 80000),
|
||||
'token_threshold': token_threshold,
|
||||
'token_cap': _parse_positive_int(values.get('chat.context_compaction.token_cap')) or token_threshold,
|
||||
'prompt_template': values.get('chat.context_compaction.prompt_template', '') or '',
|
||||
}
|
||||
|
||||
|
|
@ -203,11 +215,57 @@ def _parse_positive_int(value: Any) -> int | None:
|
|||
return parsed if parsed > 0 else None
|
||||
|
||||
|
||||
def _resolve_token_threshold(global_threshold: int, metadata: dict) -> int:
|
||||
def _resolve_token_threshold(global_threshold: int, global_cap: int, metadata: dict) -> int:
|
||||
configured_threshold = _parse_positive_int((metadata.get('params') or {}).get('compact_token_threshold'))
|
||||
if configured_threshold is None:
|
||||
return global_threshold
|
||||
return min(configured_threshold, global_threshold)
|
||||
return min(configured_threshold or global_threshold, global_cap)
|
||||
|
||||
|
||||
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 {}
|
||||
current_id = getattr(chat, 'current_message_id', None) or history.get('currentId')
|
||||
if not current_id:
|
||||
current_id = chat_data.get('currentId') or chat_data.get('branchPointMessageId')
|
||||
if not current_id and isinstance(chat_data.get('messages'), list) and chat_data['messages']:
|
||||
current_id = chat_data['messages'][-1].get('id')
|
||||
if not current_id:
|
||||
return None
|
||||
|
||||
messages_map = await Chats.get_messages_map_by_chat_id(chat.id)
|
||||
messages = get_message_list(messages_map or history.get('messages') or {}, current_id)
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
config = await _load_config()
|
||||
if not config['enable']:
|
||||
return None
|
||||
|
||||
params = ((chat.chat or {}).get('params') or {}).copy()
|
||||
if model_id:
|
||||
params['model'] = model_id
|
||||
threshold = _resolve_token_threshold(config['token_threshold'], config['token_cap'], {'params': params})
|
||||
messages, previous_summary = _apply_latest_summary_checkpoint(messages)
|
||||
|
||||
for idx in range(len(messages) - 1, -1, -1):
|
||||
usage = messages[idx].get('usage') or (messages[idx].get('info') or {}).get('usage')
|
||||
input_tokens = (usage or {}).get('input_tokens') or (usage or {}).get('prompt_tokens')
|
||||
if isinstance(usage, dict) and input_tokens:
|
||||
tokens = int(input_tokens or 0) + int(usage.get('output_tokens') or usage.get('completion_tokens') or 0)
|
||||
tokens += _estimate_messages_tokens(messages[idx + 1 :])
|
||||
return _build_context_usage(tokens, threshold)
|
||||
|
||||
tokens = _estimate_tokens(previous_summary or '') + _estimate_messages_tokens(messages)
|
||||
return _build_context_usage(tokens, threshold)
|
||||
|
||||
|
||||
def _build_context_usage(tokens: int, threshold: int) -> dict:
|
||||
return {
|
||||
'tokens': tokens,
|
||||
'estimated_tokens': tokens,
|
||||
'threshold': threshold,
|
||||
'percent': round((tokens / threshold) * 100) if threshold > 0 else 0,
|
||||
'source': 'estimated',
|
||||
}
|
||||
|
||||
|
||||
def _apply_latest_summary_checkpoint(messages: list[dict]) -> tuple[list[dict], str | None]:
|
||||
|
|
@ -246,17 +304,9 @@ def _exceeds_token_threshold(messages: list[dict], system_prompt: str, summary:
|
|||
|
||||
def _find_compaction_boundary(messages: list[dict]) -> int:
|
||||
keep_count = max(2, len(messages) * 2 // 5)
|
||||
split = max(1, len(messages) - keep_count)
|
||||
|
||||
while split < len(messages) - 1:
|
||||
previous = messages[split - 1] if split > 0 else {}
|
||||
current = messages[split]
|
||||
if current.get('role') == 'tool' or previous.get('tool_calls') or previous.get('output'):
|
||||
split += 1
|
||||
continue
|
||||
break
|
||||
|
||||
return min(split, len(messages) - 2)
|
||||
target = max(1, len(messages) - keep_count)
|
||||
boundaries = [idx for idx, message in enumerate(messages) if message.get('role') == 'user'][1:]
|
||||
return next((idx for idx in reversed(boundaries) if idx <= target), 0)
|
||||
|
||||
|
||||
async def _generate_summary(
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import inspect
|
||||
import logging
|
||||
|
||||
from open_webui.env import ENABLE_PLUGINS
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.utils.plugin import (
|
||||
get_function_module_from_cache,
|
||||
load_function_module_by_id,
|
||||
)
|
||||
from open_webui.utils.plugin import get_function_module_from_cache
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -19,22 +17,15 @@ async def get_function_module(request, function_id, load_from_db=True):
|
|||
|
||||
|
||||
async def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list = None):
|
||||
async def get_priority(function_id):
|
||||
try:
|
||||
function_module = await get_function_module(request, function_id)
|
||||
if function_module and hasattr(function_module, 'Valves'):
|
||||
valves_db = await Functions.get_function_valves_by_id(function_id)
|
||||
valves = function_module.Valves(**(valves_db if valves_db else {}))
|
||||
return getattr(valves, 'priority', 0)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
if not ENABLE_PLUGINS:
|
||||
return []
|
||||
|
||||
filter_ids = [function.id for function in await Functions.get_global_filter_functions()]
|
||||
active_filters = await Functions.get_active_filter_ids()
|
||||
filter_ids = [fid for fid, is_global in active_filters if is_global]
|
||||
if 'info' in model and 'meta' in model['info']:
|
||||
filter_ids.extend(model['info']['meta'].get('filterIds', []))
|
||||
filter_ids = list(set(filter_ids))
|
||||
active_filter_ids = {function.id for function in await Functions.get_functions_by_type('filter', active_only=True)}
|
||||
active_filter_ids = {fid for fid, _ in active_filters}
|
||||
|
||||
async def get_active_status(filter_id):
|
||||
function_module = await get_function_module(request, filter_id)
|
||||
|
|
@ -51,6 +42,18 @@ async def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list =
|
|||
active_filter_ids = {fid for fid, is_active in resolved_active.items() if is_active}
|
||||
|
||||
filter_ids = [fid for fid in filter_ids if fid in active_filter_ids]
|
||||
valves_by_id = await Functions.get_function_valves_by_ids(filter_ids)
|
||||
|
||||
async def get_priority(function_id):
|
||||
try:
|
||||
function_module = await get_function_module(request, function_id)
|
||||
if function_module and hasattr(function_module, 'Valves'):
|
||||
valves_db = valves_by_id.get(function_id)
|
||||
valves = function_module.Valves(**(valves_db if valves_db else {}))
|
||||
return getattr(valves, 'priority', 0)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
# Pre-compute priorities (async functions can't be used in sort keys)
|
||||
priorities = {}
|
||||
|
|
@ -64,13 +67,17 @@ async def get_sorted_filter_ids(request, model: dict, enabled_filter_ids: list =
|
|||
# Grant these filters the discernment to pass what serves
|
||||
# and refuse what harms, for every soul in the house.
|
||||
async def process_filter_functions(request, filter_functions, filter_type, form_data, extra_params):
|
||||
if not ENABLE_PLUGINS:
|
||||
return form_data, {}
|
||||
|
||||
skip_files = None
|
||||
valves_by_id = None
|
||||
filter_ids = [function.id for function in filter_functions if function]
|
||||
|
||||
for function in filter_functions:
|
||||
filter = function
|
||||
filter_id = function.id
|
||||
if not filter:
|
||||
if not function:
|
||||
continue
|
||||
filter_id = function.id
|
||||
|
||||
function_module = await get_function_module(request, filter_id, load_from_db=(filter_type != 'stream'))
|
||||
# Prepare handler function
|
||||
|
|
@ -84,7 +91,9 @@ async def process_filter_functions(request, filter_functions, filter_type, form_
|
|||
|
||||
# Apply valves to the function
|
||||
if hasattr(function_module, 'valves') and hasattr(function_module, 'Valves'):
|
||||
valves = await Functions.get_function_valves_by_id(filter_id)
|
||||
if valves_by_id is None:
|
||||
valves_by_id = await Functions.get_function_valves_by_ids(filter_ids)
|
||||
valves = valves_by_id.get(filter_id)
|
||||
function_module.valves = function_module.Valves(**(valves if valves else {}))
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from open_webui.env import (
|
|||
CHAT_RESPONSE_STREAM_DELTA_CHUNK_SIZE,
|
||||
ENABLE_API_OUTLET_FILTERS,
|
||||
ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION,
|
||||
ENABLE_PLUGINS,
|
||||
ENABLE_QUERIES_CACHE,
|
||||
ENABLE_REALTIME_CHAT_SAVE,
|
||||
ENABLE_RESPONSES_API_STATEFUL,
|
||||
|
|
@ -44,8 +45,10 @@ from open_webui.models.config import Config
|
|||
from open_webui.models.folders import Folders
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.models import Models
|
||||
from open_webui.models.notes import Notes
|
||||
from open_webui.models.oauth_sessions import OAuthSessions
|
||||
from open_webui.models.users import UserModel, Users
|
||||
from open_webui.events import EVENTS, publish_event
|
||||
from open_webui.retrieval.utils import get_sources_from_items
|
||||
from open_webui.routers.images import (
|
||||
CreateImageForm,
|
||||
|
|
@ -73,7 +76,8 @@ from open_webui.socket.main import (
|
|||
get_event_emitter,
|
||||
)
|
||||
from open_webui.utils.access_control import has_connection_access, has_permission
|
||||
from open_webui.utils.access_control.files import get_accessible_folder_files
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.utils.access_control.folders import has_folder_access
|
||||
from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.utils.code_interpreter import execute_code_jupyter
|
||||
from open_webui.utils.context_compaction import compact_messages_for_request
|
||||
|
|
@ -126,13 +130,54 @@ from open_webui.utils.tools import (
|
|||
get_tools,
|
||||
get_updated_tool_function,
|
||||
)
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
from starlette.responses import JSONResponse, Response, StreamingResponse
|
||||
|
||||
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _last_output_text(output: list | None) -> str:
|
||||
for item in reversed(output or []):
|
||||
if item.get('type') != 'message':
|
||||
continue
|
||||
parts = item.get('content') or []
|
||||
text = ''.join(str(part.get('text') or '') for part in parts if part.get('type') == 'output_text')
|
||||
if text:
|
||||
return text
|
||||
return ''
|
||||
|
||||
|
||||
async def publish_chat_finished_event(
|
||||
request: Request, user: UserModel, metadata: dict, title: str, content: str, output: list | None = None
|
||||
):
|
||||
chat_id = metadata.get('chat_id')
|
||||
if getattr(request.state, 'internal', False) is True or not chat_id or chat_id.startswith(('channel:', 'local:')):
|
||||
return
|
||||
|
||||
content = content or _last_output_text(output)
|
||||
webui_url = await Config.get('webui.url')
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CHAT_FINISHED,
|
||||
actor=user,
|
||||
subject_id=chat_id,
|
||||
subject_type='chat',
|
||||
data={
|
||||
'user_id': user.id,
|
||||
'chat_id': chat_id,
|
||||
'message_id': metadata.get('message_id'),
|
||||
'model_id': metadata.get('model_id'),
|
||||
'title': title,
|
||||
'url': f'{webui_url}/c/{chat_id}' if webui_url else f'/c/{chat_id}',
|
||||
'message': content,
|
||||
},
|
||||
message=title or 'Chat finished',
|
||||
)
|
||||
event_emitter = await get_event_emitter(metadata, update_db=False)
|
||||
if event_emitter:
|
||||
await event_emitter({'type': 'chat:list', 'data': {'chat_id': chat_id}})
|
||||
|
||||
|
||||
# We believe in one maker of all models, seen and unseen,
|
||||
# and in the reasoning which proceeds from the architect.
|
||||
# We look for the resurrection of dead processes and the
|
||||
|
|
@ -1880,6 +1925,7 @@ def apply_params_to_form_data(form_data, model):
|
|||
'reasoning_tags': list,
|
||||
'compact_token_threshold': int,
|
||||
'system': str,
|
||||
'note_id': str,
|
||||
}
|
||||
|
||||
for key in list(params.keys()):
|
||||
|
|
@ -1975,7 +2021,7 @@ async def load_messages_from_db(chat_id: str, message_id: str) -> Optional[list[
|
|||
return None
|
||||
|
||||
return [
|
||||
{k: v for k, v in msg.items() if k in ('role', 'content', 'output', 'files', 'contextSummary', 'usage')}
|
||||
{k: v for k, v in msg.items() if k in ('id', 'role', 'content', 'output', 'files', 'contextSummary', 'usage')}
|
||||
for msg in db_messages
|
||||
]
|
||||
|
||||
|
|
@ -2035,6 +2081,7 @@ def strip_compaction_fields(messages: list[dict]) -> list[dict]:
|
|||
clean.pop('contextSummary', None)
|
||||
clean.pop('context_summary', None)
|
||||
clean.pop('usage', None)
|
||||
clean.pop('id', None)
|
||||
stripped.append(clean)
|
||||
return stripped
|
||||
|
||||
|
|
@ -2073,7 +2120,7 @@ def sanitize_tool_pairs(messages: list[dict]) -> list[dict]:
|
|||
return sanitized
|
||||
|
||||
|
||||
SKILL_MENTION_RE = re.compile(r'<\$([^|>]+)(?:\|[^>]*)?>')
|
||||
SKILL_MENTION_RE = re.compile(r'<(?:\$([^|>]+)(?:\|[^>]*)?|/([^|>]+)\|[^>]*)>')
|
||||
|
||||
|
||||
def _get_text_parts(message: dict) -> list[str]:
|
||||
|
|
@ -2087,27 +2134,31 @@ def _get_text_parts(message: dict) -> list[str]:
|
|||
|
||||
|
||||
def extract_skill_ids_from_messages(messages: list[dict]) -> set[str]:
|
||||
"""Extract skill IDs from <$skillId|label> mention tags in messages."""
|
||||
"""Extract skill IDs from <$skillId|label> and </skillId|label> mention tags."""
|
||||
ids: set[str] = set()
|
||||
for message in messages:
|
||||
for text in _get_text_parts(message):
|
||||
ids.update(m.group(1) for m in SKILL_MENTION_RE.finditer(text))
|
||||
ids.update(m.group(1) or m.group(2) for m in SKILL_MENTION_RE.finditer(text))
|
||||
return ids
|
||||
|
||||
|
||||
def strip_skill_mentions(messages: list[dict]) -> None:
|
||||
"""Replace <$skillId|label> mention tags with the label in message content in-place."""
|
||||
strip_re = re.compile(r'<\$[^|>]+(?:\|([^>]*))?>')
|
||||
"""Replace <$skillId|label> and </skillId|label> mention tags with the label in-place."""
|
||||
strip_re = re.compile(r'<(?:\$[^|>]+(?:\|([^>]*))?|/[^|>]+\|([^>]*))>')
|
||||
|
||||
def label(match):
|
||||
return match.group(1) or match.group(2) or ''
|
||||
|
||||
for message in messages:
|
||||
content = message.get('content')
|
||||
if isinstance(content, str) and strip_re.search(content):
|
||||
message['content'] = strip_re.sub(r'\1', content).strip()
|
||||
message['content'] = strip_re.sub(label, content).strip()
|
||||
elif isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get('type') == 'text':
|
||||
text = part.get('text', '')
|
||||
if strip_re.search(text):
|
||||
part['text'] = strip_re.sub(r'\1', text).strip()
|
||||
part['text'] = strip_re.sub(label, text).strip()
|
||||
|
||||
|
||||
async def connect_mcp_server(
|
||||
|
|
@ -2223,7 +2274,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
{
|
||||
k: v
|
||||
for k, v in assistant_message.items()
|
||||
if k in ('role', 'content', 'output', 'files', 'contextSummary', 'usage')
|
||||
if k in ('id', 'role', 'content', 'output', 'files', 'contextSummary', 'usage')
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -2353,23 +2404,23 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
folder_id = metadata.get('folder_id', None)
|
||||
|
||||
if folder_id and user:
|
||||
folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id)
|
||||
folder = await Folders.get_folder_by_id(folder_id)
|
||||
if folder and user.role != 'admin' and not await has_folder_access(user.id, folder, 'read', db=None):
|
||||
folder = None
|
||||
|
||||
if folder and folder.data:
|
||||
if 'system_prompt' in folder.data:
|
||||
form_data = await apply_system_prompt_to_body(folder.data['system_prompt'], form_data, metadata, user)
|
||||
if 'files' in folder.data:
|
||||
# Defensive: filter to entries the caller can still read.
|
||||
allowed_files = await get_accessible_folder_files(folder.data['files'], user)
|
||||
if metadata.get('params', {}).get('function_calling') == 'legacy':
|
||||
form_data['files'] = [
|
||||
*allowed_files,
|
||||
{'type': 'folder', 'id': folder.id},
|
||||
*form_data.get('files', []),
|
||||
]
|
||||
else:
|
||||
# Native FC: skip RAG injection, builtin tools
|
||||
# will read folder knowledge from metadata.
|
||||
metadata['folder_knowledge'] = allowed_files
|
||||
metadata['folder_knowledge'] = folder.data['files']
|
||||
|
||||
# Model "Knowledge" handling
|
||||
user_message = get_last_user_message(form_data['messages'])
|
||||
|
|
@ -2422,19 +2473,20 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
except Exception as e:
|
||||
raise e
|
||||
|
||||
try:
|
||||
filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
|
||||
filter_functions = await Functions.get_functions_by_ids(filter_ids)
|
||||
if ENABLE_PLUGINS:
|
||||
try:
|
||||
filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
|
||||
filter_functions = await Functions.get_functions_by_ids(filter_ids)
|
||||
|
||||
form_data, flags = await process_filter_functions(
|
||||
request=request,
|
||||
filter_functions=filter_functions,
|
||||
filter_type='inlet',
|
||||
form_data=form_data,
|
||||
extra_params=extra_params,
|
||||
)
|
||||
except Exception as e:
|
||||
raise Exception(f'{e}')
|
||||
form_data, flags = await process_filter_functions(
|
||||
request=request,
|
||||
filter_functions=filter_functions,
|
||||
filter_type='inlet',
|
||||
form_data=form_data,
|
||||
extra_params=extra_params,
|
||||
)
|
||||
except Exception as e:
|
||||
raise Exception(f'{e}')
|
||||
|
||||
features = form_data.pop('features', None) or {}
|
||||
extra_params['__features__'] = features
|
||||
|
|
@ -2516,7 +2568,36 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
)
|
||||
available_skills = []
|
||||
view_skill_ids = []
|
||||
chat = None
|
||||
if metadata.get('chat_id') and not metadata['chat_id'].startswith(('local:', 'channel:')):
|
||||
chat = await Chats.get_chat_by_id(metadata['chat_id'])
|
||||
|
||||
if chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note':
|
||||
note_id = (chat.meta or {}).get('note_id')
|
||||
note = await Notes.get_note_by_id(note_id) if note_id else None
|
||||
if note and (
|
||||
user.role == 'admin'
|
||||
or note.user_id == user.id
|
||||
or await AccessGrants.has_access(
|
||||
user_id=user.id,
|
||||
resource_type='note',
|
||||
resource_id=note.id,
|
||||
permission='read',
|
||||
)
|
||||
):
|
||||
note_files = [
|
||||
file
|
||||
for file in ((note.data or {}).get('files') or [])
|
||||
if isinstance(file, dict)
|
||||
and file.get('type') != 'image'
|
||||
and not (file.get('content_type') or '').startswith('image/')
|
||||
]
|
||||
if note_files:
|
||||
files = [*(files or []), *note_files]
|
||||
|
||||
use_builtin_tools = (
|
||||
chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note'
|
||||
) or (
|
||||
bool(metadata.get('session_id'))
|
||||
and metadata.get('params', {}).get('function_calling') != 'legacy'
|
||||
and (model.get('info', {}).get('meta', {}).get('capabilities') or {}).get('builtin_tools', True)
|
||||
|
|
@ -2574,31 +2655,20 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
# urls = extract_urls(prompt)
|
||||
|
||||
if files:
|
||||
if not files:
|
||||
files = []
|
||||
|
||||
for file_item in files:
|
||||
if file_item.get('type', 'file') == 'folder':
|
||||
# Get folder files
|
||||
folder_id = file_item.get('id', None)
|
||||
if folder_id:
|
||||
folder = await Folders.get_folder_by_id_and_user_id(folder_id, user.id)
|
||||
if folder and folder.data and 'files' in folder.data:
|
||||
files = [f for f in files if f.get('id', None) != folder_id]
|
||||
files = [*files, *await get_accessible_folder_files(folder.data['files'], user)]
|
||||
|
||||
# files = [*files, *[{"type": "url", "url": url, "name": url} for url in urls]]
|
||||
# Remove duplicate files based on their content
|
||||
files = list({json.dumps(f, sort_keys=True): f for f in files}.values())
|
||||
|
||||
metadata = {
|
||||
**metadata,
|
||||
'model_id': form_data.get('model'),
|
||||
'tool_ids': tool_ids,
|
||||
'terminal_id': terminal_id,
|
||||
'files': files,
|
||||
'features': features,
|
||||
}
|
||||
metadata.update(
|
||||
{
|
||||
'model_id': form_data.get('model'),
|
||||
'tool_ids': tool_ids,
|
||||
'skill_ids': list(skill_ids),
|
||||
'terminal_id': terminal_id,
|
||||
'files': files,
|
||||
'features': features,
|
||||
}
|
||||
)
|
||||
form_data['metadata'] = metadata
|
||||
|
||||
# When the caller provides an explicit `tools` key in the request body,
|
||||
|
|
@ -2619,6 +2689,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
mcp_tools_dict = {}
|
||||
|
||||
if tool_ids:
|
||||
db_tool_ids = []
|
||||
for tool_id in tool_ids:
|
||||
if tool_id.startswith('server:mcp:'):
|
||||
try:
|
||||
|
|
@ -2670,18 +2741,21 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
}
|
||||
)
|
||||
continue
|
||||
elif ENABLE_PLUGINS:
|
||||
db_tool_ids.append(tool_id)
|
||||
|
||||
tools_dict = await get_tools(
|
||||
request,
|
||||
tool_ids,
|
||||
user,
|
||||
{
|
||||
**extra_params,
|
||||
'__model__': models[task_model_id],
|
||||
'__messages__': form_data['messages'],
|
||||
'__files__': metadata.get('files', []),
|
||||
},
|
||||
)
|
||||
if db_tool_ids:
|
||||
tools_dict = await get_tools(
|
||||
request,
|
||||
db_tool_ids,
|
||||
user,
|
||||
{
|
||||
**extra_params,
|
||||
'__model__': models[task_model_id],
|
||||
'__messages__': form_data['messages'],
|
||||
'__files__': metadata.get('files', []),
|
||||
},
|
||||
)
|
||||
|
||||
if mcp_tools_dict:
|
||||
tools_dict = {**tools_dict, **mcp_tools_dict}
|
||||
|
|
@ -2712,6 +2786,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
|
|||
)
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
raise HTTPException(status_code=503, detail=f'Terminal unavailable: {e}') from e
|
||||
|
||||
if direct_tool_servers:
|
||||
for tool_server in direct_tool_servers:
|
||||
|
|
@ -3153,6 +3228,7 @@ async def background_tasks_handler(ctx):
|
|||
{
|
||||
'followUps': follow_ups,
|
||||
},
|
||||
touch=False,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -3372,16 +3448,19 @@ async def outlet_filter_handler(ctx):
|
|||
'__model__': model,
|
||||
}
|
||||
|
||||
filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
|
||||
filter_functions = await Functions.get_functions_by_ids(filter_ids)
|
||||
if ENABLE_PLUGINS:
|
||||
filter_ids = await get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
|
||||
filter_functions = await Functions.get_functions_by_ids(filter_ids)
|
||||
|
||||
outlet_result, _ = await process_filter_functions(
|
||||
request=request,
|
||||
filter_functions=filter_functions,
|
||||
filter_type='outlet',
|
||||
form_data=outlet_data,
|
||||
extra_params=extra_params,
|
||||
)
|
||||
outlet_result, _ = await process_filter_functions(
|
||||
request=request,
|
||||
filter_functions=filter_functions,
|
||||
filter_type='outlet',
|
||||
form_data=outlet_data,
|
||||
extra_params=extra_params,
|
||||
)
|
||||
else:
|
||||
outlet_result = outlet_data
|
||||
|
||||
if outlet_result and outlet_result.get('messages'):
|
||||
if not is_temp_chat and messages_map:
|
||||
|
|
@ -3465,6 +3544,7 @@ async def non_streaming_chat_response_handler(response, ctx):
|
|||
{
|
||||
'selectedModelId': response_data['selected_model_id'],
|
||||
},
|
||||
touch=False,
|
||||
)
|
||||
|
||||
choices = response_data.get('choices', [])
|
||||
|
|
@ -3547,22 +3627,7 @@ async def non_streaming_chat_response_handler(response, ctx):
|
|||
},
|
||||
)
|
||||
|
||||
# Send a webhook notification if the user is not active
|
||||
if await Config.get('ui.enable_user_webhooks') and not await Users.is_user_active(user.id):
|
||||
webhook_url = await Users.get_user_webhook_url_by_id(user.id)
|
||||
if webhook_url:
|
||||
webui_url = await Config.get('webui.url')
|
||||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
webhook_url,
|
||||
f'{content}\n\n{title} - {webui_url}/c/{metadata["chat_id"]}',
|
||||
{
|
||||
'action': 'chat',
|
||||
'message': content,
|
||||
'title': title,
|
||||
'url': f'{webui_url}/c/{metadata["chat_id"]}',
|
||||
},
|
||||
)
|
||||
await publish_chat_finished_event(request, user, metadata, title, content, response_output)
|
||||
|
||||
ctx['assistant_message'] = {
|
||||
'content': content,
|
||||
|
|
@ -3575,6 +3640,29 @@ async def non_streaming_chat_response_handler(response, ctx):
|
|||
response = build_response_object(response, merge_events_into_response(response_data, events))
|
||||
except Exception as e:
|
||||
log.debug(f'Error occurred while processing request: {e}')
|
||||
chat_id = metadata.get('chat_id')
|
||||
if (
|
||||
getattr(request.state, 'internal', False) is not True
|
||||
and chat_id
|
||||
and not chat_id.startswith(('channel:', 'local:'))
|
||||
):
|
||||
webui_url = await Config.get('webui.url')
|
||||
await publish_event(
|
||||
request,
|
||||
EVENTS.CHAT_FAILED,
|
||||
actor=user,
|
||||
subject_id=chat_id,
|
||||
subject_type='chat',
|
||||
data={
|
||||
'user_id': user.id,
|
||||
'chat_id': chat_id,
|
||||
'message_id': metadata.get('message_id'),
|
||||
'model_id': metadata.get('model_id'),
|
||||
'url': f'{webui_url}/c/{chat_id}' if webui_url else f'/c/{chat_id}',
|
||||
'message': str(e),
|
||||
},
|
||||
message='Chat failed',
|
||||
)
|
||||
pass
|
||||
|
||||
return response
|
||||
|
|
@ -3621,10 +3709,13 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
'__model__': model,
|
||||
}
|
||||
|
||||
filter_functions = [
|
||||
await Functions.get_function_by_id(filter_id)
|
||||
for filter_id in await get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
|
||||
]
|
||||
filter_functions = (
|
||||
await Functions.get_functions_by_ids(
|
||||
await get_sorted_filter_ids(request, model, metadata.get('filter_ids', []))
|
||||
)
|
||||
if ENABLE_PLUGINS
|
||||
else []
|
||||
)
|
||||
|
||||
# Standard streaming response handler
|
||||
# event_caller is optional — only needed for direct (client-side) tools
|
||||
|
|
@ -4053,6 +4144,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
{
|
||||
'selectedModelId': model_id,
|
||||
},
|
||||
touch=False,
|
||||
)
|
||||
await event_emitter(
|
||||
{
|
||||
|
|
@ -4398,7 +4490,12 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
user,
|
||||
)
|
||||
|
||||
content = f'{content}{value}'
|
||||
if isinstance(value, str):
|
||||
# In-place append — avoids copying the full
|
||||
# accumulated response on every chunk.
|
||||
content += value
|
||||
else:
|
||||
content = f'{content}{value}'
|
||||
|
||||
# Check if we're inside a tag-based block
|
||||
# (reasoning, code_interpreter, or solution).
|
||||
|
|
@ -4505,16 +4602,17 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
if ENABLE_REALTIME_CHAT_SAVE and not metadata.get('chat_id', '').startswith(
|
||||
'channel:'
|
||||
):
|
||||
current_output = full_output()
|
||||
# Save message in the database
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{
|
||||
'output': full_output(),
|
||||
'output': current_output,
|
||||
},
|
||||
)
|
||||
data = {
|
||||
'output': full_output(),
|
||||
'output': current_output,
|
||||
}
|
||||
delta_type = 'content'
|
||||
else:
|
||||
|
|
@ -4612,6 +4710,11 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
await response.background()
|
||||
|
||||
tool_call_iterations = 0
|
||||
max_tool_call_iterations = getattr(
|
||||
request.state,
|
||||
'max_tool_call_iterations',
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS,
|
||||
)
|
||||
tool_call_sources = [] # Track citation sources from tool results
|
||||
all_tool_call_sources = [] # Accumulated sources across all iterations
|
||||
user_message = get_last_user_message(form_data['messages'])
|
||||
|
|
@ -4632,8 +4735,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
)
|
||||
|
||||
while tool_calls and (
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS is None
|
||||
or tool_call_iterations < CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS
|
||||
max_tool_call_iterations is None or tool_call_iterations < max_tool_call_iterations
|
||||
):
|
||||
tool_call_iterations += 1
|
||||
|
||||
|
|
@ -4670,83 +4772,92 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
|
||||
results = []
|
||||
|
||||
def parse_tool_params(tool_call):
|
||||
tool_args = tool_call.get('function', {}).get('arguments', '{}')
|
||||
params = {}
|
||||
if tool_args and tool_args.strip():
|
||||
try:
|
||||
params = ast.literal_eval(tool_args)
|
||||
except Exception as e:
|
||||
log.debug(e)
|
||||
try:
|
||||
params = json.loads(tool_args)
|
||||
except Exception:
|
||||
return None
|
||||
tool_call.setdefault('function', {})['arguments'] = json.dumps(params)
|
||||
return params
|
||||
|
||||
async def execute_tool_call(tool_call):
|
||||
name = tool_call.get('function', {}).get('name', '')
|
||||
params = parse_tool_params(tool_call)
|
||||
if params is None:
|
||||
return {}, None, None, None, False
|
||||
tool = tools.get(name)
|
||||
if not tool:
|
||||
return params, f'Error: Tool "{name}" not found.', None, None, False
|
||||
spec = tool.get('spec', {})
|
||||
tool_type = tool.get('type', '')
|
||||
direct_tool = tool.get('direct', False)
|
||||
allowed_params = spec.get('parameters', {}).get('properties', {}).keys()
|
||||
params = {key: value for key, value in params.items() if key in allowed_params}
|
||||
try:
|
||||
if direct_tool:
|
||||
result = await event_caller(
|
||||
{
|
||||
'type': 'execute:tool',
|
||||
'data': {
|
||||
'id': str(uuid4()),
|
||||
'name': name,
|
||||
'params': params,
|
||||
'server': tool.get('server', {}),
|
||||
'session_id': metadata.get('session_id'),
|
||||
},
|
||||
}
|
||||
)
|
||||
else:
|
||||
function = await get_updated_tool_function(
|
||||
function=tool['callable'],
|
||||
extra_params={
|
||||
'__messages__': form_data.get('messages', []),
|
||||
'__files__': metadata.get('files', []),
|
||||
},
|
||||
)
|
||||
result = await function(**params)
|
||||
except Exception as e:
|
||||
result = str(e)
|
||||
return params, result, tool, tool_type, direct_tool
|
||||
|
||||
delegate_calls = [
|
||||
tool_call
|
||||
for tool_call in response_tool_calls
|
||||
if tool_call.get('function', {}).get('name') == 'delegate_task'
|
||||
]
|
||||
tool_results = {}
|
||||
for tool_call in response_tool_calls:
|
||||
if tool_call.get('function', {}).get('name') != 'delegate_task':
|
||||
tool_results[id(tool_call)] = await execute_tool_call(tool_call)
|
||||
tool_results.update(
|
||||
zip(
|
||||
[id(tool_call) for tool_call in delegate_calls],
|
||||
await asyncio.gather(*(execute_tool_call(tool_call) for tool_call in delegate_calls)),
|
||||
)
|
||||
)
|
||||
|
||||
for tool_call in response_tool_calls:
|
||||
tool_call_id = tool_call.get('id', '')
|
||||
tool_function_name = tool_call.get('function', {}).get('name', '')
|
||||
tool_args = tool_call.get('function', {}).get('arguments', '{}')
|
||||
|
||||
tool_function_params = {}
|
||||
if tool_args and tool_args.strip():
|
||||
try:
|
||||
# json.loads cannot be used because some models do not produce valid JSON
|
||||
tool_function_params = ast.literal_eval(tool_args)
|
||||
except Exception as e:
|
||||
log.debug(e)
|
||||
# Fallback to JSON parsing
|
||||
try:
|
||||
tool_function_params = json.loads(tool_args)
|
||||
except Exception as e:
|
||||
log.error(f'Error parsing tool call arguments: {tool_args}')
|
||||
results.append(
|
||||
{
|
||||
'tool_call_id': tool_call_id,
|
||||
'content': f'Error: Tool call arguments could not be parsed. The model generated malformed or incomplete JSON for `{tool_function_name}`. Please try again.',
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
# Ensure arguments are valid JSON for downstream LLM integrations
|
||||
log.debug(f'Parsed args from {tool_args} to {tool_function_params}')
|
||||
tool_call.setdefault('function', {})['arguments'] = json.dumps(tool_function_params)
|
||||
|
||||
tool_result = None
|
||||
tool = None
|
||||
tool_type = None
|
||||
direct_tool = False
|
||||
|
||||
if tool_function_name in tools:
|
||||
tool = tools[tool_function_name]
|
||||
spec = tool.get('spec', {})
|
||||
|
||||
tool_type = tool.get('type', '')
|
||||
direct_tool = tool.get('direct', False)
|
||||
|
||||
try:
|
||||
allowed_params = spec.get('parameters', {}).get('properties', {}).keys()
|
||||
|
||||
tool_function_params = {
|
||||
k: v for k, v in tool_function_params.items() if k in allowed_params
|
||||
tool_function_params, tool_result, tool, tool_type, direct_tool = tool_results[id(tool_call)]
|
||||
if tool_result is None:
|
||||
results.append(
|
||||
{
|
||||
'tool_call_id': tool_call_id,
|
||||
'content': (
|
||||
'Error: Tool call arguments could not be parsed. The model generated '
|
||||
f'malformed or incomplete JSON for `{tool_function_name}`. Please try again.'
|
||||
),
|
||||
}
|
||||
|
||||
if direct_tool:
|
||||
tool_result = await event_caller(
|
||||
{
|
||||
'type': 'execute:tool',
|
||||
'data': {
|
||||
'id': str(uuid4()),
|
||||
'name': tool_function_name,
|
||||
'params': tool_function_params,
|
||||
'server': tool.get('server', {}),
|
||||
'session_id': metadata.get('session_id', None),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
else:
|
||||
tool_function = await get_updated_tool_function(
|
||||
function=tool['callable'],
|
||||
extra_params={
|
||||
'__messages__': form_data.get('messages', []),
|
||||
'__files__': metadata.get('files', []),
|
||||
},
|
||||
)
|
||||
|
||||
tool_result = await tool_function(**tool_function_params)
|
||||
|
||||
except Exception as e:
|
||||
tool_result = str(e)
|
||||
else:
|
||||
tool_result = f'Error: Tool "{tool_function_name}" not found.'
|
||||
)
|
||||
continue
|
||||
|
||||
tool_result, tool_result_files, tool_result_embeds = await process_tool_result(
|
||||
request,
|
||||
|
|
@ -5018,12 +5129,12 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
break
|
||||
|
||||
if (
|
||||
CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS is not None
|
||||
max_tool_call_iterations is not None
|
||||
and tool_calls
|
||||
and tool_call_iterations >= CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS
|
||||
and tool_call_iterations >= max_tool_call_iterations
|
||||
):
|
||||
log.warning('Tool-call iteration limit reached (%s)', CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS)
|
||||
error_content = f'Tool-call limit reached ({CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS} iterations).'
|
||||
log.warning('Tool-call iteration limit reached (%s)', max_tool_call_iterations)
|
||||
error_content = f'Tool-call limit reached ({max_tool_call_iterations} iterations).'
|
||||
if not metadata.get('chat_id', '').startswith('channel:'):
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata['chat_id'],
|
||||
|
|
@ -5248,22 +5359,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
{'done': True},
|
||||
)
|
||||
|
||||
# Send a webhook notification if the user is not active
|
||||
if await Config.get('ui.enable_user_webhooks') and not await Users.is_user_active(user.id):
|
||||
webhook_url = await Users.get_user_webhook_url_by_id(user.id)
|
||||
if webhook_url:
|
||||
webui_url = await Config.get('webui.url')
|
||||
await post_webhook(
|
||||
request.app.state.WEBUI_NAME,
|
||||
webhook_url,
|
||||
f'{content}\n\n{title} - {webui_url}/c/{metadata["chat_id"]}',
|
||||
{
|
||||
'action': 'chat',
|
||||
'message': content,
|
||||
'title': title,
|
||||
'url': f'{webui_url}/c/{metadata["chat_id"]}',
|
||||
},
|
||||
)
|
||||
await publish_chat_finished_event(request, user, metadata, title, content, output)
|
||||
|
||||
await event_emitter(
|
||||
{
|
||||
|
|
@ -5308,6 +5404,7 @@ async def streaming_chat_response_handler(response, ctx):
|
|||
metadata['chat_id'],
|
||||
metadata['message_id'],
|
||||
{'done': True},
|
||||
touch=False,
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from open_webui.config import (
|
|||
BYPASS_ADMIN_ACCESS_CONTROL,
|
||||
DEFAULT_ARENA_MODEL,
|
||||
)
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, GLOBAL_LOG_LEVEL
|
||||
from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, ENABLE_PLUGINS, GLOBAL_LOG_LEVEL
|
||||
from open_webui.functions import get_function_models
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.config import Config
|
||||
|
|
@ -68,6 +68,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
'models.base_models_cache',
|
||||
'evaluation.arena.enable',
|
||||
'evaluation.arena.models',
|
||||
'models.default_metadata',
|
||||
)
|
||||
if (
|
||||
request.app.state.MODELS
|
||||
|
|
@ -125,11 +126,21 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
]
|
||||
models = models + arena_models
|
||||
|
||||
global_action_ids = {function.id for function in await Functions.get_global_action_functions()}
|
||||
enabled_action_ids = {function.id for function in await Functions.get_functions_by_type('action', active_only=True)}
|
||||
# One query per type: the global sets are subsets of the active sets, so
|
||||
# deriving them from the same rows halves the function-table queries.
|
||||
if ENABLE_PLUGINS:
|
||||
active_actions = await Functions.get_functions_by_type('action', active_only=True)
|
||||
global_action_ids = {function.id for function in active_actions if function.is_global}
|
||||
enabled_action_ids = {function.id for function in active_actions}
|
||||
|
||||
global_filter_ids = {function.id for function in await Functions.get_global_filter_functions()}
|
||||
enabled_filter_ids = {function.id for function in await Functions.get_functions_by_type('filter', active_only=True)}
|
||||
active_filters = await Functions.get_functions_by_type('filter', active_only=True)
|
||||
global_filter_ids = {function.id for function in active_filters if function.is_global}
|
||||
enabled_filter_ids = {function.id for function in active_filters}
|
||||
else:
|
||||
global_action_ids = set()
|
||||
enabled_action_ids = set()
|
||||
global_filter_ids = set()
|
||||
enabled_filter_ids = set()
|
||||
|
||||
custom_models = await Models.get_all_models()
|
||||
|
||||
|
|
@ -157,8 +168,9 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
|
||||
if 'info' in model:
|
||||
if 'meta' in model['info']:
|
||||
action_ids.extend(model['info']['meta'].get('actionIds', []))
|
||||
filter_ids.extend(model['info']['meta'].get('filterIds', []))
|
||||
if ENABLE_PLUGINS:
|
||||
action_ids.extend(model['info']['meta'].get('actionIds', []))
|
||||
filter_ids.extend(model['info']['meta'].get('filterIds', []))
|
||||
|
||||
if 'params' in model['info']:
|
||||
del model['info']['params']
|
||||
|
|
@ -211,10 +223,10 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
if custom_model.meta:
|
||||
meta = custom_model.meta.model_dump()
|
||||
|
||||
if 'actionIds' in meta:
|
||||
if ENABLE_PLUGINS and 'actionIds' in meta:
|
||||
action_ids.extend(meta['actionIds'])
|
||||
|
||||
if 'filterIds' in meta:
|
||||
if ENABLE_PLUGINS and 'filterIds' in meta:
|
||||
filter_ids.extend(meta['filterIds'])
|
||||
|
||||
model['action_ids'] = action_ids
|
||||
|
|
@ -292,7 +304,7 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None)
|
|||
|
||||
# Apply global model defaults to all models
|
||||
# Per-model overrides take precedence over global defaults
|
||||
default_metadata = await Config.get('models.default_metadata', {}) or {}
|
||||
default_metadata = config.get('models.default_metadata') or {}
|
||||
|
||||
if default_metadata:
|
||||
for model in models:
|
||||
|
|
|
|||
403
backend/open_webui/utils/notifications.py
Normal file
403
backend/open_webui/utils/notifications.py
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from open_webui.events import EVENT_DEFINITIONS_BY_NAME, NOTIFICATION_EVENTS
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.retrieval.web.utils import validate_url
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
|
||||
|
||||
VALID_EVENTS = set(NOTIFICATION_EVENTS)
|
||||
LEGACY_EVENTS = {'chat.finished', 'chat.failed'}
|
||||
VALID_DELIVERY = {'away', 'always'}
|
||||
CHAT_FINISHED_EVENT = 'chat.finished'
|
||||
CHAT_FAILED_EVENT = 'chat.failed'
|
||||
CHANNEL_MESSAGE_EVENT = 'channel.message'
|
||||
CALENDAR_ALERT_EVENT = 'calendar.alert'
|
||||
|
||||
DEFAULT_TARGET_ID = 'webhook'
|
||||
DESCRIPTION_DEFAULT = object()
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_target(target: dict[str, Any], existing: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
existing = existing or {}
|
||||
now = int(time.time())
|
||||
|
||||
target_type = str(target.get('type') or existing.get('type') or 'webhook').strip()
|
||||
if target_type != 'webhook':
|
||||
raise ValueError('Unsupported notification target type')
|
||||
|
||||
config = dict(existing.get('config') or {})
|
||||
config.update(target.get('config') or {})
|
||||
url = str(config.get('url') or '').strip()
|
||||
if not url:
|
||||
raise ValueError('Webhook URL is required')
|
||||
if '...' in url:
|
||||
url = str((existing.get('config') or {}).get('url') or '').strip()
|
||||
validate_url(url)
|
||||
config['url'] = url
|
||||
|
||||
target_id = str(target.get('id') or existing.get('id') or '').strip()
|
||||
if not target_id:
|
||||
hostname = urlparse(url).hostname or 'webhook'
|
||||
target_id = re.sub(r'[^a-zA-Z0-9_-]+', '-', hostname).strip('-').lower() or 'target'
|
||||
|
||||
events = target['events'] if 'events' in target else existing.get('events', [])
|
||||
if events is None:
|
||||
events = []
|
||||
if not isinstance(events, list):
|
||||
raise ValueError('events must be a list')
|
||||
cleaned_events = []
|
||||
for event in events:
|
||||
event = str(event)
|
||||
if event not in VALID_EVENTS:
|
||||
raise ValueError(f'unsupported notification event: {event}')
|
||||
if event not in cleaned_events:
|
||||
cleaned_events.append(event)
|
||||
|
||||
delivery = str(target.get('delivery') or existing.get('delivery') or 'away').strip()
|
||||
if delivery not in VALID_DELIVERY:
|
||||
raise ValueError('Invalid notification delivery mode')
|
||||
|
||||
return {
|
||||
'id': target_id,
|
||||
'type': target_type,
|
||||
'enabled': bool(target.get('enabled', existing.get('enabled', True))),
|
||||
'events': cleaned_events,
|
||||
'delivery': delivery,
|
||||
'config': config,
|
||||
'created_at': int(existing.get('created_at') or now),
|
||||
'updated_at': now,
|
||||
}
|
||||
|
||||
|
||||
def _public_target(target: dict[str, Any], default_target_id: str | None = None) -> dict[str, Any]:
|
||||
config = dict(target.get('config') or {})
|
||||
url = str(config.pop('url', '') or '')
|
||||
if url:
|
||||
parsed = urlparse(url)
|
||||
if parsed.hostname:
|
||||
path = parsed.path or ''
|
||||
suffix = path[-4:] if len(path) > 4 else path
|
||||
config['url_masked'] = f'{parsed.scheme}://{parsed.hostname}/...{suffix}'
|
||||
else:
|
||||
config['url_masked'] = '****'
|
||||
else:
|
||||
config['url_masked'] = ''
|
||||
return {**target, 'config': config, 'is_default': target.get('id') == default_target_id}
|
||||
|
||||
|
||||
async def _load_notifications(user_id: str) -> dict[str, Any]:
|
||||
user = await Users.get_user_by_id(user_id)
|
||||
if not user:
|
||||
raise ValueError('User not found')
|
||||
|
||||
settings = getattr(user, 'settings', None)
|
||||
settings = settings.model_dump(exclude_none=True) if hasattr(settings, 'model_dump') else dict(settings or {})
|
||||
notifications = dict(settings.get('notifications') or {})
|
||||
targets = notifications.get('targets')
|
||||
|
||||
legacy_url = str(
|
||||
notifications.get('webhook_url') or settings.get('ui', {}).get('notifications', {}).get('webhook_url') or ''
|
||||
).strip()
|
||||
|
||||
if not isinstance(targets, list) or not targets:
|
||||
if legacy_url:
|
||||
target = _normalize_target(
|
||||
{
|
||||
'id': DEFAULT_TARGET_ID,
|
||||
'type': 'webhook',
|
||||
'enabled': True,
|
||||
'events': sorted(VALID_EVENTS),
|
||||
'delivery': 'away',
|
||||
'config': {'url': legacy_url},
|
||||
}
|
||||
)
|
||||
notifications = {
|
||||
**notifications,
|
||||
'targets': [target],
|
||||
'default_target_id': DEFAULT_TARGET_ID,
|
||||
'legacy_notification_events_migrated': True,
|
||||
}
|
||||
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
|
||||
else:
|
||||
notifications['targets'] = [target for target in targets if isinstance(target, dict)]
|
||||
notifications.setdefault(
|
||||
'default_target_id', notifications['targets'][0].get('id') if notifications['targets'] else None
|
||||
)
|
||||
if legacy_url and not notifications.get('legacy_notification_events_migrated'):
|
||||
changed = False
|
||||
for target in notifications['targets']:
|
||||
if (
|
||||
target.get('id') == DEFAULT_TARGET_ID
|
||||
and str((target.get('config') or {}).get('url') or '').strip() == legacy_url
|
||||
and set(target.get('events') or []) == LEGACY_EVENTS
|
||||
):
|
||||
target['events'] = sorted(VALID_EVENTS)
|
||||
changed = True
|
||||
notifications['legacy_notification_events_migrated'] = True
|
||||
if changed:
|
||||
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
|
||||
|
||||
return notifications
|
||||
|
||||
|
||||
async def list_targets(user_id: str) -> dict[str, Any]:
|
||||
notifications = await _load_notifications(user_id)
|
||||
default_target_id = notifications.get('default_target_id')
|
||||
return {
|
||||
'targets': [_public_target(target, default_target_id) for target in notifications.get('targets') or []],
|
||||
}
|
||||
|
||||
|
||||
async def create_target(user_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
notifications = await _load_notifications(user_id)
|
||||
targets = notifications.get('targets') or []
|
||||
has_explicit_id = bool(str(payload.get('id') or '').strip())
|
||||
target = _normalize_target(payload)
|
||||
if any(str(existing.get('id', '')).lower() == target['id'].lower() for existing in targets):
|
||||
if has_explicit_id:
|
||||
raise ValueError('notification target id already exists')
|
||||
base = target['id']
|
||||
suffix = 2
|
||||
while any(str(existing.get('id', '')).lower() == target['id'].lower() for existing in targets):
|
||||
target['id'] = f'{base}-{suffix}'
|
||||
suffix += 1
|
||||
targets.append(target)
|
||||
notifications['targets'] = targets
|
||||
notifications.setdefault('default_target_id', target['id'])
|
||||
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
|
||||
return _public_target(target, notifications.get('default_target_id'))
|
||||
|
||||
|
||||
async def update_target(user_id: str, target_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
notifications = await _load_notifications(user_id)
|
||||
targets = notifications.get('targets') or []
|
||||
for index, existing in enumerate(targets):
|
||||
if str(existing.get('id', '')).lower() == target_id.lower():
|
||||
updated = _normalize_target({'id': target_id, **payload}, existing=existing)
|
||||
if any(
|
||||
idx != index and str(target.get('id', '')).lower() == updated['id'].lower()
|
||||
for idx, target in enumerate(targets)
|
||||
):
|
||||
raise ValueError('notification target id already exists')
|
||||
targets[index] = updated
|
||||
notifications['targets'] = targets
|
||||
if str(notifications.get('default_target_id') or '').lower() == target_id.lower():
|
||||
notifications['default_target_id'] = updated['id']
|
||||
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
|
||||
return _public_target(updated, notifications.get('default_target_id'))
|
||||
raise ValueError('Notification target not found')
|
||||
|
||||
|
||||
async def delete_target(user_id: str, target_id: str) -> bool:
|
||||
notifications = await _load_notifications(user_id)
|
||||
targets = notifications.get('targets') or []
|
||||
next_targets = [target for target in targets if str(target.get('id', '')).lower() != target_id.lower()]
|
||||
if len(next_targets) == len(targets):
|
||||
return False
|
||||
notifications['targets'] = next_targets
|
||||
if str(notifications.get('default_target_id') or '').lower() == target_id.lower():
|
||||
notifications['default_target_id'] = next_targets[0].get('id') if next_targets else None
|
||||
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
|
||||
return True
|
||||
|
||||
|
||||
async def set_default_target(user_id: str, target_id: str) -> dict[str, Any]:
|
||||
notifications = await _load_notifications(user_id)
|
||||
for target in notifications.get('targets') or []:
|
||||
if str(target.get('id', '')).lower() == target_id.lower():
|
||||
notifications['default_target_id'] = target['id']
|
||||
await Users.update_user_settings_by_id(user_id, {'notifications': notifications})
|
||||
return _public_target(target, target['id'])
|
||||
raise ValueError('Notification target not found')
|
||||
|
||||
|
||||
def get_notification_event_catalog() -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
'event': event_name,
|
||||
'label': EVENT_DEFINITIONS_BY_NAME[event_name].message or event_name,
|
||||
'description': EVENT_DEFINITIONS_BY_NAME[event_name].description or '',
|
||||
}
|
||||
for event_name in NOTIFICATION_EVENTS
|
||||
]
|
||||
|
||||
|
||||
def _find_target(notifications: dict[str, Any], target: str = '') -> dict[str, Any] | None:
|
||||
targets = notifications.get('targets') or []
|
||||
target = target.strip()
|
||||
target_id = target or str(notifications.get('default_target_id') or '')
|
||||
if not target_id:
|
||||
return None
|
||||
for item in targets:
|
||||
if str(item.get('id', '')).lower() == target_id.lower():
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
async def _send_webhook(
|
||||
app_name: str,
|
||||
target: dict[str, Any],
|
||||
message: str,
|
||||
data: dict[str, Any],
|
||||
title: str = '',
|
||||
description: str | None | object = DESCRIPTION_DEFAULT,
|
||||
):
|
||||
url = str((target.get('config') or {}).get('url') or '').strip()
|
||||
if not url:
|
||||
raise ValueError('Webhook URL is required')
|
||||
if description is DESCRIPTION_DEFAULT:
|
||||
description = message if title else None
|
||||
ok = await post_webhook(app_name, url, title or message, data, description=description)
|
||||
if not ok:
|
||||
raise ValueError('Webhook delivery failed')
|
||||
|
||||
|
||||
def _notification_webhook_content(event: Any) -> tuple[str, str, dict[str, Any], str | None]:
|
||||
data = event.data or {}
|
||||
|
||||
if event.event == CHAT_FINISHED_EVENT:
|
||||
title = str(data.get('title') or event.message or 'Chat finished')
|
||||
content = str(data.get('message') or '')
|
||||
url = str(data.get('url') or '')
|
||||
chat_id = str(data.get('chat_id') or '')
|
||||
if chat_id and url.endswith(f'/c/{chat_id}'):
|
||||
url = f'{url[: -len(f"/c/{chat_id}")].rstrip("/")}/{chat_id}'
|
||||
body = '\n'.join(part for part in (content, url) if part)
|
||||
return (
|
||||
f'**{title}**',
|
||||
body,
|
||||
{
|
||||
'action': 'chat',
|
||||
'message': content,
|
||||
'title': title,
|
||||
'url': url,
|
||||
},
|
||||
body,
|
||||
)
|
||||
|
||||
if event.event == CHAT_FAILED_EVENT:
|
||||
title = str(event.message or 'Chat failed')
|
||||
content = str(data.get('message') or '')
|
||||
url = str(data.get('url') or '')
|
||||
chat_id = str(data.get('chat_id') or '')
|
||||
if chat_id and url.endswith(f'/c/{chat_id}'):
|
||||
url = f'{url[: -len(f"/c/{chat_id}")].rstrip("/")}/{chat_id}'
|
||||
body = '\n'.join(part for part in (content, url) if part)
|
||||
return (
|
||||
f'**{title}**',
|
||||
body,
|
||||
{
|
||||
'action': 'chat_failed',
|
||||
'message': content,
|
||||
'title': title,
|
||||
'url': url,
|
||||
},
|
||||
body,
|
||||
)
|
||||
|
||||
if event.event == CHANNEL_MESSAGE_EVENT:
|
||||
channel_name = str(data.get('title') or event.message or 'Channel')
|
||||
content = str(data.get('content') or data.get('message') or '')
|
||||
url = str(data.get('url') or '')
|
||||
body = '\n'.join(part for part in (content, url) if part)
|
||||
return (
|
||||
f'**#{channel_name}**',
|
||||
body,
|
||||
{
|
||||
'action': 'channel',
|
||||
'message': content,
|
||||
'title': channel_name,
|
||||
'url': url,
|
||||
},
|
||||
body,
|
||||
)
|
||||
|
||||
if event.event == CALENDAR_ALERT_EVENT:
|
||||
title = str(data.get('title') or event.message or 'Calendar alert')
|
||||
starts_in = str(data.get('starts_in') or '')
|
||||
message = f'**{title}**\nstarting {starts_in}'.strip()
|
||||
return (
|
||||
'',
|
||||
message,
|
||||
{
|
||||
'action': 'calendar_alert',
|
||||
'title': title,
|
||||
'minutes_until': data.get('minutes_until'),
|
||||
'event_id': data.get('event_id') or (event.subject or {}).get('id'),
|
||||
},
|
||||
None,
|
||||
)
|
||||
|
||||
definition = EVENT_DEFINITIONS_BY_NAME.get(event.event)
|
||||
title = event.message or (definition.message if definition else event.event)
|
||||
message = str(data.get('message') or data.get('preview') or data.get('content_preview') or title)
|
||||
return str(title), message, event.model_dump(), message if title else None
|
||||
|
||||
|
||||
async def test_target(user_id: str, target_id: str, app_name: str = 'Open WebUI') -> dict[str, Any]:
|
||||
notifications = await _load_notifications(user_id)
|
||||
target = _find_target(notifications, target_id)
|
||||
if not target:
|
||||
raise ValueError('Notification target not found')
|
||||
await _send_webhook(
|
||||
app_name,
|
||||
target,
|
||||
'This is a test notification from Open WebUI.',
|
||||
{'action': 'test', 'user_id': user_id},
|
||||
'Test notification',
|
||||
)
|
||||
return {'ok': True}
|
||||
|
||||
|
||||
async def notify_target(
|
||||
user_id: str, message: str, target: str = '', title: str = '', app_name: str = 'Open WebUI'
|
||||
) -> dict[str, Any]:
|
||||
notifications = await _load_notifications(user_id)
|
||||
item = _find_target(notifications, target)
|
||||
if not item:
|
||||
raise ValueError('Notification target not found')
|
||||
if not item.get('enabled', True):
|
||||
raise ValueError('Notification target is disabled')
|
||||
await _send_webhook(
|
||||
app_name,
|
||||
item,
|
||||
message,
|
||||
{'action': 'notify', 'user_id': user_id, 'message': message, 'title': title},
|
||||
title or 'Notification',
|
||||
)
|
||||
return {'ok': True, 'target_id': item.get('id')}
|
||||
|
||||
|
||||
async def dispatch_notification_event(app: Any, event: Any) -> None:
|
||||
if event.event not in VALID_EVENTS or not await Config.get('ui.enable_user_webhooks'):
|
||||
return
|
||||
|
||||
from open_webui.events import event_user_ids
|
||||
|
||||
app_name = getattr(getattr(app, 'state', None), 'WEBUI_NAME', 'Open WebUI')
|
||||
for user_id in event_user_ids(event):
|
||||
try:
|
||||
notifications = await _load_notifications(user_id)
|
||||
is_active = False if event.event == CHANNEL_MESSAGE_EVENT else await Users.is_user_active(user_id)
|
||||
|
||||
for target in notifications.get('targets') or []:
|
||||
if not target.get('enabled', True):
|
||||
continue
|
||||
if event.event not in target.get('events', []):
|
||||
continue
|
||||
if target.get('delivery', 'away') == 'away' and is_active:
|
||||
continue
|
||||
|
||||
title, message, data, description = _notification_webhook_content(event)
|
||||
await _send_webhook(app_name, target, message, data, title, description=description)
|
||||
except Exception:
|
||||
log.exception('Notification delivery failed for user %s and event %s', user_id, event.event)
|
||||
|
|
@ -13,6 +13,7 @@ from typing import Any
|
|||
|
||||
from open_webui.env import (
|
||||
ENABLE_PIP_INSTALL_FRONTMATTER_REQUIREMENTS,
|
||||
ENABLE_PLUGINS,
|
||||
OFFLINE_MODE,
|
||||
PIP_OPTIONS,
|
||||
PIP_PACKAGE_INDEX_OPTIONS,
|
||||
|
|
@ -203,6 +204,9 @@ def replace_imports(content):
|
|||
# May the intent of the one who wrote it survive every
|
||||
# import and transformation, as a deed survives the generations.
|
||||
async def load_tool_module_by_id(tool_id, content=None):
|
||||
if not ENABLE_PLUGINS:
|
||||
raise RuntimeError('Plugins are disabled by ENABLE_PLUGINS=false')
|
||||
|
||||
if content is None:
|
||||
tool = await Tools.get_tool_by_id(tool_id)
|
||||
if not tool:
|
||||
|
|
@ -251,6 +255,9 @@ async def load_tool_module_by_id(tool_id, content=None):
|
|||
|
||||
|
||||
async def load_function_module_by_id(function_id: str, content: str | None = None):
|
||||
if not ENABLE_PLUGINS:
|
||||
raise RuntimeError('Plugins are disabled by ENABLE_PLUGINS=false')
|
||||
|
||||
if content is None:
|
||||
function = await Functions.get_function_by_id(function_id)
|
||||
if not function:
|
||||
|
|
@ -447,6 +454,10 @@ async def install_tool_and_function_dependencies():
|
|||
and then installing them using pip. Duplicates or similar version specifications are
|
||||
handled by pip as much as possible.
|
||||
"""
|
||||
if not ENABLE_PLUGINS:
|
||||
log.info('ENABLE_PLUGINS is disabled, skipping tool and function dependencies.')
|
||||
return
|
||||
|
||||
function_list = await Functions.get_functions(active_only=True)
|
||||
tool_list = await Tools.get_tools()
|
||||
|
||||
|
|
|
|||
|
|
@ -2,15 +2,36 @@ import os
|
|||
import re
|
||||
from typing import Dict
|
||||
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.datastructures import MutableHeaders
|
||||
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers.update(set_security_headers())
|
||||
return response
|
||||
class SecurityHeadersMiddleware:
|
||||
"""Apply configured security headers to every HTTP response.
|
||||
|
||||
Pure ASGI to avoid BaseHTTPMiddleware's response re-buffering. See
|
||||
open_webui.utils.asgi_middleware for the rationale.
|
||||
"""
|
||||
|
||||
def __init__(self, app: ASGIApp) -> None:
|
||||
self.app = app
|
||||
# Headers derive only from env vars, which are static for the process
|
||||
# lifetime — compute them once instead of per response.
|
||||
self._headers = list(set_security_headers().items())
|
||||
|
||||
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope['type'] != 'http' or not self._headers:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
async def send_with_security_headers(message: Message) -> None:
|
||||
if message['type'] == 'http.response.start':
|
||||
headers = MutableHeaders(scope=message)
|
||||
for key, value in self._headers:
|
||||
headers[key] = value
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_security_headers)
|
||||
|
||||
|
||||
def set_security_headers() -> Dict[str, str]:
|
||||
|
|
|
|||
672
backend/open_webui/utils/subagents.py
Normal file
672
backend/open_webui/utils/subagents.py
Normal file
|
|
@ -0,0 +1,672 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from open_webui.internal.db import get_async_db
|
||||
from open_webui.models.chat_messages import ChatMessages
|
||||
from open_webui.models.chats import Chat, ChatForm, Chats
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.users import UserModel, Users
|
||||
from open_webui.tasks import create_task, has_active_tasks
|
||||
from open_webui.utils.auth import create_token
|
||||
from open_webui.utils.misc import get_message_list
|
||||
from sqlalchemy import select
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
DEFAULT_SUBAGENT_SYSTEM_PROMPT = """You are a sub-agent working on a specific task assigned by the lead agent.
|
||||
|
||||
You have full access to the workspace — you can read, write, edit files, and run commands.
|
||||
Focus exclusively on your assigned task. Do NOT work on anything outside your scope.
|
||||
|
||||
When done, end with a clear summary:
|
||||
- What you did
|
||||
- What files you changed (if any)
|
||||
- Any issues or open questions
|
||||
"""
|
||||
|
||||
MUTATING_MEMORY_TOOLS = {
|
||||
'add_memory',
|
||||
'delete_memory',
|
||||
'replace_memory_content',
|
||||
'update_memory',
|
||||
}
|
||||
|
||||
_background_active: set[str] = set()
|
||||
_background_lock = asyncio.Lock()
|
||||
_foreground_semaphore: asyncio.Semaphore | None = None
|
||||
_parent_locks: dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def _build_request(source: Request, user_id: str, *, internal: bool) -> Request:
|
||||
scope = {
|
||||
'type': 'http',
|
||||
'asgi': {'version': '3.0', 'spec_version': '2.0'},
|
||||
'method': 'POST',
|
||||
'path': '/api/v1/subagents/internal',
|
||||
'query_string': b'',
|
||||
'headers': Headers({}).raw,
|
||||
'client': ('127.0.0.1', 0),
|
||||
'server': ('127.0.0.1', 80),
|
||||
'scheme': 'http',
|
||||
'app': source.app,
|
||||
}
|
||||
request = Request(scope)
|
||||
token = create_token(
|
||||
data={'id': user_id, 'typ': 'subagent'},
|
||||
expires_delta=timedelta(hours=1),
|
||||
)
|
||||
request.state.token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=token)
|
||||
request.state.enable_api_keys = False
|
||||
if internal:
|
||||
request.state.internal = True
|
||||
return request
|
||||
|
||||
|
||||
async def process_pending_internal_messages(
|
||||
source_request: Request,
|
||||
parent_chat_id: str,
|
||||
user_id: str,
|
||||
run: dict,
|
||||
) -> None:
|
||||
lock = _parent_locks.setdefault(parent_chat_id, asyncio.Lock())
|
||||
while await has_active_tasks(source_request.app.state.redis, parent_chat_id):
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
async with lock:
|
||||
if await has_active_tasks(source_request.app.state.redis, parent_chat_id):
|
||||
return
|
||||
|
||||
user = await Users.get_user_by_id(user_id)
|
||||
if not user:
|
||||
return
|
||||
|
||||
async with get_async_db() as db:
|
||||
stmt = select(Chat).where(Chat.id == parent_chat_id, Chat.user_id == user_id)
|
||||
if db.bind.dialect.name == 'postgresql':
|
||||
stmt = stmt.with_for_update()
|
||||
result = await db.execute(stmt)
|
||||
chat = result.scalar_one_or_none()
|
||||
if not chat:
|
||||
return
|
||||
|
||||
history = copy.deepcopy((chat.chat or {}).get('history') or {})
|
||||
messages = history.get('messages') or {}
|
||||
pending = [
|
||||
message
|
||||
for message in messages.values()
|
||||
for meta in [message.get('meta') or {}]
|
||||
if message.get('role') == 'user'
|
||||
and not message.get('childrenIds')
|
||||
and (
|
||||
(
|
||||
meta.get('internal') is True
|
||||
and meta.get('type') == 'subagent'
|
||||
and meta.get('status') in (None, 'pending')
|
||||
)
|
||||
or (meta.get('internal') is True and meta.get('type') == 'timer')
|
||||
)
|
||||
]
|
||||
if not pending:
|
||||
return
|
||||
|
||||
first = pending[0]
|
||||
first_meta = first.get('meta') or {}
|
||||
kind = 'timer' if first_meta.get('internal') is True and first_meta.get('type') == 'timer' else 'subagent'
|
||||
parent_id = first.get('parentId')
|
||||
if kind == 'timer' and first_meta.get('timer_id'):
|
||||
timer = await Chats.get_chat_by_id(first_meta['timer_id'])
|
||||
run = {**run, **(((timer.meta or {}).get('run') if timer else None) or {})}
|
||||
model_id = first.get('model') or run['model_id']
|
||||
if kind == 'timer':
|
||||
batch = [first]
|
||||
else:
|
||||
batch = [
|
||||
message
|
||||
for message in pending
|
||||
for meta in [message.get('meta') or {}]
|
||||
if message.get('parentId') == parent_id
|
||||
and (message.get('model') or model_id) == model_id
|
||||
and (
|
||||
meta.get('internal') is True
|
||||
and meta.get('type') == 'subagent'
|
||||
and meta.get('status') in (None, 'pending')
|
||||
)
|
||||
]
|
||||
combined_content = '\n\n'.join(message.get('content', '') for message in batch if message.get('content'))
|
||||
if kind == 'timer':
|
||||
timer_ids = [
|
||||
message['meta']['timer_id'] for message in batch if (message.get('meta') or {}).get('timer_id')
|
||||
]
|
||||
combined_meta = {'internal': True, 'type': 'timer'}
|
||||
if len(timer_ids) == 1:
|
||||
combined_meta['timer_id'] = timer_ids[0]
|
||||
elif timer_ids:
|
||||
combined_meta['timer_ids'] = timer_ids
|
||||
else:
|
||||
delegation_ids = [
|
||||
message['meta']['delegation_id']
|
||||
for message in batch
|
||||
if (message.get('meta') or {}).get('delegation_id')
|
||||
]
|
||||
subagent_chat_ids = [
|
||||
message['meta']['subagent_chat_id']
|
||||
for message in batch
|
||||
if (message.get('meta') or {}).get('subagent_chat_id')
|
||||
]
|
||||
combined_meta = {'internal': True, 'type': 'subagent'}
|
||||
if len(delegation_ids) == 1:
|
||||
combined_meta['delegation_id'] = delegation_ids[0]
|
||||
elif delegation_ids:
|
||||
combined_meta['delegation_ids'] = delegation_ids
|
||||
if len(subagent_chat_ids) == 1:
|
||||
combined_meta['subagent_chat_id'] = subagent_chat_ids[0]
|
||||
elif subagent_chat_ids:
|
||||
combined_meta['subagent_chat_ids'] = subagent_chat_ids
|
||||
|
||||
reuse_message = len(batch) == 1 and (first.get('meta') or {}).get('status') != 'pending'
|
||||
user_message_id = first['id'] if reuse_message else str(uuid4())
|
||||
removed_ids = set()
|
||||
if not reuse_message:
|
||||
removed_ids = {message['id'] for message in batch}
|
||||
for message_id in removed_ids:
|
||||
messages.pop(message_id, None)
|
||||
if parent_id and parent_id in messages:
|
||||
messages[parent_id]['childrenIds'] = [
|
||||
child_id
|
||||
for child_id in messages[parent_id].get('childrenIds', [])
|
||||
if child_id not in removed_ids
|
||||
]
|
||||
|
||||
assistant_message_id = str(uuid4())
|
||||
message_list = get_message_list(messages, parent_id)
|
||||
system_prompt = run.get('system_prompt')
|
||||
user_message = {
|
||||
'id': user_message_id,
|
||||
'parentId': parent_id,
|
||||
'childrenIds': [assistant_message_id],
|
||||
'role': 'user',
|
||||
'content': combined_content,
|
||||
'model': model_id,
|
||||
'meta': combined_meta,
|
||||
'timestamp': int(time.time()),
|
||||
}
|
||||
assistant_message = {
|
||||
'id': assistant_message_id,
|
||||
'parentId': user_message_id,
|
||||
'childrenIds': [],
|
||||
'role': 'assistant',
|
||||
'content': '',
|
||||
'done': False,
|
||||
'model': model_id,
|
||||
'timestamp': int(time.time()),
|
||||
}
|
||||
|
||||
if parent_id and parent_id in messages:
|
||||
parent_children = [
|
||||
child_id for child_id in messages[parent_id].get('childrenIds', []) if child_id != user_message_id
|
||||
]
|
||||
parent_children.append(user_message_id)
|
||||
messages[parent_id]['childrenIds'] = parent_children
|
||||
messages[user_message_id] = {**messages.get(user_message_id, {}), **user_message}
|
||||
messages[assistant_message_id] = assistant_message
|
||||
history['messages'] = messages
|
||||
history['currentId'] = assistant_message_id
|
||||
chat.chat = {**(chat.chat or {}), 'history': history}
|
||||
chat.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
|
||||
if removed_ids:
|
||||
await ChatMessages.delete_message_ids_by_chat_id(parent_chat_id, removed_ids)
|
||||
await ChatMessages.upsert_message(user_message_id, parent_chat_id, user_id, user_message)
|
||||
await ChatMessages.upsert_message(assistant_message_id, parent_chat_id, user_id, assistant_message)
|
||||
|
||||
from open_webui.socket.main import sio
|
||||
|
||||
await sio.emit(
|
||||
'events',
|
||||
{
|
||||
'chat_id': parent_chat_id,
|
||||
'message_id': assistant_message_id,
|
||||
'data': {'type': 'chat:reload'},
|
||||
},
|
||||
room=f'user:{user.id}',
|
||||
)
|
||||
|
||||
form_data = {
|
||||
'model': model_id,
|
||||
'messages': [
|
||||
*([{'role': 'system', 'content': system_prompt}] if system_prompt else []),
|
||||
*message_list,
|
||||
{'role': 'user', 'content': combined_content},
|
||||
],
|
||||
'stream': True,
|
||||
'chat_id': parent_chat_id,
|
||||
'id': assistant_message_id,
|
||||
'parent_id': parent_id,
|
||||
'user_message': user_message,
|
||||
'session_id': run.get('session_id') or f'{kind}-result:{parent_chat_id}',
|
||||
'background_tasks': {},
|
||||
'tool_ids': run.get('tool_ids') or [],
|
||||
'skill_ids': run.get('skill_ids') or [],
|
||||
'filter_ids': run.get('filter_ids') or [],
|
||||
'features': run.get('features') or {},
|
||||
'files': run.get('files') or [],
|
||||
'variables': run.get('variables') or {},
|
||||
}
|
||||
if run.get('terminal_id'):
|
||||
form_data['terminal_id'] = run['terminal_id']
|
||||
|
||||
request = _build_request(source_request, user.id, internal=False)
|
||||
await source_request.app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user)
|
||||
|
||||
|
||||
async def delegate(
|
||||
task: str,
|
||||
context: str,
|
||||
background: bool,
|
||||
*,
|
||||
request: Request,
|
||||
user_data: dict,
|
||||
metadata: dict,
|
||||
parent_chat_id: str,
|
||||
parent_message_id: str | None,
|
||||
) -> str:
|
||||
global _foreground_semaphore
|
||||
|
||||
task = task.strip()
|
||||
if not task:
|
||||
return 'Error: task must not be empty.'
|
||||
if not parent_chat_id or not user_data.get('id'):
|
||||
return 'Error: chat and user context are required.'
|
||||
|
||||
config = await Config.get_many(
|
||||
'subagents.background_enabled',
|
||||
'subagents.max_concurrent',
|
||||
'subagents.max_async',
|
||||
'subagents.max_iterations',
|
||||
'subagents.max_output',
|
||||
'subagents.system_prompt',
|
||||
)
|
||||
max_concurrent = int(config.get('subagents.max_concurrent') or 20)
|
||||
max_async = int(config.get('subagents.max_async') or 20)
|
||||
max_iterations = int(config.get('subagents.max_iterations') or 30)
|
||||
max_output = int(config.get('subagents.max_output') or 30_000)
|
||||
if max_concurrent != -1:
|
||||
max_concurrent = max(1, max_concurrent)
|
||||
if max_async != -1:
|
||||
max_async = max(1, max_async)
|
||||
|
||||
if background and not config.get('subagents.background_enabled'):
|
||||
return 'Error: background sub-agents are disabled in settings.'
|
||||
|
||||
features = copy.deepcopy(metadata.get('features') or {})
|
||||
if (
|
||||
background
|
||||
and features.get('code_interpreter')
|
||||
and await Config.get('code_interpreter.engine', 'pyodide') != 'jupyter'
|
||||
):
|
||||
features.pop('code_interpreter')
|
||||
run = {
|
||||
'model_id': metadata.get('model_id') or (metadata.get('model') or {}).get('id'),
|
||||
'session_id': metadata.get('session_id'),
|
||||
'tool_ids': copy.deepcopy(metadata.get('tool_ids') or []),
|
||||
'skill_ids': copy.deepcopy(metadata.get('skill_ids') or []),
|
||||
'system_prompt': metadata.get('system_prompt'),
|
||||
'tool_servers': [] if background else copy.deepcopy(metadata.get('tool_servers') or []),
|
||||
'filter_ids': copy.deepcopy(metadata.get('filter_ids') or []),
|
||||
'terminal_id': metadata.get('terminal_id'),
|
||||
'features': features,
|
||||
'files': copy.deepcopy(metadata.get('files') or []),
|
||||
'variables': copy.deepcopy(metadata.get('variables') or {}),
|
||||
'direct': bool(metadata.get('direct')),
|
||||
}
|
||||
if not run.get('model_id'):
|
||||
return 'Error: model context is required.'
|
||||
if run.get('direct'):
|
||||
return 'Error: sub-agents are unavailable for direct connections.'
|
||||
|
||||
delegation_id = f'deleg_{uuid4().hex[:8]}'
|
||||
foreground_semaphore = None
|
||||
if background:
|
||||
async with _background_lock:
|
||||
if max_async != -1 and len(_background_active) >= max_async:
|
||||
return (
|
||||
f'Error: Async subagent capacity reached ({max_async} running). '
|
||||
'Wait for one to finish or increase subagents.max_async.'
|
||||
)
|
||||
_background_active.add(delegation_id)
|
||||
elif max_concurrent != -1:
|
||||
if _foreground_semaphore is None:
|
||||
_foreground_semaphore = asyncio.Semaphore(max_concurrent)
|
||||
foreground_semaphore = _foreground_semaphore
|
||||
await foreground_semaphore.acquire()
|
||||
|
||||
mode = 'background' if background else 'foreground'
|
||||
try:
|
||||
user = UserModel(**user_data)
|
||||
chat_id = str(uuid4())
|
||||
user_message_id = str(uuid4())
|
||||
assistant_message_id = str(uuid4())
|
||||
prompt = f'{task}\n\n## Context\n{context}' if context else task
|
||||
chat = await Chats.insert_new_chat(
|
||||
chat_id,
|
||||
user.id,
|
||||
ChatForm(
|
||||
chat={
|
||||
'id': chat_id,
|
||||
'title': f'Sub-agent: {task[:60]}',
|
||||
'models': [run['model_id']],
|
||||
'history': {
|
||||
'currentId': assistant_message_id,
|
||||
'messages': {
|
||||
user_message_id: {
|
||||
'id': user_message_id,
|
||||
'parentId': None,
|
||||
'childrenIds': [assistant_message_id],
|
||||
'role': 'user',
|
||||
'content': prompt,
|
||||
'timestamp': int(time.time()),
|
||||
'models': [run['model_id']],
|
||||
},
|
||||
assistant_message_id: {
|
||||
'id': assistant_message_id,
|
||||
'parentId': user_message_id,
|
||||
'childrenIds': [],
|
||||
'role': 'assistant',
|
||||
'content': '',
|
||||
'done': False,
|
||||
'model': run['model_id'],
|
||||
'timestamp': int(time.time()),
|
||||
},
|
||||
},
|
||||
},
|
||||
'messages': [{'role': 'user', 'content': prompt}],
|
||||
}
|
||||
),
|
||||
internal_meta={
|
||||
'internal': True,
|
||||
'type': 'subagent',
|
||||
'parent_chat_id': parent_chat_id,
|
||||
'parent_message_id': parent_message_id,
|
||||
'delegation_id': delegation_id,
|
||||
'mode': mode,
|
||||
},
|
||||
)
|
||||
if not chat:
|
||||
raise RuntimeError('Failed to create sub-agent chat')
|
||||
except Exception as exc:
|
||||
if background:
|
||||
async with _background_lock:
|
||||
_background_active.discard(delegation_id)
|
||||
elif foreground_semaphore:
|
||||
foreground_semaphore.release()
|
||||
prefix = 'background ' if background else ''
|
||||
return f'Error: failed to create {prefix}sub-agent: {exc}'
|
||||
|
||||
async def run_reserved() -> dict:
|
||||
try:
|
||||
child_request = _build_request(request, user.id, internal=True)
|
||||
child_request.state.max_tool_call_iterations = max_iterations
|
||||
parent_system_prompt = run.get('system_prompt') or ''
|
||||
subagent_system_prompt = (
|
||||
str(config.get('subagents.system_prompt') or '').strip() or DEFAULT_SUBAGENT_SYSTEM_PROMPT
|
||||
)
|
||||
form_data = {
|
||||
'model': run['model_id'],
|
||||
'messages': [
|
||||
{
|
||||
'role': 'system',
|
||||
'content': (
|
||||
f'{parent_system_prompt}\n\n{subagent_system_prompt}'
|
||||
if parent_system_prompt
|
||||
else subagent_system_prompt
|
||||
),
|
||||
},
|
||||
{'role': 'user', 'content': prompt},
|
||||
],
|
||||
'stream': True,
|
||||
'chat_id': chat_id,
|
||||
'id': assistant_message_id,
|
||||
'parent_id': None,
|
||||
'user_message': {
|
||||
'id': user_message_id,
|
||||
'parentId': None,
|
||||
'role': 'user',
|
||||
'content': prompt,
|
||||
},
|
||||
'session_id': run.get('session_id') or f'subagent:{chat_id}',
|
||||
'background_tasks': {},
|
||||
'tool_ids': run.get('tool_ids') or [],
|
||||
'skill_ids': run.get('skill_ids') or [],
|
||||
'filter_ids': run.get('filter_ids') or [],
|
||||
'features': run.get('features') or {},
|
||||
'files': run.get('files') or [],
|
||||
'variables': run.get('variables') or {},
|
||||
}
|
||||
if run.get('terminal_id'):
|
||||
form_data['terminal_id'] = run['terminal_id']
|
||||
if run.get('tool_servers'):
|
||||
form_data['tool_servers'] = run['tool_servers']
|
||||
await request.app.state.CHAT_COMPLETION_HANDLER(child_request, form_data, user=user)
|
||||
message = await Chats.get_message_by_id_and_message_id(chat_id, assistant_message_id)
|
||||
if not message:
|
||||
return {
|
||||
'status': 'error',
|
||||
'summary': '',
|
||||
'error': 'Sub-agent chat or completion message no longer exists.',
|
||||
}
|
||||
|
||||
summary = message.get('content') or ''
|
||||
if isinstance(summary, list):
|
||||
summary = ''.join(
|
||||
str(item.get('text', ''))
|
||||
for item in summary
|
||||
if isinstance(item, dict) and item.get('type') == 'text'
|
||||
)
|
||||
if not summary:
|
||||
summary = ''.join(
|
||||
str(part.get('text', ''))
|
||||
for item in message.get('output') or []
|
||||
if item.get('type') == 'message'
|
||||
for part in item.get('content') or []
|
||||
if part.get('type') == 'output_text'
|
||||
)
|
||||
if len(summary) > max_output:
|
||||
summary = f'{summary[:max_output]}\n\n[output truncated]'
|
||||
error = message.get('error')
|
||||
return {
|
||||
'status': 'error' if error else 'completed',
|
||||
'summary': summary or ('Sub-agent produced no output.' if not error else ''),
|
||||
'error': error,
|
||||
}
|
||||
except asyncio.CancelledError:
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
chat_id,
|
||||
assistant_message_id,
|
||||
{'done': True, 'error': {'content': 'Sub-agent cancelled.'}},
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
await Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
chat_id,
|
||||
assistant_message_id,
|
||||
{'done': True, 'error': {'content': str(exc)}},
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if background:
|
||||
async with _background_lock:
|
||||
_background_active.discard(delegation_id)
|
||||
elif foreground_semaphore:
|
||||
foreground_semaphore.release()
|
||||
|
||||
async def run_background() -> dict:
|
||||
started_at = time.time()
|
||||
cancelled = False
|
||||
try:
|
||||
result = await run_reserved()
|
||||
except asyncio.CancelledError:
|
||||
result = {'status': 'interrupted', 'summary': '', 'error': 'cancelled'}
|
||||
cancelled = True
|
||||
except Exception as exc:
|
||||
result = {'status': 'error', 'summary': '', 'error': str(exc)}
|
||||
|
||||
duration = f'{time.time() - started_at:.1f}s'
|
||||
lines = [
|
||||
f'[ASYNC SUBAGENT COMPLETE - {delegation_id}]',
|
||||
(
|
||||
'A background subagent you dispatched earlier has finished. '
|
||||
'The original task source is included so you can decide whether '
|
||||
'to use the result or continue without it.'
|
||||
),
|
||||
'',
|
||||
f'Original task: {task}',
|
||||
]
|
||||
if context:
|
||||
lines.append(f'Context provided: {context}')
|
||||
lines.extend(
|
||||
[
|
||||
f'Subagent chat: {chat_id}',
|
||||
f'Status: {result.get("status", "completed")} Duration: {duration}',
|
||||
'--- RESULT ---',
|
||||
]
|
||||
)
|
||||
if result.get('status') == 'completed':
|
||||
lines.append(result.get('summary') or 'Subagent completed without a final summary.')
|
||||
elif result.get('status') == 'interrupted':
|
||||
lines.append('The subagent was interrupted before completing.')
|
||||
if result.get('summary'):
|
||||
lines.extend(['Partial output:', result['summary']])
|
||||
else:
|
||||
detail = f' {result.get("error")}' if result.get('error') else ''
|
||||
lines.append(f'The subagent did not complete successfully.{detail}')
|
||||
if result.get('summary'):
|
||||
lines.extend(['Partial output:', result['summary']])
|
||||
|
||||
pending_message_id = str(uuid4())
|
||||
pending_meta = {
|
||||
'internal': True,
|
||||
'type': 'subagent',
|
||||
'delegation_id': delegation_id,
|
||||
'subagent_chat_id': chat_id,
|
||||
}
|
||||
pending_message = {
|
||||
'id': pending_message_id,
|
||||
'parentId': None,
|
||||
'childrenIds': [],
|
||||
'role': 'user',
|
||||
'content': '\n'.join(lines),
|
||||
'model': run['model_id'],
|
||||
'meta': pending_meta,
|
||||
'timestamp': int(time.time()),
|
||||
}
|
||||
|
||||
lock = _parent_locks.setdefault(parent_chat_id, asyncio.Lock())
|
||||
async with lock:
|
||||
async with get_async_db() as db:
|
||||
stmt = select(Chat).where(Chat.id == parent_chat_id, Chat.user_id == user.id)
|
||||
if db.bind.dialect.name == 'postgresql':
|
||||
stmt = stmt.with_for_update()
|
||||
result_row = await db.execute(stmt)
|
||||
parent = result_row.scalar_one_or_none()
|
||||
if not parent:
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
return result
|
||||
|
||||
updated_chat = copy.deepcopy(parent.chat or {})
|
||||
updated_history = updated_chat.setdefault('history', {})
|
||||
updated_messages = updated_history.setdefault('messages', {})
|
||||
done_assistants = [
|
||||
message
|
||||
for message in updated_messages.values()
|
||||
if message.get('role') == 'assistant' and message.get('done') is not False
|
||||
]
|
||||
result_parent_id = (
|
||||
max(done_assistants, key=lambda message: message.get('timestamp', 0)).get('id')
|
||||
if done_assistants
|
||||
else parent_message_id
|
||||
)
|
||||
pending_message['parentId'] = result_parent_id
|
||||
if await has_active_tasks(request.app.state.redis, parent_chat_id):
|
||||
pending_message['meta']['status'] = 'pending'
|
||||
updated_messages[pending_message_id] = pending_message
|
||||
if result_parent_id and result_parent_id in updated_messages:
|
||||
children = updated_messages[result_parent_id].setdefault('childrenIds', [])
|
||||
if pending_message_id not in children:
|
||||
children.append(pending_message_id)
|
||||
updated_history['messages'] = updated_messages
|
||||
parent.chat = {**(parent.chat or {}), **updated_chat, 'history': updated_history}
|
||||
parent.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
|
||||
await ChatMessages.upsert_message(
|
||||
message_id=pending_message_id,
|
||||
chat_id=parent_chat_id,
|
||||
user_id=user.id,
|
||||
data=pending_message,
|
||||
)
|
||||
|
||||
if pending_message['meta'].get('status') == 'pending':
|
||||
from open_webui.socket.main import sio
|
||||
|
||||
await sio.emit(
|
||||
'events',
|
||||
{
|
||||
'chat_id': parent_chat_id,
|
||||
'message_id': pending_message_id,
|
||||
'data': {'type': 'chat:reload'},
|
||||
},
|
||||
room=f'user:{user.id}',
|
||||
)
|
||||
if not await has_active_tasks(request.app.state.redis, parent_chat_id):
|
||||
await process_pending_internal_messages(request, parent_chat_id, user.id, run)
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError
|
||||
return result
|
||||
|
||||
try:
|
||||
_, child_task = await create_task(
|
||||
request.app.state.redis,
|
||||
run_background() if background else run_reserved(),
|
||||
id=chat_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
if background:
|
||||
async with _background_lock:
|
||||
_background_active.discard(delegation_id)
|
||||
elif foreground_semaphore:
|
||||
foreground_semaphore.release()
|
||||
return f'Error: {exc}'
|
||||
|
||||
if background:
|
||||
return json.dumps(
|
||||
{
|
||||
'status': 'dispatched',
|
||||
'delegation_id': delegation_id,
|
||||
'subagent_chat_id': chat_id,
|
||||
'mode': 'background',
|
||||
'task': task,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await child_task
|
||||
except asyncio.CancelledError:
|
||||
if asyncio.current_task() and asyncio.current_task().cancelling():
|
||||
raise
|
||||
return 'Error: sub-agent was cancelled.'
|
||||
except Exception as exc:
|
||||
return f'Error: {exc}'
|
||||
|
||||
if result.get('status') != 'completed':
|
||||
return f'Error: {result.get("error") or "sub-agent failed."}'
|
||||
return result.get('summary') or 'Sub-agent produced no output.'
|
||||
16
backend/open_webui/utils/terminals.py
Normal file
16
backend/open_webui/utils/terminals.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""Shared routing helpers for admin-configured terminal servers."""
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
def get_terminal_server_url(connection: dict) -> str:
|
||||
"""Return the upstream base URL for a terminal connection.
|
||||
|
||||
An explicit policy uses the named-policy route. Connections without one
|
||||
keep their existing root route.
|
||||
"""
|
||||
base_url = str(connection.get('url') or '').rstrip('/')
|
||||
policy_id = str(connection.get('policy_id') or '').strip()
|
||||
if policy_id:
|
||||
return f'{base_url}/p/{quote(policy_id, safe="")}'
|
||||
return base_url
|
||||
414
backend/open_webui/utils/timers.py
Normal file
414
backend/open_webui/utils/timers.py
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
"""Durable one-shot timers backed by internal child chats."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import select
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
from open_webui.internal.db import get_async_db
|
||||
from open_webui.models.chat_messages import ChatMessages
|
||||
from open_webui.models.chats import Chat, ChatForm, Chats
|
||||
from open_webui.models.users import UserModel, Users
|
||||
from open_webui.tasks import has_active_tasks
|
||||
from open_webui.utils.misc import get_message_list
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_RELATIVE_TIME = re.compile(r'^(?:\+|in\s+)?(\d+)\s*(s|sec(?:onds?)?|m|min(?:utes?)?|h|hours?|d|days?)$')
|
||||
_RFC3339_TIME = re.compile(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$')
|
||||
_TIME_UNITS_NS = {
|
||||
's': 1_000_000_000,
|
||||
'm': 60 * 1_000_000_000,
|
||||
'h': 60 * 60 * 1_000_000_000,
|
||||
'd': 24 * 60 * 60 * 1_000_000_000,
|
||||
}
|
||||
_timer_locks: dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
def parse_timer_at(value: str) -> int:
|
||||
"""Normalize a relative offset or timezone-aware RFC 3339 timestamp."""
|
||||
raw = value.strip()
|
||||
now = time.time_ns()
|
||||
relative = _RELATIVE_TIME.fullmatch(raw.lower())
|
||||
if relative:
|
||||
count = int(relative.group(1))
|
||||
if count <= 0:
|
||||
raise ValueError('at must be in the future.')
|
||||
return now + count * _TIME_UNITS_NS[relative.group(2)[0]]
|
||||
|
||||
if not _RFC3339_TIME.fullmatch(raw):
|
||||
raise ValueError(
|
||||
'at must be a relative time such as 10s or in 10 seconds, or an RFC 3339 timestamp with a timezone.'
|
||||
)
|
||||
try:
|
||||
parsed = datetime.fromisoformat(raw.replace('Z', '+00:00'))
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
'at must be a relative time such as 10s or in 10 seconds, or an RFC 3339 timestamp with a timezone.'
|
||||
) from exc
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
raise ValueError('absolute at values must include an explicit timezone.')
|
||||
|
||||
due_at = int(parsed.timestamp() * 1_000_000_000)
|
||||
if due_at <= now:
|
||||
raise ValueError('at must be in the future.')
|
||||
return due_at
|
||||
|
||||
|
||||
async def create_timer(
|
||||
*,
|
||||
prompt: str,
|
||||
at: str,
|
||||
cancel_on: list[Literal['chat.read', 'chat.user_message']] | None,
|
||||
request: Request,
|
||||
user_data: dict,
|
||||
metadata: dict,
|
||||
parent_chat_id: str,
|
||||
parent_message_id: str | None,
|
||||
) -> str:
|
||||
prompt = prompt.strip()
|
||||
if not prompt:
|
||||
return 'Error: prompt must not be empty.'
|
||||
if not parent_chat_id or not user_data.get('id'):
|
||||
return 'Error: chat and user context are required.'
|
||||
|
||||
try:
|
||||
due_at = parse_timer_at(at)
|
||||
except ValueError as exc:
|
||||
return f'Error: {exc}'
|
||||
|
||||
selected_events = cancel_on or []
|
||||
allowed_events = {'chat.read', 'chat.user_message'}
|
||||
if any(event not in allowed_events for event in selected_events):
|
||||
return 'Error: cancel_on accepts only chat.read and chat.user_message.'
|
||||
selected_events = list(dict.fromkeys(selected_events))
|
||||
|
||||
model_id = metadata.get('model_id') or (metadata.get('model') or {}).get('id')
|
||||
if not model_id:
|
||||
return 'Error: model context is required.'
|
||||
if metadata.get('direct'):
|
||||
return 'Error: timers are unavailable for direct connections.'
|
||||
|
||||
chat_id = str(uuid4())
|
||||
user_message_id = str(uuid4())
|
||||
user = UserModel(**user_data)
|
||||
run = {
|
||||
'model_id': model_id,
|
||||
'session_id': metadata.get('session_id'),
|
||||
'tool_ids': copy.deepcopy(metadata.get('tool_ids') or []),
|
||||
'skill_ids': copy.deepcopy(metadata.get('skill_ids') or []),
|
||||
'system_prompt': metadata.get('system_prompt'),
|
||||
'filter_ids': copy.deepcopy(metadata.get('filter_ids') or []),
|
||||
'terminal_id': metadata.get('terminal_id'),
|
||||
'features': copy.deepcopy(metadata.get('features') or {}),
|
||||
'files': copy.deepcopy(metadata.get('files') or []),
|
||||
'variables': copy.deepcopy(metadata.get('variables') or {}),
|
||||
}
|
||||
|
||||
chat = await Chats.insert_new_chat(
|
||||
chat_id,
|
||||
user.id,
|
||||
ChatForm(
|
||||
chat={
|
||||
'id': chat_id,
|
||||
'title': f'Timer: {prompt[:60]}',
|
||||
'models': [model_id],
|
||||
'history': {
|
||||
'currentId': user_message_id,
|
||||
'messages': {
|
||||
user_message_id: {
|
||||
'id': user_message_id,
|
||||
'parentId': None,
|
||||
'childrenIds': [],
|
||||
'role': 'user',
|
||||
'content': prompt,
|
||||
'timestamp': int(time.time()),
|
||||
'models': [model_id],
|
||||
},
|
||||
},
|
||||
},
|
||||
'messages': [{'role': 'user', 'content': prompt}],
|
||||
}
|
||||
),
|
||||
internal_meta={
|
||||
'internal': True,
|
||||
'type': 'timer',
|
||||
'parent_chat_id': parent_chat_id,
|
||||
'parent_message_id': parent_message_id,
|
||||
'timer_at': due_at,
|
||||
'status': 'pending',
|
||||
'timer_model_id': model_id,
|
||||
'timer_task_message_id': user_message_id,
|
||||
'cancel_on': selected_events,
|
||||
'run': run,
|
||||
},
|
||||
)
|
||||
if not chat:
|
||||
return 'Error: failed to create timer.'
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
'status': 'set',
|
||||
'at': datetime.fromtimestamp(due_at / 1_000_000_000, timezone.utc).isoformat().replace('+00:00', 'Z'),
|
||||
'cancel_on': selected_events,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
async def claim_due_timers(now_ns: int, limit: int = 10) -> list[tuple[str, str]]:
|
||||
"""Claim due timers by moving them from pending to running."""
|
||||
async with get_async_db() as db:
|
||||
stmt = (
|
||||
select(Chat)
|
||||
.where(Chat.meta['internal'].as_boolean().is_(True))
|
||||
.where(Chat.meta['type'].as_string() == 'timer')
|
||||
.where(Chat.meta['status'].as_string() == 'pending')
|
||||
)
|
||||
if db.bind.dialect.name == 'postgresql':
|
||||
stmt = stmt.with_for_update(skip_locked=True)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = [row for row in result.scalars().all() if int((row.meta or {}).get('timer_at') or 0) <= now_ns]
|
||||
rows.sort(key=lambda row: int((row.meta or {}).get('timer_at') or 0))
|
||||
rows = rows[:limit]
|
||||
|
||||
claimed = []
|
||||
for row in rows:
|
||||
claim_id = str(uuid4())
|
||||
row.meta = {
|
||||
**(row.meta or {}),
|
||||
'status': 'running',
|
||||
'timer_started_at': now_ns,
|
||||
'timer_claim_id': claim_id,
|
||||
}
|
||||
row.updated_at = int(time.time())
|
||||
claimed.append((row.id, claim_id))
|
||||
await db.commit()
|
||||
return claimed
|
||||
|
||||
|
||||
async def cancel_timers_for_chat(parent_chat_id: str, event: Literal['chat.read', 'chat.user_message']) -> None:
|
||||
async with get_async_db() as db:
|
||||
result = await db.execute(
|
||||
select(Chat)
|
||||
.where(Chat.meta['internal'].as_boolean().is_(True))
|
||||
.where(Chat.meta['type'].as_string() == 'timer')
|
||||
.where(Chat.meta['parent_chat_id'].as_string() == parent_chat_id)
|
||||
.where(Chat.meta['status'].as_string() == 'pending')
|
||||
)
|
||||
now_ns = int(time.time_ns())
|
||||
for row in result.scalars().all():
|
||||
meta = row.meta or {}
|
||||
if event not in (meta.get('cancel_on') or []):
|
||||
continue
|
||||
row.meta = {
|
||||
**meta,
|
||||
'status': 'cancelled',
|
||||
'timer_cancelled_at': now_ns,
|
||||
'timer_cancelled_by': event,
|
||||
}
|
||||
row.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def execute_due_timer(app, timer_id: str, claim_id: str | None = None) -> None:
|
||||
lock = _timer_locks.setdefault(timer_id, asyncio.Lock())
|
||||
async with lock:
|
||||
from open_webui.socket.main import sio
|
||||
from open_webui.utils.subagents import _parent_locks
|
||||
|
||||
timer = await Chats.get_chat_by_id(timer_id)
|
||||
if not timer:
|
||||
return
|
||||
meta = timer.meta or {}
|
||||
if meta.get('status') != 'running':
|
||||
return
|
||||
if claim_id is not None and meta.get('timer_claim_id') != claim_id:
|
||||
return
|
||||
|
||||
parent_chat_id = meta.get('parent_chat_id') or ''
|
||||
parent = await Chats.get_chat_by_id_and_user_id(parent_chat_id, timer.user_id)
|
||||
if not parent:
|
||||
await _set_timer_state(timer_id, 'error', timer_error='parent chat no longer exists')
|
||||
return
|
||||
|
||||
prompt_message_id = meta.get('timer_task_message_id')
|
||||
prompt_message = await Chats.get_message_by_id_and_message_id(timer_id, prompt_message_id)
|
||||
if not prompt_message:
|
||||
await _set_timer_state(timer_id, 'error', timer_error='timer task message is missing')
|
||||
return
|
||||
|
||||
user = await Users.get_user_by_id(timer.user_id)
|
||||
if not user:
|
||||
await _set_timer_state(timer_id, 'error', timer_error='timer user no longer exists')
|
||||
return
|
||||
|
||||
run = meta.get('run') or {}
|
||||
model_id = run.get('model_id') or meta.get('timer_model_id')
|
||||
if not model_id:
|
||||
await _set_timer_state(timer_id, 'error', timer_error='model context is missing')
|
||||
return
|
||||
|
||||
prompt = prompt_message.get('content') or ''
|
||||
if isinstance(prompt, list):
|
||||
prompt = ''.join(
|
||||
str(part.get('text', '')) for part in prompt if isinstance(part, dict) and part.get('type') == 'text'
|
||||
)
|
||||
|
||||
user_message_id = str(uuid4())
|
||||
assistant_message_id = str(uuid4())
|
||||
user_message = None
|
||||
assistant_message = None
|
||||
message_list = []
|
||||
parent_lock = _parent_locks.setdefault(parent_chat_id, asyncio.Lock())
|
||||
async with parent_lock:
|
||||
async with get_async_db() as db:
|
||||
stmt = select(Chat).where(Chat.id == parent_chat_id, Chat.user_id == timer.user_id)
|
||||
if db.bind.dialect.name == 'postgresql':
|
||||
stmt = stmt.with_for_update()
|
||||
result = await db.execute(stmt)
|
||||
parent = result.scalar_one_or_none()
|
||||
if not parent:
|
||||
await _set_timer_state(timer_id, 'error', timer_error='parent chat no longer exists')
|
||||
return
|
||||
if await has_active_tasks(app.state.redis, parent_chat_id):
|
||||
timer_row = await db.get(Chat, timer_id)
|
||||
if timer_row:
|
||||
timer_row.meta = {
|
||||
**(timer_row.meta or {}),
|
||||
'status': 'pending',
|
||||
'timer_claim_id': None,
|
||||
'timer_started_at': None,
|
||||
}
|
||||
timer_row.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
parent_chat = copy.deepcopy(parent.chat or {})
|
||||
history = parent_chat.setdefault('history', {})
|
||||
messages = history.setdefault('messages', {})
|
||||
done_assistants = [
|
||||
message
|
||||
for message in messages.values()
|
||||
if message.get('role') == 'assistant' and message.get('done') is not False
|
||||
]
|
||||
parent_id = (
|
||||
max(done_assistants, key=lambda message: message.get('timestamp', 0)).get('id')
|
||||
if done_assistants
|
||||
else meta.get('parent_message_id')
|
||||
)
|
||||
message_list = get_message_list(messages, parent_id)
|
||||
|
||||
user_message = {
|
||||
'id': user_message_id,
|
||||
'parentId': parent_id,
|
||||
'childrenIds': [assistant_message_id],
|
||||
'role': 'user',
|
||||
'content': prompt,
|
||||
'model': model_id,
|
||||
'meta': {'internal': True, 'type': 'timer', 'timer_id': timer_id},
|
||||
'timestamp': int(time.time()),
|
||||
}
|
||||
assistant_message = {
|
||||
'id': assistant_message_id,
|
||||
'parentId': user_message_id,
|
||||
'childrenIds': [],
|
||||
'role': 'assistant',
|
||||
'content': '',
|
||||
'done': False,
|
||||
'model': model_id,
|
||||
'timestamp': int(time.time()),
|
||||
}
|
||||
messages[user_message_id] = user_message
|
||||
messages[assistant_message_id] = assistant_message
|
||||
if parent_id and parent_id in messages:
|
||||
children = messages[parent_id].setdefault('childrenIds', [])
|
||||
if user_message_id not in children:
|
||||
children.append(user_message_id)
|
||||
|
||||
parent.chat = parent_chat
|
||||
history['currentId'] = assistant_message_id
|
||||
parent.updated_at = int(time.time())
|
||||
timer_row = await db.get(Chat, timer_id)
|
||||
if timer_row:
|
||||
timer_row.meta = {
|
||||
**(timer_row.meta or {}),
|
||||
'status': 'completed',
|
||||
'timer_completed_at': int(time.time_ns()),
|
||||
}
|
||||
timer_row.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
await ChatMessages.upsert_message(user_message_id, parent_chat_id, timer.user_id, user_message)
|
||||
await ChatMessages.upsert_message(assistant_message_id, parent_chat_id, timer.user_id, assistant_message)
|
||||
|
||||
await sio.emit(
|
||||
'events',
|
||||
{
|
||||
'chat_id': parent_chat_id,
|
||||
'message_id': assistant_message_id,
|
||||
'data': {'type': 'chat:reload'},
|
||||
},
|
||||
room=f'user:{timer.user_id}',
|
||||
)
|
||||
form_data = {
|
||||
'model': model_id,
|
||||
'messages': [
|
||||
*([{'role': 'system', 'content': run.get('system_prompt')}] if run.get('system_prompt') else []),
|
||||
*message_list,
|
||||
{'role': 'user', 'content': prompt},
|
||||
],
|
||||
'stream': True,
|
||||
'chat_id': parent_chat_id,
|
||||
'id': assistant_message_id,
|
||||
'parent_id': user_message.get('parentId'),
|
||||
'user_message': user_message,
|
||||
'session_id': run.get('session_id') or f'timer:{parent_chat_id}',
|
||||
'background_tasks': {},
|
||||
'tool_ids': run.get('tool_ids') or [],
|
||||
'skill_ids': run.get('skill_ids') or [],
|
||||
'filter_ids': run.get('filter_ids') or [],
|
||||
'features': run.get('features') or {},
|
||||
'files': run.get('files') or [],
|
||||
'variables': run.get('variables') or {},
|
||||
}
|
||||
if run.get('terminal_id'):
|
||||
form_data['terminal_id'] = run['terminal_id']
|
||||
request = Request(
|
||||
{
|
||||
'type': 'http',
|
||||
'asgi': {'version': '3.0', 'spec_version': '2.0'},
|
||||
'method': 'POST',
|
||||
'path': '/api/v1/timers/internal',
|
||||
'query_string': b'',
|
||||
'headers': Headers({}).raw,
|
||||
'client': ('127.0.0.1', 0),
|
||||
'server': ('127.0.0.1', 80),
|
||||
'scheme': 'http',
|
||||
'app': app,
|
||||
}
|
||||
)
|
||||
request.state.token = None
|
||||
request.state.enable_api_keys = False
|
||||
await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user)
|
||||
|
||||
|
||||
async def _set_timer_state(timer_id: str, status: str, **fields) -> None:
|
||||
async with get_async_db() as db:
|
||||
row = await db.get(Chat, timer_id)
|
||||
if not row:
|
||||
return
|
||||
row.meta = {**(row.meta or {}), 'status': status, **fields}
|
||||
row.updated_at = int(time.time())
|
||||
await db.commit()
|
||||
|
|
@ -35,11 +35,13 @@ from open_webui.env import (
|
|||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER,
|
||||
AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
|
||||
ENABLE_FORWARD_USER_INFO_HEADERS,
|
||||
ENABLE_PLUGINS,
|
||||
FORWARD_SESSION_INFO_HEADER_CHAT_ID,
|
||||
FORWARD_SESSION_INFO_HEADER_MESSAGE_ID,
|
||||
REDIS_KEY_PREFIX,
|
||||
)
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.models.tools import Tools
|
||||
|
|
@ -50,6 +52,7 @@ from open_webui.tools.builtin import (
|
|||
create_automation,
|
||||
create_calendar_event,
|
||||
create_tasks,
|
||||
delegate_task,
|
||||
delete_automation,
|
||||
delete_calendar_event,
|
||||
delete_memory,
|
||||
|
|
@ -65,6 +68,7 @@ from open_webui.tools.builtin import (
|
|||
list_knowledge_bases,
|
||||
list_memories,
|
||||
list_memory_paths,
|
||||
notify,
|
||||
query_knowledge_bases,
|
||||
query_knowledge_files,
|
||||
read_memory_path,
|
||||
|
|
@ -79,6 +83,7 @@ from open_webui.tools.builtin import (
|
|||
search_memories,
|
||||
search_notes,
|
||||
search_web,
|
||||
timer,
|
||||
toggle_automation,
|
||||
update_automation,
|
||||
update_calendar_event,
|
||||
|
|
@ -97,6 +102,7 @@ from open_webui.utils.access_control import has_access, has_connection_access, h
|
|||
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
|
||||
from open_webui.utils.misc import is_string_allowed
|
||||
from open_webui.utils.plugin import get_tool_contents_cache, get_tools_cache, load_tool_module_by_id
|
||||
from open_webui.utils.terminals import get_terminal_server_url
|
||||
from pydantic import BaseModel, Field, create_model
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
|
|
@ -253,6 +259,9 @@ async def get_updated_tool_function(function: Callable, extra_params: dict):
|
|||
|
||||
async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extra_params: dict) -> dict[str, dict]:
|
||||
"""Load tools for the given tool_ids, checking access control."""
|
||||
if not ENABLE_PLUGINS:
|
||||
return {}
|
||||
|
||||
if not tool_ids:
|
||||
return {}
|
||||
|
||||
|
|
@ -495,6 +504,9 @@ async def get_builtin_tools(
|
|||
'channels.enable',
|
||||
'automations.enable',
|
||||
'calendar.enable',
|
||||
'ui.enable_user_webhooks',
|
||||
'subagents.enable',
|
||||
'subagents.background_enabled',
|
||||
)
|
||||
|
||||
async def has_user_permission(feature_key: str) -> bool:
|
||||
|
|
@ -559,6 +571,14 @@ async def get_builtin_tools(
|
|||
if is_builtin_tool_enabled('chats'):
|
||||
builtin_functions.extend([search_chats, view_chat])
|
||||
|
||||
if (
|
||||
is_builtin_tool_enabled('subagents')
|
||||
and config.get('subagents.enable')
|
||||
and getattr(request.state, 'internal', False) is not True
|
||||
and getattr(request.state, 'direct', False) is not True
|
||||
):
|
||||
builtin_functions.extend([delegate_task, timer])
|
||||
|
||||
# Add memory tools when memory is enabled and the model allows this builtin category.
|
||||
if (
|
||||
is_builtin_tool_enabled('memory')
|
||||
|
|
@ -589,7 +609,8 @@ async def get_builtin_tools(
|
|||
):
|
||||
builtin_functions.extend([search_web, fetch_url])
|
||||
|
||||
# Add image generation/edit tools if builtin category enabled AND enabled globally AND model has image_generation capability
|
||||
# Add image generation/edit tools if builtin category enabled,
|
||||
# globally enabled, and allowed by model capability.
|
||||
if (
|
||||
is_builtin_tool_enabled('image_generation')
|
||||
and config.get('image_generation.enable')
|
||||
|
|
@ -607,7 +628,8 @@ async def get_builtin_tools(
|
|||
):
|
||||
builtin_functions.append(edit_image)
|
||||
|
||||
# Add code interpreter tool if builtin category enabled AND enabled globally AND model has code_interpreter capability
|
||||
# Add code interpreter tool if builtin category enabled,
|
||||
# globally enabled, and allowed by model capability.
|
||||
if (
|
||||
is_builtin_tool_enabled('code_interpreter')
|
||||
and config.get('code_interpreter.enable')
|
||||
|
|
@ -617,8 +639,16 @@ async def get_builtin_tools(
|
|||
):
|
||||
builtin_functions.append(execute_code)
|
||||
|
||||
metadata = extra_params.get('__metadata__') or {}
|
||||
chat_id = metadata.get('chat_id') or ''
|
||||
chat = None
|
||||
if chat_id and not chat_id.startswith(('local:', 'channel:')):
|
||||
chat = await Chats.get_chat_by_id(chat_id)
|
||||
|
||||
# Notes tools - search, view, create, and update user's notes
|
||||
if is_builtin_tool_enabled('notes') and config.get('notes.enable') and await has_user_permission('notes'):
|
||||
if (chat and (chat.meta or {}).get('internal') is True and (chat.meta or {}).get('type') == 'note') or (
|
||||
is_builtin_tool_enabled('notes') and config.get('notes.enable') and await has_user_permission('notes')
|
||||
):
|
||||
builtin_functions.extend([search_notes, view_note, write_note, replace_note_content])
|
||||
|
||||
# Channels tools - search channels and messages
|
||||
|
|
@ -656,6 +686,18 @@ async def get_builtin_tools(
|
|||
[search_calendar_events, create_calendar_event, update_calendar_event, delete_calendar_event]
|
||||
)
|
||||
|
||||
if (
|
||||
is_builtin_tool_enabled('notifications')
|
||||
and config.get('ui.enable_user_webhooks')
|
||||
and await has_user_permission('webhooks')
|
||||
):
|
||||
builtin_functions.append(notify)
|
||||
|
||||
if getattr(request.state, 'internal', False) is True:
|
||||
from open_webui.utils.subagents import MUTATING_MEMORY_TOOLS
|
||||
|
||||
builtin_functions = [func for func in builtin_functions if func.__name__ not in MUTATING_MEMORY_TOOLS]
|
||||
|
||||
for func in builtin_functions:
|
||||
callable = await get_async_tool_function_and_apply_extra_params(
|
||||
func,
|
||||
|
|
@ -675,6 +717,11 @@ async def get_builtin_tools(
|
|||
pydantic_model = convert_function_to_pydantic_model(func)
|
||||
spec = convert_pydantic_model_to_openai_function_spec(pydantic_model)
|
||||
spec = clean_openai_tool_schema(spec)
|
||||
if func.__name__ == 'delegate_task' and not config.get('subagents.background_enabled'):
|
||||
parameters = spec.get('parameters', {})
|
||||
parameters.get('properties', {}).pop('background', None)
|
||||
if isinstance(parameters.get('required'), list):
|
||||
parameters['required'] = [name for name in parameters['required'] if name != 'background']
|
||||
|
||||
tools_dict[func.__name__] = {
|
||||
'tool_id': f'builtin:{func.__name__}',
|
||||
|
|
@ -1071,7 +1118,9 @@ async def get_terminal_system_prompt(
|
|||
trust_env=True,
|
||||
) as session:
|
||||
# 1. Check feature flag
|
||||
async with session.get(f'{base}/api/config', ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp:
|
||||
async with session.get(
|
||||
f'{base}/api/config', headers=headers, cookies=cookies or {}, ssl=AIOHTTP_CLIENT_SESSION_SSL
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
return None
|
||||
config = await resp.json()
|
||||
|
|
@ -1103,13 +1152,7 @@ async def set_terminal_servers(request: Request):
|
|||
|
||||
enabled = connection.get('enabled', True)
|
||||
|
||||
base_url = connection.get('url', '').rstrip('/')
|
||||
policy_id = connection.get('policy_id', '')
|
||||
|
||||
# Orchestrator connections route through /p/{policy_id}/ — the
|
||||
# OpenAPI spec lives on the proxied terminal, not the orchestrator.
|
||||
if connection.get('server_type') == 'orchestrator' and policy_id:
|
||||
base_url = f'{base_url}/p/{policy_id}'
|
||||
base_url = get_terminal_server_url(connection)
|
||||
|
||||
server_configs.append(
|
||||
{
|
||||
|
|
@ -1139,6 +1182,8 @@ async def set_terminal_servers(request: Request):
|
|||
headers = {}
|
||||
if connection.get('auth_type', 'bearer') == 'bearer':
|
||||
headers.update(bearer_auth_header(connection.get('key', '')))
|
||||
if connection.get('policy_id'):
|
||||
headers['X-User-Id'] = 'system'
|
||||
prompt = await get_terminal_system_prompt(server['url'], headers)
|
||||
if prompt:
|
||||
server['system_prompt'] = prompt
|
||||
|
|
@ -1188,24 +1233,21 @@ async def get_terminal_tools(
|
|||
connections = await Config.get('terminal_server.connections', []) or []
|
||||
connection = next((c for c in connections if c.get('id') == terminal_id), None)
|
||||
if connection is None:
|
||||
log.warning(f'Terminal server not found: {terminal_id}')
|
||||
return {}
|
||||
raise RuntimeError(f"Terminal server '{terminal_id}' not found")
|
||||
|
||||
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
|
||||
if not await has_connection_access(user, connection, user_group_ids):
|
||||
log.warning(f'Access denied to terminal {terminal_id} for user {user.id}')
|
||||
return {}
|
||||
raise RuntimeError(f'Access denied to terminal {terminal_id}')
|
||||
|
||||
# Find the cached spec data for this terminal
|
||||
terminal_servers = await get_terminal_servers(request)
|
||||
server_data = next((s for s in terminal_servers if s.get('id') == terminal_id), None)
|
||||
if server_data is None:
|
||||
log.warning(f'Terminal server spec not found for {terminal_id}')
|
||||
return {}
|
||||
raise RuntimeError(f"Terminal server '{terminal_id}' is unavailable")
|
||||
|
||||
specs = server_data.get('specs', [])
|
||||
if not specs:
|
||||
return {}
|
||||
raise RuntimeError(f"Terminal server '{terminal_id}' has no available tools")
|
||||
|
||||
# Build auth headers
|
||||
auth_type = connection.get('auth_type', 'bearer')
|
||||
|
|
@ -1232,7 +1274,7 @@ async def get_terminal_tools(
|
|||
if session_id:
|
||||
headers['X-Session-Id'] = session_id
|
||||
|
||||
terminal_cwd = await get_terminal_cwd(connection.get('url', ''), headers, cookies)
|
||||
terminal_cwd = await get_terminal_cwd(server_data['url'], headers, cookies)
|
||||
|
||||
tools_dict = {}
|
||||
for spec in specs:
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ alembic==1.18.4
|
|||
|
||||
pycrdt==0.13.1
|
||||
redis
|
||||
hiredis
|
||||
|
||||
APScheduler==3.11.2
|
||||
RestrictedPython==8.2
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ psycopg[binary]==3.3.4
|
|||
alembic==1.18.4
|
||||
|
||||
pycrdt==0.13.1
|
||||
redis==8.0.0
|
||||
redis==8.0.1
|
||||
hiredis==3.4.0
|
||||
|
||||
APScheduler==3.11.2
|
||||
RestrictedPython==8.2
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ Your remediation guidance can include, for example:
|
|||
> Similar to rule "Default Configuration Testing": If you believe you have found a vulnerability that affects admins and is NOT caused by admin negligence or intentionally malicious actions,
|
||||
> **then we absolutely want to hear about it.** This policy is intended to filter social engineering attacks on admins, malicious plugins being deployed by admins and similar malicious actions, not to discourage legitimate security research.
|
||||
|
||||
10. **Tools & Functions Code Execution Is Intended Behavior:** Open WebUI's Tools and Functions feature is **designed** to execute user-provided Python code on the server. This is core, intentional functionality — not a vulnerability (see also 'Threat Model Understanding'). Function creation is **restricted to administrators only**. Tool creation is controlled by the `workspace.tools` permission, which is **disabled by default** for non-admin users and should only be granted to fully trusted users who are equivalent to system administrators in terms of trust. <ins>**Granting a user the ability to create Tools is equivalent to giving them shell access to the server**</ins>. If an administrator grants this permission to untrusted users, this constitutes intentional misconfiguration and is additionally covered by 'Admin Actions Are Out of Scope'. More generally, **reports describing ANY attack chain that involves Tools or Functions — including but not limited to code execution, file access, network requests, or environment variable access — will be closed as not a vulnerability / intended behavior.** This applies to both direct code execution and frontmatter-based package installation (`pip install`).
|
||||
10. **Tools & Functions Code Execution Is Intended Behavior:** Open WebUI's Tools and Functions feature is **designed** to execute user-provided Python code on the server. This is core, intentional functionality — not a vulnerability (see also 'Threat Model Understanding'). Function creation is **restricted to administrators only**. Tool creation is controlled by the `workspace.tools` permission, which is **disabled by default** for non-admin users and should only be granted to fully trusted users who are equivalent to system administrators in terms of trust. <ins>**Granting a user the ability to create Tools is equivalent to giving them shell access to the server**</ins>. If an administrator grants this permission to untrusted users, this constitutes intentional misconfiguration and is additionally covered by 'Admin Actions Are Out of Scope'. Deployments that do not need `workspace.tools` or Functions plugin execution can set `ENABLE_PLUGINS=false`. More generally, **reports describing ANY attack chain that involves Tools or Functions — including but not limited to code execution, file access, network requests, or environment variable access — will be closed as not a vulnerability / intended behavior.** This applies to both direct code execution and frontmatter-based package installation (`pip install`).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **For administrators:** Treat the `workspace.tools` permission as **root-equivalent access**. Only grant it to users you would trust with direct access to your server. If you enable this permission for untrusted users, you are accepting the risk of arbitrary code execution on your host. For more details, see our [Plugin Security documentation](https://docs.openwebui.com/features/extensibility/plugin/).
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@ dependencies = [
|
|||
"alembic==1.18.4",
|
||||
|
||||
"pycrdt==0.13.1",
|
||||
"redis==8.0.0",
|
||||
"redis==8.0.1",
|
||||
"hiredis==3.4.0",
|
||||
# "valkey-glide-sync==2.3.1", # optional: install manually if VECTOR_DB=valkey
|
||||
|
||||
"pytz==2026.2",
|
||||
|
|
@ -151,7 +152,6 @@ all = [
|
|||
"psycopg2-binary==2.9.12",
|
||||
"pgvector==0.4.2",
|
||||
"moto[s3]>=5.0.26",
|
||||
"gcp-storage-emulator>=2024.8.3",
|
||||
"docker~=7.1.0",
|
||||
"pytest~=8.3.2",
|
||||
"pytest-docker~=3.2.5",
|
||||
|
|
|
|||
47
src/app.css
47
src/app.css
|
|
@ -6,12 +6,6 @@
|
|||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Archivo';
|
||||
src: url('/assets/fonts/Archivo-Variable.ttf');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Mona Sans';
|
||||
src: url('/assets/fonts/Mona-Sans.woff2');
|
||||
|
|
@ -74,6 +68,18 @@ code {
|
|||
font-family: 'InstrumentSerif', sans-serif;
|
||||
}
|
||||
|
||||
.app-muted {
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.app-icon-muted {
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.app-interactive-active {
|
||||
@apply bg-gray-50/40 dark:bg-gray-800/40;
|
||||
}
|
||||
|
||||
.marked a {
|
||||
@apply underline;
|
||||
}
|
||||
|
|
@ -94,34 +100,43 @@ textarea::placeholder {
|
|||
direction: auto;
|
||||
}
|
||||
|
||||
textarea {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark textarea {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
textarea::-webkit-resizer,
|
||||
textarea::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.input-prose {
|
||||
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
|
||||
@apply prose dark:prose-invert !text-[0.9375rem] prose-headings:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.input-prose-sm {
|
||||
@apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
|
||||
@apply prose dark:prose-invert prose-headings:font-normal prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
|
||||
}
|
||||
|
||||
.markdown-prose {
|
||||
@apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
@apply prose prose-sm dark:prose-invert max-w-none break-words !text-[0.9375rem] font-normal leading-relaxed prose-p:mt-0 prose-p:mb-2 prose-p:font-normal prose-p:leading-relaxed prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-normal prose-headings:leading-snug prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-li:font-normal prose-pre:my-3 prose-table:my-0 prose-blockquote:my-3 prose-blockquote:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850/30 prose-img:my-2 [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_p:first-child]:mt-0 [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0 [&_h5:first-child]:mt-0 [&_h6:first-child]:mt-0;
|
||||
}
|
||||
|
||||
.markdown-prose-sm {
|
||||
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-normal prose-hr:my-2 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.markdown-prose-xs {
|
||||
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-normal prose-hr:my-0.5 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.markdown a {
|
||||
@apply underline;
|
||||
}
|
||||
|
||||
.font-primary {
|
||||
font-family: 'Archivo', 'Vazirmatn', sans-serif;
|
||||
}
|
||||
|
||||
.drag-region {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
|
@ -673,7 +688,7 @@ input[type='number'] {
|
|||
}
|
||||
|
||||
.tiptap th {
|
||||
@apply cursor-pointer text-start text-xs text-gray-700 dark:text-gray-400 font-semibold uppercase bg-gray-50 dark:bg-gray-850;
|
||||
@apply cursor-pointer text-start text-xs text-gray-700 dark:text-gray-400 font-normal uppercase bg-gray-50 dark:bg-gray-850;
|
||||
}
|
||||
|
||||
.tiptap td {
|
||||
|
|
|
|||
|
|
@ -920,6 +920,47 @@ export const cloneChatById = async (token: string, id: string, title?: string) =
|
|||
return res;
|
||||
};
|
||||
|
||||
export const forkChatById = async (token: string, id: string, messageId?: string | null) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/fork`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message_id: messageId ?? null
|
||||
})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
|
||||
if ('detail' in err) {
|
||||
error = err.detail;
|
||||
} else {
|
||||
error = err;
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const cloneSharedChatById = async (token: string, id: string) => {
|
||||
let error = null;
|
||||
|
||||
|
|
@ -1191,6 +1232,39 @@ export const updateChatById = async (token: string, id: string, chat: object) =>
|
|||
return res;
|
||||
};
|
||||
|
||||
export const compactChatById = async (token: string, id: string, model?: string | null) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/compact`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
},
|
||||
body: JSON.stringify({ model })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const deleteChatMessageById = async (token: string, id: string, messageId: string) => {
|
||||
let error = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -311,6 +311,33 @@ export const putOrchestratorPolicy = async (
|
|||
return res;
|
||||
};
|
||||
|
||||
export const getOrchestratorPolicy = async (
|
||||
token: string,
|
||||
url: string,
|
||||
key: string,
|
||||
policyId: string,
|
||||
authType: string = 'bearer'
|
||||
): Promise<any> => {
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers/policy`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: url.replace(/\/$/, ''),
|
||||
key,
|
||||
auth_type: authType,
|
||||
policy_id: policyId
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json();
|
||||
throw Object.assign(new Error(body.detail || 'Failed to read policy'), { status: res.status });
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const putOrchestratorLifecycle = async (
|
||||
token: string,
|
||||
url: string,
|
||||
|
|
@ -352,6 +379,35 @@ export const putOrchestratorLifecycle = async (
|
|||
return res;
|
||||
};
|
||||
|
||||
export const getOrchestratorLifecycle = async (
|
||||
token: string,
|
||||
url: string,
|
||||
key: string,
|
||||
policyId: string,
|
||||
authType: string = 'bearer'
|
||||
): Promise<any> => {
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/terminal_servers/lifecycle`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: url.replace(/\/$/, ''),
|
||||
key,
|
||||
auth_type: authType,
|
||||
policy_id: policyId
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json();
|
||||
throw Object.assign(new Error(body.detail || 'Failed to read lifecycle'), {
|
||||
status: res.status
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const refreshOrchestratorTerminals = async (
|
||||
token: string,
|
||||
url: string,
|
||||
|
|
@ -661,6 +717,31 @@ export const setModelsConfig = async (token: string, config: object) => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export const getSubagentsConfig = async (token: string) => {
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/subagents`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const setSubagentsConfig = async (token: string, config: object) => {
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/configs/subagents`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const setDefaultPromptSuggestions = async (token: string, promptSuggestions: string) => {
|
||||
let error = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -290,17 +290,40 @@ export const getSharedFolders = async (token: string) => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export const getSharedFolderChats = async (token: string, folderId: string) => {
|
||||
export const getSharedFolderChats = async (
|
||||
token: string,
|
||||
folderId: string,
|
||||
params: {
|
||||
page?: number | null;
|
||||
sortBy?: 'title' | 'updated_at';
|
||||
sortDir?: 'asc' | 'desc';
|
||||
} = {}
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/folders/${folderId}/shared/chats`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.page !== undefined && params.page !== null) {
|
||||
searchParams.append('page', `${params.page}`);
|
||||
}
|
||||
if (params.sortBy) {
|
||||
searchParams.append('sort_by', params.sortBy);
|
||||
}
|
||||
if (params.sortDir) {
|
||||
searchParams.append('sort_dir', params.sortDir);
|
||||
}
|
||||
const query = searchParams.toString();
|
||||
|
||||
const res = await fetch(
|
||||
`${WEBUI_API_BASE_URL}/folders/${folderId}/shared/chats${query ? `?${query}` : ''}`,
|
||||
{
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -97,7 +97,8 @@ export const searchNotes = async (
|
|||
viewOption: string | null = null,
|
||||
permission: string | null = null,
|
||||
sortKey: string | null = null,
|
||||
page: number | null = null
|
||||
page: number | null = null,
|
||||
direction: string | null = null
|
||||
) => {
|
||||
let error = null;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
|
@ -118,6 +119,10 @@ export const searchNotes = async (
|
|||
searchParams.append('order_by', sortKey);
|
||||
}
|
||||
|
||||
if (direction !== null) {
|
||||
searchParams.append('direction', direction);
|
||||
}
|
||||
|
||||
if (page !== null) {
|
||||
searchParams.append('page', `${page}`);
|
||||
}
|
||||
|
|
@ -218,6 +223,98 @@ export const getNoteById = async (token: string, id: string) => {
|
|||
return res;
|
||||
};
|
||||
|
||||
export const getNoteChatById = async (token: string, id: string) => {
|
||||
let error = null;
|
||||
const url = `${WEBUI_API_BASE_URL}/notes/${id}/chat`;
|
||||
|
||||
console.info('[note-chat] fetching linked chat', { noteId: id, url });
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
console.info('[note-chat] linked chat response', {
|
||||
noteId: id,
|
||||
status: res.status,
|
||||
ok: res.ok
|
||||
});
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
console.error('[note-chat] linked chat request failed', { noteId: id, error: err });
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getNoteChatsById = async (token: string, id: string) => {
|
||||
let error = null;
|
||||
const url = `${WEBUI_API_BASE_URL}/notes/${id}/chats`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const createNoteChatById = async (token: string, id: string) => {
|
||||
let error = null;
|
||||
const url = `${WEBUI_API_BASE_URL}/notes/${id}/chat`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
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;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const updateNoteById = async (token: string, id: string, note: NoteItem) => {
|
||||
let error = null;
|
||||
|
||||
|
|
|
|||
81
src/lib/apis/notifications/index.ts
Normal file
81
src/lib/apis/notifications/index.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
export type NotificationTarget = {
|
||||
id: string;
|
||||
type: 'webhook';
|
||||
is_default?: boolean;
|
||||
enabled: boolean;
|
||||
events: string[];
|
||||
delivery: 'away' | 'always';
|
||||
config: {
|
||||
url?: string;
|
||||
url_masked?: string;
|
||||
};
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
};
|
||||
|
||||
export type NotificationEvent = {
|
||||
event: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
const jsonRequest = async (url: string, token: string, method = 'GET', body?: object) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {})
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getNotificationEvents = async (token: string): Promise<NotificationEvent[]> => {
|
||||
const data = await jsonRequest(`${WEBUI_API_BASE_URL}/notifications/events`, token);
|
||||
return data?.events ?? data ?? [];
|
||||
};
|
||||
|
||||
export const getNotificationTargets = async (
|
||||
token: string
|
||||
): Promise<{ targets: NotificationTarget[] }> =>
|
||||
jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets`, token);
|
||||
|
||||
export const createNotificationTarget = async (
|
||||
token: string,
|
||||
target: Partial<NotificationTarget>
|
||||
) => jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets`, token, 'POST', target);
|
||||
|
||||
export const updateNotificationTarget = async (
|
||||
token: string,
|
||||
targetId: string,
|
||||
target: Partial<NotificationTarget>
|
||||
) => jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets/${targetId}`, token, 'PUT', target);
|
||||
|
||||
export const deleteNotificationTarget = async (token: string, targetId: string) =>
|
||||
jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets/${targetId}`, token, 'DELETE');
|
||||
|
||||
export const setDefaultNotificationTarget = async (token: string, targetId: string) =>
|
||||
jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets/${targetId}/default`, token, 'PUT');
|
||||
|
||||
export const testNotificationTarget = async (token: string, targetId: string) =>
|
||||
jsonRequest(`${WEBUI_API_BASE_URL}/notifications/targets/${targetId}/test`, token, 'POST');
|
||||
|
|
@ -97,7 +97,9 @@ export const getSkillItems = async (
|
|||
token: string = '',
|
||||
query: string | null = null,
|
||||
viewOption: string | null = null,
|
||||
page: number | null = null
|
||||
page: number | null = null,
|
||||
orderBy: string | null = null,
|
||||
direction: string | null = null
|
||||
) => {
|
||||
let error = null;
|
||||
|
||||
|
|
@ -105,6 +107,8 @@ export const getSkillItems = async (
|
|||
if (query) searchParams.append('query', query);
|
||||
if (viewOption) searchParams.append('view_option', viewOption);
|
||||
if (page) searchParams.append('page', page.toString());
|
||||
if (orderBy) searchParams.append('order_by', orderBy);
|
||||
if (direction) searchParams.append('direction', direction);
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/skills/list?${searchParams.toString()}`, {
|
||||
method: 'GET',
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
||||
export const checkActiveChats = async (token: string, chatIds: string[]) => {
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/tasks/active/chats`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ chat_ids: chatIds })
|
||||
});
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
};
|
||||
|
|
@ -604,3 +604,87 @@ export const getUserPreview = async (token: string, userId: string) => {
|
|||
|
||||
return res;
|
||||
};
|
||||
|
||||
export type UserUsageHeatmapEntry = {
|
||||
date: string;
|
||||
messages: number;
|
||||
chats: number;
|
||||
tokens: number;
|
||||
models: Record<string, number>;
|
||||
};
|
||||
|
||||
export type UserUsageResponse = {
|
||||
totals: {
|
||||
lifetime_tokens: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
peak_daily_tokens: number;
|
||||
longest_chat_seconds: number;
|
||||
current_streak: number;
|
||||
longest_streak: number;
|
||||
total_chats: number;
|
||||
active_days: number;
|
||||
models_used: number;
|
||||
messages: number;
|
||||
user_messages: number;
|
||||
assistant_messages: number;
|
||||
};
|
||||
heatmap: UserUsageHeatmapEntry[];
|
||||
weekly_heatmap: UserUsageHeatmapEntry[];
|
||||
cumulative_heatmap: UserUsageHeatmapEntry[];
|
||||
insights: {
|
||||
most_used_model: string | null;
|
||||
average_tokens_per_chat: number;
|
||||
average_messages_per_active_day: number;
|
||||
user_message_share: number;
|
||||
assistant_message_share: number;
|
||||
};
|
||||
top_models: Array<{
|
||||
model_id: string;
|
||||
messages: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
}>;
|
||||
top_tools: Array<{ name: string; count: number }>;
|
||||
period: {
|
||||
start_date: number;
|
||||
end_date: number;
|
||||
days: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const getUserUsage = async (
|
||||
token: string,
|
||||
options: { days?: number; startDate?: number | null; endDate?: number | null } = {}
|
||||
): Promise<UserUsageResponse | null> => {
|
||||
let error = null;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (options.days) searchParams.append('days', options.days.toString());
|
||||
if (options.startDate) searchParams.append('start_date', options.startDate.toString());
|
||||
if (options.endDate) searchParams.append('end_date', options.endDate.toString());
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/users/usage?${searchParams.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err.detail;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { getContext, onMount } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { settings } from '$lib/stores';
|
||||
import { verifyOpenAIConnection } from '$lib/apis/openai';
|
||||
import { verifyOllamaConnection } from '$lib/apis/ollama';
|
||||
|
||||
|
|
@ -50,6 +49,7 @@
|
|||
let apiType = ''; // '' = chat completions (default), 'responses' = Responses API
|
||||
|
||||
let headers = '';
|
||||
let passthroughParams = '';
|
||||
|
||||
let tags = [];
|
||||
|
||||
|
|
@ -58,6 +58,18 @@
|
|||
|
||||
let loading = false;
|
||||
let showDeleteConfirmDialog = false;
|
||||
let showAdvanced = false;
|
||||
|
||||
const inputClass =
|
||||
'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
|
||||
const selectClass =
|
||||
'dark:bg-gray-900 bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
|
||||
|
||||
const parsePassthroughParams = (value: string) =>
|
||||
value
|
||||
.split(',')
|
||||
.map((param) => param.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const verifyOllamaHandler = async () => {
|
||||
// remove trailing slash from url
|
||||
|
|
@ -105,6 +117,7 @@
|
|||
...(provider ? { provider } : {}),
|
||||
...(azure ? { azure: true } : {}),
|
||||
api_version: apiVersion,
|
||||
passthrough_params: parsePassthroughParams(passthroughParams),
|
||||
...(_headers ? { headers: _headers } : {})
|
||||
}
|
||||
},
|
||||
|
|
@ -145,6 +158,7 @@
|
|||
if (azure) {
|
||||
if (!apiVersion) {
|
||||
loading = false;
|
||||
showAdvanced = true;
|
||||
|
||||
toast.error($i18n.t('API Version is required'));
|
||||
return;
|
||||
|
|
@ -159,6 +173,7 @@
|
|||
|
||||
if (modelIds.length === 0) {
|
||||
loading = false;
|
||||
showAdvanced = true;
|
||||
toast.error($i18n.t('Deployment names are required for Azure OpenAI'));
|
||||
return;
|
||||
}
|
||||
|
|
@ -191,6 +206,7 @@
|
|||
connection_type: connectionType,
|
||||
auth_type,
|
||||
headers: headers ? JSON.parse(headers) : undefined,
|
||||
passthrough_params: parsePassthroughParams(passthroughParams),
|
||||
...(provider ? { provider } : {}),
|
||||
...(!ollama && azure ? { azure: true } : {}),
|
||||
...(azure ? { api_version: apiVersion } : {}),
|
||||
|
|
@ -207,6 +223,8 @@
|
|||
key = '';
|
||||
auth_type = 'bearer';
|
||||
prefixId = '';
|
||||
passthroughParams = '';
|
||||
showAdvanced = false;
|
||||
tags = [];
|
||||
modelIds = [];
|
||||
};
|
||||
|
|
@ -224,6 +242,9 @@
|
|||
enable = connection.config?.enable ?? true;
|
||||
tags = connection.config?.tags ?? [];
|
||||
prefixId = connection.config?.prefix_id ?? '';
|
||||
passthroughParams = Array.isArray(connection.config?.passthrough_params)
|
||||
? connection.config.passthrough_params.join(', ')
|
||||
: (connection.config?.passthrough_params ?? '');
|
||||
modelIds = connection.config?.model_ids ?? [];
|
||||
|
||||
if (ollama) {
|
||||
|
|
@ -306,14 +327,13 @@
|
|||
<label
|
||||
for="url-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
>{$i18n.t('URL')}</label
|
||||
`}>{$i18n.t('URL')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
id="url-input"
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full text-sm ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={url}
|
||||
placeholder={$i18n.t('API Base URL')}
|
||||
|
|
@ -373,9 +393,7 @@
|
|||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="select-bearer-or-session"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
<label for="select-bearer-or-session" class={`text-xs text-gray-500`}
|
||||
>{$i18n.t('Auth')}</label
|
||||
>
|
||||
|
||||
|
|
@ -383,7 +401,7 @@
|
|||
<div class="flex-shrink-0 self-start">
|
||||
<select
|
||||
id="select-bearer-or-session"
|
||||
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full text-sm ${selectClass}`}
|
||||
bind:value={auth_type}
|
||||
>
|
||||
<option value="none">{$i18n.t('None')}</option>
|
||||
|
|
@ -407,27 +425,19 @@
|
|||
required={false}
|
||||
/>
|
||||
{:else if auth_type === 'none'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
|
||||
{$i18n.t('No authentication')}
|
||||
</div>
|
||||
{:else if auth_type === 'session'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
|
||||
{$i18n.t('Forwards system user session credentials to authenticate')}
|
||||
</div>
|
||||
{:else if auth_type === 'system_oauth'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
|
||||
{$i18n.t('Forwards system user OAuth access token to authenticate')}
|
||||
</div>
|
||||
{:else if ['azure_ad', 'microsoft_entra_id'].includes(auth_type)}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
|
||||
{$i18n.t('Uses DefaultAzureCredential to authenticate')}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -436,118 +446,12 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if !direct}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="headers-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
>{$i18n.t('Headers')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Enter additional headers in JSON format (e.g. {"X-Custom-Header": "value"}'
|
||||
)}
|
||||
>
|
||||
<Textarea
|
||||
className="w-full text-sm outline-hidden"
|
||||
bind:value={headers}
|
||||
placeholder={$i18n.t('Enter additional headers in JSON format')}
|
||||
required={false}
|
||||
minSize={30}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="prefix-id-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
>{$i18n.t('Prefix ID')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Prefix ID is used to avoid conflicts with other connections by adding a prefix to the model IDs - leave empty to disable'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
id="prefix-id-input"
|
||||
bind:value={prefixId}
|
||||
placeholder={$i18n.t('Prefix ID')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !ollama && !direct}
|
||||
<div class="flex flex-row justify-between items-center w-full mt-2">
|
||||
<label
|
||||
for="provider-select"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
>{$i18n.t('Provider')}</label
|
||||
>
|
||||
|
||||
<div>
|
||||
<select
|
||||
id="provider-select"
|
||||
bind:value={provider}
|
||||
class="text-xs text-gray-700 dark:text-gray-300 bg-transparent outline-hidden"
|
||||
>
|
||||
<option value="">{$i18n.t('Default')}</option>
|
||||
<option value="azure">{$i18n.t('Azure OpenAI')}</option>
|
||||
<option value="llama.cpp">{$i18n.t('llama.cpp')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if azure}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="api-version-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
>{$i18n.t('API Version')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
id="api-version-input"
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
bind:value={apiVersion}
|
||||
placeholder={$i18n.t('API Version')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !ollama && !direct}
|
||||
<div class="flex flex-row justify-between items-center w-full mt-1">
|
||||
<label
|
||||
for="api-type-toggle"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
>{$i18n.t('API Type')}</label
|
||||
`}>{$i18n.t('API Type')}</label
|
||||
>
|
||||
|
||||
<div>
|
||||
|
|
@ -560,18 +464,7 @@
|
|||
class=" text-xs text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{#if apiType === 'responses'}
|
||||
<Tooltip
|
||||
className="flex items-center gap-1"
|
||||
content={$i18n.t(
|
||||
'This feature is currently experimental and may not work as expected.'
|
||||
)}
|
||||
>
|
||||
<span class=" text-gray-400 dark:text-gray-600"
|
||||
>{$i18n.t('Experimental')}</span
|
||||
>
|
||||
|
||||
{$i18n.t('Responses')}
|
||||
</Tooltip>
|
||||
{$i18n.t('Responses')}
|
||||
{:else}
|
||||
{$i18n.t('Chat Completions')}
|
||||
{/if}
|
||||
|
|
@ -580,115 +473,268 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col w-full mt-2">
|
||||
<div class="mb-1 flex justify-between">
|
||||
<div
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
<div class="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition mt-2"
|
||||
on:click={() => (showAdvanced = !showAdvanced)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-3 h-3 transition-transform {showAdvanced ? 'rotate-90' : ''}"
|
||||
>
|
||||
{$i18n.t('Model IDs')}
|
||||
</div>
|
||||
</div>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{$i18n.t('Advanced')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if modelIds.length > 0}
|
||||
<ul class="flex flex-col">
|
||||
{#each modelIds as modelId, modelIdx}
|
||||
<li class=" flex gap-2 w-full justify-between items-center">
|
||||
<div class=" text-sm flex-1 py-1 rounded-lg">
|
||||
{modelId}
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<button
|
||||
aria-label={$i18n.t(`Remove {{MODELID}} from list.`, {
|
||||
MODELID: modelId
|
||||
})}
|
||||
type="button"
|
||||
on:click={() => {
|
||||
modelIds = modelIds.filter((_, idx) => idx !== modelIdx);
|
||||
}}
|
||||
>
|
||||
<Minus strokeWidth="2" className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<div
|
||||
class={`text-gray-500 text-xs text-center py-2 px-10
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
>
|
||||
{#if ollama}
|
||||
{$i18n.t('Leave empty to include all models from "{{url}}/api/tags" endpoint', {
|
||||
url: url
|
||||
})}
|
||||
{:else if azure}
|
||||
{$i18n.t('Deployment names are required for Azure OpenAI')}
|
||||
<!-- {$i18n.t('Leave empty to include all models from "{{url}}" endpoint', {
|
||||
url: `${url}/openai/deployments`
|
||||
})} -->
|
||||
{:else}
|
||||
{$i18n.t('Leave empty to include all models from "{{url}}/models" endpoint', {
|
||||
url: url
|
||||
})}
|
||||
{/if}
|
||||
{#if showAdvanced}
|
||||
{#if !direct}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="headers-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}>{$i18n.t('Headers')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Enter additional headers in JSON format (e.g. {"X-Custom-Header": "value"}'
|
||||
)}
|
||||
>
|
||||
<Textarea
|
||||
className="w-full text-sm outline-hidden"
|
||||
bind:value={headers}
|
||||
placeholder={$i18n.t('Enter additional headers in JSON format')}
|
||||
required={false}
|
||||
minSize={30}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
|
||||
<input
|
||||
class="w-full py-1 text-sm rounded-lg bg-transparent {modelId
|
||||
? ''
|
||||
: 'text-gray-500'} {($settings?.highContrastMode ?? false)
|
||||
? 'dark:placeholder:text-gray-100 placeholder:text-gray-700'
|
||||
: 'placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden'}"
|
||||
bind:value={modelId}
|
||||
id="add-model-id-input"
|
||||
placeholder={$i18n.t('Add a model ID')}
|
||||
/>
|
||||
{#if !ollama && !direct}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="allowed-passthrough-params-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}>{$i18n.t('Passthrough params')}</label
|
||||
>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Add')}
|
||||
on:click={() => {
|
||||
addModelHandler();
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" strokeWidth="2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Comma-separated top-level request parameters this upstream may receive without translation. Use * to allow all captured passthrough params.'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
class={`w-full text-sm ${inputClass}`}
|
||||
type="text"
|
||||
id="allowed-passthrough-params-input"
|
||||
bind:value={passthroughParams}
|
||||
placeholder={$i18n.t('thinking, output_config')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
>
|
||||
{$i18n.t('Tags')}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="prefix-id-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}>{$i18n.t('Prefix ID')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Prefix ID is used to avoid conflicts with other connections by adding a prefix to the model IDs - leave empty to disable'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
class={`w-full text-sm ${inputClass}`}
|
||||
type="text"
|
||||
id="prefix-id-input"
|
||||
bind:value={prefixId}
|
||||
placeholder={$i18n.t('Prefix ID')}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 mt-0.5">
|
||||
<Tags
|
||||
bind:tags
|
||||
on:add={(e) => {
|
||||
tags = [
|
||||
...tags,
|
||||
{
|
||||
name: e.detail
|
||||
}
|
||||
];
|
||||
}}
|
||||
on:delete={(e) => {
|
||||
tags = tags.filter((tag) => tag.name !== e.detail);
|
||||
}}
|
||||
{#if !ollama && !direct}
|
||||
<div class="flex flex-row justify-between items-center w-full mt-2">
|
||||
<label
|
||||
for="provider-select"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}>{$i18n.t('Provider')}</label
|
||||
>
|
||||
|
||||
<div>
|
||||
<select
|
||||
id="provider-select"
|
||||
bind:value={provider}
|
||||
class="text-xs text-gray-700 dark:text-gray-300 bg-transparent outline-hidden"
|
||||
>
|
||||
<option value="">{$i18n.t('Default')}</option>
|
||||
<option value="azure">{$i18n.t('Azure OpenAI')}</option>
|
||||
<option value="llama.cpp">{$i18n.t('llama.cpp')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if azure}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="api-version-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}>{$i18n.t('API Version')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
id="api-version-input"
|
||||
class={`w-full text-sm ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={apiVersion}
|
||||
placeholder={$i18n.t('API Version')}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col w-full mt-2">
|
||||
<div class="mb-1 flex justify-between">
|
||||
<div
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}
|
||||
>
|
||||
{$i18n.t('Model IDs')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if modelIds.length > 0}
|
||||
<ul class="flex flex-col">
|
||||
{#each modelIds as modelId, modelIdx}
|
||||
<li class=" flex gap-2 w-full justify-between items-center">
|
||||
<div class=" text-sm flex-1 py-1 rounded-lg">
|
||||
{modelId}
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<button
|
||||
aria-label={$i18n.t(`Remove {{MODELID}} from list.`, {
|
||||
MODELID: modelId
|
||||
})}
|
||||
type="button"
|
||||
on:click={() => {
|
||||
modelIds = modelIds.filter((_, idx) => idx !== modelIdx);
|
||||
}}
|
||||
>
|
||||
<Minus strokeWidth="2" className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<div
|
||||
class={`text-gray-500 text-xs text-center py-2 px-10
|
||||
`}
|
||||
>
|
||||
{#if ollama}
|
||||
{$i18n.t(
|
||||
'Leave empty to include all models from "{{url}}/api/tags" endpoint',
|
||||
{
|
||||
url: url
|
||||
}
|
||||
)}
|
||||
{:else if azure}
|
||||
{$i18n.t('Deployment names are required for Azure OpenAI')}
|
||||
<!-- {$i18n.t('Leave empty to include all models from "{{url}}" endpoint', {
|
||||
url: `${url}/openai/deployments`
|
||||
})} -->
|
||||
{:else}
|
||||
{$i18n.t('Leave empty to include all models from "{{url}}/models" endpoint', {
|
||||
url: url
|
||||
})}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
|
||||
<input
|
||||
class={`w-full text-sm ${inputClass} ${modelId ? '' : 'text-gray-500'}`}
|
||||
bind:value={modelId}
|
||||
id="add-model-id-input"
|
||||
placeholder={$i18n.t('Add a model ID')}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Add')}
|
||||
on:click={() => {
|
||||
addModelHandler();
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" strokeWidth="2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
`}
|
||||
>
|
||||
{$i18n.t('Tags')}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 mt-0.5">
|
||||
<Tags
|
||||
bind:tags
|
||||
on:add={(e) => {
|
||||
tags = [
|
||||
...tags,
|
||||
{
|
||||
name: e.detail
|
||||
}
|
||||
];
|
||||
}}
|
||||
on:delete={(e) => {
|
||||
tags = tags.filter((tag) => tag.name !== e.detail);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center pt-3 text-sm font-medium">
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
</script>
|
||||
|
||||
<div class="px-3">
|
||||
<div class="text-center dark:text-white text-2xl font-medium z-50" role="heading" aria-level="2">
|
||||
<div class="text-center dark:text-white text-2xl font-normal z-50" role="heading" aria-level="2">
|
||||
{#if title}
|
||||
{title}
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,11 @@
|
|||
import { getContext, onMount } from 'svelte';
|
||||
const i18n = getContext<any>('i18n');
|
||||
|
||||
import { settings } from '$lib/stores';
|
||||
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import SensitiveInput from '$lib/components/common/SensitiveInput.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte';
|
||||
import LockClosed from '$lib/components/icons/LockClosed.svelte';
|
||||
import AccessButton from '$lib/components/common/AccessButton.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import {
|
||||
|
|
@ -17,6 +15,8 @@
|
|||
verifyTerminalServerConnection,
|
||||
putOrchestratorPolicy,
|
||||
putOrchestratorLifecycle,
|
||||
getOrchestratorPolicy,
|
||||
getOrchestratorLifecycle,
|
||||
refreshOrchestratorTerminals
|
||||
} from '$lib/apis/configs';
|
||||
import { getTerminalConfig } from '$lib/apis/terminal';
|
||||
|
|
@ -56,6 +56,13 @@
|
|||
let lifecycleJson = '{}';
|
||||
let refreshOnlyIdle = true;
|
||||
let refreshReset = false;
|
||||
let loadingPolicy = false;
|
||||
let policyLoadError = '';
|
||||
|
||||
const inputClass =
|
||||
'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
|
||||
const selectClass =
|
||||
'dark:bg-gray-900 bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
|
||||
|
||||
const stringifyJson = (value: object | null | undefined) => {
|
||||
return JSON.stringify(value && Object.keys(value).length ? value : {}, null, 2);
|
||||
|
|
@ -73,10 +80,10 @@
|
|||
accessGrants = connection?.config?.access_grants ?? [];
|
||||
|
||||
// Restore policy state
|
||||
serverType = connection?.server_type ?? null;
|
||||
serverType = connection?.server_type ?? (connection?.policy_id ? 'orchestrator' : null);
|
||||
policyId = connection?.policy_id ?? '';
|
||||
|
||||
const p = connection?.policy ?? {};
|
||||
const p: Record<string, any> = {};
|
||||
policyImage = p.image ?? '';
|
||||
policyIdleTimeout = p.idle_timeout_minutes ?? 30;
|
||||
policyStorage = p.storage ? 'persistent' : 'ephemeral';
|
||||
|
|
@ -89,9 +96,11 @@
|
|||
// Restore resources
|
||||
policyCpu = p.cpu_limit ?? '1';
|
||||
policyMemory = p.memory_limit ?? '1Gi';
|
||||
lifecycleJson = stringifyJson(connection?.lifecycle);
|
||||
lifecycleJson = stringifyJson({});
|
||||
refreshOnlyIdle = true;
|
||||
refreshReset = false;
|
||||
loadingPolicy = false;
|
||||
policyLoadError = '';
|
||||
} else {
|
||||
id = '';
|
||||
url = '';
|
||||
|
|
@ -114,11 +123,53 @@
|
|||
lifecycleJson = '{}';
|
||||
refreshOnlyIdle = true;
|
||||
refreshReset = false;
|
||||
loadingPolicy = false;
|
||||
policyLoadError = '';
|
||||
}
|
||||
};
|
||||
|
||||
const loadPolicy = async () => {
|
||||
if (!connection || serverType !== 'orchestrator' || !policyId || direct) return;
|
||||
|
||||
loadingPolicy = true;
|
||||
policyLoadError = '';
|
||||
try {
|
||||
let policy: any = null;
|
||||
try {
|
||||
policy = await getOrchestratorPolicy(localStorage.token, url, key, policyId, auth_type);
|
||||
} catch (error: any) {
|
||||
if (error?.status !== 404) throw error;
|
||||
}
|
||||
|
||||
const lifecycle = await getOrchestratorLifecycle(
|
||||
localStorage.token,
|
||||
url,
|
||||
key,
|
||||
policyId,
|
||||
auth_type
|
||||
);
|
||||
const data = policy?.data ?? {};
|
||||
policyImage = data.image ?? '';
|
||||
policyIdleTimeout = data.idle_timeout_minutes ?? 30;
|
||||
policyStorage = data.storage ? 'persistent' : 'ephemeral';
|
||||
policyStorageSize = data.storage ?? '5Gi';
|
||||
policyEnvPairs = Object.entries(data.env ?? {}).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value)
|
||||
}));
|
||||
policyCpu = data.cpu_limit ?? '1';
|
||||
policyMemory = data.memory_limit ?? '1Gi';
|
||||
lifecycleJson = stringifyJson(lifecycle?.data);
|
||||
} catch (error: any) {
|
||||
policyLoadError = error?.message || String(error);
|
||||
} finally {
|
||||
loadingPolicy = false;
|
||||
}
|
||||
};
|
||||
|
||||
$: if (show) {
|
||||
init();
|
||||
void loadPolicy();
|
||||
}
|
||||
|
||||
const verifyHandler = async () => {
|
||||
|
|
@ -262,6 +313,14 @@
|
|||
url = url.replace(/\/$/, '');
|
||||
// Bearer key whitespace breaks the terminal WebSocket auth (HTTP headers strip it, JSON doesn't)
|
||||
key = key.trim();
|
||||
if (loadingPolicy) {
|
||||
toast.error($i18n.t('Policy is still loading'));
|
||||
return;
|
||||
}
|
||||
if (policyLoadError) {
|
||||
toast.error($i18n.t('Failed to load policy: {{error}}', { error: policyLoadError }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Save policy to orchestrator if applicable
|
||||
let policyData = {};
|
||||
|
|
@ -301,8 +360,7 @@
|
|||
},
|
||||
// Policy fields
|
||||
...(serverType ? { server_type: serverType } : {}),
|
||||
...(serverType === 'orchestrator' && policyId ? { policy_id: policyId } : {}),
|
||||
...(serverType === 'orchestrator' ? { policy: policyData, lifecycle: lifecycleData } : {})
|
||||
...(serverType === 'orchestrator' && policyId ? { policy_id: policyId } : {})
|
||||
};
|
||||
|
||||
onSubmit(result);
|
||||
|
|
@ -339,9 +397,7 @@
|
|||
<div class="flex gap-2">
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<label
|
||||
for="terminal-name"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
<label for="terminal-name" class={`text-xs text-gray-500`}
|
||||
>{$i18n.t('Name')}</label
|
||||
>
|
||||
</div>
|
||||
|
|
@ -349,7 +405,7 @@
|
|||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="terminal-name"
|
||||
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full flex-1 text-sm ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$i18n.t('My Terminal')}
|
||||
|
|
@ -360,9 +416,7 @@
|
|||
{#if !direct}
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<label
|
||||
for="terminal-id"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
<label for="terminal-id" class={`text-xs text-gray-500`}
|
||||
>{$i18n.t('ID')}
|
||||
<span class="opacity-50">({$i18n.t('optional')})</span></label
|
||||
>
|
||||
|
|
@ -370,7 +424,7 @@
|
|||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="terminal-id"
|
||||
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={id}
|
||||
placeholder="auto"
|
||||
|
|
@ -384,17 +438,13 @@
|
|||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<label
|
||||
for="terminal-url"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('URL')}</label
|
||||
>
|
||||
<label for="terminal-url" class={`text-xs text-gray-500`}>{$i18n.t('URL')}</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="terminal-url"
|
||||
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full flex-1 text-sm ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={url}
|
||||
placeholder="http://localhost:9900"
|
||||
|
|
@ -459,16 +509,14 @@
|
|||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<div
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs text-gray-500`}>
|
||||
{$i18n.t('Policy ID')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="policy-id"
|
||||
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={policyId}
|
||||
placeholder="python-ds"
|
||||
|
|
@ -478,13 +526,18 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if loadingPolicy}
|
||||
<div class="mt-2 text-xs text-gray-500">{$i18n.t('Loading policy...')}</div>
|
||||
{:else if policyLoadError}
|
||||
<div class="mt-2 text-xs text-red-600 dark:text-red-400">
|
||||
{$i18n.t('Failed to load policy: {{error}}', { error: policyLoadError })}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<div
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs text-gray-500`}>
|
||||
{$i18n.t('Image')}
|
||||
<span class="opacity-50">({$i18n.t('optional')})</span>
|
||||
</div>
|
||||
|
|
@ -492,7 +545,7 @@
|
|||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="policy-image"
|
||||
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={policyImage}
|
||||
placeholder="ghcr.io/open-webui/open-terminal:latest"
|
||||
|
|
@ -505,16 +558,14 @@
|
|||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<div
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs text-gray-500`}>
|
||||
{$i18n.t('CPU')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="policy-cpu"
|
||||
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={policyCpu}
|
||||
placeholder="1"
|
||||
|
|
@ -524,16 +575,14 @@
|
|||
</div>
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<div
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs text-gray-500`}>
|
||||
{$i18n.t('Memory')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="policy-memory"
|
||||
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={policyMemory}
|
||||
placeholder="1Gi"
|
||||
|
|
@ -546,18 +595,13 @@
|
|||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<div
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs text-gray-500`}>
|
||||
{$i18n.t('Storage')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-shrink-0 self-start">
|
||||
<select
|
||||
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
bind:value={policyStorage}
|
||||
>
|
||||
<select class={`w-full text-sm ${selectClass}`} bind:value={policyStorage}>
|
||||
<option value="ephemeral">{$i18n.t('Ephemeral')}</option>
|
||||
<option value="persistent">{$i18n.t('Persistent')}</option>
|
||||
</select>
|
||||
|
|
@ -566,7 +610,7 @@
|
|||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="policy-storage-size"
|
||||
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={policyStorageSize}
|
||||
placeholder="5Gi"
|
||||
|
|
@ -579,9 +623,7 @@
|
|||
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<div
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs text-gray-500`}>
|
||||
{$i18n.t('Idle Timeout')}
|
||||
<span class="opacity-50">({$i18n.t('min')})</span>
|
||||
</div>
|
||||
|
|
@ -589,7 +631,7 @@
|
|||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="idle-timeout"
|
||||
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={policyIdleTimeout}
|
||||
|
|
@ -604,9 +646,7 @@
|
|||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between items-center mb-0.5">
|
||||
<div
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs text-gray-500`}>
|
||||
{$i18n.t('Environment Variables')}
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -621,13 +661,13 @@
|
|||
{#each policyEnvPairs as pair, idx}
|
||||
<div class="flex gap-1.5 mb-1">
|
||||
<input
|
||||
class={`flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`flex-1 text-sm font-mono ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={pair.key}
|
||||
placeholder="KEY"
|
||||
/>
|
||||
<input
|
||||
class={`flex-[2] text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`flex-[2] text-sm font-mono ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={pair.value}
|
||||
placeholder="value"
|
||||
|
|
@ -648,15 +688,13 @@
|
|||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<div
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs text-gray-500`}>
|
||||
{$i18n.t('Lifecycle JSON')}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
id="lifecycle-json"
|
||||
class={`w-full min-h-24 resize-y text-xs bg-transparent font-mono rounded-lg p-2 border border-gray-200 dark:border-gray-800 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full min-h-24 resize-y text-xs font-mono ${inputClass}`}
|
||||
bind:value={lifecycleJson}
|
||||
spellcheck="false"
|
||||
placeholder={`{\n "reset": {\n "schedule": "@weekly",\n "timezone": "UTC"\n }\n}`}
|
||||
|
|
@ -675,6 +713,11 @@
|
|||
<span>{$i18n.t('Reset persisted files')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t(
|
||||
'Policy changes apply to newly provisioned terminals. Refresh matching terminals to apply them to existing terminals.'
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 hover:bg-gray-200 dark:bg-gray-850 dark:hover:bg-gray-800 transition"
|
||||
|
|
@ -708,19 +751,12 @@
|
|||
</button>
|
||||
|
||||
{#if !direct}
|
||||
<button
|
||||
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 object-cover rounded-full flex gap-1 items-center mt-2"
|
||||
type="button"
|
||||
<AccessButton
|
||||
className="mt-2"
|
||||
on:click={() => {
|
||||
showAccessControlModal = true;
|
||||
}}
|
||||
>
|
||||
<LockClosed strokeWidth="2.5" className="size-3.5 shrink-0" />
|
||||
|
||||
<div class="text-xs font-medium shrink-0">
|
||||
{$i18n.t('Access')}
|
||||
</div>
|
||||
</button>
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -729,9 +765,7 @@
|
|||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between items-center mb-0.5">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs text-gray-500`}>
|
||||
{$i18n.t('OpenAPI Spec')}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -744,7 +778,7 @@
|
|||
>{$i18n.t('openapi.json URL or Path')}</label
|
||||
>
|
||||
<input
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full text-sm ${inputClass}`}
|
||||
type="text"
|
||||
id="openapi-path"
|
||||
bind:value={path}
|
||||
|
|
@ -756,9 +790,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs mt-1 text-gray-500`}>
|
||||
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
|
||||
url: path.includes('://')
|
||||
? path
|
||||
|
|
@ -773,9 +805,7 @@
|
|||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs text-gray-500`}>
|
||||
{$i18n.t('Auth')}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -783,10 +813,7 @@
|
|||
|
||||
<div class="flex gap-2">
|
||||
<div class="flex-shrink-0 self-start">
|
||||
<select
|
||||
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
bind:value={auth_type}
|
||||
>
|
||||
<select class={`w-full text-sm ${selectClass}`} bind:value={auth_type}>
|
||||
<option value="none">{$i18n.t('None')}</option>
|
||||
<option value="bearer">{$i18n.t('Bearer')}</option>
|
||||
{#if !direct}
|
||||
|
|
@ -804,21 +831,15 @@
|
|||
required={false}
|
||||
/>
|
||||
{:else if auth_type === 'none'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
|
||||
{$i18n.t('No authentication')}
|
||||
</div>
|
||||
{:else if auth_type === 'session'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
|
||||
{$i18n.t('Forwards system user session credentials to authenticate')}
|
||||
</div>
|
||||
{:else if auth_type === 'system_oauth'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
|
||||
{$i18n.t('Forwards system user OAuth access token to authenticate')}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -843,8 +864,9 @@
|
|||
</div>
|
||||
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 disabled:opacity-50 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
|
||||
type="submit"
|
||||
disabled={loadingPolicy || !!policyLoadError}
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@
|
|||
import { getContext, onMount } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { settings } from '$lib/stores';
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
import Minus from '$lib/components/icons/Minus.svelte';
|
||||
|
|
@ -20,7 +19,7 @@
|
|||
import { getToolServerData } from '$lib/apis';
|
||||
import { verifyToolServerConnection, registerOAuthClient } from '$lib/apis/configs';
|
||||
import AccessControlModal from '$lib/components/workspace/common/AccessControlModal.svelte';
|
||||
import LockClosed from '$lib/components/icons/LockClosed.svelte';
|
||||
import AccessButton from '$lib/components/common/AccessButton.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import Textarea from './common/Textarea.svelte';
|
||||
|
|
@ -70,6 +69,11 @@
|
|||
let showAccessControlModal = false;
|
||||
let showDeleteConfirmDialog = false;
|
||||
|
||||
const inputClass =
|
||||
'bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
|
||||
const selectClass =
|
||||
'dark:bg-gray-900 bg-transparent pr-5 outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700';
|
||||
|
||||
const registerOAuthClientHandler = async () => {
|
||||
if (url === '') {
|
||||
toast.error($i18n.t('Please enter a valid URL'));
|
||||
|
|
@ -533,17 +537,13 @@
|
|||
<div class="flex gap-2">
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<label
|
||||
for="enter-name"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('Name')}</label
|
||||
>
|
||||
<label for="enter-name" class={`text-xs text-gray-500`}>{$i18n.t('Name')}</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="enter-name"
|
||||
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full flex-1 text-sm ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$i18n.t('Enter name')}
|
||||
|
|
@ -554,9 +554,7 @@
|
|||
{#if !direct}
|
||||
<div class="flex flex-col flex-1">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<label
|
||||
for="enter-id"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
<label for="enter-id" class={`text-xs text-gray-500`}
|
||||
>{$i18n.t('ID')}
|
||||
{#if type !== 'mcp'}<span class="opacity-50">({$i18n.t('optional')})</span
|
||||
>{/if}</label
|
||||
|
|
@ -565,7 +563,7 @@
|
|||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="enter-id"
|
||||
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full flex-1 text-sm font-mono ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={id}
|
||||
placeholder="auto"
|
||||
|
|
@ -578,16 +576,14 @@
|
|||
</div>
|
||||
|
||||
<div class="flex flex-col w-full mt-1 mb-1.5">
|
||||
<label
|
||||
for="description"
|
||||
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
<label for="description" class={`mb-0.5 text-xs text-gray-500`}
|
||||
>{$i18n.t('Description')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
id="description"
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full text-sm ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={description}
|
||||
placeholder={$i18n.t('Enter description')}
|
||||
|
|
@ -599,17 +595,13 @@
|
|||
<div class="flex gap-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<label
|
||||
for="api-base-url"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('URL')}</label
|
||||
>
|
||||
<label for="api-base-url" class={`text-xs text-gray-500`}>{$i18n.t('URL')}</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="api-base-url"
|
||||
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full flex-1 text-sm ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={url}
|
||||
placeholder={$i18n.t('API Base URL')}
|
||||
|
|
@ -656,10 +648,7 @@
|
|||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div
|
||||
for="select-bearer-or-session"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div for="select-bearer-or-session" class={`text-xs text-gray-500`}>
|
||||
{$i18n.t('Auth')}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -686,13 +675,13 @@
|
|||
|
||||
{#if !oauthClientInfo}
|
||||
<div
|
||||
class="text-xs font-medium px-1.5 rounded-md bg-yellow-500/20 text-yellow-700 dark:text-yellow-200"
|
||||
class="text-xs font-normal px-1.5 rounded-md bg-yellow-500/20 text-yellow-700 dark:text-yellow-200"
|
||||
>
|
||||
{$i18n.t('Not Registered')}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="text-xs font-medium px-1.5 rounded-md bg-green-500/20 text-green-700 dark:text-green-200"
|
||||
class="text-xs font-normal px-1.5 rounded-md bg-green-500/20 text-green-700 dark:text-green-200"
|
||||
>
|
||||
{$i18n.t('Registered')}
|
||||
</div>
|
||||
|
|
@ -705,7 +694,7 @@
|
|||
<div class="flex-shrink-0 self-start">
|
||||
<select
|
||||
id="select-bearer-or-session"
|
||||
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full text-sm ${selectClass}`}
|
||||
bind:value={auth_type}
|
||||
>
|
||||
<option value="none">{$i18n.t('None')}</option>
|
||||
|
|
@ -731,26 +720,20 @@
|
|||
required={false}
|
||||
/>
|
||||
{:else if auth_type === 'none'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
|
||||
{$i18n.t('No authentication')}
|
||||
</div>
|
||||
{:else if auth_type === 'session'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
|
||||
{$i18n.t('Forwards system user session credentials to authenticate')}
|
||||
</div>
|
||||
{:else if auth_type === 'system_oauth'}
|
||||
<div
|
||||
class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs self-center translate-y-[1px] text-gray-500`}>
|
||||
{$i18n.t('Forwards system user OAuth access token to authenticate')}
|
||||
</div>
|
||||
{:else if auth_type === 'oauth_2.1'}
|
||||
<div
|
||||
class={`flex items-center text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
class={`flex items-center text-xs self-center translate-y-[1px] text-gray-500`}
|
||||
>
|
||||
{$i18n.t('Uses OAuth 2.1 Dynamic Client Registration')}
|
||||
</div>
|
||||
|
|
@ -768,7 +751,7 @@
|
|||
/>
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full text-sm ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={oauthServerUrl}
|
||||
placeholder={$i18n.t('OAuth Server URL')}
|
||||
|
|
@ -804,19 +787,12 @@
|
|||
</button>
|
||||
|
||||
{#if !direct}
|
||||
<button
|
||||
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 object-cover rounded-full flex gap-1 items-center mt-2"
|
||||
type="button"
|
||||
<AccessButton
|
||||
className="mt-2"
|
||||
on:click={() => {
|
||||
showAccessControlModal = true;
|
||||
}}
|
||||
>
|
||||
<LockClosed strokeWidth="2.5" className="size-3.5 shrink-0" />
|
||||
|
||||
<div class="text-xs font-medium shrink-0">
|
||||
{$i18n.t('Access')}
|
||||
</div>
|
||||
</button>
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -826,10 +802,7 @@
|
|||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between items-center mb-0.5">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div
|
||||
for="select-bearer-or-session"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div for="select-bearer-or-session" class={`text-xs text-gray-500`}>
|
||||
{$i18n.t('OpenAPI Spec')}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -839,7 +812,7 @@
|
|||
<div class="flex-shrink-0 self-start">
|
||||
<select
|
||||
id="select-bearer-or-session"
|
||||
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full text-sm ${selectClass}`}
|
||||
bind:value={spec_type}
|
||||
>
|
||||
<option value="url">{$i18n.t('URL')}</option>
|
||||
|
|
@ -854,7 +827,7 @@
|
|||
>{$i18n.t('openapi.json URL or Path')}</label
|
||||
>
|
||||
<input
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full text-sm ${inputClass}`}
|
||||
type="text"
|
||||
id="url-or-path"
|
||||
bind:value={path}
|
||||
|
|
@ -864,12 +837,10 @@
|
|||
/>
|
||||
</div>
|
||||
{:else if spec_type === 'json'}
|
||||
<div
|
||||
class={`text-xs w-full self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs w-full self-center translate-y-[1px] text-gray-500`}>
|
||||
<label for="url-or-path" class="sr-only">{$i18n.t('JSON Spec')}</label>
|
||||
<textarea
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-black dark:text-white'}`}
|
||||
class={`w-full text-sm ${inputClass}`}
|
||||
bind:value={spec}
|
||||
placeholder={$i18n.t('JSON Spec')}
|
||||
autocomplete="off"
|
||||
|
|
@ -882,9 +853,7 @@
|
|||
</div>
|
||||
|
||||
{#if ['', 'url'].includes(spec_type)}
|
||||
<div
|
||||
class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>
|
||||
<div class={`text-xs mt-1 text-gray-500`}>
|
||||
{$i18n.t(`WebUI will make requests to "{{url}}"`, {
|
||||
url: path.includes('://')
|
||||
? path
|
||||
|
|
@ -899,16 +868,14 @@
|
|||
{#if type === 'mcp' && ['oauth_2.1', 'oauth_2.1_static'].includes(auth_type)}
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="oauth-scope"
|
||||
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
<label for="oauth-scope" class={`mb-0.5 text-xs text-gray-500`}
|
||||
>{$i18n.t('OAuth Scopes')}</label
|
||||
>
|
||||
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="oauth-scope"
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full text-sm ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={oauthScope}
|
||||
placeholder={$i18n.t('Use discovered scopes')}
|
||||
|
|
@ -920,16 +887,14 @@
|
|||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<label
|
||||
for="oauth-resource-parameter"
|
||||
class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
<label for="oauth-resource-parameter" class={`mb-0.5 text-xs text-gray-500`}
|
||||
>{$i18n.t('OAuth Resource Parameter')}</label
|
||||
>
|
||||
|
||||
<div class="flex flex-1 items-center">
|
||||
<select
|
||||
id="oauth-resource-parameter"
|
||||
class={`dark:bg-gray-900 w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full text-sm ${selectClass}`}
|
||||
bind:value={oauthResourceParameter}
|
||||
>
|
||||
<option value="auto">{$i18n.t('Automatic')}</option>
|
||||
|
|
@ -947,8 +912,7 @@
|
|||
<label
|
||||
for="headers-input"
|
||||
class={`mb-0.5 text-xs text-gray-500
|
||||
${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
|
||||
>{$i18n.t('Headers')}</label
|
||||
`}>{$i18n.t('Headers')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
|
|
@ -975,16 +939,14 @@
|
|||
<hr class=" border-gray-100/50 dark:border-gray-700/10 my-2.5 w-full" />
|
||||
|
||||
<div class="flex flex-col w-full mt-2">
|
||||
<label
|
||||
for="function-name-filter-list"
|
||||
class={`mb-1 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100 placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 text-gray-500'}`}
|
||||
<label for="function-name-filter-list" class={`mb-1 text-xs text-gray-500`}
|
||||
>{$i18n.t('Function Name Filter List')}</label
|
||||
>
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
id="function-name-filter-list"
|
||||
class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
class={`w-full text-sm ${inputClass}`}
|
||||
type="text"
|
||||
bind:value={functionNameFilterList}
|
||||
placeholder={$i18n.t('Enter function name filter list (e.g. func1, !func2)')}
|
||||
|
|
@ -999,14 +961,14 @@
|
|||
<div
|
||||
class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-2xl text-xs px-4 py-3 mb-2 mt-1"
|
||||
>
|
||||
<span class="font-medium">
|
||||
<span class="font-normal">
|
||||
{$i18n.t('Warning')}:
|
||||
</span>
|
||||
{$i18n.t(
|
||||
'MCP support is experimental and its specification changes often, which can lead to incompatibilities. OpenAPI specification support is directly maintained by the Open WebUI team, making it the more reliable option for compatibility.'
|
||||
)}
|
||||
|
||||
<a class="font-medium underline" href="https://docs.openwebui.com/" target="_blank"
|
||||
<a class="font-normal underline" href="https://docs.openwebui.com/" target="_blank"
|
||||
>{$i18n.t('Read more →')}</a
|
||||
>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -110,9 +110,9 @@
|
|||
<Modal size="md" bind:show>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
|
||||
<div class="flex justify-between dark:text-gray-100 px-4 pt-3 pb-1">
|
||||
<input
|
||||
class="w-full text-lg font-medium bg-transparent outline-hidden font-primary placeholder:text-gray-300 dark:placeholder:text-gray-700"
|
||||
class="w-full text-sm font-medium bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder={$i18n.t('Automation title')}
|
||||
|
|
@ -122,7 +122,7 @@
|
|||
aria-label={$i18n.t('Close')}
|
||||
on:click={() => (show = false)}
|
||||
>
|
||||
<XMark className="size-5" />
|
||||
<XMark className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -154,7 +154,7 @@
|
|||
{$i18n.t('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 {loading
|
||||
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 {loading
|
||||
? 'cursor-not-allowed'
|
||||
: ''}"
|
||||
on:click={submitHandler}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
<script lang="ts">
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
import { onMount, getContext } from 'svelte';
|
||||
import { Confetti } from 'svelte-confetti';
|
||||
import { getContext } from 'svelte';
|
||||
|
||||
import { WEBUI_NAME, config, settings } from '$lib/stores';
|
||||
|
||||
|
|
@ -17,10 +16,26 @@
|
|||
|
||||
export let show = false;
|
||||
|
||||
let changelog = null;
|
||||
type ChangelogEntry = {
|
||||
raw?: string;
|
||||
};
|
||||
type ChangelogVersion = {
|
||||
date: string;
|
||||
[section: string]: string | ChangelogEntry[];
|
||||
};
|
||||
|
||||
let changelog: Record<string, ChangelogVersion> | null = null;
|
||||
let error = false;
|
||||
|
||||
const init = async () => {
|
||||
changelog = await getChangelog();
|
||||
if (changelog || error) {
|
||||
return;
|
||||
}
|
||||
|
||||
changelog = await getChangelog().catch(() => {
|
||||
error = true;
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
const closeModal = async () => {
|
||||
|
|
@ -33,75 +48,150 @@
|
|||
$: if (show) {
|
||||
init();
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
if (!date) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const [year, month, day] = date.split('-');
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec'
|
||||
];
|
||||
const monthIndex = Number(month) - 1;
|
||||
|
||||
return year && monthIndex >= 0 && monthIndex < months.length && day
|
||||
? `${months[monthIndex]} ${Number(day)}, ${year}`
|
||||
: date;
|
||||
};
|
||||
|
||||
const getSectionEntries = (version: string, section: string) => {
|
||||
const entries = changelog?.[version]?.[section];
|
||||
return Array.isArray(entries) ? entries : [];
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal bind:show size="xl">
|
||||
<div class="px-6 pt-5 dark:text-white text-black">
|
||||
<div class="flex justify-between items-start">
|
||||
<h2 class="text-xl font-medium m-0">
|
||||
{$i18n.t("What's New in")}
|
||||
{$WEBUI_NAME}
|
||||
<Confetti x={[-1, -0.25]} y={[0, 0.5]} />
|
||||
</h2>
|
||||
<button class="self-center" on:click={closeModal} aria-label={$i18n.t('Close')}>
|
||||
<XMark className={'size-5'} />
|
||||
<Modal
|
||||
bind:show
|
||||
size="lg"
|
||||
className="bg-white dark:bg-gray-900 rounded-3xl overflow-hidden"
|
||||
containerClassName="p-3"
|
||||
>
|
||||
<div class="flex max-h-[58vh] flex-col">
|
||||
<div
|
||||
class="flex shrink-0 items-start justify-between gap-4 px-4 pb-2.5 pt-3.5 dark:text-white text-black"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<h2 class="m-0 truncate text-base font-normal">
|
||||
{$i18n.t("What's New in")}
|
||||
{$WEBUI_NAME}
|
||||
</h2>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{$i18n.t('Release Notes')}</span>
|
||||
<span class="h-1 w-1 rounded-full bg-gray-300 dark:bg-gray-700"></span>
|
||||
<span>v{WEBUI_VERSION}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl text-gray-400 transition hover:bg-gray-100 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-white/10 dark:hover:text-gray-200"
|
||||
on:click={closeModal}
|
||||
aria-label={$i18n.t('Close')}
|
||||
>
|
||||
<XMark className={'size-4'} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center mt-1">
|
||||
<div class="text-sm dark:text-gray-200">{$i18n.t('Release Notes')}</div>
|
||||
<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50/50 dark:bg-gray-850/50" />
|
||||
<div class="text-sm dark:text-gray-200">
|
||||
v{WEBUI_VERSION}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" w-full p-4 px-5 text-gray-700 dark:text-gray-100">
|
||||
<div class=" overflow-y-scroll max-h-[30rem] scrollbar-hidden">
|
||||
<div class="mb-3">
|
||||
{#if changelog}
|
||||
<div
|
||||
class="min-h-0 flex-1 overflow-y-auto px-4 py-2 text-gray-700 scrollbar-hidden dark:text-gray-100"
|
||||
>
|
||||
{#if changelog}
|
||||
<div class="space-y-4">
|
||||
{#each Object.keys(changelog) as version}
|
||||
<div class=" mb-3 pr-2">
|
||||
<h3 class="font-semibold text-xl mb-1 dark:text-white m-0">
|
||||
v{version} - {changelog[version].date}
|
||||
</h3>
|
||||
|
||||
<hr class="border-gray-50/50 dark:border-gray-850/50 my-2" />
|
||||
<section class="pr-1">
|
||||
<div class="mb-2">
|
||||
<h3 class="m-0 text-sm font-normal text-gray-950 dark:text-white">v{version}</h3>
|
||||
<div class="mt-0.5 text-[0.6875rem] text-gray-400 dark:text-gray-500">
|
||||
{formatDate(changelog[version].date)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each Object.keys(changelog[version]).filter((section) => section !== 'date') as section}
|
||||
<div class="w-full">
|
||||
<div class="mb-3 w-full">
|
||||
<div
|
||||
class="font-semibold uppercase text-xs {section === 'added'
|
||||
? 'bg-blue-500/20 text-blue-700 dark:text-blue-200'
|
||||
class="mb-2 text-[0.6875rem] font-normal uppercase tracking-wide {section ===
|
||||
'added'
|
||||
? 'text-blue-600 dark:text-blue-300'
|
||||
: section === 'fixed'
|
||||
? 'bg-green-500/20 text-green-700 dark:text-green-200'
|
||||
? 'text-green-600 dark:text-green-300'
|
||||
: section === 'changed'
|
||||
? 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200'
|
||||
? 'text-yellow-700 dark:text-yellow-300'
|
||||
: section === 'removed'
|
||||
? 'bg-red-500/20 text-red-700 dark:text-red-200'
|
||||
: ''} w-fit rounded-xl px-2 my-2.5"
|
||||
? 'text-red-600 dark:text-red-300'
|
||||
: 'text-gray-500 dark:text-gray-400'}"
|
||||
>
|
||||
{section}
|
||||
</div>
|
||||
|
||||
<div class="my-2.5 px-1.5 markdown-prose-sm !list-none !w-full !max-w-none">
|
||||
{#each changelog[version][section] as entry}
|
||||
<div class="my-2">
|
||||
{@html DOMPurify.sanitize(entry?.raw)}
|
||||
<div class="space-y-2 text-[0.8125rem] leading-relaxed">
|
||||
{#each getSectionEntries(version, section) as entry}
|
||||
<div class="flex gap-2.5">
|
||||
<span
|
||||
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"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||
{@html DOMPurify.sanitize(entry?.raw)}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Could not load release notes.')}
|
||||
</p>
|
||||
<button
|
||||
on:click={() => {
|
||||
error = false;
|
||||
init();
|
||||
}}
|
||||
class="text-sm font-normal text-gray-700 transition hover:text-black dark:text-gray-300 dark:hover:text-white"
|
||||
>
|
||||
{$i18n.t('Retry')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex items-center justify-center py-16 text-sm text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
{$i18n.t('Loading release notes...')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex justify-end pt-3 text-sm font-medium">
|
||||
|
||||
<div class="flex shrink-0 justify-end px-4 pb-3.5 pt-1.5 text-sm">
|
||||
<button
|
||||
on:click={closeModal}
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||
class="font-normal text-gray-600 transition hover:text-gray-950 dark:text-gray-400 dark:hover:text-white"
|
||||
>
|
||||
<span class="relative">{$i18n.t("Okay, Let's Go!")}</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -63,16 +63,16 @@
|
|||
|
||||
<Modal size="sm" bind:show>
|
||||
<div>
|
||||
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
|
||||
<div class=" text-lg font-medium self-center">{$i18n.t('Import')}</div>
|
||||
<div class=" flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
|
||||
<div class=" text-sm font-medium self-center">{$i18n.t('Import')}</div>
|
||||
<button
|
||||
class="self-center"
|
||||
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
aria-label={$i18n.t('Close')}
|
||||
on:click={() => {
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<XMark className={'size-5'} />
|
||||
<XMark className={'size-4'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -102,9 +102,9 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-3 text-sm font-medium">
|
||||
<div class="flex justify-end pt-3 text-sm font-normal">
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 whitespace-nowrap {loading
|
||||
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 whitespace-nowrap {loading
|
||||
? ' cursor-not-allowed'
|
||||
: ''}"
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@
|
|||
|
||||
<div>
|
||||
{#if title}
|
||||
<div class=" text-[13px] font-medium mb-0.5 line-clamp-1">{title}</div>
|
||||
<div class=" text-[13px] font-normal mb-0.5 line-clamp-1">{title}</div>
|
||||
{/if}
|
||||
|
||||
<div class=" line-clamp-2 text-xs self-center dark:text-gray-300 font-normal">
|
||||
|
|
|
|||
|
|
@ -1,103 +1,116 @@
|
|||
<script>
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import { getContext } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||
|
||||
import Marquee from './common/Marquee.svelte';
|
||||
import SlideShow from './common/SlideShow.svelte';
|
||||
import ArrowRightCircle from './icons/ArrowRightCircle.svelte';
|
||||
|
||||
export let show = true;
|
||||
export let getStartedHandler = () => {};
|
||||
|
||||
function setLogoImage() {
|
||||
const logo = document.getElementById('logo');
|
||||
let videoElement;
|
||||
let playOnInteractionRegistered = false;
|
||||
|
||||
if (logo) {
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
|
||||
if (isDarkMode) {
|
||||
const darkImage = new Image();
|
||||
darkImage.src = `${WEBUI_BASE_URL}/static/favicon-dark.png`;
|
||||
|
||||
darkImage.onload = () => {
|
||||
logo.src = `${WEBUI_BASE_URL}/static/favicon-dark.png`;
|
||||
logo.style.filter = ''; // Ensure no inversion is applied if splash-dark.png exists
|
||||
};
|
||||
|
||||
darkImage.onerror = () => {
|
||||
logo.style.filter = 'invert(1)'; // Invert image if splash-dark.png is missing
|
||||
};
|
||||
}
|
||||
function playBackgroundVideo() {
|
||||
if (!videoElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
videoElement.play().catch(() => {
|
||||
if (playOnInteractionRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
playOnInteractionRegistered = true;
|
||||
|
||||
const playOnInteraction = () => {
|
||||
videoElement.play().catch(() => {});
|
||||
document.removeEventListener('click', playOnInteraction);
|
||||
document.removeEventListener('touchstart', playOnInteraction);
|
||||
playOnInteractionRegistered = false;
|
||||
};
|
||||
|
||||
document.addEventListener('click', playOnInteraction);
|
||||
document.addEventListener('touchstart', playOnInteraction);
|
||||
});
|
||||
}
|
||||
|
||||
$: if (show) {
|
||||
setLogoImage();
|
||||
$: if (show && videoElement) {
|
||||
playBackgroundVideo();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<div class="w-full h-screen max-h-[100dvh] text-white relative">
|
||||
<div class="fixed m-10 z-50">
|
||||
<div class="flex space-x-2">
|
||||
<div class=" self-center">
|
||||
<img
|
||||
id="logo"
|
||||
crossorigin="anonymous"
|
||||
src="{WEBUI_BASE_URL}/static/favicon.png"
|
||||
class=" w-6 rounded-full"
|
||||
alt="logo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative h-screen max-h-[100dvh] w-full overflow-hidden text-white">
|
||||
<div class="fixed top-6 left-6 z-50 sm:top-10 sm:left-10">
|
||||
<img
|
||||
id="logo"
|
||||
crossorigin="anonymous"
|
||||
src="/static/favicon.png"
|
||||
class="size-6 rounded-full"
|
||||
alt="logo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SlideShow duration={5000} />
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
src="/assets/welcome.mp4"
|
||||
autoplay
|
||||
muted
|
||||
loop
|
||||
playsinline
|
||||
preload="auto"
|
||||
poster="/assets/welcome.webp"
|
||||
aria-hidden="true"
|
||||
></video>
|
||||
|
||||
<div
|
||||
class="w-full h-full absolute top-0 left-0 bg-linear-to-t from-20% from-black to-transparent"
|
||||
></div>
|
||||
<div class="absolute inset-0 bg-linear-to-t from-black/80 via-black/20 to-transparent"></div>
|
||||
<div class="absolute inset-0 bg-linear-to-r from-black/50 via-black/10 to-transparent"></div>
|
||||
|
||||
<div class="w-full h-full absolute top-0 left-0 backdrop-blur-xs bg-black/50"></div>
|
||||
<div class="relative z-10 flex h-screen max-h-[100dvh] w-full">
|
||||
<div class="flex w-full flex-col justify-end px-6 pb-8 sm:px-10 sm:pb-10 lg:px-16 lg:pb-14">
|
||||
<div class="max-w-3xl">
|
||||
<div class="mb-4 text-[11px] font-medium tracking-[0.18em] uppercase opacity-35">
|
||||
Open WebUI
|
||||
</div>
|
||||
|
||||
<div class="relative bg-transparent w-full h-screen max-h-[100dvh] flex z-10">
|
||||
<div class="flex flex-col justify-end w-full items-center pb-10 text-center">
|
||||
<div class="text-5xl lg:text-7xl font-secondary">
|
||||
<Marquee
|
||||
duration={5000}
|
||||
words={[
|
||||
$i18n.t('Explore the cosmos'),
|
||||
$i18n.t('Unlock mysteries'),
|
||||
$i18n.t('Chart new frontiers'),
|
||||
$i18n.t('Dive into knowledge'),
|
||||
$i18n.t('Discover wonders'),
|
||||
$i18n.t('Ignite curiosity'),
|
||||
$i18n.t('Forge new paths'),
|
||||
$i18n.t('Unravel secrets'),
|
||||
$i18n.t('Pioneer insights'),
|
||||
$i18n.t('Embark on adventures')
|
||||
]}
|
||||
/>
|
||||
<h1 class="m-0 max-w-3xl text-2xl leading-[1.15] font-light tracking-tight lg:text-4xl">
|
||||
{$i18n.t('Welcome to your AI home.')}
|
||||
</h1>
|
||||
|
||||
<div class="mt-0.5">{$i18n.t(`wherever you are`)}</div>
|
||||
</div>
|
||||
<p class="mt-6 max-w-xl text-sm leading-relaxed font-light text-white/60 lg:text-base">
|
||||
{$i18n.t(
|
||||
'Run AI on your own terms. Connect any model, extend with code, and protect what matters without compromise. Your models, your data, your machine, wherever you open it.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center mt-8">
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<div class="mt-8 flex flex-col items-start gap-6 sm:flex-row sm:items-center sm:gap-7">
|
||||
<button
|
||||
aria-label={$i18n.t('Get started')}
|
||||
class="relative z-20 flex p-1 rounded-full bg-white/5 hover:bg-white/10 transition font-medium text-sm"
|
||||
class="group relative z-20 inline-flex min-w-40 items-center justify-center gap-2 bg-white px-8 py-3 text-sm font-normal text-black transition hover:bg-white/90 focus:ring-2 focus:ring-white/50 focus:outline-hidden"
|
||||
on:click={() => {
|
||||
getStartedHandler();
|
||||
}}
|
||||
>
|
||||
<ArrowRightCircle className="size-6" aria-hidden="true" />
|
||||
{$i18n.t('Get started')}
|
||||
<svg
|
||||
class="h-4 w-4 transition group-hover:translate-x-0.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="mt-1.5 font-primary text-base font-medium" aria-hidden="true">
|
||||
{$i18n.t(`Get started`)}
|
||||
</div>
|
||||
|
||||
<a
|
||||
class="inline-flex items-center text-sm text-white/60 transition hover:text-white"
|
||||
href="https://docs.openwebui.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{$i18n.t('Read the docs')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
</script>
|
||||
|
||||
{#if loaded}
|
||||
<div class="w-full h-full pb-2 px-[16px]">
|
||||
<div class="w-full h-full pb-2">
|
||||
<Dashboard />
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -178,14 +178,18 @@
|
|||
|
||||
<Modal size="md" bind:show>
|
||||
{#if model}
|
||||
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
|
||||
<div class="flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
|
||||
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
|
||||
<div class="text-lg font-medium self-center line-clamp-1">
|
||||
<div class="text-sm font-medium self-center line-clamp-1">
|
||||
{model.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<button class="self-center" on:click={close} aria-label="Close">
|
||||
<XMark className={'size-5'} />
|
||||
<button
|
||||
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
on:click={close}
|
||||
aria-label="Close"
|
||||
>
|
||||
<XMark className={'size-4'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -193,7 +197,7 @@
|
|||
<div class="px-5 border-b border-gray-100 dark:border-gray-850">
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
class="py-2 text-sm font-medium border-b-2 transition-colors {selectedTab === 'overview'
|
||||
class="py-2 text-sm font-normal border-b-2 transition-colors {selectedTab === 'overview'
|
||||
? 'border-black dark:border-white text-gray-900 dark:text-white'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}"
|
||||
on:click={() => selectTab('overview')}
|
||||
|
|
@ -202,7 +206,7 @@
|
|||
</button>
|
||||
{#if $config?.features?.enable_admin_chat_access}
|
||||
<button
|
||||
class="py-2 text-sm font-medium border-b-2 transition-colors {selectedTab === 'chats'
|
||||
class="py-2 text-sm font-normal border-b-2 transition-colors {selectedTab === 'chats'
|
||||
? 'border-black dark:border-white text-gray-900 dark:text-white'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}"
|
||||
on:click={() => selectTab('chats')}
|
||||
|
|
@ -219,7 +223,7 @@
|
|||
<div class="mb-4 mt-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<Tooltip content={$i18n.t('Thumbs up/down ratings from users on model responses')}>
|
||||
<div class="text-xs text-gray-500 font-medium uppercase tracking-wide cursor-help">
|
||||
<div class="text-xs text-gray-500 font-normal uppercase tracking-wide cursor-help">
|
||||
{$i18n.t('Feedback Activity')}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
|
@ -229,7 +233,7 @@
|
|||
{#each TIME_RANGES as range}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full transition-all duration-200 px-2.5 py-0.5 text-xs font-medium {selectedRange ===
|
||||
class="rounded-full transition-all duration-200 px-2.5 py-0.5 text-xs font-normal {selectedRange ===
|
||||
range.key
|
||||
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}"
|
||||
|
|
@ -249,14 +253,14 @@
|
|||
|
||||
<!-- Tags -->
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-gray-500 mb-2 font-medium uppercase tracking-wide">
|
||||
<div class="text-xs text-gray-500 mb-2 font-normal uppercase tracking-wide">
|
||||
{$i18n.t('Tags')}
|
||||
</div>
|
||||
{#if tags.length}
|
||||
<div class="flex flex-wrap gap-1 -mx-1">
|
||||
{#each tags as tagInfo}
|
||||
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-xs">
|
||||
{tagInfo.tag} <span class="text-gray-500 font-medium">{tagInfo.count}</span>
|
||||
{tagInfo.tag} <span class="text-gray-500 font-normal">{tagInfo.count}</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -283,7 +287,7 @@
|
|||
|
||||
<div class="flex justify-end pt-4">
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||
type="button"
|
||||
on:click={close}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -230,13 +230,10 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<!-- Header with title and period selector -->
|
||||
<div
|
||||
class="pt-0.5 pb-1 gap-1 flex flex-row justify-between items-center sticky top-0 z-10 bg-white dark:bg-gray-900"
|
||||
>
|
||||
<div class="text-lg font-medium px-0.5 shrink-0">
|
||||
<div class="flex items-center justify-between mb-2 gap-2">
|
||||
<h2 class="text-sm font-medium text-gray-900 dark:text-white shrink-0">
|
||||
{$i18n.t('Analytics')}
|
||||
</div>
|
||||
</h2>
|
||||
<div class="flex items-center gap-2 flex-wrap justify-end min-w-0">
|
||||
{#if groups.length > 0}
|
||||
<select
|
||||
|
|
@ -287,27 +284,27 @@
|
|||
{#if !loading}
|
||||
<div class="flex gap-3 text-xs text-gray-500 dark:text-gray-400 px-0.5 pb-2">
|
||||
<span
|
||||
><span class="font-medium text-gray-900 dark:text-gray-300"
|
||||
><span class="font-normal text-gray-900 dark:text-gray-300"
|
||||
>{summary.total_messages.toLocaleString()}</span
|
||||
>
|
||||
{$i18n.t('messages')}</span
|
||||
>
|
||||
<Tooltip content={$i18n.t('Token counts are estimates and may not reflect actual API usage')}>
|
||||
<span class="cursor-help"
|
||||
><span class="font-medium text-gray-900 dark:text-gray-300"
|
||||
><span class="font-normal text-gray-900 dark:text-gray-300"
|
||||
>{formatNumber(totalTokens.total)}</span
|
||||
>
|
||||
{$i18n.t('tokens')}</span
|
||||
>
|
||||
</Tooltip>
|
||||
<span
|
||||
><span class="font-medium text-gray-900 dark:text-gray-300"
|
||||
><span class="font-normal text-gray-900 dark:text-gray-300"
|
||||
>{summary.total_chats.toLocaleString()}</span
|
||||
>
|
||||
{$i18n.t('chats')}</span
|
||||
>
|
||||
<span
|
||||
><span class="font-medium text-gray-900 dark:text-gray-300">{summary.total_users}</span>
|
||||
><span class="font-normal text-gray-900 dark:text-gray-300">{summary.total_users}</span>
|
||||
{$i18n.t('users')}</span
|
||||
>
|
||||
</div>
|
||||
|
|
@ -328,7 +325,7 @@
|
|||
]}
|
||||
{@const periodMap = { '24h': 'hour', '7d': 'week', '30d': 'month', '90d': 'year', all: 'all' }}
|
||||
<div class="mb-4">
|
||||
<div class="text-xs font-medium text-gray-600 dark:text-gray-400 mb-2 px-0.5">
|
||||
<div class="text-xs font-normal text-gray-600 dark:text-gray-400 mb-2 px-0.5">
|
||||
{selectedPeriod === '24h' ? $i18n.t('Hourly Messages') : $i18n.t('Daily Messages')}
|
||||
</div>
|
||||
<ChartLine
|
||||
|
|
@ -350,7 +347,7 @@
|
|||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<!-- Model Usage Table -->
|
||||
<div>
|
||||
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 px-0.5">
|
||||
<div class="text-xs font-normal text-gray-700 dark:text-gray-300 mb-1 px-0.5">
|
||||
{$i18n.t('Model Usage')}
|
||||
</div>
|
||||
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
|
||||
|
|
@ -478,7 +475,7 @@
|
|||
}}
|
||||
>
|
||||
<td class="px-3 py-1 text-gray-400">{idx + 1}</td>
|
||||
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white">
|
||||
<td class="px-3 py-1 font-normal text-gray-900 dark:text-white">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
|
||||
|
|
@ -518,7 +515,7 @@
|
|||
|
||||
<!-- User Activity Table -->
|
||||
<div>
|
||||
<div class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 px-0.5">
|
||||
<div class="text-xs font-normal text-gray-700 dark:text-gray-300 mb-1 px-0.5">
|
||||
{$i18n.t('User Activity')}
|
||||
</div>
|
||||
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
|
||||
|
|
@ -586,7 +583,7 @@
|
|||
{#each sortedUsers as user, idx (user.user_id)}
|
||||
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
|
||||
<td class="px-3 py-1 text-gray-400">{idx + 1}</td>
|
||||
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white">
|
||||
<td class="px-3 py-1 font-normal text-gray-900 dark:text-white">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
src="{WEBUI_API_BASE_URL}/users/{user.user_id}/profile/image"
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@
|
|||
<div
|
||||
class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex items-center text-xl font-medium px-0.5 gap-2 shrink-0">
|
||||
<div class="flex items-center text-xl font-normal px-0.5 gap-2 shrink-0">
|
||||
{$i18n.t('Model Usage')}
|
||||
<span class="text-lg text-gray-500">{totalMessages} {$i18n.t('messages')}</span>
|
||||
</div>
|
||||
|
|
@ -137,7 +137,7 @@
|
|||
<tr
|
||||
class="bg-white dark:bg-gray-900 text-xs hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
|
||||
>
|
||||
<td class="px-3 py-1.5 font-medium text-gray-900 dark:text-white">
|
||||
<td class="px-3 py-1.5 font-normal text-gray-900 dark:text-white">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td class="px-3 py-1.5">
|
||||
|
|
@ -150,13 +150,13 @@
|
|||
e.target.src = '/favicon.png';
|
||||
}}
|
||||
/>
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200">{model.name}</span>
|
||||
<span class="font-normal text-gray-800 dark:text-gray-200">{model.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white">
|
||||
<td class="px-3 py-1.5 text-right font-normal text-gray-900 dark:text-white">
|
||||
{model.count.toLocaleString()}
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right font-medium text-blue-500">
|
||||
<td class="px-3 py-1.5 text-right font-normal text-blue-500">
|
||||
{((model.count / totalMessages) * 100).toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
<div
|
||||
class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex items-center text-xl font-medium px-0.5 gap-2 shrink-0">
|
||||
<div class="flex items-center text-xl font-normal px-0.5 gap-2 shrink-0">
|
||||
{$i18n.t('User Activity')}
|
||||
<span class="text-lg text-gray-500">{userStats.length} {$i18n.t('users')}</span>
|
||||
</div>
|
||||
|
|
@ -132,18 +132,18 @@
|
|||
<tr
|
||||
class="bg-white dark:bg-gray-900 text-xs hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
|
||||
>
|
||||
<td class="px-3 py-1.5 font-medium text-gray-900 dark:text-white">
|
||||
<td class="px-3 py-1.5 font-normal text-gray-900 dark:text-white">
|
||||
{idx + 1}
|
||||
</td>
|
||||
<td class="px-3 py-1.5">
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200 font-mono text-xs">
|
||||
<span class="font-normal text-gray-800 dark:text-gray-200 font-mono text-xs">
|
||||
{user.user_id.substring(0, 8)}...
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white">
|
||||
<td class="px-3 py-1.5 text-right font-normal text-gray-900 dark:text-white">
|
||||
{user.count.toLocaleString()}
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right font-medium text-blue-500">
|
||||
<td class="px-3 py-1.5 text-right font-normal text-blue-500">
|
||||
{((user.count / totalMessages) * 100).toFixed(1)}%
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
<script>
|
||||
import { getContext, tick, onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { adminFeedbackCount, adminLeaderboardCount, models as _models } from '$lib/stores';
|
||||
import { getFeedbackItems, getLeaderboard } from '$lib/apis/evaluations';
|
||||
import { formatNumber } from '$lib/utils';
|
||||
|
||||
import Leaderboard from './Evaluations/Leaderboard.svelte';
|
||||
import Feedbacks from './Evaluations/Feedbacks.svelte';
|
||||
|
|
@ -28,8 +30,34 @@
|
|||
};
|
||||
|
||||
let loaded = false;
|
||||
$: formattedLeaderboardCount =
|
||||
$adminLeaderboardCount === null ? null : formatNumber($adminLeaderboardCount);
|
||||
$: formattedFeedbackCount =
|
||||
$adminFeedbackCount === null ? null : formatNumber($adminFeedbackCount);
|
||||
|
||||
const getLeaderboardCount = (res) => {
|
||||
const entries = res?.entries ?? [];
|
||||
const modelMap = new Map(($_models ?? []).map((model) => [model.id, model]));
|
||||
const activeModelCount = ($_models ?? []).filter(
|
||||
(model) => model?.owned_by !== 'arena' && !model?.info?.meta?.hidden
|
||||
).length;
|
||||
const evaluatedModelCount = entries.filter((entry) => !modelMap.has(entry.model_id)).length;
|
||||
|
||||
return activeModelCount + evaluatedModelCount;
|
||||
};
|
||||
|
||||
const loadCounts = async () => {
|
||||
const [leaderboardRes, feedbackRes] = await Promise.all([
|
||||
getLeaderboard(localStorage.token).catch(() => null),
|
||||
getFeedbackItems(localStorage.token, 'updated_at', 'desc', 1).catch(() => null)
|
||||
]);
|
||||
|
||||
adminLeaderboardCount.set(getLeaderboardCount(leaderboardRes));
|
||||
adminFeedbackCount.set(feedbackRes?.total ?? null);
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
await loadCounts();
|
||||
loaded = true;
|
||||
|
||||
const containerElement = document.getElementById('users-tabs-container');
|
||||
|
|
@ -52,58 +80,40 @@
|
|||
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
|
||||
<div
|
||||
id="users-tabs-container"
|
||||
class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
|
||||
class="tabs mx-2 px-2 sm:mx-2.5 lg:mx-0 lg:px-2.5 flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-0 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-normal text-left scrollbar-none"
|
||||
>
|
||||
<a
|
||||
id="leaderboard"
|
||||
href="/admin/evaluations/leaderboard"
|
||||
draggable="false"
|
||||
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
|
||||
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex items-center gap-1.5 text-right transition select-none {selectedTab ===
|
||||
'leaderboard'
|
||||
? ''
|
||||
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
|
||||
>
|
||||
<div class=" self-center mr-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm6 5.75a.75.75 0 0 1 1.5 0v3.5a.75.75 0 0 1-1.5 0v-3.5Zm-2.75 1.5a.75.75 0 0 1 1.5 0v2a.75.75 0 0 1-1.5 0v-2Zm-2 .75a.75.75 0 0 0-.75.75v.5a.75.75 0 0 0 1.5 0v-.5a.75.75 0 0 0-.75-.75Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class=" self-center">{$i18n.t('Leaderboard')}</div>
|
||||
<div class="self-center">{$i18n.t('Leaderboard')}</div>
|
||||
{#if formattedLeaderboardCount !== null}
|
||||
<div class="self-center text-sm opacity-60">
|
||||
{formattedLeaderboardCount}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<a
|
||||
id="feedback"
|
||||
href="/admin/evaluations/feedback"
|
||||
draggable="false"
|
||||
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex text-right transition select-none {selectedTab ===
|
||||
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex items-center gap-1.5 text-right transition select-none {selectedTab ===
|
||||
'feedback'
|
||||
? ''
|
||||
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
|
||||
>
|
||||
<div class=" self-center mr-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.25 2A2.25 2.25 0 0 0 3 4.25v9a.75.75 0 0 0 1.183.613l1.692-1.195 1.692 1.195a.75.75 0 0 0 .866 0l1.692-1.195 1.693 1.195A.75.75 0 0 0 13 13.25v-9A2.25 2.25 0 0 0 10.75 2h-5.5Zm3.03 3.28a.75.75 0 0 0-1.06-1.06L4.97 6.47a.75.75 0 0 0 0 1.06l2.25 2.25a.75.75 0 0 0 1.06-1.06l-.97-.97h1.315c.76 0 1.375.616 1.375 1.375a.75.75 0 0 0 1.5 0A2.875 2.875 0 0 0 8.625 6.25H7.311l.97-.97Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class=" self-center">{$i18n.t('Feedback')}</div>
|
||||
<div class="self-center">{$i18n.t('Feedback')}</div>
|
||||
{#if formattedFeedbackCount !== null}
|
||||
<div class="self-center text-sm opacity-60">
|
||||
{formattedFeedbackCount}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
const i18n = getContext('i18n');
|
||||
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
|
||||
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
|
||||
|
|
@ -17,19 +18,17 @@
|
|||
</Tooltip>
|
||||
|
||||
<div slot="content">
|
||||
<div
|
||||
class="min-w-[150px] rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
|
||||
>
|
||||
<DropdownMenu className="min-w-[150px]">
|
||||
<button
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md w-full"
|
||||
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[13px] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
on:click={() => {
|
||||
dispatch('delete');
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<GarbageBin strokeWidth="2" />
|
||||
<GarbageBin className="size-3.5" strokeWidth="2" />
|
||||
<div class="flex items-center">{$i18n.t('Delete')}</div>
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
|
|
|||
|
|
@ -42,12 +42,16 @@
|
|||
<Modal size="sm" bind:show>
|
||||
{#if selectedFeedback}
|
||||
<div>
|
||||
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
|
||||
<div class="text-lg font-medium self-center">
|
||||
<div class="flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
|
||||
<div class="text-sm font-medium self-center">
|
||||
{$i18n.t('Feedback Details')}
|
||||
</div>
|
||||
<button class="self-center" on:click={close} aria-label="Close">
|
||||
<XMark className={'size-5'} />
|
||||
<button
|
||||
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
on:click={close}
|
||||
aria-label="Close"
|
||||
>
|
||||
<XMark className={'size-4'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -131,7 +135,7 @@
|
|||
|
||||
<div class="flex justify-end pt-2">
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||
type="button"
|
||||
on:click={close}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -26,11 +26,12 @@
|
|||
import FeedbackModal from './FeedbackModal.svelte';
|
||||
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
|
||||
|
||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||
import { config } from '$lib/stores';
|
||||
import { adminFeedbackCount, config } from '$lib/stores';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import Select from '$lib/components/common/Select.svelte';
|
||||
import Check from '$lib/components/icons/Check.svelte';
|
||||
|
|
@ -89,6 +90,7 @@
|
|||
if (res) {
|
||||
items = res.items;
|
||||
total = res.total;
|
||||
adminFeedbackCount.set(total);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
|
@ -215,36 +217,68 @@
|
|||
<Spinner className="size-5" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-1 mt-0.5 mb-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
|
||||
<div>
|
||||
{$i18n.t('Feedback History')}
|
||||
<div>
|
||||
{#if modelIds.length > 0 || total > 0}
|
||||
<div class="flex h-8 flex-1 items-center w-full gap-2">
|
||||
<div
|
||||
class="flex min-w-0 flex-1 bg-transparent overflow-x-auto scrollbar-none"
|
||||
on:wheel={(e) => {
|
||||
if (e.deltaY !== 0) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.scrollLeft += e.deltaY;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if modelIds.length > 0}
|
||||
<div
|
||||
class="flex gap-0.5 w-fit text-center text-sm rounded-full bg-transparent whitespace-nowrap"
|
||||
>
|
||||
<Select
|
||||
bind:value={selectedModelId}
|
||||
items={[
|
||||
{ value: '', label: $i18n.t('All') },
|
||||
...modelIds.map((mid) => ({ value: mid, label: mid }))
|
||||
]}
|
||||
placeholder={$i18n.t('All')}
|
||||
triggerClass="relative w-full flex items-center gap-0.5 px-2.5 py-1.5 bg-transparent rounded-xl text-[13px] font-normal text-gray-700 transition hover:text-gray-900 dark:text-gray-200 dark:hover:text-gray-100"
|
||||
onChange={() => {
|
||||
page = 1;
|
||||
getFeedbacks();
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="trigger" let:selectedLabel>
|
||||
<span
|
||||
class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
|
||||
>
|
||||
{selectedLabel}
|
||||
</span>
|
||||
<ChevronDown className="size-3.5" strokeWidth="2.5" />
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="item" let:item let:selected>
|
||||
{item.label}
|
||||
<div class="ml-auto {selected ? '' : 'invisible'}">
|
||||
<Check />
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
|
||||
{total}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full justify-end gap-1.5">
|
||||
{#if total > 0}
|
||||
<Dropdown align="end">
|
||||
<button
|
||||
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
|
||||
class="flex h-8 shrink-0 items-center gap-1 px-2 py-1.5 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-850 dark:text-gray-200 transition text-xs"
|
||||
>
|
||||
<div class="self-center font-medium line-clamp-1">
|
||||
{$i18n.t('Export')}
|
||||
</div>
|
||||
{$i18n.t('Export')}
|
||||
<ChevronDown className="size-3" strokeWidth="2.5" />
|
||||
</button>
|
||||
|
||||
<div slot="content">
|
||||
<div
|
||||
class="w-[170px] rounded-2xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
|
||||
>
|
||||
<DropdownMenu className="w-[170px]">
|
||||
<button
|
||||
class="select-none flex w-full gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||
class="select-none flex w-full gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl"
|
||||
type="button"
|
||||
on:click={() => exportHandler('json')}
|
||||
>
|
||||
|
|
@ -252,75 +286,25 @@
|
|||
</button>
|
||||
|
||||
<button
|
||||
class="select-none flex w-full gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
|
||||
class="select-none flex w-full gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl"
|
||||
type="button"
|
||||
on:click={() => exportHandler('csv')}
|
||||
>
|
||||
{$i18n.t('Export as CSV')}
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Dropdown>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30"
|
||||
>
|
||||
{#if modelIds.length > 0}
|
||||
<div
|
||||
class="px-2.5 flex w-full bg-transparent overflow-x-auto scrollbar-none mb-1"
|
||||
on:wheel={(e) => {
|
||||
if (e.deltaY !== 0) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.scrollLeft += e.deltaY;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="flex gap-0.5 w-fit text-center text-sm rounded-full bg-transparent whitespace-nowrap"
|
||||
>
|
||||
<Select
|
||||
bind:value={selectedModelId}
|
||||
items={[
|
||||
{ value: '', label: $i18n.t('All') },
|
||||
...modelIds.map((mid) => ({ value: mid, label: mid }))
|
||||
]}
|
||||
placeholder={$i18n.t('All')}
|
||||
triggerClass="relative w-full flex items-center gap-0.5 px-2.5 py-1.5 bg-gray-50 dark:bg-gray-850 rounded-xl"
|
||||
onChange={() => {
|
||||
page = 1;
|
||||
getFeedbacks();
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="trigger" let:selectedLabel>
|
||||
<span
|
||||
class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
|
||||
>
|
||||
{selectedLabel}
|
||||
</span>
|
||||
<ChevronDown className="size-3.5" strokeWidth="2.5" />
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="item" let:item let:selected>
|
||||
{item.label}
|
||||
<div class="ml-auto {selected ? '' : 'invisible'}">
|
||||
<Check />
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full px-2">
|
||||
<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-medium mb-1">{$i18n.t('No feedback found')}</div>
|
||||
<div class="text-lg font-normal mb-1">{$i18n.t('No feedback found')}</div>
|
||||
<div class="text-gray-500 text-center text-xs">
|
||||
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
|
||||
</div>
|
||||
|
|
@ -334,7 +318,7 @@
|
|||
<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none w-3"
|
||||
class="px-2.5 py-2 font-normal cursor-pointer select-none w-3"
|
||||
on:click={() => setSortKey('user')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
|
|
@ -357,7 +341,7 @@
|
|||
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none"
|
||||
class="px-2.5 py-2 font-normal cursor-pointer select-none"
|
||||
on:click={() => setSortKey('model_id')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
|
|
@ -380,7 +364,7 @@
|
|||
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 text-right cursor-pointer select-none w-fit"
|
||||
class="px-2.5 py-2 font-normal text-right cursor-pointer select-none w-fit"
|
||||
on:click={() => setSortKey('rating')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
|
|
@ -403,7 +387,7 @@
|
|||
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 text-right cursor-pointer select-none w-0"
|
||||
class="px-2.5 py-2 font-normal text-right cursor-pointer select-none w-0"
|
||||
on:click={() => setSortKey('updated_at')}
|
||||
>
|
||||
<div class="flex gap-1.5 items-center justify-end">
|
||||
|
|
@ -424,7 +408,11 @@
|
|||
</div>
|
||||
</th>
|
||||
|
||||
<th scope="col" class="px-2.5 py-2 text-right cursor-pointer select-none w-0"> </th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 font-normal text-right cursor-pointer select-none w-0"
|
||||
>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="">
|
||||
|
|
@ -433,7 +421,7 @@
|
|||
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition rounded-xl"
|
||||
on:click={() => openFeedbackModal(feedback)}
|
||||
>
|
||||
<td class=" py-0.5 text-right font-medium">
|
||||
<td class=" py-0.5 text-right font-normal">
|
||||
<div class="flex justify-center">
|
||||
<Tooltip content={feedback?.user?.name}>
|
||||
<div class="shrink-0">
|
||||
|
|
@ -453,7 +441,7 @@
|
|||
{#if feedback.data?.sibling_model_ids}
|
||||
<Tooltip content={feedback.data?.model_id} placement="top-start">
|
||||
<div
|
||||
class="font-medium text-gray-600 dark:text-gray-400 flex-1 line-clamp-1"
|
||||
class="font-normal text-gray-600 dark:text-gray-400 flex-1 line-clamp-1"
|
||||
>
|
||||
{feedback.data?.model_id}
|
||||
</div>
|
||||
|
|
@ -477,7 +465,7 @@
|
|||
{:else}
|
||||
<Tooltip content={feedback.data?.model_id} placement="top-start">
|
||||
<div
|
||||
class="text-sm font-medium text-gray-600 dark:text-gray-400 flex-1 py-1.5 line-clamp-1"
|
||||
class="text-sm font-normal text-gray-600 dark:text-gray-400 flex-1 py-1.5 line-clamp-1"
|
||||
>
|
||||
{feedback.data?.model_id}
|
||||
</div>
|
||||
|
|
@ -488,7 +476,7 @@
|
|||
</td>
|
||||
|
||||
{#if feedback?.data?.rating}
|
||||
<td class="px-3 py-1 text-right font-medium text-gray-900 dark:text-white w-max">
|
||||
<td class="px-3 py-1 text-right font-normal text-gray-900 dark:text-white w-max">
|
||||
<div class=" flex justify-end">
|
||||
{#if feedback?.data?.rating.toString() === '1'}
|
||||
<Badge type="info" content={$i18n.t('Won')} />
|
||||
|
|
@ -501,11 +489,11 @@
|
|||
</td>
|
||||
{/if}
|
||||
|
||||
<td class=" px-3 py-1 text-right font-medium">
|
||||
<td class=" px-3 py-1 text-right font-normal">
|
||||
{dayjs(feedback.updated_at * 1000).fromNow()}
|
||||
</td>
|
||||
|
||||
<td class=" px-3 py-1 text-right font-medium" on:click={(e) => e.stopPropagation()}>
|
||||
<td class=" px-3 py-1 text-right font-normal" on:click={(e) => e.stopPropagation()}>
|
||||
<FeedbackMenu
|
||||
on:delete={(e) => {
|
||||
deleteFeedbackHandler(feedback.id);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { onMount, getContext } from 'svelte';
|
||||
import { models } from '$lib/stores';
|
||||
import { getContext } from 'svelte';
|
||||
import { adminLeaderboardCount, models } from '$lib/stores';
|
||||
import { getLeaderboard } from '$lib/apis/evaluations';
|
||||
import ModelModal from './LeaderboardModal.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Search from '$lib/components/icons/Search.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
|
||||
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
|
|
@ -83,6 +84,7 @@
|
|||
if (b.rating === '-') return -1;
|
||||
return b.rating - a.rating;
|
||||
});
|
||||
adminLeaderboardCount.set(rankedModels.length);
|
||||
} catch (err) {
|
||||
console.error('Leaderboard load failed:', err);
|
||||
}
|
||||
|
|
@ -120,118 +122,133 @@
|
|||
|
||||
<ModelModal bind:show={showModal} model={selectedModel} onClose={closeModal} />
|
||||
|
||||
<div
|
||||
class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
|
||||
>
|
||||
<div class="flex items-center text-xl font-medium px-0.5 gap-2 shrink-0">
|
||||
{$i18n.t('Leaderboard')}
|
||||
<span class="text-lg text-gray-500">{rankedModels.length}</span>
|
||||
</div>
|
||||
<Tooltip content={$i18n.t('Re-rank models by topic similarity')}>
|
||||
<div class="flex flex-1">
|
||||
<Search className="size-3 ml-1 mr-3 self-center" />
|
||||
<input
|
||||
class="w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||
bind:value={query}
|
||||
placeholder={$i18n.t('Search')}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div>
|
||||
<div class="sticky top-0 z-10 bg-white dark:bg-gray-900">
|
||||
<div class="flex h-8 flex-1 items-center w-full gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-center">
|
||||
<div class="self-center ml-1 mr-3">
|
||||
<Search className="size-3.5" />
|
||||
</div>
|
||||
<input
|
||||
class="w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
|
||||
bind:value={query}
|
||||
aria-label={$i18n.t('Search')}
|
||||
placeholder={$i18n.t('Search')}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm min-h-[100px]"
|
||||
>
|
||||
{#if loading}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center z-10 bg-white/50 dark:bg-gray-900/50"
|
||||
>
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !rankedModels.length && !loading}
|
||||
<div class="text-center text-xs text-gray-500 py-1">{$i18n.t('No models found')}</div>
|
||||
{:else if rankedModels.length}
|
||||
<table
|
||||
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 {loading
|
||||
? 'opacity-20'
|
||||
: ''}"
|
||||
>
|
||||
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
|
||||
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
|
||||
{#each [{ key: 'rating', label: 'RK', class: 'w-3' }, { key: 'name', label: 'Model', class: '' }, { key: 'rating', label: 'Rating', class: 'text-right w-fit' }, { key: 'won', label: 'Won', class: 'text-right w-5' }, { key: 'lost', label: 'Lost', class: 'text-right w-5' }] as col}
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 cursor-pointer select-none {col.class}"
|
||||
on:click={() => toggleSort(col.key)}
|
||||
{#if query}
|
||||
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
|
||||
<button
|
||||
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
|
||||
aria-label={$i18n.t('Clear search')}
|
||||
on:click={() => {
|
||||
query = '';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="flex gap-1.5 items-center {col.class.includes('right') ? 'justify-end' : ''}"
|
||||
<XMark className="size-3" strokeWidth="2" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm min-h-[100px]"
|
||||
>
|
||||
{#if loading}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center z-10 bg-white/50 dark:bg-gray-900/50"
|
||||
>
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !rankedModels.length && !loading}
|
||||
<div class="text-center text-xs text-gray-500 py-1">{$i18n.t('No models found')}</div>
|
||||
{:else if rankedModels.length}
|
||||
<table
|
||||
class="w-full text-sm text-left text-gray-500 dark:text-gray-400 {loading
|
||||
? 'opacity-20'
|
||||
: ''}"
|
||||
>
|
||||
<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
|
||||
<tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
|
||||
{#each [{ key: 'rating', label: 'RK', class: 'w-3' }, { key: 'name', label: 'Model', class: '' }, { key: 'rating', label: 'Rating', class: 'text-right w-fit' }, { key: 'won', label: 'Won', class: 'text-right w-5' }, { key: 'lost', label: 'Lost', class: 'text-right w-5' }] as col}
|
||||
<th
|
||||
scope="col"
|
||||
class="px-2.5 py-2 font-normal cursor-pointer select-none {col.class}"
|
||||
on:click={() => toggleSort(col.key)}
|
||||
>
|
||||
{$i18n.t(col.label)}
|
||||
{#if orderBy === col.key}
|
||||
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
|
||||
className="size-2"
|
||||
/>{/if}
|
||||
{:else}
|
||||
<span class="invisible"><ChevronUp className="size-2" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedModels as model, idx (model.id)}
|
||||
<tr
|
||||
class="bg-white dark:bg-gray-900 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
|
||||
on:click={() => openModal(model)}
|
||||
>
|
||||
<td class="px-3 py-1.5 font-medium text-gray-900 dark:text-white">
|
||||
{model.rating !== '-' ? idx + 1 : '-'}
|
||||
</td>
|
||||
<td class="px-3 py-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.id}"
|
||||
alt={model.name}
|
||||
class="size-5 rounded-full object-cover shrink-0"
|
||||
on:error={(e) => {
|
||||
e.target.src = '/favicon.png';
|
||||
}}
|
||||
/>
|
||||
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
|
||||
<span class="font-medium text-gray-800 dark:text-gray-200 line-clamp-1"
|
||||
>{model.name}</span
|
||||
>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white">
|
||||
{model.rating}
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right font-medium text-green-500 w-10">
|
||||
{#if model.stats.won === '-'}-{:else}
|
||||
<span class="hidden group-hover:inline"
|
||||
>{((Number(model.stats.won) / model.stats.count) * 100).toFixed(1)}%</span
|
||||
<div
|
||||
class="flex gap-1.5 items-center {col.class.includes('right')
|
||||
? 'justify-end'
|
||||
: ''}"
|
||||
>
|
||||
<span class="group-hover:hidden">{model.stats.won}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right font-medium text-red-500 w-10">
|
||||
{#if model.stats.lost === '-'}-{:else}
|
||||
<span class="hidden group-hover:inline"
|
||||
>{((Number(model.stats.lost) / model.stats.count) * 100).toFixed(1)}%</span
|
||||
>
|
||||
<span class="group-hover:hidden">{model.stats.lost}</span>
|
||||
{/if}
|
||||
</td>
|
||||
{$i18n.t(col.label)}
|
||||
{#if orderBy === col.key}
|
||||
{#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
|
||||
className="size-2"
|
||||
/>{/if}
|
||||
{:else}
|
||||
<span class="invisible"><ChevronUp className="size-2" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedModels as model, idx (model.id)}
|
||||
<tr
|
||||
class="bg-white dark:bg-gray-900 text-xs group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
|
||||
on:click={() => openModal(model)}
|
||||
>
|
||||
<td class="px-3 py-1.5 font-normal text-gray-900 dark:text-white">
|
||||
{model.rating !== '-' ? idx + 1 : '-'}
|
||||
</td>
|
||||
<td class="px-3 py-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.id}"
|
||||
alt={model.name}
|
||||
class="size-5 rounded-full object-cover shrink-0"
|
||||
on:error={(e) => {
|
||||
e.target.src = '/favicon.png';
|
||||
}}
|
||||
/>
|
||||
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
|
||||
<span class="font-normal text-gray-800 dark:text-gray-200 line-clamp-1"
|
||||
>{model.name}</span
|
||||
>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right font-normal text-gray-900 dark:text-white">
|
||||
{model.rating}
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right font-normal text-green-500 w-10">
|
||||
{#if model.stats.won === '-'}-{:else}
|
||||
<span class="hidden group-hover:inline"
|
||||
>{((Number(model.stats.won) / model.stats.count) * 100).toFixed(1)}%</span
|
||||
>
|
||||
<span class="group-hover:hidden">{model.stats.won}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-3 py-1.5 text-right font-normal text-red-500 w-10">
|
||||
{#if model.stats.lost === '-'}-{:else}
|
||||
<span class="hidden group-hover:inline"
|
||||
>{((Number(model.stats.lost) / model.stats.count) * 100).toFixed(1)}%</span
|
||||
>
|
||||
<span class="group-hover:hidden">{model.stats.lost}</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-500 text-xs mt-1.5 w-full flex justify-end">
|
||||
|
|
|
|||
|
|
@ -61,21 +61,25 @@
|
|||
|
||||
<Modal size="md" bind:show>
|
||||
{#if model}
|
||||
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
|
||||
<div class="flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
|
||||
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
|
||||
<div class="text-lg font-medium self-center line-clamp-1">
|
||||
<div class="text-sm font-medium self-center line-clamp-1">
|
||||
{model.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<button class="self-center" on:click={close} aria-label="Close">
|
||||
<XMark className={'size-5'} />
|
||||
<button
|
||||
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
on:click={close}
|
||||
aria-label="Close"
|
||||
>
|
||||
<XMark className={'size-4'} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-5 pb-4 dark:text-gray-200">
|
||||
<!-- Activity Chart -->
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="text-xs text-gray-500 font-medium uppercase tracking-wide">
|
||||
<div class="text-xs text-gray-500 font-normal uppercase tracking-wide">
|
||||
{$i18n.t('Activity')}
|
||||
</div>
|
||||
<div
|
||||
|
|
@ -84,7 +88,7 @@
|
|||
{#each TIME_RANGES as range}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full transition-all duration-200 px-2.5 py-0.5 text-xs font-medium {selectedRange ===
|
||||
class="rounded-full transition-all duration-200 px-2.5 py-0.5 text-xs font-normal {selectedRange ===
|
||||
range.key
|
||||
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}"
|
||||
|
|
@ -103,14 +107,14 @@
|
|||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-xs text-gray-500 mb-2 font-medium uppercase tracking-wide">
|
||||
<div class="text-xs text-gray-500 mb-2 font-normal uppercase tracking-wide">
|
||||
{$i18n.t('Tags')}
|
||||
</div>
|
||||
{#if topTags.length}
|
||||
<div class="flex flex-wrap gap-1 -mx-1">
|
||||
{#each topTags as tagInfo}
|
||||
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-xs">
|
||||
{tagInfo.tag} <span class="text-gray-500 font-medium">{tagInfo.count}</span>
|
||||
{tagInfo.tag} <span class="text-gray-500 font-normal">{tagInfo.count}</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -121,7 +125,7 @@
|
|||
|
||||
<div class="flex justify-end pt-2">
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||
class="px-3.5 py-1.5 text-sm font-normal bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||
type="button"
|
||||
on:click={close}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
<script lang="ts">
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import fileSaver from 'file-saver';
|
||||
const { saveAs } = fileSaver;
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
import { WEBUI_NAME, config, functions as _functions, models, settings, user } from '$lib/stores';
|
||||
import { onMount, getContext, tick, onDestroy } from 'svelte';
|
||||
|
||||
|
|
@ -19,7 +23,6 @@
|
|||
toggleGlobalById
|
||||
} from '$lib/apis/functions';
|
||||
|
||||
import Download from '../icons/Download.svelte';
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
import ConfirmDialog from '../common/ConfirmDialog.svelte';
|
||||
import { getModels } from '$lib/apis';
|
||||
|
|
@ -32,15 +35,16 @@
|
|||
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import GarbageBin from '../icons/GarbageBin.svelte';
|
||||
import Search from '../icons/Search.svelte';
|
||||
import Plus from '../icons/Plus.svelte';
|
||||
import ChevronRight from '../icons/ChevronRight.svelte';
|
||||
import XMark from '../icons/XMark.svelte';
|
||||
import AddFunctionMenu from './Functions/AddFunctionMenu.svelte';
|
||||
import ImportModal from '../ImportModal.svelte';
|
||||
import ViewSelector from '../workspace/common/ViewSelector.svelte';
|
||||
import TagSelector from '../workspace/common/TagSelector.svelte';
|
||||
import CommunityDiscover from '../workspace/common/CommunityDiscover.svelte';
|
||||
import { capitalizeFirstLetter } from '$lib/utils';
|
||||
import Spinner from '../common/Spinner.svelte';
|
||||
import SplitCreateButton from '../common/SplitCreateButton.svelte';
|
||||
import ChevronDown from '../icons/ChevronDown.svelte';
|
||||
import ChevronUp from '../icons/ChevronUp.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -51,6 +55,9 @@
|
|||
|
||||
let tagsContainerElement: HTMLDivElement;
|
||||
let viewOption = '';
|
||||
let sortKey = 'updated_at';
|
||||
let sortDirection = 'desc';
|
||||
let openFunctionMenuId: string | null = null;
|
||||
|
||||
let query = '';
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
|
@ -78,7 +85,27 @@
|
|||
}, 300);
|
||||
};
|
||||
|
||||
$: if (functions && selectedType !== undefined && viewOption !== undefined) {
|
||||
const downloadFunctions = async () => {
|
||||
const _functions = await exportFunctions(localStorage.token).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (_functions) {
|
||||
let blob = new Blob([JSON.stringify(_functions)], {
|
||||
type: 'application/json'
|
||||
});
|
||||
saveAs(blob, `functions-export-${Date.now()}.json`);
|
||||
}
|
||||
};
|
||||
|
||||
$: if (
|
||||
functions &&
|
||||
selectedType !== undefined &&
|
||||
viewOption !== undefined &&
|
||||
sortKey !== undefined &&
|
||||
sortDirection !== undefined
|
||||
) {
|
||||
setFilteredItems();
|
||||
}
|
||||
|
||||
|
|
@ -97,7 +124,32 @@
|
|||
(viewOption === 'created' && f.user_id === $user?.id) ||
|
||||
(viewOption === 'shared' && f.user_id !== $user?.id))
|
||||
)
|
||||
.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
|
||||
.sort((a, b) => {
|
||||
const direction = sortDirection === 'asc' ? 1 : -1;
|
||||
|
||||
if (sortKey === 'name') {
|
||||
return direction * (a.name ?? '').localeCompare(b.name ?? '');
|
||||
}
|
||||
|
||||
return direction * ((a.updated_at ?? 0) - (b.updated_at ?? 0));
|
||||
});
|
||||
};
|
||||
|
||||
const setSortKey = (key: string) => {
|
||||
if (sortKey === key) {
|
||||
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortKey = key;
|
||||
sortDirection = key === 'updated_at' ? 'desc' : 'asc';
|
||||
}
|
||||
};
|
||||
|
||||
const openFunction = (func) => {
|
||||
goto(`/admin/functions/edit?id=${encodeURIComponent(func.id)}`);
|
||||
};
|
||||
|
||||
const shouldIgnoreRowClick = (target: EventTarget | null) => {
|
||||
return target instanceof Element && !!target.closest('button, a, input, [role="menu"]');
|
||||
};
|
||||
const shareHandler = async (func) => {
|
||||
const item = await getFunctionById(localStorage.token, func.id).catch((error) => {
|
||||
|
|
@ -268,8 +320,8 @@
|
|||
/>
|
||||
|
||||
{#if loaded}
|
||||
<div class="px-4.5 w-full">
|
||||
<div class="flex flex-col gap-1 px-1 mt-2.5 mb-2">
|
||||
<div class="px-2.5 w-full">
|
||||
<div class="flex flex-col gap-1 px-1 mt-0.5">
|
||||
<div class="flex justify-between items-center mb-1 w-full">
|
||||
<input
|
||||
id="documents-import-input"
|
||||
|
|
@ -285,78 +337,53 @@
|
|||
/>
|
||||
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
|
||||
<div class="flex items-center md:self-center text-sm font-normal px-0.5 gap-1.5 shrink-0">
|
||||
<div>
|
||||
{$i18n.t('Functions')}
|
||||
</div>
|
||||
|
||||
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
|
||||
<div class="text-sm font-normal text-gray-500 dark:text-gray-500 opacity-60">
|
||||
{filteredItems.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full justify-end gap-1.5">
|
||||
{#if $user?.role === 'admin'}
|
||||
<button
|
||||
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
|
||||
on:click={() => {
|
||||
functionsImportInputElement.click();
|
||||
}}
|
||||
>
|
||||
<div class=" self-center font-medium line-clamp-1">
|
||||
{$i18n.t('Import')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if functions.length}
|
||||
<button
|
||||
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
|
||||
on:click={async () => {
|
||||
const _functions = await exportFunctions(localStorage.token).catch((error) => {
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (_functions) {
|
||||
let blob = new Blob([JSON.stringify(_functions)], {
|
||||
type: 'application/json'
|
||||
});
|
||||
saveAs(blob, `functions-export-${Date.now()}.json`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class=" self-center font-medium line-clamp-1">
|
||||
{$i18n.t('Export')}
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<AddFunctionMenu
|
||||
createHandler={() => {
|
||||
goto('/admin/functions/create');
|
||||
}}
|
||||
importFromLinkHandler={() => {
|
||||
showImportModal = true;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="cursor-pointer px-2 py-1.5 rounded-xl bg-black text-white dark:bg-white dark:text-black transition font-medium text-sm flex items-center"
|
||||
>
|
||||
<Plus className="size-3" strokeWidth="2.5" />
|
||||
|
||||
<div class=" hidden md:block md:ml-1 text-xs">{$i18n.t('New Function')}</div>
|
||||
</div>
|
||||
</AddFunctionMenu>
|
||||
<div class="flex w-full justify-end">
|
||||
<SplitCreateButton
|
||||
actions={[
|
||||
{
|
||||
id: 'functions-new',
|
||||
label: $i18n.t('Create'),
|
||||
href: '/admin/functions/create'
|
||||
},
|
||||
{
|
||||
id: 'functions-import-link',
|
||||
label: $i18n.t('Import From Link'),
|
||||
onClick: () => {
|
||||
showImportModal = true;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'functions-import',
|
||||
label: $i18n.t('Import JSON'),
|
||||
onClick: () => functionsImportInputElement?.click(),
|
||||
visible: $user?.role === 'admin'
|
||||
},
|
||||
{
|
||||
id: 'functions-export',
|
||||
label: $i18n.t('Export JSON'),
|
||||
onClick: downloadFunctions,
|
||||
visible: $user?.role === 'admin' && functions.length > 0
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30"
|
||||
>
|
||||
<div class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2">
|
||||
<div class="flex flex-1">
|
||||
<div class="space-y-1">
|
||||
<div class="flex h-8 flex-1 items-center w-full gap-2">
|
||||
<div class="flex min-w-0 flex-1">
|
||||
<div class=" self-center ml-1 mr-3">
|
||||
<Search className="size-3.5" />
|
||||
</div>
|
||||
|
|
@ -381,217 +408,316 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="px-3 flex w-full bg-transparent overflow-x-auto scrollbar-none"
|
||||
on:wheel={(e) => {
|
||||
if (e.deltaY !== 0) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.scrollLeft += e.deltaY;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="flex gap-0.5 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
|
||||
class="flex max-w-[55%] shrink-0 overflow-x-auto scrollbar-none"
|
||||
bind:this={tagsContainerElement}
|
||||
on:wheel={(e) => {
|
||||
if (e.deltaY !== 0) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.scrollLeft += e.deltaY;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ViewSelector
|
||||
bind:value={viewOption}
|
||||
onChange={async (value) => {
|
||||
localStorage.workspaceViewOption = value;
|
||||
<div
|
||||
class="flex w-fit gap-0.5 text-center text-sm rounded-full bg-transparent whitespace-nowrap"
|
||||
>
|
||||
<ViewSelector
|
||||
bind:value={viewOption}
|
||||
align="end"
|
||||
onChange={async (value) => {
|
||||
localStorage.workspaceViewOption = value;
|
||||
|
||||
await tick();
|
||||
}}
|
||||
/>
|
||||
await tick();
|
||||
}}
|
||||
/>
|
||||
|
||||
<TagSelector
|
||||
bind:value={selectedType}
|
||||
items={[
|
||||
{ value: 'pipe', label: $i18n.t('Pipe') },
|
||||
{ value: 'filter', label: $i18n.t('Filter') },
|
||||
{ value: 'action', label: $i18n.t('Action') },
|
||||
{ value: 'event', label: $i18n.t('Event') }
|
||||
]}
|
||||
/>
|
||||
<TagSelector
|
||||
bind:value={selectedType}
|
||||
align="end"
|
||||
items={[
|
||||
{ value: 'pipe', label: $i18n.t('Pipe') },
|
||||
{ value: 'filter', label: $i18n.t('Filter') },
|
||||
{ value: 'action', label: $i18n.t('Action') },
|
||||
{ value: 'event', label: $i18n.t('Event') }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if (filteredItems ?? []).length !== 0}
|
||||
<div class="px-3 my-2 gap-1 lg:gap-2 grid lg:grid-cols-2">
|
||||
{#each filteredItems as func (func.id)}
|
||||
<div
|
||||
class=" flex space-x-4 cursor-pointer w-full px-2 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
|
||||
<div class="my-1">
|
||||
<div
|
||||
class="flex w-full items-center gap-2 px-1.5 pb-0.5 text-xs text-gray-400 dark:text-gray-600"
|
||||
>
|
||||
<button
|
||||
class="flex min-w-0 flex-1 items-center gap-1 py-0.5 text-left"
|
||||
type="button"
|
||||
on:click={() => setSortKey('name')}
|
||||
>
|
||||
<a
|
||||
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
|
||||
href={`/admin/functions/edit?id=${encodeURIComponent(func.id)}`}
|
||||
{$i18n.t('Title')}
|
||||
{#if sortKey === 'name'}
|
||||
{#if sortDirection === 'asc'}
|
||||
<ChevronUp className="size-2" />
|
||||
{:else}
|
||||
<ChevronDown className="size-2" />
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<div class="hidden w-44 shrink-0 md:block"></div>
|
||||
|
||||
<button
|
||||
class="flex w-36 shrink-0 items-center justify-end gap-1 py-0.5 text-right"
|
||||
type="button"
|
||||
on:click={() => setSortKey('updated_at')}
|
||||
>
|
||||
{$i18n.t('Updated at')}
|
||||
{#if sortKey === 'updated_at'}
|
||||
{#if sortDirection === 'asc'}
|
||||
<ChevronUp className="size-2" />
|
||||
{:else}
|
||||
<ChevronDown className="size-2" />
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-y-0.5">
|
||||
{#each filteredItems as func (func.id)}
|
||||
<div
|
||||
class="group flex min-h-8 w-full cursor-pointer items-center gap-2 overflow-hidden rounded-xl px-2 py-1 text-left"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:click={(e) => {
|
||||
if (shouldIgnoreRowClick(e.target)) return;
|
||||
openFunction(func);
|
||||
}}
|
||||
on:keydown={(e) => {
|
||||
if (e.currentTarget !== e.target) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openFunction(func);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center text-left">
|
||||
<div class=" flex-1 self-center pl-1">
|
||||
<Tooltip content={func.id} placement="top-start">
|
||||
<div class=" flex items-center gap-1.5">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
|
||||
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<div
|
||||
class=" text-xs font-semibold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
|
||||
class="shrink-0 rounded-sm bg-gray-500/20 px-1 text-[10px] uppercase leading-4 text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
{func.type}
|
||||
</div>
|
||||
|
||||
<div class="line-clamp-1 text-sm">
|
||||
{func.name}
|
||||
</div>
|
||||
<Tooltip content={func.id} className="min-w-0" placement="top-start">
|
||||
<div
|
||||
class="truncate text-[13px] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
|
||||
>
|
||||
{func.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{#if func?.meta?.manifest?.version}
|
||||
<div class=" text-gray-500 text-xs font-medium shrink-0">
|
||||
<div
|
||||
class="min-w-0 max-w-[40%] shrink-0 truncate text-[11px] leading-5 text-gray-500"
|
||||
>
|
||||
v{func?.meta?.manifest?.version ?? ''}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div class="flex gap-1.5 px-1">
|
||||
<div class="text-xs text-gray-500 shrink-0">
|
||||
<Tooltip
|
||||
content={func?.user?.email ?? $i18n.t('Deleted User')}
|
||||
className="flex shrink-0"
|
||||
placement="top-start"
|
||||
>
|
||||
{$i18n.t('By {{name}}', {
|
||||
name: capitalizeFirstLetter(
|
||||
func?.user?.name ?? func?.user?.email ?? $i18n.t('Deleted User')
|
||||
)
|
||||
})}
|
||||
<Tooltip content={dayjs(func.updated_at * 1000).format('LLLL')}>
|
||||
<div
|
||||
class="shrink-0 truncate text-[11px] leading-5 text-gray-400 dark:text-gray-600"
|
||||
>
|
||||
{dayjs(func.updated_at * 1000).fromNow()}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
|
||||
{func.meta.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if func?.meta?.description}
|
||||
<Tooltip
|
||||
content={func?.meta?.description}
|
||||
className="min-w-0"
|
||||
placement="top-start"
|
||||
>
|
||||
<div
|
||||
class="mt-0.5 truncate text-[0.6875rem] leading-4 text-gray-400 dark:text-gray-600"
|
||||
>
|
||||
{func?.meta?.description}
|
||||
</div>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex flex-row gap-0.5 self-center">
|
||||
{#if shiftKey}
|
||||
<Tooltip content={$i18n.t('Delete')}>
|
||||
<button
|
||||
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
deleteHandler(func);
|
||||
}}
|
||||
>
|
||||
<GarbageBin />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
{#if func?.meta?.manifest?.funding_url ?? false}
|
||||
<Tooltip content={$i18n.t('Support')}>
|
||||
<button
|
||||
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
selectedFunction = func;
|
||||
showManifestModal = true;
|
||||
}}
|
||||
>
|
||||
<Heart />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
<Tooltip content={$i18n.t('Valves')}>
|
||||
<button
|
||||
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
selectedFunction = func;
|
||||
showValvesModal = true;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<FunctionMenu
|
||||
{func}
|
||||
editHandler={() => {
|
||||
goto(`/admin/functions/edit?id=${encodeURIComponent(func.id)}`);
|
||||
}}
|
||||
shareHandler={() => {
|
||||
shareHandler(func);
|
||||
}}
|
||||
cloneHandler={() => {
|
||||
cloneHandler(func);
|
||||
}}
|
||||
exportHandler={() => {
|
||||
exportHandler(func);
|
||||
}}
|
||||
deleteHandler={async () => {
|
||||
selectedFunction = func;
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
toggleGlobalHandler={() => {
|
||||
if (['filter', 'action'].includes(func.type)) {
|
||||
toggleGlobalHandler(func);
|
||||
}
|
||||
}}
|
||||
onClose={() => {}}
|
||||
<div
|
||||
class="hidden max-w-44 shrink-0 self-center truncate text-right text-[11px] leading-5 text-gray-500 dark:text-gray-500 md:block"
|
||||
>
|
||||
<Tooltip
|
||||
content={func?.user?.email ?? $i18n.t('Deleted User')}
|
||||
className="min-w-0"
|
||||
placement="top-start"
|
||||
>
|
||||
<button
|
||||
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
|
||||
type="button"
|
||||
>
|
||||
<EllipsisHorizontal className="size-5" />
|
||||
</button>
|
||||
</FunctionMenu>
|
||||
{/if}
|
||||
|
||||
<div class=" self-center mx-1">
|
||||
<Tooltip content={func.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
|
||||
<Switch
|
||||
bind:state={func.is_active}
|
||||
on:change={async (e) => {
|
||||
toggleFunctionById(localStorage.token, func.id);
|
||||
models.set(
|
||||
await getModels(
|
||||
localStorage.token,
|
||||
$config?.features?.enable_direct_connections &&
|
||||
($settings?.directConnections ?? null),
|
||||
false,
|
||||
true
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div class="truncate">
|
||||
{capitalizeFirstLetter(
|
||||
func?.user?.name ?? func?.user?.email ?? $i18n.t('Deleted User')
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="ml-2 flex shrink-0 flex-row items-center gap-1.5 self-center">
|
||||
{#if shiftKey}
|
||||
<Tooltip content={$i18n.t('Delete')}>
|
||||
<button
|
||||
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
|
||||
type="button"
|
||||
aria-label={$i18n.t('Delete')}
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
deleteHandler(func);
|
||||
}}
|
||||
>
|
||||
<GarbageBin className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
{#if func?.meta?.manifest?.funding_url ?? false}
|
||||
<Tooltip content={$i18n.t('Support')}>
|
||||
<button
|
||||
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
|
||||
type="button"
|
||||
aria-label={$i18n.t('Support')}
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectedFunction = func;
|
||||
showManifestModal = true;
|
||||
}}
|
||||
>
|
||||
<Heart className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
<Tooltip content={$i18n.t('Valves')}>
|
||||
<button
|
||||
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
|
||||
type="button"
|
||||
aria-label={$i18n.t('Valves')}
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectedFunction = func;
|
||||
showValvesModal = true;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.940 1.11-.940h2.593c.55 0 1.02.398 1.11.940l.213 1.281c.063.374.313.686.645.870.074.040.147.083.220.127.325.196.720.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.370.490l1.296 2.247a1.125 1.125 0 0 1-.260 1.431l-1.003.827c-.293.241-.438.613-.430.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.750.430.991l1.004.827c.424.350.534.955.260 1.430l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.750-.072-1.076.124a6.47 6.47 0 0 1-.220.128c-.331.183-.581.495-.644.869l-.213 1.281c-.090.543-.560.940-1.110.940h-2.594c-.550 0-1.019-.398-1.110-.940l-.213-1.281c-.062-.374-.312-.686-.644-.870a6.52 6.52 0 0 1-.220-.127c-.325-.196-.720-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.490l-1.297-2.247a1.125 1.125 0 0 1 .260-1.431l1.004-.827c.292-.240.437-.613.430-.991a6.932 6.932 0 0 1 0-.255c.007-.380-.138-.751-.430-.992l-1.004-.827a1.125 1.125 0 0 1-.260-1.430l1.297-2.247a1.125 1.125 0 0 1 1.370-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.220-.128.332-.183.582-.495.644-.869l.214-1.280Z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<FunctionMenu
|
||||
{func}
|
||||
show={openFunctionMenuId === func.id}
|
||||
editHandler={() => {
|
||||
goto(`/admin/functions/edit?id=${encodeURIComponent(func.id)}`);
|
||||
}}
|
||||
shareHandler={() => {
|
||||
shareHandler(func);
|
||||
}}
|
||||
cloneHandler={() => {
|
||||
cloneHandler(func);
|
||||
}}
|
||||
exportHandler={() => {
|
||||
exportHandler(func);
|
||||
}}
|
||||
deleteHandler={async () => {
|
||||
selectedFunction = func;
|
||||
showDeleteConfirm = true;
|
||||
}}
|
||||
toggleGlobalHandler={() => {
|
||||
if (['filter', 'action'].includes(func.type)) {
|
||||
toggleGlobalHandler(func);
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
openFunctionMenuId = null;
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition dark:text-gray-500"
|
||||
type="button"
|
||||
aria-label={$i18n.t('Function Menu')}
|
||||
on:click={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openFunctionMenuId = openFunctionMenuId === func.id ? null : func.id;
|
||||
}}
|
||||
>
|
||||
<EllipsisHorizontal className="size-4" />
|
||||
</button>
|
||||
</FunctionMenu>
|
||||
|
||||
<button
|
||||
class="flex h-6 items-center"
|
||||
type="button"
|
||||
on:click={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<Tooltip content={func.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
|
||||
<Switch
|
||||
bind:state={func.is_active}
|
||||
on:change={async () => {
|
||||
toggleFunctionById(localStorage.token, func.id);
|
||||
models.set(
|
||||
await getModels(
|
||||
localStorage.token,
|
||||
$config?.features?.enable_direct_connections &&
|
||||
($settings?.directConnections ?? null),
|
||||
false,
|
||||
true
|
||||
)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
|
||||
<div class="max-w-md text-center">
|
||||
<div class=" text-3xl mb-3">😕</div>
|
||||
<div class=" text-lg font-medium mb-1">{$i18n.t('No functions 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 functions 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>
|
||||
|
|
@ -606,30 +732,11 @@
|
|||
</div> -->
|
||||
|
||||
{#if $config?.features.enable_community_sharing}
|
||||
<div class=" my-16">
|
||||
<div class=" text-xl font-medium mb-1 line-clamp-1">
|
||||
{$i18n.t('Made by Open WebUI Community')}
|
||||
</div>
|
||||
|
||||
<a
|
||||
class=" flex cursor-pointer items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-850 w-full mb-2 px-3.5 py-1.5 rounded-xl transition"
|
||||
href="https://openwebui.com/functions"
|
||||
target="_blank"
|
||||
>
|
||||
<div class=" self-center">
|
||||
<div class=" font-semibold line-clamp-1">{$i18n.t('Discover a function')}</div>
|
||||
<div class=" text-sm line-clamp-1">
|
||||
{$i18n.t('Discover, download, and explore custom functions')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<ChevronRight />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<CommunityDiscover
|
||||
href="https://openwebui.com/functions"
|
||||
title={$i18n.t('Discover a function')}
|
||||
description={$i18n.t('Discover, download, and explore custom functions')}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -641,7 +748,7 @@
|
|||
}}
|
||||
>
|
||||
<div class=" text-sm text-gray-500 truncate">
|
||||
{$i18n.t('This will delete')} <span class=" font-semibold">{selectedFunction.name}</span>.
|
||||
{$i18n.t('This will delete')} <span class=" font-normal">{selectedFunction.name}</span>.
|
||||
</div>
|
||||
</DeleteConfirmDialog>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { getContext } from 'svelte';
|
||||
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Pencil from '$lib/components/icons/Pencil.svelte';
|
||||
import Link from '$lib/components/icons/Link.svelte';
|
||||
|
|
@ -30,34 +31,32 @@
|
|||
</Tooltip>
|
||||
|
||||
<div slot="content">
|
||||
<div
|
||||
class="min-w-[190px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
|
||||
>
|
||||
<DropdownMenu className="min-w-[190px]">
|
||||
<button
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[13px] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
on:click={async () => {
|
||||
createHandler();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-2">
|
||||
<Pencil />
|
||||
<div class="self-center">
|
||||
<Pencil className="size-3.5" />
|
||||
</div>
|
||||
<div class=" self-center truncate">{$i18n.t('New Function')}</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl w-full"
|
||||
class="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[13px] cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
|
||||
on:click={async () => {
|
||||
importFromLinkHandler();
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-2">
|
||||
<Link />
|
||||
<div class="self-center">
|
||||
<Link className="size-3.5" />
|
||||
</div>
|
||||
<div class=" self-center truncate">{$i18n.t('Import From Link')}</div>
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import { extractFrontmatter, formatSkillName, nameToId } from '$lib/utils';
|
||||
import CodeEditor from '$lib/components/common/CodeEditor.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import Badge from '$lib/components/common/Badge.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
|
||||
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
let loading = false;
|
||||
let showConfirm = false;
|
||||
|
||||
export let onSave = () => {};
|
||||
export let onSave = /** @param {any} _value */ async (_value) => {};
|
||||
|
||||
export let edit = false;
|
||||
export let clone = false;
|
||||
|
|
@ -305,12 +305,16 @@ class Pipe:
|
|||
|
||||
const saveHandler = async () => {
|
||||
loading = true;
|
||||
onSave({
|
||||
id,
|
||||
name,
|
||||
meta,
|
||||
content
|
||||
});
|
||||
try {
|
||||
await onSave({
|
||||
id,
|
||||
name,
|
||||
meta,
|
||||
content
|
||||
});
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitHandler = async () => {
|
||||
|
|
@ -333,146 +337,148 @@ class Pipe:
|
|||
};
|
||||
</script>
|
||||
|
||||
<div class=" flex flex-col justify-between w-full overflow-y-auto h-full">
|
||||
<div class="mx-auto w-full md:px-0 h-full">
|
||||
<form
|
||||
bind:this={formElement}
|
||||
class=" flex flex-col max-h-[100dvh] h-full"
|
||||
on:submit|preventDefault={() => {
|
||||
if (edit) {
|
||||
submitHandler();
|
||||
} else {
|
||||
showConfirm = true;
|
||||
}
|
||||
<div class="flex h-full w-full min-w-0 flex-col overflow-hidden">
|
||||
<form
|
||||
bind:this={formElement}
|
||||
class="flex h-full min-h-0 min-w-0 flex-col"
|
||||
on:submit|preventDefault={() => {
|
||||
if (edit) {
|
||||
submitHandler();
|
||||
} else {
|
||||
showConfirm = true;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
class="mb-1 flex h-6 w-fit items-center gap-1 rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
goto('/admin/functions');
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col flex-1 overflow-auto h-0 rounded-lg">
|
||||
<div class="w-full mb-2 flex flex-col gap-0.5">
|
||||
<div class="flex w-full items-center">
|
||||
<div class=" shrink-0 mr-2">
|
||||
<Tooltip content={$i18n.t('Back')}>
|
||||
<button
|
||||
class="w-full text-left text-sm py-1.5 px-1 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
|
||||
on:click={() => {
|
||||
goto('/admin/functions');
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft strokeWidth="2.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<ChevronLeft className="size-3" strokeWidth="2" />
|
||||
<span>{$i18n.t('Back')}</span>
|
||||
</button>
|
||||
|
||||
<div class="flex shrink-0 items-start gap-2 pb-2 px-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<Tooltip content={$i18n.t('e.g. My Filter')} placement="top-start">
|
||||
<input
|
||||
class="w-full bg-transparent text-sm outline-hidden"
|
||||
type="text"
|
||||
placeholder={$i18n.t('Function Name')}
|
||||
aria-label={$i18n.t('Function Name')}
|
||||
bind:value={name}
|
||||
required
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<div class="mt-0.5 flex min-w-0 items-center gap-2 text-xs text-gray-500">
|
||||
{#if edit}
|
||||
<div class="shrink-0 truncate font-mono" title={id}>
|
||||
{id}
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<Tooltip content={$i18n.t('e.g. My Filter')} placement="top-start">
|
||||
<input
|
||||
class="w-full text-2xl font-medium bg-transparent outline-hidden font-primary"
|
||||
type="text"
|
||||
placeholder={$i18n.t('Function Name')}
|
||||
bind:value={name}
|
||||
required
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !edit}
|
||||
<select
|
||||
class="text-xs bg-transparent border border-gray-100 dark:border-gray-800 rounded-lg px-2 py-1 outline-hidden"
|
||||
bind:value={starterType}
|
||||
on:change={(event) => selectStarterType(event.currentTarget.value)}
|
||||
aria-label={$i18n.t('Function starter')}
|
||||
>
|
||||
<option value="filter">{$i18n.t('Filter')}</option>
|
||||
<option value="event">{$i18n.t('Event')}</option>
|
||||
</select>
|
||||
{/if}
|
||||
<Badge type="muted" content={$i18n.t('Function')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" flex gap-2 px-1 items-center">
|
||||
{#if edit}
|
||||
<div class="text-sm text-gray-500 shrink-0">
|
||||
{id}
|
||||
</div>
|
||||
{:else}
|
||||
<Tooltip className="w-full" content={$i18n.t('e.g. my_filter')} placement="top-start">
|
||||
<input
|
||||
class="w-full text-sm disabled:text-gray-500 bg-transparent outline-hidden"
|
||||
type="text"
|
||||
placeholder={$i18n.t('Function ID')}
|
||||
bind:value={id}
|
||||
required
|
||||
disabled={edit}
|
||||
/>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
|
||||
{:else}
|
||||
<Tooltip
|
||||
className="w-full self-center items-center flex"
|
||||
content={$i18n.t('e.g. A filter to remove profanity from text')}
|
||||
className="min-w-[8rem] flex-1"
|
||||
content={$i18n.t('e.g. my_filter')}
|
||||
placement="top-start"
|
||||
>
|
||||
<input
|
||||
class="w-full text-sm bg-transparent outline-hidden"
|
||||
class="w-full bg-transparent font-mono outline-hidden disabled:text-gray-500"
|
||||
type="text"
|
||||
placeholder={$i18n.t('Function Description')}
|
||||
bind:value={meta.description}
|
||||
placeholder={$i18n.t('Function ID')}
|
||||
aria-label={$i18n.t('Function ID')}
|
||||
bind:value={id}
|
||||
required
|
||||
disabled={edit}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-2 flex-1 overflow-auto h-0 rounded-lg">
|
||||
<CodeEditor
|
||||
bind:this={codeEditor}
|
||||
value={content}
|
||||
lang="python"
|
||||
{boilerplate}
|
||||
onChange={(e) => {
|
||||
_content = e;
|
||||
if (!edit) {
|
||||
const fm = extractFrontmatter(e);
|
||||
if (fm.title && !name) {
|
||||
name = formatSkillName(fm.title);
|
||||
id = nameToId(fm.title);
|
||||
}
|
||||
if (fm.description && !meta.description) {
|
||||
meta = { ...meta, description: fm.description };
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSave={async () => {
|
||||
if (formElement) {
|
||||
formElement.requestSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pb-3 flex justify-between">
|
||||
<div class="flex-1 pr-3">
|
||||
<div class="text-xs text-gray-500 line-clamp-2">
|
||||
<span class=" font-semibold dark:text-gray-200">{$i18n.t('Warning:')}</span>
|
||||
{$i18n.t('Functions allow arbitrary code execution.')} <br />—
|
||||
<span class=" font-medium dark:text-gray-400"
|
||||
>{$i18n.t(`don't install random functions from sources you don't trust.`)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
|
||||
type="submit"
|
||||
<Tooltip
|
||||
className="flex min-w-0 flex-1 items-center"
|
||||
content={$i18n.t('e.g. A filter to remove profanity from text')}
|
||||
placement="top-start"
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
</button>
|
||||
<input
|
||||
class="w-full bg-transparent outline-hidden"
|
||||
type="text"
|
||||
placeholder={$i18n.t('Function Description')}
|
||||
aria-label={$i18n.t('Function Description')}
|
||||
bind:value={meta.description}
|
||||
required
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1">
|
||||
{#if !edit}
|
||||
<select
|
||||
class="h-7 rounded-lg border border-gray-100 bg-transparent px-2 text-xs outline-hidden dark:border-gray-800"
|
||||
bind:value={starterType}
|
||||
on:change={(event) => selectStarterType(event.currentTarget.value)}
|
||||
aria-label={$i18n.t('Function starter')}
|
||||
>
|
||||
<option value="filter">{$i18n.t('Filter')}</option>
|
||||
<option value="event">{$i18n.t('Event')}</option>
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-hidden rounded-lg">
|
||||
<CodeEditor
|
||||
bind:this={codeEditor}
|
||||
value={content}
|
||||
lang="python"
|
||||
{boilerplate}
|
||||
className="text-[11px]"
|
||||
onChange={(e) => {
|
||||
_content = e;
|
||||
if (!edit) {
|
||||
const fm = extractFrontmatter(e);
|
||||
if (fm.title && !name) {
|
||||
name = formatSkillName(fm.title);
|
||||
id = nameToId(fm.title);
|
||||
}
|
||||
if (fm.description && !meta.description) {
|
||||
meta = { ...meta, description: fm.description };
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSave={async () => {
|
||||
if (formElement) {
|
||||
formElement.requestSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 py-2 text-xs text-gray-500">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<span class="font-normal dark:text-gray-200">{$i18n.t('Warning:')}</span>
|
||||
{$i18n.t('Functions can execute arbitrary code.')}
|
||||
<span class="font-normal dark:text-gray-400">
|
||||
{$i18n.t('Only install functions from sources you trust.')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="flex h-7 shrink-0 items-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
>
|
||||
{$i18n.t(edit ? 'Save' : 'Save & Create')}
|
||||
{#if loading}
|
||||
<Spinner className="size-3" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { getContext } from 'svelte';
|
||||
|
||||
import Dropdown from '$lib/components/common/Dropdown.svelte';
|
||||
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
|
||||
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Share from '$lib/components/icons/Share.svelte';
|
||||
|
|
@ -23,7 +24,12 @@
|
|||
|
||||
export let onClose: Function;
|
||||
|
||||
let show = false;
|
||||
export let show = false;
|
||||
|
||||
const closeMenu = () => {
|
||||
show = false;
|
||||
onClose();
|
||||
};
|
||||
</script>
|
||||
|
||||
<Dropdown
|
||||
|
|
@ -39,12 +45,10 @@
|
|||
</Tooltip>
|
||||
|
||||
<div slot="content">
|
||||
<div
|
||||
class="min-w-[180px] rounded-xl p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
|
||||
>
|
||||
<DropdownMenu className="min-w-[180px]">
|
||||
{#if ['filter', 'action'].includes(func.type)}
|
||||
<div
|
||||
class="flex gap-2 justify-between items-center px-3 py-1.5 text-sm font-medium cursor-pointerrounded-md"
|
||||
class="flex gap-2 justify-between items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer rounded-xl"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<GlobeAlt />
|
||||
|
|
@ -60,9 +64,10 @@
|
|||
{/if}
|
||||
|
||||
<button
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md w-full"
|
||||
class="select-none flex gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl w-full"
|
||||
on:click={() => {
|
||||
editHandler();
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
|
|
@ -71,7 +76,7 @@
|
|||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-4 h-4"
|
||||
class="size-3.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
|
|
@ -84,9 +89,10 @@
|
|||
</button>
|
||||
|
||||
<button
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md w-full"
|
||||
class="select-none flex gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl w-full"
|
||||
on:click={() => {
|
||||
shareHandler();
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Share />
|
||||
|
|
@ -94,9 +100,10 @@
|
|||
</button>
|
||||
|
||||
<button
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md w-full"
|
||||
class="select-none flex gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl w-full"
|
||||
on:click={() => {
|
||||
cloneHandler();
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<DocumentDuplicate />
|
||||
|
|
@ -104,9 +111,10 @@
|
|||
</button>
|
||||
|
||||
<button
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md w-full"
|
||||
class="select-none flex gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl w-full"
|
||||
on:click={() => {
|
||||
exportHandler();
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Download />
|
||||
|
|
@ -116,14 +124,15 @@
|
|||
<hr class="border-gray-50 dark:border-gray-850/30 my-1" />
|
||||
|
||||
<button
|
||||
class="select-none flex gap-2 items-center px-3 py-1.5 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md w-full"
|
||||
class="select-none flex gap-2 items-center h-[1.6875rem] px-2 text-[13px] font-normal cursor-pointer hover:bg-gray-50/40 dark:hover:bg-gray-800/40 rounded-xl w-full"
|
||||
on:click={() => {
|
||||
deleteHandler();
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<GarbageBin strokeWidth="2" />
|
||||
<div class="flex items-center">{$i18n.t('Delete')}</div>
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
|
|
|||
|
|
@ -20,13 +20,13 @@
|
|||
import WebSearch from './Settings/WebSearch.svelte';
|
||||
|
||||
import Evaluations from './Settings/Evaluations.svelte';
|
||||
import Analytics from './Analytics.svelte';
|
||||
import CodeExecution from './Settings/CodeExecution.svelte';
|
||||
import Integrations from './Settings/Integrations.svelte';
|
||||
import Subagents from './Settings/Subagents.svelte';
|
||||
|
||||
import ChartBar from '../icons/ChartBar.svelte';
|
||||
import DocumentChartBar from '../icons/DocumentChartBar.svelte';
|
||||
import Search from '../icons/Search.svelte';
|
||||
import XMark from '../icons/XMark.svelte';
|
||||
import AdminTabIcon from './Settings/AdminTabIcon.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -41,7 +41,9 @@
|
|||
'authentication',
|
||||
'connections',
|
||||
'models',
|
||||
'subagents',
|
||||
'evaluations',
|
||||
'analytics',
|
||||
'integrations',
|
||||
'documents',
|
||||
'web',
|
||||
|
|
@ -145,12 +147,24 @@
|
|||
'export'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'subagents',
|
||||
title: 'Sub-agents',
|
||||
route: '/admin/settings/subagents',
|
||||
keywords: ['sub-agents', 'subagents', 'delegation', 'background', 'agents']
|
||||
},
|
||||
{
|
||||
id: 'evaluations',
|
||||
title: 'Evaluations',
|
||||
route: '/admin/settings/evaluations',
|
||||
keywords: ['evaluations', 'feedback', 'rating', 'arena', 'leaderboard', 'preference']
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
title: 'Analytics',
|
||||
route: '/admin/settings/analytics',
|
||||
keywords: ['analytics', 'usage', 'stats', 'dashboard', 'models', 'users', 'messages']
|
||||
},
|
||||
{
|
||||
id: 'integrations',
|
||||
title: 'Integrations',
|
||||
|
|
@ -283,6 +297,10 @@
|
|||
const setFilteredSettings = () => {
|
||||
filteredSettings = allSettings.filter((tab) => {
|
||||
const searchTerm = search.toLowerCase().trim();
|
||||
if (tab.id === 'analytics' && !($config?.features.enable_admin_analytics ?? true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
search === '' ||
|
||||
tab.title.toLowerCase().includes(searchTerm) ||
|
||||
|
|
@ -301,6 +319,13 @@
|
|||
}, 100);
|
||||
};
|
||||
|
||||
const tabButtonClass = (active) =>
|
||||
`flex items-center gap-1.5 h-7 px-2 lg:w-full shrink-0 rounded-lg text-xs text-left transition-colors duration-75 select-none ${
|
||||
active
|
||||
? 'font-medium text-gray-900 dark:text-white bg-gray-100 dark:bg-white/6'
|
||||
: 'text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`;
|
||||
|
||||
onMount(() => {
|
||||
const containerElement = document.getElementById('admin-settings-tabs-container');
|
||||
|
||||
|
|
@ -319,321 +344,154 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
|
||||
<div
|
||||
<div class="flex flex-col lg:flex-row w-full h-full min-h-0 pb-2">
|
||||
<nav
|
||||
id="admin-settings-tabs-container"
|
||||
class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
|
||||
class="shrink-0 min-w-0 lg:min-h-0 flex lg:block border-b lg:border-b-0 lg:border-r border-gray-100/30 dark:border-white/[0.02] lg:w-[15rem]"
|
||||
>
|
||||
<div
|
||||
class="hidden lg:flex w-full rounded-full px-2.5 gap-2 bg-gray-100/80 dark:bg-gray-850/80 backdrop-blur-2xl my-1 -mx-1 mt-1.5"
|
||||
id="settings-search"
|
||||
class="tabs flex min-w-0 flex-1 overflow-x-auto lg:overflow-x-hidden lg:overflow-y-auto lg:flex-col p-1 lg:pt-4 gap-px"
|
||||
>
|
||||
<div class="self-center rounded-l-xl bg-transparent">
|
||||
<Search className="size-3.5" strokeWidth="1.5" />
|
||||
</div>
|
||||
<label class="sr-only" for="search-input-settings-modal">{$i18n.t('Search')}</label>
|
||||
<input
|
||||
class="w-full py-1 text-sm bg-transparent dark:text-gray-300 outline-hidden"
|
||||
bind:value={search}
|
||||
id="search-input-settings-modal"
|
||||
on:input={searchDebounceHandler}
|
||||
placeholder={$i18n.t('Search')}
|
||||
/>
|
||||
</div>
|
||||
<span class="hidden lg:block text-[0.625rem] text-gray-400 dark:text-gray-600 px-2 mb-1">
|
||||
{$i18n.t('Admin')}
|
||||
</span>
|
||||
|
||||
<!-- {$i18n.t('General')} -->
|
||||
<!-- {$i18n.t('Authentication')} -->
|
||||
<!-- {$i18n.t('Connections')} -->
|
||||
<!-- {$i18n.t('Models')} -->
|
||||
<!-- {$i18n.t('Evaluations')} -->
|
||||
<!-- {$i18n.t('Integrations')} -->
|
||||
<!-- {$i18n.t('Documents')} -->
|
||||
<!-- {$i18n.t('Web Search')} -->
|
||||
<!-- {$i18n.t('Code Execution')} -->
|
||||
<!-- {$i18n.t('Interface')} -->
|
||||
<!-- {$i18n.t('Audio')} -->
|
||||
<!-- {$i18n.t('Images')} -->
|
||||
<!-- {$i18n.t('Pipelines')} -->
|
||||
<!-- {$i18n.t('Database')} -->
|
||||
{#each filteredSettings as tab (tab.id)}
|
||||
<a
|
||||
id={tab.id}
|
||||
href={tab.route}
|
||||
draggable="false"
|
||||
class="px-0.5 py-1 min-w-fit rounded-lg flex-1 lg:flex-none flex text-right transition select-none {selectedTab ===
|
||||
tab.id
|
||||
? ''
|
||||
: ' text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'}"
|
||||
<div
|
||||
class="hidden lg:flex items-center gap-1.5 h-7 px-2 lg:w-full shrink-0 rounded-lg text-xs bg-gray-50/70 dark:bg-white/[0.03] mb-2"
|
||||
id="settings-search"
|
||||
>
|
||||
<div class=" self-center mr-2">
|
||||
{#if tab.id === 'general'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M6.955 1.45A.5.5 0 0 1 7.452 1h1.096a.5.5 0 0 1 .497.45l.17 1.699c.484.12.94.312 1.356.562l1.321-1.081a.5.5 0 0 1 .67.033l.774.775a.5.5 0 0 1 .034.67l-1.08 1.32c.25.417.44.873.561 1.357l1.699.17a.5.5 0 0 1 .45.497v1.096a.5.5 0 0 1-.45.497l-1.699.17c-.12.484-.312.94-.562 1.356l1.082 1.322a.5.5 0 0 1-.034.67l-.774.774a.5.5 0 0 1-.67.033l-1.322-1.08c-.416.25-.872.44-1.356.561l-.17 1.699a.5.5 0 0 1-.497.45H7.452a.5.5 0 0 1-.497-.45l-.17-1.699a4.973 4.973 0 0 1-1.356-.562L4.108 13.37a.5.5 0 0 1-.67-.033l-.774-.775a.5.5 0 0 1-.034-.67l1.08-1.32a4.971 4.971 0 0 1-.561-1.357l-1.699-.17A.5.5 0 0 1 1 8.548V7.452a.5.5 0 0 1 .45-.497l1.699-.17c.12-.484.312-.94.562-1.356L2.629 4.107a.5.5 0 0 1 .034-.67l.774-.774a.5.5 0 0 1 .67-.033L5.43 3.71a4.97 4.97 0 0 1 1.356-.561l.17-1.699ZM6 8c0 .538.212 1.026.558 1.385l.057.057a2 2 0 0 0 2.828-2.828l-.058-.056A2 2 0 0 0 6 8Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else if tab.id === 'authentication'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8 1.5A3.5 3.5 0 0 0 4.5 5v1H4a2 2 0 0 0-2 2v4.5a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-.5V5A3.5 3.5 0 0 0 8 1.5ZM6 6V5a2 2 0 1 1 4 0v1H6Zm2 3a.75.75 0 0 0-.75.75v1a.75.75 0 0 0 1.5 0v-1A.75.75 0 0 0 8 9Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else if tab.id === 'connections'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
d="M1 9.5A3.5 3.5 0 0 0 4.5 13H12a3 3 0 0 0 .917-5.857 2.503 2.503 0 0 0-3.198-3.019 3.5 3.5 0 0 0-6.628 2.171A3.5 3.5 0 0 0 1 9.5Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if tab.id === 'models'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 1c3.866 0 7 1.79 7 4s-3.134 4-7 4-7-1.79-7-4 3.134-4 7-4zm5.694 8.13c.464-.264.91-.583 1.306-.952V10c0 2.21-3.134 4-7 4s-7-1.79-7-4V8.178c.396.37.842.688 1.306.953C5.838 10.006 7.854 10.5 10 10.5s4.162-.494 5.694-1.37zM3 13.179V15c0 2.21 3.134 4 7 4s7-1.79 7-4v-1.822c-.396.37-.842.688-1.306.953-1.532.875-3.548 1.369-5.694 1.369s-4.162-.494-5.694-1.37A7.009 7.009 0 013 13.179z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else if tab.id === 'evaluations'}
|
||||
<DocumentChartBar />
|
||||
{:else if tab.id === 'integrations'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12 6.75a5.25 5.25 0 0 1 6.775-5.025.75.75 0 0 1 .313 1.248l-3.32 3.319c.063.475.276.934.641 1.299.365.365.824.578 1.3.64l3.318-3.319a.75.75 0 0 1 1.248.313 5.25 5.25 0 0 1-5.472 6.756c-1.018-.086-1.87.1-2.309.634L7.344 21.3A3.298 3.298 0 1 1 2.7 16.657l8.684-7.151c.533-.44.72-1.291.634-2.309A5.342 5.342 0 0 1 12 6.75ZM4.117 19.125a.75.75 0 0 1 .75-.75h.008a.75.75 0 0 1 .75.75v.008a.75.75 0 0 1-.75.75h-.008a.75.75 0 0 1-.75-.75v-.008Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else if tab.id === 'documents'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path d="M11.625 16.5a1.875 1.875 0 1 0 0-3.75 1.875 1.875 0 0 0 0 3.75Z" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875Zm6 16.5c.66 0 1.277-.19 1.797-.518l1.048 1.048a.75.75 0 0 0 1.06-1.06l-1.047-1.048A3.375 3.375 0 1 0 11.625 18Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if tab.id === 'web'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
d="M21.721 12.752a9.711 9.711 0 0 0-.945-5.003 12.754 12.754 0 0 1-4.339 2.708 18.991 18.991 0 0 1-.214 4.772 17.165 17.165 0 0 0 5.498-2.477ZM14.634 15.55a17.324 17.324 0 0 0 .332-4.647c-.952.227-1.945.347-2.966.347-1.021 0-2.014-.12-2.966-.347a17.515 17.515 0 0 0 .332 4.647 17.385 17.385 0 0 0 5.268 0ZM9.772 17.119a18.963 18.963 0 0 0 4.456 0A17.182 17.182 0 0 1 12 21.724a17.18 17.18 0 0 1-2.228-4.605ZM7.777 15.23a18.87 18.87 0 0 1-.214-4.774 12.753 12.753 0 0 1-4.34-2.708 9.711 9.711 0 0 0-.944 5.004 17.165 17.165 0 0 0 5.498 2.477ZM21.356 14.752a9.765 9.765 0 0 1-7.478 6.817 18.64 18.64 0 0 0 1.988-4.718 18.627 18.627 0 0 0 5.49-2.098ZM2.644 14.752c1.682.971 3.53 1.688 5.49 2.099a18.64 18.64 0 0 0 1.988 4.718 9.765 9.765 0 0 1-7.478-6.816ZM13.878 2.43a9.755 9.755 0 0 1 6.116 3.986 11.267 11.267 0 0 1-3.746 2.504 18.63 18.63 0 0 0-2.37-6.49ZM12 2.276a17.152 17.152 0 0 1 2.805 7.121c-.897.23-1.837.353-2.805.353-.968 0-1.908-.122-2.805-.353A17.151 17.151 0 0 1 12 2.276ZM10.122 2.43a18.629 18.629 0 0 0-2.37 6.49 11.266 11.266 0 0 1-3.746-2.504 9.754 9.754 0 0 1 6.116-3.985Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if tab.id === 'code-execution'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2 4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4Zm2.22 1.97a.75.75 0 0 0 0 1.06l.97.97-.97.97a.75.75 0 1 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06l-1.5-1.5a.75.75 0 0 0-1.06 0ZM8.75 8.5a.75.75 0 0 0 0 1.5h2.5a.75.75 0 0 0 0-1.5h-2.5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else if tab.id === 'interface'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2 4.25A2.25 2.25 0 0 1 4.25 2h7.5A2.25 2.25 0 0 1 14 4.25v5.5A2.25 2.25 0 0 1 11.75 12h-1.312c.1.128.21.248.328.36a.75.75 0 0 1 .234.545v.345a.75.75 0 0 1-.75.75h-4.5a.75.75 0 0 1-.75-.75v-.345a.75.75 0 0 1 .234-.545c.118-.111.228-.232.328-.36H4.25A2.25 2.25 0 0 1 2 9.75v-5.5Zm2.25-.75a.75.75 0 0 0-.75.75v4.5c0 .414.336.75.75.75h7.5a.75.75 0 0 0 .75-.75v-4.5a.75.75 0 0 0-.75-.75h-7.5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else if tab.id === 'audio'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
d="M7.557 2.066A.75.75 0 0 1 8 2.75v10.5a.75.75 0 0 1-1.248.56L3.59 11H2a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h1.59l3.162-2.81a.75.75 0 0 1 .805-.124ZM12.95 3.05a.75.75 0 1 0-1.06 1.06 5.5 5.5 0 0 1 0 7.78.75.75 0 1 0 1.06 1.06 7 7 0 0 0 0-9.9Z"
|
||||
/>
|
||||
<path
|
||||
d="M10.828 5.172a.75.75 0 1 0-1.06 1.06 2.5 2.5 0 0 1 0 3.536.75.75 0 1 0 1.06 1.06 4 4 0 0 0 0-5.656Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if tab.id === 'images'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M2 4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4Zm10.5 5.707a.5.5 0 0 0-.146-.353l-1-1a.5.5 0 0 0-.708 0L9.354 9.646a.5.5 0 0 1-.708 0L6.354 7.354a.5.5 0 0 0-.708 0l-2 2a.5.5 0 0 0-.146.353V12a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V9.707ZM12 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else if tab.id === 'pipelines'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="size-4"
|
||||
>
|
||||
<path
|
||||
d="M11.644 1.59a.75.75 0 0 1 .712 0l9.75 5.25a.75.75 0 0 1 0 1.32l-9.75 5.25a.75.75 0 0 1-.712 0l-9.75-5.25a.75.75 0 0 1 0-1.32l9.75-5.25Z"
|
||||
/>
|
||||
<path
|
||||
d="m3.265 10.602 7.668 4.129a2.25 2.25 0 0 0 2.134 0l7.668-4.13 1.37.739a.75.75 0 0 1 0 1.32l-9.75 5.25a.75.75 0 0 1-.71 0l-9.75-5.25a.75.75 0 0 1 0-1.32l1.37-.738Z"
|
||||
/>
|
||||
<path
|
||||
d="m10.933 19.231-7.668-4.13-1.37.739a.75.75 0 0 0 0 1.32l9.75 5.25c.221.12.489.12.71 0l9.75-5.25a.75.75 0 0 0 0-1.32l-1.37-.738-7.668 4.13a2.25 2.25 0 0 1-2.134-.001Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if tab.id === 'db'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="w-4 h-4"
|
||||
>
|
||||
<path d="M8 7c3.314 0 6-1.343 6-3s-2.686-3-6-3-6 1.343-6 3 2.686 3 6 3Z" />
|
||||
<path
|
||||
d="M8 8.5c1.84 0 3.579-.37 4.914-1.037A6.33 6.33 0 0 0 14 6.78V8c0 1.657-2.686 3-6 3S2 9.657 2 8V6.78c.346.273.72.5 1.087.683C4.42 8.131 6.16 8.5 8 8.5Z"
|
||||
/>
|
||||
<path
|
||||
d="M8 12.5c1.84 0 3.579-.37 4.914-1.037.366-.183.74-.41 1.086-.684V12c0 1.657-2.686 3-6 3s-6-1.343-6-3v-1.22c.346.273.72.5 1.087.683C4.42 12.131 6.16 12.5 8 12.5Z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
<div class="self-center rounded-l-xl bg-transparent">
|
||||
<Search className="size-3.5" strokeWidth="1.5" />
|
||||
</div>
|
||||
<div class=" self-center">{$i18n.t(tab.title)}</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<label class="sr-only" for="search-input-admin-settings">{$i18n.t('Search')}</label>
|
||||
<input
|
||||
class="w-full py-1 text-xs bg-transparent dark:text-gray-300 outline-hidden"
|
||||
bind:value={search}
|
||||
id="search-input-admin-settings"
|
||||
on:input={searchDebounceHandler}
|
||||
placeholder={$i18n.t('Search')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex-1 mt-3 lg:mt-1 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll scrollbar-hidden"
|
||||
>
|
||||
{#if selectedTab === 'general'}
|
||||
<General
|
||||
saveHandler={async () => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
<!-- {$i18n.t('General')} -->
|
||||
<!-- {$i18n.t('Authentication')} -->
|
||||
<!-- {$i18n.t('Connections')} -->
|
||||
<!-- {$i18n.t('Models')} -->
|
||||
<!-- {$i18n.t('Sub-agents')} -->
|
||||
<!-- {$i18n.t('Evaluations')} -->
|
||||
<!-- {$i18n.t('Analytics')} -->
|
||||
<!-- {$i18n.t('Integrations')} -->
|
||||
<!-- {$i18n.t('Documents')} -->
|
||||
<!-- {$i18n.t('Web Search')} -->
|
||||
<!-- {$i18n.t('Code Execution')} -->
|
||||
<!-- {$i18n.t('Interface')} -->
|
||||
<!-- {$i18n.t('Audio')} -->
|
||||
<!-- {$i18n.t('Images')} -->
|
||||
<!-- {$i18n.t('Pipelines')} -->
|
||||
<!-- {$i18n.t('Database')} -->
|
||||
{#each filteredSettings as tab (tab.id)}
|
||||
<a
|
||||
id={tab.id}
|
||||
href={tab.route}
|
||||
draggable="false"
|
||||
class={tabButtonClass(selectedTab === tab.id)}
|
||||
>
|
||||
<AdminTabIcon id={tab.id} className="size-3.5" strokeWidth="2" />
|
||||
<div class="self-center truncate">{$i18n.t(tab.title)}</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
await tick();
|
||||
await config.set(await getBackendConfig());
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'authentication'}
|
||||
<Authentication />
|
||||
{:else if selectedTab === 'connections'}
|
||||
<Connections
|
||||
on:save={() => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'models'}
|
||||
<Models />
|
||||
{:else if selectedTab === 'evaluations'}
|
||||
<Evaluations />
|
||||
{:else if selectedTab === 'integrations'}
|
||||
<Integrations />
|
||||
{:else if selectedTab === 'documents'}
|
||||
<Documents
|
||||
on:save={async () => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
<div class="flex-1 min-h-0 p-4 lg:px-5 flex flex-col">
|
||||
<div class="w-full h-full min-h-0 flex flex-col">
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
{#if selectedTab === 'general'}
|
||||
<General
|
||||
saveHandler={async () => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
|
||||
await tick();
|
||||
await config.set(await getBackendConfig());
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'web'}
|
||||
<WebSearch
|
||||
saveHandler={async () => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
await tick();
|
||||
await config.set(await getBackendConfig());
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'authentication'}
|
||||
<Authentication />
|
||||
{:else if selectedTab === 'connections'}
|
||||
<Connections
|
||||
on:save={() => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'models'}
|
||||
<Models />
|
||||
{:else if selectedTab === 'subagents'}
|
||||
<Subagents />
|
||||
{:else if selectedTab === 'evaluations'}
|
||||
<Evaluations />
|
||||
{:else if selectedTab === 'analytics'}
|
||||
<Analytics />
|
||||
{:else if selectedTab === 'integrations'}
|
||||
<Integrations />
|
||||
{:else if selectedTab === 'documents'}
|
||||
<Documents
|
||||
on:save={async () => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
|
||||
await tick();
|
||||
await config.set(await getBackendConfig());
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'code-execution'}
|
||||
<CodeExecution
|
||||
saveHandler={async () => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
await tick();
|
||||
await config.set(await getBackendConfig());
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'web'}
|
||||
<WebSearch
|
||||
saveHandler={async () => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
|
||||
await tick();
|
||||
await config.set(await getBackendConfig());
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'interface'}
|
||||
<Interface
|
||||
on:save={() => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'audio'}
|
||||
<Audio
|
||||
saveHandler={() => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'images'}
|
||||
<Images
|
||||
on:save={() => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'db'}
|
||||
<Database
|
||||
saveHandler={() => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'pipelines'}
|
||||
<Pipelines
|
||||
saveHandler={() => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
await tick();
|
||||
await config.set(await getBackendConfig());
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'code-execution'}
|
||||
<CodeExecution
|
||||
saveHandler={async () => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
|
||||
await tick();
|
||||
await config.set(await getBackendConfig());
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'interface'}
|
||||
<Interface
|
||||
on:save={() => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'audio'}
|
||||
<Audio
|
||||
saveHandler={() => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'images'}
|
||||
<Images
|
||||
on:save={() => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'db'}
|
||||
<Database
|
||||
saveHandler={() => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
}}
|
||||
/>
|
||||
{:else if selectedTab === 'pipelines'}
|
||||
<Pipelines
|
||||
saveHandler={() => {
|
||||
toast.success($i18n.t('Settings saved successfully!'));
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
24
src/lib/components/admin/Settings/AdminSettingField.svelte
Normal file
24
src/lib/components/admin/Settings/AdminSettingField.svelte
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script lang="ts">
|
||||
export let label = '';
|
||||
export let description = '';
|
||||
export let forId = '';
|
||||
export let className = '';
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
{#if label}
|
||||
<label class="text-xs text-gray-600 dark:text-gray-400" for={forId || undefined}>
|
||||
{label}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class={label ? 'mt-1' : ''}>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
{#if description}
|
||||
<p class="mt-0.5 text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
22
src/lib/components/admin/Settings/AdminSettingRow.svelte
Normal file
22
src/lib/components/admin/Settings/AdminSettingRow.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts">
|
||||
export let label = '';
|
||||
export let description = '';
|
||||
export let className = '';
|
||||
export let labelClassName = '';
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 {className}">
|
||||
<div class="min-w-0 text-xs text-gray-600 dark:text-gray-400 {labelClassName}">
|
||||
<slot name="label">{label}</slot>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if description}
|
||||
<p class="-mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
|
||||
{description}
|
||||
</p>
|
||||
{/if}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue