Browse Source
- 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/deletemaster
1 changed files with 361 additions and 0 deletions
@ -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…
Reference in new issue