新增AI的功能与页面
This commit is contained in:
@@ -17,6 +17,7 @@ import SystemPage from "./pages/SystemPage";
|
|||||||
import IOPage from "./pages/IOPage";
|
import IOPage from "./pages/IOPage";
|
||||||
import VUPage from "./pages/VUPage";
|
import VUPage from "./pages/VUPage";
|
||||||
import SelectPage from "./pages/SelectPage";
|
import SelectPage from "./pages/SelectPage";
|
||||||
|
import AIPage from "./pages/AIPage";
|
||||||
|
|
||||||
function AppRouter() {
|
function AppRouter() {
|
||||||
return (
|
return (
|
||||||
@@ -33,6 +34,7 @@ function AppRouter() {
|
|||||||
<Route path="/io" component={IOPage} />
|
<Route path="/io" component={IOPage} />
|
||||||
<Route path="/vu" component={VUPage} />
|
<Route path="/vu" component={VUPage} />
|
||||||
<Route path="/select" component={SelectPage} />
|
<Route path="/select" component={SelectPage} />
|
||||||
|
<Route path="/ai" component={AIPage} />
|
||||||
<Route path="/404" component={NotFound} />
|
<Route path="/404" component={NotFound} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
{ path: "/eq", icon: Sliders, label: "HP-EQ" },
|
{ path: "/eq", icon: Sliders, label: "HP-EQ" },
|
||||||
{ path: "/", icon: Home, label: "主页" },
|
{ path: "/", icon: Home, label: "主页" },
|
||||||
{ path: "/effects", icon: Activity, label: "Effect" },
|
{ path: "/effects", icon: Activity, label: "Effect" },
|
||||||
{ path: "/ai", icon: Bot, label: "我的AI", placeholder: true },
|
{ path: "/ai", icon: Bot, label: "我的AI" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function BottomNav() {
|
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