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

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