feat(ui): 实现 ask_user_question 工具的前端支持
- 添加 AskUserQuestion 组件,支持单选/多选和自定义输入 - 添加 Question 相关类型定义 (QuestionOption, Question, QuestionMessagePart) - 在 useChat 中处理 ask_user_question 工具完成事件,转换为问题 UI - 添加 answerQuestion 回调用于提交用户回答 - 更新 ChatMessage 组件支持渲染问题类型的消息部分
This commit is contained in:
@@ -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 />}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 进度显示 */}
|
||||
|
||||
Reference in New Issue
Block a user