From 8b59569b999cb8e031a86d89911c6a993c776e96 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Sat, 28 Feb 2026 10:39:33 +0800 Subject: [PATCH] Add choice_prompt tool to Assistants and DebugDrawer. Implement state management for choice prompts, including normalization of options and handling user selections. Enhance UI interactions for improved user experience. --- web/pages/Assistants.tsx | 198 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 192 insertions(+), 6 deletions(-) diff --git a/web/pages/Assistants.tsx b/web/pages/Assistants.tsx index f843c28..6ec63c4 100644 --- a/web/pages/Assistants.tsx +++ b/web/pages/Assistants.tsx @@ -1696,6 +1696,32 @@ const TOOL_PARAMETER_HINTS: Record = { }, required: ['msg'], }, + choice_prompt: { + type: 'object', + properties: { + question: { type: 'string', description: 'Question text to ask the user' }, + options: { + type: 'array', + description: 'Selectable options (string or object with id/label/value)', + minItems: 2, + items: { + anyOf: [ + { type: 'string' }, + { + type: 'object', + properties: { + id: { type: 'string' }, + label: { type: 'string' }, + value: { type: 'string' }, + }, + required: ['label'], + }, + ], + }, + }, + }, + required: ['question', 'options'], + }, code_interpreter: { type: 'object', properties: { @@ -1715,10 +1741,12 @@ const DEBUG_CLIENT_TOOLS = [ { id: 'decrease_volume', name: 'decrease_volume', description: '调低音量' }, { id: 'voice_message_prompt', name: 'voice_message_prompt', description: '语音消息提示' }, { id: 'text_msg_prompt', name: 'text_msg_prompt', description: '文本消息提示' }, + { id: 'choice_prompt', name: 'choice_prompt', description: '选项问题提示' }, ] as const; const DEBUG_CLIENT_TOOL_ID_SET = new Set(DEBUG_CLIENT_TOOLS.map((item) => item.id)); const DEBUG_CLIENT_TOOL_WAIT_DEFAULTS: Record = { text_msg_prompt: true, + choice_prompt: true, }; type DynamicVariableEntry = { @@ -1895,6 +1923,49 @@ type DebugTranscriptMessage = { ttfbMs?: number; }; +type DebugPromptPendingResult = { + toolCallId: string; + toolName: string; + toolDisplayName: string; + waitForResponse: boolean; +}; + +type DebugChoicePromptOption = { + id: string; + label: string; + value: string; +}; + +const normalizeChoicePromptOptions = (rawOptions: unknown[]): DebugChoicePromptOption[] => { + const usedIds = new Set(); + const resolved: DebugChoicePromptOption[] = []; + rawOptions.forEach((rawOption, index) => { + let id = `opt_${index + 1}`; + let label = ''; + let value = ''; + if (typeof rawOption === 'string' || typeof rawOption === 'number' || typeof rawOption === 'boolean') { + label = String(rawOption).trim(); + value = label; + } else if (rawOption && typeof rawOption === 'object') { + const row = rawOption as Record; + const labelCandidate = row.label ?? row.text ?? row.name; + label = String(labelCandidate ?? '').trim(); + id = String(row.id ?? id).trim() || id; + const valueCandidate = row.value; + value = valueCandidate === undefined || valueCandidate === null ? label : String(valueCandidate); + } + if (!label) return; + if (usedIds.has(id)) { + let suffix = 2; + while (usedIds.has(`${id}_${suffix}`)) suffix += 1; + id = `${id}_${suffix}`; + } + usedIds.add(id); + resolved.push({ id, label, value }); + }); + return resolved; +}; + // Stable transcription log so the scroll container is not recreated on every render (avoids scroll jumping) const TranscriptionLog: React.FC<{ scrollRef: React.RefObject; @@ -1987,14 +2058,16 @@ export const DebugDrawer: React.FC<{ const [textPromptDialog, setTextPromptDialog] = useState<{ open: boolean; message: string; - pendingResult?: { - toolCallId: string; - toolName: string; - toolDisplayName: string; - waitForResponse: boolean; - }; + pendingResult?: DebugPromptPendingResult; }>({ open: false, message: '' }); + const [choicePromptDialog, setChoicePromptDialog] = useState<{ + open: boolean; + question: string; + options: DebugChoicePromptOption[]; + pendingResult?: DebugPromptPendingResult; + }>({ open: false, question: '', options: [] }); const textPromptDialogRef = useRef(textPromptDialog); + const choicePromptDialogRef = useRef(choicePromptDialog); const [textSessionStarted, setTextSessionStarted] = useState(false); const [wsStatus, setWsStatus] = useState<'disconnected' | 'connecting' | 'ready' | 'error'>('disconnected'); const [wsError, setWsError] = useState(''); @@ -2176,6 +2249,7 @@ export const DebugDrawer: React.FC<{ stopMedia(); closeWs(); setTextPromptDialog({ open: false, message: '' }); + setChoicePromptDialog({ open: false, question: '', options: [] }); if (audioCtxRef.current) { void audioCtxRef.current.close(); audioCtxRef.current = null; @@ -2198,6 +2272,10 @@ export const DebugDrawer: React.FC<{ textPromptDialogRef.current = textPromptDialog; }, [textPromptDialog]); + useEffect(() => { + choicePromptDialogRef.current = choicePromptDialog; + }, [choicePromptDialog]); + useEffect(() => { dynamicVariableSeqRef.current = 0; setDynamicVariables([]); @@ -2458,6 +2536,40 @@ export const DebugDrawer: React.FC<{ } }; + const closeChoicePromptDialog = ( + action: 'select' | 'dismiss', + selectedOption?: DebugChoicePromptOption + ) => { + const snapshot = choicePromptDialogRef.current; + const pending = snapshot?.pendingResult; + const question = snapshot?.question || ''; + const options = snapshot?.options || []; + setChoicePromptDialog({ open: false, question: '', options: [] }); + if (pending?.waitForResponse) { + emitClientToolResult( + { + tool_call_id: pending.toolCallId, + name: pending.toolName, + output: { + message: action === 'select' ? 'choice_selected' : 'choice_dismissed', + action, + question, + option: selectedOption + ? { + id: selectedOption.id, + label: selectedOption.label, + value: selectedOption.value, + } + : null, + options, + }, + status: { code: 200, message: 'ok' }, + }, + pending.toolDisplayName + ); + } + }; + const scheduleQueuedPlayback = (ctx: AudioContext) => { const queue = queuedAudioBuffersRef.current; if (queue.length === 0) return; @@ -2589,6 +2701,9 @@ export const DebugDrawer: React.FC<{ if (textPromptDialog.open) { closeTextPromptDialog('dismiss'); } + if (choicePromptDialog.open) { + closeChoicePromptDialog('dismiss'); + } stopVoiceCapture(); stopMedia(); closeWs(); @@ -2596,6 +2711,7 @@ export const DebugDrawer: React.FC<{ clearResponseTracking(); setMessages([]); setTextPromptDialog({ open: false, message: '' }); + setChoicePromptDialog({ open: false, question: '', options: [] }); lastUserFinalRef.current = ''; setIsLoading(false); }; @@ -3237,6 +3353,7 @@ export const DebugDrawer: React.FC<{ resultPayload.output = { message: "Missing required argument 'msg'" }; resultPayload.status = { code: 422, message: 'invalid_arguments' }; } else { + setChoicePromptDialog({ open: false, question: '', options: [] }); setTextPromptDialog({ open: true, message: msg, @@ -3254,6 +3371,40 @@ export const DebugDrawer: React.FC<{ return; } } + } else if (toolName === 'choice_prompt') { + const question = String(parsedArgs?.question || '').trim(); + const rawOptions = Array.isArray(parsedArgs?.options) ? parsedArgs.options : []; + const options = normalizeChoicePromptOptions(rawOptions); + if (!question) { + resultPayload.output = { message: "Missing required argument 'question'" }; + resultPayload.status = { code: 422, message: 'invalid_arguments' }; + } else if (options.length < 2) { + resultPayload.output = { message: "Argument 'options' requires at least 2 valid entries" }; + resultPayload.status = { code: 422, message: 'invalid_arguments' }; + } else { + setTextPromptDialog({ open: false, message: '' }); + setChoicePromptDialog({ + open: true, + question, + options, + pendingResult: { + toolCallId: toolCallId, + toolName, + toolDisplayName, + waitForResponse, + }, + }); + if (!waitForResponse) { + resultPayload.output = { + message: 'choice_prompt_shown', + question, + options, + }; + resultPayload.status = { code: 200, message: 'ok' }; + } else { + return; + } + } } } catch (err) { resultPayload.output = { @@ -3984,6 +4135,41 @@ export const DebugDrawer: React.FC<{ + )} + {choicePromptDialog.open && ( +
+
+ +
+
选项问题提示
+

{choicePromptDialog.question}

+
+
+ {choicePromptDialog.options.map((option) => ( + + ))} +
+
+ +
+
+
)}