mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-24 06:24:54 -06:00
feat: tasks
This commit is contained in:
@@ -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')
|
||||
@@ -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()
|
||||
|
||||
@@ -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)})
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
<!-- Task list display -->
|
||||
{#if chatTasks.length > 0}
|
||||
<div class="mx-1">
|
||||
<TaskList tasks={chatTasks} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Queued messages display -->
|
||||
{#if messageQueue.length > 0}
|
||||
<div
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
export let readOnly = false;
|
||||
export let editCodeBlock = true;
|
||||
export let topPadding = false;
|
||||
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
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');
|
||||
</script>
|
||||
|
||||
{#if tasks.length > 0 && hasActive}
|
||||
<div
|
||||
class="my-2 rounded-xl border border-gray-50 dark:border-gray-850 bg-white dark:bg-gray-900"
|
||||
transition:slide={{ duration: 200 }}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-3.5 py-2">
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<TaskListIcon className="w-3.5 h-3.5" />
|
||||
<span>
|
||||
{completedCount} {$i18n.t('out of')} {totalCount} {$i18n.t('tasks completed')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
on:click={() => (collapsed = !collapsed)}
|
||||
aria-label={collapsed ? 'Expand' : 'Collapse'}
|
||||
>
|
||||
{#if collapsed}
|
||||
<ChevronDown className="w-2.5 h-2.5" />
|
||||
{:else}
|
||||
<ChevronUp className="w-2.5 h-2.5" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Task list -->
|
||||
{#if !collapsed}
|
||||
<div class="px-3.5 pb-2.5 space-y-0.5" transition:slide={{ duration: 150 }}>
|
||||
{#each tasks as task, idx (task.id)}
|
||||
<div class="flex items-start gap-2 py-0.5 text-xs">
|
||||
<span class="flex-shrink-0 mt-0.5 text-gray-400 dark:text-gray-500">
|
||||
{#if task.status === 'completed'}
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M5 13l4 4L19 7" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
{:else if task.status === 'in_progress'}
|
||||
<svg class="w-3.5 h-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M12 3a9 9 0 1 0 9 9" stroke-linecap="round" />
|
||||
</svg>
|
||||
{:else if task.status === 'cancelled'}
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="9" stroke-dasharray="4 3" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
class="line-clamp-2 {task.status === 'completed'
|
||||
? 'line-through text-gray-400 dark:text-gray-500'
|
||||
: task.status === 'cancelled'
|
||||
? 'line-through text-gray-400 dark:text-gray-600'
|
||||
: 'text-gray-700 dark:text-gray-300'}"
|
||||
>
|
||||
{idx + 1}. {task.content}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
export let className = 'w-4 h-4';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class={className}
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 20L15 15M15 15V19M15 15H19" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M4 20L9 15M9 15V19M9 15H5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M20 4L15 9M15 9V5M15 9H19" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M4 4L9 9M9 9V5M9 9H5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
@@ -1,21 +1,17 @@
|
||||
<script lang="ts">
|
||||
export let className = 'w-4 h-4';
|
||||
export let strokeWidth = '1.5';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class={className}
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke-width={strokeWidth}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
><path d="M9 9L4 4M4 4V8M4 4H8" stroke-linecap="round" stroke-linejoin="round"></path><path
|
||||
d="M15 9L20 4M20 4V8M20 4H16"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path><path d="M9 15L4 20M4 20V16M4 20H8" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path><path d="M15 15L20 20M20 20V16M20 20H16" stroke-linecap="round" stroke-linejoin="round"
|
||||
></path></svg
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9 9L4 4M4 4V8M4 4H8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M15 9L20 4M20 4V8M20 4H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9 15L4 20M4 20V16M4 20H8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M15 15L20 20M20 20V16M20 20H16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
export let className = 'w-4 h-4';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class={className}
|
||||
stroke-width="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M9 6L20 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M3.80002 5.79999L4.60002 6.59998L6.60001 4.59999" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M3.80002 11.8L4.60002 12.6L6.60001 10.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M3.80002 17.8L4.60002 18.6L6.60001 16.6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9 12L20 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9 18L20 18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
@@ -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')
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user