新增AI的功能与页面
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
// ============================================================
|
||||
// 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,
|
||||
} 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 MessageRead {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
created_at: string;
|
||||
type: number;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface QuestionPayload {
|
||||
question: string;
|
||||
language: number;
|
||||
mac: string;
|
||||
device: string;
|
||||
chat_id?: string | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// REST helpers
|
||||
// ============================================================
|
||||
async function jsonFetch<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
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<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> {
|
||||
await fetch(`${getAIBaseUrl()}/sse/clear?chat_id=${encodeURIComponent(chatId)}`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 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();
|
||||
return { ok: true, content: state };
|
||||
}
|
||||
case "set_device_settings": {
|
||||
const params = (block.input ?? {}) as Record<string, string | number>;
|
||||
await api.setSetting(params);
|
||||
return { ok: true, content: { ok: true, updated: params } };
|
||||
}
|
||||
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) };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user