From 5a9530eede57b109b20635a621727a2fc368e513 Mon Sep 17 00:00:00 2001 From: fucheng Date: Fri, 11 Sep 2026 16:41:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=E5=A4=A7=E5=B9=85=E6=8F=90?= =?UTF-8?q?=E9=AB=98=E9=9F=B3=E9=87=8F=E5=89=8D=E5=A2=9E=E5=8A=A0=E4=BA=8C?= =?UTF-8?q?=E6=AC=A1=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 避免 AI 突然拉高音量或打开直通,需用户确认后再写入设备。 Co-authored-by: Cursor --- client/src/lib/aiApi.ts | 129 +++++++++++++++++++++++++++-- client/src/locales/data-de.json | 11 +++ client/src/locales/data-en.json | 11 +++ client/src/locales/data-es.json | 11 +++ client/src/locales/data-fr.json | 11 +++ client/src/locales/data-zh-HK.json | 11 +++ client/src/locales/data-zh.json | 11 +++ client/src/pages/AIPage.tsx | 125 ++++++++++++++++++++++++++-- 8 files changed, 307 insertions(+), 13 deletions(-) diff --git a/client/src/lib/aiApi.ts b/client/src/lib/aiApi.ts index d4df4da..deba230 100644 --- a/client/src/lib/aiApi.ts +++ b/client/src/lib/aiApi.ts @@ -157,12 +157,74 @@ export async function sendToolResult(toolUseId: string, content: { ok: boolean; }); } +/** 达到或高于此 dB 视为拉满,需二次确认(0 dB 为设备最大音量)。 */ +export const VOLUME_MAX_CONFIRM_DB = -1; +/** 单次升高达到此幅度(dB)需二次确认。 */ +export const VOLUME_JUMP_CONFIRM_DB = 10; + +export interface RiskyVolumeChange { + fromDb: number | null; + toDb: number; + kind: "max" | "jump"; +} + +export interface RiskyDeviceChange { + volume?: RiskyVolumeChange; + dacVolumeDirect?: { from: number; to: number }; +} + +export interface ExecuteFrontendToolOptions { + currentVolumeStored?: number; + currentDacVolumeDirect?: number; + /** 返回 true 才写入危险字段;未提供回调时对危险字段默认拒绝(失败关闭)。 */ + confirmRiskyChange?: (risk: RiskyDeviceChange) => Promise; +} + +function parseDbVolume(value: unknown): number | null { + const db = Number(value); + if (!Number.isFinite(db)) return null; + return Math.max(-100, Math.min(0, db)); +} + +/** 判断 AI 下发的 set_device_settings 是否会突然拉高音量或打开直通。 */ +export function assessRiskyDeviceSettings( + input: Record, + current: { volumeStored?: number; dacVolumeDirect?: number } = {}, +): RiskyDeviceChange | null { + const risk: RiskyDeviceChange = {}; + + if (input.volume !== undefined && input.volume !== null) { + const toDb = parseDbVolume(input.volume); + if (toDb !== null) { + const fromDb = + typeof current.volumeStored === "number" ? volumeToDb(current.volumeStored) : null; + const delta = fromDb === null ? Number.POSITIVE_INFINITY : toDb - fromDb; + const raising = fromDb === null || toDb > fromDb + 0.4; + const isMax = raising && toDb >= VOLUME_MAX_CONFIRM_DB; + const isJump = fromDb === null ? toDb >= -20 : delta >= VOLUME_JUMP_CONFIRM_DB; + if (isMax || isJump) { + risk.volume = { fromDb, toDb, kind: isMax ? "max" : "jump" }; + } + } + } + + if (input.dacVolumeDirect !== undefined && input.dacVolumeDirect !== null) { + const to = Number(input.dacVolumeDirect); + const from = current.dacVolumeDirect ?? 0; + if (Number.isFinite(to) && (to === 1 || to === 2) && from !== to) { + risk.dacVolumeDirect = { from, to }; + } + } + + return risk.volume || risk.dacVolumeDirect ? risk : null; +} + // ============================================================ // SSE streaming: POST /sse/question → text/event-stream // ============================================================ export interface StreamCallbacks { onText?: (text: string) => void; - onToolUse?: (block: ToolUseBlock) => void; + onToolUse?: (block: ToolUseBlock) => void | Promise; onError?: (msg: string) => void; onDone?: () => void; } @@ -219,7 +281,7 @@ export function streamQuestion(payload: QuestionPayload, cb: StreamCallbacks): A try { const ev = JSON.parse(dataStr) as SSEEvent; - dispatchEvent(ev, cb); + await dispatchEvent(ev, cb); if (ev.type === "done" || ev.type === "error") { return; } @@ -238,14 +300,14 @@ export function streamQuestion(payload: QuestionPayload, cb: StreamCallbacks): A return controller; } -function dispatchEvent(ev: SSEEvent, cb: StreamCallbacks) { +async 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); + await cb.onToolUse?.(ev.content as ToolUseBlock); } break; case "error": @@ -263,6 +325,7 @@ function dispatchEvent(ev: SSEEvent, cb: StreamCallbacks) { export async function executeFrontendTool( block: ToolUseBlock, api: LuxsinAPI | null, + options: ExecuteFrontendToolOptions = {}, ): Promise<{ ok: boolean; content?: any; message?: string }> { if (!api) return { ok: false, message: "Device is not connected." }; @@ -279,7 +342,38 @@ export async function executeFrontendTool( return { ok: true, content }; } case "set_device_settings": { - const rawParams = (block.input ?? {}) as Record; + const rawParams = { ...(block.input ?? {}) } as Record; + let liveVolume = options.currentVolumeStored; + let liveDirect = options.currentDacVolumeDirect; + try { + const live = await api.getDeviceState(); + if (typeof live.volume === "number") liveVolume = live.volume; + if (typeof live.dacVolumeDirect === "number") liveDirect = live.dacVolumeDirect; + } catch { + // 用调用方传入的当前值兜底 + } + + const risk = assessRiskyDeviceSettings(rawParams, { + volumeStored: liveVolume, + dacVolumeDirect: liveDirect, + }); + if (risk) { + const allowed = options.confirmRiskyChange + ? await options.confirmRiskyChange(risk) + : false; + if (!allowed) { + if (risk.volume) delete rawParams.volume; + if (risk.dacVolumeDirect) delete rawParams.dacVolumeDirect; + if (Object.keys(rawParams).length === 0) { + return { + ok: false, + message: + "User declined the dangerous volume change for hearing safety. Keep the current listening level unchanged.", + }; + } + } + } + // AI 传进来的 volume 是 dB,需换算回设备 0-200 的原始值 const params: Record = { ...rawParams }; if (params.volume !== undefined && params.volume !== null) { @@ -291,8 +385,29 @@ export async function executeFrontendTool( } await api.setSetting(params); await api.refreshState(); - // 回传给 AI 的仍以人类可读的 dB 展示 - return { ok: true, content: { ok: true, updated: rawParams } }; + const skippedVolume = Boolean(risk?.volume && rawParams.volume === undefined); + const skippedDirect = Boolean( + risk?.dacVolumeDirect && rawParams.dacVolumeDirect === undefined, + ); + const skipped = skippedVolume || skippedDirect; + return { + ok: true, + content: { + ok: true, + updated: rawParams, + ...(skipped + ? { + volume_unchanged: skippedVolume, + dac_volume_direct_unchanged: skippedDirect, + message: + "User declined the dangerous volume change. Other requested settings were applied; current listening volume was left unchanged.", + } + : {}), + }, + message: skipped + ? "User declined the dangerous volume change. Other requested settings were applied; current listening volume was left unchanged." + : undefined, + }; } case "get_peq_list": { const peqState = await api.getPeqState(); diff --git a/client/src/locales/data-de.json b/client/src/locales/data-de.json index 548d70e..5f081dc 100644 --- a/client/src/locales/data-de.json +++ b/client/src/locales/data-de.json @@ -636,6 +636,17 @@ "loadHistoryFailed": "Laden des Verlaufs fehlgeschlagen: {message}", "loadChatFailed": "Laden des Chats fehlgeschlagen: {message}", "toolResultFailed": "Senden des Tool-Ergebnisses fehlgeschlagen: {message}", + "riskyConfirm": { + "title": "Starke Lautstärkeerhöhung bestätigen?", + "descMax": "Die Lautstärke springt von {from} auf Maximum ({to} dB). Ein plötzliches Maximum kann Gehör oder Kopfhörer schädigen. Fortfahren?", + "descJump": "Die Lautstärke steigt um {delta} dB, von {from} auf {to} dB. Ein plötzlicher Anstieg kann Gehör oder Kopfhörer schädigen. Fortfahren?", + "descDacDirect0db": "Der 0-dB-Durchleitungsmodus wird aktiviert: Die Lautstärke geht auf Maximum und ist nicht regelbar. Kopfhörer können extrem laut werden.", + "descDacDirect12db": "Der -12-dB-Durchleitungsmodus wird aktiviert: Die Lautstärke wird fixiert und ist nicht regelbar. Es kann plötzlich deutlich lauter werden.", + "fromUnknown": "der aktuellen Lautstärke", + "confirm": "Änderung bestätigen", + "cancel": "Aktuelle Lautstärke behalten", + "declined": "Gefährliche Lautstärkeänderung abgebrochen. Andere Einstellungen werden trotzdem angewendet." + }, "history": { "title": "Chat-Verlauf", "newChat": "Neuen Chat starten", diff --git a/client/src/locales/data-en.json b/client/src/locales/data-en.json index 1852b8f..83b81aa 100644 --- a/client/src/locales/data-en.json +++ b/client/src/locales/data-en.json @@ -636,6 +636,17 @@ "loadHistoryFailed": "Failed to load history: {message}", "loadChatFailed": "Failed to load chat: {message}", "toolResultFailed": "Failed to send tool result: {message}", + "riskyConfirm": { + "title": "Confirm a large volume increase?", + "descMax": "Volume will jump from {from} to maximum ({to} dB). A sudden max level can damage hearing or headphones. Continue?", + "descJump": "Volume will jump {delta} dB, from {from} to {to} dB. A sudden increase can damage hearing or headphones. Continue?", + "descDacDirect0db": "This will enable 0 dB volume passthrough: volume goes to maximum and cannot be adjusted. Headphones may become extremely loud.", + "descDacDirect12db": "This will enable -12 dB volume passthrough: volume will be fixed and cannot be adjusted. Headphones may suddenly get much louder.", + "fromUnknown": "the current level", + "confirm": "Confirm change", + "cancel": "Keep current volume", + "declined": "Dangerous volume change cancelled. Other settings will still be applied." + }, "history": { "title": "Chat history", "newChat": "Start a new chat", diff --git a/client/src/locales/data-es.json b/client/src/locales/data-es.json index 041dea4..b55379a 100644 --- a/client/src/locales/data-es.json +++ b/client/src/locales/data-es.json @@ -636,6 +636,17 @@ "loadHistoryFailed": "Error al cargar el historial: {message}", "loadChatFailed": "Error al cargar el chat: {message}", "toolResultFailed": "Error al enviar el resultado de la herramienta: {message}", + "riskyConfirm": { + "title": "¿Confirmar un gran aumento de volumen?", + "descMax": "El volumen saltará de {from} al máximo ({to} dB). Un máximo súbito puede dañar la audición o los auriculares. ¿Continuar?", + "descJump": "El volumen subirá {delta} dB, de {from} a {to} dB. Un aumento súbito puede dañar la audición o los auriculares. ¿Continuar?", + "descDacDirect0db": "Se activará el paso directo a 0 dB: el volumen irá al máximo y no se podrá ajustar. Los auriculares pueden volverse extremadamente altos.", + "descDacDirect12db": "Se activará el paso directo a -12 dB: el volumen quedará fijo y no se podrá ajustar. Puede subir de golpe.", + "fromUnknown": "el nivel actual", + "confirm": "Confirmar cambio", + "cancel": "Mantener el volumen actual", + "declined": "Se canceló el cambio de volumen peligroso. El resto de ajustes sí se aplicarán." + }, "history": { "title": "Historial de chat", "newChat": "Iniciar un nuevo chat", diff --git a/client/src/locales/data-fr.json b/client/src/locales/data-fr.json index b94ae01..cbf32d0 100644 --- a/client/src/locales/data-fr.json +++ b/client/src/locales/data-fr.json @@ -636,6 +636,17 @@ "loadHistoryFailed": "Échec du chargement de l'historique : {message}", "loadChatFailed": "Échec du chargement du chat : {message}", "toolResultFailed": "Échec de l'envoi du résultat de l'outil : {message}", + "riskyConfirm": { + "title": "Confirmer une forte hausse du volume ?", + "descMax": "Le volume passera de {from} au maximum ({to} dB). Un maximum soudain peut endommager l'audition ou les écouteurs. Continuer ?", + "descJump": "Le volume augmentera de {delta} dB, de {from} à {to} dB. Une hausse soudaine peut endommager l'audition ou les écouteurs. Continuer ?", + "descDacDirect0db": "Cela activera le passage direct à 0 dB : le volume ira au maximum et ne sera plus réglable. Les écouteurs peuvent devenir extrêmement forts.", + "descDacDirect12db": "Cela activera le passage direct à -12 dB : le volume sera figé et non réglable. Le son peut monter brusquement.", + "fromUnknown": "le niveau actuel", + "confirm": "Confirmer le changement", + "cancel": "Garder le volume actuel", + "declined": "Changement de volume dangereux annulé. Les autres réglages seront tout de même appliqués." + }, "history": { "title": "Historique des chats", "newChat": "Démarrer un nouveau chat", diff --git a/client/src/locales/data-zh-HK.json b/client/src/locales/data-zh-HK.json index 14336f9..96b1923 100644 --- a/client/src/locales/data-zh-HK.json +++ b/client/src/locales/data-zh-HK.json @@ -630,6 +630,17 @@ "loadHistoryFailed": "載入歷史失敗:{message}", "loadChatFailed": "載入會話失敗:{message}", "toolResultFailed": "工具結果回傳失敗:{message}", + "riskyConfirm": { + "title": "確認大幅提高音量?", + "descMax": "音量將從 {from} 調至最大({to} dB)。突然拉滿可能導致聽力損傷或損壞耳機,請確認後繼續。", + "descJump": "音量將從 {from} 突然升高 {delta} dB 到 {to} dB。這可能導致聽力損傷或損壞耳機,請確認後繼續。", + "descDacDirect0db": "將開啟 0 dB 音量直通:音量會拉到最大且不可調節,耳機可能突然變得非常大聲。", + "descDacDirect12db": "將開啟 -12 dB 音量直通:音量會被固定且不可調節,可能突然變大聲。", + "fromUnknown": "目前音量", + "confirm": "確認調整", + "cancel": "保持目前音量", + "declined": "已取消危險音量調整,其它設定仍會套用。" + }, "history": { "title": "歷史會話", "newChat": "開始新會話", diff --git a/client/src/locales/data-zh.json b/client/src/locales/data-zh.json index 3699bea..8fc44ef 100644 --- a/client/src/locales/data-zh.json +++ b/client/src/locales/data-zh.json @@ -631,6 +631,17 @@ "loadHistoryFailed": "加载历史失败:{message}", "loadChatFailed": "加载会话失败:{message}", "toolResultFailed": "工具结果回传失败:{message}", + "riskyConfirm": { + "title": "确认大幅提高音量?", + "descMax": "音量将从 {from} 调至最大({to} dB)。突然拉满可能导致听力损伤或损坏耳机,请确认后继续。", + "descJump": "音量将从 {from} 突然升高 {delta} dB 到 {to} dB。这可能导致听力损伤或损坏耳机,请确认后继续。", + "descDacDirect0db": "将开启 0 dB 音量直通:音量会拉到最大且不可调节,耳机可能突然变得非常大声。", + "descDacDirect12db": "将开启 -12 dB 音量直通:音量会被固定且不可调节,可能突然变大声。", + "fromUnknown": "当前音量", + "confirm": "确认调整", + "cancel": "保持当前音量", + "declined": "已取消危险音量调整,其它设置仍会应用。" + }, "history": { "title": "历史会话", "newChat": "开始新会话", diff --git a/client/src/pages/AIPage.tsx b/client/src/pages/AIPage.tsx index 91b2f98..7f018df 100644 --- a/client/src/pages/AIPage.tsx +++ b/client/src/pages/AIPage.tsx @@ -34,6 +34,7 @@ import { executeFrontendTool, listChats, listMessages, + RiskyDeviceChange, sendToolResult, streamQuestion, updateMessageApplied, @@ -84,6 +85,11 @@ function formatText(template: string, vars: Record): st }); } +function formatDb(n: number): string { + const rounded = Math.round(n * 10) / 10; + return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1); +} + type ChatUIMessage = { kind: "chat"; id: string; @@ -243,6 +249,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) const [historyOpen, setHistoryOpen] = useState(false); const [pendingDeleteId, setPendingDeleteId] = useState(null); const [pendingApplyMsg, setPendingApplyMsg] = useState(null); + const [riskyConfirm, setRiskyConfirm] = useState(null); const [chats, setChats] = useState([]); // 初次加载历史会话期间,避免先闪一下欢迎页再切到消息 const [initializing, setInitializing] = useState(true); @@ -251,6 +258,23 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) const abortRef = useRef(null); const textareaRef = useRef(null); const initialLoadRef = useRef(false); + const riskyConfirmRef = useRef<{ resolve: (ok: boolean) => void } | null>(null); + const riskyAcceptRef = useRef(false); + + const settleRiskyConfirm = useCallback((ok: boolean) => { + riskyConfirmRef.current?.resolve(ok); + riskyConfirmRef.current = null; + riskyAcceptRef.current = false; + setRiskyConfirm(null); + }, []); + + const confirmRiskyChange = useCallback((risk: RiskyDeviceChange) => { + return new Promise((resolve) => { + riskyConfirmRef.current?.resolve(false); + riskyConfirmRef.current = { resolve }; + setRiskyConfirm(risk); + }); + }, []); const isEmpty = messages.length === 0; @@ -263,7 +287,11 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) // ── 组件卸载时取消请求 ── useEffect(() => { - return () => abortRef.current?.abort(); + return () => { + abortRef.current?.abort(); + riskyConfirmRef.current?.resolve(false); + riskyConfirmRef.current = null; + }; }, []); // ── 打开历史会话 ── @@ -321,6 +349,8 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) // ── 加载指定会话的消息 ── const loadChat = useCallback( async (id: string, opts?: { closePanel?: boolean }) => { + abortRef.current?.abort(); + settleRiskyConfirm(false); try { const rows = await listMessages(id); setMessages(mapRowsToUI(rows)); @@ -330,7 +360,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) toast.error(formatText(aiText.loadChatFailed, { message: e?.message ?? e })); } }, - [aiText, mapRowsToUI], + [aiText, mapRowsToUI, settleRiskyConfirm], ); // ── 进入页面时若有历史会话,自动打开最近一次 ── @@ -380,11 +410,12 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) // ── 新建会话 ── const newChat = useCallback(() => { abortRef.current?.abort(); + settleRiskyConfirm(false); setMessages([]); setChatId(null); setInput(""); setHistoryOpen(false); - }, []); + }, [settleRiskyConfirm]); // ── 清空当前会话 ── const clearCurrent = useCallback(async () => { @@ -515,7 +546,11 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) pending: false, tools: [...(m.tools ?? []), { id: block.id, name: block.name }], })); - const result = await executeFrontendTool(block, api); + const result = await executeFrontendTool(block, api, { + currentVolumeStored: deviceState?.volume, + currentDacVolumeDirect: deviceState?.dacVolumeDirect, + confirmRiskyChange, + }); updateAssistant((m) => ({ ...m, tools: (m.tools ?? []).map((t) => @@ -568,11 +603,12 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) }; void buildAndStream(); }, - [aiText, api, chatId, deviceState, mac, refreshCurrentChat, sending], + [aiText, api, chatId, confirmRiskyChange, deviceState, mac, refreshCurrentChat, sending], ); const stop = useCallback(() => { abortRef.current?.abort(); + settleRiskyConfirm(false); setSending(false); setMessages((prev) => prev.map((m) => @@ -581,7 +617,40 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) : m, ), ); - }, [aiText]); + }, [aiText, settleRiskyConfirm]); + + const riskyConfirmCopy = useMemo(() => { + if (!riskyConfirm) return null; + const copy = aiText.riskyConfirm; + const parts: string[] = []; + if (riskyConfirm.volume) { + const from = + riskyConfirm.volume.fromDb === null + ? copy.fromUnknown + : `${formatDb(riskyConfirm.volume.fromDb)} dB`; + const to = formatDb(riskyConfirm.volume.toDb); + const delta = + riskyConfirm.volume.fromDb === null + ? "" + : formatDb(riskyConfirm.volume.toDb - riskyConfirm.volume.fromDb); + parts.push( + riskyConfirm.volume.kind === "max" + ? formatText(copy.descMax, { from, to }) + : formatText(copy.descJump, { from, to, delta }), + ); + } + if (riskyConfirm.dacVolumeDirect) { + parts.push( + riskyConfirm.dacVolumeDirect.to === 2 ? copy.descDacDirect12db : copy.descDacDirect0db, + ); + } + return { + title: copy.title, + parts, + confirm: copy.confirm, + cancel: copy.cancel, + }; + }, [aiText, riskyConfirm]); const onSubmit = useCallback( (e: React.FormEvent) => { @@ -1033,6 +1102,50 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {}) + { + if (!open) { + const accepted = riskyAcceptRef.current; + settleRiskyConfirm(accepted); + } + }} + > + + + + {riskyConfirmCopy?.title} + + + {(riskyConfirmCopy?.parts ?? []).map((part, i) => ( + + {part} + + ))} + + + + toast.info(aiText.riskyConfirm.declined)} + > + {riskyConfirmCopy?.cancel} + + { + riskyAcceptRef.current = true; + }} + onClick={() => { + riskyAcceptRef.current = true; + }} + > + {riskyConfirmCopy?.confirm} + + + + + {/* ── 对比对话框 ── */} {compareMsg && (