390 lines
13 KiB
TypeScript
390 lines
13 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { PageHeader } from "../components/layout/PageHeader";
|
||
import { DevModeSessionCard } from "../components/settings/DevModeSessionCard";
|
||
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";
|
||
|
||
/** 可選 provider;文案/研究/延伸共用目前選中的那一組 */
|
||
const AI_PROVIDERS = [
|
||
{ id: "xai", label: "xAI" },
|
||
{ id: "opencode-go", label: "OpenCode Go" },
|
||
] as const;
|
||
|
||
type AiProviderId = (typeof AI_PROVIDERS)[number]["id"];
|
||
|
||
function normalizeProvider(id: string): AiProviderId {
|
||
return id === "opencode-go" ? "opencode-go" : "xai";
|
||
}
|
||
|
||
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 [exaKey, setExaKey] = useState("");
|
||
const [models, setModels] = useState<string[]>([]);
|
||
const [message, setMessage] = useState("");
|
||
const [error, setError] = useState("");
|
||
const [busy, setBusy] = useState("");
|
||
const [draftLocale, setDraftLocale] = useState<AppLocale>(locale);
|
||
const [draftCurrency, setDraftCurrency] = useState<AppCurrency>(currency);
|
||
|
||
useEffect(() => {
|
||
setDraftLocale(locale);
|
||
setDraftCurrency(currency);
|
||
}, [locale, currency]);
|
||
|
||
/** 進頁:一支 GetAi 同時拿設定 + 模型清單 + 上次選定 model */
|
||
useEffect(() => {
|
||
void (async () => {
|
||
const a = await repos.settings.getAi();
|
||
const p = await repos.settings.getPlacement();
|
||
const provider = normalizeProvider(a.provider);
|
||
const list = a.models?.length
|
||
? a.models
|
||
: [a.selected_model || a.model].filter(Boolean) as string[];
|
||
const model =
|
||
a.selected_model && list.includes(a.selected_model)
|
||
? a.selected_model
|
||
: list.includes(a.model)
|
||
? a.model
|
||
: list[0] || a.model;
|
||
setModels(list);
|
||
setAi({
|
||
...a,
|
||
provider,
|
||
model,
|
||
research_provider: provider,
|
||
research_model: model,
|
||
selected_model: model,
|
||
models: list,
|
||
});
|
||
setPlacement(p);
|
||
if (a.models_error) {
|
||
setError(a.models_error);
|
||
}
|
||
})();
|
||
}, [repos.settings]);
|
||
|
||
/** 換 provider:同一支 getAi(?provider=) 帶回 models + selected_model */
|
||
async function onProviderChange(nextId: string) {
|
||
if (!ai) return;
|
||
const provider = normalizeProvider(nextId);
|
||
setBusy("ai");
|
||
setError("");
|
||
try {
|
||
const a = await repos.settings.getAi(provider);
|
||
const list = a.models?.length
|
||
? a.models
|
||
: [a.selected_model || a.model].filter(Boolean) as string[];
|
||
const model =
|
||
a.selected_model && list.includes(a.selected_model)
|
||
? a.selected_model
|
||
: list.includes(ai.model)
|
||
? ai.model
|
||
: list[0] || ai.model;
|
||
setModels(list);
|
||
setAi({
|
||
...ai,
|
||
...a,
|
||
provider,
|
||
model,
|
||
research_provider: provider,
|
||
research_model: model,
|
||
selected_model: model,
|
||
models: list,
|
||
});
|
||
if (a.models_error) setError(a.models_error);
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : t("common.error"));
|
||
} finally {
|
||
setBusy("");
|
||
}
|
||
}
|
||
|
||
async function saveAi() {
|
||
if (!ai) return;
|
||
setBusy("ai");
|
||
setError("");
|
||
try {
|
||
const provider = normalizeProvider(ai.provider);
|
||
// 同一 provider + 同一 model;save 回傳仍含 models
|
||
const next = await repos.settings.saveAi({
|
||
provider,
|
||
model: ai.model,
|
||
research_provider: provider,
|
||
research_model: ai.model,
|
||
api_key: apiKey || undefined,
|
||
research_api_key: apiKey || undefined,
|
||
});
|
||
const list = next.models?.length ? next.models : models;
|
||
const model = next.selected_model || next.model || ai.model;
|
||
setModels(list);
|
||
setAi({
|
||
...next,
|
||
provider: normalizeProvider(next.provider),
|
||
model,
|
||
research_provider: normalizeProvider(next.provider),
|
||
research_model: model,
|
||
selected_model: model,
|
||
models: list,
|
||
});
|
||
setApiKey("");
|
||
setMessage(t("settings.aiSaved"));
|
||
if (next.models_error) setError(next.models_error);
|
||
refresh();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : t("common.error"));
|
||
} finally {
|
||
setBusy("");
|
||
}
|
||
}
|
||
|
||
async function savePlacement() {
|
||
if (!placement) return;
|
||
setBusy("placement");
|
||
try {
|
||
const next = await repos.settings.savePlacement({
|
||
...placement,
|
||
web_search_provider: "exa",
|
||
exa_api_key: exaKey || undefined,
|
||
});
|
||
setPlacement(next);
|
||
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"));
|
||
}
|
||
|
||
const modelOptions = models.length ? models : [ai?.model || ai?.selected_model].filter(Boolean) as string[];
|
||
|
||
return (
|
||
<>
|
||
<PageHeader title={t("settings.title")} />
|
||
|
||
{message ? (
|
||
<p className="hb-banner-ok" role="status">
|
||
{message}
|
||
</p>
|
||
) : null}
|
||
{error ? (
|
||
<p className="hb-form-error" role="alert">
|
||
{error}
|
||
</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">
|
||
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
||
{t("settings.aiUnifiedHint")}
|
||
</p>
|
||
<Select
|
||
label={t("settings.provider")}
|
||
value={normalizeProvider(ai.provider)}
|
||
onChange={(e) => void onProviderChange(e.target.value)}
|
||
>
|
||
{AI_PROVIDERS.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.label}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
<Select
|
||
label={t("settings.model")}
|
||
value={ai.model}
|
||
disabled={busy === "ai" && models.length === 0}
|
||
onChange={(e) => {
|
||
const provider = normalizeProvider(ai.provider);
|
||
setAi({
|
||
...ai,
|
||
provider,
|
||
model: e.target.value,
|
||
research_provider: provider,
|
||
research_model: e.target.value,
|
||
});
|
||
}}
|
||
>
|
||
{modelOptions.map((m) => (
|
||
<option key={m} value={m}>
|
||
{m}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
<Input
|
||
label={t("settings.apiKey")}
|
||
type="password"
|
||
value={apiKey}
|
||
onChange={(e) => setApiKey(e.target.value)}
|
||
placeholder={
|
||
ai.api_key_configured || ai.research_api_key_configured
|
||
? t("settings.configured")
|
||
: ai.platform_key_available
|
||
? t("settings.platformKeyOk")
|
||
: t("settings.notConfigured")
|
||
}
|
||
/>
|
||
{ai.models_error ? (
|
||
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
||
{t("settings.modelsHint")}: {ai.models_error}
|
||
</p>
|
||
) : ai.models_from_cache ? (
|
||
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}>
|
||
{t("settings.modelsCached")}
|
||
</p>
|
||
) : null}
|
||
<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">
|
||
<Input label={t("settings.searchProvider")} value="Exa" readOnly disabled />
|
||
<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")
|
||
}
|
||
/>
|
||
<Select
|
||
label={t("settings.expand")}
|
||
value={placement.expand_strategy === "brave" ? "hybrid" : placement.expand_strategy}
|
||
onChange={(e) =>
|
||
setPlacement({
|
||
...placement,
|
||
web_search_provider: "exa",
|
||
expand_strategy: e.target.value as PlacementSettings["expand_strategy"],
|
||
})
|
||
}
|
||
>
|
||
<option value="llm">LLM</option>
|
||
<option value="hybrid">Hybrid</option>
|
||
</Select>
|
||
<div className="hb-stack hb-stack--tight">
|
||
<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>
|
||
<p className="text-muted hb-settings-dev-hint">{t("settings.devModeHint")}</p>
|
||
</div>
|
||
{placement.dev_mode_enabled ? (
|
||
<DevModeSessionCard
|
||
onMessage={(msg) => {
|
||
setError("");
|
||
setMessage(msg);
|
||
}}
|
||
onError={(msg) => {
|
||
setMessage("");
|
||
setError(msg);
|
||
}}
|
||
/>
|
||
) : null}
|
||
<Button type="button" onClick={() => void savePlacement()} disabled={busy === "placement"}>
|
||
{t("common.save")}
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<p className="text-muted">{t("common.loading")}</p>
|
||
)}
|
||
</Card>
|
||
</>
|
||
);
|
||
}
|