Files
ai-terminal-assistant/packages/ui/src/components/ChatMessage.tsx
T
kurihada 3ff489fbc0 feat(ui): 添加 Token 消耗统计显示
- 状态栏显示当前会话 Token 消耗总量,悬停显示详情
- AI 消息底部显示本次响应的输入/输出 Token
- 会话列表顶部显示项目 Token 消耗总量
- 会话列表每项显示该会话的 Token 消耗
- 新增 Token 统计 API 客户端函数
- Server done 事件携带 usage 信息
2025-12-18 17:01:09 +08:00

434 lines
15 KiB
TypeScript

/**
* Chat Message Component
*/
import {
User,
Bot,
Copy,
Check,
Wrench,
ChevronDown,
ChevronRight,
Clock,
AlertCircle,
CheckCircle2,
Loader2,
GitCompare,
Coins,
} 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, ToolStatus, ToolMessagePart, QuestionMessagePart, PermissionMessagePart, FileDiffInfo } from '../api/types.js';
import { AskUserQuestion } from './AskUserQuestion.js';
import { PermissionRequestInline } from './PermissionRequestInline.js';
interface ChatMessageProps {
message: Message;
/** 是否为流式输出中(显示打字光标) */
isStreaming?: boolean;
/** 回答问题的回调(用于 ask_user_question 工具) */
onAnswerQuestion?: (questionPartId: string, answers: string[]) => void;
/** 查看文件 Diff 的回调 */
onViewDiff?: (diff: FileDiffInfo) => void;
/** 允许权限请求的回调 */
onAllowPermission?: (requestId: string, remember: boolean) => void;
/** 拒绝权限请求的回调 */
onDenyPermission?: (requestId: string, remember: boolean) => void;
}
// 格式化 Token 数量
const formatTokens = (tokens: number): string => {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(2)}M`;
}
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`;
}
return `${tokens}`;
};
export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
({ message, isStreaming = false, onAnswerQuestion, onViewDiff, onAllowPermission, onDenyPermission }, ref) => {
const isUser = message.role === 'user';
const [copied, setCopied] = useState(false);
// Token 信息
const hasTokenInfo = !isUser && message.metadata?.inputTokens !== undefined;
const inputTokens = message.metadata?.inputTokens ?? 0;
const outputTokens = message.metadata?.outputTokens ?? 0;
const totalTokens = message.metadata?.totalTokens ?? (inputTokens + outputTokens);
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>
);
case 'permission': {
// 权限请求组件:内联显示,不阻断用户操作
const permissionPart = part as PermissionMessagePart;
return (
<PermissionRequestInline
key={part.id}
part={permissionPart}
onAllow={onAllowPermission || (() => {})}
onDeny={onDenyPermission || (() => {})}
disabled={permissionPart.handled}
/>
);
}
default:
return null;
}
})}
</div>
);
}
// 回退:使用 content 字段(无 parts 时)
return (
<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()}
{/* Token 统计显示(仅 AI 消息,非流式输出时) */}
{hasTokenInfo && !isStreaming && totalTokens > 0 && (
<div className="mt-2 pt-2 border-t border-line/50 flex items-center gap-3 text-xs text-fg-subtle">
<span className="flex items-center gap-1" title="Token 消耗">
<Coins size={12} className="text-fg-muted" />
<span>{formatTokens(totalTokens)}</span>
</span>
<span className="text-fg-muted/40">|</span>
<span title="输入 Token">
{formatTokens(inputTokens)}
</span>
<span title="输出 Token">
{formatTokens(outputTokens)}
</span>
</div>
)}
</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`;
}