File size: 7,557 Bytes
e5faeaf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 | 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>
)
}
|