feat(ai): 大幅提高音量前增加二次确认
避免 AI 突然拉高音量或打开直通,需用户确认后再写入设备。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+119
-6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user