diff --git a/web/client/src/components/chat/ChatMessage.tsx b/web/client/src/components/chat/ChatMessage.tsx index 6344ea3..afeb82d 100644 --- a/web/client/src/components/chat/ChatMessage.tsx +++ b/web/client/src/components/chat/ChatMessage.tsx @@ -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 = { + generate_images: '图片生成', + generate_videos: '视频生成', + upload_images: '图片上传', + generate_tts: '语音合成', + assemble_draft: '草稿组装', + confirm_images: '图片确认', + get_pipeline_status: '流水线状态', +}; + type InlineAsset = NonNullable[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 ( -
-
- - 工具调用: {toolName} -
- {message.assets && message.assets.length > 0 && ( -
- {message.assets.map((asset, i) => ( - - ))} +
+
+ {/* Header */} +
+ {isRunning ? ( + + ) : isError ? ( + + ) : ( + + )} + {label} + + {isRunning ? '执行中' : isError ? '失败' : '完成'} + + {inputSummary && ( + {inputSummary} + )}
- )} + + {/* Assets grid - images and video thumbnails */} + {message.assets && message.assets.length > 0 && ( +
+
+ {message.assets.map((asset, i) => ( + + ))} +
+
+ )} + + {/* Running animation - liquid fill wave */} + {isRunning && ( +
+
+
+
+
+ )} +
+ + {/* Lightbox */} {previewIndex !== null && message.assets && ( {messages.map((msg, i) => ( - msg.role !== 'tool' && ( - - ) + ))} {pipeline && ( @@ -244,7 +242,7 @@ export function ChatView() { /> )} - {thinking && !pipeline && ( + {thinking && !pipeline && !messages.some(m => m.role === 'tool' && m.toolStatus === 'running') && (
{toolStatus || '思考中...'} diff --git a/web/client/src/hooks/useChat.ts b/web/client/src/hooks/useChat.ts index 9178a04..4831345 100644 --- a/web/client/src/hooks/useChat.ts +++ b/web/client/src/hooks/useChat.ts @@ -64,21 +64,60 @@ export function useChat(conversationId: string | null) { }; const onToolStart = (data: Record) => { setToolStatus(`执行: ${data.tool}...`); - }; - const onToolResult = (data: Record) => { - 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) || undefined, created_at: new Date().toISOString(), conversation_id: '', }]); }; + const onToolResult = (data: Record) => { + 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) => { 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) => { diff --git a/web/client/src/index.css b/web/client/src/index.css index 76afdd9..c57817a 100644 --- a/web/client/src/index.css +++ b/web/client/src/index.css @@ -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 { diff --git a/web/client/src/types/index.ts b/web/client/src/types/index.ts index a8283ff..1297fe0 100644 --- a/web/client/src/types/index.ts +++ b/web/client/src/types/index.ts @@ -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; } export interface Asset { diff --git a/web/server/routes/assets.ts b/web/server/routes/assets.ts index 1119a00..24a0198 100644 --- a/web/server/routes/assets.ts +++ b/web/server/routes/assets.ts @@ -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);