feat(api): 实现消息合并 API,支持工具调用显示

- 新增 MergedMessage、ToolCallInfo 类型定义
- 创建 message-merger.ts 消息合并工具
- 更新 sessions 路由使用合并后的消息格式
- 前端新增 ToolCallsDisplay 组件展示工具调用
- 工具调用显示状态、时长,可展开查看参数和结果
This commit is contained in:
2025-12-15 12:21:16 +08:00
parent e9e8bfa30a
commit eda2ccb171
7 changed files with 567 additions and 43 deletions
+35
View File
@@ -11,11 +11,46 @@ export interface Session {
messageCount: number;
}
/**
* 工具调用状态
*/
export type ToolCallStatus = 'pending' | 'running' | 'completed' | 'error';
/**
* 工具调用信息
*/
export interface ToolCallInfo {
id: string;
name: string;
arguments: Record<string, unknown>;
status: ToolCallStatus;
result?: unknown;
error?: string;
duration?: number; // 执行时长 ms
}
/**
* 消息(合并后的格式)
*
* 助手消息可能包含工具调用信息,将多个原始消息合并为一条
*/
export interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: string;
/** 工具调用列表 */
toolCalls?: ToolCallInfo[];
/** 是否包含推理过程 */
hasReasoning?: boolean;
/** 推理内容 */
reasoning?: string;
/** 元数据 */
metadata?: {
model?: string;
stepCount?: number;
totalTokens?: number;
};
}
export interface HealthStatus {
+153 -3
View File
@@ -2,13 +2,25 @@
* Chat Message Component
*/
import { User, Bot, Copy, Check } from 'lucide-react';
import { motion } from 'framer-motion';
import {
User,
Bot,
Copy,
Check,
Wrench,
ChevronDown,
ChevronRight,
Clock,
AlertCircle,
CheckCircle2,
Loader2,
} 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 { Markdown } from './Markdown';
import type { Message } from '../api/client.js';
import type { Message, ToolCallInfo, ToolCallStatus } from '../api/types.js';
interface ChatMessageProps {
message: Message;
@@ -59,6 +71,10 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</button>
</div>
{/* 工具调用显示 */}
{!isUser && message.toolCalls && message.toolCalls.length > 0 && (
<ToolCallsDisplay toolCalls={message.toolCalls} />
)}
<div className="message-content text-gray-200">
{isUser ? (
// 用户消息:保持原样显示
@@ -135,3 +151,137 @@ export function TypingIndicator() {
</motion.div>
);
}
// ============ 工具调用显示组件 ============
/**
* 获取工具状态图标
*/
function getStatusIcon(status: ToolCallStatus) {
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-gray-700 rounded-lg overflow-hidden bg-gray-800/30">
{/* 头部:工具名称、状态、时长 */}
<button
onClick={() => hasDetails && setExpanded(!expanded)}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-sm',
hasDetails && 'hover:bg-gray-700/50 cursor-pointer',
!hasDetails && 'cursor-default'
)}
>
<Wrench size={14} className="text-gray-400 flex-shrink-0" />
<span className="font-mono text-gray-200 flex-1 text-left truncate">
{toolCall.name}
</span>
{getStatusIcon(toolCall.status)}
{toolCall.duration && (
<span className="text-xs text-gray-500">{formatDuration(toolCall.duration)}</span>
)}
{hasDetails && (
<span className="text-gray-500">
{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-gray-700 overflow-hidden"
>
<div className="px-3 py-2 space-y-2 text-xs">
{/* 参数 */}
{Object.keys(toolCall.arguments).length > 0 && (
<div>
<div className="text-gray-500 mb-1">Arguments:</div>
<pre className="bg-gray-900 rounded p-2 overflow-x-auto text-gray-300 max-h-48 overflow-y-auto">
{JSON.stringify(toolCall.arguments, null, 2)}
</pre>
</div>
)}
{/* 结果 */}
{toolCall.result !== undefined && (
<div>
<div className="text-gray-500 mb-1">Result:</div>
<pre className="bg-gray-900 rounded p-2 overflow-x-auto text-green-300 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-gray-900 rounded p-2 overflow-x-auto text-red-300 max-h-48 overflow-y-auto">
{toolCall.error}
</pre>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}