import { SUCCESS_CODE, type ApiEnvelope } from '../types/api' import { storage } from '../lib/storage' export class ApiError extends Error { code: number constructor(code: number, message: string) { super(message) this.code = code } } type RequestOptions = { method?: string body?: unknown auth?: boolean memberAuth?: boolean providerToken?: string query?: Record } let refreshPromise: Promise | null = null async function refreshTokens() { const refreshToken = storage.getRefreshToken() if (!refreshToken) throw new ApiError(0, '缺少 refresh token') const res = await fetch('/api/v1/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken }), }) const json = (await res.json()) as ApiEnvelope<{ access_token: string refresh_token: string uid: string }> if (json.code !== SUCCESS_CODE || !json.data) { storage.clearSession() // 通知 AuthProvider 清空狀態並導回登入。 if (typeof window !== 'undefined') { window.dispatchEvent(new Event('haixun:session-expired')) } throw new ApiError(json.code, json.message || 'refresh failed') } storage.setAccessToken(json.data.access_token) storage.setRefreshToken(json.data.refresh_token) storage.setUid(json.data.uid) } async function parseEnvelope(res: Response): Promise> { const text = await res.text() if (!text.trim()) { if (!res.ok) { const hint = res.status === 404 ? '後端找不到此 API,請確認已執行 make gen-api 並重啟 API(make restart-all)' : res.status === 405 ? '後端尚未支援此操作,請重啟 API 服務(make run 或 scripts/restart-all.sh)' : res.status >= 500 ? '後端 API 無法連線,請確認服務已啟動(scripts/start-all.sh 或 make -C haixun-backend start-all)' : `HTTP ${res.status}` throw new ApiError(res.status, hint) } return { code: SUCCESS_CODE, message: 'success', data: null as T } } try { return JSON.parse(text) as ApiEnvelope } catch { throw new ApiError(res.status || 0, text.slice(0, 200) || 'invalid response') } } function buildURL(path: string, query?: RequestOptions['query']) { const url = new URL(path, window.location.origin) if (query) { for (const [k, v] of Object.entries(query)) { if (v !== undefined && v !== '') url.searchParams.set(k, String(v)) } } return url.pathname + url.search } async function request(path: string, opts: RequestOptions = {}): Promise { const buildHeaders = () => { const headers: Record = { 'Content-Type': 'application/json' } if (opts.auth) { const token = storage.getAccessToken() if (token) headers.Authorization = `Bearer ${token}` } if (opts.memberAuth) { const token = storage.getAccessToken() if (token) headers['X-Member-Authorization'] = `Bearer ${token}` } if (opts.providerToken) { headers.Authorization = `Bearer ${opts.providerToken}` } return headers } const doFetch = () => fetch(buildURL(path, opts.query), { method: opts.method ?? (opts.body ? 'POST' : 'GET'), headers: buildHeaders(), body: opts.body ? JSON.stringify(opts.body) : undefined, }) let res = await doFetch() let json = await parseEnvelope(res) if (json.code !== SUCCESS_CODE && res.status === 401 && opts.auth && storage.getRefreshToken()) { if (!refreshPromise) { refreshPromise = refreshTokens().finally(() => { refreshPromise = null }) } try { await refreshPromise res = await doFetch() json = await parseEnvelope(res) } catch { /* fall through */ } } if (json.code !== SUCCESS_CODE) { throw new ApiError(json.code, json.message || 'request failed') } return json.data as T } export const api = { get: (path: string, opts?: Omit) => request(path, { ...opts, method: 'GET' }), post: (path: string, body?: unknown, opts?: Omit) => request(path, { ...opts, method: 'POST', body }), put: (path: string, body?: unknown, opts?: Omit) => request(path, { ...opts, method: 'PUT', body }), patch: (path: string, body?: unknown, opts?: Omit) => request(path, { ...opts, method: 'PATCH', body }), delete: (path: string, opts?: Omit) => request(path, { ...opts, method: 'DELETE' }), } async function readStreamErrorMessage(res: Response): Promise { const text = await res.text() if (!text.trim()) { if (res.status === 404) { return '後端尚未載入島民 API,請重啟 API(make restart-all 或 scripts/restart-all.sh)' } return `HTTP ${res.status}` } try { const json = JSON.parse(text) as { message?: string; code?: number } if (json.message) return json.message } catch { return text.slice(0, 200) || `HTTP ${res.status}` } return `HTTP ${res.status}` } async function consumeAIEventStream( res: Response, onDelta: (text: string) => void, onDone: (finishReason?: string) => void, onError: (msg: string) => void, signal?: AbortSignal, ) { if (!res.ok || !res.body) { onError(await readStreamErrorMessage(res)) return } const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' // 確保 onDone / onError 只會被呼叫一次:避免串流提前關閉時 // 外層 Promise 永遠不 settle(聊天輸入永久鎖死)。 let settled = false const finishDone = (finishReason?: string) => { if (settled) return settled = true onDone(finishReason) } const finishError = (msg: string) => { if (settled) return settled = true onError(msg) } // 回傳 true 代表收到 done/error,串流應停止。 const handlePart = (part: string): boolean => { const lines = part.split('\n') let event = '' const dataLines: string[] = [] for (const line of lines) { if (line.startsWith('event:')) event = line.slice(6).trim() else if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, '')) } const data = dataLines.join('\n').trim() if (!data) return false try { const parsed = JSON.parse(data) as { type?: string text?: string finish_reason?: string message?: string } if (event === 'error' || parsed.type === 'error') { finishError(parsed.message || 'stream error') return true } if (parsed.type === 'delta' && parsed.text) onDelta(parsed.text) if (parsed.type === 'done') { finishDone(parsed.finish_reason) return true } } catch { /* ignore malformed chunk */ } return false } try { while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const parts = buffer.split('\n\n') buffer = parts.pop() ?? '' for (const part of parts) { if (handlePart(part)) return } } // 沖出殘餘 buffer(最後一段可能沒有結尾的空行)。 if (buffer.trim()) handlePart(buffer) } catch (err) { if (signal?.aborted) { finishDone() return } finishError(err instanceof Error ? err.message : '串流連線中斷') return } // reader 正常結束但未收到明確的 done 事件:仍要 settle,避免卡死。 finishDone() } export async function streamIslanderChat( body: { messages: { role: string; content: string }[]; context: string }, onDelta: (text: string) => void, onDone: (finishReason?: string) => void, onError: (msg: string) => void, signal?: AbortSignal, ) { const memberToken = storage.getAccessToken() const headers: Record = { 'Content-Type': 'application/json' } if (memberToken) headers.Authorization = `Bearer ${memberToken}` try { const res = await fetch('/api/v1/ai/islander/chat/stream', { method: 'POST', headers, body: JSON.stringify(body), signal, }) await consumeAIEventStream(res, onDelta, onDone, onError, signal) } catch (err) { if (signal?.aborted) { onDone() return } onError(err instanceof Error ? err.message : '無法連線島民 API') } }