Browse Source

feat: add AI Chat page with SSE streaming support

- Left sidebar: conversation list, new chat, balance display
- Center: message list with real-time streaming
- Bottom: textarea input with model selector
- Auto-scroll, conversation CRUD, rename/delete
master
dark 1 month ago
parent
commit
208992d80a
  1. 361
      frontend/react-shadcn/pc/src/pages/AIChatPage.tsx

361
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<AIModelInfo[]>([])
const [conversations, setConversations] = useState<AIConversation[]>([])
const [currentConv, setCurrentConv] = useState<AIConversation | null>(null)
const [messages, setMessages] = useState<ChatMsg[]>([])
const [input, setInput] = useState('')
const [selectedModel, setSelectedModel] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [quota, setQuota] = useState<AIQuotaInfo | null>(null)
const [editingTitle, setEditingTitle] = useState<number | null>(null)
const [editTitle, setEditTitle] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(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<string, AIModelInfo[]>)
return (
<div className="flex h-[calc(100vh-4rem)] overflow-hidden">
{/* Left Sidebar - Conversations */}
<div className="w-72 border-r border-border bg-card flex flex-col">
{/* New Chat Button */}
<div className="p-3 border-b border-border">
<button
onClick={createConversation}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity font-medium"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
</button>
</div>
{/* Conversation List */}
<div className="flex-1 overflow-y-auto">
{conversations.length === 0 ? (
<div className="p-4 text-center text-muted-foreground text-sm"></div>
) : (
conversations.map(conv => (
<div
key={conv.id}
onClick={() => 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 ? (
<input
value={editTitle}
onChange={e => 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()}
/>
) : (
<>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate">{conv.title}</div>
<div className="text-xs text-muted-foreground">{conv.modelId}</div>
</div>
<div className="hidden group-hover:flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); setEditingTitle(conv.id); setEditTitle(conv.title) }}
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground"
title="重命名"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
</button>
<button
onClick={(e) => deleteConversation(conv.id, e)}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="删除"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
</>
)}
</div>
))
)}
</div>
{/* Quota Display */}
<div className="p-3 border-t border-border bg-muted/30">
<div className="text-xs text-muted-foreground"></div>
<div className="text-lg font-semibold text-foreground">
¥{quota?.balance?.toFixed(4) || '0.0000'}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
: ¥{quota?.totalConsumed?.toFixed(4) || '0.0000'}
</div>
</div>
</div>
{/* Main Chat Area */}
<div className="flex-1 flex flex-col bg-background">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-card">
<div className="text-sm font-medium text-foreground">
{currentConv?.title || 'AI 对话'}
</div>
<select
value={selectedModel}
onChange={e => setSelectedModel(e.target.value)}
className="text-sm bg-muted border border-border rounded-md px-2 py-1 text-foreground outline-none"
>
{Object.entries(modelsByProvider).map(([provider, provModels]) => (
<optgroup key={provider} label={provider}>
{provModels.map(m => (
<option key={m.modelId} value={m.modelId}>{m.displayName}</option>
))}
</optgroup>
))}
</select>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<svg className="w-16 h-16 mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg>
<p className="text-lg"></p>
<p className="text-sm mt-1"></p>
</div>
) : (
messages.map(msg => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[70%] rounded-2xl px-4 py-2.5 ${
msg.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground'
}`}>
<div className="whitespace-pre-wrap break-words text-sm leading-relaxed">
{msg.content || (msg.isStreaming ? (
<span className="inline-flex gap-1">
<span className="animate-bounce" style={{animationDelay:'0ms'}}>.</span>
<span className="animate-bounce" style={{animationDelay:'150ms'}}>.</span>
<span className="animate-bounce" style={{animationDelay:'300ms'}}>.</span>
</span>
) : '')}
</div>
{msg.isStreaming && msg.content && (
<span className="inline-block w-1.5 h-4 ml-0.5 bg-current animate-pulse" />
)}
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="px-4 py-3 border-t border-border bg-card">
<div className="flex items-end gap-2 max-w-4xl mx-auto">
<textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息... (Enter 发送, Shift+Enter 换行)"
disabled={isLoading}
rows={1}
className="flex-1 resize-none rounded-xl border border-border bg-background px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground outline-none focus:ring-2 focus:ring-ring disabled:opacity-50 max-h-32"
style={{ minHeight: '2.5rem' }}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement
target.style.height = 'auto'
target.style.height = Math.min(target.scrollHeight, 128) + 'px'
}}
/>
<button
onClick={sendMessage}
disabled={isLoading || !input.trim()}
className="flex-shrink-0 p-2.5 bg-primary text-primary-foreground rounded-xl hover:opacity-90 disabled:opacity-50 transition-opacity"
>
{isLoading ? (
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" /></svg>
)}
</button>
</div>
</div>
</div>
</div>
)
}
Loading…
Cancel
Save