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
|
// SSE streaming: POST /sse/question → text/event-stream
|
||||||
// ============================================================
|
// ============================================================
|
||||||
export interface StreamCallbacks {
|
export interface StreamCallbacks {
|
||||||
onText?: (text: string) => void;
|
onText?: (text: string) => void;
|
||||||
onToolUse?: (block: ToolUseBlock) => void;
|
onToolUse?: (block: ToolUseBlock) => void | Promise<void>;
|
||||||
onError?: (msg: string) => void;
|
onError?: (msg: string) => void;
|
||||||
onDone?: () => void;
|
onDone?: () => void;
|
||||||
}
|
}
|
||||||
@@ -219,7 +281,7 @@ export function streamQuestion(payload: QuestionPayload, cb: StreamCallbacks): A
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const ev = JSON.parse(dataStr) as SSEEvent;
|
const ev = JSON.parse(dataStr) as SSEEvent;
|
||||||
dispatchEvent(ev, cb);
|
await dispatchEvent(ev, cb);
|
||||||
if (ev.type === "done" || ev.type === "error") {
|
if (ev.type === "done" || ev.type === "error") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -238,14 +300,14 @@ export function streamQuestion(payload: QuestionPayload, cb: StreamCallbacks): A
|
|||||||
return controller;
|
return controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
function dispatchEvent(ev: SSEEvent, cb: StreamCallbacks) {
|
async function dispatchEvent(ev: SSEEvent, cb: StreamCallbacks) {
|
||||||
switch (ev.type) {
|
switch (ev.type) {
|
||||||
case "text":
|
case "text":
|
||||||
if (typeof ev.content === "string") cb.onText?.(ev.content);
|
if (typeof ev.content === "string") cb.onText?.(ev.content);
|
||||||
break;
|
break;
|
||||||
case "tool_use":
|
case "tool_use":
|
||||||
if (ev.content && typeof ev.content === "object") {
|
if (ev.content && typeof ev.content === "object") {
|
||||||
cb.onToolUse?.(ev.content as ToolUseBlock);
|
await cb.onToolUse?.(ev.content as ToolUseBlock);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "error":
|
case "error":
|
||||||
@@ -263,6 +325,7 @@ function dispatchEvent(ev: SSEEvent, cb: StreamCallbacks) {
|
|||||||
export async function executeFrontendTool(
|
export async function executeFrontendTool(
|
||||||
block: ToolUseBlock,
|
block: ToolUseBlock,
|
||||||
api: LuxsinAPI | null,
|
api: LuxsinAPI | null,
|
||||||
|
options: ExecuteFrontendToolOptions = {},
|
||||||
): Promise<{ ok: boolean; content?: any; message?: string }> {
|
): Promise<{ ok: boolean; content?: any; message?: string }> {
|
||||||
if (!api) return { ok: false, message: "Device is not connected." };
|
if (!api) return { ok: false, message: "Device is not connected." };
|
||||||
|
|
||||||
@@ -279,7 +342,38 @@ export async function executeFrontendTool(
|
|||||||
return { ok: true, content };
|
return { ok: true, content };
|
||||||
}
|
}
|
||||||
case "set_device_settings": {
|
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 的原始值
|
// AI 传进来的 volume 是 dB,需换算回设备 0-200 的原始值
|
||||||
const params: Record<string, string | number> = { ...rawParams };
|
const params: Record<string, string | number> = { ...rawParams };
|
||||||
if (params.volume !== undefined && params.volume !== null) {
|
if (params.volume !== undefined && params.volume !== null) {
|
||||||
@@ -291,8 +385,29 @@ export async function executeFrontendTool(
|
|||||||
}
|
}
|
||||||
await api.setSetting(params);
|
await api.setSetting(params);
|
||||||
await api.refreshState();
|
await api.refreshState();
|
||||||
// 回传给 AI 的仍以人类可读的 dB 展示
|
const skippedVolume = Boolean(risk?.volume && rawParams.volume === undefined);
|
||||||
return { ok: true, content: { ok: true, updated: rawParams } };
|
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": {
|
case "get_peq_list": {
|
||||||
const peqState = await api.getPeqState();
|
const peqState = await api.getPeqState();
|
||||||
|
|||||||
@@ -636,6 +636,17 @@
|
|||||||
"loadHistoryFailed": "Laden des Verlaufs fehlgeschlagen: {message}",
|
"loadHistoryFailed": "Laden des Verlaufs fehlgeschlagen: {message}",
|
||||||
"loadChatFailed": "Laden des Chats fehlgeschlagen: {message}",
|
"loadChatFailed": "Laden des Chats fehlgeschlagen: {message}",
|
||||||
"toolResultFailed": "Senden des Tool-Ergebnisses 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": {
|
"history": {
|
||||||
"title": "Chat-Verlauf",
|
"title": "Chat-Verlauf",
|
||||||
"newChat": "Neuen Chat starten",
|
"newChat": "Neuen Chat starten",
|
||||||
|
|||||||
@@ -636,6 +636,17 @@
|
|||||||
"loadHistoryFailed": "Failed to load history: {message}",
|
"loadHistoryFailed": "Failed to load history: {message}",
|
||||||
"loadChatFailed": "Failed to load chat: {message}",
|
"loadChatFailed": "Failed to load chat: {message}",
|
||||||
"toolResultFailed": "Failed to send tool result: {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": {
|
"history": {
|
||||||
"title": "Chat history",
|
"title": "Chat history",
|
||||||
"newChat": "Start a new chat",
|
"newChat": "Start a new chat",
|
||||||
|
|||||||
@@ -636,6 +636,17 @@
|
|||||||
"loadHistoryFailed": "Error al cargar el historial: {message}",
|
"loadHistoryFailed": "Error al cargar el historial: {message}",
|
||||||
"loadChatFailed": "Error al cargar el chat: {message}",
|
"loadChatFailed": "Error al cargar el chat: {message}",
|
||||||
"toolResultFailed": "Error al enviar el resultado de la herramienta: {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": {
|
"history": {
|
||||||
"title": "Historial de chat",
|
"title": "Historial de chat",
|
||||||
"newChat": "Iniciar un nuevo chat",
|
"newChat": "Iniciar un nuevo chat",
|
||||||
|
|||||||
@@ -636,6 +636,17 @@
|
|||||||
"loadHistoryFailed": "Échec du chargement de l'historique : {message}",
|
"loadHistoryFailed": "Échec du chargement de l'historique : {message}",
|
||||||
"loadChatFailed": "Échec du chargement du chat : {message}",
|
"loadChatFailed": "Échec du chargement du chat : {message}",
|
||||||
"toolResultFailed": "Échec de l'envoi du résultat de l'outil : {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": {
|
"history": {
|
||||||
"title": "Historique des chats",
|
"title": "Historique des chats",
|
||||||
"newChat": "Démarrer un nouveau chat",
|
"newChat": "Démarrer un nouveau chat",
|
||||||
|
|||||||
@@ -630,6 +630,17 @@
|
|||||||
"loadHistoryFailed": "載入歷史失敗:{message}",
|
"loadHistoryFailed": "載入歷史失敗:{message}",
|
||||||
"loadChatFailed": "載入會話失敗:{message}",
|
"loadChatFailed": "載入會話失敗:{message}",
|
||||||
"toolResultFailed": "工具結果回傳失敗:{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": {
|
"history": {
|
||||||
"title": "歷史會話",
|
"title": "歷史會話",
|
||||||
"newChat": "開始新會話",
|
"newChat": "開始新會話",
|
||||||
|
|||||||
@@ -631,6 +631,17 @@
|
|||||||
"loadHistoryFailed": "加载历史失败:{message}",
|
"loadHistoryFailed": "加载历史失败:{message}",
|
||||||
"loadChatFailed": "加载会话失败:{message}",
|
"loadChatFailed": "加载会话失败:{message}",
|
||||||
"toolResultFailed": "工具结果回传失败:{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": {
|
"history": {
|
||||||
"title": "历史会话",
|
"title": "历史会话",
|
||||||
"newChat": "开始新会话",
|
"newChat": "开始新会话",
|
||||||
|
|||||||
+119
-6
@@ -34,6 +34,7 @@ import {
|
|||||||
executeFrontendTool,
|
executeFrontendTool,
|
||||||
listChats,
|
listChats,
|
||||||
listMessages,
|
listMessages,
|
||||||
|
RiskyDeviceChange,
|
||||||
sendToolResult,
|
sendToolResult,
|
||||||
streamQuestion,
|
streamQuestion,
|
||||||
updateMessageApplied,
|
updateMessageApplied,
|
||||||
@@ -84,6 +85,11 @@ function formatText(template: string, vars: Record<string, string | number>): st
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDb(n: number): string {
|
||||||
|
const rounded = Math.round(n * 10) / 10;
|
||||||
|
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
type ChatUIMessage = {
|
type ChatUIMessage = {
|
||||||
kind: "chat";
|
kind: "chat";
|
||||||
id: string;
|
id: string;
|
||||||
@@ -243,6 +249,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
const [historyOpen, setHistoryOpen] = useState(false);
|
||||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||||
const [pendingApplyMsg, setPendingApplyMsg] = useState<OptimizeUIMessage | null>(null);
|
const [pendingApplyMsg, setPendingApplyMsg] = useState<OptimizeUIMessage | null>(null);
|
||||||
|
const [riskyConfirm, setRiskyConfirm] = useState<RiskyDeviceChange | null>(null);
|
||||||
const [chats, setChats] = useState<ChatRead[]>([]);
|
const [chats, setChats] = useState<ChatRead[]>([]);
|
||||||
// 初次加载历史会话期间,避免先闪一下欢迎页再切到消息
|
// 初次加载历史会话期间,避免先闪一下欢迎页再切到消息
|
||||||
const [initializing, setInitializing] = useState(true);
|
const [initializing, setInitializing] = useState(true);
|
||||||
@@ -251,6 +258,23 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const initialLoadRef = useRef(false);
|
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<boolean>((resolve) => {
|
||||||
|
riskyConfirmRef.current?.resolve(false);
|
||||||
|
riskyConfirmRef.current = { resolve };
|
||||||
|
setRiskyConfirm(risk);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isEmpty = messages.length === 0;
|
const isEmpty = messages.length === 0;
|
||||||
|
|
||||||
@@ -263,7 +287,11 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
|
|
||||||
// ── 组件卸载时取消请求 ──
|
// ── 组件卸载时取消请求 ──
|
||||||
useEffect(() => {
|
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(
|
const loadChat = useCallback(
|
||||||
async (id: string, opts?: { closePanel?: boolean }) => {
|
async (id: string, opts?: { closePanel?: boolean }) => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
settleRiskyConfirm(false);
|
||||||
try {
|
try {
|
||||||
const rows = await listMessages(id);
|
const rows = await listMessages(id);
|
||||||
setMessages(mapRowsToUI(rows));
|
setMessages(mapRowsToUI(rows));
|
||||||
@@ -330,7 +360,7 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
toast.error(formatText(aiText.loadChatFailed, { message: e?.message ?? e }));
|
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(() => {
|
const newChat = useCallback(() => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
|
settleRiskyConfirm(false);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setChatId(null);
|
setChatId(null);
|
||||||
setInput("");
|
setInput("");
|
||||||
setHistoryOpen(false);
|
setHistoryOpen(false);
|
||||||
}, []);
|
}, [settleRiskyConfirm]);
|
||||||
|
|
||||||
// ── 清空当前会话 ──
|
// ── 清空当前会话 ──
|
||||||
const clearCurrent = useCallback(async () => {
|
const clearCurrent = useCallback(async () => {
|
||||||
@@ -515,7 +546,11 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
pending: false,
|
pending: false,
|
||||||
tools: [...(m.tools ?? []), { id: block.id, name: block.name }],
|
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) => ({
|
updateAssistant((m) => ({
|
||||||
...m,
|
...m,
|
||||||
tools: (m.tools ?? []).map((t) =>
|
tools: (m.tools ?? []).map((t) =>
|
||||||
@@ -568,11 +603,12 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
};
|
};
|
||||||
void buildAndStream();
|
void buildAndStream();
|
||||||
},
|
},
|
||||||
[aiText, api, chatId, deviceState, mac, refreshCurrentChat, sending],
|
[aiText, api, chatId, confirmRiskyChange, deviceState, mac, refreshCurrentChat, sending],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
const stop = useCallback(() => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
|
settleRiskyConfirm(false);
|
||||||
setSending(false);
|
setSending(false);
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
@@ -581,7 +617,40 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
: m,
|
: 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(
|
const onSubmit = useCallback(
|
||||||
(e: React.FormEvent) => {
|
(e: React.FormEvent) => {
|
||||||
@@ -1033,6 +1102,50 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={riskyConfirm !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
const accepted = riskyAcceptRef.current;
|
||||||
|
settleRiskyConfirm(accepted);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent className="top-[42%] z-[80] border-white/10 bg-zinc-900 text-white sm:max-w-md">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-white">
|
||||||
|
{riskyConfirmCopy?.title}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="space-y-2 text-white/60">
|
||||||
|
{(riskyConfirmCopy?.parts ?? []).map((part, i) => (
|
||||||
|
<span key={i} className="block">
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel
|
||||||
|
className="border-white/20 bg-transparent text-white hover:bg-white/10"
|
||||||
|
onClick={() => toast.info(aiText.riskyConfirm.declined)}
|
||||||
|
>
|
||||||
|
{riskyConfirmCopy?.cancel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-[#00FFF6] text-black hover:bg-[#00FFF6]/90 focus-visible:ring-[#00FFF6]"
|
||||||
|
onPointerDown={() => {
|
||||||
|
riskyAcceptRef.current = true;
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
riskyAcceptRef.current = true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{riskyConfirmCopy?.confirm}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
{/* ── 对比对话框 ── */}
|
{/* ── 对比对话框 ── */}
|
||||||
{compareMsg && (
|
{compareMsg && (
|
||||||
<CompareDialog
|
<CompareDialog
|
||||||
|
|||||||
Reference in New Issue
Block a user