feat(chat): 将工具调用卡片状态化并嵌入消息流

重构工具消息的呈现与更新逻辑,使其与常规消息一同展示并通过视觉状态反映执行进展。

- 为工具消息新增状态指示(执行中/完成/出错)、图标标签及输入摘要
- 实现通过 toolName/toolStatus 字段定位并就地更新已有工具消息而非追加新项
- 从消息列表中移除对 tool 角色的过滤,使其以内联卡片形式嵌入聊天流
- 添加液体填充动画,在工具执行期间提供进度反馈
- 为视频资源预览增加播放按钮遮罩以提示可播放
- 为 .mp4 与 .webm 文件设置正确的 Content-Type 头
This commit is contained in:
2026-05-16 18:03:16 +08:00
parent 5eb922b1eb
commit 504862343d
6 changed files with 195 additions and 48 deletions

View File

@@ -1,12 +1,22 @@
import { useState, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { renderMarkdown } from '@/lib/markdown';
import { Bot, User, Copy, Trash2, ChevronDown, ChevronRight, RefreshCw, ArrowRight, Quote, Brain, X, ChevronLeft, ChevronRight as ChevronRightIcon, Play } from 'lucide-react';
import { Bot, User, Copy, Trash2, ChevronDown, ChevronRight, RefreshCw, ArrowRight, Quote, Brain, X, ChevronLeft, ChevronRight as ChevronRightIcon, Play, Loader2, CheckCircle2, XCircle } from 'lucide-react';
import { ConfirmDialog } from '@/components/ui/confirm';
import { useConfirm } from '@/hooks/useConfirm';
import { useToast } from '@/hooks/useToast';
import type { Message } from '@/types';
const TOOL_LABELS: Record<string, string> = {
generate_images: '图片生成',
generate_videos: '视频生成',
upload_images: '图片上传',
generate_tts: '语音合成',
assemble_draft: '草稿组装',
confirm_images: '图片确认',
get_pipeline_status: '流水线状态',
};
type InlineAsset = NonNullable<Message['assets']>[number];
function AssetLightbox({ assets, currentIndex, onClose, onNavigate }: {
@@ -93,36 +103,103 @@ export function ChatMessage({ message, isLast, isThinking, onRegenerate, onConti
if (ok) onDelete?.(message.id);
}, [message.id, onDelete, confirm]);
// Tool messages
// Tool messages - inline card
if (isTool) {
let toolName = '';
try { const p = JSON.parse(message.content); toolName = p.tool || ''; } catch {}
const toolName = message.toolName || '';
const label = TOOL_LABELS[toolName] || toolName;
const status = message.toolStatus || 'done';
const isRunning = status === 'running';
const isError = status === 'error';
// Extract input summary
let inputSummary = '';
if (message.toolInput) {
const inp = message.toolInput;
if (inp.count) inputSummary = `${inp.count}`;
else if (inp.manifestPath) inputSummary = (inp.manifestPath as string).split(/[\\/]/).pop()?.replace(/\.json$/, '') || '';
else if (inp.script) inputSummary = `${String(inp.script).slice(0, 30)}...`;
else {
const keys = Object.keys(inp).slice(0, 2);
inputSummary = keys.map(k => `${k}: ${JSON.stringify(inp[k]).slice(0, 20)}`).join(', ');
}
}
return (
<div className="py-1 px-3">
<div className="flex items-center gap-2 mb-1.5">
<Bot size={13} className="text-zinc-400" />
<span className="text-xs text-zinc-400">: {toolName}</span>
</div>
{message.assets && message.assets.length > 0 && (
<div className="flex flex-wrap gap-2">
{message.assets.map((asset, i) => (
<button
key={i}
onClick={() => setPreviewIndex(i)}
className="relative group/thumb rounded-lg overflow-hidden border border-zinc-200 hover:border-indigo-300 transition-colors"
>
{asset.type === 'image' ? (
<img src={asset.url} alt={asset.name} className="w-20 h-20 object-cover" />
) : (
<div className="w-20 h-20 bg-zinc-900 flex items-center justify-center">
<Play size={20} className="text-white" />
</div>
)}
<div className="absolute inset-0 bg-black/0 group-hover/thumb:bg-black/10 transition-colors" />
</button>
))}
<div className="py-1">
<div className={cn(
'rounded-xl border transition-all duration-300 overflow-hidden',
isRunning ? 'border-indigo-200 bg-gradient-to-r from-indigo-50/60 to-violet-50/40' :
isError ? 'border-red-200 bg-red-50/40' :
'border-zinc-200/80 bg-zinc-50/60'
)}>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2">
{isRunning ? (
<Loader2 size={14} className="text-indigo-500 animate-spin" />
) : isError ? (
<XCircle size={14} className="text-red-400" />
) : (
<CheckCircle2 size={14} className="text-emerald-500" />
)}
<span className="text-xs font-medium text-zinc-600">{label}</span>
<span className={cn(
'text-[10px] px-1.5 py-0.5 rounded-full',
isRunning ? 'bg-indigo-100 text-indigo-600' :
isError ? 'bg-red-100 text-red-600' :
'bg-emerald-100/80 text-emerald-600'
)}>
{isRunning ? '执行中' : isError ? '失败' : '完成'}
</span>
{inputSummary && (
<span className="text-[10px] text-zinc-400 truncate ml-auto">{inputSummary}</span>
)}
</div>
)}
{/* Assets grid - images and video thumbnails */}
{message.assets && message.assets.length > 0 && (
<div className="px-3 pb-3 pt-1">
<div className="flex flex-wrap gap-2">
{message.assets.map((asset, i) => (
<button
key={i}
onClick={() => setPreviewIndex(i)}
className="relative group/thumb rounded-lg overflow-hidden border border-zinc-200/80 hover:border-indigo-300 hover:shadow-md transition-all duration-200"
>
{asset.type === 'image' ? (
<img src={asset.url} alt={asset.name} className="w-24 h-24 object-cover" loading="lazy" />
) : (
<div className="w-24 h-24 relative bg-zinc-900 overflow-hidden">
<video
src={asset.url}
className="w-full h-full object-cover"
muted
preload="metadata"
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 rounded-full bg-white/25 backdrop-blur-[2px] flex items-center justify-center shadow-sm">
<Play size={14} className="text-white ml-0.5" fill="white" />
</div>
</div>
</div>
)}
<div className="absolute inset-0 bg-black/0 group-hover/thumb:bg-black/10 transition-colors" />
</button>
))}
</div>
</div>
)}
{/* Running animation - liquid fill wave */}
{isRunning && (
<div className="px-3 pb-3">
<div className="tool-progress-track h-1.5 w-full rounded-full bg-indigo-100/60 overflow-hidden">
<div className="tool-shimmer-bar h-full" />
</div>
</div>
)}
</div>
{/* Lightbox */}
{previewIndex !== null && message.assets && (
<AssetLightbox
assets={message.assets}

View File

@@ -220,18 +220,16 @@ export function ChatView() {
{/* Message list */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
{messages.map((msg, i) => (
msg.role !== 'tool' && (
<ChatMessage
key={msg.id}
message={msg}
isLast={i === messages.length - 1}
isThinking={thinking}
onRegenerate={msg.role === 'assistant' ? handleRegenerate : undefined}
onContinue={msg.role === 'assistant' ? handleContinue : undefined}
onQuote={handleQuote}
onDelete={handleDeleteMsg}
/>
)
<ChatMessage
key={msg.id}
message={msg}
isLast={i === messages.length - 1}
isThinking={thinking}
onRegenerate={msg.role === 'assistant' ? handleRegenerate : undefined}
onContinue={msg.role === 'assistant' ? handleContinue : undefined}
onQuote={handleQuote}
onDelete={handleDeleteMsg}
/>
))}
{pipeline && (
@@ -244,7 +242,7 @@ export function ChatView() {
/>
)}
{thinking && !pipeline && (
{thinking && !pipeline && !messages.some(m => m.role === 'tool' && m.toolStatus === 'running') && (
<div className="flex items-center gap-2 text-zinc-400 text-sm py-2">
<Loader2 size={14} className="animate-spin text-indigo-400" />
{toolStatus || '思考中...'}

View File

@@ -64,21 +64,60 @@ export function useChat(conversationId: string | null) {
};
const onToolStart = (data: Record<string, unknown>) => {
setToolStatus(`执行: ${data.tool}...`);
};
const onToolResult = (data: Record<string, unknown>) => {
setToolStatus(null);
setThinking(true);
const toolId = `tool-${Date.now()}`;
setMessages((prev) => [...prev, {
id: `tool-${Date.now()}`,
id: toolId,
role: 'tool' as const,
content: JSON.stringify({ tool: data.tool, result: data.result }),
assets: (data.assets as Message['assets']) || undefined,
content: '',
toolName: data.tool as string,
toolStatus: 'running' as const,
toolInput: (data.input as Record<string, unknown>) || undefined,
created_at: new Date().toISOString(),
conversation_id: '',
}]);
};
const onToolResult = (data: Record<string, unknown>) => {
setToolStatus(null);
setThinking(true);
setMessages((prev) => {
const idx = prev.findIndex((m) => m.role === 'tool' && m.toolStatus === 'running' && m.toolName === data.tool);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = {
...updated[idx],
content: JSON.stringify({ tool: data.tool, result: data.result }),
assets: (data.assets as Message['assets']) || undefined,
toolStatus: 'done' as const,
};
return updated;
}
return [...prev, {
id: `tool-${Date.now()}`,
role: 'tool' as const,
content: JSON.stringify({ tool: data.tool, result: data.result }),
assets: (data.assets as Message['assets']) || undefined,
toolName: data.tool as string,
toolStatus: 'done' as const,
created_at: new Date().toISOString(),
conversation_id: '',
}];
});
};
const onToolError = (data: Record<string, unknown>) => {
setToolStatus(`失败: ${data.tool}`);
setMessages((prev) => {
const idx = prev.findIndex((m) => m.role === 'tool' && m.toolStatus === 'running' && m.toolName === data.tool);
if (idx >= 0) {
const updated = [...prev];
updated[idx] = {
...updated[idx],
content: JSON.stringify({ tool: data.tool, error: data.error }),
toolStatus: 'error' as const,
};
return updated;
}
return prev;
});
setTimeout(() => setToolStatus(null), 4000);
};
const onPipelineProgress = (data: Record<string, unknown>) => {

View File

@@ -38,6 +38,33 @@
}
}
/* Tool card progress - liquid fill animation */
@keyframes liquid-fill {
0% { width: 5%; opacity: 0.5; }
20% { width: 30%; opacity: 0.9; }
40% { width: 55%; opacity: 1; }
60% { width: 75%; opacity: 0.95; }
80% { width: 92%; opacity: 0.85; }
90% { width: 100%; opacity: 0.7; }
100% { width: 5%; opacity: 0.5; }
}
@keyframes liquid-glow {
0%, 100% { box-shadow: 0 0 4px rgba(99, 102, 241, 0.3); }
50% { box-shadow: 0 0 12px rgba(99, 102, 241, 0.5); }
}
.tool-shimmer-bar {
animation: liquid-fill 2.4s cubic-bezier(0.4, 0, 0.2, 1) infinite;
border-radius: 9999px;
background: linear-gradient(90deg,
rgba(99, 102, 241, 0.8),
rgba(139, 92, 246, 0.9),
rgba(99, 102, 241, 0.7));
background-size: 200% 100%;
}
.tool-progress-track {
animation: liquid-glow 2.4s ease-in-out infinite;
}
/* GitHub-style Markdown - light theme */
@layer components {
.markdown-body {

View File

@@ -101,6 +101,9 @@ export interface Message {
tool_calls?: unknown;
created_at: string;
assets?: Array<{ type: 'image' | 'video'; url: string; name: string }>;
toolName?: string;
toolStatus?: 'running' | 'done' | 'error';
toolInput?: Record<string, unknown>;
}
export interface Asset {

View File

@@ -132,6 +132,9 @@ assetsRouter.get('/file', (req, res) => {
const fullPath = path.resolve(PROJECT_ROOT, filePath);
if (!fullPath.startsWith(PROJECT_ROOT)) return res.status(403).send('Forbidden');
if (!fss.existsSync(fullPath)) return res.status(404).send('Not found');
// Set correct Content-Type for video files
if (fullPath.endsWith('.mp4')) res.setHeader('Content-Type', 'video/mp4');
else if (fullPath.endsWith('.webm')) res.setHeader('Content-Type', 'video/webm');
// Cache static assets for 1 hour
res.setHeader('Cache-Control', 'public, max-age=3600');
res.sendFile(fullPath);