This commit is contained in:
Timothy Jaeryang Baek
2026-04-12 18:48:06 -05:00
parent d59b933bf2
commit a359262616
3 changed files with 139 additions and 147 deletions
+15 -11
View File
@@ -161,18 +161,22 @@ class NoteTable:
if filter: if filter:
query_key = filter.get('query') query_key = filter.get('query')
if query_key: if query_key:
# Normalize search by removing hyphens and spaces (e.g., "todo" matches "to-do" and "to do") # Split query into individual words and normalize each
normalized_query = query_key.replace('-', '').replace(' ', '') # (strip hyphens so "todo" matches "to-do").
stmt = stmt.filter( # All words must match somewhere in title OR content (AND semantics).
or_( search_words = query_key.split()
func.replace(func.replace(Note.title, '-', ''), ' ', '').ilike(f'%{normalized_query}%'), normalized_words = [w.replace('-', '') for w in search_words if w.replace('-', '')]
func.replace( for word in normalized_words:
func.replace(cast(Note.data['content']['md'], Text), '-', ''), stmt = stmt.filter(
' ', or_(
'', func.replace(func.replace(Note.title, '-', ''), ' ', '').ilike(f'%{word}%'),
).ilike(f'%{normalized_query}%'), func.replace(
func.replace(cast(Note.data['content']['md'], Text), '-', ''),
' ',
'',
).ilike(f'%{word}%'),
)
) )
)
view_option = filter.get('view_option') view_option = filter.get('view_option')
if view_option == 'created': if view_option == 'created':
+121 -134
View File
@@ -760,14 +760,26 @@ async def search_notes(
content_snippet = '' content_snippet = ''
if note.data and note.data.get('content', {}).get('md'): if note.data and note.data.get('content', {}).get('md'):
md_content = note.data['content']['md'] md_content = note.data['content']['md']
lower_content = md_content.lower() content_lower = md_content.lower()
lower_query = query.lower()
idx = lower_content.find(lower_query) # Find the first matching word to center the snippet around.
if idx != -1: search_words = query.lower().split()
start = max(0, idx - 50) match_pos = -1
end = min(len(md_content), idx + len(query) + 100) match_len = len(query)
for word in search_words:
found_pos = content_lower.find(word)
if found_pos != -1:
match_pos = found_pos
match_len = len(word)
break
if match_pos != -1:
snippet_start = max(0, match_pos - 50)
snippet_end = min(len(md_content), match_pos + match_len + 100)
content_snippet = ( content_snippet = (
('...' if start > 0 else '') + md_content[start:end] + ('...' if end < len(md_content) else '') ('...' if snippet_start > 0 else '')
+ md_content[snippet_start:snippet_end]
+ ('...' if snippet_end < len(md_content) else '')
) )
else: else:
content_snippet = md_content[:150] + ('...' if len(md_content) > 150 else '') content_snippet = md_content[:150] + ('...' if len(md_content) > 150 else '')
@@ -2337,13 +2349,40 @@ VALID_TASK_STATUSES = {'pending', 'in_progress', 'completed', 'cancelled'}
class TaskItem(BaseModel): class TaskItem(BaseModel):
id: Optional[str] = Field(None, description='Unique identifier for the task. Auto-generated if omitted.') 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.') content: str = Field(..., description='Task description.')
status: Literal['pending', 'in_progress', 'completed', 'cancelled'] = Field('pending', description='Task status.') status: Literal['pending', 'in_progress', 'completed', 'cancelled'] = Field('pending', description='Task status.')
async def tasks( def _task_summary(all_tasks: list[dict]) -> dict:
tasks: Optional[list[TaskItem]] = None, """Build summary counts for a task list."""
overwrite: bool = True, 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 {
'total': len(all_tasks),
'pending': pending,
'in_progress': in_progress,
'completed': completed,
'cancelled': cancelled,
}
async def _emit_tasks(event_emitter, all_tasks: list[dict]):
"""Persist task state to the UI."""
if event_emitter:
await event_emitter(
{
'type': 'chat:message:tasks',
'data': {
'tasks': all_tasks,
},
}
)
async def create_tasks(
tasks: list[TaskItem],
__chat_id__: str = None, __chat_id__: str = None,
__message_id__: str = None, __message_id__: str = None,
__event_emitter__: callable = None, __event_emitter__: callable = None,
@@ -2351,144 +2390,42 @@ async def tasks(
__user__: dict = None, __user__: dict = None,
) -> str: ) -> str:
""" """
Track progress on multi-step work by maintaining a task checklist. Create a task checklist to track progress on multi-step work.
Use this whenever a request involves multiple steps or could take Call this once at the start to define all steps, then use
significant effort. Call to set the full list, then call again update_task to mark each task as you complete it.
with overwrite=false after completing each task to mark it
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 tasks: List of task items. Each item: content (string, required), status (pending|in_progress|completed|cancelled, default pending), id (optional, auto-generated).
: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 :return: JSON with the full task list and summary counts
""" """
if __chat_id__ is None: if __chat_id__ is None:
return json.dumps({'error': 'Chat context not available'}) return json.dumps({'error': 'Chat context not available'})
try: try:
all_tasks = []
def _to_dict(task) -> dict: for idx, task in enumerate(tasks):
"""Convert TaskItem or dict to plain dict."""
if hasattr(task, 'model_dump'): if hasattr(task, 'model_dump'):
d = task.model_dump(exclude_none=True) d = task.model_dump(exclude_none=True)
# Include any extra fields the model sent elif isinstance(task, dict):
if hasattr(task, 'model_extra') and task.model_extra: d = task
d.update(task.model_extra) else:
return d d = dict(task)
return dict(task) if not isinstance(task, dict) else task
def _resolve_content(d: dict) -> str: content = str(d.get('content', '')).strip()
"""Accept content, title, name, or description as the task text.""" if not content:
for key in ('content', 'title', 'name', 'description'): continue
val = str(d.get(key, '')).strip()
if val:
return val
return ''
def _resolve_id(d: dict, idx: int) -> str: item_id = str(d.get('id', '') or '').strip() or str(idx + 1)
"""Use provided id, or auto-generate from index.""" status = str(d.get('status', 'pending')).strip().lower()
item_id = str(d.get('id', '') or '').strip() if status not in VALID_TASK_STATUSES:
return item_id if item_id else str(idx + 1) status = 'pending'
if tasks is None: all_tasks.append({'id': item_id, 'content': content, 'status': status})
# Read-only - return current list
all_tasks = await Chats.get_chat_tasks_by_id(__chat_id__)
elif 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() await Chats.update_chat_tasks_by_id(__chat_id__, all_tasks)
if status not in VALID_TASK_STATUSES: await _emit_tasks(__event_emitter__, all_tasks)
status = 'pending'
all_tasks.append(
{
'id': item_id,
'content': content,
'status': status,
}
)
else:
# Partial update - merge by id
existing_tasks = await 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 and emit (skip for read-only)
if tasks is not None:
await Chats.update_chat_tasks_by_id(__chat_id__, 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')
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( return json.dumps(
{ {'tasks': all_tasks, 'summary': _task_summary(all_tasks)},
'tasks': all_tasks,
'summary': {
'total': len(all_tasks),
'pending': pending,
'in_progress': in_progress,
'completed': completed,
'cancelled': cancelled,
},
},
ensure_ascii=False, ensure_ascii=False,
) )
except Exception as e: except Exception as e:
@@ -2496,6 +2433,56 @@ async def tasks(
return json.dumps({'error': str(e)}) return json.dumps({'error': str(e)})
async def update_task(
id: str,
status: str = 'completed',
__chat_id__: str = None,
__message_id__: str = None,
__event_emitter__: callable = None,
__request__: Request = None,
__user__: dict = None,
) -> str:
"""
Mark a single task as completed, in_progress, pending, or cancelled.
Call this after finishing each step. You MUST call this for every
task, including the very last one.
:param id: The task ID to update
:param status: New status: completed, in_progress, pending, or cancelled (default: completed)
:return: JSON with the updated task list and summary counts
"""
if __chat_id__ is None:
return json.dumps({'error': 'Chat context not available'})
try:
status = status.strip().lower()
if status not in VALID_TASK_STATUSES:
return json.dumps({'error': f'Invalid status: {status}. Must be one of: {", ".join(sorted(VALID_TASK_STATUSES))}'})
all_tasks = await Chats.get_chat_tasks_by_id(__chat_id__)
found = False
for task in all_tasks:
if task['id'] == id:
task['status'] = status
found = True
break
if not found:
return json.dumps({'error': f'Task with id "{id}" not found'})
await Chats.update_chat_tasks_by_id(__chat_id__, all_tasks)
await _emit_tasks(__event_emitter__, all_tasks)
return json.dumps(
{'tasks': all_tasks, 'summary': _task_summary(all_tasks)},
ensure_ascii=False,
)
except Exception as e:
log.exception(f'update_task_status error: {e}')
return json.dumps({'error': str(e)})
# ============================================================================= # =============================================================================
# AUTOMATION TOOLS # AUTOMATION TOOLS
# ============================================================================= # =============================================================================
+3 -2
View File
@@ -86,7 +86,8 @@ from open_webui.tools.builtin import (
view_file, view_file,
view_knowledge_file, view_knowledge_file,
view_skill, view_skill,
tasks, create_tasks,
update_task,
create_automation, create_automation,
update_automation, update_automation,
list_automations, list_automations,
@@ -543,7 +544,7 @@ async def get_builtin_tools(
# Task management - break down complex work into trackable steps # Task management - break down complex work into trackable steps
if is_builtin_tool_enabled('tasks'): if is_builtin_tool_enabled('tasks'):
builtin_functions.append(tasks) builtin_functions.extend([create_tasks, update_task])
# Automation tools - create and manage scheduled automations from chat # Automation tools - create and manage scheduled automations from chat
if is_builtin_tool_enabled('automations') and await has_user_permission('automations'): if is_builtin_tool_enabled('automations') and await has_user_permission('automations'):