diff --git a/frontend/react-shadcn/pc/src/pages/AIChatPage.tsx b/frontend/react-shadcn/pc/src/pages/AIChatPage.tsx new file mode 100644 index 0000000..3130a03 --- /dev/null +++ b/frontend/react-shadcn/pc/src/pages/AIChatPage.tsx @@ -0,0 +1,361 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { apiClient } from '@/services/api' +import type { AIModelInfo, AIConversation, AIChatMessage, AIQuotaInfo } from '@/types' + +// Simple message type for local state +interface ChatMsg { + id: number | string + role: 'user' | 'assistant' | 'system' + content: string + isStreaming?: boolean +} + +export function AIChatPage() { + // State + const [models, setModels] = useState([]) + const [conversations, setConversations] = useState([]) + const [currentConv, setCurrentConv] = useState(null) + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [selectedModel, setSelectedModel] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [quota, setQuota] = useState(null) + const [editingTitle, setEditingTitle] = useState(null) + const [editTitle, setEditTitle] = useState('') + const messagesEndRef = useRef(null) + const textareaRef = useRef(null) + + // Load initial data + useEffect(() => { + loadModels() + loadConversations() + loadQuota() + }, []) + + // Auto-scroll + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + const loadModels = async () => { + try { + const data = await apiClient.getAIModels() + setModels(data.list || []) + if (data.list?.length > 0 && !selectedModel) { + setSelectedModel(data.list[0].modelId) + } + } catch (e) { console.error('Failed to load models', e) } + } + + const loadConversations = async () => { + try { + const data = await apiClient.getAIConversations(1, 50) + setConversations(data.list || []) + } catch (e) { console.error('Failed to load conversations', e) } + } + + const loadQuota = async () => { + try { + const data = await apiClient.getAIQuota() + setQuota(data) + } catch (e) { console.error('Failed to load quota', e) } + } + + const selectConversation = async (conv: AIConversation) => { + setCurrentConv(conv) + try { + const data = await apiClient.getAIConversation(conv.id) + const msgs: ChatMsg[] = (data.messages || []).map((m: AIChatMessage) => ({ + id: m.id, + role: m.role, + content: m.content, + })) + setMessages(msgs) + if (conv.modelId) setSelectedModel(conv.modelId) + } catch (e) { console.error('Failed to load conversation', e) } + } + + const createConversation = async () => { + try { + const conv = await apiClient.createAIConversation(selectedModel) + setConversations(prev => [conv, ...prev]) + setCurrentConv(conv) + setMessages([]) + } catch (e) { console.error('Failed to create conversation', e) } + } + + const deleteConversation = async (id: number, e: React.MouseEvent) => { + e.stopPropagation() + try { + await apiClient.deleteAIConversation(id) + setConversations(prev => prev.filter(c => c.id !== id)) + if (currentConv?.id === id) { + setCurrentConv(null) + setMessages([]) + } + } catch (e) { console.error('Failed to delete conversation', e) } + } + + const renameConversation = async (id: number) => { + if (!editTitle.trim()) { setEditingTitle(null); return } + try { + const updated = await apiClient.updateAIConversation(id, editTitle.trim()) + setConversations(prev => prev.map(c => c.id === id ? { ...c, title: updated.title } : c)) + if (currentConv?.id === id) setCurrentConv(prev => prev ? { ...prev, title: updated.title } : null) + } catch (e) { console.error('Failed to rename', e) } + setEditingTitle(null) + } + + const sendMessage = useCallback(async () => { + if (!input.trim() || isLoading) return + const content = input.trim() + setInput('') + + // Auto-create conversation if none selected + let conv = currentConv + if (!conv) { + try { + conv = await apiClient.createAIConversation(selectedModel, content.slice(0, 30)) + setConversations(prev => [conv!, ...prev]) + setCurrentConv(conv) + } catch (e) { + console.error('Failed to create conversation', e) + return + } + } + + // Add user message + const userMsg: ChatMsg = { id: `user-${Date.now()}`, role: 'user', content } + const assistantMsg: ChatMsg = { id: `asst-${Date.now()}`, role: 'assistant', content: '', isStreaming: true } + setMessages(prev => [...prev, userMsg, assistantMsg]) + setIsLoading(true) + + try { + // Build message history for context + const history = messages + .filter(m => m.role === 'user' || m.role === 'assistant') + .map(m => ({ role: m.role, content: m.content })) + history.push({ role: 'user', content }) + + const stream = apiClient.chatStream({ + model: selectedModel, + messages: history, + stream: true, + conversation_id: conv!.id, + }) + + let accumulated = '' + for await (const chunk of stream) { + try { + const parsed = JSON.parse(chunk) + if (parsed.content) { + accumulated += parsed.content + setMessages(prev => prev.map(m => + m.id === assistantMsg.id ? { ...m, content: accumulated } : m + )) + } + } catch { /* skip malformed chunks */ } + } + + // Finalize streaming message + setMessages(prev => prev.map(m => + m.id === assistantMsg.id ? { ...m, isStreaming: false } : m + )) + + // Refresh quota after message + loadQuota() + loadConversations() + } catch (e: any) { + setMessages(prev => prev.map(m => + m.id === assistantMsg.id ? { ...m, content: `Error: ${e.message || 'Request failed'}`, isStreaming: false } : m + )) + } finally { + setIsLoading(false) + } + }, [input, isLoading, currentConv, selectedModel, messages]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + sendMessage() + } + } + + // Group models by provider + const modelsByProvider = models.reduce((acc, m) => { + const key = m.providerName || 'Other' + if (!acc[key]) acc[key] = [] + acc[key].push(m) + return acc + }, {} as Record) + + return ( +
+ {/* Left Sidebar - Conversations */} +
+ {/* New Chat Button */} +
+ +
+ + {/* Conversation List */} +
+ {conversations.length === 0 ? ( +
暂无对话
+ ) : ( + conversations.map(conv => ( +
selectConversation(conv)} + className={`group flex items-center px-3 py-2.5 cursor-pointer border-b border-border/50 hover:bg-accent/50 transition-colors ${currentConv?.id === conv.id ? 'bg-accent' : ''}`} + > + {editingTitle === conv.id ? ( + setEditTitle(e.target.value)} + onBlur={() => renameConversation(conv.id)} + onKeyDown={e => e.key === 'Enter' && renameConversation(conv.id)} + className="flex-1 bg-transparent border border-border rounded px-1 py-0.5 text-sm text-foreground outline-none" + autoFocus + onClick={e => e.stopPropagation()} + /> + ) : ( + <> +
+
{conv.title}
+
{conv.modelId}
+
+
+ + +
+ + )} +
+ )) + )} +
+ + {/* Quota Display */} +
+
余额
+
+ ¥{quota?.balance?.toFixed(4) || '0.0000'} +
+
+ 已消费: ¥{quota?.totalConsumed?.toFixed(4) || '0.0000'} +
+
+
+ + {/* Main Chat Area */} +
+ {/* Header */} +
+
+ {currentConv?.title || 'AI 对话'} +
+ +
+ + {/* Messages */} +
+ {messages.length === 0 ? ( +
+ +

开始新对话

+

选择模型并发送消息

+
+ ) : ( + messages.map(msg => ( +
+
+
+ {msg.content || (msg.isStreaming ? ( + + . + . + . + + ) : '')} +
+ {msg.isStreaming && msg.content && ( + + )} +
+
+ )) + )} +
+
+ + {/* Input Area */} +
+
+