agent / src /components /ChatInterface.jsx
shutajb's picture
Upload 22 files
e5faeaf verified
Raw History Blame Contribute Delete
7.56 kB
import { useState } from 'react'
import { Send, Trash2, Loader2 } from 'lucide-react'
const DEFAULT_MODEL = 'nvidia/nemotron-3-ultra-550b-a55b:free'
const TIMEOUT_MS = 180000
/** 解析一筆 SSE 行:回傳 { delta } / { error } / { done } / null */
function parseSseLine(line) {
const payload = line.trim()
if (!payload.startsWith('data:')) return null
const raw = payload.slice(5).trim()
if (raw === '[DONE]') return { done: true }
let data
try { data = JSON.parse(raw) } catch { return null }
if (data.error) return { error: data.error.message || 'API 回傳錯誤' }
return { delta: data.choices?.[0]?.delta?.content }
}
/** 把內容接到最後一則 assistant 訊息(還沒有就新增一則) */
function withLastAssistant(prev, content, append) {
const last = prev[prev.length - 1]
if (last && last.role === 'assistant') {
return [...prev.slice(0, -1), { role: 'assistant', content: append ? last.content + content : content }]
}
return [...prev, { role: 'assistant', content }]
}
async function readErrorMessage(res) {
try {
const data = await res.json()
return data?.error?.message || data?.error?.data?.message || data?.message || '無錯誤訊息'
} catch {
try { return (await res.text()).slice(0, 300) || '無錯誤訊息' }
catch { return '無錯誤訊息' }
}
}
export default function ChatInterface({ apiConfig, onChange }) {
const [messages, setMessages] = useState([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const setField = (field, value) => onChange({ ...apiConfig, [field]: value })
const clearChat = () => {
if (!loading) setMessages([])
}
const sendMessage = async () => {
const text = input.trim()
if (!text || loading) return
if (!apiConfig.apiKey?.trim()) return alert('請輸入 API Key')
// 送完整對話歷史,不只最新一則
const next = [...messages, { role: 'user', content: text }, { role: 'assistant', content: '' }]
setMessages(next)
setInput('')
setLoading(true)
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
try {
const base = String(apiConfig.supplier).replace(/\/+$/, '')
const res = await fetch(`${base}/chat/completions`, {
method: 'POST',
signal: controller.signal,
headers: {
Authorization: `Bearer ${apiConfig.apiKey.trim()}`,
'Content-Type': 'application/json',
'HTTP-Referer': window.location.origin,
'X-Title': 'Agent Studio',
},
body: JSON.stringify({
model: apiConfig.model || DEFAULT_MODEL,
messages: next.filter((m) => m.content !== ''),
stream: true,
}),
})
if (!res.ok) {
const detail = await readErrorMessage(res)
setMessages((prev) => withLastAssistant(prev, `⚠️ API 錯誤 ${res.status}:${detail}`, false))
return
}
if (!res.body) {
setMessages((prev) => withLastAssistant(prev, '⚠️ 回應不含可讀取的串流', false))
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let gotAny = false
for (;;) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() // 最後一行可能還沒送完,留到下一次
for (const line of lines) {
const event = parseSseLine(line)
if (!event) continue
if (event.done) continue
if (event.error) throw new Error(event.error)
if (event.delta) {
gotAny = true
setMessages((prev) => withLastAssistant(prev, event.delta, true))
}
}
}
if (!gotAny) {
setMessages((prev) => withLastAssistant(prev, '⚠️ 模型沒有回傳任何內容', false))
}
} catch (e) {
const message =
e.name === 'AbortError' ? `請求逾時(超過 ${TIMEOUT_MS / 1000} 秒)` : e.message
setMessages((prev) => withLastAssistant(prev, `⚠️ ${message}`, false))
} finally {
clearTimeout(timer)
setLoading(false)
}
}
const keyState = apiConfig.apiKey?.trim()
? <span className="text-emerald-400 text-xs">Key 已設定</span>
: <span className="text-red-400 text-xs">尚未設定 Key</span>
return (
<div className="flex flex-col h-[80vh] border border-edge rounded-3xl overflow-hidden bg-panel">
<div className="p-4 border-b border-edge flex items-center gap-3 flex-wrap bg-app">
<input
type="text"
value={apiConfig.supplier}
onChange={(e) => setField('supplier', e.target.value)}
placeholder="API URL"
className="bg-field px-4 py-2 rounded-xl w-64 focus:outline-none"
/>
<input
type="password"
value={apiConfig.apiKey}
onChange={(e) => setField('apiKey', e.target.value)}
placeholder="API Key"
className="bg-field px-4 py-2 rounded-xl w-52 focus:outline-none"
/>
<input
type="text"
value={apiConfig.model}
onChange={(e) => setField('model', e.target.value)}
placeholder="Model"
className="bg-field px-4 py-2 rounded-xl w-60 focus:outline-none"
/>
{keyState}
<div className="flex-1" />
<button
onClick={clearChat}
disabled={loading || messages.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-field rounded-xl disabled:opacity-40"
title="清空對話"
>
<Trash2 className="w-4 h-4" /> 清空
</button>
<button
onClick={sendMessage}
disabled={loading || !input.trim()}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 px-6 py-2 rounded-2xl disabled:opacity-50"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
{loading ? '思考中...' : '發送'}
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 && (
<div className="h-full flex items-center justify-center text-muted text-sm">
輸入訊息開始對話。API 設定與 Agent 頁共用。
</div>
)}
{messages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[75%] px-5 py-3 rounded-3xl whitespace-pre-wrap break-words ${
msg.role === 'user' ? 'bg-blue-600' : msg.content.startsWith('⚠️') ? 'bg-red-900/60 text-red-200' : 'bg-field'
}`}
>
{msg.content || (msg.role === 'assistant' && loading ? '…' : '')}
</div>
</div>
))}
</div>
<div className="p-4 border-t border-edge">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}}
placeholder="輸入訊息..."
className="w-full bg-field px-6 py-3 rounded-3xl focus:outline-none"
/>
</div>
</div>
)
}