Files
video-create/web/client/src/components/chat/ChatView.tsx

195 lines
7.7 KiB
TypeScript
Raw Normal View History

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 { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import { RefreshCw, Loader2, StopCircle, X } 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, setSelectedAccountId } = useAppStore();
const { messages, connected, thinking, toolStatus, pipeline, send, stop, createConversation } = useChat(conversationId || null);
const [manifestPath, setManifestPath] = useState<string | null>(null);
const [accounts, setAccounts] = useState<Account[]>([]);
const [quote, setQuote] = useState<string | null>(null);
const creatingRef = useRef(false);
useEffect(() => { api.listAccounts().then(setAccounts).catch(() => {}); }, []);
useEffect(() => {
fetch('/api/pipeline/conversations')
.then((r) => r.json()).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]);
useEffect(() => {
if (selectedAccountId && conversationId && connected && messages.length === 0) {
const account = accounts.find((a) => a.id === selectedAccountId);
if (account) {
send(`已选择账号「${account.name}」:${account.description || ''}。生图:${account.imageModel} 视频:${account.videoModel} 画幅:${account.defaultFormat}`);
}
}
}, [selectedAccountId, conversationId, connected]);
const handleNewConversation = useCallback(() => {
if (creatingRef.current) return;
creatingRef.current = true;
createConversation('新对话', selectedAccountId || undefined);
setTimeout(() => {
fetch('/api/pipeline/conversations').then((r) => r.json()).then((list) => {
setConversations(list);
if (list.length > 0) navigate(`/chat/${list[0].id}`);
creatingRef.current = false;
});
}, 500);
}, [createConversation, selectedAccountId]);
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 handleSend = useCallback((content: string) => {
if (quote) { send(`> ${quote}\n\n${content}`); setQuote(null); }
else send(content);
}, [send, quote]);
const handleStop = useCallback(() => { stop(); }, [stop]);
if (!conversationId) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-4">
<div className="text-4xl mb-2">💬</div>
<h2 className="text-lg font-semibold text-zinc-800"></h2>
<p className="text-sm text-zinc-500"></p>
<div className="flex gap-2 mt-2">
{accounts.length > 0 && (
<select
value={selectedAccountId || ''}
onChange={(e) => setSelectedAccountId(e.target.value || null)}
className="h-9 rounded-md border border-zinc-200 bg-white px-3 text-sm text-zinc-700"
>
<option value=""></option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
)}
<button
onClick={handleNewConversation}
disabled={creatingRef.current}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-50"
>
{creatingRef.current ? '创建中...' : '开始对话'}
</button>
</div>
</div>
);
}
return (
<div className="flex-1 flex flex-col bg-white">
<div className="px-4 py-2 border-b border-zinc-200 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-500' : 'bg-red-400'}`} />
<span className="text-xs text-zinc-400">{connected ? '在线' : '连接中'}</span>
</div>
<select
value={selectedAccountId || ''}
onChange={(e) => setSelectedAccountId(e.target.value || null)}
className="h-7 rounded border border-zinc-200 bg-zinc-50 px-2 text-xs text-zinc-600"
>
<option value=""></option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
</div>
<div className="flex items-center gap-2">
{manifestPath && (
<Button size="sm" variant="outline" className="h-7 text-xs" 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" onClick={handleStop}>
<StopCircle size={12} className="mr-1" />
</Button>
)}
</div>
</div>
<ScrollArea className="flex-1 px-4 py-4">
{messages.map((msg) => (
<ChatMessage
key={msg.id}
message={msg}
onRegenerate={msg.role === 'assistant' ? handleRegenerate : undefined}
onContinue={msg.role === 'assistant' ? handleContinue : undefined}
onQuote={handleQuote}
/>
))}
{pipeline && (
<PipelineProgress
phase={pipeline.phase}
progress={pipeline.progress}
currentItem={pipeline.currentItem}
totalItems={pipeline.totalItems}
status={pipeline.status}
/>
)}
{thinking && !pipeline && (
<div className="flex items-center gap-2 text-zinc-400 text-sm py-2">
<Loader2 size={14} className="animate-spin" />
{toolStatus || '思考中...'}
</div>
)}
</ScrollArea>
{quote && (
<div className="mx-4 px-3 py-1.5 bg-zinc-50 border border-zinc-200 rounded-lg flex items-center gap-2 text-xs text-zinc-500">
<span className="flex-1 truncate">: {quote}</span>
<button onClick={() => setQuote(null)} className="text-zinc-400 hover:text-zinc-600"><X size={12} /></button>
</div>
)}
<ChatInput onSend={handleSend} disabled={thinking} />
</div>
);
}