feat(ai): 大幅提高音量前增加二次确认

避免 AI 突然拉高音量或打开直通,需用户确认后再写入设备。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
fucheng
2026-09-11 16:41:58 +08:00
parent 8db56e2211
commit 07b9312148
8 changed files with 307 additions and 13 deletions
+119 -6
View File
@@ -34,6 +34,7 @@ import {
executeFrontendTool,
listChats,
listMessages,
RiskyDeviceChange,
sendToolResult,
streamQuestion,
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 = {
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<string | null>(null);
const [pendingApplyMsg, setPendingApplyMsg] = useState<OptimizeUIMessage | null>(null);
const [riskyConfirm, setRiskyConfirm] = useState<RiskyDeviceChange | null>(null);
const [chats, setChats] = useState<ChatRead[]>([]);
// 初次加载历史会话期间,避免先闪一下欢迎页再切到消息
const [initializing, setInitializing] = useState(true);
@@ -251,6 +258,23 @@ export default function AIPage({ variant = "page", onClose }: AIPageProps = {})
const abortRef = useRef<AbortController | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(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<boolean>((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 = {})
</AlertDialogContent>
</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 && (
<CompareDialog