Files
video-create/web/client/src/components/chat/ChatView.tsx
sion123 504862343d feat(chat): 将工具调用卡片状态化并嵌入消息流
重构工具消息的呈现与更新逻辑,使其与常规消息一同展示并通过视觉状态反映执行进展。

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

282 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAppStore } from '@/store';
import { useChat } from '@/hooks/useChat';
import { ChatMessage } from './ChatMessage';
import { ChatInput } from './ChatInput';
import { PipelineProgress } from './PipelineProgress';
import { Button } from '@/components/ui/button';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { RefreshCw, Loader2, StopCircle, X, MessageCircle, ArrowDown } from 'lucide-react';
import { api } from '@/lib/api';
import type { Account, Message } from '@/types';
export function ChatView() {
const { conversationId } = useParams<{ conversationId?: string }>();
const navigate = useNavigate();
const { setConversations, selectedAccountId, conversations } = useAppStore();
const { messages, connected, thinking, toolStatus, pipeline, send, stop, createConversation, removeMessage } = useChat(conversationId || null);
const [manifestPath, setManifestPath] = useState<string | null>(null);
const [accounts, setAccounts] = useState<Account[]>([]);
const [quote, setQuote] = useState<string | null>(null);
const [pendingMessage, setPendingMessage] = useState<{ content: string; images?: Array<{ type: 'image'; data: string; mimeType: string }> } | null>(null);
const [showScrollBtn, setShowScrollBtn] = useState(false);
const creatingRef = useRef(false);
const scrollRef = useRef<HTMLDivElement>(null);
const userScrolledUp = useRef(false);
useEffect(() => { api.listAccounts().then(setAccounts).catch(() => {}); }, []);
useEffect(() => {
api.listConversations().then(setConversations).catch(() => {});
}, [messages]);
useEffect(() => {
const toolMsgs = messages.filter((m) => m.role === 'tool');
if (toolMsgs.length > 0) {
try {
const lastTool = JSON.parse(toolMsgs[toolMsgs.length - 1].content);
if (lastTool.manifest) setManifestPath(lastTool.manifest);
} catch {}
}
}, [messages]);
// Auto-scroll: follow new content unless user has scrolled up
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
if (userScrolledUp.current) return;
el.scrollTop = el.scrollHeight;
}, [messages, thinking, pipeline, toolStatus]);
// Detect user manual scroll
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const onScroll = () => {
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
userScrolledUp.current = !atBottom;
setShowScrollBtn(!atBottom);
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, []);
// Reset scroll state on conversation switch
useEffect(() => {
userScrolledUp.current = false;
setShowScrollBtn(false);
}, [conversationId]);
// After navigating to a new conversation, send the pending message once history is loaded
useEffect(() => {
if (conversationId && connected && pendingMessage) {
const timer = setTimeout(() => {
send(pendingMessage.content, pendingMessage.images);
setPendingMessage(null);
}, 300);
return () => clearTimeout(timer);
}
}, [conversationId, connected, pendingMessage]);
const handleResume = async () => {
if (!manifestPath) return;
await fetch('/api/pipeline/resume', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ manifest: manifestPath }),
});
};
const findPrecedingUser = useCallback((msgId: string): Message | null => {
const idx = messages.findIndex((m) => m.id === msgId);
if (idx <= 0) return null;
for (let i = idx - 1; i >= 0; i--) {
if (messages[i].role === 'user') return messages[i];
}
return null;
}, [messages]);
const handleRegenerate = useCallback((msgId: string) => {
const prev = findPrecedingUser(msgId);
if (prev) send(prev.content);
}, [findPrecedingUser, send]);
const handleContinue = useCallback(() => { send('请继续'); }, [send]);
const handleQuote = useCallback((content: string) => { setQuote(content.slice(0, 200)); }, []);
const handleDeleteMsg = useCallback((msgId: string) => {
removeMessage(msgId);
}, [removeMessage]);
// Delayed conversation creation
const handleSendNew = useCallback(async (content: string, images?: Array<{ type: 'image'; data: string; mimeType: string }>) => {
if (creatingRef.current) return;
creatingRef.current = true;
setPendingMessage({ content, images });
try {
const newId = await createConversation(content.slice(0, 30), selectedAccountId || undefined);
if (newId) {
const list = await api.listConversations();
setConversations(list);
navigate(`/chat/${newId}`);
}
} catch {
// fallback
}
creatingRef.current = false;
}, [createConversation, selectedAccountId]);
const handleSend = useCallback((content: string, images?: Array<{ type: 'image'; data: string; mimeType: string }>) => {
if (quote) { content = `> ${quote}\n\n${content}`; setQuote(null); }
if (conversationId) {
send(content, images);
} else {
handleSendNew(content, images);
}
}, [send, quote, conversationId]);
const handleStop = useCallback(() => { stop(); }, [stop]);
// Empty state - no conversation selected
if (!conversationId) {
const recentConvs = conversations.slice(0, 5);
return (
<div className="flex-1 flex flex-col items-center justify-center bg-gradient-to-b from-white to-zinc-50/30">
<div className="w-full max-w-xl px-4">
<div className="text-center mb-6">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-indigo-50 to-violet-50 flex items-center justify-center mx-auto mb-4 shadow-sm">
<MessageCircle size={24} className="text-indigo-400" strokeWidth={1.5} />
</div>
<h2 className="text-lg font-semibold text-zinc-800"></h2>
<p className="text-sm text-zinc-400 mt-1"></p>
</div>
<ChatInput onSend={handleSend} disabled={thinking || !connected || creatingRef.current} connecting={!connected} />
{accounts.length > 0 && (
<div className="flex items-center gap-2 mt-3 justify-center">
<span className="text-xs text-zinc-400">:</span>
<Select
value={selectedAccountId || ''}
onValueChange={(v) => useAppStore.getState().setSelectedAccountId(v === 'none' ? null : v)}
>
<SelectTrigger className="h-7 w-auto min-w-[100px] text-xs px-2.5 border-zinc-100 hover:border-zinc-200">
<SelectValue placeholder="不指定" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{accounts.map((a) => <SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
)}
{recentConvs.length > 0 && (
<div className="mt-8 border-t border-zinc-100 pt-4">
<p className="text-[10px] text-zinc-400 uppercase tracking-wider mb-2"></p>
{recentConvs.map((conv) => (
<button
key={conv.id}
onClick={() => navigate(`/chat/${conv.id}`)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-xl text-left hover:bg-zinc-50/80 transition-colors group"
>
<MessageCircle size={13} className="text-zinc-300 flex-shrink-0" />
<span className="text-sm text-zinc-600 truncate flex-1 group-hover:text-zinc-800">{conv.title}</span>
<span className="text-[10px] text-zinc-300 flex-shrink-0">
{new Date(conv.updated_at).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}
</span>
</button>
))}
</div>
)}
</div>
</div>
);
}
// Active conversation
return (
<div className="flex-1 flex flex-col bg-white h-full">
{/* Header bar */}
<div className="px-4 py-2 border-b border-zinc-100 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${connected ? 'bg-emerald-400 shadow-sm shadow-emerald-200' : 'bg-red-400 animate-pulse'}`} />
<span className="text-xs text-zinc-400">{connected ? '在线' : '连接中'}</span>
</div>
<div className="flex items-center gap-2">
{manifestPath && (
<Button size="sm" variant="outline" className="h-7 text-xs rounded-lg" onClick={handleResume}>
<RefreshCw size={12} className="mr-1" />
</Button>
)}
{thinking && (
<Button size="sm" variant="outline" className="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50 rounded-lg" onClick={handleStop}>
<StopCircle size={12} className="mr-1" />
</Button>
)}
</div>
</div>
{/* Chat area */}
<div className="flex-1 overflow-hidden relative">
<div className="h-full flex flex-col">
{/* Message list */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
{messages.map((msg, i) => (
<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 && (
<PipelineProgress
phase={pipeline.phase}
progress={pipeline.progress}
currentItem={pipeline.currentItem}
totalItems={pipeline.totalItems}
status={pipeline.status}
/>
)}
{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 || '思考中...'}
</div>
)}
</div>
{/* Scroll to bottom FAB */}
{showScrollBtn && (
<button
onClick={() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
userScrolledUp.current = false;
setShowScrollBtn(false);
}}
className="absolute bottom-4 left-1/2 -translate-x-1/2 w-9 h-9 rounded-full bg-white border border-zinc-200 shadow-lg shadow-zinc-200/60 flex items-center justify-center text-zinc-400 hover:text-zinc-700 hover:border-zinc-300 hover:shadow-xl transition-all"
>
<ArrowDown size={16} />
</button>
)}
{/* Quote bar */}
{quote && (
<div className="mx-4 px-3 py-1.5 bg-indigo-50/60 border border-indigo-100/50 rounded-xl flex items-center gap-2 text-xs text-zinc-500 flex-shrink-0">
<span className="flex-1 truncate">: {quote}</span>
<button onClick={() => setQuote(null)} className="text-zinc-400 hover:text-zinc-600"><X size={12} /></button>
</div>
)}
{/* Input */}
<ChatInput onSend={handleSend} disabled={thinking || !connected} connecting={!connected} />
</div>
</div>
</div>
);
}