diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index be42b5d85..25a790580 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -161,18 +161,22 @@ class NoteTable: if filter: query_key = filter.get('query') if query_key: - # Normalize search by removing hyphens and spaces (e.g., "todo" matches "to-do" and "to do") - normalized_query = query_key.replace('-', '').replace(' ', '') - stmt = stmt.filter( - or_( - func.replace(func.replace(Note.title, '-', ''), ' ', '').ilike(f'%{normalized_query}%'), - func.replace( - func.replace(cast(Note.data['content']['md'], Text), '-', ''), - ' ', - '', - ).ilike(f'%{normalized_query}%'), + # Split query into individual words and normalize each + # (strip hyphens so "todo" matches "to-do"). + # All words must match somewhere in title OR content (AND semantics). + search_words = query_key.split() + normalized_words = [w.replace('-', '') for w in search_words if w.replace('-', '')] + for word in normalized_words: + stmt = stmt.filter( + or_( + func.replace(func.replace(Note.title, '-', ''), ' ', '').ilike(f'%{word}%'), + func.replace( + func.replace(cast(Note.data['content']['md'], Text), '-', ''), + ' ', + '', + ).ilike(f'%{word}%'), + ) ) - ) view_option = filter.get('view_option') if view_option == 'created': diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 25d1f8441..5ed0df7d5 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -760,14 +760,26 @@ async def search_notes( content_snippet = '' if note.data and note.data.get('content', {}).get('md'): md_content = note.data['content']['md'] - lower_content = md_content.lower() - lower_query = query.lower() - idx = lower_content.find(lower_query) - if idx != -1: - start = max(0, idx - 50) - end = min(len(md_content), idx + len(query) + 100) + content_lower = md_content.lower() + + # Find the first matching word to center the snippet around. + search_words = query.lower().split() + match_pos = -1 + 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 = ( - ('...' 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: 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): 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.') -async def tasks( - tasks: Optional[list[TaskItem]] = None, - overwrite: bool = True, +def _task_summary(all_tasks: list[dict]) -> dict: + """Build summary counts for a task list.""" + 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, __message_id__: str = None, __event_emitter__: callable = None, @@ -2351,144 +2390,42 @@ async def tasks( __user__: dict = None, ) -> str: """ - 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. Do not leave tasks in_progress when the work is done. - Each task has an id, content, and status (pending, in_progress, - completed, cancelled). + Create a task checklist to track progress on multi-step work. + Call this once at the start to define all steps, then use + update_task to mark each task as you complete it. - :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. + :param tasks: List of task items. Each item: content (string, required), status (pending|in_progress|completed|cancelled, default pending), id (optional, auto-generated). :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.""" + all_tasks = [] + for idx, task in enumerate(tasks): 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 + elif isinstance(task, dict): + d = task + else: + d = dict(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 '' + content = str(d.get('content', '')).strip() + if not content: + continue - 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) + item_id = str(d.get('id', '') or '').strip() or str(idx + 1) + status = str(d.get('status', 'pending')).strip().lower() + if status not in VALID_TASK_STATUSES: + status = 'pending' - if tasks is None: - # 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 + all_tasks.append({'id': item_id, 'content': content, 'status': status}) - 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 = 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') + 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': { - 'total': len(all_tasks), - 'pending': pending, - 'in_progress': in_progress, - 'completed': completed, - 'cancelled': cancelled, - }, - }, + {'tasks': all_tasks, 'summary': _task_summary(all_tasks)}, ensure_ascii=False, ) except Exception as e: @@ -2496,6 +2433,56 @@ async def tasks( 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 # ============================================================================= diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 108a40587..ddee2dc20 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -86,7 +86,8 @@ from open_webui.tools.builtin import ( view_file, view_knowledge_file, view_skill, - tasks, + create_tasks, + update_task, create_automation, update_automation, list_automations, @@ -543,7 +544,7 @@ async def get_builtin_tools( # Task management - break down complex work into trackable steps 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 if is_builtin_tool_enabled('automations') and await has_user_permission('automations'):