thread-master/apps/web/src/pages/SettingsPage.tsx

390 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 + 同一 modelsave 回傳仍含 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>
</>
);
}