新增AI的功能与页面

This commit is contained in:
allen2fuc
2026-04-21 09:39:54 +08:00
parent 24278c263f
commit 52cdaf1e51
5 changed files with 13057 additions and 1 deletions
+719
View File
@@ -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: <Wand2 size={14} />,
title: "优化我的 EQ",
prompt: "帮我优化当前 EQ,我希望人声更清晰一些",
},
{
icon: <Sliders size={14} />,
title: "推荐流行乐 EQ",
prompt: "推荐一套适合听流行音乐的 EQ 参数",
},
{
icon: <Volume2 size={14} />,
title: "查看设备状态",
prompt: "帮我查看当前的音量、输入源和输出端口",
},
{
icon: <Settings2 size={14} />,
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<string | null>(null);
const [messages, setMessages] = useState<UIMessage[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [chats, setChats] = useState<ChatRead[]>([]);
const scrollRef = useRef<HTMLDivElement | null>(null);
const abortRef = useRef<AbortController | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(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<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
send(input);
}
},
[input, send],
);
const onInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
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 <ConnectScreen />;
return (
<div className="min-h-screen bg-black flex flex-col">
{/* ── Header ── */}
<div className="page-header">
<div className="flex items-center gap-2 mr-auto">
<div
className="w-7 h-7 rounded-full flex items-center justify-center"
style={{
background: "rgba(0,255,246,0.12)",
border: "1px solid rgba(0,255,246,0.3)",
boxShadow: "0 0 10px rgba(0,255,246,0.25)",
}}
>
<Sparkles size={14} className="text-[#00FFF6]" />
</div>
<span className="text-[17px] font-semibold text-white">{headerTitle}</span>
</div>
<button
onClick={openHistory}
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors mr-2"
style={{ background: "rgba(44,44,46,0.6)" }}
aria-label="历史会话"
>
<History size={16} />
</button>
<button
onClick={newChat}
className="w-9 h-9 rounded-full flex items-center justify-center text-white/60 active:text-white transition-colors"
style={{ background: "rgba(44,44,46,0.6)" }}
aria-label="新建会话"
>
<MessageSquarePlus size={16} />
</button>
</div>
{/* ── Messages / Welcome ── */}
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 pb-[200px]">
{isEmpty ? (
<WelcomeView
deviceName={deviceName}
onPick={(p) => {
setInput(p);
setTimeout(() => textareaRef.current?.focus(), 0);
}}
/>
) : (
<div className="pt-3 space-y-3 max-w-full">
{messages.map((m) => (
<MessageBubble key={m.id} msg={m} />
))}
{sending && messages[messages.length - 1]?.pending && <TypingIndicator />}
</div>
)}
</div>
{/* ── Input bar ── */}
<div
className="fixed left-0 right-0 z-40 px-3 pb-3"
style={{
bottom: "calc(env(safe-area-inset-bottom, 0px) + 56px)",
background: "linear-gradient(to top, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.85) 60%, transparent 100%)",
paddingTop: 12,
}}
>
<form
onSubmit={onSubmit}
className="flex items-end gap-2 rounded-[22px] px-3 py-2"
style={{
background: "rgba(28,28,30,0.92)",
border: "1px solid rgba(255,255,255,0.08)",
backdropFilter: "blur(20px) saturate(180%)",
WebkitBackdropFilter: "blur(20px) saturate(180%)",
}}
>
<textarea
ref={textareaRef}
value={input}
onChange={onInputChange}
onKeyDown={onKeyDown}
rows={1}
placeholder="问问关于设备、EQ、音效……"
className="flex-1 bg-transparent outline-none resize-none text-[15px] text-white placeholder:text-white/35 py-1.5 px-1 max-h-40 leading-[1.4]"
/>
{sending ? (
<button
type="button"
onClick={stop}
className="flex-shrink-0 w-9 h-9 rounded-full flex items-center justify-center transition-all active:scale-95"
style={{
background: "rgba(239,68,68,0.18)",
border: "1px solid rgba(239,68,68,0.4)",
}}
aria-label="停止"
>
<span
className="block w-3 h-3 rounded-[2px]"
style={{ background: "#ef4444" }}
/>
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className={cn(
"flex-shrink-0 w-9 h-9 rounded-full flex items-center justify-center transition-all active:scale-95 disabled:opacity-40",
)}
style={{
background: input.trim() ? "#00FFF6" : "rgba(120,120,128,0.3)",
boxShadow: input.trim() ? "0 0 12px rgba(0,255,246,0.45)" : "none",
}}
aria-label="发送"
>
<ArrowUp
size={18}
className={input.trim() ? "text-black" : "text-white/50"}
strokeWidth={2.5}
/>
</button>
)}
</form>
{chatId && messages.length > 0 && (
<div className="flex justify-center mt-2">
<button
onClick={clearCurrent}
className="text-[11px] text-white/35 active:text-white/70 transition-colors px-2"
>
</button>
</div>
)}
</div>
{/* ── History panel ── */}
{historyOpen && (
<HistoryPanel
chats={chats}
currentId={chatId}
onClose={() => setHistoryOpen(false)}
onSelect={loadChat}
onNew={newChat}
/>
)}
<BottomNav />
</div>
);
}
// ============================================================
// 子组件
// ============================================================
function WelcomeView({
deviceName,
onPick,
}: {
deviceName: string;
onPick: (prompt: string) => void;
}) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] pt-6">
{/* Logo */}
<div
className="w-16 h-16 rounded-[22px] flex items-center justify-center mb-4"
style={{
background:
"radial-gradient(circle at 30% 30%, rgba(0,255,246,0.3), rgba(0,255,246,0.08) 60%, transparent 80%)",
border: "1px solid rgba(0,255,246,0.4)",
boxShadow:
"0 0 30px rgba(0,255,246,0.25), inset 0 0 20px rgba(0,255,246,0.08)",
}}
>
<Sparkles size={28} className="text-[#00FFF6]" />
</div>
<h2 className="text-[22px] font-semibold text-white mb-1.5"> Luxsin AI</h2>
<p className="text-[13px] text-white/45 text-center leading-relaxed max-w-[280px] mb-7">
{deviceName} EQ使
</p>
<div className="w-full max-w-[420px]">
<div className="text-[11px] text-white/35 font-medium tracking-wider uppercase px-1 mb-2">
</div>
<div className="grid grid-cols-2 gap-2">
{SUGGESTIONS.map((s) => (
<button
key={s.title}
onClick={() => onPick(s.prompt)}
className="text-left rounded-[14px] p-3 transition-all active:scale-[0.98]"
style={{
background: "rgba(28,28,30,0.85)",
border: "1px solid rgba(255,255,255,0.06)",
}}
>
<div
className="w-6 h-6 rounded-[8px] flex items-center justify-center mb-2"
style={{
background: "rgba(0,255,246,0.1)",
border: "1px solid rgba(0,255,246,0.2)",
color: "#00FFF6",
}}
>
{s.icon}
</div>
<div className="text-[13px] font-medium text-white mb-0.5">{s.title}</div>
<div className="text-[11px] text-white/40 leading-snug line-clamp-2">
{s.prompt}
</div>
</button>
))}
</div>
</div>
</div>
);
}
function MessageBubble({ msg }: { msg: UIMessage }) {
const isUser = msg.role === "user";
return (
<div className={cn("flex w-full", isUser ? "justify-end" : "justify-start")}>
{!isUser && (
<div
className="w-7 h-7 rounded-full flex items-center justify-center mr-2 mt-0.5 flex-shrink-0"
style={{
background: "rgba(0,255,246,0.12)",
border: "1px solid rgba(0,255,246,0.25)",
}}
>
<Bot size={14} className="text-[#00FFF6]" />
</div>
)}
<div
className={cn(
"rounded-[18px] px-3.5 py-2.5 max-w-[82%] text-[14.5px] leading-relaxed break-words",
isUser ? "text-black" : "text-white",
)}
style={
isUser
? {
background: "#00FFF6",
boxShadow: "0 0 14px rgba(0,255,246,0.25)",
borderBottomRightRadius: 6,
}
: {
background: "rgba(28,28,30,0.92)",
border: "1px solid rgba(255,255,255,0.06)",
borderBottomLeftRadius: 6,
}
}
>
{/* 工具调用 chips */}
{msg.tools && msg.tools.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{msg.tools.map((t) => (
<span
key={t.id}
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full"
style={{
background: "rgba(0,255,246,0.08)",
border: "1px solid rgba(0,255,246,0.22)",
color: "#00FFF6",
}}
>
<Wrench size={10} />
{t.name}
{t.ok === false && <AlertCircle size={10} className="text-red-400" />}
</span>
))}
</div>
)}
{isUser ? (
<div className="whitespace-pre-wrap">{msg.text}</div>
) : (
<div className={cn("markdown-body", msg.error && "text-red-400")}>
{msg.text ? (
<Streamdown parseIncompleteMarkdown>{msg.text}</Streamdown>
) : (
<span className="text-white/40 italic"></span>
)}
</div>
)}
</div>
</div>
);
}
function TypingIndicator() {
return (
<div className="flex items-center gap-1.5 ml-9 mt-1">
<span className="w-1.5 h-1.5 rounded-full bg-white/40 animate-pulse" style={{ animationDelay: "0ms" }} />
<span className="w-1.5 h-1.5 rounded-full bg-white/40 animate-pulse" style={{ animationDelay: "150ms" }} />
<span className="w-1.5 h-1.5 rounded-full bg-white/40 animate-pulse" style={{ animationDelay: "300ms" }} />
</div>
);
}
function HistoryPanel({
chats,
currentId,
onClose,
onSelect,
onNew,
}: {
chats: ChatRead[];
currentId: string | null;
onClose: () => void;
onSelect: (id: string) => void;
onNew: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex flex-col bg-black/70" onClick={onClose}>
<div
className="mt-auto rounded-t-[22px] max-h-[70vh] flex flex-col"
style={{
background: "rgba(18,18,20,0.98)",
border: "1px solid rgba(255,255,255,0.08)",
paddingBottom: "env(safe-area-inset-bottom, 0px)",
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center px-4 pt-3 pb-2">
<span className="text-[15px] font-semibold text-white flex-1"></span>
<button
onClick={onClose}
className="w-7 h-7 rounded-full flex items-center justify-center text-white/60"
style={{ background: "rgba(44,44,46,0.8)" }}
>
<X size={14} />
</button>
</div>
<div className="overflow-y-auto px-3 pb-3">
<button
onClick={onNew}
className="w-full flex items-center gap-2 px-3 py-3 rounded-[12px] mb-2 text-left"
style={{
background: "rgba(0,255,246,0.08)",
border: "1px dashed rgba(0,255,246,0.3)",
color: "#00FFF6",
}}
>
<MessageSquarePlus size={16} />
<span className="text-[14px] font-medium"></span>
</button>
{chats.length === 0 ? (
<div className="text-center text-white/40 text-[13px] py-6"></div>
) : (
<div className="ios-list-group">
{chats.map((c) => (
<button
key={c.id}
onClick={() => onSelect(c.id)}
className={cn(
"ios-list-row w-full text-left active:bg-white/5",
currentId === c.id && "bg-white/[0.04]",
)}
>
<div className="flex-1 min-w-0">
<div className="text-[15px] text-white truncate">{c.title}</div>
<div className="text-[11px] text-white/35 mt-0.5">
{new Date(c.updated_at).toLocaleString()}
</div>
</div>
{currentId === c.id && (
<span className="text-[11px] text-[#00FFF6] ml-2"></span>
)}
</button>
))}
</div>
)}
</div>
</div>
</div>
);
}