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.

This commit is contained in:
Xin Wang
2026-02-28 10:39:33 +08:00
parent e40899613f
commit 8b59569b99

View File

@@ -1696,6 +1696,32 @@ const TOOL_PARAMETER_HINTS: Record<string, any> = {
}, },
required: ['msg'], 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: { code_interpreter: {
type: 'object', type: 'object',
properties: { properties: {
@@ -1715,10 +1741,12 @@ const DEBUG_CLIENT_TOOLS = [
{ id: 'decrease_volume', name: 'decrease_volume', description: '调低音量' }, { id: 'decrease_volume', name: 'decrease_volume', description: '调低音量' },
{ id: 'voice_message_prompt', name: 'voice_message_prompt', description: '语音消息提示' }, { id: 'voice_message_prompt', name: 'voice_message_prompt', description: '语音消息提示' },
{ id: 'text_msg_prompt', name: 'text_msg_prompt', description: '文本消息提示' }, { id: 'text_msg_prompt', name: 'text_msg_prompt', description: '文本消息提示' },
{ id: 'choice_prompt', name: 'choice_prompt', description: '选项问题提示' },
] as const; ] as const;
const DEBUG_CLIENT_TOOL_ID_SET = new Set<string>(DEBUG_CLIENT_TOOLS.map((item) => item.id)); const DEBUG_CLIENT_TOOL_ID_SET = new Set<string>(DEBUG_CLIENT_TOOLS.map((item) => item.id));
const DEBUG_CLIENT_TOOL_WAIT_DEFAULTS: Record<string, boolean> = { const DEBUG_CLIENT_TOOL_WAIT_DEFAULTS: Record<string, boolean> = {
text_msg_prompt: true, text_msg_prompt: true,
choice_prompt: true,
}; };
type DynamicVariableEntry = { type DynamicVariableEntry = {
@@ -1895,6 +1923,49 @@ type DebugTranscriptMessage = {
ttfbMs?: number; 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<string>();
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<string, unknown>;
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) // Stable transcription log so the scroll container is not recreated on every render (avoids scroll jumping)
const TranscriptionLog: React.FC<{ const TranscriptionLog: React.FC<{
scrollRef: React.RefObject<HTMLDivElement | null>; scrollRef: React.RefObject<HTMLDivElement | null>;
@@ -1987,14 +2058,16 @@ export const DebugDrawer: React.FC<{
const [textPromptDialog, setTextPromptDialog] = useState<{ const [textPromptDialog, setTextPromptDialog] = useState<{
open: boolean; open: boolean;
message: string; message: string;
pendingResult?: { pendingResult?: DebugPromptPendingResult;
toolCallId: string;
toolName: string;
toolDisplayName: string;
waitForResponse: boolean;
};
}>({ open: false, message: '' }); }>({ open: false, message: '' });
const [choicePromptDialog, setChoicePromptDialog] = useState<{
open: boolean;
question: string;
options: DebugChoicePromptOption[];
pendingResult?: DebugPromptPendingResult;
}>({ open: false, question: '', options: [] });
const textPromptDialogRef = useRef(textPromptDialog); const textPromptDialogRef = useRef(textPromptDialog);
const choicePromptDialogRef = useRef(choicePromptDialog);
const [textSessionStarted, setTextSessionStarted] = useState(false); const [textSessionStarted, setTextSessionStarted] = useState(false);
const [wsStatus, setWsStatus] = useState<'disconnected' | 'connecting' | 'ready' | 'error'>('disconnected'); const [wsStatus, setWsStatus] = useState<'disconnected' | 'connecting' | 'ready' | 'error'>('disconnected');
const [wsError, setWsError] = useState(''); const [wsError, setWsError] = useState('');
@@ -2176,6 +2249,7 @@ export const DebugDrawer: React.FC<{
stopMedia(); stopMedia();
closeWs(); closeWs();
setTextPromptDialog({ open: false, message: '' }); setTextPromptDialog({ open: false, message: '' });
setChoicePromptDialog({ open: false, question: '', options: [] });
if (audioCtxRef.current) { if (audioCtxRef.current) {
void audioCtxRef.current.close(); void audioCtxRef.current.close();
audioCtxRef.current = null; audioCtxRef.current = null;
@@ -2198,6 +2272,10 @@ export const DebugDrawer: React.FC<{
textPromptDialogRef.current = textPromptDialog; textPromptDialogRef.current = textPromptDialog;
}, [textPromptDialog]); }, [textPromptDialog]);
useEffect(() => {
choicePromptDialogRef.current = choicePromptDialog;
}, [choicePromptDialog]);
useEffect(() => { useEffect(() => {
dynamicVariableSeqRef.current = 0; dynamicVariableSeqRef.current = 0;
setDynamicVariables([]); 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 scheduleQueuedPlayback = (ctx: AudioContext) => {
const queue = queuedAudioBuffersRef.current; const queue = queuedAudioBuffersRef.current;
if (queue.length === 0) return; if (queue.length === 0) return;
@@ -2589,6 +2701,9 @@ export const DebugDrawer: React.FC<{
if (textPromptDialog.open) { if (textPromptDialog.open) {
closeTextPromptDialog('dismiss'); closeTextPromptDialog('dismiss');
} }
if (choicePromptDialog.open) {
closeChoicePromptDialog('dismiss');
}
stopVoiceCapture(); stopVoiceCapture();
stopMedia(); stopMedia();
closeWs(); closeWs();
@@ -2596,6 +2711,7 @@ export const DebugDrawer: React.FC<{
clearResponseTracking(); clearResponseTracking();
setMessages([]); setMessages([]);
setTextPromptDialog({ open: false, message: '' }); setTextPromptDialog({ open: false, message: '' });
setChoicePromptDialog({ open: false, question: '', options: [] });
lastUserFinalRef.current = ''; lastUserFinalRef.current = '';
setIsLoading(false); setIsLoading(false);
}; };
@@ -3237,6 +3353,7 @@ export const DebugDrawer: React.FC<{
resultPayload.output = { message: "Missing required argument 'msg'" }; resultPayload.output = { message: "Missing required argument 'msg'" };
resultPayload.status = { code: 422, message: 'invalid_arguments' }; resultPayload.status = { code: 422, message: 'invalid_arguments' };
} else { } else {
setChoicePromptDialog({ open: false, question: '', options: [] });
setTextPromptDialog({ setTextPromptDialog({
open: true, open: true,
message: msg, message: msg,
@@ -3254,6 +3371,40 @@ export const DebugDrawer: React.FC<{
return; 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) { } catch (err) {
resultPayload.output = { resultPayload.output = {
@@ -3984,6 +4135,41 @@ export const DebugDrawer: React.FC<{
</div> </div>
</div> </div>
</div> </div>
)}
{choicePromptDialog.open && (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-black/55 backdrop-blur-[1px]">
<div className="relative w-[92%] max-w-md rounded-xl border border-white/15 bg-card/95 p-4 shadow-2xl animate-in zoom-in-95 duration-200">
<button
type="button"
onClick={() => closeChoicePromptDialog('dismiss')}
className="absolute right-3 top-3 rounded-sm opacity-70 hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
title="关闭"
>
<X className="h-4 w-4" />
</button>
<div className="mb-3 pr-6">
<div className="text-[10px] font-black tracking-[0.14em] uppercase text-cyan-300"></div>
<p className="mt-2 text-sm leading-6 text-foreground whitespace-pre-wrap break-words">{choicePromptDialog.question}</p>
</div>
<div className="space-y-2">
{choicePromptDialog.options.map((option) => (
<Button
key={option.id}
variant="outline"
className="w-full justify-start text-left h-auto py-2.5 px-3"
onClick={() => closeChoicePromptDialog('select', option)}
>
{option.label}
</Button>
))}
</div>
<div className="mt-3 flex justify-end">
<Button size="sm" variant="ghost" onClick={() => closeChoicePromptDialog('dismiss')}>
</Button>
</div>
</div>
</div>
)} )}
</div> </div>
</Drawer> </Drawer>