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
@@ -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">