2026-05-07 03:48:14 +08:00
|
|
|
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
|
|
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
2026-05-07 02:41:01 +08:00
|
|
|
|
import { useAppStore } from '@/store';
|
|
|
|
|
|
import { useChat } from '@/hooks/useChat';
|
|
|
|
|
|
import { ChatMessage } from './ChatMessage';
|
|
|
|
|
|
import { ChatInput } from './ChatInput';
|
2026-05-07 04:09:00 +08:00
|
|
|
|
import { PipelineProgress } from './PipelineProgress';
|
2026-05-07 02:46:16 +08:00
|
|
|
|
import { Button } from '@/components/ui/button';
|
2026-05-08 02:14:28 +08:00
|
|
|
|
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
2026-05-08 00:41:09 +08:00
|
|
|
|
import { RefreshCw, Loader2, StopCircle, X, MessageCircle, ArrowDown } from 'lucide-react';
|
2026-05-07 03:48:14 +08:00
|
|
|
|
import { api } from '@/lib/api';
|
2026-05-07 04:09:00 +08:00
|
|
|
|
import type { Account, Message } from '@/types';
|
2026-05-07 02:41:01 +08:00
|
|
|
|
|
2026-05-07 02:24:43 +08:00
|
|
|
|
export function ChatView() {
|
2026-05-07 03:48:14 +08:00
|
|
|
|
const { conversationId } = useParams<{ conversationId?: string }>();
|
|
|
|
|
|
const navigate = useNavigate();
|
2026-05-08 00:23:36 +08:00
|
|
|
|
const { setConversations, selectedAccountId, conversations } = useAppStore();
|
2026-05-07 23:48:26 +08:00
|
|
|
|
const { messages, connected, thinking, toolStatus, pipeline, send, stop, createConversation, removeMessage } = useChat(conversationId || null);
|
2026-05-07 02:46:16 +08:00
|
|
|
|
const [manifestPath, setManifestPath] = useState<string | null>(null);
|
2026-05-07 03:48:14 +08:00
|
|
|
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
2026-05-07 04:09:00 +08:00
|
|
|
|
const [quote, setQuote] = useState<string | null>(null);
|
2026-05-07 23:48:26 +08:00
|
|
|
|
const [pendingMessage, setPendingMessage] = useState<string | null>(null);
|
2026-05-08 00:41:09 +08:00
|
|
|
|
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
2026-05-07 03:48:14 +08:00
|
|
|
|
const creatingRef = useRef(false);
|
2026-05-08 00:23:36 +08:00
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const userScrolledUp = useRef(false);
|
2026-05-07 03:48:14 +08:00
|
|
|
|
|
2026-05-07 04:09:00 +08:00
|
|
|
|
useEffect(() => { api.listAccounts().then(setAccounts).catch(() => {}); }, []);
|
2026-05-07 02:41:01 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-05-07 23:48:26 +08:00
|
|
|
|
api.listConversations().then(setConversations).catch(() => {});
|
2026-05-07 02:41:01 +08:00
|
|
|
|
}, [messages]);
|
|
|
|
|
|
|
2026-05-07 02:46:16 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-05-08 00:23:36 +08:00
|
|
|
|
// Auto-scroll: follow new content unless user has scrolled up
|
2026-05-07 03:48:14 +08:00
|
|
|
|
useEffect(() => {
|
2026-05-08 00:23:36 +08:00
|
|
|
|
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;
|
2026-05-08 00:41:09 +08:00
|
|
|
|
setShowScrollBtn(!atBottom);
|
2026-05-08 00:23:36 +08:00
|
|
|
|
};
|
|
|
|
|
|
el.addEventListener('scroll', onScroll, { passive: true });
|
|
|
|
|
|
return () => el.removeEventListener('scroll', onScroll);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// Reset scroll state on conversation switch
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
userScrolledUp.current = false;
|
2026-05-08 00:41:09 +08:00
|
|
|
|
setShowScrollBtn(false);
|
2026-05-08 00:23:36 +08:00
|
|
|
|
}, [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);
|
|
|
|
|
|
setPendingMessage(null);
|
|
|
|
|
|
}, 300);
|
|
|
|
|
|
return () => clearTimeout(timer);
|
2026-05-07 03:48:14 +08:00
|
|
|
|
}
|
2026-05-07 23:48:26 +08:00
|
|
|
|
}, [conversationId, connected, pendingMessage]);
|
2026-05-07 02:41:01 +08:00
|
|
|
|
|
2026-05-07 02:46:16 +08:00
|
|
|
|
const handleResume = async () => {
|
|
|
|
|
|
if (!manifestPath) return;
|
2026-05-07 04:09:00 +08:00
|
|
|
|
await fetch('/api/pipeline/resume', {
|
|
|
|
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ manifest: manifestPath }),
|
|
|
|
|
|
});
|
2026-05-07 02:46:16 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-07 04:09:00 +08:00
|
|
|
|
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)); }, []);
|
2026-05-07 23:48:26 +08:00
|
|
|
|
const handleDeleteMsg = useCallback((msgId: string) => {
|
|
|
|
|
|
removeMessage(msgId);
|
|
|
|
|
|
}, [removeMessage]);
|
|
|
|
|
|
|
|
|
|
|
|
// Delayed conversation creation
|
|
|
|
|
|
const handleSendNew = useCallback(async (content: string) => {
|
|
|
|
|
|
if (creatingRef.current) return;
|
|
|
|
|
|
creatingRef.current = true;
|
|
|
|
|
|
setPendingMessage(content);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-08 00:23:36 +08:00
|
|
|
|
const newId = await createConversation(content.slice(0, 30), selectedAccountId || undefined);
|
|
|
|
|
|
if (newId) {
|
2026-05-07 23:48:26 +08:00
|
|
|
|
const list = await api.listConversations();
|
|
|
|
|
|
setConversations(list);
|
2026-05-08 00:23:36 +08:00
|
|
|
|
navigate(`/chat/${newId}`);
|
|
|
|
|
|
}
|
2026-05-07 23:48:26 +08:00
|
|
|
|
} catch {
|
2026-05-08 00:23:36 +08:00
|
|
|
|
// fallback
|
2026-05-07 23:48:26 +08:00
|
|
|
|
}
|
2026-05-08 00:23:36 +08:00
|
|
|
|
creatingRef.current = false;
|
2026-05-07 23:48:26 +08:00
|
|
|
|
}, [createConversation, selectedAccountId]);
|
2026-05-07 04:09:00 +08:00
|
|
|
|
|
2026-05-08 02:18:50 +08:00
|
|
|
|
const handleSend = useCallback((content: string, images?: Array<{ data: string; mimeType: string }>) => {
|
2026-05-07 23:48:26 +08:00
|
|
|
|
if (quote) { content = `> ${quote}\n\n${content}`; setQuote(null); }
|
|
|
|
|
|
if (conversationId) {
|
2026-05-08 02:18:50 +08:00
|
|
|
|
send(content, images);
|
2026-05-07 23:48:26 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
handleSendNew(content);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [send, quote, conversationId]);
|
2026-05-07 04:09:00 +08:00
|
|
|
|
|
|
|
|
|
|
const handleStop = useCallback(() => { stop(); }, [stop]);
|
|
|
|
|
|
|
2026-05-07 23:48:26 +08:00
|
|
|
|
// Empty state - no conversation selected
|
2026-05-07 03:48:14 +08:00
|
|
|
|
if (!conversationId) {
|
2026-05-08 00:23:36 +08:00
|
|
|
|
const recentConvs = conversations.slice(0, 5);
|
2026-05-07 02:41:01 +08:00
|
|
|
|
return (
|
2026-05-08 00:41:09 +08:00
|
|
|
|
<div className="flex-1 flex flex-col items-center justify-center bg-gradient-to-b from-white to-zinc-50/30">
|
2026-05-08 00:23:36 +08:00
|
|
|
|
<div className="w-full max-w-xl px-4">
|
|
|
|
|
|
<div className="text-center mb-6">
|
2026-05-08 00:41:09 +08:00
|
|
|
|
<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>
|
2026-05-08 00:23:36 +08:00
|
|
|
|
<h2 className="text-lg font-semibold text-zinc-800">开始新对话</h2>
|
|
|
|
|
|
<p className="text-sm text-zinc-400 mt-1">输入消息开始创作,对话将自动创建</p>
|
|
|
|
|
|
</div>
|
2026-05-08 02:47:23 +08:00
|
|
|
|
<ChatInput onSend={handleSend} disabled={thinking || !connected || creatingRef.current} connecting={!connected} />
|
2026-05-08 00:23:36 +08:00
|
|
|
|
{accounts.length > 0 && (
|
|
|
|
|
|
<div className="flex items-center gap-2 mt-3 justify-center">
|
|
|
|
|
|
<span className="text-xs text-zinc-400">当前账号:</span>
|
2026-05-08 02:18:50 +08:00
|
|
|
|
<Select
|
2026-05-08 00:23:36 +08:00
|
|
|
|
value={selectedAccountId || ''}
|
2026-05-08 02:18:50 +08:00
|
|
|
|
onValueChange={(v) => useAppStore.getState().setSelectedAccountId(v === 'none' ? null : v)}
|
2026-05-08 00:23:36 +08:00
|
|
|
|
>
|
2026-05-08 02:18:50 +08:00
|
|
|
|
<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>
|
2026-05-08 00:23:36 +08:00
|
|
|
|
</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}`)}
|
2026-05-08 00:41:09 +08:00
|
|
|
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-xl text-left hover:bg-zinc-50/80 transition-colors group"
|
2026-05-08 00:23:36 +08:00
|
|
|
|
>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-05-07 23:48:26 +08:00
|
|
|
|
</div>
|
2026-05-07 02:41:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 23:48:26 +08:00
|
|
|
|
// Active conversation
|
2026-05-07 02:41:01 +08:00
|
|
|
|
return (
|
2026-05-07 23:48:26 +08:00
|
|
|
|
<div className="flex-1 flex flex-col bg-white h-full">
|
|
|
|
|
|
{/* Header bar */}
|
2026-05-08 00:41:09 +08:00
|
|
|
|
<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'}`} />
|
2026-05-07 23:48:26 +08:00
|
|
|
|
<span className="text-xs text-zinc-400">{connected ? '在线' : '连接中'}</span>
|
2026-05-07 02:46:16 +08:00
|
|
|
|
</div>
|
2026-05-07 04:09:00 +08:00
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
{manifestPath && (
|
2026-05-08 00:41:09 +08:00
|
|
|
|
<Button size="sm" variant="outline" className="h-7 text-xs rounded-lg" onClick={handleResume}>
|
2026-05-07 04:09:00 +08:00
|
|
|
|
<RefreshCw size={12} className="mr-1" />断点续跑
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{thinking && (
|
2026-05-08 00:41:09 +08:00
|
|
|
|
<Button size="sm" variant="outline" className="h-7 text-xs text-red-500 border-red-200 hover:bg-red-50 rounded-lg" onClick={handleStop}>
|
2026-05-07 04:09:00 +08:00
|
|
|
|
<StopCircle size={12} className="mr-1" />停止
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-05-07 02:41:01 +08:00
|
|
|
|
</div>
|
2026-05-07 03:22:15 +08:00
|
|
|
|
|
2026-05-07 23:48:26 +08:00
|
|
|
|
{/* Chat area */}
|
2026-05-08 00:41:09 +08:00
|
|
|
|
<div className="flex-1 overflow-hidden relative">
|
2026-05-07 23:48:26 +08:00
|
|
|
|
<div className="h-full flex flex-col">
|
|
|
|
|
|
{/* Message list */}
|
2026-05-08 00:23:36 +08:00
|
|
|
|
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
|
2026-05-07 23:48:26 +08:00
|
|
|
|
{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}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
{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">
|
2026-05-08 00:41:09 +08:00
|
|
|
|
<Loader2 size={14} className="animate-spin text-indigo-400" />
|
2026-05-07 23:48:26 +08:00
|
|
|
|
{toolStatus || '思考中...'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-07 03:22:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-08 00:41:09 +08:00
|
|
|
|
{/* 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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-05-07 23:48:26 +08:00
|
|
|
|
{/* Quote bar */}
|
|
|
|
|
|
{quote && (
|
2026-05-08 00:41:09 +08:00
|
|
|
|
<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">
|
2026-05-07 23:48:26 +08:00
|
|
|
|
<span className="flex-1 truncate">引用: {quote}</span>
|
|
|
|
|
|
<button onClick={() => setQuote(null)} className="text-zinc-400 hover:text-zinc-600"><X size={12} /></button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-07 04:09:00 +08:00
|
|
|
|
|
2026-05-07 23:48:26 +08:00
|
|
|
|
{/* Input */}
|
2026-05-08 02:47:23 +08:00
|
|
|
|
<ChatInput onSend={handleSend} disabled={thinking || !connected} connecting={!connected} />
|
2026-05-07 23:48:26 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-07 02:41:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2026-05-07 02:24:43 +08:00
|
|
|
|
}
|