feat(web): 添加全局 Toast 通知系统和资产预览导航功能
- 新增 ToastProvider 和 useToast hook,支持全局成功/错误/信息提示 - 资产预览增加左右导航按钮、键盘快捷键(方向键)和计数器显示 - 资产库增加图片/视频类型筛选标签页及计数 - 对话列表增加最近对话展示、搜索优化和删除确认 - 消息增加删除确认对话框 - 优化聊天自动滚动行为,仅在用户未手动滚动时跟随新内容 - 新增删除消息 API 端点 - 优化消息历史清理逻辑,过滤错误消息和孤儿 tool 消息 - 添加自定义滚动条样式 - 优化账户参考图显示逻辑,支持本地文件显示 - 修复对话创建流程,直接导航到新创建的对话
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user