336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
|
|
import { useEffect, useState } from "react";
|
||
|
|
import { PageHeader } from "../components/layout/PageHeader";
|
||
|
|
import { Button, Card, Input, Select } from "../components/ui";
|
||
|
|
import { useData, useRepos } from "../data/DataContext";
|
||
|
|
import type { AiSettings, PlacementSettings } from "../domain/types";
|
||
|
|
import { useI18n } from "../i18n/I18nContext";
|
||
|
|
import { CURRENCIES, LOCALES, type AppCurrency, type AppLocale } from "../lib/i18n/types";
|
||
|
|
import type { ThemePreference } from "../lib/theme";
|
||
|
|
import { useTheme } from "../theme/ThemeContext";
|
||
|
|
|
||
|
|
export function SettingsPage() {
|
||
|
|
const repos = useRepos();
|
||
|
|
const { refresh } = useData();
|
||
|
|
const { t, locale, currency, setPrefs } = useI18n();
|
||
|
|
const { preference, setPreference } = useTheme();
|
||
|
|
|
||
|
|
const [ai, setAi] = useState<AiSettings | null>(null);
|
||
|
|
const [placement, setPlacement] = useState<PlacementSettings | null>(null);
|
||
|
|
const [apiKey, setApiKey] = useState("");
|
||
|
|
const [researchKey, setResearchKey] = useState("");
|
||
|
|
const [braveKey, setBraveKey] = useState("");
|
||
|
|
const [exaKey, setExaKey] = useState("");
|
||
|
|
const [copyModels, setCopyModels] = useState<string[]>([]);
|
||
|
|
const [researchModels, setResearchModels] = useState<string[]>([]);
|
||
|
|
const [message, setMessage] = useState("");
|
||
|
|
const [busy, setBusy] = useState("");
|
||
|
|
const [draftLocale, setDraftLocale] = useState<AppLocale>(locale);
|
||
|
|
const [draftCurrency, setDraftCurrency] = useState<AppCurrency>(currency);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
setDraftLocale(locale);
|
||
|
|
setDraftCurrency(currency);
|
||
|
|
}, [locale, currency]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
void (async () => {
|
||
|
|
setAi(await repos.settings.getAi());
|
||
|
|
setPlacement(await repos.settings.getPlacement());
|
||
|
|
})();
|
||
|
|
}, [repos.settings]);
|
||
|
|
|
||
|
|
async function loadModels(kind: "copy" | "research") {
|
||
|
|
if (!ai) return;
|
||
|
|
setBusy(kind);
|
||
|
|
try {
|
||
|
|
const provider = kind === "copy" ? ai.provider : ai.research_provider;
|
||
|
|
const models = await repos.settings.listModels(provider);
|
||
|
|
if (kind === "copy") setCopyModels(models);
|
||
|
|
else setResearchModels(models);
|
||
|
|
setMessage(t("settings.modelsLoaded", { provider }));
|
||
|
|
} finally {
|
||
|
|
setBusy("");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function saveAi() {
|
||
|
|
if (!ai) return;
|
||
|
|
setBusy("ai");
|
||
|
|
try {
|
||
|
|
const next = await repos.settings.saveAi({
|
||
|
|
...ai,
|
||
|
|
api_key: apiKey || undefined,
|
||
|
|
research_api_key: researchKey || undefined,
|
||
|
|
});
|
||
|
|
setAi(next);
|
||
|
|
setApiKey("");
|
||
|
|
setResearchKey("");
|
||
|
|
setMessage(t("settings.aiSaved"));
|
||
|
|
refresh();
|
||
|
|
} finally {
|
||
|
|
setBusy("");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function savePlacement() {
|
||
|
|
if (!placement) return;
|
||
|
|
setBusy("placement");
|
||
|
|
try {
|
||
|
|
const next = await repos.settings.savePlacement({
|
||
|
|
...placement,
|
||
|
|
brave_api_key: braveKey || undefined,
|
||
|
|
exa_api_key: exaKey || undefined,
|
||
|
|
});
|
||
|
|
setPlacement(next);
|
||
|
|
setBraveKey("");
|
||
|
|
setExaKey("");
|
||
|
|
setMessage(t("settings.searchSaved"));
|
||
|
|
refresh();
|
||
|
|
} finally {
|
||
|
|
setBusy("");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function saveLocaleCurrency() {
|
||
|
|
setPrefs({ locale: draftLocale, currency: draftCurrency });
|
||
|
|
setMessage(t("settings.localeSaved"));
|
||
|
|
}
|
||
|
|
|
||
|
|
function pickTheme(next: ThemePreference) {
|
||
|
|
setPreference(next);
|
||
|
|
setMessage(t("settings.themeSaved"));
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<PageHeader title={t("settings.title")} />
|
||
|
|
|
||
|
|
{message ? (
|
||
|
|
<p className="hb-banner-ok" role="status">
|
||
|
|
{message}
|
||
|
|
</p>
|
||
|
|
) : null}
|
||
|
|
|
||
|
|
<Card title={t("settings.appearance")}>
|
||
|
|
<div className="hb-stack">
|
||
|
|
<p className="hb-field__label" style={{ margin: 0 }}>
|
||
|
|
{t("settings.theme")}
|
||
|
|
</p>
|
||
|
|
<div className="hb-theme-picker" role="group" aria-label={t("settings.theme")}>
|
||
|
|
{(
|
||
|
|
[
|
||
|
|
["light", t("settings.themeLight")],
|
||
|
|
["dark", t("settings.themeDark")],
|
||
|
|
["system", t("settings.themeSystem")],
|
||
|
|
] as const
|
||
|
|
).map(([id, label]) => (
|
||
|
|
<button
|
||
|
|
key={id}
|
||
|
|
type="button"
|
||
|
|
className={`hb-theme-picker__btn${preference === id ? " is-active" : ""}`}
|
||
|
|
onClick={() => pickTheme(id)}
|
||
|
|
>
|
||
|
|
<span className={`hb-theme-picker__swatch hb-theme-picker__swatch--${id}`} aria-hidden />
|
||
|
|
{label}
|
||
|
|
</button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card title={t("settings.localeCurrency")}>
|
||
|
|
<div className="hb-stack">
|
||
|
|
<div className="hb-grid-2">
|
||
|
|
<Select
|
||
|
|
label={t("settings.locale")}
|
||
|
|
value={draftLocale}
|
||
|
|
onChange={(e) => setDraftLocale(e.target.value as AppLocale)}
|
||
|
|
>
|
||
|
|
{LOCALES.map((l) => (
|
||
|
|
<option key={l.id} value={l.id}>
|
||
|
|
{t(`locale.${l.id}`)}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</Select>
|
||
|
|
<Select
|
||
|
|
label={t("settings.currency")}
|
||
|
|
value={draftCurrency}
|
||
|
|
onChange={(e) => setDraftCurrency(e.target.value as AppCurrency)}
|
||
|
|
>
|
||
|
|
{CURRENCIES.map((c) => (
|
||
|
|
<option key={c.id} value={c.id}>
|
||
|
|
{t(`currency.${c.id}`)}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
<Button type="button" onClick={saveLocaleCurrency}>
|
||
|
|
{t("common.save")}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card title={t("settings.ai")}>
|
||
|
|
{ai ? (
|
||
|
|
<div className="hb-stack">
|
||
|
|
<div className="hb-grid-2">
|
||
|
|
<Select
|
||
|
|
label={t("settings.copyProvider")}
|
||
|
|
value={ai.provider}
|
||
|
|
onChange={(e) => setAi({ ...ai, provider: e.target.value })}
|
||
|
|
>
|
||
|
|
<option value="opencode-go">OpenCode Go</option>
|
||
|
|
<option value="xai">xAI</option>
|
||
|
|
</Select>
|
||
|
|
<div>
|
||
|
|
<Select
|
||
|
|
label={t("settings.copyModel")}
|
||
|
|
value={ai.model}
|
||
|
|
onChange={(e) => setAi({ ...ai, model: e.target.value })}
|
||
|
|
>
|
||
|
|
{(copyModels.length ? copyModels : [ai.model || "deepseek-v4-pro"]).map((m) => (
|
||
|
|
<option key={m} value={m}>
|
||
|
|
{m}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</Select>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
className="hb-mt-2"
|
||
|
|
disabled={busy === "copy"}
|
||
|
|
onClick={() => void loadModels("copy")}
|
||
|
|
>
|
||
|
|
{busy === "copy" ? t("settings.fetchingModels") : t("settings.fetchModels")}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
<Select
|
||
|
|
label={t("settings.researchProvider")}
|
||
|
|
value={ai.research_provider}
|
||
|
|
onChange={(e) => setAi({ ...ai, research_provider: e.target.value })}
|
||
|
|
>
|
||
|
|
<option value="opencode-go">OpenCode Go</option>
|
||
|
|
<option value="xai">xAI</option>
|
||
|
|
</Select>
|
||
|
|
<div>
|
||
|
|
<Select
|
||
|
|
label={t("settings.researchModel")}
|
||
|
|
value={ai.research_model}
|
||
|
|
onChange={(e) => setAi({ ...ai, research_model: e.target.value })}
|
||
|
|
>
|
||
|
|
{(researchModels.length ? researchModels : [ai.research_model || "deepseek-v4-pro"]).map(
|
||
|
|
(m) => (
|
||
|
|
<option key={m} value={m}>
|
||
|
|
{m}
|
||
|
|
</option>
|
||
|
|
),
|
||
|
|
)}
|
||
|
|
</Select>
|
||
|
|
<Button
|
||
|
|
type="button"
|
||
|
|
variant="ghost"
|
||
|
|
className="hb-mt-2"
|
||
|
|
disabled={busy === "research"}
|
||
|
|
onClick={() => void loadModels("research")}
|
||
|
|
>
|
||
|
|
{busy === "research" ? t("settings.fetchingModels") : t("settings.fetchModels")}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<Input
|
||
|
|
label={t("settings.copyApiKey")}
|
||
|
|
type="password"
|
||
|
|
value={apiKey}
|
||
|
|
onChange={(e) => setApiKey(e.target.value)}
|
||
|
|
placeholder={ai.api_key_configured ? t("settings.configured") : ""}
|
||
|
|
/>
|
||
|
|
<Input
|
||
|
|
label={t("settings.researchApiKey")}
|
||
|
|
type="password"
|
||
|
|
value={researchKey}
|
||
|
|
onChange={(e) => setResearchKey(e.target.value)}
|
||
|
|
placeholder={ai.research_api_key_configured ? t("settings.configured") : ""}
|
||
|
|
/>
|
||
|
|
<Button type="button" onClick={() => void saveAi()} disabled={busy === "ai"}>
|
||
|
|
{t("common.save")}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<p className="text-muted">{t("common.loading")}</p>
|
||
|
|
)}
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card title={t("settings.search")}>
|
||
|
|
{placement ? (
|
||
|
|
<div className="hb-stack">
|
||
|
|
<div className="hb-grid-2">
|
||
|
|
<Select
|
||
|
|
label={t("settings.searchProvider")}
|
||
|
|
value={placement.web_search_provider}
|
||
|
|
onChange={(e) =>
|
||
|
|
setPlacement({
|
||
|
|
...placement,
|
||
|
|
web_search_provider: e.target.value as PlacementSettings["web_search_provider"],
|
||
|
|
})
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<option value="brave">Brave</option>
|
||
|
|
<option value="exa">Exa</option>
|
||
|
|
</Select>
|
||
|
|
<Select
|
||
|
|
label={t("settings.expand")}
|
||
|
|
value={placement.expand_strategy}
|
||
|
|
onChange={(e) =>
|
||
|
|
setPlacement({
|
||
|
|
...placement,
|
||
|
|
expand_strategy: e.target.value as PlacementSettings["expand_strategy"],
|
||
|
|
})
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<option value="brave">Brave</option>
|
||
|
|
<option value="llm">LLM</option>
|
||
|
|
<option value="hybrid">Hybrid</option>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
<Input
|
||
|
|
label={t("settings.braveKey")}
|
||
|
|
type="password"
|
||
|
|
value={braveKey}
|
||
|
|
onChange={(e) => setBraveKey(e.target.value)}
|
||
|
|
placeholder={
|
||
|
|
placement.brave_api_key_configured
|
||
|
|
? t("settings.configured")
|
||
|
|
: t("settings.notConfigured")
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
<Input
|
||
|
|
label={t("settings.exaKey")}
|
||
|
|
type="password"
|
||
|
|
value={exaKey}
|
||
|
|
onChange={(e) => setExaKey(e.target.value)}
|
||
|
|
placeholder={
|
||
|
|
placement.exa_api_key_configured
|
||
|
|
? t("settings.configured")
|
||
|
|
: t("settings.notConfigured")
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
<label className="hb-check-row">
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={placement.dev_mode_enabled}
|
||
|
|
onChange={(e) => setPlacement({ ...placement, dev_mode_enabled: e.target.checked })}
|
||
|
|
/>
|
||
|
|
<span>{t("settings.devMode")}</span>
|
||
|
|
</label>
|
||
|
|
<Button type="button" onClick={() => void savePlacement()} disabled={busy === "placement"}>
|
||
|
|
{t("common.save")}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<p className="text-muted">{t("common.loading")}</p>
|
||
|
|
)}
|
||
|
|
</Card>
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|