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

277 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
// 通知 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<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,
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<string, string> = { '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')
}
}