// ============================================================ // 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, dbToVolume, volumeToDb, } from "./luxsinApi"; // 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 "http://localhost:8000"; } // ============================================================ // 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; 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 { const res = await fetch(url, init); 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 { await fetch(`${getAIBaseUrl()}/sse/clear?chat_id=${encodeURIComponent(chatId)}`, { method: "POST", }); } 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 }), }); } // ============================================================ // SSE streaming: POST /sse/question → text/event-stream // ============================================================ export interface StreamCallbacks { onText?: (text: string) => void; onToolUse?: (block: ToolUseBlock) => void; 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; 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?.(e?.message ?? String(e)); } })(); return controller; } 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") { 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, ): 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; // 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); // 回传给 AI 的仍以人类可读的 dB 展示 return { ok: true, content: { ok: true, updated: rawParams } }; } 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: { name: p.name, filters: p.filters ?? [], autoPre: p.autoPre, preamp: p.preamp, canDel: p.canDel, }, }); return { ok: true, content: { ok: true, name: p.name } }; } case "delete_peqs": { const names = (block.input?.names ?? []) as string[]; await api.removePeq(names); return { ok: true, content: { ok: true, deleted: names } }; } default: return { ok: false, message: `Unsupported tool: ${block.name}` }; } } catch (e: any) { return { ok: false, message: e?.message ?? String(e) }; } }