From bcb71bb5206ac01d97a39fde8ecf0e0541dde636 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 29 Mar 2026 18:01:04 -0500 Subject: [PATCH 01/85] feat: tasks --- ...d5bedd151_add_tasks_and_summary_to_chat.py | 28 +++ backend/open_webui/models/chats.py | 31 ++++ backend/open_webui/tools/builtin.py | 166 ++++++++++++++++++ backend/open_webui/utils/tools.py | 5 + src/lib/components/chat/Chat.svelte | 9 + src/lib/components/chat/MessageInput.svelte | 10 ++ .../components/chat/Messages/Message.svelte | 1 + .../Messages/ResponseMessage/TaskList.svelte | 84 +++++++++ src/lib/components/icons/Collapse.svelte | 17 ++ src/lib/components/icons/Expand.svelte | 22 +-- src/lib/components/icons/TaskList.svelte | 18 ++ .../workspace/Models/BuiltinTools.svelte | 4 + 12 files changed, 382 insertions(+), 13 deletions(-) create mode 100644 backend/open_webui/migrations/versions/a3dd5bedd151_add_tasks_and_summary_to_chat.py create mode 100644 src/lib/components/chat/Messages/ResponseMessage/TaskList.svelte create mode 100644 src/lib/components/icons/Collapse.svelte create mode 100644 src/lib/components/icons/TaskList.svelte diff --git a/backend/open_webui/migrations/versions/a3dd5bedd151_add_tasks_and_summary_to_chat.py b/backend/open_webui/migrations/versions/a3dd5bedd151_add_tasks_and_summary_to_chat.py new file mode 100644 index 0000000000..20a3152cfe --- /dev/null +++ b/backend/open_webui/migrations/versions/a3dd5bedd151_add_tasks_and_summary_to_chat.py @@ -0,0 +1,28 @@ +"""Add tasks and summary columns to chat table + +Revision ID: a3dd5bedd151 +Revises: b2c3d4e5f6a7 +Create Date: 2026-03-29 22:15:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = 'a3dd5bedd151' +down_revision: Union[str, None] = 'b2c3d4e5f6a7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('chat', sa.Column('tasks', sa.JSON(), nullable=True)) + op.add_column('chat', sa.Column('summary', sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column('chat', 'summary') + op.drop_column('chat', 'tasks') diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index f19a5e7537..d9caf864ee 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -54,6 +54,9 @@ class Chat(Base): meta = Column(JSON, server_default='{}') folder_id = Column(Text, nullable=True) + tasks = Column(JSON, nullable=True) + summary = Column(Text, nullable=True) + __table_args__ = ( # Performance indexes for common queries # WHERE folder_id = ... @@ -87,6 +90,9 @@ class ChatModel(BaseModel): meta: dict = {} folder_id: Optional[str] = None + tasks: Optional[list] = None + summary: Optional[str] = None + class ChatFile(Base): __tablename__ = 'chat_file' @@ -161,6 +167,9 @@ class ChatResponse(BaseModel): meta: dict = {} folder_id: Optional[str] = None + tasks: Optional[list] = None + summary: Optional[str] = None + class ChatTitleIdResponse(BaseModel): id: str @@ -1552,5 +1561,27 @@ class ChatTable: return [ChatModel.model_validate(chat) for chat in all_chats] + def update_chat_tasks_by_id(self, id: str, tasks: list[dict]) -> Optional[ChatModel]: + """Update the tasks list on a chat.""" + try: + with get_db_context() as db: + chat = db.get(Chat, id) + if chat is None: + return None + chat.tasks = tasks + db.commit() + db.refresh(chat) + return ChatModel.model_validate(chat) + except Exception: + return None + + def get_chat_tasks_by_id(self, id: str) -> list[dict]: + """Read the tasks list from a chat (lightweight column query).""" + with get_db_context() as db: + result = db.query(Chat.tasks).filter_by(id=id).first() + if result is None or result[0] is None: + return [] + return result[0] + Chats = ChatTable() diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index f02a082c42..d9c93eb93c 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -2324,3 +2324,169 @@ async def view_skill( except Exception as e: log.exception(f'view_skill error: {e}') return json.dumps({'error': str(e)}) + + +# ============================================================================= +# TASK MANAGEMENT TOOLS +# ============================================================================= + +from pydantic import BaseModel, Field +from typing import Literal + +VALID_TASK_STATUSES = {'pending', 'in_progress', 'completed', 'cancelled'} + + +class TaskItem(BaseModel): + id: Optional[str] = Field(None, description="Unique identifier for the task. Auto-generated if omitted.") + content: Optional[str] = Field(None, description="Task description. Aliases: title, name, description.") + status: Literal['pending', 'in_progress', 'completed', 'cancelled'] = Field('pending', description="Task status.") + + +async def update_tasks( + tasks: list[TaskItem], + overwrite: bool = True, + __chat_id__: str = None, + __message_id__: str = None, + __event_emitter__: callable = None, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Create or update tasks for the current chat. By default replaces the + entire task list. Set overwrite=false to update individual tasks by id + while preserving the rest. + + Only ONE task should be in_progress at a time. Mark tasks completed + immediately when done. + + :param tasks: List of task items. Each must have: id (string, unique identifier), content (string, task description — required for new tasks), status (one of: pending, in_progress, completed, cancelled). + :param overwrite: If true (default), replaces the entire task list. If false, updates/adds tasks by id while keeping existing ones. + :return: JSON with the full task list and summary counts + """ + if __chat_id__ is None: + return json.dumps({'error': 'Chat context not available'}) + + try: + def _to_dict(task) -> dict: + """Convert TaskItem or dict to plain dict.""" + if hasattr(task, 'model_dump'): + d = task.model_dump(exclude_none=True) + # Include any extra fields the model sent + if hasattr(task, 'model_extra') and task.model_extra: + d.update(task.model_extra) + return d + return dict(task) if not isinstance(task, dict) else task + + def _resolve_content(d: dict) -> str: + """Accept content, title, name, or description as the task text.""" + for key in ('content', 'title', 'name', 'description'): + val = str(d.get(key, '')).strip() + if val: + return val + return '' + + def _resolve_id(d: dict, idx: int) -> str: + """Use provided id, or auto-generate from index.""" + item_id = str(d.get('id', '') or '').strip() + return item_id if item_id else str(idx + 1) + + if overwrite: + # Full replacement — validate and write + all_tasks = [] + for idx, task in enumerate(tasks): + d = _to_dict(task) + item_id = _resolve_id(d, idx) + content = _resolve_content(d) + if not content: + continue + + status = str(d.get('status', 'pending')).strip().lower() + if status not in VALID_TASK_STATUSES: + status = 'pending' + + all_tasks.append({ + 'id': item_id, + 'content': content, + 'status': status, + }) + else: + # Partial update — merge by id + existing_tasks = Chats.get_chat_tasks_by_id(__chat_id__) + existing_by_id = {t['id']: t for t in existing_tasks} + + seen_ids = set() + for idx, task in enumerate(tasks): + d = _to_dict(task) + item_id = _resolve_id(d, len(existing_tasks) + idx) + + seen_ids.add(item_id) + + if item_id in existing_by_id: + resolved = _resolve_content(d) + if resolved: + existing_by_id[item_id]['content'] = resolved + status = str(d.get('status', '')).strip().lower() + if status and status in VALID_TASK_STATUSES: + existing_by_id[item_id]['status'] = status + else: + content = _resolve_content(d) + if not content: + continue + + status = str(d.get('status', 'pending')).strip().lower() + if status not in VALID_TASK_STATUSES: + status = 'pending' + + existing_by_id[item_id] = { + 'id': item_id, + 'content': content, + 'status': status, + } + + # Preserve order of existing, append new + all_tasks = [] + for t in existing_tasks: + if t['id'] in existing_by_id: + all_tasks.append(existing_by_id[t['id']]) + for item_id in seen_ids: + if not any(t['id'] == item_id for t in existing_tasks): + all_tasks.append(existing_by_id[item_id]) + + # Persist to DB + Chats.update_chat_tasks_by_id(__chat_id__, all_tasks) + + # Emit to frontend for real-time UI update + if __event_emitter__: + await __event_emitter__( + { + 'type': 'chat:message:tasks', + 'data': { + 'tasks': all_tasks, + }, + } + ) + + # Build summary counts + pending = sum(1 for t in all_tasks if t['status'] == 'pending') + in_progress = sum(1 for t in all_tasks if t['status'] == 'in_progress') + completed = sum(1 for t in all_tasks if t['status'] == 'completed') + cancelled = sum(1 for t in all_tasks if t['status'] == 'cancelled') + + return json.dumps( + { + 'tasks': all_tasks, + 'summary': { + 'total': len(all_tasks), + 'pending': pending, + 'in_progress': in_progress, + 'completed': completed, + 'cancelled': cancelled, + }, + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'update_tasks error: {e}') + return json.dumps({'error': str(e)}) + + diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 226830a1fa..391c45dcbf 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -85,6 +85,7 @@ from open_webui.tools.builtin import ( view_file, view_knowledge_file, view_skill, + update_tasks, ) import copy @@ -503,6 +504,10 @@ def get_builtin_tools( if extra_params.get('__skill_ids__'): builtin_functions.append(view_skill) + # Task management - break down complex work into trackable steps + if is_builtin_tool_enabled('tasks'): + builtin_functions.append(update_tasks) + for func in builtin_functions: callable = get_async_tool_function_and_apply_extra_params( func, diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index 45c2e01e2b..19dae7cd3e 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -159,6 +159,8 @@ let chat = null; let tags = []; + let chatTasks = []; + let history = { messages: {}, currentId: null @@ -449,6 +451,8 @@ message.content = data.content; } else if (type === 'chat:message:files' || type === 'files') { message.files = data.files; + } else if (type === 'chat:message:tasks') { + chatTasks = data.tasks; } else if (type === 'chat:message:embeds' || type === 'embeds') { message.embeds = data.embeds; @@ -1156,6 +1160,7 @@ chatFiles = []; params = {}; taskIds = null; + chatTasks = []; if ($page.url.searchParams.get('youtube')) { await uploadWeb(`https://www.youtube.com/watch?v=${$page.url.searchParams.get('youtube')}`); @@ -1268,6 +1273,9 @@ params = chatContent?.params ?? {}; chatFiles = chatContent?.files ?? []; + // Load tasks from chat-level DB field + chatTasks = chat?.tasks ?? []; + autoScroll = true; await tick(); @@ -2863,6 +2871,7 @@ {createMessagePair} {onUpload} messageQueue={$chatRequestQueues[$chatId] ?? []} + {chatTasks} onQueueSendNow={async (id) => { const queue = $chatRequestQueues[$chatId] ?? []; const item = queue.find((m) => m.id === id); diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 602200deed..2768cb481e 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -99,6 +99,7 @@ import InputModal from '../common/InputModal.svelte'; import Expand from '../icons/Expand.svelte'; import QueuedMessageItem from './MessageInput/QueuedMessageItem.svelte'; + import TaskList from './Messages/ResponseMessage/TaskList.svelte'; const i18n = getContext('i18n'); @@ -140,6 +141,8 @@ export let onQueueEdit: (id: string) => void = () => {}; export let onQueueDelete: (id: string) => void = () => {}; + export let chatTasks = []; + let inputContent = null; let showInputVariablesModal = false; @@ -1217,6 +1220,13 @@ on:click={() => createMessagePair(prompt)} /> + + {#if chatTasks.length > 0} +
+ +
+ {/if} + {#if messageQueue.length > 0}
+ import { getContext } from 'svelte'; + import { slide } from 'svelte/transition'; + import TaskListIcon from '$lib/components/icons/TaskList.svelte'; + import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; + import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; + + const i18n = getContext('i18n'); + + export let tasks: Array<{ id: string; content: string; status: string }> = []; + + let collapsed = false; + + $: completedCount = tasks.filter((t) => t.status === 'completed').length; + $: totalCount = tasks.length; + $: hasActive = tasks.some((t) => t.status === 'pending' || t.status === 'in_progress'); + + +{#if tasks.length > 0 && hasActive} +
+ +
+
+ + + {completedCount} {$i18n.t('out of')} {totalCount} {$i18n.t('tasks completed')} + +
+ + +
+ + + {#if !collapsed} +
+ {#each tasks as task, idx (task.id)} +
+ + {#if task.status === 'completed'} + + + + {:else if task.status === 'in_progress'} + + + + {:else if task.status === 'cancelled'} + + + + {:else} + + + + {/if} + + + {idx + 1}. {task.content} + +
+ {/each} +
+ {/if} +
+{/if} diff --git a/src/lib/components/icons/Collapse.svelte b/src/lib/components/icons/Collapse.svelte new file mode 100644 index 0000000000..05ce616dad --- /dev/null +++ b/src/lib/components/icons/Collapse.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/icons/Expand.svelte b/src/lib/components/icons/Expand.svelte index e11230aa37..a645436dcd 100644 --- a/src/lib/components/icons/Expand.svelte +++ b/src/lib/components/icons/Expand.svelte @@ -1,21 +1,17 @@ + + + + + diff --git a/src/lib/components/icons/TaskList.svelte b/src/lib/components/icons/TaskList.svelte new file mode 100644 index 0000000000..850660c1df --- /dev/null +++ b/src/lib/components/icons/TaskList.svelte @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/src/lib/components/workspace/Models/BuiltinTools.svelte b/src/lib/components/workspace/Models/BuiltinTools.svelte index b1f925302b..cc94cddb33 100644 --- a/src/lib/components/workspace/Models/BuiltinTools.svelte +++ b/src/lib/components/workspace/Models/BuiltinTools.svelte @@ -42,6 +42,10 @@ code_interpreter: { label: $i18n.t('Code Interpreter'), description: $i18n.t('Execute code') + }, + tasks: { + label: $i18n.t('Task Management'), + description: $i18n.t('Break down complex requests into trackable steps') } }; From 2388dd7dc3530b5dd5419c5d0bb1bcdcb7544099 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 29 Mar 2026 18:40:09 -0500 Subject: [PATCH 02/85] refac --- .../Messages/ResponseMessage/TaskList.svelte | 39 +++++++++++++--- .../chat/ModelSelector/ModelItem.svelte | 2 + .../chat/ModelSelector/ModelItemMenu.svelte | 32 +++++++++++++ .../chat/ModelSelector/Selector.svelte | 46 +++++++++++++++++++ 4 files changed, 113 insertions(+), 6 deletions(-) diff --git a/src/lib/components/chat/Messages/ResponseMessage/TaskList.svelte b/src/lib/components/chat/Messages/ResponseMessage/TaskList.svelte index b8541a3ffa..79a9f42d5d 100644 --- a/src/lib/components/chat/Messages/ResponseMessage/TaskList.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage/TaskList.svelte @@ -18,7 +18,7 @@ {#if tasks.length > 0 && hasActive}
@@ -26,7 +26,10 @@
- {completedCount} {$i18n.t('out of')} {totalCount} {$i18n.t('tasks completed')} + {completedCount} + {$i18n.t('out of')} + {totalCount} + {$i18n.t('tasks completed')}
@@ -50,19 +53,43 @@
{#if task.status === 'completed'} - + {:else if task.status === 'in_progress'} - + {:else if task.status === 'cancelled'} - + {:else} - + {/if} diff --git a/src/lib/components/chat/ModelSelector/ModelItem.svelte b/src/lib/components/chat/ModelSelector/ModelItem.svelte index b5a29ffe96..cd5fe453c3 100644 --- a/src/lib/components/chat/ModelSelector/ModelItem.svelte +++ b/src/lib/components/chat/ModelSelector/ModelItem.svelte @@ -26,6 +26,7 @@ export let unloadModelHandler: (modelValue: string) => void = () => {}; export let pinModelHandler: (modelId: string) => void = () => {}; + export let deleteModelHandler: (model: any) => void = () => {}; export let onClick: () => void = () => {}; @@ -255,6 +256,7 @@ bind:show={showMenu} model={item.model} {pinModelHandler} + {deleteModelHandler} copyLinkHandler={() => { copyLinkHandler(item.model); }} diff --git a/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte b/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte index 65d90bf2a3..d0f46b2624 100644 --- a/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte +++ b/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte @@ -18,6 +18,7 @@ export let pinModelHandler: (modelId: string) => void = () => {}; export let copyLinkHandler: Function = () => {}; + export let deleteModelHandler: Function = () => {}; export let onClose: Function = () => {}; @@ -66,6 +67,37 @@
{$i18n.t('Edit')}
+ {#if $user?.role === 'admin' && model?.owned_by === 'ollama'} + + {/if} +
{/if} diff --git a/src/lib/components/chat/ModelSelector/Selector.svelte b/src/lib/components/chat/ModelSelector/Selector.svelte index 1a7a3dfc97..3d316562d5 100644 --- a/src/lib/components/chat/ModelSelector/Selector.svelte +++ b/src/lib/components/chat/ModelSelector/Selector.svelte @@ -8,6 +8,7 @@ dayjs.extend(relativeTime); import Spinner from '$lib/components/common/Spinner.svelte'; + import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import { flyAndScale } from '$lib/utils/transitions'; import { createEventDispatcher, onMount, getContext, tick } from 'svelte'; @@ -375,6 +376,41 @@ } }; + let showDeleteConfirm = false; + let deleteModelTarget: any = null; + + const deleteModelHandler = async (model: any) => { + deleteModelTarget = model; + showDeleteConfirm = true; + }; + + const confirmDeleteModel = async () => { + const model = deleteModelTarget; + if (!model) return; + + const res = await deleteModel(localStorage.token, model.id).catch((error) => { + toast.error($i18n.t('Error deleting model: {{error}}', { error })); + }); + + if (res) { + toast.success($i18n.t('Model {{modelName}} deleted successfully', { modelName: model.name ?? model.id })); + + // If the deleted model was selected, clear the selection + if (value === model.id) { + value = ''; + } + + models.set( + await getModels( + localStorage.token, + $config?.features?.enable_direct_connections && ($settings?.directConnections ?? null) + ) + ); + } + + deleteModelTarget = null; + }; + const ITEM_HEIGHT = 42; const OVERSCAN = 10; @@ -388,6 +424,15 @@ ); + { + confirmDeleteModel(); + }} +/> + { @@ -646,6 +691,7 @@ {value} {pinModelHandler} {unloadModelHandler} + {deleteModelHandler} onClick={() => { value = item.value; selectedModelIdx = index; From 2040095050056d01c61aa597c5010445449a42c7 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 29 Mar 2026 19:01:50 -0500 Subject: [PATCH 03/85] enh: shortcode emojis --- .../components/channel/MessageInput.svelte | 19 ++++ src/lib/components/chat/MessageInput.svelte | 21 +++- .../MessageInput/CommandSuggestionList.svelte | 17 ++++ .../chat/MessageInput/Commands/Emojis.svelte | 99 +++++++++++++++++++ 4 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 src/lib/components/chat/MessageInput/Commands/Emojis.svelte diff --git a/src/lib/components/channel/MessageInput.svelte b/src/lib/components/channel/MessageInput.svelte index 0a7478b89d..ec00a944d0 100644 --- a/src/lib/components/channel/MessageInput.svelte +++ b/src/lib/components/channel/MessageInput.svelte @@ -622,6 +622,25 @@ } } }) + }, + { + char: ':', + allowSpaces: false, + command: ({ editor, range, props }) => { + // Convert the Unicode hex codepoint (e.g. "1F44B") to the actual emoji character (👋) + const codepoint = props.id; + const emoji = String.fromCodePoint(parseInt(codepoint, 16)); + editor.chain().focus().deleteRange(range).insertContent(emoji).run(); + }, + render: getSuggestionRenderer(CommandSuggestionList, { + i18n, + onSelect: (e) => { + document.getElementById('chat-input')?.focus(); + }, + + insertTextHandler: insertTextAtCursor, + onUpload: () => {} + }) } ]; loaded = true; diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 2768cb481e..e826f1c68f 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -407,7 +407,7 @@ let command = ''; export let showCommands = false; $: showCommands = - ['/', '#', '@', '$'].includes(command?.charAt(0)) || '\\#' === command?.slice(0, 2); + ['/', '#', '@', '$', ':'].includes(command?.charAt(0)) || '\\#' === command?.slice(0, 2); let suggestions = null; let showTools = false; @@ -1030,6 +1030,25 @@ document.getElementById('chat-input')?.focus(); }, + insertTextHandler: insertTextAtCursor, + onUpload: () => {} + }) + }, + { + char: ':', + allowSpaces: false, + command: ({ editor, range, props }) => { + // Convert the Unicode hex codepoint (e.g. "1F44B") to the actual emoji character (👋) + const codepoint = props.id; + const emoji = String.fromCodePoint(parseInt(codepoint, 16)); + editor.chain().focus().deleteRange(range).insertContent(emoji).run(); + }, + render: getSuggestionRenderer(CommandSuggestionList, { + i18n, + onSelect: (e) => { + document.getElementById('chat-input')?.focus(); + }, + insertTextHandler: insertTextAtCursor, onUpload: () => {} }) diff --git a/src/lib/components/chat/MessageInput/CommandSuggestionList.svelte b/src/lib/components/chat/MessageInput/CommandSuggestionList.svelte index 5308d4797f..3032ad1916 100644 --- a/src/lib/components/chat/MessageInput/CommandSuggestionList.svelte +++ b/src/lib/components/chat/MessageInput/CommandSuggestionList.svelte @@ -3,6 +3,7 @@ import Knowledge from './Commands/Knowledge.svelte'; import Models from './Commands/Models.svelte'; import Skills from './Commands/Skills.svelte'; + import Emojis from './Commands/Emojis.svelte'; export let char = ''; export let query = ''; @@ -135,6 +136,22 @@ } }} /> + {:else if char === ':'} + { + const { type, data } = e; + + if (type === 'emoji') { + command({ + id: data.name, + label: data.shortCodes[0] + }); + } + }} + /> {/if}
diff --git a/src/lib/components/chat/MessageInput/Commands/Emojis.svelte b/src/lib/components/chat/MessageInput/Commands/Emojis.svelte new file mode 100644 index 0000000000..fd06bf8c6a --- /dev/null +++ b/src/lib/components/chat/MessageInput/Commands/Emojis.svelte @@ -0,0 +1,99 @@ + + +{#if filteredItems.length > 0} +
+ {$i18n.t('Emojis')} +
+ + {#each filteredItems as emoji, emojiIdx} + + {/each} +{/if} From 6c2b2f2c3e5f4c86273c408b901b5af9b3c7d6d6 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 29 Mar 2026 19:01:53 -0500 Subject: [PATCH 04/85] refac --- src/lib/components/common/RichTextInput/suggestions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/common/RichTextInput/suggestions.ts b/src/lib/components/common/RichTextInput/suggestions.ts index 7793b99337..4fc8132b72 100644 --- a/src/lib/components/common/RichTextInput/suggestions.ts +++ b/src/lib/components/common/RichTextInput/suggestions.ts @@ -109,7 +109,7 @@ export function getSuggestionRenderer(Component: any, ComponentProps = {}) { popup = null; try { - component.$destroy(); + component?.$destroy(); } catch (e) { console.error('Error unmounting component:', e); } From 012ce95f27d57bea8911bd63bfb923443c5797ae Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 29 Mar 2026 19:09:13 -0500 Subject: [PATCH 05/85] enh: swipe to reply --- .../channel/Messages/Message.svelte | 151 ++++++++++++++++-- src/lib/components/channel/Navbar.svelte | 60 +++---- 2 files changed, 170 insertions(+), 41 deletions(-) diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte index c1d1414f43..3923f9fb0d 100644 --- a/src/lib/components/channel/Messages/Message.svelte +++ b/src/lib/components/channel/Messages/Message.svelte @@ -65,6 +65,67 @@ let editedContent = null; let showDeleteConfirmDialog = false; + // Swipe-to-reply state + let swipeStartX = 0; + let swipeStartY = 0; + let swipeOffsetX = 0; + let isSwiping = false; + let swipeLocked = false; // locked to horizontal once determined + let swipeMessageEl: HTMLElement | null = null; + + const SWIPE_THRESHOLD = 60; + const SWIPE_MAX = 100; + const SWIPE_DEAD_ZONE = 10; + + const handleTouchStart = (e: TouchEvent) => { + if (disabled || edit || !onReply) return; + const touch = e.touches[0]; + swipeStartX = touch.clientX; + swipeStartY = touch.clientY; + swipeOffsetX = 0; + isSwiping = false; + swipeLocked = false; + }; + + const handleTouchMove = (e: TouchEvent) => { + if (disabled || edit || !onReply) return; + const touch = e.touches[0]; + const deltaX = touch.clientX - swipeStartX; + const deltaY = touch.clientY - swipeStartY; + + // Determine swipe direction from dead zone + if (!swipeLocked && (Math.abs(deltaX) > SWIPE_DEAD_ZONE || Math.abs(deltaY) > SWIPE_DEAD_ZONE)) { + if (Math.abs(deltaY) > Math.abs(deltaX)) { + // Vertical scroll — abort swipe tracking + isSwiping = false; + swipeLocked = true; + return; + } + // Horizontal swipe — lock in + swipeLocked = true; + isSwiping = true; + } + + if (!isSwiping) return; + + // Only allow right swipe + const clampedX = Math.max(0, deltaX); + // Dampen the motion beyond threshold for a rubber-band feel + swipeOffsetX = clampedX <= SWIPE_THRESHOLD + ? clampedX + : SWIPE_THRESHOLD + (clampedX - SWIPE_THRESHOLD) * 0.3; + swipeOffsetX = Math.min(swipeOffsetX, SWIPE_MAX); + }; + + const handleTouchEnd = () => { + if (isSwiping && swipeOffsetX >= SWIPE_THRESHOLD && onReply) { + onReply(message); + } + swipeOffsetX = 0; + isSwiping = false; + swipeLocked = false; + }; + const loadMessageData = async () => { if (message && message?.data === true) { const res = await getMessageData(localStorage.token, channel?.id, message.id); @@ -92,20 +153,39 @@ {#if message}
+ + {#if swipeOffsetX > 0} +
+
= SWIPE_THRESHOLD}> + +
+
+ {/if} + +
{#if !edit && !disabled}
+
{/if} @@ -561,4 +642,48 @@ background-color: transparent; } } + + /* Swipe-to-reply styles */ + .swipe-reply-wrapper { + touch-action: pan-y; + } + + .swipe-reply-indicator { + position: absolute; + left: 8px; + top: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 5; + pointer-events: none; + } + + .swipe-reply-icon { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background-color: rgba(128, 128, 128, 0.15); + color: rgba(128, 128, 128, 0.8); + transition: background-color 0.15s, color 0.15s; + } + + .swipe-reply-icon--active { + background-color: rgba(59, 130, 246, 0.2); + color: rgb(59, 130, 246); + } + + :global(.dark) .swipe-reply-icon { + background-color: rgba(200, 200, 200, 0.1); + color: rgba(200, 200, 200, 0.6); + } + + :global(.dark) .swipe-reply-icon--active { + background-color: rgba(96, 165, 250, 0.2); + color: rgb(96, 165, 250); + } diff --git a/src/lib/components/channel/Navbar.svelte b/src/lib/components/channel/Navbar.svelte index a9c8043600..660b2175cc 100644 --- a/src/lib/components/channel/Navbar.svelte +++ b/src/lib/components/channel/Navbar.svelte @@ -153,7 +153,9 @@ {/if}
-
+
{#if channel} @@ -173,17 +175,17 @@ {#if channel?.user_count !== undefined} - + + +
{/if}
From 64da99a32218171d41b3af5acc14783de8dbdf49 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 29 Mar 2026 19:17:12 -0500 Subject: [PATCH 06/85] refac --- src/lib/components/common/EmojiPicker.svelte | 49 +++++++++++++++++++- src/lib/stores/index.ts | 1 + 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/lib/components/common/EmojiPicker.svelte b/src/lib/components/common/EmojiPicker.svelte index aa3df2e584..de8ba54acf 100644 --- a/src/lib/components/common/EmojiPicker.svelte +++ b/src/lib/components/common/EmojiPicker.svelte @@ -11,6 +11,9 @@ import emojiGroups from '$lib/emoji-groups.json'; import emojiShortCodes from '$lib/emoji-shortcodes.json'; + import { settings } from '$lib/stores'; + import { updateUserSettings } from '$lib/apis/users'; + const i18n = getContext('i18n'); export let onClose = () => {}; @@ -20,12 +23,37 @@ export let user = null; export let selected = null; + const MAX_RECENT = 30; + let show = false; let emojis = emojiShortCodes; let search = ''; let flattenedEmojis = []; let emojiRows = []; + let saveDebounceTimer: ReturnType | null = null; + + $: recentEmojiNames = ($settings?.recentEmojis ?? []) + .filter((name) => emojiShortCodes[name]) + .slice(0, MAX_RECENT); + + function saveRecentEmoji(emojiName: string) { + // Remove if already present, then prepend + const updated = [emojiName, ...recentEmojiNames.filter((n) => n !== emojiName)].slice( + 0, + MAX_RECENT + ); + + // Update store immediately (reactive UI) + settings.set({ ...$settings, recentEmojis: updated }); + + // Debounce backend save (avoid API spam on rapid picks) + if (saveDebounceTimer) clearTimeout(saveDebounceTimer); + saveDebounceTimer = setTimeout(async () => { + await updateUserSettings(localStorage.token, { ui: { ...$settings, recentEmojis: updated } }); + }, 1000); + } + // Reactive statement to filter the emojis based on search query $: { if (search) { @@ -55,6 +83,22 @@ // Flatten emoji groups and group them into rows of 8 for virtual scrolling $: { flattenedEmojis = []; + + // Add "Recently Used" group first (only when not searching) + if (!search && recentEmojiNames.length > 0) { + flattenedEmojis.push({ type: 'group', label: $i18n.t('Recently Used') }); + flattenedEmojis.push( + ...recentEmojiNames.map((emoji) => ({ + type: 'emoji', + name: emoji, + shortCodes: + typeof emojiShortCodes[emoji] === 'string' + ? [emojiShortCodes[emoji]] + : emojiShortCodes[emoji] + })) + ); + } + Object.keys(emojiGroups).forEach((group) => { const groupEmojis = emojiGroups[group].filter((emoji) => emojis[emoji]); if (groupEmojis.length > 0) { @@ -97,6 +141,7 @@ // Handle emoji selection function selectEmoji(emoji) { const selectedCode = emoji.shortCodes[0]; + saveRecentEmoji(emoji.name); if (selected === selectedCode) { onSubmit(null); } else { @@ -140,10 +185,10 @@ {:else}
-
+
{#if item.length === 1 && item[0].type === 'group'} -
+
{item[0].label}
{:else} diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index a6697f082d..5cd7f0d632 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -219,6 +219,7 @@ type Settings = { chatDirection?: 'LTR' | 'RTL' | 'auto'; ctrlEnterToSend?: boolean; renderMarkdownInPreviews?: boolean; + recentEmojis?: string[]; system?: string; seed?: number; From edb8971c7dbd974322c3207c4655ff66479c3ee2 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 29 Mar 2026 19:30:38 -0500 Subject: [PATCH 07/85] refac --- .../chat/MessageInput/InputMenu.svelte | 52 ++++++++ .../chat/MessageInput/InputMenu/Files.svelte | 120 ++++++++++++++++++ .../components/chat/Messages/CodeBlock.svelte | 10 +- 3 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 src/lib/components/chat/MessageInput/InputMenu/Files.svelte diff --git a/src/lib/components/chat/MessageInput/InputMenu.svelte b/src/lib/components/chat/MessageInput/InputMenu.svelte index 1b4f980256..e32435d3ae 100644 --- a/src/lib/components/chat/MessageInput/InputMenu.svelte +++ b/src/lib/components/chat/MessageInput/InputMenu.svelte @@ -22,6 +22,7 @@ import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte'; import PageEdit from '$lib/components/icons/PageEdit.svelte'; import Chats from './InputMenu/Chats.svelte'; + import Files from './InputMenu/Files.svelte'; import Notes from './InputMenu/Notes.svelte'; import Knowledge from './InputMenu/Knowledge.svelte'; import AttachWebpageModal from './AttachWebpageModal.svelte'; @@ -204,6 +205,38 @@ + + + + {#if $config?.features?.enable_notes ?? false}
+ {:else if tab === 'files'} +
+ + + +
{:else if tab === 'chats'}
+ {/each} + + {#if !allItemsLoaded} + { + if (!itemsLoading) { + loadMoreItems(); + } + }} + > +
+ +
{$i18n.t('Loading...')}
+
+
+ {/if} +
+ {/if} +{:else} +
+ +
+{/if} diff --git a/src/lib/components/chat/Messages/CodeBlock.svelte b/src/lib/components/chat/Messages/CodeBlock.svelte index 1be70cdeb5..308b15aa9c 100644 --- a/src/lib/components/chat/Messages/CodeBlock.svelte +++ b/src/lib/components/chat/Messages/CodeBlock.svelte @@ -469,7 +469,7 @@ {/if} {:else}
@@ -599,17 +599,17 @@ {#if executing || stdout || stderr || result || files}
{#if executing}
-
{$i18n.t('STDOUT/STDERR')}
+
{$i18n.t('STDOUT/STDERR')}
{$i18n.t('Running...')}
{:else} {#if stdout || stderr}
-
{$i18n.t('STDOUT/STDERR')}
+
{$i18n.t('STDOUT/STDERR')}
-
{$i18n.t('RESULT')}
+
{$i18n.t('RESULT')}
{#if result}
{`${JSON.stringify(result)}`}
{/if} From 1b1d85fe2e3a5505e371848105bf70c140d77f5e Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 29 Mar 2026 19:42:10 -0500 Subject: [PATCH 08/85] refac --- backend/open_webui/tools/builtin.py | 48 +++++++++++++++++------------ backend/open_webui/utils/tools.py | 4 +-- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index d9c93eb93c..2c79be9074 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -393,7 +393,8 @@ async def execute_code( if CODE_INTERPRETER_BLOCKED_MODULES: import textwrap - blocking_code = textwrap.dedent(f""" + blocking_code = textwrap.dedent( + f""" import builtins BLOCKED_MODULES = {CODE_INTERPRETER_BLOCKED_MODULES} @@ -409,7 +410,8 @@ async def execute_code( return _real_import(name, globals, locals, fromlist, level) builtins.__import__ = restricted_import - """) + """ + ) code = blocking_code + '\n' + code engine = getattr(__request__.app.state.config, 'CODE_INTERPRETER_ENGINE', 'pyodide') @@ -2342,7 +2344,7 @@ class TaskItem(BaseModel): status: Literal['pending', 'in_progress', 'completed', 'cancelled'] = Field('pending', description="Task status.") -async def update_tasks( +async def tasks( tasks: list[TaskItem], overwrite: bool = True, __chat_id__: str = None, @@ -2352,14 +2354,21 @@ async def update_tasks( __user__: dict = None, ) -> str: """ - Create or update tasks for the current chat. By default replaces the - entire task list. Set overwrite=false to update individual tasks by id - while preserving the rest. + Create or update a checklist of tasks tied to this chat. + Useful whenever a request involves multiple pieces of work that + should be tracked individually. - Only ONE task should be in_progress at a time. Mark tasks completed - immediately when done. + By default the entire list is replaced (overwrite=true). Set + overwrite=false to patch specific items by id without discarding + the rest. - :param tasks: List of task items. Each must have: id (string, unique identifier), content (string, task description — required for new tasks), status (one of: pending, in_progress, completed, cancelled). + Each item carries an id, content string, and a status field + (pending, in_progress, completed, or cancelled). Order reflects + priority. Only one item should be in_progress at a time; mark + it completed before moving on, or cancel and replace it if the + approach changes. + + :param tasks: List of task items. Each must have: id (string, unique identifier), content (string, task description, required for new tasks), status (one of: pending, in_progress, completed, cancelled). :param overwrite: If true (default), replaces the entire task list. If false, updates/adds tasks by id while keeping existing ones. :return: JSON with the full task list and summary counts """ @@ -2367,6 +2376,7 @@ async def update_tasks( return json.dumps({'error': 'Chat context not available'}) try: + def _to_dict(task) -> dict: """Convert TaskItem or dict to plain dict.""" if hasattr(task, 'model_dump'): @@ -2391,7 +2401,7 @@ async def update_tasks( return item_id if item_id else str(idx + 1) if overwrite: - # Full replacement — validate and write + # Full replacement - validate and write all_tasks = [] for idx, task in enumerate(tasks): d = _to_dict(task) @@ -2404,13 +2414,15 @@ async def update_tasks( if status not in VALID_TASK_STATUSES: status = 'pending' - all_tasks.append({ - 'id': item_id, - 'content': content, - 'status': status, - }) + all_tasks.append( + { + 'id': item_id, + 'content': content, + 'status': status, + } + ) else: - # Partial update — merge by id + # Partial update - merge by id existing_tasks = Chats.get_chat_tasks_by_id(__chat_id__) existing_by_id = {t['id']: t for t in existing_tasks} @@ -2486,7 +2498,5 @@ async def update_tasks( ensure_ascii=False, ) except Exception as e: - log.exception(f'update_tasks error: {e}') + log.exception(f'tasks error: {e}') return json.dumps({'error': str(e)}) - - diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 391c45dcbf..e266e9d9c8 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -85,7 +85,7 @@ from open_webui.tools.builtin import ( view_file, view_knowledge_file, view_skill, - update_tasks, + tasks, ) import copy @@ -506,7 +506,7 @@ def get_builtin_tools( # Task management - break down complex work into trackable steps if is_builtin_tool_enabled('tasks'): - builtin_functions.append(update_tasks) + builtin_functions.append(tasks) for func in builtin_functions: callable = get_async_tool_function_and_apply_extra_params( From 4777f4fa3256a04d837338797327653273ffa94a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 29 Mar 2026 19:48:23 -0500 Subject: [PATCH 09/85] refac --- backend/open_webui/tools/builtin.py | 36 ++++++++++++++++------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 2c79be9074..252d1c6c48 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -2345,7 +2345,7 @@ class TaskItem(BaseModel): async def tasks( - tasks: list[TaskItem], + tasks: Optional[list[TaskItem]] = None, overwrite: bool = True, __chat_id__: str = None, __message_id__: str = None, @@ -2356,7 +2356,8 @@ async def tasks( """ Create or update a checklist of tasks tied to this chat. Useful whenever a request involves multiple pieces of work that - should be tracked individually. + should be tracked individually. Call without arguments to retrieve + the existing list. By default the entire list is replaced (overwrite=true). Set overwrite=false to patch specific items by id without discarding @@ -2368,7 +2369,7 @@ async def tasks( it completed before moving on, or cancel and replace it if the approach changes. - :param tasks: List of task items. Each must have: id (string, unique identifier), content (string, task description, required for new tasks), status (one of: pending, in_progress, completed, cancelled). + :param tasks: Optional list of task items. Each item: id (string), content (string, required for new tasks), status (pending|in_progress|completed|cancelled). Leave empty to fetch without modifying. :param overwrite: If true (default), replaces the entire task list. If false, updates/adds tasks by id while keeping existing ones. :return: JSON with the full task list and summary counts """ @@ -2400,7 +2401,10 @@ async def tasks( item_id = str(d.get('id', '') or '').strip() return item_id if item_id else str(idx + 1) - if overwrite: + if tasks is None: + # Read-only - return current list + all_tasks = Chats.get_chat_tasks_by_id(__chat_id__) + elif overwrite: # Full replacement - validate and write all_tasks = [] for idx, task in enumerate(tasks): @@ -2464,19 +2468,19 @@ async def tasks( if not any(t['id'] == item_id for t in existing_tasks): all_tasks.append(existing_by_id[item_id]) - # Persist to DB - Chats.update_chat_tasks_by_id(__chat_id__, all_tasks) + # Persist to DB and emit (skip for read-only) + if tasks is not None: + Chats.update_chat_tasks_by_id(__chat_id__, all_tasks) - # Emit to frontend for real-time UI update - if __event_emitter__: - await __event_emitter__( - { - 'type': 'chat:message:tasks', - 'data': { - 'tasks': all_tasks, - }, - } - ) + if __event_emitter__: + await __event_emitter__( + { + 'type': 'chat:message:tasks', + 'data': { + 'tasks': all_tasks, + }, + } + ) # Build summary counts pending = sum(1 for t in all_tasks if t['status'] == 'pending') From a06685a47b89fb19dd6124fbe391ff78b54f451d Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 29 Mar 2026 21:01:10 -0500 Subject: [PATCH 10/85] refac --- backend/open_webui/routers/terminals.py | 4 ++ backend/open_webui/utils/tools.py | 7 +++ src/lib/apis/index.ts | 4 +- src/lib/apis/terminal/index.ts | 21 ++++--- src/lib/components/chat/ChatControls.svelte | 4 +- src/lib/components/chat/FileNav.svelte | 66 +++++++++++++++------ src/lib/components/chat/XTerminal.svelte | 9 ++- src/routes/+layout.svelte | 7 ++- 8 files changed, 86 insertions(+), 36 deletions(-) diff --git a/backend/open_webui/routers/terminals.py b/backend/open_webui/routers/terminals.py index 59f1f3ab48..34d5eb96d6 100644 --- a/backend/open_webui/routers/terminals.py +++ b/backend/open_webui/routers/terminals.py @@ -105,6 +105,10 @@ async def proxy_terminal( target_url += f'?{request.query_params}' headers = {'X-User-Id': user.id} + # Forward per-session cwd tracking header + session_id = request.headers.get('x-session-id') + if session_id: + headers['X-Session-Id'] = session_id cookies = {} auth_type = connection.get('auth_type', 'bearer') diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index e266e9d9c8..377a81d749 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -1009,6 +1009,13 @@ async def get_terminal_tools( # auth_type == "none": no Authorization header system_prompt = server_data.get('system_prompt') + + # Use chat_id as the per-session key for cwd tracking + metadata = extra_params.get('__metadata__', {}) + session_id = metadata.get('chat_id') + if session_id: + headers['X-Session-Id'] = session_id + terminal_cwd = await get_terminal_cwd(connection.get('url', ''), headers, cookies) tools_dict = {} diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index b07d524cba..c46c86b801 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -454,7 +454,8 @@ export const executeToolServer = async ( url: string, name: string, params: Record, - serverData: { openapi: any; info: any; specs: any } + serverData: { openapi: any; info: any; specs: any }, + sessionId?: string ) => { let error = null; @@ -531,6 +532,7 @@ export const executeToolServer = async ( 'Content-Type': 'application/json', ...(token && { authorization: `Bearer ${token}` }) }; + if (sessionId) headers['X-Session-Id'] = sessionId; const requestOptions: RequestInit = { method: httpMethod.toUpperCase(), diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index 23567baf8e..a01ffa5125 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -45,11 +45,11 @@ export const getTerminalConfig = async ( return res.json().catch(() => null); }; -export const getCwd = async (baseUrl: string, apiKey: string): Promise => { +export const getCwd = async (baseUrl: string, apiKey: string, sessionId?: string): Promise => { const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`; - const res = await fetch(url, { - headers: { Authorization: `Bearer ${apiKey}` } - }).catch(() => null); + const headers: Record = { Authorization: `Bearer ${apiKey}` }; + if (sessionId) headers['X-Session-Id'] = sessionId; + const res = await fetch(url, { headers }).catch(() => null); if (!res || !res.ok) return null; const json = await res.json().catch(() => null); return json?.cwd ?? null; @@ -218,15 +218,18 @@ export const deleteEntry = async ( export const setCwd = async ( baseUrl: string, apiKey: string, - path: string + path: string, + sessionId?: string ): Promise<{ cwd: string } | null> => { const url = `${baseUrl.replace(/\/$/, '')}/files/cwd`; + const headers: Record = { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }; + if (sessionId) headers['X-Session-Id'] = sessionId; const res = await fetch(url, { method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, + headers, body: JSON.stringify({ path }) }) .then(async (res) => { diff --git a/src/lib/components/chat/ChatControls.svelte b/src/lib/components/chat/ChatControls.svelte index f27a307fea..3cbcc7ed87 100644 --- a/src/lib/components/chat/ChatControls.svelte +++ b/src/lib/components/chat/ChatControls.svelte @@ -362,7 +362,7 @@ onClose={() => showControls.set(false)} /> {:else if activeTab === 'files' && $selectedTerminalId} - + {:else if activeTab === 'files' && codeInterpreterEnabled} {:else} @@ -513,7 +513,7 @@ onClose={() => showControls.set(false)} /> {:else if activeTab === 'files' && $selectedTerminalId} - + {:else if activeTab === 'files' && codeInterpreterEnabled} {:else} diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index 99549aec6b..ef2db742aa 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -49,6 +49,7 @@ export let onAttach: ((blob: Blob, name: string, contentType: string) => void) | null = null; export let overlay = false; + export let chatId: string | null = null; // ── Terminal panel state ──────────────────────────────────────────── let terminalExpanded = false; @@ -215,30 +216,48 @@ return url ? { url, key } : null; }; - // Detect terminal changes — the explicit store references ensure + // Detect terminal or chat changes — the explicit store references ensure // Svelte re-runs this block when any of them update. + // The `mounted` flag prevents the initial run from racing with onMount. let prevTerminalUrl = ''; + let prevChatId = chatId; + let mounted = false; $: { ($selectedTerminalId, $terminalServers, $settings); const terminal = getTerminal(); selectedTerminal = terminal; - if (terminal && terminal.url !== prevTerminalUrl) { - prevTerminalUrl = terminal.url; - loading = true; - error = null; - entries = []; - (async () => { - // Discover server features (terminal enabled/disabled) - const config = await getTerminalConfig(terminal.url, terminal.key); - terminalEnabled = config?.features?.terminal !== false; + const chatChanged = chatId !== prevChatId; + const oldChatId = prevChatId; + if (chatChanged) prevChatId = chatId; - const rawCwd = await getCwd(terminal.url, terminal.key); - const cwd = rawCwd ? normalizePath(rawCwd) : null; - const dir = cwd ? (cwd.endsWith('/') ? cwd : cwd + '/') : '/'; - savedPath = dir; - loadDir(dir); - })(); + const terminalChanged = terminal && terminal.url !== prevTerminalUrl; + if (terminalChanged) prevTerminalUrl = terminal.url; + + if (mounted && terminal) { + if (chatChanged && chatId && !oldChatId) { + // Chat just got created (null → real ID): persist the current + // browsed path as the new session's cwd — don't re-fetch. + setCwd(terminal.url, terminal.key, savedPath, chatId); + } else if (terminalChanged || chatChanged) { + // Terminal switched, new chat started, or switched between + // existing chats — re-fetch the session cwd. + loading = true; + error = null; + entries = []; + (async () => { + if (terminalChanged) { + const config = await getTerminalConfig(terminal.url, terminal.key); + terminalEnabled = config?.features?.terminal !== false; + } + + const rawCwd = await getCwd(terminal.url, terminal.key, chatId ?? undefined); + const cwd = rawCwd ? normalizePath(rawCwd) : null; + const dir = cwd ? (cwd.endsWith('/') ? cwd : cwd + '/') : '/'; + savedPath = dir; + loadDir(dir); + })(); + } } } @@ -316,7 +335,7 @@ loading = false; // Set working directory on the terminal server (fire-and-forget) - setCwd(terminal.url, terminal.key, path); + setCwd(terminal.url, terminal.key, path, chatId ?? undefined); if (result === null) { error = @@ -736,14 +755,22 @@ if (!handledDisplayFile) { loading = true; - if (savedPath === '/') { - const rawCwd = await getCwd(terminal.url, terminal.key); + + // Discover server features on initial mount + const config = await getTerminalConfig(terminal.url, terminal.key); + terminalEnabled = config?.features?.terminal !== false; + + if (chatId || savedPath === '/') { + // Fetch session-specific cwd from the server (or global default for new chats) + const rawCwd = await getCwd(terminal.url, terminal.key, chatId ?? undefined); const cwd = rawCwd ? normalizePath(rawCwd) : null; if (cwd) savedPath = cwd.endsWith('/') ? cwd : cwd + '/'; } loadDir(savedPath); } + mounted = true; + const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftKey = true; }; @@ -1368,6 +1395,7 @@ overlay={overlay || isDraggingHandle} bind:connected={terminalConnected} bind:connecting={terminalConnecting} + {chatId} />
{/if} diff --git a/src/lib/components/chat/XTerminal.svelte b/src/lib/components/chat/XTerminal.svelte index eb74a7e1a5..e16beef978 100644 --- a/src/lib/components/chat/XTerminal.svelte +++ b/src/lib/components/chat/XTerminal.svelte @@ -12,6 +12,7 @@ const i18n = getContext('i18n'); export let overlay = false; + export let chatId: string | null = null; let terminalEl: HTMLDivElement; let term: Terminal | null = null; @@ -67,9 +68,11 @@ authToken = apiKey; // Create session + const createHeaders: Record = { Authorization: `Bearer ${apiKey}` }; + if (chatId) createHeaders['X-Session-Id'] = chatId; const res = await fetch(`${base}/api/terminals`, { method: 'POST', - headers: { Authorization: `Bearer ${apiKey}` } + headers: createHeaders }); if (!res.ok) throw new Error(`Failed to create session: ${res.status}`); const session = await res.json(); @@ -83,9 +86,11 @@ authToken = token; // Create session via proxy + const proxyHeaders: Record = { Authorization: `Bearer ${token}` }; + if (chatId) proxyHeaders['X-Session-Id'] = chatId; const res = await fetch(`${base}/terminals/${info.serverId}/api/terminals`, { method: 'POST', - headers: { Authorization: `Bearer ${token}` } + headers: proxyHeaders }); if (!res.ok) throw new Error(`Failed to create session: ${res.status}`); const session = await res.json(); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 920e56f96b..b40c9c558b 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -375,7 +375,7 @@ return { toolServer, toolServerData, token }; }; - const executeTool = async (data, cb) => { + const executeTool = async (data, cb, chatId) => { const { toolServer, toolServerData, token } = resolveToolServer(data.server?.url); console.log('executeTool', data, toolServer); @@ -386,7 +386,8 @@ toolServer.url, data?.name, data?.params, - toolServerData + toolServerData, + chatId ); console.log('executeToolServer', res); @@ -485,7 +486,7 @@ executePythonAsWorker(data.id, data.code, cb, data.files || []); } else if (type === 'execute:tool') { console.log('execute:tool', data); - executeTool(data, cb); + executeTool(data, cb, event.chat_id); } else if (type === 'request:chat:completion') { console.log(data, $socket.id); const { session_id, channel, form_data, model } = data; From b794d6162650fec17c3923671e5c2cfb0033cb3a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 29 Mar 2026 21:15:20 -0500 Subject: [PATCH 11/85] refac --- backend/open_webui/tools/builtin.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 252d1c6c48..261dba0c69 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -2354,20 +2354,12 @@ async def tasks( __user__: dict = None, ) -> str: """ - Create or update a checklist of tasks tied to this chat. - Useful whenever a request involves multiple pieces of work that - should be tracked individually. Call without arguments to retrieve - the existing list. - - By default the entire list is replaced (overwrite=true). Set - overwrite=false to patch specific items by id without discarding - the rest. - - Each item carries an id, content string, and a status field - (pending, in_progress, completed, or cancelled). Order reflects - priority. Only one item should be in_progress at a time; mark - it completed before moving on, or cancel and replace it if the - approach changes. + Track progress on multi-step work by maintaining a task checklist. + Use this whenever a request involves multiple steps or could take + significant effort. Call to set the full list, then call again + with overwrite=false after completing each task to mark it + completed. Each task has an id, content, and status (pending, + in_progress, completed, cancelled). :param tasks: Optional list of task items. Each item: id (string), content (string, required for new tasks), status (pending|in_progress|completed|cancelled). Leave empty to fetch without modifying. :param overwrite: If true (default), replaces the entire task list. If false, updates/adds tasks by id while keeping existing ones. From b1e8c7d2aafd41e2fcde5dc4ce3d0c454535daff Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 29 Mar 2026 21:25:39 -0500 Subject: [PATCH 12/85] refac --- backend/open_webui/tools/builtin.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 261dba0c69..088319f2e6 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -2358,8 +2358,9 @@ async def tasks( Use this whenever a request involves multiple steps or could take significant effort. Call to set the full list, then call again with overwrite=false after completing each task to mark it - completed. Each task has an id, content, and status (pending, - in_progress, completed, cancelled). + completed. Do not leave tasks in_progress when the work is done. + Each task has an id, content, and status (pending, in_progress, + completed, cancelled). :param tasks: Optional list of task items. Each item: id (string), content (string, required for new tasks), status (pending|in_progress|completed|cancelled). Leave empty to fetch without modifying. :param overwrite: If true (default), replaces the entire task list. If false, updates/adds tasks by id while keeping existing ones. From 0ad397c0482004173d4a8bf4722100acc43db454 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 29 Mar 2026 21:29:00 -0500 Subject: [PATCH 13/85] refac --- src/lib/components/chat/MessageInput.svelte | 2 +- .../chat/Messages/ResponseMessage/TaskList.svelte | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index e826f1c68f..853bd9824f 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -1242,7 +1242,7 @@ {#if chatTasks.length > 0}
- +
{/if} diff --git a/src/lib/components/chat/Messages/ResponseMessage/TaskList.svelte b/src/lib/components/chat/Messages/ResponseMessage/TaskList.svelte index 79a9f42d5d..a295380eb3 100644 --- a/src/lib/components/chat/Messages/ResponseMessage/TaskList.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage/TaskList.svelte @@ -8,12 +8,21 @@ const i18n = getContext('i18n'); export let tasks: Array<{ id: string; content: string; status: string }> = []; + export let done = false; let collapsed = false; $: completedCount = tasks.filter((t) => t.status === 'completed').length; $: totalCount = tasks.length; - $: hasActive = tasks.some((t) => t.status === 'pending' || t.status === 'in_progress'); + $: inProgressCount = tasks.filter((t) => t.status === 'in_progress').length; + $: pendingCount = tasks.filter((t) => t.status === 'pending').length; + + // Hide once all work is effectively done: + // - no active tasks at all, OR + // - message is done and the only remaining active tasks are in_progress (no pending) + $: hasActive = + tasks.some((t) => t.status === 'pending' || t.status === 'in_progress') && + !(done && pendingCount === 0 && inProgressCount > 0); {#if tasks.length > 0 && hasActive} From 6512e085c4e56897dd49e56aff5d616820a962f3 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 29 Mar 2026 21:50:54 -0500 Subject: [PATCH 14/85] refac --- src/lib/apis/terminal/index.ts | 80 ++++++++++++++++---------- src/lib/components/chat/FileNav.svelte | 30 +++++----- 2 files changed, 64 insertions(+), 46 deletions(-) diff --git a/src/lib/apis/terminal/index.ts b/src/lib/apis/terminal/index.ts index a01ffa5125..de2e2fd5a6 100644 --- a/src/lib/apis/terminal/index.ts +++ b/src/lib/apis/terminal/index.ts @@ -58,13 +58,14 @@ export const getCwd = async (baseUrl: string, apiKey: string, sessionId?: string export const listFiles = async ( baseUrl: string, apiKey: string, - path: string = '/' + path: string = '/', + sessionId?: string ): Promise => { // The endpoint uses `directory` as the query param name const url = `${baseUrl.replace(/\/$/, '')}/files/list?directory=${encodeURIComponent(path)}`; - const res = await fetch(url, { - headers: { Authorization: `Bearer ${apiKey}` } - }) + const headers: Record = { Authorization: `Bearer ${apiKey}` }; + if (sessionId) headers['X-Session-Id'] = sessionId; + const res = await fetch(url, { headers }) .then(async (res) => { if (!res.ok) throw await res.json(); return res.json(); @@ -79,12 +80,13 @@ export const listFiles = async ( export const readFile = async ( baseUrl: string, apiKey: string, - path: string + path: string, + sessionId?: string ): Promise => { const url = `${baseUrl.replace(/\/$/, '')}/files/read?path=${encodeURIComponent(path)}`; - const res = await fetch(url, { - headers: { Authorization: `Bearer ${apiKey}` } - }).catch((err) => { + const headers: Record = { Authorization: `Bearer ${apiKey}` }; + if (sessionId) headers['X-Session-Id'] = sessionId; + const res = await fetch(url, { headers }).catch((err) => { console.error('open-terminal readFile error:', err); return null; }); @@ -106,12 +108,13 @@ export const readFile = async ( export const downloadFileBlob = async ( baseUrl: string, apiKey: string, - path: string + path: string, + sessionId?: string ): Promise<{ blob: Blob; filename: string } | null> => { const url = `${baseUrl.replace(/\/$/, '')}/files/view?path=${encodeURIComponent(path)}`; - const res = await fetch(url, { - headers: { Authorization: `Bearer ${apiKey}` } - }).catch(() => null); + const headers: Record = { Authorization: `Bearer ${apiKey}` }; + if (sessionId) headers['X-Session-Id'] = sessionId; + const res = await fetch(url, { headers }).catch(() => null); if (!res || !res.ok) return null; @@ -123,15 +126,18 @@ export const downloadFileBlob = async ( export const archiveFromTerminal = async ( baseUrl: string, apiKey: string, - paths: string[] + paths: string[], + sessionId?: string ): Promise<{ blob: Blob; filename: string } | null> => { const url = `${baseUrl.replace(/\/$/, '')}/files/archive`; + const headers: Record = { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }; + if (sessionId) headers['X-Session-Id'] = sessionId; const res = await fetch(url, { method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, + headers, body: JSON.stringify({ paths }) }).catch(() => null); @@ -148,14 +154,17 @@ export const uploadToTerminal = async ( baseUrl: string, apiKey: string, directory: string, - file: File + file: File, + sessionId?: string ): Promise<{ path: string; size: number } | null> => { const url = `${baseUrl.replace(/\/$/, '')}/files/upload?directory=${encodeURIComponent(directory)}`; const body = new FormData(); body.append('file', file); + const headers: Record = { Authorization: `Bearer ${apiKey}` }; + if (sessionId) headers['X-Session-Id'] = sessionId; const res = await fetch(url, { method: 'POST', - headers: { Authorization: `Bearer ${apiKey}` }, + headers, body }) .then(async (res) => { @@ -172,15 +181,18 @@ export const uploadToTerminal = async ( export const createDirectory = async ( baseUrl: string, apiKey: string, - path: string + path: string, + sessionId?: string ): Promise<{ path: string } | null> => { const url = `${baseUrl.replace(/\/$/, '')}/files/mkdir`; + const headers: Record = { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }; + if (sessionId) headers['X-Session-Id'] = sessionId; const res = await fetch(url, { method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, + headers, body: JSON.stringify({ path }) }) .then(async (res) => { @@ -197,12 +209,15 @@ export const createDirectory = async ( export const deleteEntry = async ( baseUrl: string, apiKey: string, - path: string + path: string, + sessionId?: string ): Promise<{ path: string; type: string } | null> => { const url = `${baseUrl.replace(/\/$/, '')}/files/delete?path=${encodeURIComponent(path)}`; + const headers: Record = { Authorization: `Bearer ${apiKey}` }; + if (sessionId) headers['X-Session-Id'] = sessionId; const res = await fetch(url, { method: 'DELETE', - headers: { Authorization: `Bearer ${apiKey}` } + headers }) .then(async (res) => { if (!res.ok) throw await res.json(); @@ -247,15 +262,18 @@ export const moveEntry = async ( baseUrl: string, apiKey: string, source: string, - destination: string + destination: string, + sessionId?: string ): Promise<{ source: string; destination: string } | { error: string }> => { const url = `${baseUrl.replace(/\/$/, '')}/files/move`; + const headers: Record = { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }; + if (sessionId) headers['X-Session-Id'] = sessionId; const res = await fetch(url, { method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, + headers, body: JSON.stringify({ source, destination }) }) .then(async (res) => { diff --git a/src/lib/components/chat/FileNav.svelte b/src/lib/components/chat/FileNav.svelte index ef2db742aa..988c92669c 100644 --- a/src/lib/components/chat/FileNav.svelte +++ b/src/lib/components/chat/FileNav.svelte @@ -331,7 +331,7 @@ savedPath = path; pushNavHistory(path); - const result = await listFiles(terminal.url, terminal.key, path); + const result = await listFiles(terminal.url, terminal.key, path, chatId ?? undefined); loading = false; // Set working directory on the terminal server (fire-and-forget) @@ -366,22 +366,22 @@ clearFilePreview(); if (isImage(filePath)) { - const result = await downloadFileBlob(terminal.url, terminal.key, filePath); + const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined); if (result) fileImageUrl = URL.createObjectURL(result.blob); } else if (isVideo(filePath)) { - const result = await downloadFileBlob(terminal.url, terminal.key, filePath); + const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined); if (result) fileVideoUrl = URL.createObjectURL(result.blob); } else if (isAudio(filePath)) { - const result = await downloadFileBlob(terminal.url, terminal.key, filePath); + const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined); if (result) fileAudioUrl = URL.createObjectURL(result.blob); } else if (isPdf(filePath)) { - const result = await downloadFileBlob(terminal.url, terminal.key, filePath); + const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined); if (result) filePdfData = await result.blob.arrayBuffer(); } else if (isSqlite(filePath)) { - const result = await downloadFileBlob(terminal.url, terminal.key, filePath); + const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined); if (result) fileSqliteData = await result.blob.arrayBuffer(); } else if (isOffice(filePath)) { - const result = await downloadFileBlob(terminal.url, terminal.key, filePath); + const result = await downloadFileBlob(terminal.url, terminal.key, filePath, chatId ?? undefined); if (result) { const ext = getFileExt(filePath); const arrayBuffer = await result.blob.arrayBuffer(); @@ -414,7 +414,7 @@ } } } else { - fileContent = await readFile(terminal.url, terminal.key, filePath); + fileContent = await readFile(terminal.url, terminal.key, filePath, chatId ?? undefined); } fileLoading = false; }; @@ -427,7 +427,7 @@ const isDir = path.endsWith('/'); const result = isDir ? await archiveFromTerminal(terminal.url, terminal.key, [path.replace(/\/$/, '')]) - : await downloadFileBlob(terminal.url, terminal.key, path); + : await downloadFileBlob(terminal.url, terminal.key, path, chatId ?? undefined); if (!result) return; const url = URL.createObjectURL(result.blob); const a = document.createElement('a'); @@ -459,7 +459,7 @@ uploading = true; for (const file of droppedFiles) { - await uploadToTerminal(terminal.url, terminal.key, currentPath, file); + await uploadToTerminal(terminal.url, terminal.key, currentPath, file, chatId ?? undefined); } uploading = false; await loadDir(currentPath); @@ -471,7 +471,7 @@ uploading = true; for (const file of files) { - await uploadToTerminal(terminal.url, terminal.key, currentPath, file); + await uploadToTerminal(terminal.url, terminal.key, currentPath, file, chatId ?? undefined); } uploading = false; await loadDir(currentPath); @@ -494,7 +494,7 @@ const terminal = selectedTerminal; if (!terminal) return; - const result = await createDirectory(terminal.url, terminal.key, `${currentPath}${name}`); + const result = await createDirectory(terminal.url, terminal.key, `${currentPath}${name}`, chatId ?? undefined); toast[result ? 'success' : 'error']( $i18n.t(result ? 'Folder created' : 'Failed to create folder') ); @@ -529,7 +529,7 @@ const terminal = selectedTerminal; if (!terminal) return; - const result = await deleteEntry(terminal.url, terminal.key, path); + const result = await deleteEntry(terminal.url, terminal.key, path, chatId ?? undefined); toast[result ? 'success' : 'error']( $i18n.t(result ? '{{name}} deleted' : 'Failed to delete {{name}}', { name }) ); @@ -555,7 +555,7 @@ const sourceDir = source.endsWith('/') ? source : source + '/'; if (destFolder.startsWith(sourceDir)) return; - const result = await moveEntry(terminal.url, terminal.key, source, destination); + const result = await moveEntry(terminal.url, terminal.key, source, destination, chatId ?? undefined); if ('error' in result) { toast.error(result.error); } else { @@ -574,7 +574,7 @@ if (oldPath === destination) return; - const result = await moveEntry(terminal.url, terminal.key, oldPath, destination); + const result = await moveEntry(terminal.url, terminal.key, oldPath, destination, chatId ?? undefined); if ('error' in result) { toast.error(result.error); } else { From 4b35d70078a2d7a322566699a43594b3c10b2dda Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 29 Mar 2026 21:59:51 -0500 Subject: [PATCH 15/85] refac --- src/lib/components/chat/MessageInput.svelte | 8 +++++--- .../chat/Messages/ResponseMessage/TaskList.svelte | 11 +---------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 853bd9824f..f853b59a1c 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -122,6 +122,8 @@ export let history; export let taskIds = null; + $: isActive = (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true) || generating; + export let prompt = ''; export let files = []; @@ -1240,9 +1242,9 @@ /> - {#if chatTasks.length > 0} + {#if isActive && chatTasks.length > 0}
- +
{/if} @@ -1857,7 +1859,7 @@
- {#if (taskIds && taskIds.length > 0) || (history.currentId && history.messages[history.currentId]?.done != true) || generating} + {#if isActive}
-
+ {item?.name -
+
{:else if selectedTab === ''} {#if item?.file?.data} diff --git a/src/lib/components/common/ImagePreview.svelte b/src/lib/components/common/ImagePreview.svelte index 17d9a93a1e..9cb36dc160 100644 --- a/src/lib/components/common/ImagePreview.svelte +++ b/src/lib/components/common/ImagePreview.svelte @@ -1,10 +1,10 @@ @@ -181,14 +160,13 @@
-
+ -
+
{/if} diff --git a/src/lib/components/common/PanzoomContainer.svelte b/src/lib/components/common/PanzoomContainer.svelte new file mode 100644 index 0000000000..50aec0709d --- /dev/null +++ b/src/lib/components/common/PanzoomContainer.svelte @@ -0,0 +1,33 @@ + + +
+ +
diff --git a/src/lib/components/common/SVGPanZoom.svelte b/src/lib/components/common/SVGPanZoom.svelte index aaeb26dac9..307ddda21e 100644 --- a/src/lib/components/common/SVGPanZoom.svelte +++ b/src/lib/components/common/SVGPanZoom.svelte @@ -4,15 +4,14 @@ import { toast } from 'svelte-sonner'; - import panzoom, { type PanZoom } from 'panzoom'; import DOMPurify from 'dompurify'; - import { onMount, getContext } from 'svelte'; + import { getContext } from 'svelte'; const i18n = getContext('i18n'); import { copyToClipboard } from '$lib/utils'; - import DocumentDuplicate from '../icons/DocumentDuplicate.svelte'; + import PanzoomContainer from './PanzoomContainer.svelte'; import Tooltip from './Tooltip.svelte'; import Clipboard from '../icons/Clipboard.svelte'; import Reset from '../icons/Reset.svelte'; @@ -22,23 +21,9 @@ export let svg = ''; export let content = ''; - let instance: PanZoom; - - let sceneParentElement: HTMLElement; - let sceneElement: HTMLElement; - - $: if (sceneElement) { - instance = panzoom(sceneElement, { - bounds: true, - boundsPadding: 0.1, - - zoomSpeed: 0.065 - }); - } + let panzoomRef: PanzoomContainer; const resetPanZoomViewport = () => { - instance.moveTo(0, 0); - instance.zoomAbs(0, 0, 1); - console.log(instance.getTransform()); + panzoomRef?.reset(); }; const downloadAsSVG = () => { @@ -47,8 +32,11 @@ }; -
-
+
+ {@html DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true }, // allow , , , etc. WHOLE_DOCUMENT: false, @@ -88,7 +76,7 @@ ], SANITIZE_DOM: true })} -
+ {#if content}
From b10c70cfcf1ece2d6d9959716ec2ed66e21f602a Mon Sep 17 00:00:00 2001 From: Shirasawa <764798966@qq.com> Date: Tue, 31 Mar 2026 17:11:47 +0800 Subject: [PATCH 21/85] feat: Save error messages to the database (#23231) --- backend/open_webui/utils/middleware.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index b64febd673..398d693cd5 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -3632,6 +3632,16 @@ async def streaming_chat_response_handler(response, ctx): if not choices: error = data.get('error', {}) if error: + try: + Chats.upsert_message_to_chat_by_id_and_message_id( + metadata["chat_id"], + metadata["message_id"], + { + "error": {"content": error}, + }, + ) + except Exception: + pass await event_emitter( { 'type': 'chat:completion', From 5fd9db873900f96e9822a5c5e7e368ae368c1749 Mon Sep 17 00:00:00 2001 From: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:19:47 +0300 Subject: [PATCH 22/85] perf: replace JS transition with CSS animation in CodespanToken (#23258) --- .../MarkdownInlineTokens/CodespanToken.svelte | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/CodespanToken.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/CodespanToken.svelte index c0b1ec327c..1de3f13f6e 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/CodespanToken.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/CodespanToken.svelte @@ -1,7 +1,6 @@ + + +
+ +
+ + +
+ + +
+
{$i18n.t('Instructions')}
+