224 lines
7.0 KiB
TypeScript
224 lines
7.0 KiB
TypeScript
|
|
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()
|
|||
|
|
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 並重啟 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<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,請重啟 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<string, string> = { '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)
|
|||
|
|
}
|
|||
|
|
|