feat(web): 添加全局 Toast 通知系统和资产预览导航功能
- 新增 ToastProvider 和 useToast hook,支持全局成功/错误/信息提示 - 资产预览增加左右导航按钮、键盘快捷键(方向键)和计数器显示 - 资产库增加图片/视频类型筛选标签页及计数 - 对话列表增加最近对话展示、搜索优化和删除确认 - 消息增加删除确认对话框 - 优化聊天自动滚动行为,仅在用户未手动滚动时跟随新内容 - 新增删除消息 API 端点 - 优化消息历史清理逻辑,过滤错误消息和孤儿 tool 消息 - 添加自定义滚动条样式 - 优化账户参考图显示逻辑,支持本地文件显示 - 修复对话创建流程,直接导航到新创建的对话
This commit is contained in:
1
web/client/dist/assets/index-Bd3B3-IW.css
vendored
Normal file
1
web/client/dist/assets/index-Bd3B3-IW.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/client/dist/assets/index-CXGrBwuC.css
vendored
1
web/client/dist/assets/index-CXGrBwuC.css
vendored
File diff suppressed because one or more lines are too long
315
web/client/dist/assets/index-ChelO-mr.js
vendored
Normal file
315
web/client/dist/assets/index-ChelO-mr.js
vendored
Normal file
File diff suppressed because one or more lines are too long
274
web/client/dist/assets/index-V7y9417K.js
vendored
274
web/client/dist/assets/index-V7y9417K.js
vendored
File diff suppressed because one or more lines are too long
4
web/client/dist/index.html
vendored
4
web/client/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
57
web/client/src/components/ui/Toast.tsx
Normal file
57
web/client/src/components/ui/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
48
web/client/src/hooks/useToast.tsx
Normal file
48
web/client/src/hooks/useToast.tsx
Normal 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;
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}` },
|
||||
|
||||
Reference in New Issue
Block a user