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>
  )
}