diff --git a/client/src/App.tsx b/client/src/App.tsx index 880d234..45bf58c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -17,6 +17,7 @@ import SystemPage from "./pages/SystemPage"; import IOPage from "./pages/IOPage"; import VUPage from "./pages/VUPage"; import SelectPage from "./pages/SelectPage"; +import AIPage from "./pages/AIPage"; function AppRouter() { return ( @@ -33,6 +34,7 @@ function AppRouter() { + diff --git a/client/src/components/BottomNav.tsx b/client/src/components/BottomNav.tsx index 0823adf..0a0a532 100644 --- a/client/src/components/BottomNav.tsx +++ b/client/src/components/BottomNav.tsx @@ -27,7 +27,7 @@ const NAV_ITEMS: NavItem[] = [ { path: "/eq", icon: Sliders, label: "HP-EQ" }, { path: "/", icon: Home, label: "主页" }, { path: "/effects", icon: Activity, label: "Effect" }, - { path: "/ai", icon: Bot, label: "我的AI", placeholder: true }, + { path: "/ai", icon: Bot, label: "我的AI" }, ]; export default function BottomNav() { diff --git a/client/src/lib/aiApi.ts b/client/src/lib/aiApi.ts new file mode 100644 index 0000000..4f53489 --- /dev/null +++ b/client/src/lib/aiApi.ts @@ -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; +} + +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(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 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(); + return { ok: true, content: state }; + } + case "set_device_settings": { + const params = (block.input ?? {}) as Record; + 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) }; + } +} diff --git a/client/src/pages/AIPage.tsx b/client/src/pages/AIPage.tsx new file mode 100644 index 0000000..c772323 --- /dev/null +++ b/client/src/pages/AIPage.tsx @@ -0,0 +1,719 @@ +/* ============================================================ + AI PAGE — Luxsin X9 AI Assistant + - 首次进入: 中央 Logo + 建议气泡,引导用户如何提问 + - 对话中: 消息流式展示(用户气泡 / 助手气泡) + - 底部: 输入框 + 发送按钮 + - 顶部: 标题 + 历史/新建/清除按钮 + Backend: /sse/question (SSE), /sse/messages, /sse/chats, /sse/clear + ============================================================ */ +import BottomNav from "@/components/BottomNav"; +import ConnectScreen from "@/components/ConnectScreen"; +import { useDevice } from "@/contexts/DeviceContext"; +import { + ChatRead, + MessageRead, + ToolUseBlock, + clearChat, + executeFrontendTool, + listChats, + listMessages, + sendToolResult, + streamQuestion, +} from "@/lib/aiApi"; +import { cn } from "@/lib/utils"; +import { + ArrowUp, + History, + MessageSquarePlus, + Sparkles, + Sliders, + Volume2, + Settings2, + Wand2, + X, + Bot, + Wrench, + AlertCircle, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Streamdown } from "streamdown"; +import { toast } from "sonner"; + +type UIMessage = { + id: string; + role: "user" | "assistant"; + text: string; + pending?: boolean; + error?: boolean; + tools?: Array<{ id: string; name: string; ok?: boolean }>; +}; + +interface Suggestion { + icon: React.ReactNode; + title: string; + prompt: string; +} + +const SUGGESTIONS: Suggestion[] = [ + { + icon: , + title: "优化我的 EQ", + prompt: "帮我优化当前 EQ,我希望人声更清晰一些", + }, + { + icon: , + title: "推荐流行乐 EQ", + prompt: "推荐一套适合听流行音乐的 EQ 参数", + }, + { + icon: , + title: "查看设备状态", + prompt: "帮我查看当前的音量、输入源和输出端口", + }, + { + icon: , + title: "切换输入源", + prompt: "把输入源切换到 USB-C", + }, +]; + +// 将后端 DB 存的 content(可能是 JSON 文本)转为纯文本展示 +function extractText(raw: string): { text: string; tools: Array<{ id: string; name: string }> } { + const trimmed = raw?.trim?.() ?? ""; + if (!trimmed) return { text: "", tools: [] }; + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + try { + const arr = JSON.parse(trimmed); + if (Array.isArray(arr)) { + const textParts: string[] = []; + const tools: Array<{ id: string; name: string }> = []; + for (const block of arr) { + if (block?.type === "text" && typeof block.text === "string") { + textParts.push(block.text); + } else if (block?.type === "tool_use") { + tools.push({ id: block.id, name: block.name }); + } else if (block?.type === "tool_result") { + // 用户侧的工具结果不直接展示 + } + } + return { text: textParts.join("\n").trim(), tools }; + } + } catch { + // fall through + } + } + return { text: raw, tools: [] }; +} + +function makeId() { + return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +export default function AIPage() { + const { isConnected, deviceState, api } = useDevice(); + const mac = deviceState?.mac ?? ""; + const language = deviceState?.language ?? 2; + const deviceName = deviceState?.device ?? "Luxsin X9"; + + const [chatId, setChatId] = useState(null); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [sending, setSending] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); + const [chats, setChats] = useState([]); + + const scrollRef = useRef(null); + const abortRef = useRef(null); + const textareaRef = useRef(null); + + const isEmpty = messages.length === 0; + + // ── 自动滚动到底部 ── + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, [messages, sending]); + + // ── 组件卸载时取消请求 ── + useEffect(() => { + return () => abortRef.current?.abort(); + }, []); + + // ── 打开历史会话 ── + const openHistory = useCallback(async () => { + if (!mac) { + toast.error("请先连接设备"); + return; + } + setHistoryOpen(true); + try { + const list = await listChats(mac); + setChats(list); + } catch (e: any) { + toast.error(`加载历史失败: ${e?.message ?? e}`); + } + }, [mac]); + + // ── 加载指定会话的消息 ── + const loadChat = useCallback(async (id: string) => { + try { + const rows: MessageRead[] = await listMessages(id); + const mapped: UIMessage[] = rows + .filter((r) => r.type === 0) + .map((r) => { + const { text, tools } = extractText(r.content); + return { + id: r.id, + role: r.role, + text, + tools: tools.map((t) => ({ ...t, ok: true })), + }; + }) + .filter((m) => m.text || (m.tools && m.tools.length > 0)); + setMessages(mapped); + setChatId(id); + setHistoryOpen(false); + } catch (e: any) { + toast.error(`加载会话失败: ${e?.message ?? e}`); + } + }, []); + + // ── 新建会话 ── + const newChat = useCallback(() => { + abortRef.current?.abort(); + setMessages([]); + setChatId(null); + setInput(""); + setHistoryOpen(false); + }, []); + + // ── 清空当前会话 ── + const clearCurrent = useCallback(async () => { + if (!chatId) { + newChat(); + return; + } + try { + await clearChat(chatId); + newChat(); + toast.success("已清空当前会话"); + } catch (e: any) { + toast.error(`清空失败: ${e?.message ?? e}`); + } + }, [chatId, newChat]); + + // ── 发送提问 ── + const send = useCallback( + (question: string) => { + const trimmed = question.trim(); + if (!trimmed || sending) return; + if (!mac) { + toast.error("请先连接设备"); + return; + } + + const userMsg: UIMessage = { id: makeId(), role: "user", text: trimmed }; + const assistantMsg: UIMessage = { + id: makeId(), + role: "assistant", + text: "", + pending: true, + tools: [], + }; + + setMessages((prev) => [...prev, userMsg, assistantMsg]); + setInput(""); + setSending(true); + + const assistantId = assistantMsg.id; + + const updateAssistant = (updater: (m: UIMessage) => UIMessage) => { + setMessages((prev) => + prev.map((m) => (m.id === assistantId ? updater(m) : m)), + ); + }; + + // 工具执行后续调用时需要保留当前 chat_id,先捕获一下 + let currentChatId = chatId; + + const controller = streamQuestion( + { + question: trimmed, + language, + mac, + device: deviceName, + chat_id: currentChatId, + }, + { + onText: (t) => { + updateAssistant((m) => ({ + ...m, + text: (m.text ?? "") + t, + pending: false, + })); + }, + onToolUse: async (block: ToolUseBlock) => { + updateAssistant((m) => ({ + ...m, + pending: false, + tools: [...(m.tools ?? []), { id: block.id, name: block.name }], + })); + const result = await executeFrontendTool(block, api); + updateAssistant((m) => ({ + ...m, + tools: (m.tools ?? []).map((t) => + t.id === block.id ? { ...t, ok: result.ok } : t, + ), + })); + try { + await sendToolResult(block.id, result); + } catch (e: any) { + toast.error(`工具结果回传失败: ${e?.message ?? e}`); + } + }, + onError: (msg) => { + updateAssistant((m) => ({ + ...m, + pending: false, + error: true, + text: m.text || msg || "请求失败,请稍后重试", + })); + setSending(false); + }, + onDone: () => { + setSending(false); + // 如果是新建会话,后端首次返回时会创建 chat_id;这里拉一次会话列表补上 id + if (!currentChatId && mac) { + listChats(mac) + .then((list) => { + if (list.length > 0) { + // 列表按 updated_at 倒序或无序,这里取最新的 + const newest = list.reduce((a, b) => + new Date(a.updated_at) > new Date(b.updated_at) ? a : b, + ); + currentChatId = newest.id; + setChatId(newest.id); + } + }) + .catch(() => void 0); + } + }, + }, + ); + abortRef.current = controller; + }, + [api, chatId, deviceName, language, mac, sending], + ); + + const stop = useCallback(() => { + abortRef.current?.abort(); + setSending(false); + setMessages((prev) => + prev.map((m) => (m.pending ? { ...m, pending: false, text: m.text || "(已停止)" } : m)), + ); + }, []); + + const onSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + send(input); + }, + [input, send], + ); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { + e.preventDefault(); + send(input); + } + }, + [input, send], + ); + + const onInputChange = useCallback((e: React.ChangeEvent) => { + setInput(e.target.value); + const el = e.target; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 160)}px`; + }, []); + + const headerTitle = useMemo(() => "Luxsin AI", []); + + if (!isConnected) return ; + + return ( +
+ {/* ── Header ── */} +
+
+
+ +
+ {headerTitle} +
+ + +
+ + {/* ── Messages / Welcome ── */} +
+ {isEmpty ? ( + { + setInput(p); + setTimeout(() => textareaRef.current?.focus(), 0); + }} + /> + ) : ( +
+ {messages.map((m) => ( + + ))} + {sending && messages[messages.length - 1]?.pending && } +
+ )} +
+ + {/* ── Input bar ── */} +
+
+