thread-master/frontend/src/api/client.ts

277 lines
8.5 KiB
TypeScript
Raw Normal View History

2026-06-26 08:37:04 +00:00
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<string, string | number | boolean | undefined>
}
let refreshPromise: Promise<void> | 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()
2026-06-26 16:02:06 +00:00
// 通知 AuthProvider 清空狀態並導回登入。
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event('haixun:session-expired'))
}
2026-06-26 08:37:04 +00:00
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<T>(res: Response): Promise<ApiEnvelope<T>> {
const text = await res.text()
if (!text.trim()) {
if (!res.ok) {
const hint =
res.status === 404
? '後端找不到此 API請確認已執行 make gen-api 並重啟 APImake 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<T>
} 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<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const buildHeaders = () => {
const headers: Record<string, string> = { '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<T>(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<T>(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: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
request<T>(path, { ...opts, method: 'GET' }),
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
request<T>(path, { ...opts, method: 'POST', body }),
put: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
request<T>(path, { ...opts, method: 'PUT', body }),
patch: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
request<T>(path, { ...opts, method: 'PATCH', body }),
delete: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
request<T>(path, { ...opts, method: 'DELETE' }),
}
async function readStreamErrorMessage(res: Response): Promise<string> {
const text = await res.text()
if (!text.trim()) {
if (res.status === 404) {
return '後端尚未載入島民 API請重啟 APImake 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,
2026-06-26 16:02:06 +00:00
signal?: AbortSignal,
2026-06-26 08:37:04 +00:00
) {
if (!res.ok || !res.body) {
onError(await readStreamErrorMessage(res))
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
2026-06-26 16:02:06 +00:00
// 確保 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
2026-06-26 08:37:04 +00:00
}
2026-06-26 16:02:06 +00:00
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
2026-06-26 08:37:04 +00:00
}
}
2026-06-26 16:02:06 +00:00
// 沖出殘餘 buffer最後一段可能沒有結尾的空行
if (buffer.trim()) handlePart(buffer)
} catch (err) {
if (signal?.aborted) {
finishDone()
return
}
finishError(err instanceof Error ? err.message : '串流連線中斷')
return
2026-06-26 08:37:04 +00:00
}
2026-06-26 16:02:06 +00:00
// reader 正常結束但未收到明確的 done 事件:仍要 settle避免卡死。
finishDone()
2026-06-26 08:37:04 +00:00
}
export async function streamIslanderChat(
body: { messages: { role: string; content: string }[]; context: string },
onDelta: (text: string) => void,
onDone: (finishReason?: string) => void,
onError: (msg: string) => void,
2026-06-26 16:02:06 +00:00
signal?: AbortSignal,
2026-06-26 08:37:04 +00:00
) {
const memberToken = storage.getAccessToken()
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (memberToken) headers.Authorization = `Bearer ${memberToken}`
2026-06-26 16:02:06 +00:00
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')
}
2026-06-26 08:37:04 +00:00
}