feat(web): 添加全局 Toast 通知系统和资产预览导航功能

- 新增 ToastProvider 和 useToast hook,支持全局成功/错误/信息提示
- 资产预览增加左右导航按钮、键盘快捷键(方向键)和计数器显示
- 资产库增加图片/视频类型筛选标签页及计数
- 对话列表增加最近对话展示、搜索优化和删除确认
- 消息增加删除确认对话框
- 优化聊天自动滚动行为,仅在用户未手动滚动时跟随新内容
- 新增删除消息 API 端点
- 优化消息历史清理逻辑,过滤错误消息和孤儿 tool 消息
- 添加自定义滚动条样式
- 优化账户参考图显示逻辑,支持本地文件显示
- 修复对话创建流程,直接导航到新创建的对话
This commit is contained in:
2026-05-08 00:23:36 +08:00
parent 088bdb9a8e
commit 66d170066a
23 changed files with 801 additions and 374 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

315
web/client/dist/assets/index-ChelO-mr.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>美图 Agent</title>
<script type="module" crossorigin src="/assets/index-V7y9417K.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CXGrBwuC.css">
<script type="module" crossorigin src="/assets/index-ChelO-mr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bd3B3-IW.css">
</head>
<body class="bg-white text-zinc-900 antialiased">
<div id="root"></div>

View File

@@ -4,19 +4,22 @@ import { ChatView } from '@/components/chat/ChatView';
import { AccountList } from '@/components/accounts/AccountList';
import { AssetGallery } from '@/components/assets/AssetGallery';
import { ConfigForm } from '@/components/config/ConfigForm';
import { ToastProvider } from '@/hooks/useToast';
export default function App() {
return (
<AppLayout>
<Routes>
<Route path="/" element={<Navigate to="/chat" replace />} />
<Route path="/chat" element={<ChatView />} />
<Route path="/chat/:conversationId" element={<ChatView />} />
<Route path="/accounts" element={<AccountList />} />
<Route path="/accounts/:accountId" element={<AccountList />} />
<Route path="/assets" element={<AssetGallery />} />
<Route path="/settings" element={<ConfigForm />} />
</Routes>
</AppLayout>
<ToastProvider>
<AppLayout>
<Routes>
<Route path="/" element={<Navigate to="/chat" replace />} />
<Route path="/chat" element={<ChatView />} />
<Route path="/chat/:conversationId" element={<ChatView />} />
<Route path="/accounts" element={<AccountList />} />
<Route path="/accounts/:accountId" element={<AccountList />} />
<Route path="/assets" element={<AssetGallery />} />
<Route path="/settings" element={<ConfigForm />} />
</Routes>
</AppLayout>
</ToastProvider>
);
}

View File

@@ -36,6 +36,7 @@ export function AccountReferencesTab({ account, onSave }: Props) {
};
const handleDelete = async (index: number) => {
if (!confirm('确定删除这张参考图?')) return;
try {
await fetch(`/api/accounts/${account.id}/references/${index}`, { method: 'DELETE' });
const updated = await api.getAccount(account.id);
@@ -62,7 +63,9 @@ export function AccountReferencesTab({ account, onSave }: Props) {
<div className="grid grid-cols-4 gap-2">
{refs.map((ref, i) => (
<div key={i} className="group relative aspect-square bg-zinc-100 rounded-lg overflow-hidden border border-zinc-200">
{ref.url ? (
{ref.file ? (
<img src={`/api/accounts/${account.id}/references/${encodeURIComponent(ref.file.split('/').pop() || '')}`} alt="" className="w-full h-full object-cover" />
) : ref.url ? (
<img src={ref.url} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from 'react';
import { RefreshCw, FolderOpen } from 'lucide-react';
import { RefreshCw, FolderOpen, Image as ImageIcon, Film } from 'lucide-react';
import { useAssets } from '@/hooks/useAssets';
import { AssetProjectGroup } from './AssetProjectGroup';
import { AssetPreview } from './AssetPreview';
@@ -14,8 +14,11 @@ interface ManifestGroup {
latest_at: string;
}
type TypeTab = 'all' | 'image' | 'video';
export function AssetGallery() {
const [accountFilter, setAccountFilter] = useState('');
const [typeTab, setTypeTab] = useState<TypeTab>('all');
const [accounts, setAccounts] = useState<{ id: string; name: string }[]>([]);
const [manifests, setManifests] = useState<ManifestGroup[]>([]);
const [previewAsset, setPreviewAsset] = useState<Asset | null>(null);
@@ -28,16 +31,25 @@ export function AssetGallery() {
fetch('/api/assets/manifests').then((r) => r.json()).then(setManifests).catch(() => {});
}, []);
// Group assets by manifest_path
// Filter by type tab
const filtered = useMemo(() => {
if (typeTab === 'all') return assets;
return assets.filter((a) => a.type === typeTab);
}, [assets, typeTab]);
// Group filtered assets by manifest_path
const grouped = useMemo(() => {
const map = new Map<string, Asset[]>();
for (const asset of assets) {
for (const asset of filtered) {
const key = asset.manifest_path || 'ungrouped';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(asset);
}
return map;
}, [assets]);
}, [filtered]);
const imageCount = assets.filter((a) => a.type === 'image').length;
const videoCount = assets.filter((a) => a.type === 'video').length;
const handleScan = async () => {
await fetch('/api/assets/scan', { method: 'POST' });
@@ -46,7 +58,6 @@ export function AssetGallery() {
const getGroupName = (manifestPath: string) => {
const parts = manifestPath.replace(/\\/g, '/').split('/');
// output/<name>/manifest.json → <name>
const dir = parts.length >= 2 ? parts[parts.length - 2] : manifestPath;
return dir;
};
@@ -72,10 +83,36 @@ export function AssetGallery() {
});
}, [grouped, manifests, accountFilter]);
const tabs: { key: TypeTab; label: string; icon: typeof ImageIcon; count: number }[] = [
{ key: 'all', label: '全部', icon: FolderOpen, count: assets.length },
{ key: 'image', label: '图片', icon: ImageIcon, count: imageCount },
{ key: 'video', label: '视频', icon: Film, count: videoCount },
];
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-200 bg-white flex-shrink-0">
{/* Type tabs */}
<div className="flex rounded-lg border border-zinc-200 overflow-hidden">
{tabs.map(({ key, label, icon: Icon, count }) => (
<button
key={key}
onClick={() => setTypeTab(key)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors
${typeTab === key
? 'bg-indigo-50 text-indigo-700 border-r border-zinc-200 last:border-r-0'
: 'bg-white text-zinc-500 hover:bg-zinc-50 border-r border-zinc-200 last:border-r-0'}`}
>
<Icon size={13} />
{label}
<span className={`text-[10px] px-1 rounded-full ${typeTab === key ? 'bg-indigo-100 text-indigo-600' : 'bg-zinc-100 text-zinc-400'}`}>
{count}
</span>
</button>
))}
</div>
<select
value={accountFilter}
onChange={(e) => setAccountFilter(e.target.value)}
@@ -86,7 +123,7 @@ export function AssetGallery() {
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
<span className="text-[10px] text-zinc-400">{assets.length} </span>
<div className="flex-1" />
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={handleScan}>
<RefreshCw size={12} className="mr-1" />
@@ -101,7 +138,7 @@ export function AssetGallery() {
) : filteredGroups.length === 0 ? (
<div className="flex flex-col items-center justify-center mt-16 text-zinc-400">
<FolderOpen size={28} className="mb-2 opacity-40" />
<p className="text-sm"></p>
<p className="text-sm">{typeTab === 'all' ? '暂无资产' : `暂无${typeTab === 'image' ? '图片' : '视频'}`}</p>
<p className="text-xs mt-1">"扫描" output </p>
</div>
) : (
@@ -118,7 +155,7 @@ export function AssetGallery() {
)}
</div>
{previewAsset && <AssetPreview asset={previewAsset} onClose={() => setPreviewAsset(null)} />}
{previewAsset && <AssetPreview asset={previewAsset} allAssets={filtered} onClose={() => setPreviewAsset(null)} onNavigate={setPreviewAsset} />}
</div>
);
}

View File

@@ -1,7 +1,39 @@
import { X } from 'lucide-react';
import { useEffect, useCallback } from 'react';
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
import type { Asset } from '@/types';
export function AssetPreview({ asset, onClose }: { asset: Asset; onClose: () => void }) {
interface Props {
asset: Asset;
allAssets: Asset[];
onClose: () => void;
onNavigate: (asset: Asset) => void;
}
export function AssetPreview({ asset, allAssets, onClose, onNavigate }: Props) {
const currentIndex = allAssets.findIndex((a) => a.id === asset.id);
const goPrev = useCallback(() => {
if (currentIndex > 0) onNavigate(allAssets[currentIndex - 1]);
}, [currentIndex, allAssets, onNavigate]);
const goNext = useCallback(() => {
if (currentIndex < allAssets.length - 1) onNavigate(allAssets[currentIndex + 1]);
}, [currentIndex, allAssets, onNavigate]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'ArrowLeft') goPrev();
if (e.key === 'ArrowRight') goNext();
}, [onClose, goPrev, goNext]);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
const hasPrev = currentIndex > 0;
const hasNext = currentIndex < allAssets.length - 1;
return (
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
@@ -9,11 +41,29 @@ export function AssetPreview({ asset, onClose }: { asset: Asset; onClose: () =>
>
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 rounded-full bg-zinc-800 hover:bg-zinc-700 text-white"
className="absolute top-4 right-4 p-2 rounded-full bg-zinc-800 hover:bg-zinc-700 text-white z-10"
>
<X size={20} />
</button>
{/* Navigation arrows */}
{hasPrev && (
<button
onClick={(e) => { e.stopPropagation(); goPrev(); }}
className="absolute left-4 p-2 rounded-full bg-zinc-800/80 hover:bg-zinc-700 text-white z-10"
>
<ChevronLeft size={24} />
</button>
)}
{hasNext && (
<button
onClick={(e) => { e.stopPropagation(); goNext(); }}
className="absolute right-4 p-2 rounded-full bg-zinc-800/80 hover:bg-zinc-700 text-white z-10"
>
<ChevronRight size={24} />
</button>
)}
<div className="max-w-[90vw] max-h-[90vh]" onClick={(e) => e.stopPropagation()}>
{asset.type === 'image' ? (
<img
@@ -30,6 +80,13 @@ export function AssetPreview({ asset, onClose }: { asset: Asset; onClose: () =>
/>
)}
</div>
{/* Counter */}
{allAssets.length > 1 && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-xs text-zinc-400 bg-zinc-900/80 px-3 py-1 rounded-full">
{currentIndex + 1} / {allAssets.length}
</div>
)}
</div>
);
}

View File

@@ -60,7 +60,7 @@ export function AssetProjectGroup({ title, subtitle, assets, onPreview, onDelete
</div>
)}
<button
onClick={(e) => { e.stopPropagation(); onDelete(asset.id); }}
onClick={(e) => { e.stopPropagation(); if (confirm('确定删除这个资产?')) onDelete(asset.id); }}
className="absolute top-1 right-1 p-0.5 rounded bg-white/80 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm"
>
<Trash2 size={10} className="text-red-500" />

View File

@@ -1,7 +1,8 @@
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, Check, Brain } from 'lucide-react';
import { Bot, User, Copy, Trash2, ChevronDown, ChevronRight, RefreshCw, ArrowRight, Quote, Brain } from 'lucide-react';
import { useToast } from '@/hooks/useToast';
import type { Message } from '@/types';
type MessageState = 'streaming' | 'thinking' | 'done';
@@ -26,16 +27,20 @@ function getState(msg: Message, isLast: boolean, isThinking: boolean): MessageSt
export function ChatMessage({ message, isLast, isThinking, onRegenerate, onContinue, onQuote, onDelete }: Props) {
const isUser = message.role === 'user';
const isTool = message.role === 'tool';
const [copied, setCopied] = useState(false);
const [reasoningOpen, setReasoningOpen] = useState(false);
const { toast } = useToast();
const state = getState(message, !!isLast, !!isThinking);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(message.content).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast('已复制到剪贴板', 'success');
});
}, [message.content]);
}, [message.content, toast]);
const handleDelete = useCallback(() => {
if (!confirm('确定删除这条消息?')) return;
onDelete?.(message.id);
}, [message.id, onDelete]);
// Tool messages
if (isTool) {
@@ -125,13 +130,13 @@ export function ChatMessage({ message, isLast, isThinking, onRegenerate, onConti
<div className="flex gap-0.5 mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity px-1">
<button onClick={handleCopy}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
{copied ? <><Check size={10} /></> : <><Copy size={10} /></>}
<Copy size={10} />
</button>
<button onClick={() => onQuote?.(message.content)}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 transition-colors">
<Quote size={10} />
</button>
<button onClick={() => onDelete?.(message.id)}
<button onClick={handleDelete}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] text-zinc-400 hover:text-red-500 hover:bg-red-50 transition-colors">
<Trash2 size={10} />
</button>

View File

@@ -6,20 +6,22 @@ import { ChatMessage } from './ChatMessage';
import { ChatInput } from './ChatInput';
import { PipelineProgress } from './PipelineProgress';
import { Button } from '@/components/ui/button';
import { RefreshCw, Loader2, StopCircle, X } from 'lucide-react';
import { RefreshCw, Loader2, StopCircle, X, MessageCircle } 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 } = useAppStore();
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<string | null>(null);
const creatingRef = useRef(false);
const scrollRef = useRef<HTMLDivElement>(null);
const userScrolledUp = useRef(false);
useEffect(() => { api.listAccounts().then(setAccounts).catch(() => {}); }, []);
@@ -37,11 +39,39 @@ export function ChatView() {
}
}, [messages]);
// After navigating to a new conversation, send the pending message
// Auto-scroll: follow new content unless user has scrolled up
useEffect(() => {
if (conversationId && connected && pendingMessage && messages.length === 0) {
send(pendingMessage);
setPendingMessage(null);
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;
};
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, []);
// Reset scroll state on conversation switch
useEffect(() => {
userScrolledUp.current = 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);
setPendingMessage(null);
}, 300);
return () => clearTimeout(timer);
}
}, [conversationId, connected, pendingMessage]);
@@ -80,18 +110,16 @@ export function ChatView() {
setPendingMessage(content);
try {
createConversation(content.slice(0, 30), selectedAccountId || undefined);
setTimeout(async () => {
const newId = await createConversation(content.slice(0, 30), selectedAccountId || undefined);
if (newId) {
const list = await api.listConversations();
setConversations(list);
if (list.length > 0) {
navigate(`/chat/${list[0].id}`);
}
creatingRef.current = false;
}, 600);
navigate(`/chat/${newId}`);
}
} catch {
creatingRef.current = false;
// fallback
}
creatingRef.current = false;
}, [createConversation, selectedAccountId]);
const handleSend = useCallback((content: string) => {
@@ -107,27 +135,47 @@ export function ChatView() {
// 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 gap-4 bg-white">
<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="w-full max-w-xl px-4 mt-4">
<ChatInput onSend={handleSend} disabled={thinking || creatingRef.current} />
</div>
{accounts.length > 0 && (
<div className="flex items-center gap-2 mt-3">
<span className="text-xs text-zinc-400">:</span>
<select
value={selectedAccountId || ''}
onChange={(e) => useAppStore.getState().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 className="flex-1 flex flex-col items-center justify-center bg-white">
<div className="w-full max-w-xl px-4">
<div className="text-center mb-6">
<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 || creatingRef.current} />
{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 || ''}
onChange={(e) => useAppStore.getState().setSelectedAccountId(e.target.value || null)}
className="h-7 rounded-md border border-zinc-200 bg-white px-2.5 text-xs text-zinc-600"
>
<option value=""></option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
</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-lg text-left hover:bg-zinc-50 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>
);
}
@@ -159,7 +207,7 @@ export function ChatView() {
<div className="flex-1 overflow-hidden">
<div className="h-full flex flex-col">
{/* Message list */}
<div className="flex-1 overflow-y-auto px-4 py-4">
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
{messages.map((msg, i) => (
msg.role !== 'tool' && (
<ChatMessage

View File

@@ -1,10 +1,13 @@
import { useLocation } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { MiddlePanel } from './MiddlePanel';
import { ToastContainer } from '@/components/ui/Toast';
import { useToast } from '@/hooks/useToast';
export function AppLayout({ children }: { children: React.ReactNode }) {
const { pathname } = useLocation();
const isChat = pathname.startsWith('/chat');
const { toasts, dismiss } = useToast();
return (
<div className="h-screen flex bg-white text-zinc-900 overflow-hidden">
@@ -13,6 +16,7 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
<main className="flex-1 flex flex-col min-w-0 bg-zinc-50">
{children}
</main>
<ToastContainer toasts={toasts} dismiss={dismiss} />
</div>
);
}

View File

@@ -1,24 +1,37 @@
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useState, useEffect, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Plus, Pin, Trash2, MessageCircle, Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useAppStore } from '@/store';
import { api } from '@/lib/api';
import { useToast } from '@/hooks/useToast';
export function MiddlePanel() {
const { conversations, setConversations } = useAppStore();
const [search, setSearch] = useState('');
const navigate = useNavigate();
const { conversationId } = useParams();
const location = useLocation();
const { toast } = useToast();
useEffect(() => {
const conversationId = useMemo(() => {
const match = location.pathname.match(/^\/chat\/(.+)$/);
return match ? match[1] : null;
}, [location.pathname]);
const loadConversations = () => {
api.listConversations().then(setConversations).catch(() => {});
}, []);
};
useEffect(() => { loadConversations(); }, []);
const handleSearch = (value: string) => {
setSearch(value);
const qs = value ? `?search=${encodeURIComponent(value)}` : '';
if (!value) {
loadConversations();
return;
}
const qs = `?search=${encodeURIComponent(value)}`;
fetch(`/api/pipeline/conversations${qs}`)
.then((r) => r.json())
.then(setConversations)
@@ -30,16 +43,15 @@ export function MiddlePanel() {
const conv = conversations.find((c) => c.id === id);
if (!conv) return;
await api.updateConversation(id, { pinned: !conv.pinned });
const list = await api.listConversations();
setConversations(list);
loadConversations();
};
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm('确定删除这个对话?')) return;
await api.deleteConversation(id);
const list = await api.listConversations();
setConversations(list);
loadConversations();
toast('对话已删除', 'success');
if (id === conversationId) navigate('/chat');
};
@@ -84,7 +96,7 @@ export function MiddlePanel() {
>
<div className="flex items-center gap-1.5">
{conv.pinned ? <Pin size={11} className="text-indigo-500 flex-shrink-0" /> : null}
<div className={`text-sm truncate font-medium flex-1 ${conv.id === conversationId ? 'text-indigo-700' : 'text-zinc-700'}`}>
<div className={`text-sm truncate flex-1 ${conv.id === conversationId ? 'text-indigo-700 font-medium' : 'text-zinc-700'}`}>
{conv.title}
</div>
<div className="opacity-0 group-hover:opacity-100 flex gap-0.5 transition-opacity flex-shrink-0">
@@ -104,12 +116,8 @@ export function MiddlePanel() {
</button>
</div>
</div>
<div className="flex items-center justify-between mt-0.5">
<span className="text-xs text-zinc-400 truncate max-w-[140px]">
<MessageCircle size={10} className="inline mr-1" />
{conv.title.length > 25 ? conv.title.slice(0, 25) + '...' : conv.title}
</span>
<span className="text-[10px] text-zinc-300 flex-shrink-0 ml-2">
<div className="mt-0.5">
<span className="text-[10px] text-zinc-300">
{formatDate(conv.updated_at)}
</span>
</div>

View File

@@ -0,0 +1,57 @@
import { useEffect, useRef } from 'react';
import { CheckCircle, AlertCircle, Info, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ToastItem } from '@/hooks/useToast';
const icons = {
success: CheckCircle,
error: AlertCircle,
info: Info,
};
const styles = {
success: 'text-green-600 bg-green-50 border-green-200',
error: 'text-red-600 bg-red-50 border-red-200',
info: 'text-zinc-600 bg-zinc-50 border-zinc-200',
};
export function ToastContainer({ toasts, dismiss }: { toasts: ToastItem[]; dismiss: (id: number) => void }) {
return (
<div className="fixed bottom-6 right-6 z-[100] flex flex-col gap-2 pointer-events-none">
{toasts.map((t) => (
<Toast key={t.id} item={t} dismiss={dismiss} />
))}
</div>
);
}
function Toast({ item, dismiss }: { item: ToastItem; dismiss: (id: number) => void }) {
const Icon = icons[item.type];
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
ref.current?.animate(
[
{ opacity: 0, transform: 'translateY(8px)' },
{ opacity: 1, transform: 'translateY(0)' },
],
{ duration: 200, fill: 'forwards' }
);
}, []);
return (
<div
ref={ref}
className={cn(
'pointer-events-auto flex items-center gap-2 px-3.5 py-2.5 rounded-lg border shadow-lg text-sm',
styles[item.type]
)}
>
<Icon size={15} className="flex-shrink-0" />
<span className="flex-1">{item.message}</span>
<button onClick={() => dismiss(item.id)} className="opacity-60 hover:opacity-100 ml-1">
<X size={13} />
</button>
</div>
);
}

View File

@@ -7,7 +7,7 @@ export function useAccounts() {
const [loading, setLoading] = useState(true);
const refresh = useCallback(() => {
api.listAccounts().then(setAccounts).finally(() => setLoading(false));
return api.listAccounts().then(setAccounts).finally(() => setLoading(false));
}, []);
useEffect(() => { refresh(); }, [refresh]);

View File

@@ -16,10 +16,13 @@ export function useChat(conversationId: string | null) {
const [thinking, setThinking] = useState(false);
const [toolStatus, setToolStatus] = useState<string | null>(null);
const [pipeline, setPipeline] = useState<PipelineState | null>(null);
const initRef = useRef(false);
// Track which conversation is currently loaded via WS
const activeConvRef = useRef<string | null>(null);
// Pending conversation creation resolve
const createResolveRef = useRef<((id: string) => void) | null>(null);
useEffect(() => {
// Bind all event handlers once
const onConnected = () => setConnected(true);
const onHistory = (data: Record<string, unknown>) => {
setMessages((data.messages as Message[]) || []);
@@ -82,6 +85,14 @@ export function useChat(conversationId: string | null) {
status: data.status as string | undefined,
});
};
const onConversationCreated = (data: Record<string, unknown>) => {
const id = data.id as string;
activeConvRef.current = id;
if (createResolveRef.current) {
createResolveRef.current(id);
createResolveRef.current = null;
}
};
chatSocket.on('connected', onConnected);
chatSocket.on('history', onHistory);
@@ -95,6 +106,7 @@ export function useChat(conversationId: string | null) {
chatSocket.on('tool_result', onToolResult);
chatSocket.on('tool_error', onToolError);
chatSocket.on('pipeline_progress', onPipelineProgress);
chatSocket.on('conversation_created', onConversationCreated);
chatSocket.connect();
return () => {
@@ -110,19 +122,30 @@ export function useChat(conversationId: string | null) {
chatSocket.off('tool_result', onToolResult);
chatSocket.off('tool_error', onToolError);
chatSocket.off('pipeline_progress', onPipelineProgress);
chatSocket.off('conversation_created', onConversationCreated);
chatSocket.disconnect();
};
}, []);
// Load conversation when ID changes
// Init/re-init when conversationId changes
useEffect(() => {
if (conversationId && connected && !initRef.current) {
initRef.current = true;
if (!connected) return;
if (conversationId && conversationId !== activeConvRef.current) {
activeConvRef.current = conversationId;
setMessages([]);
setThinking(false);
setToolStatus(null);
setPipeline(null);
chatSocket.send('init', { conversationId });
}
if (!conversationId) {
initRef.current = false;
activeConvRef.current = null;
setMessages([]);
setThinking(false);
setToolStatus(null);
setPipeline(null);
}
}, [conversationId, connected]);
@@ -134,12 +157,23 @@ export function useChat(conversationId: string | null) {
chatSocket.stop();
}, []);
const createConversation = useCallback((title: string, accountId?: string) => {
chatSocket.send('create_conversation', { title, accountId });
const createConversation = useCallback((title: string, accountId?: string): Promise<string> => {
return new Promise((resolve) => {
createResolveRef.current = resolve;
chatSocket.send('create_conversation', { title, accountId });
// Timeout fallback
setTimeout(() => {
if (createResolveRef.current) {
createResolveRef.current = null;
resolve('');
}
}, 5000);
});
}, []);
const removeMessage = useCallback((msgId: string) => {
const removeMessage = useCallback(async (msgId: string) => {
setMessages((prev) => prev.filter((m) => m.id !== msgId));
try { await fetch(`/api/pipeline/messages/${msgId}`, { method: 'DELETE' }); } catch {}
}, []);
return { messages, connected, thinking, toolStatus, pipeline, send, stop, createConversation, removeMessage };

View File

@@ -0,0 +1,48 @@
import { useState, useCallback, useRef, createContext, useContext } from 'react';
export interface ToastItem {
id: number;
message: string;
type: 'success' | 'error' | 'info';
}
interface ToastContextValue {
toasts: ToastItem[];
toast: (message: string, type?: ToastItem['type']) => void;
dismiss: (id: number) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
let nextId = 0;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const timers = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const dismiss = useCallback((id: number) => {
const t = timers.current.get(id);
if (t) clearTimeout(t);
timers.current.delete(id);
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const toast = useCallback((message: string, type: ToastItem['type'] = 'info') => {
const id = nextId++;
setToasts((prev) => [...prev.slice(-4), { id, message, type }]);
const timer = setTimeout(() => dismiss(id), 3000);
timers.current.set(id, timer);
}, [dismiss]);
return (
<ToastContext.Provider value={{ toasts, toast, dismiss }}>
{children}
</ToastContext.Provider>
);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
}

View File

@@ -9,6 +9,26 @@
body {
@apply bg-white text-zinc-900;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #d4d4d8;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a1a1aa;
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: #d4d4d8 transparent;
}
}
/* GitHub-style Markdown - light theme */

View File

@@ -8,6 +8,7 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
...options,
});
if (!res.ok) throw new Error(await res.text());
if (res.status === 204) return undefined as T;
return res.json();
}
@@ -35,6 +36,8 @@ export const api = {
request<void>(`/pipeline/conversations/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
deleteConversation: (id: string) =>
request<void>(`/pipeline/conversations/${id}`, { method: 'DELETE' }),
deleteMessage: (id: string) =>
request<void>(`/pipeline/messages/${id}`, { method: 'DELETE' }),
// Assets
listAssets: (params?: { accountId?: string; type?: string }) => {

View File

@@ -1,5 +1,6 @@
import { Router } from 'express';
import fs from 'fs/promises';
import fss from 'fs';
import path from 'path';
import multer from 'multer';
@@ -124,6 +125,16 @@ accountsRouter.post('/:id/references/upload', upload.single('file'), async (req,
res.json({ ok: true, reference: refEntry });
});
// Serve reference image file
accountsRouter.get('/:id/references/:filename', (req, res) => {
const id = req.params.id as string;
const filename = req.params.filename as string;
const fullPath = path.join(ACCOUNTS_DIR, id, 'references', filename);
if (!fullPath.startsWith(ACCOUNTS_DIR)) return res.status(403).send('Forbidden');
if (!fss.existsSync(fullPath)) return res.status(404).send('Not found');
res.sendFile(fullPath);
});
// Reference image delete
accountsRouter.delete('/:id/references/:index', async (req, res) => {
const id = req.params.id as string;

View File

@@ -32,8 +32,15 @@ pipelineRouter.get('/conversations/:id/messages', (req, res) => {
});
pipelineRouter.delete('/conversations/:id', (req, res) => {
getDb().prepare('DELETE FROM messages WHERE conversation_id = ?').run(req.params.id);
getDb().prepare('DELETE FROM conversations WHERE id = ?').run(req.params.id);
res.status(204).send();
res.json({ ok: true });
});
// Delete a single message
pipelineRouter.delete('/messages/:id', (req, res) => {
getDb().prepare('DELETE FROM messages WHERE id = ?').run(req.params.id);
res.json({ ok: true });
});
// Update conversation (rename / toggle pin)

View File

@@ -381,6 +381,48 @@ async function streamOpenAI(
}
}
// Clean and validate message history before sending to API
function sanitizeHistory(messages: DbMessage[]): DbMessage[] {
// 1. Filter out error messages stored in DB
const cleaned = messages.filter((m) => {
if (m.role === 'assistant' && m.content.startsWith('抱歉,出错了:')) return false;
return true;
});
// 2. Validate sequence: tool messages must follow assistant with tool_calls
const result: DbMessage[] = [];
for (let i = 0; i < cleaned.length; i++) {
const msg = cleaned[i];
// Tool message: check if preceding assistant has tool_calls
if (msg.role === 'tool') {
let hasPrecedingToolCall = false;
for (let j = result.length - 1; j >= 0; j--) {
const prev = result[j];
if (prev.role === 'assistant') {
if (prev.tool_calls) {
try {
const parsed = JSON.parse(prev.tool_calls);
const blocks = Array.isArray(parsed) ? parsed : parsed.content_blocks;
if (blocks?.some((b: ContentBlock) => b.type === 'tool_use')) {
hasPrecedingToolCall = true;
}
} catch {}
}
break; // stop at nearest assistant
}
if (prev.role === 'tool') continue; // skip consecutive tool messages
break;
}
if (!hasPrecedingToolCall) continue; // skip orphan tool message
}
result.push(msg);
}
return result;
}
async function handleChatMessage(ws: WebSocket, convId: string, content: string) {
const userMsgId = randomUUID();
getDb().prepare(
@@ -398,9 +440,10 @@ async function handleChatMessage(ws: WebSocket, convId: string, content: string)
}
getDb().prepare('UPDATE conversations SET updated_at = datetime(\'now\') WHERE id = ?').run(convId);
const history = getDb().prepare(
const rawHistory = getDb().prepare(
'SELECT * FROM messages WHERE conversation_id = ? AND id != ? ORDER BY created_at'
).all(convId, userMsgId) as DbMessage[];
const history = sanitizeHistory(rawHistory);
ws.send(JSON.stringify({ type: 'status', data: { status: 'thinking' } }));
@@ -419,9 +462,7 @@ async function handleChatMessage(ws: WebSocket, convId: string, content: string)
const errMsg = (err as Error).message;
console.error('[chat] LLM error:', errMsg);
const errId = randomUUID();
getDb().prepare(
'INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, ?, ?)'
).run(errId, convId, 'assistant', `抱歉,出错了:${errMsg}`);
// Don't store error in DB to avoid polluting history
ws.send(JSON.stringify({
type: 'message',
data: { id: errId, role: 'assistant', content: `抱歉,出错了:${errMsg}` },