From bcb71bb5206ac01d97a39fde8ecf0e0541dde636 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Sun, 29 Mar 2026 18:01:04 -0500 Subject: [PATCH] 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') } };