源码
packages/desktop/src/renderer/WebSearchSettings.tsx
import * as Dialog from "@radix-ui/react-dialog";
import * as Menu from "@radix-ui/react-dropdown-menu";
import { desktopPluginIds } from "@zpi-dev/builtin-plugins";
import { ActionHint } from "@zpi-dev/ui";
import { Trash2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ProviderCredentialInput } from "./ProviderCredentialInput.tsx";
import { ProviderHeadingActions } from "./ProviderHeadingActions.tsx";
import { ProviderLogo } from "./ProviderLogo.tsx";
import { SettingsHelp } from "./SettingsHelp.tsx";
import { useStore } from "./store.ts";
import { unwrap } from "./unwrap.ts";
import "./web-search-settings.css";
export function WebSearchSettings() {
const settings = useStore((state) => state.settings?.webSearch);
const globalEnabled = useStore((state) => state.settings?.plugins?.enabled[desktopPluginIds.web]);
// Only genuine input changes populate the replacement. A revealed saved key is separate and transient.
const [apiKey, setApiKey] = useState("");
const [editing, setEditing] = useState(false);
const [revealed, setRevealed] = useState<{ settings: typeof settings; key: string }>();
const [visible, setVisible] = useState(false);
const [revealing, setRevealing] = useState(false);
const [saving, setSaving] = useState(false);
const [removing, setRemoving] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const inFlight = useRef(false);
const revealRevision = useRef(0);
const revealIntent = useRef(false);
const draftEditing = useRef(editing);
draftEditing.current = editing;
const keyInput = useRef<HTMLInputElement>(null);
const shownKey =
revealed?.settings === settings && !settings?.credentialsLocked ? revealed?.key : undefined;
const masked = Boolean(settings?.hasApiKey && !editing && shownKey === undefined);
const keyNote = settings?.credentialsLocked
? "保存的 API Key 尚未解锁,联网服务暂不可用。解锁系统密钥存储后重启应用。"
: !settings?.hasApiKey
? "尚未配置可用的 API Key,联网服务不可用。"
: !settings.enabled
? "DeepSeek 服务已停用,保存的 API Key 将保留。"
: undefined;
const trimmed = apiKey.trim();
const replacement = trimmed && !/^[*•●]+$/u.test(trimmed) ? trimmed : "";
const clearReveal = useCallback(() => {
revealRevision.current++;
revealIntent.current = false;
setRevealed(undefined);
setRevealing(false);
setVisible(false);
}, []);
useEffect(() => {
if (!draftEditing.current) keyInput.current?.blur();
clearReveal();
// Every new public settings snapshot invalidates an outstanding read, including same-length replacements.
// An unrelated settings refresh must not discard a replacement the user is still typing.
}, [settings, clearReveal]);
useEffect(() => {
const hide = () => {
keyInput.current?.blur();
clearReveal();
};
const visibility = () => {
if (document.hidden) hide();
};
window.addEventListener("blur", hide);
document.addEventListener("visibilitychange", visibility);
return () => {
revealRevision.current++;
window.removeEventListener("blur", hide);
document.removeEventListener("visibilitychange", visibility);
};
}, [clearReveal]);
const resetDraft = () => {
keyInput.current?.blur();
setApiKey("");
setEditing(false);
clearReveal();
};
const cancelEdit = () => {
if (inFlight.current) return;
resetDraft();
setError("");
setNotice("");
};
const toggleVisibility = async () => {
if (inFlight.current || removing || settings?.credentialsLocked) return;
if (revealIntent.current) {
if (!editing) keyInput.current?.blur();
clearReveal();
return;
}
revealIntent.current = true;
if (editing || !settings?.hasApiKey) {
setVisible(true);
return;
}
const revision = ++revealRevision.current;
setVisible(true);
setRevealing(true);
setError("");
try {
const value = unwrap(await window.zpi.getWebSearchCredentials());
if (revision !== revealRevision.current) return;
setRevealed({ settings, key: value.apiKey });
setRevealing(false);
} catch {
if (revision !== revealRevision.current) return;
clearReveal();
setError("无法读取已保存的 API Key,请解锁系统密钥存储后重试。");
}
};
const save = async (remove = false, enabled?: boolean) => {
if (
inFlight.current ||
!settings ||
(settings.credentialsLocked && enabled !== false) ||
(!remove && enabled === undefined && !replacement) ||
(enabled === true && !settings.hasApiKey && !replacement)
)
return;
inFlight.current = true;
clearReveal();
setSaving(true);
setError("");
setNotice("");
try {
const next = unwrap(
await window.zpi.saveWebSearch({
provider: "deepseek",
...(enabled !== undefined ? { enabled } : {}),
...(remove ? { apiKey: "" } : replacement && enabled !== false ? { apiKey: replacement } : {}),
}),
);
useStore.setState({ settings: next });
if (enabled !== false) resetDraft();
setRemoving(false);
setNotice(
remove
? "已移除 API Key。"
: enabled === true
? "已启用 DeepSeek 联网服务。"
: enabled === false
? "已停用 DeepSeek 联网服务。"
: "已保存 API Key。",
);
} catch {
setError("无法保存联网搜索设置,请重试。");
} finally {
inFlight.current = false;
setSaving(false);
}
};
const setGlobalEnabled = async (enabled: boolean) => {
if (inFlight.current) return;
inFlight.current = true;
setSaving(true);
setError("");
clearReveal();
try {
const settings = unwrap(await window.zpi.setPluginEnabled(desktopPluginIds.web, enabled));
useStore.setState({ settings });
setNotice(enabled ? "已启用联网搜索。" : "已关闭联网搜索。");
} catch {
setError("无法保存联网搜索开关,请重试。");
} finally {
inFlight.current = false;
setSaving(false);
}
};
return (
<div className="web-search-settings">
<header className="settings-page-heading">
<div className="settings-heading-title">
<h1>联网搜索</h1>
<SettingsHelp
label="联网搜索"
text="联网搜索默认开启,配置并启用下方提供商后即可搜索和读取网页。开关在新对话首次发送时生效,已有对话保留原配置。停用提供商不改变此开关,联网工具暂不可用。"
/>
</div>
<label className="settings-enable">
<span>启用联网搜索</span>
<span className="settings-switch">
<input
type="checkbox"
role="switch"
aria-label="启用联网搜索"
aria-checked={globalEnabled ?? false}
checked={globalEnabled ?? false}
disabled={saving || removing}
onChange={(event) => void setGlobalEnabled(event.target.checked)}
/>
<span aria-hidden="true" />
</span>
</label>
</header>
<div className="settings-layout model-provider-panel web-search-provider-panel">
<nav className="provider-list" aria-label="联网搜索提供商">
<div className="provider-nav-group">
<h3>供应商</h3>
<ActionHint appearance="control" label="DeepSeek" side="right">
<button
type="button"
className="selected"
aria-label="DeepSeek"
aria-current="true"
disabled={saving || removing}
>
<ProviderLogo preset="deepseek" size={16} />
<span>DeepSeek</span>
<i
className={`provider-status ${settings?.enabled ? "enabled" : "disabled"}`}
title={settings?.enabled ? "已启用" : "已关闭"}
aria-hidden="true"
/>
</button>
</ActionHint>
</div>
</nav>
<form
className="provider-editor"
onSubmit={(event) => {
event.preventDefault();
if (!removing) void save();
}}
>
<div className="provider-details">
<header className="provider-detail-heading">
<ProviderLogo preset="deepseek" size={20} />
<h2>DeepSeek</h2>
<ProviderHeadingActions
name="DeepSeek 联网服务"
enabled={settings?.enabled ?? false}
disabled={saving || removing}
switchDisabled={
saving ||
removing ||
!settings ||
(!settings.enabled && (settings.credentialsLocked || (!settings.hasApiKey && !replacement)))
}
onEnabledChange={(enabled) => void save(false, enabled)}
>
{settings?.hasApiKey && (
<Menu.Item
className="provider-action-item provider-action-delete"
onSelect={() => {
clearReveal();
setError("");
setRemoving(true);
}}
>
<Trash2 size={14} />
移除 API Key
</Menu.Item>
)}
</ProviderHeadingActions>
</header>
<div className="provider-connection-fields">
<label htmlFor="web-search-api-key">
<span>API Key</span>
<ProviderCredentialInput
ref={keyInput}
id="web-search-api-key"
label="API Key"
visible={visible && !masked}
onToggleVisibility={() => void toggleVisibility()}
visibilityDisabled={saving || removing || settings?.credentialsLocked}
visibilityLabel={visible ? "隐藏 API Key" : "显示 API Key"}
visibilityPressed={visible}
value={editing ? apiKey : (shownKey ?? "*".repeat(settings?.apiKeyLength ?? 0))}
spellCheck={false}
autoCapitalize="none"
disabled={saving || removing || settings?.credentialsLocked}
placeholder={settings?.credentialsLocked ? "保存的凭据尚未解锁" : "输入 API Key"}
onFocus={() => {
if (inFlight.current || settings?.credentialsLocked || shownKey !== undefined) return;
clearReveal();
setEditing(true);
}}
onChange={(event) => {
if (masked || inFlight.current || settings?.credentialsLocked) return;
revealRevision.current++;
setRevealed(undefined);
setRevealing(false);
setEditing(true);
setApiKey(event.target.value);
setNotice("");
setError("");
}}
onBlur={() => {
if (editing && !apiKey.trim() && !inFlight.current) resetDraft();
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
cancelEdit();
}
}}
aria-describedby={keyNote ? "web-search-key-note" : undefined}
aria-busy={revealing}
/>
</label>
</div>
{keyNote && (
<p id="web-search-key-note" className="web-search-note" role="status">
{keyNote}
</p>
)}
{settings?.hasApiKey && settings.credentialsPersisted === false && (
<p className="web-search-storage-note" role="status">
系统加密存储不可用,API Key 仅保留在本次应用运行的内存中,重启后需重新输入。
</p>
)}
<div className="provider-footer">
{error && (
<p className="run-error" role="alert">
{error}
</p>
)}
{notice && (
<p className="run-notice" role="status">
{notice}
</p>
)}
<div className="modal-actions">
{editing && (
<button type="button" className="secondary" disabled={saving} onClick={cancelEdit}>
取消编辑
</button>
)}
<button
type="submit"
className="primary"
disabled={saving || settings?.credentialsLocked || !replacement}
>
{saving ? "保存中…" : "保存"}
</button>
</div>
</div>
</div>
</form>
</div>
<Dialog.Root
open={removing}
onOpenChange={(open) => {
if (!saving) setRemoving(open);
}}
>
<Dialog.Portal>
<Dialog.Overlay className="model-config-backdrop" />
<Dialog.Content
className="provider-delete-dialog"
onOpenAutoFocus={(event) => {
event.preventDefault();
document.querySelector<HTMLButtonElement>(".provider-delete-dialog .primary")?.focus();
}}
onKeyDownCapture={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
event.currentTarget.querySelector<HTMLButtonElement>(".primary")?.click();
}
}}
onEscapeKeyDown={(event) => {
if (saving) event.preventDefault();
}}
>
<header>
<Dialog.Title>移除 DeepSeek 联网服务的 API Key?</Dialog.Title>
<Dialog.Description>移除后联网工具将无法使用,可重新配置 API Key。</Dialog.Description>
</header>
{error && (
<div className="model-config-error" role="alert">
{error}
</div>
)}
<footer>
<Dialog.Close className="secondary" disabled={saving}>
取消<span aria-hidden="true">esc</span>
</Dialog.Close>
<button type="button" className="primary" disabled={saving} onClick={() => void save(true)}>
确认移除
</button>
</footer>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</div>
);
}