fea5442d53
当 AI 执行 write_file 或 edit_file 工具时,在工具结果中显示 View Diff 按钮, 点击后在 IDE 面板中显示文件修改前后的对比视图。 主要改动: - core: edit_file/write_file 工具返回 fileDiff 元数据 - ui: 新增 DiffEditor 组件用于显示文件差异 - ui: ChatMessage 添加 View Diff 按钮 - ui: IDE 组件支持 Diff 视图切换 - ui: useChat hook 处理 fileDiff 回调
493 lines
16 KiB
TypeScript
493 lines
16 KiB
TypeScript
/**
|
|
* Chat Message Component
|
|
*/
|
|
|
|
import {
|
|
User,
|
|
Bot,
|
|
Copy,
|
|
Check,
|
|
Wrench,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Clock,
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
Loader2,
|
|
GitCompare,
|
|
} from 'lucide-react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { useState, forwardRef } from 'react';
|
|
import { cn } from '../utils/cn';
|
|
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, QuestionMessagePart, FileDiffInfo } from '../api/types.js';
|
|
import { AskUserQuestion } from './AskUserQuestion.js';
|
|
|
|
interface ChatMessageProps {
|
|
message: Message;
|
|
/** 是否为流式输出中(显示打字光标) */
|
|
isStreaming?: boolean;
|
|
/** 回答问题的回调(用于 ask_user_question 工具) */
|
|
onAnswerQuestion?: (questionPartId: string, answers: string[]) => void;
|
|
/** 查看文件 Diff 的回调 */
|
|
onViewDiff?: (diff: FileDiffInfo) => void;
|
|
}
|
|
|
|
export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
|
|
({ message, isStreaming = false, onAnswerQuestion, onViewDiff }, ref) => {
|
|
const isUser = message.role === 'user';
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
const handleCopy = async () => {
|
|
await navigator.clipboard.writeText(message.content ?? '');
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
};
|
|
|
|
// 渲染消息内容 - 使用 parts 保持原始顺序
|
|
const renderContent = () => {
|
|
// 优先使用 parts 数组(保持原始顺序)
|
|
if (message.parts && message.parts.length > 0) {
|
|
// 查找最后一个文本 part 的索引(用于显示打字光标)
|
|
let lastTextPartIndex = -1;
|
|
if (isStreaming) {
|
|
for (let i = message.parts.length - 1; i >= 0; i--) {
|
|
if (message.parts[i].type === 'text') {
|
|
lastTextPartIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="message-content text-fg-secondary space-y-3">
|
|
{message.parts.map((part, index) => {
|
|
switch (part.type) {
|
|
case 'text':
|
|
if (!part.text && index !== lastTextPartIndex) return null;
|
|
return isUser ? (
|
|
<div key={part.id}>
|
|
<FileMentionText text={part.text} />
|
|
</div>
|
|
) : (
|
|
<div key={part.id}>
|
|
<Markdown content={part.text} />
|
|
{/* 流式输出时在最后一个文本末尾显示打字光标 */}
|
|
{isStreaming && index === lastTextPartIndex && (
|
|
<motion.span
|
|
animate={{ opacity: [1, 0] }}
|
|
transition={{ duration: 0.8, repeat: Infinity, repeatType: 'reverse' }}
|
|
className="inline-block w-2 h-4 bg-primary-400 ml-1 rounded-sm align-middle"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
case 'tool':
|
|
return <ToolPartItem key={part.id} part={part} onViewDiff={onViewDiff} />;
|
|
case 'question': {
|
|
// 问题组件:即使在流式输出时也允许用户回答(除非已回答)
|
|
const questionPart = part as QuestionMessagePart;
|
|
return (
|
|
<AskUserQuestion
|
|
key={part.id}
|
|
part={questionPart}
|
|
onAnswer={
|
|
onAnswerQuestion
|
|
? (answers) => onAnswerQuestion(part.id, answers)
|
|
: undefined
|
|
}
|
|
disabled={questionPart.answered}
|
|
/>
|
|
);
|
|
}
|
|
case 'reasoning':
|
|
return (
|
|
<div key={part.id} className="text-fg-muted italic border-l-2 border-line pl-3">
|
|
{part.text}
|
|
</div>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 回退:使用旧的 content + toolCalls 字段
|
|
return (
|
|
<>
|
|
{!isUser && message.toolCalls && message.toolCalls.length > 0 && (
|
|
<ToolCallsDisplay toolCalls={message.toolCalls} />
|
|
)}
|
|
<div className="message-content text-fg-secondary">
|
|
{isUser ? (
|
|
<div>
|
|
<FileMentionText text={message.content ?? ''} />
|
|
</div>
|
|
) : (
|
|
<Markdown content={message.content ?? ''} />
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<motion.div
|
|
ref={ref}
|
|
variants={fadeInUp}
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
transition={smoothTransition}
|
|
className={cn(
|
|
'group flex gap-4 p-4 rounded-lg',
|
|
isUser ? 'bg-surface-subtle' : 'bg-surface-subtle/50'
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 text-white',
|
|
isUser ? 'bg-primary-600' : 'bg-green-600'
|
|
)}
|
|
>
|
|
{isUser ? <User size={18} /> : <Bot size={18} />}
|
|
</div>
|
|
<div className="flex-1 min-w-0 overflow-hidden">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-sm text-fg-muted">
|
|
{isUser ? 'You' : getAgentDisplayName(message.metadata?.agentName)}
|
|
</span>
|
|
<button
|
|
onClick={handleCopy}
|
|
className="opacity-0 group-hover:opacity-100 p-1 rounded text-fg-subtle hover:text-fg-muted hover:bg-surface-muted transition-all"
|
|
title="Copy message"
|
|
>
|
|
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
|
</button>
|
|
</div>
|
|
{renderContent()}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
);
|
|
|
|
interface StreamingMessageProps {
|
|
content: string;
|
|
/** 当前 Agent 名称 */
|
|
agentName?: string;
|
|
}
|
|
|
|
export function StreamingMessage({ content, agentName }: StreamingMessageProps) {
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={smoothTransition}
|
|
className="flex gap-4 p-4 rounded-lg bg-surface-subtle/50"
|
|
>
|
|
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-green-600 text-white">
|
|
<Bot size={18} />
|
|
</div>
|
|
<div className="flex-1 min-w-0 overflow-hidden">
|
|
<div className="text-sm text-fg-muted mb-1">{getAgentDisplayName(agentName)}</div>
|
|
<div className="message-content text-fg-secondary">
|
|
<Markdown content={content} />
|
|
<motion.span
|
|
animate={{ opacity: [1, 0] }}
|
|
transition={{ duration: 0.8, repeat: Infinity, repeatType: 'reverse' }}
|
|
className="inline-block w-2 h-4 bg-primary-400 ml-1 rounded-sm align-middle"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
interface TypingIndicatorProps {
|
|
/** 当前 Agent 名称 */
|
|
agentName?: string;
|
|
}
|
|
|
|
export function TypingIndicator({ agentName }: TypingIndicatorProps) {
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={smoothTransition}
|
|
className="flex gap-4 p-4 rounded-lg bg-surface-subtle/50"
|
|
>
|
|
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-green-600 text-white">
|
|
<Bot size={18} />
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="text-sm text-fg-muted mb-1">{getAgentDisplayName(agentName)}</div>
|
|
<div className="flex items-center gap-1 h-6">
|
|
{[0, 1, 2].map((i) => (
|
|
<motion.span
|
|
key={i}
|
|
animate={{ y: [0, -4, 0] }}
|
|
transition={{
|
|
duration: 0.6,
|
|
repeat: Infinity,
|
|
delay: i * 0.15,
|
|
}}
|
|
className="w-2 h-2 rounded-full bg-fg-muted"
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
// ============ 工具调用显示组件 ============
|
|
|
|
/**
|
|
* 单个工具 Part 项(用于 parts 数组渲染)
|
|
*/
|
|
interface ToolPartItemProps {
|
|
part: ToolMessagePart;
|
|
/** 查看文件 Diff 的回调 */
|
|
onViewDiff?: (diff: FileDiffInfo) => void;
|
|
}
|
|
|
|
function ToolPartItem({ part, onViewDiff }: ToolPartItemProps) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const hasDetails =
|
|
Object.keys(part.arguments).length > 0 ||
|
|
part.result !== undefined ||
|
|
part.error !== undefined;
|
|
|
|
// 判断是否为文件操作工具且有 diff 数据
|
|
const isFileOperation = part.toolName === 'write_file' || part.toolName === 'edit_file';
|
|
const hasDiff = isFileOperation && part.fileDiff && part.status === 'completed';
|
|
|
|
return (
|
|
<div className="border border-line rounded-lg overflow-hidden bg-surface-subtle/30">
|
|
{/* 头部:工具名称、状态、时长 */}
|
|
<button
|
|
onClick={() => hasDetails && setExpanded(!expanded)}
|
|
className={cn(
|
|
'w-full flex items-center gap-2 px-3 py-2 text-sm',
|
|
hasDetails && 'hover:bg-surface-muted/50 cursor-pointer',
|
|
!hasDetails && 'cursor-default'
|
|
)}
|
|
>
|
|
<Wrench size={14} className="text-fg-muted flex-shrink-0" />
|
|
<span className="font-mono text-fg-secondary flex-1 text-left truncate">
|
|
{part.toolName}
|
|
</span>
|
|
{getStatusIcon(part.status)}
|
|
{part.duration && (
|
|
<span className="text-xs text-fg-subtle">{formatDuration(part.duration)}</span>
|
|
)}
|
|
{/* View Diff 按钮 */}
|
|
{hasDiff && onViewDiff && (
|
|
<motion.button
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onViewDiff(part.fileDiff!);
|
|
}}
|
|
className="px-2 py-0.5 text-xs bg-primary-500/10 text-primary-400 rounded hover:bg-primary-500/20 transition-colors"
|
|
>
|
|
<GitCompare size={12} className="inline mr-1" />
|
|
View Diff
|
|
</motion.button>
|
|
)}
|
|
{hasDetails && (
|
|
<span className="text-fg-subtle">
|
|
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* 展开的详情 */}
|
|
<AnimatePresence>
|
|
{expanded && hasDetails && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="border-t border-line overflow-hidden"
|
|
>
|
|
<div className="px-3 py-2 space-y-2 text-xs">
|
|
{/* 参数 */}
|
|
{Object.keys(part.arguments).length > 0 && (
|
|
<div>
|
|
<div className="text-fg-subtle mb-1">Arguments:</div>
|
|
<pre className="bg-surface-base rounded p-2 overflow-x-auto text-fg-muted max-h-48 overflow-y-auto">
|
|
{JSON.stringify(part.arguments, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{/* 结果 */}
|
|
{part.result !== undefined && (
|
|
<div>
|
|
<div className="text-fg-subtle mb-1">Result:</div>
|
|
<pre className="bg-surface-base rounded p-2 overflow-x-auto text-green-400 max-h-48 overflow-y-auto">
|
|
{typeof part.result === 'string'
|
|
? part.result
|
|
: JSON.stringify(part.result, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{/* 错误 */}
|
|
{part.error && (
|
|
<div>
|
|
<div className="text-red-400 mb-1">Error:</div>
|
|
<pre className="bg-surface-base rounded p-2 overflow-x-auto text-red-300 max-h-48 overflow-y-auto">
|
|
{part.error}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 获取工具状态图标
|
|
*/
|
|
function getStatusIcon(status: ToolStatus) {
|
|
switch (status) {
|
|
case 'pending':
|
|
return <Clock size={14} className="text-yellow-500" />;
|
|
case 'running':
|
|
return <Loader2 size={14} className="text-blue-500 animate-spin" />;
|
|
case 'completed':
|
|
return <CheckCircle2 size={14} className="text-green-500" />;
|
|
case 'error':
|
|
return <AlertCircle size={14} className="text-red-500" />;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 格式化执行时长
|
|
*/
|
|
function formatDuration(ms?: number): string {
|
|
if (!ms) return '';
|
|
if (ms < 1000) return `${ms}ms`;
|
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
}
|
|
|
|
/**
|
|
* 工具调用列表容器
|
|
*/
|
|
interface ToolCallsDisplayProps {
|
|
toolCalls: ToolCallInfo[];
|
|
}
|
|
|
|
function ToolCallsDisplay({ toolCalls }: ToolCallsDisplayProps) {
|
|
return (
|
|
<div className="mb-3 space-y-2">
|
|
{toolCalls.map((toolCall) => (
|
|
<ToolCallItem key={toolCall.id} toolCall={toolCall} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 单个工具调用项
|
|
*/
|
|
interface ToolCallItemProps {
|
|
toolCall: ToolCallInfo;
|
|
}
|
|
|
|
function ToolCallItem({ toolCall }: ToolCallItemProps) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const hasDetails =
|
|
Object.keys(toolCall.arguments).length > 0 ||
|
|
toolCall.result !== undefined ||
|
|
toolCall.error !== undefined;
|
|
|
|
return (
|
|
<div className="border border-line rounded-lg overflow-hidden bg-surface-subtle/30">
|
|
{/* 头部:工具名称、状态、时长 */}
|
|
<button
|
|
onClick={() => hasDetails && setExpanded(!expanded)}
|
|
className={cn(
|
|
'w-full flex items-center gap-2 px-3 py-2 text-sm',
|
|
hasDetails && 'hover:bg-surface-muted/50 cursor-pointer',
|
|
!hasDetails && 'cursor-default'
|
|
)}
|
|
>
|
|
<Wrench size={14} className="text-fg-muted flex-shrink-0" />
|
|
<span className="font-mono text-fg-secondary flex-1 text-left truncate">
|
|
{toolCall.name}
|
|
</span>
|
|
{getStatusIcon(toolCall.status)}
|
|
{toolCall.duration && (
|
|
<span className="text-xs text-fg-subtle">{formatDuration(toolCall.duration)}</span>
|
|
)}
|
|
{hasDetails && (
|
|
<span className="text-fg-subtle">
|
|
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* 展开的详情 */}
|
|
<AnimatePresence>
|
|
{expanded && hasDetails && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="border-t border-line overflow-hidden"
|
|
>
|
|
<div className="px-3 py-2 space-y-2 text-xs">
|
|
{/* 参数 */}
|
|
{Object.keys(toolCall.arguments).length > 0 && (
|
|
<div>
|
|
<div className="text-fg-subtle mb-1">Arguments:</div>
|
|
<pre className="bg-surface-base rounded p-2 overflow-x-auto text-fg-muted max-h-48 overflow-y-auto">
|
|
{JSON.stringify(toolCall.arguments, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{/* 结果 */}
|
|
{toolCall.result !== undefined && (
|
|
<div>
|
|
<div className="text-fg-subtle mb-1">Result:</div>
|
|
<pre className="bg-surface-base rounded p-2 overflow-x-auto text-green-400 max-h-48 overflow-y-auto">
|
|
{typeof toolCall.result === 'string'
|
|
? toolCall.result
|
|
: JSON.stringify(toolCall.result, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{/* 错误 */}
|
|
{toolCall.error && (
|
|
<div>
|
|
<div className="text-red-400 mb-1">Error:</div>
|
|
<pre className="bg-surface-base rounded p-2 overflow-x-auto text-red-300 max-h-48 overflow-y-auto">
|
|
{toolCall.error}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|