feat(ui): 实现 ask_user_question 工具的前端支持

- 添加 AskUserQuestion 组件,支持单选/多选和自定义输入
- 添加 Question 相关类型定义 (QuestionOption, Question, QuestionMessagePart)
- 在 useChat 中处理 ask_user_question 工具完成事件,转换为问题 UI
- 添加 answerQuestion 回调用于提交用户回答
- 更新 ChatMessage 组件支持渲染问题类型的消息部分
This commit is contained in:
2025-12-16 23:14:25 +08:00
parent 791c4a4616
commit 3b170738ee
8 changed files with 556 additions and 12 deletions
+3 -2
View File
@@ -48,6 +48,7 @@ export function ChatPage({
streamingMessage,
sendMessage,
cancelProcessing,
answerQuestion,
} = useChat({
sessionId,
onError: (error) => {
@@ -265,13 +266,13 @@ export function ChatPage({
<AnimatePresence mode="popLayout">
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
<ChatMessage key={message.id} message={message} onAnswerQuestion={answerQuestion} />
))}
</AnimatePresence>
{/* 流式消息 - 复用 ChatMessage 组件 */}
{streamingMessage && (
<ChatMessage message={streamingMessage} isStreaming />
<ChatMessage message={streamingMessage} isStreaming onAnswerQuestion={answerQuestion} />
)}
{isLoading && !streamingMessage && <TypingIndicator />}
+4
View File
@@ -131,6 +131,10 @@ export type {
FileSearchResponse,
// Agent mode types
AgentModeType,
// Question types (for ask_user_question tool)
QuestionOption,
Question,
QuestionMessagePart,
} from './types.js';
// API Configuration
+39 -1
View File
@@ -68,10 +68,48 @@ export interface ReasoningMessagePart {
text: string;
}
/**
* 问题选项
*/
export interface QuestionOption {
/** 选项标签 */
label: string;
/** 选项说明 */
description?: string;
}
/**
* 问题定义
*/
export interface Question {
/** 问题内容 */
question: string;
/** 简短标签 */
header?: string;
/** 选项列表 */
options?: QuestionOption[];
/** 是否允许多选 */
multiSelect?: boolean;
}
/**
* 用户问题 Part(由 ask_user_question 工具生成)
*/
export interface QuestionMessagePart {
type: 'question';
id: string;
/** 问题列表 */
questions: Question[];
/** 是否已回答 */
answered?: boolean;
/** 用户的回答 */
answers?: string[];
}
/**
* 消息 Part 联合类型
*/
export type MessagePart = TextMessagePart | ToolMessagePart | ReasoningMessagePart;
export type MessagePart = TextMessagePart | ToolMessagePart | ReasoningMessagePart | QuestionMessagePart;
/**
* 消息格式(存储层已经是 2-message 格式,无需 API 层合并)
@@ -0,0 +1,349 @@
/**
* Ask User Question Component
*
* 展示 AI 向用户提出的问题,支持单选/多选和自定义输入
*/
import { useState, useCallback } from 'react';
import { motion } from 'framer-motion';
import { HelpCircle, Check, ChevronRight } from 'lucide-react';
import { cn } from '../utils/cn';
import type { Question, QuestionMessagePart } from '../api/types.js';
interface AskUserQuestionProps {
part: QuestionMessagePart;
/** 提交回答回调 */
onAnswer?: (answers: string[]) => void;
/** 是否禁用(如消息正在流式输出时) */
disabled?: boolean;
}
export function AskUserQuestion({ part, onAnswer, disabled = false }: AskUserQuestionProps) {
// 每个问题的选择状态
const [selections, setSelections] = useState<Map<number, Set<string>>>(new Map());
// 每个问题的自定义输入
const [customInputs, setCustomInputs] = useState<Map<number, string>>(new Map());
// 是否显示自定义输入框
const [showCustomInput, setShowCustomInput] = useState<Map<number, boolean>>(new Map());
const handleOptionClick = useCallback(
(questionIndex: number, optionLabel: string, multiSelect: boolean) => {
if (disabled || part.answered) return;
setSelections((prev) => {
const newSelections = new Map(prev);
const current = newSelections.get(questionIndex) || new Set<string>();
if (optionLabel === '__other__') {
// 点击 Other 选项,显示输入框
setShowCustomInput((prev) => {
const newShow = new Map(prev);
newShow.set(questionIndex, true);
return newShow;
});
return newSelections;
}
if (multiSelect) {
// 多选:切换选中状态
if (current.has(optionLabel)) {
current.delete(optionLabel);
} else {
current.add(optionLabel);
}
} else {
// 单选:替换选中项
current.clear();
current.add(optionLabel);
}
newSelections.set(questionIndex, current);
return newSelections;
});
// 单选时,隐藏自定义输入
if (!multiSelect) {
setShowCustomInput((prev) => {
const newShow = new Map(prev);
newShow.set(questionIndex, false);
return newShow;
});
setCustomInputs((prev) => {
const newInputs = new Map(prev);
newInputs.delete(questionIndex);
return newInputs;
});
}
},
[disabled, part.answered]
);
const handleCustomInputChange = useCallback(
(questionIndex: number, value: string) => {
if (disabled || part.answered) return;
setCustomInputs((prev) => {
const newInputs = new Map(prev);
newInputs.set(questionIndex, value);
return newInputs;
});
},
[disabled, part.answered]
);
const handleSubmit = useCallback(() => {
if (disabled || part.answered || !onAnswer) return;
const answers: string[] = [];
part.questions.forEach((q, index) => {
const selected = selections.get(index);
const customInput = customInputs.get(index);
if (customInput) {
// 有自定义输入
answers.push(`Other: ${customInput}`);
} else if (selected && selected.size > 0) {
// 有选中的选项
const selectedLabels = Array.from(selected);
if (q.multiSelect) {
answers.push(selectedLabels.join(', '));
} else {
answers.push(selectedLabels[0]);
}
} else {
// 没有回答
answers.push('');
}
});
onAnswer(answers);
}, [disabled, part.answered, part.questions, selections, customInputs, onAnswer]);
// 检查是否可以提交(至少有一个问题有回答)
const canSubmit = part.questions.some((_, index) => {
const selected = selections.get(index);
const customInput = customInputs.get(index);
return (selected && selected.size > 0) || customInput;
});
// 如果已回答,显示回答结果
if (part.answered && part.answers) {
return (
<div className="border border-green-500/30 rounded-lg bg-green-500/5 p-4">
<div className="flex items-center gap-2 text-green-500 mb-3">
<Check size={16} />
<span className="text-sm font-medium"></span>
</div>
{part.questions.map((q, index) => (
<div key={index} className="mb-2 last:mb-0">
<div className="text-sm text-fg-muted mb-1">{q.header || `问题 ${index + 1}`}</div>
<div className="text-sm text-fg-secondary">{part.answers![index] || '(未回答)'}</div>
</div>
))}
</div>
);
}
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="border border-primary-500/30 rounded-lg bg-primary-500/5 p-4"
>
{/* 标题 */}
<div className="flex items-center gap-2 text-primary-400 mb-4">
<HelpCircle size={16} />
<span className="text-sm font-medium">AI </span>
</div>
{/* 问题列表 */}
<div className="space-y-4">
{part.questions.map((question, qIndex) => (
<QuestionItem
key={qIndex}
question={question}
questionIndex={qIndex}
totalQuestions={part.questions.length}
selected={selections.get(qIndex) || new Set()}
customInput={customInputs.get(qIndex) || ''}
showCustomInput={showCustomInput.get(qIndex) || false}
onOptionClick={handleOptionClick}
onCustomInputChange={handleCustomInputChange}
disabled={disabled}
/>
))}
</div>
{/* 提交按钮 */}
<div className="mt-4 flex justify-end">
<button
onClick={handleSubmit}
disabled={disabled || !canSubmit}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors',
canSubmit && !disabled
? 'bg-primary-600 text-white hover:bg-primary-700'
: 'bg-surface-muted text-fg-subtle cursor-not-allowed'
)}
>
<ChevronRight size={16} />
</button>
</div>
</motion.div>
);
}
interface QuestionItemProps {
question: Question;
questionIndex: number;
totalQuestions: number;
selected: Set<string>;
customInput: string;
showCustomInput: boolean;
onOptionClick: (qIndex: number, label: string, multiSelect: boolean) => void;
onCustomInputChange: (qIndex: number, value: string) => void;
disabled: boolean;
}
function QuestionItem({
question,
questionIndex,
totalQuestions,
selected,
customInput,
showCustomInput,
onOptionClick,
onCustomInputChange,
disabled,
}: QuestionItemProps) {
const multiSelect = question.multiSelect ?? false;
return (
<div className="border-b border-line last:border-b-0 pb-4 last:pb-0">
{/* 问题标题 */}
<div className="mb-3">
{totalQuestions > 1 && (
<span className="text-xs text-fg-subtle mr-2">
[{questionIndex + 1}/{totalQuestions}]
</span>
)}
{question.header && (
<span className="text-xs text-primary-400 mr-2">{question.header}</span>
)}
<span className="text-sm text-fg-primary">{question.question}</span>
{multiSelect && (
<span className="text-xs text-fg-subtle ml-2">()</span>
)}
</div>
{/* 选项列表 */}
{question.options && question.options.length > 0 && (
<div className="space-y-2">
{question.options.map((option, optIndex) => {
const optionKey = String.fromCharCode(65 + optIndex); // A, B, C, D
const isSelected = selected.has(option.label);
return (
<button
key={optIndex}
onClick={() => onOptionClick(questionIndex, option.label, multiSelect)}
disabled={disabled}
className={cn(
'w-full flex items-start gap-3 p-3 rounded-lg border transition-all text-left',
isSelected
? 'border-primary-500 bg-primary-500/10'
: 'border-line hover:border-primary-500/50 hover:bg-surface-muted/50',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
{/* 选项标记 */}
<span
className={cn(
'flex-shrink-0 w-6 h-6 rounded flex items-center justify-center text-xs font-medium',
isSelected ? 'bg-primary-500 text-white' : 'bg-surface-muted text-fg-muted'
)}
>
{isSelected ? <Check size={14} /> : optionKey}
</span>
{/* 选项内容 */}
<div className="flex-1 min-w-0">
<div className="text-sm text-fg-secondary">{option.label}</div>
{option.description && (
<div className="text-xs text-fg-muted mt-1">{option.description}</div>
)}
</div>
</button>
);
})}
{/* Other 选项 */}
<button
onClick={() => onOptionClick(questionIndex, '__other__', multiSelect)}
disabled={disabled}
className={cn(
'w-full flex items-start gap-3 p-3 rounded-lg border transition-all text-left',
showCustomInput
? 'border-primary-500 bg-primary-500/10'
: 'border-line hover:border-primary-500/50 hover:bg-surface-muted/50',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
<span
className={cn(
'flex-shrink-0 w-6 h-6 rounded flex items-center justify-center text-xs font-medium',
showCustomInput ? 'bg-primary-500 text-white' : 'bg-surface-muted text-fg-muted'
)}
>
{showCustomInput ? <Check size={14} /> : String.fromCharCode(65 + question.options!.length)}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm text-fg-secondary">Other</div>
<div className="text-xs text-fg-muted mt-1"></div>
</div>
</button>
{/* 自定义输入框 */}
{showCustomInput && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="ml-9"
>
<input
type="text"
value={customInput}
onChange={(e) => onCustomInputChange(questionIndex, e.target.value)}
placeholder="请输入您的回答..."
disabled={disabled}
className={cn(
'w-full px-3 py-2 rounded-lg border border-line bg-surface-base',
'text-sm text-fg-primary placeholder:text-fg-subtle',
'focus:outline-none focus:border-primary-500',
disabled && 'opacity-50 cursor-not-allowed'
)}
/>
</motion.div>
)}
</div>
)}
{/* 无选项时显示文本输入 */}
{(!question.options || question.options.length === 0) && (
<input
type="text"
value={customInput}
onChange={(e) => onCustomInputChange(questionIndex, e.target.value)}
placeholder="请输入您的回答..."
disabled={disabled}
className={cn(
'w-full px-3 py-2 rounded-lg border border-line bg-surface-base',
'text-sm text-fg-primary placeholder:text-fg-subtle',
'focus:outline-none focus:border-primary-500',
disabled && 'opacity-50 cursor-not-allowed'
)}
/>
)}
</div>
);
}
+18 -2
View File
@@ -22,16 +22,19 @@ import { fadeInUp, smoothTransition } from '../utils/animations';
import { getAgentDisplayName } from '../utils/agent';
import { Markdown } from './Markdown';
import { FileMentionText } from './FileMentionTag';
import type { Message, ToolCallInfo, ToolStatus, ToolMessagePart } from '../api/types.js';
import type { Message, ToolCallInfo, ToolStatus, ToolMessagePart, QuestionMessagePart } from '../api/types.js';
import { AskUserQuestion } from './AskUserQuestion.js';
interface ChatMessageProps {
message: Message;
/** 是否为流式输出中(显示打字光标) */
isStreaming?: boolean;
/** 回答问题的回调(用于 ask_user_question 工具) */
onAnswerQuestion?: (questionPartId: string, answers: string[]) => void;
}
export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
({ message, isStreaming = false }, ref) => {
({ message, isStreaming = false, onAnswerQuestion }, ref) => {
const isUser = message.role === 'user';
const [copied, setCopied] = useState(false);
@@ -81,6 +84,19 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
);
case 'tool':
return <ToolPartItem key={part.id} part={part} />;
case 'question':
return (
<AskUserQuestion
key={part.id}
part={part as QuestionMessagePart}
onAnswer={
onAnswerQuestion
? (answers) => onAnswerQuestion(part.id, answers)
: undefined
}
disabled={isStreaming}
/>
);
case 'reasoning':
return (
<div key={part.id} className="text-fg-muted italic border-l-2 border-line pl-3">
+135 -5
View File
@@ -13,6 +13,8 @@ import type {
ToolEndPayload,
MessagePart,
ToolMessagePart,
QuestionMessagePart,
Question,
AgentModeType,
SubagentStartPayload,
SubagentEndPayload,
@@ -240,7 +242,54 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
setState((prev) => {
if (!prev.streamingMessage) return prev;
// 查找并更新对应的工具 part
// 查找完成的工具
const completedTool = prev.streamingMessage.parts.find(
(part) => part.type === 'tool' && part.id === payload.id
) as ToolMessagePart | undefined;
// 检查是否为 ask_user_question 工具
const isAskUserQuestion = completedTool?.toolName === 'ask_user_question';
// 如果是 ask_user_question 且成功,转换为 QuestionMessagePart
if (isAskUserQuestion && payload.status === 'completed' && payload.result) {
const result = payload.result as {
metadata?: {
type?: string;
questions?: Array<{ index: number; header?: string; optionCount: number; multiSelect: boolean }>;
requiresUserInput?: boolean;
};
};
// 从工具参数中提取完整的问题数据
const questionsArg = completedTool?.arguments?.questions as Question[] | undefined;
if (result.metadata?.requiresUserInput && questionsArg) {
// 创建 QuestionMessagePart 替换原来的 tool part
const questionPart: QuestionMessagePart = {
type: 'question',
id: payload.id,
questions: questionsArg,
answered: false,
};
const parts = prev.streamingMessage.parts.map((part) => {
if (part.type === 'tool' && part.id === payload.id) {
return questionPart;
}
return part;
});
return {
...prev,
streamingMessage: {
...prev.streamingMessage,
parts,
},
};
}
}
// 查找并更新对应的工具 part(普通工具)
const parts = prev.streamingMessage.parts.map((part) => {
if (part.type === 'tool' && part.id === payload.id) {
return {
@@ -255,10 +304,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
});
// 查找完成的工具是否为 task,如果是则恢复主 agent
const completedTool = prev.streamingMessage.parts.find(
(part) => part.type === 'tool' && part.id === payload.id
);
const isTaskTool = completedTool?.type === 'tool' && completedTool.toolName === 'task';
const isTaskTool = completedTool?.toolName === 'task';
const newAgent = isTaskTool ? prev.agentMode : prev.currentAgent;
return {
@@ -606,6 +652,89 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
[sessionId]
);
// 回答问题(ask_user_question 工具)
const answerQuestion = useCallback(
(questionPartId: string, answers: string[]) => {
// 更新问题状态为已回答
setState((prev) => {
// 更新流式消息中的问题
if (prev.streamingMessage) {
const parts = prev.streamingMessage.parts.map((part) => {
if (part.type === 'question' && part.id === questionPartId) {
return {
...part,
answered: true,
answers,
} as QuestionMessagePart;
}
return part;
});
// 发送回答作为用户消息
const answerText = answers.filter((a) => a).join('\n');
if (answerText && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: 'message',
sessionId,
payload: {
content: answerText,
agentMode: state.agentMode,
autoApprove: state.autoApprove,
},
})
);
}
return {
...prev,
streamingMessage: {
...prev.streamingMessage,
parts,
},
};
}
// 也检查已完成的消息
const messages = prev.messages.map((msg) => {
if (msg.parts) {
const parts = msg.parts.map((part) => {
if (part.type === 'question' && part.id === questionPartId) {
return {
...part,
answered: true,
answers,
} as QuestionMessagePart;
}
return part;
});
return { ...msg, parts };
}
return msg;
});
// 发送回答作为用户消息
const answerText = answers.filter((a) => a).join('\n');
if (answerText && wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: 'message',
sessionId,
payload: {
content: answerText,
agentMode: state.agentMode,
autoApprove: state.autoApprove,
},
})
);
}
return { ...prev, messages };
});
},
[sessionId, state.agentMode, state.autoApprove]
);
// 初始化
useEffect(() => {
// 重置状态
@@ -659,5 +788,6 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
denyPermission,
setAgentMode,
setAutoApprove,
answerQuestion,
};
}
+5
View File
@@ -176,6 +176,10 @@ export type {
FileSearchResponse,
// Agent Mode types
AgentModeType,
// Question types (for ask_user_question tool)
QuestionOption,
Question,
QuestionMessagePart,
} from './api/client.js';
// Primitives (shadcn/ui style)
@@ -222,6 +226,7 @@ export { Skeleton, MessageSkeleton, SessionSkeleton, FileSkeleton } from './comp
export { Markdown } from './components/Markdown.js';
export { CodeBlock, InlineCode } from './components/CodeBlock.js';
export { SubagentProgress, SubagentProgressCompact } from './components/SubagentProgress.js';
export { AskUserQuestion } from './components/AskUserQuestion.js';
// Toast function (re-export from sonner)
export { toast } from 'sonner';
+3 -2
View File
@@ -65,6 +65,7 @@ export function ChatPage({
setAutoApprove,
currentAgent,
currentSubagent,
answerQuestion,
} = useChat({
sessionId,
onError: (error) => {
@@ -291,13 +292,13 @@ export function ChatPage({
<AnimatePresence mode="popLayout">
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
<ChatMessage key={message.id} message={message} onAnswerQuestion={answerQuestion} />
))}
</AnimatePresence>
{/* 流式消息 - 复用 ChatMessage 组件 */}
{streamingMessage && (
<ChatMessage message={streamingMessage} isStreaming />
<ChatMessage message={streamingMessage} isStreaming onAnswerQuestion={answerQuestion} />
)}
{/* 子 Agent 进度显示 */}