新增AI的功能与页面
This commit is contained in:
@@ -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() {
|
||||
<Route path="/io" component={IOPage} />
|
||||
<Route path="/vu" component={VUPage} />
|
||||
<Route path="/select" component={SelectPage} />
|
||||
<Route path="/ai" component={AIPage} />
|
||||
<Route path="/404" component={NotFound} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Generated
+12059
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user