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() 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, ) { if (!res.ok || !res.body) { onError(await readStreamErrorMessage(res)) return } const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' 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) { const lines = part.split('\n') let event = '' let data = '' for (const line of lines) { if (line.startsWith('event:')) event = line.slice(6).trim() if (line.startsWith('data:')) data = line.slice(5).trim() } if (!data) continue try { const parsed = JSON.parse(data) as { type?: string text?: string finish_reason?: string message?: string } if (event === 'error' || parsed.type === 'error') { onError(parsed.message || 'stream error') return } if (parsed.type === 'delta' && parsed.text) onDelta(parsed.text) if (parsed.type === 'done') onDone(parsed.finish_reason) } catch { /* ignore malformed chunk */ } } } } export async function streamIslanderChat( body: { messages: { role: string; content: string }[]; context: string }, onDelta: (text: string) => void, onDone: (finishReason?: string) => void, onError: (msg: string) => void, ) { const memberToken = storage.getAccessToken() const headers: Record = { 'Content-Type': 'application/json' } if (memberToken) headers.Authorization = `Bearer ${memberToken}` const res = await fetch('/api/v1/ai/islander/chat/stream', { method: 'POST', headers, body: JSON.stringify(body), }) await consumeAIEventStream(res, onDelta, onDone, onError) }