- 新增 frontend-v2/ 为生产前端真相源(此前仅容器 dist,未入 git) - vite.config.ts: minify:false 根治压缩器压坏 react-router 6.28 (o.slice) - useApi.ts: asArray select 防御非裸数组返回值 - LandingPage/AppServices: Array.isArray coerce 双重防御 (apps.slice) - 关联: INCIDENT-2026-08-23-blank-page.md / SOP-FRONTEND-ITERATIVE-DEVELOPMENT.md - 公网验证通过 2026-08-23 09:31 (DOM 24493B, 无 slice 错误)
271 lines
11 KiB
TypeScript
271 lines
11 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
|
import { motion } from 'framer-motion';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import {
|
|
Send,
|
|
Sparkles,
|
|
Plus,
|
|
Search,
|
|
Settings,
|
|
MessageSquare,
|
|
Copy,
|
|
ThumbsUp,
|
|
ThumbsDown,
|
|
RotateCcw,
|
|
Trash2,
|
|
Loader2,
|
|
} from 'lucide-react';
|
|
import {
|
|
useConversations,
|
|
useMessages,
|
|
useCreateConversation,
|
|
useSendMessage,
|
|
useDeleteConversation,
|
|
} from '@/hooks/useApi';
|
|
import type { Conversation, ChatMessage } from '@/api/client';
|
|
|
|
export function ChatPage() {
|
|
const [selectedConv, setSelectedConv] = useState<string | null>(null);
|
|
const [input, setInput] = useState('');
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const [streamingContent, setStreamingContent] = useState('');
|
|
|
|
// API hooks
|
|
const { data: conversations = [], isLoading: convsLoading } = useConversations();
|
|
const { data: messages = [], isLoading: msgsLoading } = useMessages(selectedConv);
|
|
const createConv = useCreateConversation();
|
|
const sendMessage = useSendMessage(selectedConv || '');
|
|
const deleteConv = useDeleteConversation();
|
|
|
|
const scrollToBottom = () => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
};
|
|
|
|
useEffect(() => {
|
|
scrollToBottom();
|
|
}, [messages, streamingContent]);
|
|
|
|
const handleNewChat = async () => {
|
|
try {
|
|
const conv = await createConv.mutateAsync({ title: '新对话' });
|
|
setSelectedConv(conv.id);
|
|
} catch {}
|
|
};
|
|
|
|
const handleSend = async () => {
|
|
if (!input.trim() || !selectedConv) return;
|
|
const content = input;
|
|
setInput('');
|
|
setStreamingContent('');
|
|
|
|
try {
|
|
// Try streaming first
|
|
const stream = chatApi.streamMessage(selectedConv, { content });
|
|
let accumulated = '';
|
|
for await (const chunk of stream) {
|
|
accumulated += chunk;
|
|
setStreamingContent(accumulated);
|
|
}
|
|
setStreamingContent('');
|
|
// Refresh messages
|
|
} catch {
|
|
// Fallback to regular send
|
|
await sendMessage.mutateAsync({ content });
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
try {
|
|
await deleteConv.mutateAsync(id);
|
|
if (selectedConv === id) setSelectedConv(null);
|
|
} catch {}
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-[calc(100vh-4rem)] gap-4">
|
|
{/* Conversation List */}
|
|
<div className="w-72 flex-shrink-0 flex flex-col border border-border rounded-xl bg-white dark:bg-gray-950 overflow-hidden">
|
|
<div className="p-3 border-b border-border">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<input
|
|
type="text"
|
|
placeholder="搜索对话..."
|
|
className="h-9 w-full rounded-lg bg-muted pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
className="mx-3 mt-3 gap-2"
|
|
variant="outline"
|
|
onClick={handleNewChat}
|
|
disabled={createConv.isPending}
|
|
>
|
|
<Plus size={16} /> 新建对话
|
|
</Button>
|
|
|
|
<ScrollArea className="flex-1 px-2 py-2">
|
|
<div className="space-y-1">
|
|
{convsLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 size={20} className="animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : (
|
|
conversations.map((conv: Conversation) => (
|
|
<div key={conv.id} className="group relative">
|
|
<button
|
|
onClick={() => setSelectedConv(conv.id)}
|
|
className={`w-full text-left px-3 py-2.5 rounded-lg transition-colors ${
|
|
selectedConv === conv.id
|
|
? 'bg-primary/10 text-primary'
|
|
: 'hover:bg-muted text-muted-foreground'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<MessageSquare size={14} className="shrink-0" />
|
|
<span className="text-sm truncate flex-1">{conv.title}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-1 ml-5">
|
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
|
{conv.model || 'Auto'}
|
|
</Badge>
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{new Date(conv.updated_at).toLocaleDateString('zh-CN')}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleDelete(conv.id); }}
|
|
className="absolute right-2 top-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-destructive/10 transition-opacity"
|
|
>
|
|
<Trash2 size={12} className="text-destructive" />
|
|
</button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
|
|
{/* Chat Area */}
|
|
<div className="flex-1 flex flex-col border border-border rounded-xl bg-white dark:bg-gray-950 overflow-hidden">
|
|
{/* Messages */}
|
|
<ScrollArea className="flex-1 p-6">
|
|
{!selectedConv ? (
|
|
<div className="h-full flex flex-col items-center justify-center text-center">
|
|
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
|
|
<Sparkles className="h-8 w-8 text-primary" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-foreground mb-2">开始与 AI 对话吧</h3>
|
|
<p className="text-sm text-muted-foreground max-w-md">
|
|
支持 Markdown 渲染和代码高亮,选择合适的模型开始聊天
|
|
</p>
|
|
<Button className="mt-4 gap-2" onClick={handleNewChat}>
|
|
<Plus size={16} /> 新建对话
|
|
</Button>
|
|
</div>
|
|
) : msgsLoading ? (
|
|
<div className="h-full flex items-center justify-center">
|
|
<Loader2 size={24} className="animate-spin text-primary" />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6">
|
|
{messages.map((msg: ChatMessage) => (
|
|
<motion.div
|
|
key={msg.id}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
|
>
|
|
<div className={`max-w-[80%] rounded-xl px-4 py-3 ${
|
|
msg.role === 'user'
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'bg-muted text-foreground'
|
|
}`}>
|
|
<div className="text-sm whitespace-pre-wrap">{msg.content}</div>
|
|
{msg.role === 'assistant' && (
|
|
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-border/50">
|
|
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
|
<Copy size={14} className="text-muted-foreground" />
|
|
</button>
|
|
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
|
<ThumbsUp size={14} className="text-muted-foreground" />
|
|
</button>
|
|
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
|
<ThumbsDown size={14} className="text-muted-foreground" />
|
|
</button>
|
|
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
|
<RotateCcw size={14} className="text-muted-foreground" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
|
|
{/* Streaming indicator */}
|
|
{streamingContent && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="flex justify-start"
|
|
>
|
|
<div className="max-w-[80%] rounded-xl px-4 py-3 bg-muted text-foreground">
|
|
<div className="text-sm whitespace-pre-wrap">{streamingContent}</div>
|
|
<div className="flex items-center gap-1 mt-2">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
|
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse [animation-delay:0.2s]" />
|
|
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse [animation-delay:0.4s]" />
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
)}
|
|
</ScrollArea>
|
|
|
|
{/* Input Area */}
|
|
<div className="p-4 border-t border-border">
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
|
|
placeholder={selectedConv ? '输入消息... (Shift+Enter 换行)' : '请先选择或创建对话'}
|
|
className="flex-1"
|
|
disabled={!selectedConv || sendMessage.isPending}
|
|
/>
|
|
<Button
|
|
onClick={handleSend}
|
|
disabled={!input.trim() || !selectedConv || sendMessage.isPending}
|
|
className="gap-2"
|
|
>
|
|
{sendMessage.isPending ? (
|
|
<Loader2 size={16} className="animate-spin" />
|
|
) : (
|
|
<Send size={16} />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
<div className="flex items-center justify-between mt-2">
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Sparkles size={12} /> Auto(自动匹配)
|
|
</div>
|
|
<button className="p-1 hover:bg-muted rounded transition-colors">
|
|
<Settings size={16} className="text-muted-foreground" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Import for streaming
|
|
import { chatApi } from '@/api/client';
|