/* Gradio API 桥: POST /gradio_api/call/ -> event_id -> SSE complete */ export const LS = { get: (k: string, d: string | null = null) => localStorage.getItem("celestial_" + k) ?? d, set: (k: string, v: string) => localStorage.setItem("celestial_" + k, v), del: (k: string) => localStorage.removeItem("celestial_" + k), } /* 设备标识: 档案与创建它的设备/浏览器绑定 */ function deviceId(): string { let d = LS.get("device") if (!d) { d = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}` LS.set("device", d) } return d } export const DEVICE = deviceId() export async function api(name: string, args: unknown[] = []): Promise { const post = await fetch(`/gradio_api/call/${name}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ data: args }), }) if (!post.ok) throw new Error(`后端未应答 (${post.status})`) const { event_id } = await post.json() const res = await fetch(`/gradio_api/call/${name}/${event_id}`) const text = await res.text() let event = "" let data: unknown[] | null = null for (const raw of text.split("\n")) { if (raw.startsWith("event:")) event = raw.slice(6).trim() else if (raw.startsWith("data:")) { const payload = raw.slice(5).trim() if (event === "complete" && payload) data = JSON.parse(payload) if (event === "error") throw new Error(payload && payload !== "null" ? payload : "天问服务出错") } } if (data === null) throw new Error("天问服务未应答,请确认 app.py 已运行") return data[0] as T } /* Windows 后端返回的 FileData url 含反斜杠 */ export const fileUrl = (fd: { url?: string }) => String(fd.url || "").replace(/\\/g, "/") /* 流式解读 (FastAPI 原生 SSE 路由 /stream/*): 每个事件 data:{"text": 累积全文}, onText 每次收到当前完整文本; 命中安全护栏时后端直接推送替换后的安全文案。 */ export async function apiStream(path: string, body: object, onText: (text: string) => void): Promise { // 90s 断流超时: 防止 LLM 卡死时无限等待 const ctrl = new AbortController() const timer = setTimeout(() => ctrl.abort(), 90000) try { const res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal: ctrl.signal, }) if (!res.ok || !res.body) throw new Error(`流式服务未应答 (${res.status})`) const reader = res.body.getReader() const decoder = new TextDecoder() let buf = "" for (;;) { const { done, value } = await reader.read() if (done) break buf += decoder.decode(value, { stream: true }) const events = buf.split("\n\n") buf = events.pop() ?? "" for (const evt of events) { const dataLine = evt.split("\n").find((l) => l.startsWith("data:")) if (!dataLine) continue try { const o = JSON.parse(dataLine.slice(5).trim()) if (typeof o.text === "string") onText(o.text) } catch { /* 忽略不完整片段 */ } } } } finally { clearTimeout(timer) } }