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

364 lines
12 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 { ExtensionInstallCard } from "../components/settings/ExtensionInstallCard";
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, dataSource, setDataSource } = 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 [busy, setBusy] = useState("");
const [draftLocale, setDraftLocale] = useState<AppLocale>(locale);
const [draftCurrency, setDraftCurrency] = useState<AppCurrency>(currency);
useEffect(() => {
setDraftLocale(locale);
setDraftCurrency(currency);
}, [locale, currency]);
/** 進頁:讀目前設定 + 依目前 provider 拉模型(保留已選 model否則用預設第一個 */
useEffect(() => {
void (async () => {
const a = await repos.settings.getAi();
const p = await repos.settings.getPlacement();
const provider = normalizeProvider(a.provider);
let list: string[] = [];
try {
list = await repos.settings.listModels(provider);
} catch {
list = [a.model || "grok-3"];
}
setModels(list);
const model = list.includes(a.model) ? a.model : list[0] || a.model;
setAi({
...a,
provider,
model,
research_provider: provider,
research_model: model,
});
setPlacement(p);
})();
}, [repos.settings]);
/** 換 provider打後端拿模型清單保留同名 model 或落到預設 */
async function onProviderChange(nextId: string) {
if (!ai) return;
const provider = normalizeProvider(nextId);
setBusy("ai");
try {
const list = await repos.settings.listModels(provider);
setModels(list);
const model = list.includes(ai.model) ? ai.model : list[0] || ai.model;
setAi({
...ai,
provider,
model,
research_provider: provider,
research_model: model,
});
} finally {
setBusy("");
}
}
async function saveAi() {
if (!ai) return;
setBusy("ai");
try {
const provider = normalizeProvider(ai.provider);
// 同一 provider + 同一 model 用於文案/研究
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,
});
setAi(next);
setApiKey("");
setMessage(t("settings.aiSaved"));
refresh();
} 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 || "grok-3"];
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.dataSource")}>
<div className="hb-stack">
<p className="text-muted" style={{ margin: 0, fontSize: "0.85rem" }}>
{t("settings.dataSourceHint")}
</p>
<div className="hb-theme-picker" role="group" aria-label={t("settings.dataSource")}>
{(
[
["mock", t("settings.dataSourceMock")],
["live", t("settings.dataSourceLive")],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
className={`hb-theme-picker__btn${dataSource === id ? " is-active" : ""}`}
onClick={() => {
if (dataSource === id) return;
setDataSource(id);
setMessage(
id === "live" ? t("settings.dataSourceSwitchedLive") : t("settings.dataSourceSwitchedMock"),
);
}}
>
{label}
</button>
))}
</div>
<p className="text-muted" style={{ margin: 0, fontSize: "0.8rem" }}>
{t("settings.dataSourceCurrent")}: <strong>{dataSource}</strong>
{dataSource === "live" ? ` · ${t("settings.dataSourceLiveNeedGateway")}` : null}
</p>
</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")
: t("settings.notConfigured")
}
/>
<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 ? (
<div className="hb-settings-ext-wrap">
<ExtensionInstallCard />
</div>
) : null}
<Button type="button" onClick={() => void savePlacement()} disabled={busy === "placement"}>
{t("common.save")}
</Button>
</div>
) : (
<p className="text-muted">{t("common.loading")}</p>
)}
</Card>
</>
);
}