feat(ai): 大幅提高音量前增加二次确认
避免 AI 突然拉高音量或打开直通,需用户确认后再写入设备。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+122
-7
@@ -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<boolean>;
|
||||
}
|
||||
|
||||
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<string, unknown>,
|
||||
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<void>;
|
||||
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<string, string | number>;
|
||||
const rawParams = { ...(block.input ?? {}) } as Record<string, string | number>;
|
||||
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<string, string | number> = { ...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();
|
||||
|
||||
Reference in New Issue
Block a user