053c0e3cd8
Co-authored-by: Cursor <cursoragent@cursor.com>
509 lines
17 KiB
TypeScript
509 lines
17 KiB
TypeScript
// ============================================================
|
|
// 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";
|
|
|
|
const AI_HOST = "v2ai.luxsin.com.cn";
|
|
|
|
/** Match the page protocol so http://device-ip does not call https://v2ai (and vice versa). */
|
|
function currentPageProtocol(): "http:" | "https:" {
|
|
if (typeof window !== "undefined" && window.location.protocol === "http:") return "http:";
|
|
return "https:";
|
|
}
|
|
|
|
function withPageProtocol(url: string): string {
|
|
const trimmed = url.replace(/\/$/, "");
|
|
const protocol = currentPageProtocol();
|
|
if (/^https?:\/\//i.test(trimmed)) {
|
|
return trimmed.replace(/^https?:\/\//i, `${protocol}//`);
|
|
}
|
|
if (trimmed.startsWith("//")) return `${protocol}${trimmed}`;
|
|
return trimmed;
|
|
}
|
|
|
|
// Allow override via Vite env / localStorage; protocol follows the current page.
|
|
export function getAIBaseUrl(): string {
|
|
const fromLs = typeof window !== "undefined" ? window.localStorage.getItem("luxsin_ai_url") : null;
|
|
if (fromLs) return withPageProtocol(fromLs);
|
|
const fromEnv = (import.meta as any).env?.VITE_AI_API_URL as string | undefined;
|
|
if (fromEnv) return withPageProtocol(fromEnv);
|
|
return `${currentPageProtocol()}//${AI_HOST}`;
|
|
}
|
|
|
|
// ============================================================
|
|
// 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<string, any>;
|
|
}
|
|
|
|
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<T>(url: string, init?: RequestInit): Promise<T> {
|
|
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<ChatRead[]> {
|
|
return jsonFetch(`${getAIBaseUrl()}/sse/chats?mac=${encodeURIComponent(mac)}`);
|
|
}
|
|
|
|
export async function listMessages(chatId: string): Promise<MessageRead[]> {
|
|
return jsonFetch(`${getAIBaseUrl()}/sse/messages?chat_id=${encodeURIComponent(chatId)}`);
|
|
}
|
|
|
|
export async function clearChat(chatId: string): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<boolean>;
|
|
}
|
|
|
|
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<string, unknown>,
|
|
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<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;
|
|
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<string, any> = { ...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<string, string | number>;
|
|
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<string, string | number> = { ...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) };
|
|
}
|
|
}
|