Download src/components/ChatInterface.jsx from shutajb/agent: direct link, hf CLI and curl.
- Browser
- Download file 7.56 kB
-
https://huggingface.co/spaces/shutajb/agent/resolve/main/src/components/ChatInterface.jsx
- Command line
-
hf download hf://spaces/shutajb/agent/src/components/ChatInterface.jsx
-
curl -L -o ChatInterface.jsx https://huggingface.co/spaces/shutajb/agent/resolve/main/src/components/ChatInterface.jsx
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> | |
| ) | |
| } | |