diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index ac9a36066d..dc7d9f9dd3 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -498,6 +498,114 @@ async def edit_image( return JSONCodec.dumps({'error': str(e)}) +# ============================================================================= +# USER INPUT TOOLS +# ============================================================================= + + +async def ask_user( + questions: list[dict], + allow_other: bool = True, + __event_call__: callable = None, +) -> str: + """ + Ask the user clarifying questions before continuing. + Use this when the next step depends on user intent, preference, or a tradeoff that cannot be inferred safely. + + :param questions: 1-3 question objects, each with id, header, question, and 2-3 options. Each option needs label and description. + :param allow_other: Whether users may enter a free-form answer instead of choosing one of the options + :return: JSON with status and answers keyed by question id + """ + try: + if not isinstance(questions, list) or not 1 <= len(questions) <= 3: + raise ValueError('ask_user requires 1-3 questions.') + + normalized_questions = [] + seen_ids = set() + for index, question in enumerate(questions): + if not isinstance(question, dict): + raise ValueError('Each question must be an object.') + + question_id = str(question.get('id') or '').strip()[:64] + if not question_id: + raise ValueError('Each question requires a non-empty id.') + if question_id in seen_ids: + raise ValueError(f'Duplicate question id: {question_id}') + seen_ids.add(question_id) + + options = question.get('options') + if not isinstance(options, list) or not 2 <= len(options) <= 3: + raise ValueError('Each question requires 2-3 options.') + + normalized_options = [] + for option in options: + if not isinstance(option, dict): + raise ValueError('Each option must be an object.') + + label = str(option.get('label') or '').strip()[:80] + description = str(option.get('description') or '').strip()[:240] + if not label or not description: + raise ValueError('Each option requires a label and description.') + + normalized_options.append( + { + 'label': label, + 'description': description, + } + ) + + question_text = str(question.get('question') or '').strip()[:500] + if not question_text: + raise ValueError('Each question requires question text.') + + normalized_questions.append( + { + 'id': question_id, + 'header': str(question.get('header') or '').strip()[:48] or f'Question {index + 1}', + 'question': question_text, + 'options': normalized_options, + 'allow_other': bool(question.get('allow_other', allow_other)), + } + ) + + if __event_call__ is None: + return JSONCodec.dumps( + { + 'status': 'error', + 'error': 'User input requires an active browser session with WebSocket connection.', + }, + ensure_ascii=False, + ) + + output = await __event_call__( + { + 'type': 'request:user_input', + 'data': { + 'questions': normalized_questions, + 'allow_other': allow_other, + }, + } + ) + + if not isinstance(output, dict): + return JSONCodec.dumps({'status': 'error', 'error': 'Invalid user input response.'}, ensure_ascii=False) + if output.get('error'): + return JSONCodec.dumps({'status': 'error', 'error': output.get('error')}, ensure_ascii=False) + if output.get('status') == 'cancelled': + return JSONCodec.dumps({'status': 'cancelled', 'answers': {}}, ensure_ascii=False) + + return JSONCodec.dumps( + { + 'status': 'answered', + 'answers': output.get('answers', {}), + }, + ensure_ascii=False, + ) + except Exception as e: + log.exception(f'ask_user error: {e}') + return JSONCodec.dumps({'status': 'error', 'error': str(e)}, ensure_ascii=False) + + # ============================================================================= # CODE INTERPRETER TOOLS # ============================================================================= diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 5a3852b89d..bb682d4cdb 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -47,6 +47,7 @@ from open_webui.models.tools import Tools from open_webui.models.users import UserModel from open_webui.tools.builtin import ( add_memory, + ask_user, calculate_timestamp, create_automation, create_calendar_event, @@ -583,6 +584,9 @@ async def get_builtin_tools( if is_builtin_tool_enabled('time'): builtin_functions.extend([get_current_timestamp, calculate_timestamp]) + if is_builtin_tool_enabled('user_input'): + builtin_functions.append(ask_user) + metadata = extra_params.get('__metadata__') or {} chat_files = metadata.get('files') or extra_params.get('__files__') or [] has_chat_files = any( diff --git a/src/lib/components/chat/AskUserCard.svelte b/src/lib/components/chat/AskUserCard.svelte new file mode 100644 index 0000000000..4c69789c04 --- /dev/null +++ b/src/lib/components/chat/AskUserCard.svelte @@ -0,0 +1,296 @@ + + +{#if show} +
+
+
+ {$i18n.t('Planning question')} +
+
+ {$i18n.t('Question')} + {questionIndex + 1} + {$i18n.t('of')} + {questions.length} ยท + {$i18n.t('Paused while visible')} +
+
+ +
+ {#if question} + {#key question.id} +
+
+
+ {question.header} +
+
+ {question.question} +
+
+ +
+ {#each question.options || [] as option, optionIndex} + + + + {/each} + + {#if questionAllowsOther(question)} + + {#if selectedAnswer?.type === 'other'} + + updateOther(question, (event.currentTarget as HTMLInputElement).value)} + /> + {/if} + {/if} +
+
+ {/key} + {/if} + +
+ + {#if questionIndex < questions.length - 1} + + {:else} + + {/if} +
+
+
+{/if} diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index dc9f6653f7..9f168455dc 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -162,7 +162,10 @@ let eventConfirmationInputValue = ''; let eventConfirmationInputType = ''; let eventConfirmationInputOptions: ({ label?: string; value: string } | string)[] = []; - let eventCallback = null; + let eventCallback: (value: any) => void = () => {}; + let showAskUserDialog = false; + let askUserQuestions: any[] = []; + let askUserAllowOther = true; let selectedModels = ['']; let atSelectedModel: Model | undefined; @@ -1144,6 +1147,11 @@ eventConfirmationInputValue = data?.value ?? ''; eventConfirmationInputType = data?.input?.type ?? data?.type ?? ''; eventConfirmationInputOptions = data?.input?.options ?? data?.options ?? []; + } else if (type === 'request:user_input') { + eventCallback = cb; + askUserQuestions = data?.questions ?? []; + askUserAllowOther = data?.allow_other ?? true; + showAskUserDialog = true; } else if (type.startsWith('terminal:')) { terminalEventHandler(type, data); } else { @@ -1600,7 +1608,9 @@ fileItem.content_type = uploadedFile.meta?.content_type; fileItem.size = uploadedFile.meta?.size; fileItem.collection_name = - res.collection_name ?? uploadedFile.meta?.collection_name ?? uploadedFile.collection_name; + res.collection_name ?? + uploadedFile.meta?.collection_name ?? + uploadedFile.collection_name; } else { fileItem.type = 'text'; fileItem.file = { @@ -2249,9 +2259,7 @@ chatRequestQueues.update((q) => ({ ...q, - [targetChatId]: (q[targetChatId] ?? []).filter( - (m) => !queuedMessageIds.has(m.id) - ) + [targetChatId]: (q[targetChatId] ?? []).filter((m) => !queuedMessageIds.has(m.id)) })); await submitPrompt(combinedPrompt, combinedFiles); @@ -4165,6 +4173,15 @@ {onUpdate} messageQueue={$chatRequestQueues[$chatId] ?? []} {chatTasks} + askUser={{ + show: showAskUserDialog, + questions: askUserQuestions, + allowOther: askUserAllowOther, + onConfirm: (value) => { + showAskUserDialog = false; + eventCallback(value); + } + }} onQueueSendNow={sendQueuedMessageNow} onQueueEdit={editQueuedMessage} onQueueDelete={deleteQueuedMessage} @@ -4219,10 +4236,7 @@ {/if} -
+
{ + showAskUserDialog = false; + eventCallback(value); + } + }} onQueueSendNow={sendQueuedMessageNow} onQueueEdit={editQueuedMessage} onQueueDelete={deleteQueuedMessage} diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 0b6be97771..6b40cec2b9 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -104,6 +104,7 @@ import Knobs from '../icons/Knobs.svelte'; import ValvesModal from '../workspace/common/ValvesModal.svelte'; import Note from '../icons/Note.svelte'; + import AskUserCard from './AskUserCard.svelte'; import { goto } from '$app/navigation'; import InputModal from '../common/InputModal.svelte'; import Expand from '../icons/Expand.svelte'; @@ -170,6 +171,12 @@ export let onQueueEdit: (id: string) => void = () => {}; export let onQueueDelete: (id: string) => void = () => {}; export let onUpdate: (data?: { file?: any }) => void = () => {}; + export let askUser = { + show: false, + questions: [], + allowOther: true, + onConfirm: (_value: any) => {} + }; export let chatTasks = []; @@ -1580,6 +1587,19 @@ on:click={() => createMessagePair(prompt)} /> + {#if askUser?.show} +
+ { + askUser.onConfirm(e.detail); + }} + /> +
+ {/if} + {#if isActive && chatTasks.length > 0}
diff --git a/src/lib/components/workspace/Models/BuiltinTools.svelte b/src/lib/components/workspace/Models/BuiltinTools.svelte index 38e37cbc3e..b91626aa94 100644 --- a/src/lib/components/workspace/Models/BuiltinTools.svelte +++ b/src/lib/components/workspace/Models/BuiltinTools.svelte @@ -1,16 +1,22 @@