// ============================================================ // AI CHAT API — Client for /sse/* endpoints on luxsin-tool backend // - SSE streaming via fetch (POST body) — EventSource不支持POST // - 前端侧工具(get/set_device_settings、get/set/delete peq)本地执行后回传 // ============================================================ import { LuxsinAPI, PeqFilter, FILTER_TYPE_LABELS, buildPeqPresetBody, dbToVolume, volumeToDb, } from "./luxsinApi"; import { AI_NETWORK_ERROR_CODE, isLikelyNetworkFetchError } from "./networkError"; // Allow override via Vite env / localStorage; default to same origin in prod. export function getAIBaseUrl(): string { const fromLs = typeof window !== "undefined" ? window.localStorage.getItem("luxsin_ai_url") : null; if (fromLs) return fromLs.replace(/\/$/, ""); const fromEnv = (import.meta as any).env?.VITE_AI_API_URL as string | undefined; if (fromEnv) return fromEnv.replace(/\/$/, ""); return "https://v2ai.luxsin.com.cn"; } // ============================================================ // Types (mirror backend schemas) // ============================================================ export interface ChatRead { id: string; title: string; created_at: string; updated_at: string; } export interface OptimizePeqPayload { name?: string; brand?: string; model?: string; target?: string; form?: string; preamp?: number; canDel?: number; autoPre?: number; filters?: Array<{ type: number | string; fc?: number; frequency?: number; gain: number; q: number; }>; } export interface MessageRead { id: string; role: "user" | "assistant"; content: string; created_at: string; /** 0: 默认聊天, 1: 摘要, 2: 优化记录, 3: 标题生成 */ type: number; before_peq: OptimizePeqPayload | null; after_peq: OptimizePeqPayload | null; applied: boolean; applied_at: string | null; } export interface ToolUseBlock { type: "tool_use"; id: string; name: string; input: Record; } export interface TextBlock { type: "text"; text: string; } export type ContentBlock = TextBlock | ToolUseBlock; export type SSEEventType = "text" | "done" | "error" | "tool_use"; export interface SSEEvent { type: SSEEventType; content?: string | ToolUseBlock | null; } /** 与后端 `DeviceSetting` 对齐,字段来自 `deviceState`。允许其它透传字段。 */ export interface DeviceSettingPayload { mac: string; language: number; device: string; volume?: number; msgCount?: number; [key: string]: any; } /** 与后端 `DevicePEQ` 对齐,由 `api.getPeqState()` 拼装。 */ export interface DevicePEQPayload { peqSelect: number; peqEnable: number; peq: Array<{ name: string; brand?: string; model?: string; filters: Array<{ type: number | string; fc: number; gain: number; q: number }> | string; preamp?: number; canDel?: number; autoPre?: number; }>; msgCount: number; } export interface QuestionPayload { question: string; device_setting: DeviceSettingPayload; device_peq: DevicePEQPayload; chat_id?: string | null; } // ============================================================ // REST helpers // ============================================================ async function jsonFetch(url: string, init?: RequestInit): Promise { let res: Response; try { res = await fetch(url, init); } catch (error) { if (isLikelyNetworkFetchError(error)) throw new Error(AI_NETWORK_ERROR_CODE); throw error; } if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); return res.json(); } export async function listChats(mac: string): Promise { return jsonFetch(`${getAIBaseUrl()}/sse/chats?mac=${encodeURIComponent(mac)}`); } export async function listMessages(chatId: string): Promise { return jsonFetch(`${getAIBaseUrl()}/sse/messages?chat_id=${encodeURIComponent(chatId)}`); } export async function clearChat(chatId: string): Promise { const res = await fetch( `${getAIBaseUrl()}/sse/clear?chat_id=${encodeURIComponent(chatId)}`, { method: "POST" }, ); if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); } export async function updateMessageApplied(messageId: string, applied: boolean): Promise { const url = `${getAIBaseUrl()}/sse/update_message_applied?message_id=${encodeURIComponent( messageId, )}&applied=${applied ? "true" : "false"}`; const res = await fetch(url, { method: "POST" }); if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); } export async function sendToolResult(toolUseId: string, content: { ok: boolean; content?: any; message?: string }): Promise { await fetch(`${getAIBaseUrl()}/chat/tool_result`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tool_use_id: toolUseId, content }), }); } /** 达到或高于此 dB 视为拉满,需二次确认(0 dB 为设备最大音量)。 */ export const VOLUME_MAX_CONFIRM_DB = -1; /** 单次升高达到此幅度(dB)需二次确认。 */ export const VOLUME_JUMP_CONFIRM_DB = 10; export interface RiskyVolumeChange { fromDb: number | null; toDb: number; kind: "max" | "jump"; } export interface RiskyDeviceChange { volume?: RiskyVolumeChange; dacVolumeDirect?: { from: number; to: number }; } export interface ExecuteFrontendToolOptions { currentVolumeStored?: number; currentDacVolumeDirect?: number; /** 返回 true 才写入危险字段;未提供回调时对危险字段默认拒绝(失败关闭)。 */ confirmRiskyChange?: (risk: RiskyDeviceChange) => Promise; } function parseDbVolume(value: unknown): number | null { const db = Number(value); if (!Number.isFinite(db)) return null; return Math.max(-100, Math.min(0, db)); } /** 判断 AI 下发的 set_device_settings 是否会突然拉高音量或打开直通。 */ export function assessRiskyDeviceSettings( input: Record, current: { volumeStored?: number; dacVolumeDirect?: number } = {}, ): RiskyDeviceChange | null { const risk: RiskyDeviceChange = {}; if (input.volume !== undefined && input.volume !== null) { const toDb = parseDbVolume(input.volume); if (toDb !== null) { const fromDb = typeof current.volumeStored === "number" ? volumeToDb(current.volumeStored) : null; const delta = fromDb === null ? Number.POSITIVE_INFINITY : toDb - fromDb; const raising = fromDb === null || toDb > fromDb + 0.4; const isMax = raising && toDb >= VOLUME_MAX_CONFIRM_DB; const isJump = fromDb === null ? toDb >= -20 : delta >= VOLUME_JUMP_CONFIRM_DB; if (isMax || isJump) { risk.volume = { fromDb, toDb, kind: isMax ? "max" : "jump" }; } } } if (input.dacVolumeDirect !== undefined && input.dacVolumeDirect !== null) { const to = Number(input.dacVolumeDirect); const from = current.dacVolumeDirect ?? 0; if (Number.isFinite(to) && (to === 1 || to === 2) && from !== to) { risk.dacVolumeDirect = { from, to }; } } return risk.volume || risk.dacVolumeDirect ? risk : null; } // ============================================================ // SSE streaming: POST /sse/question → text/event-stream // ============================================================ export interface StreamCallbacks { onText?: (text: string) => void; onToolUse?: (block: ToolUseBlock) => void | Promise; onError?: (msg: string) => void; onDone?: () => void; } /** * 调用 SSE 问答接口并实时解析 data: 行。 * 返回 abort 控制器供外部主动中断。 */ export function streamQuestion(payload: QuestionPayload, cb: StreamCallbacks): AbortController { const controller = new AbortController(); (async () => { try { const res = await fetch(`${getAIBaseUrl()}/sse/question`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream", }, body: JSON.stringify(payload), signal: controller.signal, }); if (!res.ok || !res.body) { cb.onError?.(`HTTP ${res.status}`); return; } const reader = res.body.getReader(); const decoder = new TextDecoder("utf-8"); let buffer = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // SSE events are separated by blank line (\n\n) let sepIndex: number; // Accept both \n\n and \r\n\r\n while ((sepIndex = buffer.search(/\r?\n\r?\n/)) !== -1) { const rawEvent = buffer.slice(0, sepIndex); const match = /\r?\n\r?\n/.exec(buffer.slice(sepIndex)); const sepLen = match ? match[0].length : 2; buffer = buffer.slice(sepIndex + sepLen); const dataLines = rawEvent .split(/\r?\n/) .filter((l) => l.startsWith("data:")) .map((l) => l.slice(5).replace(/^\s/, "")); if (dataLines.length === 0) continue; const dataStr = dataLines.join("\n"); if (!dataStr) continue; try { const ev = JSON.parse(dataStr) as SSEEvent; await dispatchEvent(ev, cb); if (ev.type === "done" || ev.type === "error") { return; } } catch { // skip malformed event } } } cb.onDone?.(); } catch (e: any) { if (e?.name === "AbortError") return; cb.onError?.(isLikelyNetworkFetchError(e) ? AI_NETWORK_ERROR_CODE : (e?.message ?? String(e))); } })(); return controller; } async function dispatchEvent(ev: SSEEvent, cb: StreamCallbacks) { switch (ev.type) { case "text": if (typeof ev.content === "string") cb.onText?.(ev.content); break; case "tool_use": if (ev.content && typeof ev.content === "object") { await cb.onToolUse?.(ev.content as ToolUseBlock); } break; case "error": cb.onError?.(typeof ev.content === "string" ? ev.content : "Unknown error"); break; case "done": cb.onDone?.(); break; } } // ============================================================ // 前端侧工具执行:接到 tool_use 事件后在浏览器里执行,再回传结果 // ============================================================ export async function executeFrontendTool( block: ToolUseBlock, api: LuxsinAPI | null, options: ExecuteFrontendToolOptions = {}, ): Promise<{ ok: boolean; content?: any; message?: string }> { if (!api) return { ok: false, message: "Device is not connected." }; try { switch (block.name) { case "get_device_settings": { const state = await api.getDeviceState(); // 设备的 volume 是 0-200 的原始值,AI 侧以 dB (-100 ~ 0) 看更直观 const content: Record = { ...state }; if (typeof state.volume === "number") { content.volume = Number(volumeToDb(state.volume).toFixed(1)); content.volume_unit = "dB"; } return { ok: true, content }; } case "set_device_settings": { const rawParams = { ...(block.input ?? {}) } as Record; let liveVolume = options.currentVolumeStored; let liveDirect = options.currentDacVolumeDirect; try { const live = await api.getDeviceState(); if (typeof live.volume === "number") liveVolume = live.volume; if (typeof live.dacVolumeDirect === "number") liveDirect = live.dacVolumeDirect; } catch { // 用调用方传入的当前值兜底 } const risk = assessRiskyDeviceSettings(rawParams, { volumeStored: liveVolume, dacVolumeDirect: liveDirect, }); if (risk) { const allowed = options.confirmRiskyChange ? await options.confirmRiskyChange(risk) : false; if (!allowed) { if (risk.volume) delete rawParams.volume; if (risk.dacVolumeDirect) delete rawParams.dacVolumeDirect; if (Object.keys(rawParams).length === 0) { return { ok: false, message: "User declined the dangerous volume change for hearing safety. Keep the current listening level unchanged.", }; } } } // AI 传进来的 volume 是 dB,需换算回设备 0-200 的原始值 const params: Record = { ...rawParams }; if (params.volume !== undefined && params.volume !== null) { const db = Number(params.volume); if (Number.isFinite(db)) { const clampedDb = Math.max(-100, Math.min(0, db)); params.volume = dbToVolume(clampedDb); } } await api.setSetting(params); await api.refreshState(); const skippedVolume = Boolean(risk?.volume && rawParams.volume === undefined); const skippedDirect = Boolean( risk?.dacVolumeDirect && rawParams.dacVolumeDirect === undefined, ); const skipped = skippedVolume || skippedDirect; return { ok: true, content: { ok: true, updated: rawParams, ...(skipped ? { volume_unchanged: skippedVolume, dac_volume_direct_unchanged: skippedDirect, message: "User declined the dangerous volume change. Other requested settings were applied; current listening volume was left unchanged.", } : {}), }, message: skipped ? "User declined the dangerous volume change. Other requested settings were applied; current listening volume was left unchanged." : undefined, }; } case "get_peq_list": { const peqState = await api.getPeqState(); const names = (peqState.peq ?? []).map((p) => p.name); return { ok: true, content: names }; } case "get_current_peq": { const peqState = await api.getPeqState(); const idx = peqState.peqSelect ?? 0; const item = peqState.peq?.[idx]; if (!item) return { ok: false, message: "No current PEQ found." }; let filters: PeqFilter[] = []; if (Array.isArray(item.filters)) filters = item.filters as PeqFilter[]; else if (typeof item.filters === "string") { try { filters = JSON.parse(item.filters) as PeqFilter[]; } catch { filters = []; } } return { ok: true, content: { name: item.name, brand: item.brand ?? "", model: item.model ?? "", preamp: item.preamp ?? 0, canDel: item.canDel ?? 1, autoPre: item.autoPre ?? 0, filters: filters.map((f) => ({ type: FILTER_TYPE_LABELS[f.type] ?? String(f.type), fc: f.fc, gain: f.gain, q: f.q, })), }, }; } case "set_peq": { const p = block.input ?? {}; await api.upgradePeqChange({ peqChange: buildPeqPresetBody( { name: p.name, brand: p.brand, model: p.model, target: p.target, form: p.form, autoPre: p.autoPre, preamp: p.preamp, canDel: p.canDel, }, (p.filters ?? []) as PeqFilter[], ), }); await api.refreshState(); return { ok: true, content: { ok: true, name: p.name } }; } case "delete_peqs": { const names = (block.input?.names ?? []) as string[]; await api.removePeq(names); await api.refreshState(); return { ok: true, content: { ok: true, deleted: names } }; } default: return { ok: false, message: `Unsupported tool: ${block.name}` }; } } catch (e: any) { if (isLikelyNetworkFetchError(e)) { return { ok: false, message: "Device is not reachable. Ask the user to reconnect the device on the same network, then retry.", }; } return { ok: false, message: e?.message ?? String(e) }; } }