Files
nanobot/webui/src/components/settings/capabilities/WebSettings.tsx
T

294 lines
11 KiB
TypeScript
Raw Normal View History

import type { Dispatch, SetStateAction } from "react";
import { Eye, EyeOff, Pencil } from "lucide-react";
import { useTranslation } from "react-i18next";
import { ProviderPicker } from "@/components/settings/shared/ModelControls";
import {
CapabilityInstallNotice,
NumberInput,
RestartSettingsFooter,
SettingsGroup,
SettingsRow,
SettingsSectionTitle,
StatusPill,
} from "@/components/settings/shared/SettingsControls";
import { ToggleButton } from "@/components/settings/ToggleButton";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type {
NanobotFeatureInfo,
SettingsPayload,
WebSearchSettingsUpdate,
} from "@/lib/types";
export const DEFAULT_WEB_SEARCH_FORM: WebSearchSettingsUpdate = {
provider: "duckduckgo",
apiKey: "",
baseUrl: "",
maxResults: 5,
timeout: 30,
useJinaReader: true,
};
export function webSearchFormFromPayload(
payload: SettingsPayload,
previous?: WebSearchSettingsUpdate,
): WebSearchSettingsUpdate {
return {
provider: payload.web_search.provider,
apiKey: previous?.provider === payload.web_search.provider ? previous.apiKey ?? "" : "",
baseUrl: payload.web_search.base_url ?? "",
maxResults: payload.web_search.max_results,
timeout: payload.web_search.timeout,
useJinaReader: payload.web.fetch.use_jina_reader,
};
}
type WebSearchProviderOption = SettingsPayload["web_search"]["providers"][number];
export function webSearchProviderAcceptsApiKey(provider?: WebSearchProviderOption): boolean {
return provider?.credential === "api_key" || provider?.credential === "optional_api_key";
}
export function webSearchProviderRequiresApiKey(provider?: WebSearchProviderOption): boolean {
return provider?.credential === "api_key";
}
export function WebSettings({
settings,
form,
keyVisible,
keyEditing,
saving,
onChangeForm,
onChangeProvider,
onToggleKey,
onToggleKeyEditing,
onReset,
onSave,
showBrandLogos,
onRestart,
isRestarting,
requiresRestartPending,
olostepFeature,
olostepInstalling,
capabilityError,
}: {
settings: SettingsPayload;
form: WebSearchSettingsUpdate;
keyVisible: boolean;
keyEditing: boolean;
saving: boolean;
onChangeForm: Dispatch<SetStateAction<WebSearchSettingsUpdate>>;
onChangeProvider: (provider: string) => void;
onToggleKey: () => void;
onToggleKeyEditing: () => void;
onReset: () => void;
onSave: () => void;
showBrandLogos: boolean;
onRestart?: () => void;
isRestarting?: boolean;
requiresRestartPending: boolean;
olostepFeature?: NanobotFeatureInfo;
olostepInstalling: boolean;
capabilityError: string | null;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const selectedProvider =
settings.web_search.providers.find((provider) => provider.name === form.provider) ??
settings.web_search.providers[0];
const hasExistingSecret =
webSearchProviderAcceptsApiKey(selectedProvider) &&
form.provider === settings.web_search.provider &&
!!settings.web_search.api_key_hint;
const showKeyInput = webSearchProviderAcceptsApiKey(selectedProvider) && (!hasExistingSecret || keyEditing);
const apiKey = form.apiKey?.trim() ?? "";
const baseUrl = form.baseUrl?.trim() ?? "";
const effectiveJinaReader = form.useJinaReader ?? settings.web.fetch.use_jina_reader;
const dirty =
form.provider !== settings.web_search.provider ||
apiKey.length > 0 ||
baseUrl !== (settings.web_search.base_url ?? "") ||
form.maxResults !== settings.web_search.max_results ||
form.timeout !== settings.web_search.timeout ||
effectiveJinaReader !== settings.web.fetch.use_jina_reader;
const jinaReaderDirty = effectiveJinaReader !== settings.web.fetch.use_jina_reader;
const missingCredential =
webSearchProviderRequiresApiKey(selectedProvider)
? !apiKey && !hasExistingSecret
: selectedProvider?.credential === "base_url"
? !baseUrl
: false;
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>{tx("settings.sections.webSearch", "Web search")}</SettingsSectionTitle>
{form.provider === "olostep" && olostepFeature && !olostepFeature.installed ? (
<div className="mb-3">
<CapabilityInstallNotice
title={tx("settings.capabilities.searchSupport", "Search provider support")}
description={tx(
"settings.capabilities.searchInstallOnSave",
"Olostep support will be installed automatically when you save.",
)}
installing={olostepInstalling}
/>
</div>
) : null}
{capabilityError ? (
<p className="mb-3 text-[12px] text-destructive">{capabilityError}</p>
) : null}
<SettingsGroup>
<SettingsRow title={t("settings.byok.webSearch.provider")}>
<ProviderPicker
providers={settings.web_search.providers}
value={form.provider}
emptyLabel={t("settings.byok.webSearch.selectProvider")}
showProviderLogos={showBrandLogos}
onChange={onChangeProvider}
/>
</SettingsRow>
{selectedProvider?.credential === "none" ? (
<SettingsRow title={t("settings.byok.webSearch.credentials")}>
<StatusPill tone="success">{t("settings.byok.webSearch.noCredentialRequired")}</StatusPill>
</SettingsRow>
) : null}
{webSearchProviderAcceptsApiKey(selectedProvider) ? (
<SettingsRow
title={t("settings.byok.apiKey")}
description={t("settings.byok.webSearch.apiKeyHelp")}
>
<div className="relative w-[280px] max-w-full">
{showKeyInput ? (
<>
<Input
type={keyVisible ? "text" : "password"}
value={form.apiKey ?? ""}
onChange={(event) =>
onChangeForm((prev) => ({ ...prev, apiKey: event.target.value }))
}
placeholder={
hasExistingSecret
? t("settings.byok.apiKeyConfiguredPlaceholder")
: t("settings.byok.apiKeyPlaceholder")
}
className="h-9 rounded-full pr-11 text-[13px]"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onToggleKey}
aria-label={
keyVisible ? t("settings.byok.hideApiKey") : t("settings.byok.showApiKey")
}
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
{keyVisible ? (
<EyeOff className="h-3.5 w-3.5" aria-hidden />
) : (
<Eye className="h-3.5 w-3.5" aria-hidden />
)}
</Button>
</>
) : (
<>
<div className="flex h-9 items-center rounded-full border border-input bg-background px-3 pr-11 text-[13px] text-muted-foreground">
{settings.web_search.api_key_hint ?? t("settings.byok.configuredKeyHint")}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onToggleKeyEditing}
aria-label={t("settings.actions.edit")}
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Pencil className="h-3.5 w-3.5" aria-hidden />
</Button>
</>
)}
</div>
</SettingsRow>
) : null}
{selectedProvider?.credential === "base_url" ? (
<SettingsRow
title={t("settings.byok.webSearch.baseUrl")}
description={t("settings.byok.webSearch.baseUrlHelp")}
>
<Input
value={form.baseUrl ?? ""}
onChange={(event) =>
onChangeForm((prev) => ({ ...prev, baseUrl: event.target.value }))
}
placeholder={t("settings.byok.webSearch.baseUrlPlaceholder")}
className="h-9 w-[280px] rounded-full text-[13px]"
/>
</SettingsRow>
) : null}
</SettingsGroup>
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.webBehavior", "Behavior")}</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow title={tx("settings.rows.maxResults", "Max results")}>
<NumberInput
value={form.maxResults ?? settings.web_search.max_results}
min={1}
max={10}
onChange={(maxResults) => onChangeForm((prev) => ({ ...prev, maxResults }))}
/>
</SettingsRow>
<SettingsRow title={tx("settings.rows.timeout", "Timeout")}>
<NumberInput
value={form.timeout ?? settings.web_search.timeout}
min={1}
max={120}
onChange={(timeout) => onChangeForm((prev) => ({ ...prev, timeout }))}
suffix="s"
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.jinaReader", "Jina reader")}
description={tx("settings.help.jinaReader", "Use Jina Reader for web_fetch when available.")}
>
<ToggleButton
checked={effectiveJinaReader}
onChange={(useJinaReader) => onChangeForm((prev) => ({ ...prev, useJinaReader }))}
ariaLabel={tx("settings.rows.jinaReader", "Jina reader")}
label={effectiveJinaReader ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<RestartSettingsFooter
dirty={dirty}
saving={saving}
pendingRestart={requiresRestartPending}
disabled={missingCredential}
message={
missingCredential
? t("settings.byok.webSearch.missingCredential")
: requiresRestartPending && !dirty
? tx("settings.status.savedRestartApply", "Saved. Restart when ready.")
: jinaReaderDirty
? tx("settings.status.restartAfterSaving", "Save changes, then restart when ready.")
: dirty
? t("settings.byok.webSearch.saveHint")
: undefined
}
onSave={onSave}
onRestart={onRestart}
onReset={onReset}
isRestarting={isRestarting}
/>
</SettingsGroup>
</section>
</div>
);
}