diff --git a/packages/desktop/src/pages/Chat.tsx b/packages/desktop/src/pages/Chat.tsx index 64be9d7..4742048 100644 --- a/packages/desktop/src/pages/Chat.tsx +++ b/packages/desktop/src/pages/Chat.tsx @@ -48,6 +48,7 @@ export function ChatPage({ streamingMessage, sendMessage, cancelProcessing, + answerQuestion, } = useChat({ sessionId, onError: (error) => { @@ -265,13 +266,13 @@ export function ChatPage({ {messages.map((message) => ( - + ))} {/* 流式消息 - 复用 ChatMessage 组件 */} {streamingMessage && ( - + )} {isLoading && !streamingMessage && } diff --git a/packages/ui/src/api/client.ts b/packages/ui/src/api/client.ts index 8f5fdd6..636066c 100644 --- a/packages/ui/src/api/client.ts +++ b/packages/ui/src/api/client.ts @@ -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 diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts index f680933..9deebf1 100644 --- a/packages/ui/src/api/types.ts +++ b/packages/ui/src/api/types.ts @@ -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 层合并) diff --git a/packages/ui/src/components/AskUserQuestion.tsx b/packages/ui/src/components/AskUserQuestion.tsx new file mode 100644 index 0000000..9f39b61 --- /dev/null +++ b/packages/ui/src/components/AskUserQuestion.tsx @@ -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>>(new Map()); + // 每个问题的自定义输入 + const [customInputs, setCustomInputs] = useState>(new Map()); + // 是否显示自定义输入框 + const [showCustomInput, setShowCustomInput] = useState>(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(); + + 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 ( +
+
+ + 已回答 +
+ {part.questions.map((q, index) => ( +
+
{q.header || `问题 ${index + 1}`}
+
{part.answers![index] || '(未回答)'}
+
+ ))} +
+ ); + } + + return ( + + {/* 标题 */} +
+ + AI 需要您的输入 +
+ + {/* 问题列表 */} +
+ {part.questions.map((question, qIndex) => ( + + ))} +
+ + {/* 提交按钮 */} +
+ +
+
+ ); +} + +interface QuestionItemProps { + question: Question; + questionIndex: number; + totalQuestions: number; + selected: Set; + 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 ( +
+ {/* 问题标题 */} +
+ {totalQuestions > 1 && ( + + [{questionIndex + 1}/{totalQuestions}] + + )} + {question.header && ( + 【{question.header}】 + )} + {question.question} + {multiSelect && ( + (可多选) + )} +
+ + {/* 选项列表 */} + {question.options && question.options.length > 0 && ( +
+ {question.options.map((option, optIndex) => { + const optionKey = String.fromCharCode(65 + optIndex); // A, B, C, D + const isSelected = selected.has(option.label); + + return ( + + ); + })} + + {/* Other 选项 */} + + + {/* 自定义输入框 */} + {showCustomInput && ( + + 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' + )} + /> + + )} +
+ )} + + {/* 无选项时显示文本输入 */} + {(!question.options || question.options.length === 0) && ( + 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' + )} + /> + )} +
+ ); +} diff --git a/packages/ui/src/components/ChatMessage.tsx b/packages/ui/src/components/ChatMessage.tsx index 0b34516..e842e1d 100644 --- a/packages/ui/src/components/ChatMessage.tsx +++ b/packages/ui/src/components/ChatMessage.tsx @@ -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( - ({ 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( ); case 'tool': return ; + case 'question': + return ( + onAnswerQuestion(part.id, answers) + : undefined + } + disabled={isStreaming} + /> + ); case 'reasoning': return (
diff --git a/packages/ui/src/hooks/useChat.ts b/packages/ui/src/hooks/useChat.ts index 4fce528..a6f7c4a 100644 --- a/packages/ui/src/hooks/useChat.ts +++ b/packages/ui/src/hooks/useChat.ts @@ -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, }; } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index b3828fc..b3d8ec9 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -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'; diff --git a/packages/web/src/pages/Chat.tsx b/packages/web/src/pages/Chat.tsx index e061fd0..162be49 100644 --- a/packages/web/src/pages/Chat.tsx +++ b/packages/web/src/pages/Chat.tsx @@ -65,6 +65,7 @@ export function ChatPage({ setAutoApprove, currentAgent, currentSubagent, + answerQuestion, } = useChat({ sessionId, onError: (error) => { @@ -291,13 +292,13 @@ export function ChatPage({ {messages.map((message) => ( - + ))} {/* 流式消息 - 复用 ChatMessage 组件 */} {streamingMessage && ( - + )} {/* 子 Agent 进度显示 */}