[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
211
components/settings/tabs/ai/WebSearchSettings.tsx
Normal file
211
components/settings/tabs/ai/WebSearchSettings.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import type { WebSearchConfig, WebSearchProviderId } from "../../../../infrastructure/ai/types";
|
||||
import { WEB_SEARCH_PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
|
||||
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { Select, SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
|
||||
|
||||
const SEARCH_ICON_PATHS: Record<WebSearchProviderId, string> = {
|
||||
tavily: "/ai/search/tavily.svg",
|
||||
exa: "/ai/search/exa.png",
|
||||
bocha: "/ai/search/bocha.webp",
|
||||
zhipu: "/ai/search/zhipu.png",
|
||||
searxng: "/ai/search/searxng.svg",
|
||||
};
|
||||
|
||||
const SearchProviderIcon: React.FC<{ providerId: WebSearchProviderId }> = ({ providerId }) => (
|
||||
<img
|
||||
src={SEARCH_ICON_PATHS[providerId]}
|
||||
alt=""
|
||||
className="w-4 h-4 shrink-0"
|
||||
/>
|
||||
);
|
||||
|
||||
const PROVIDER_OPTIONS: Array<{ value: WebSearchProviderId; label: string; icon: React.ReactNode }> = Object.entries(
|
||||
WEB_SEARCH_PROVIDER_PRESETS,
|
||||
).map(([id, preset]) => ({
|
||||
value: id as WebSearchProviderId,
|
||||
label: preset.name,
|
||||
icon: <SearchProviderIcon providerId={id as WebSearchProviderId} />,
|
||||
}));
|
||||
|
||||
export const WebSearchSettings: React.FC<{
|
||||
webSearchConfig: WebSearchConfig | null;
|
||||
setWebSearchConfig: (config: WebSearchConfig | null) => void;
|
||||
}> = ({ webSearchConfig, setWebSearchConfig }) => {
|
||||
const { t } = useI18n();
|
||||
const [apiKeyInput, setApiKeyInput] = useState("");
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
|
||||
const config = useMemo(() => webSearchConfig ?? {
|
||||
providerId: "tavily" as WebSearchProviderId,
|
||||
enabled: false,
|
||||
maxResults: 5,
|
||||
}, [webSearchConfig]);
|
||||
|
||||
// Ref to always read the latest config in async callbacks (avoids stale closure)
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
|
||||
const preset = WEB_SEARCH_PROVIDER_PRESETS[config.providerId];
|
||||
|
||||
// Decrypt API key on mount or when provider changes (with cancellation guard)
|
||||
const decryptSeqRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (config.apiKey) {
|
||||
const seq = ++decryptSeqRef.current;
|
||||
setIsDecrypting(true);
|
||||
decryptField(config.apiKey)
|
||||
.then((decrypted) => {
|
||||
if (decryptSeqRef.current === seq) setApiKeyInput(decrypted ?? "");
|
||||
})
|
||||
.catch(() => {
|
||||
if (decryptSeqRef.current === seq) setApiKeyInput(config.apiKey ?? "");
|
||||
})
|
||||
.finally(() => {
|
||||
if (decryptSeqRef.current === seq) setIsDecrypting(false);
|
||||
});
|
||||
} else {
|
||||
decryptSeqRef.current++;
|
||||
setApiKeyInput("");
|
||||
setIsDecrypting(false);
|
||||
}
|
||||
}, [config.apiKey, config.providerId]);
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<WebSearchConfig>) => {
|
||||
setWebSearchConfig({ ...configRef.current, ...updates });
|
||||
},
|
||||
[setWebSearchConfig],
|
||||
);
|
||||
|
||||
const handleProviderChange = useCallback(
|
||||
(val: string) => {
|
||||
const providerId = val as WebSearchProviderId;
|
||||
const newPreset = WEB_SEARCH_PROVIDER_PRESETS[providerId];
|
||||
setWebSearchConfig({
|
||||
...configRef.current,
|
||||
providerId,
|
||||
apiKey: undefined,
|
||||
apiHost: newPreset.defaultApiHost || undefined,
|
||||
});
|
||||
setApiKeyInput("");
|
||||
},
|
||||
[setWebSearchConfig],
|
||||
);
|
||||
|
||||
// Sequence counter for blur saves — prevents out-of-order encryption results
|
||||
const blurSeqRef = useRef(0);
|
||||
const handleApiKeyBlur = useCallback(async () => {
|
||||
if (!apiKeyInput.trim()) {
|
||||
blurSeqRef.current++;
|
||||
updateConfig({ apiKey: undefined });
|
||||
return;
|
||||
}
|
||||
const seq = ++blurSeqRef.current;
|
||||
const providerAtBlur = configRef.current.providerId;
|
||||
const encrypted = await encryptField(apiKeyInput.trim());
|
||||
// Only apply if this is still the latest blur and provider hasn't changed
|
||||
if (blurSeqRef.current === seq && configRef.current.providerId === providerAtBlur) {
|
||||
updateConfig({ apiKey: encrypted });
|
||||
}
|
||||
}, [apiKeyInput, updateConfig]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t("ai.webSearch.title")}>
|
||||
<SettingCard divided>
|
||||
<SettingRow
|
||||
anchorId="ai-web-search-enable"
|
||||
label={t("ai.webSearch.enable")}
|
||||
description={t("ai.webSearch.enable.description")}
|
||||
>
|
||||
<Toggle
|
||||
checked={config.enabled}
|
||||
onChange={(enabled) => updateConfig({ enabled })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* Provider */}
|
||||
<SettingRow
|
||||
anchorId="ai-web-search-provider"
|
||||
label={t("ai.webSearch.provider")}
|
||||
description={t("ai.webSearch.provider.description")}
|
||||
>
|
||||
<Select
|
||||
value={config.providerId}
|
||||
options={PROVIDER_OPTIONS}
|
||||
onChange={handleProviderChange}
|
||||
className="w-48"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* API Key (hidden for SearXNG) */}
|
||||
{preset.requiresApiKey && (
|
||||
<SettingRow
|
||||
label={t("ai.webSearch.apiKey")}
|
||||
description={t("ai.webSearch.apiKey.description")}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={isDecrypting ? "" : apiKeyInput}
|
||||
placeholder={isDecrypting ? t("ai.providers.apiKey.decrypting") : t("ai.webSearch.apiKey.placeholder")}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
onBlur={() => void handleApiKeyBlur()}
|
||||
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
disabled={isDecrypting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="p-1.5 rounded hover:bg-muted text-muted-foreground"
|
||||
>
|
||||
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
{/* API Host */}
|
||||
<SettingRow
|
||||
label={t("ai.webSearch.apiHost")}
|
||||
description={
|
||||
config.providerId === "searxng"
|
||||
? t("ai.webSearch.apiHost.searxngDescription")
|
||||
: t("ai.webSearch.apiHost.description")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={config.apiHost ?? preset.defaultApiHost}
|
||||
onChange={(e) => updateConfig({ apiHost: e.target.value || undefined })}
|
||||
placeholder={preset.defaultApiHost || "https://..."}
|
||||
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* Max Results */}
|
||||
<SettingRow
|
||||
label={t("ai.webSearch.maxResults")}
|
||||
description={t("ai.webSearch.maxResults.description")}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
value={config.maxResults ?? 5}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value, 10);
|
||||
if (!isNaN(val) && val >= 1 && val <= 20) {
|
||||
updateConfig({ maxResults: val });
|
||||
}
|
||||
}}
|
||||
min={1}
|
||||
max={20}
|
||||
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user