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,
|
streamingMessage,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
cancelProcessing,
|
cancelProcessing,
|
||||||
|
answerQuestion,
|
||||||
} = useChat({
|
} = useChat({
|
||||||
sessionId,
|
sessionId,
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -265,13 +266,13 @@ export function ChatPage({
|
|||||||
|
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout">
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<ChatMessage key={message.id} message={message} />
|
<ChatMessage key={message.id} message={message} onAnswerQuestion={answerQuestion} />
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* 流式消息 - 复用 ChatMessage 组件 */}
|
{/* 流式消息 - 复用 ChatMessage 组件 */}
|
||||||
{streamingMessage && (
|
{streamingMessage && (
|
||||||
<ChatMessage message={streamingMessage} isStreaming />
|
<ChatMessage message={streamingMessage} isStreaming onAnswerQuestion={answerQuestion} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && !streamingMessage && <TypingIndicator />}
|
{isLoading && !streamingMessage && <TypingIndicator />}
|
||||||
|
|||||||
@@ -131,6 +131,10 @@ export type {
|
|||||||
FileSearchResponse,
|
FileSearchResponse,
|
||||||
// Agent mode types
|
// Agent mode types
|
||||||
AgentModeType,
|
AgentModeType,
|
||||||
|
// Question types (for ask_user_question tool)
|
||||||
|
QuestionOption,
|
||||||
|
Question,
|
||||||
|
QuestionMessagePart,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
// API Configuration
|
// API Configuration
|
||||||
|
|||||||
@@ -68,10 +68,48 @@ export interface ReasoningMessagePart {
|
|||||||
text: string;
|
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 联合类型
|
* 消息 Part 联合类型
|
||||||
*/
|
*/
|
||||||
export type MessagePart = TextMessagePart | ToolMessagePart | ReasoningMessagePart;
|
export type MessagePart = TextMessagePart | ToolMessagePart | ReasoningMessagePart | QuestionMessagePart;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 消息格式(存储层已经是 2-message 格式,无需 API 层合并)
|
* 消息格式(存储层已经是 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 { getAgentDisplayName } from '../utils/agent';
|
||||||
import { Markdown } from './Markdown';
|
import { Markdown } from './Markdown';
|
||||||
import { FileMentionText } from './FileMentionTag';
|
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 {
|
interface ChatMessageProps {
|
||||||
message: Message;
|
message: Message;
|
||||||
/** 是否为流式输出中(显示打字光标) */
|
/** 是否为流式输出中(显示打字光标) */
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
|
/** 回答问题的回调(用于 ask_user_question 工具) */
|
||||||
|
onAnswerQuestion?: (questionPartId: string, answers: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
|
export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
|
||||||
({ message, isStreaming = false }, ref) => {
|
({ message, isStreaming = false, onAnswerQuestion }, ref) => {
|
||||||
const isUser = message.role === 'user';
|
const isUser = message.role === 'user';
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
@@ -81,6 +84,19 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
|
|||||||
);
|
);
|
||||||
case 'tool':
|
case 'tool':
|
||||||
return <ToolPartItem key={part.id} part={part} />;
|
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':
|
case 'reasoning':
|
||||||
return (
|
return (
|
||||||
<div key={part.id} className="text-fg-muted italic border-l-2 border-line pl-3">
|
<div key={part.id} className="text-fg-muted italic border-l-2 border-line pl-3">
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import type {
|
|||||||
ToolEndPayload,
|
ToolEndPayload,
|
||||||
MessagePart,
|
MessagePart,
|
||||||
ToolMessagePart,
|
ToolMessagePart,
|
||||||
|
QuestionMessagePart,
|
||||||
|
Question,
|
||||||
AgentModeType,
|
AgentModeType,
|
||||||
SubagentStartPayload,
|
SubagentStartPayload,
|
||||||
SubagentEndPayload,
|
SubagentEndPayload,
|
||||||
@@ -240,7 +242,54 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
|||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
if (!prev.streamingMessage) return 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) => {
|
const parts = prev.streamingMessage.parts.map((part) => {
|
||||||
if (part.type === 'tool' && part.id === payload.id) {
|
if (part.type === 'tool' && part.id === payload.id) {
|
||||||
return {
|
return {
|
||||||
@@ -255,10 +304,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 查找完成的工具是否为 task,如果是则恢复主 agent
|
// 查找完成的工具是否为 task,如果是则恢复主 agent
|
||||||
const completedTool = prev.streamingMessage.parts.find(
|
const isTaskTool = completedTool?.toolName === 'task';
|
||||||
(part) => part.type === 'tool' && part.id === payload.id
|
|
||||||
);
|
|
||||||
const isTaskTool = completedTool?.type === 'tool' && completedTool.toolName === 'task';
|
|
||||||
const newAgent = isTaskTool ? prev.agentMode : prev.currentAgent;
|
const newAgent = isTaskTool ? prev.agentMode : prev.currentAgent;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -606,6 +652,89 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
|||||||
[sessionId]
|
[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(() => {
|
useEffect(() => {
|
||||||
// 重置状态
|
// 重置状态
|
||||||
@@ -659,5 +788,6 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
|||||||
denyPermission,
|
denyPermission,
|
||||||
setAgentMode,
|
setAgentMode,
|
||||||
setAutoApprove,
|
setAutoApprove,
|
||||||
|
answerQuestion,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,10 @@ export type {
|
|||||||
FileSearchResponse,
|
FileSearchResponse,
|
||||||
// Agent Mode types
|
// Agent Mode types
|
||||||
AgentModeType,
|
AgentModeType,
|
||||||
|
// Question types (for ask_user_question tool)
|
||||||
|
QuestionOption,
|
||||||
|
Question,
|
||||||
|
QuestionMessagePart,
|
||||||
} from './api/client.js';
|
} from './api/client.js';
|
||||||
|
|
||||||
// Primitives (shadcn/ui style)
|
// Primitives (shadcn/ui style)
|
||||||
@@ -222,6 +226,7 @@ export { Skeleton, MessageSkeleton, SessionSkeleton, FileSkeleton } from './comp
|
|||||||
export { Markdown } from './components/Markdown.js';
|
export { Markdown } from './components/Markdown.js';
|
||||||
export { CodeBlock, InlineCode } from './components/CodeBlock.js';
|
export { CodeBlock, InlineCode } from './components/CodeBlock.js';
|
||||||
export { SubagentProgress, SubagentProgressCompact } from './components/SubagentProgress.js';
|
export { SubagentProgress, SubagentProgressCompact } from './components/SubagentProgress.js';
|
||||||
|
export { AskUserQuestion } from './components/AskUserQuestion.js';
|
||||||
|
|
||||||
// Toast function (re-export from sonner)
|
// Toast function (re-export from sonner)
|
||||||
export { toast } from 'sonner';
|
export { toast } from 'sonner';
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export function ChatPage({
|
|||||||
setAutoApprove,
|
setAutoApprove,
|
||||||
currentAgent,
|
currentAgent,
|
||||||
currentSubagent,
|
currentSubagent,
|
||||||
|
answerQuestion,
|
||||||
} = useChat({
|
} = useChat({
|
||||||
sessionId,
|
sessionId,
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@@ -291,13 +292,13 @@ export function ChatPage({
|
|||||||
|
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout">
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<ChatMessage key={message.id} message={message} />
|
<ChatMessage key={message.id} message={message} onAnswerQuestion={answerQuestion} />
|
||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* 流式消息 - 复用 ChatMessage 组件 */}
|
{/* 流式消息 - 复用 ChatMessage 组件 */}
|
||||||
{streamingMessage && (
|
{streamingMessage && (
|
||||||
<ChatMessage message={streamingMessage} isStreaming />
|
<ChatMessage message={streamingMessage} isStreaming onAnswerQuestion={answerQuestion} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 子 Agent 进度显示 */}
|
{/* 子 Agent 进度显示 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user