refac
This commit is contained in:
@@ -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
@@ -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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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'):
|
||||||
|
|||||||
Reference in New Issue
Block a user