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

458 lines
15 KiB
TypeScript
Raw Normal View History

2026-07-10 05:10:31 +00:00
import { useEffect, useState } from "react";
import { PageHeader } from "../components/layout/PageHeader";
2026-07-13 01:15:30 +00:00
import { DevModeSessionCard } from "../components/settings/DevModeSessionCard";
2026-07-10 05:10:31 +00:00
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";
2026-07-10 12:54:45 +00:00
/** 可選 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";
}
2026-07-10 05:10:31 +00:00
export function SettingsPage() {
const repos = useRepos();
2026-07-13 01:15:30 +00:00
const { refresh } = useData();
2026-07-10 05:10:31 +00:00
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("");
2026-07-10 12:54:45 +00:00
const [models, setModels] = useState<string[]>([]);
2026-07-10 05:10:31 +00:00
const [message, setMessage] = useState("");
2026-07-13 01:15:30 +00:00
const [error, setError] = useState("");
2026-07-10 05:10:31 +00:00
const [busy, setBusy] = useState("");
const [draftLocale, setDraftLocale] = useState<AppLocale>(locale);
const [draftCurrency, setDraftCurrency] = useState<AppCurrency>(currency);
useEffect(() => {
setDraftLocale(locale);
setDraftCurrency(currency);
}, [locale, currency]);
2026-07-13 01:15:30 +00:00
/** 進頁:一支 GetAi 同時拿設定 + 模型清單 + 上次選定 model */
2026-07-10 05:10:31 +00:00
useEffect(() => {
void (async () => {
2026-07-28 06:33:51 +00:00
let a: AiSettings;
let p: PlacementSettings;
try {
a = await repos.settings.getAi();
p = await repos.settings.getPlacement();
} catch (e) {
// ai 留 null → 整個設定表單不渲染,沒有訊息的話畫面會是一片空白。
setError(e instanceof Error ? e.message : t("settings.loadFail"));
return;
}
2026-07-10 12:54:45 +00:00
const provider = normalizeProvider(a.provider);
2026-07-13 01:15:30 +00:00
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;
2026-07-10 12:54:45 +00:00
setModels(list);
setAi({
...a,
provider,
model,
research_provider: provider,
research_model: model,
2026-07-13 01:15:30 +00:00
selected_model: model,
models: list,
2026-07-10 12:54:45 +00:00
});
setPlacement(p);
2026-07-28 06:33:51 +00:00
setError(a.models_error || "");
2026-07-10 05:10:31 +00:00
})();
2026-07-28 06:33:51 +00:00
}, [repos.settings, t]);
2026-07-10 05:10:31 +00:00
2026-07-13 01:15:30 +00:00
/** 換 provider同一支 getAi(?provider=) 帶回 models + selected_model */
2026-07-10 12:54:45 +00:00
async function onProviderChange(nextId: string) {
2026-07-10 05:10:31 +00:00
if (!ai) return;
2026-07-10 12:54:45 +00:00
const provider = normalizeProvider(nextId);
setBusy("ai");
2026-07-13 01:15:30 +00:00
setError("");
2026-07-10 05:10:31 +00:00
try {
2026-07-13 01:15:30 +00:00
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;
2026-07-10 12:54:45 +00:00
setModels(list);
setAi({
...ai,
2026-07-13 01:15:30 +00:00
...a,
2026-07-10 12:54:45 +00:00
provider,
model,
research_provider: provider,
research_model: model,
2026-07-13 01:15:30 +00:00
selected_model: model,
models: list,
2026-07-10 12:54:45 +00:00
});
2026-07-13 01:15:30 +00:00
if (a.models_error) setError(a.models_error);
} catch (e) {
setError(e instanceof Error ? e.message : t("common.error"));
2026-07-10 05:10:31 +00:00
} finally {
setBusy("");
}
}
async function saveAi() {
if (!ai) return;
setBusy("ai");
2026-07-13 01:15:30 +00:00
setError("");
2026-07-10 05:10:31 +00:00
try {
2026-07-10 12:54:45 +00:00
const provider = normalizeProvider(ai.provider);
2026-07-13 01:15:30 +00:00
// 同一 provider + 同一 modelsave 回傳仍含 models
2026-07-10 05:10:31 +00:00
const next = await repos.settings.saveAi({
2026-07-10 12:54:45 +00:00
provider,
model: ai.model,
research_provider: provider,
research_model: ai.model,
2026-07-10 05:10:31 +00:00
api_key: apiKey || undefined,
2026-07-10 12:54:45 +00:00
research_api_key: apiKey || undefined,
2026-07-10 05:10:31 +00:00
});
2026-07-13 01:15:30 +00:00
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,
});
2026-07-10 05:10:31 +00:00
setApiKey("");
setMessage(t("settings.aiSaved"));
2026-07-13 01:15:30 +00:00
if (next.models_error) setError(next.models_error);
2026-07-10 05:10:31 +00:00
refresh();
2026-07-13 01:15:30 +00:00
} catch (e) {
setError(e instanceof Error ? e.message : t("common.error"));
2026-07-10 05:10:31 +00:00
} finally {
setBusy("");
}
}
2026-07-15 15:23:59 +00:00
async function clearAiKey() {
if (!ai) return;
setBusy("ai-clear");
setMessage("");
setError("");
try {
const next = await repos.settings.saveAi({
provider: normalizeProvider(ai.provider),
api_key_configured: false,
research_api_key_configured: false,
});
setAi({ ...ai, ...next });
setApiKey("");
setMessage(t("settings.aiKeyCleared"));
refresh();
} catch (e) {
setError(e instanceof Error ? e.message : t("common.error"));
} finally {
setBusy("");
}
}
2026-07-10 05:10:31 +00:00
async function savePlacement() {
if (!placement) return;
setBusy("placement");
try {
const next = await repos.settings.savePlacement({
...placement,
2026-07-10 12:54:45 +00:00
web_search_provider: "exa",
2026-07-10 05:10:31 +00:00
exa_api_key: exaKey || undefined,
});
setPlacement(next);
setExaKey("");
setMessage(t("settings.searchSaved"));
refresh();
2026-07-13 08:59:13 +00:00
} catch (e) {
setError(e instanceof Error ? e.message : t("common.error"));
2026-07-10 05:10:31 +00:00
} finally {
setBusy("");
}
}
2026-07-15 15:23:59 +00:00
async function clearExaKey() {
if (!placement) return;
setBusy("placement-clear");
setMessage("");
setError("");
try {
const next = await repos.settings.savePlacement({ exa_api_key_configured: false });
setPlacement(next);
setExaKey("");
setMessage(t("settings.exaKeyCleared"));
refresh();
} catch (e) {
setError(e instanceof Error ? e.message : t("common.error"));
} finally {
setBusy("");
}
}
2026-07-10 05:10:31 +00:00
function saveLocaleCurrency() {
setPrefs({ locale: draftLocale, currency: draftCurrency });
setMessage(t("settings.localeSaved"));
}
function pickTheme(next: ThemePreference) {
setPreference(next);
setMessage(t("settings.themeSaved"));
}
2026-07-13 01:15:30 +00:00
const modelOptions = models.length ? models : [ai?.model || ai?.selected_model].filter(Boolean) as string[];
2026-07-10 12:54:45 +00:00
2026-07-10 05:10:31 +00:00
return (
<>
<PageHeader title={t("settings.title")} />
{message ? (
<p className="hb-banner-ok" role="status">
{message}
</p>
) : null}
2026-07-13 01:15:30 +00:00
{error ? (
<p className="hb-form-error" role="alert">
{error}
</p>
) : null}
2026-07-10 05:10:31 +00:00
<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">
2026-07-30 01:25:34 +00:00
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
2026-07-10 12:54:45 +00:00
{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>
2026-07-10 05:10:31 +00:00
<Input
2026-07-10 12:54:45 +00:00
label={t("settings.apiKey")}
2026-07-10 05:10:31 +00:00
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
2026-07-10 12:54:45 +00:00
placeholder={
ai.api_key_configured || ai.research_api_key_configured
? t("settings.configured")
2026-07-13 01:15:30 +00:00
: ai.platform_key_available
? t("settings.platformKeyOk")
: t("settings.notConfigured")
2026-07-10 12:54:45 +00:00
}
2026-07-10 05:10:31 +00:00
/>
2026-07-13 01:15:30 +00:00
{ai.models_error ? (
2026-07-30 01:25:34 +00:00
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
2026-07-13 01:15:30 +00:00
{t("settings.modelsHint")}: {ai.models_error}
</p>
) : ai.models_from_cache ? (
2026-07-30 01:25:34 +00:00
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
2026-07-13 01:15:30 +00:00
{t("settings.modelsCached")}
</p>
) : null}
2026-07-15 15:23:59 +00:00
<div className="hb-wizard-actions">
<Button type="button" onClick={() => void saveAi()} disabled={Boolean(busy)}>
{t("common.save")}
</Button>
<Button
type="button"
variant="danger"
onClick={() => void clearAiKey()}
disabled={Boolean(busy) || (!ai.api_key_configured && !ai.research_api_key_configured)}
>
{busy === "ai-clear" ? t("common.loading") : t("settings.clearAiKey")}
</Button>
</div>
2026-07-10 05:10:31 +00:00
</div>
) : (
<p className="text-muted">{t("common.loading")}</p>
)}
</Card>
<Card title={t("settings.search")}>
{placement ? (
<div className="hb-stack">
2026-07-10 12:54:45 +00:00
<Input label={t("settings.searchProvider")} value="Exa" readOnly disabled />
2026-07-10 05:10:31 +00:00
<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")
}
/>
2026-07-10 12:54:45 +00:00
<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 ? (
2026-07-13 01:15:30 +00:00
<DevModeSessionCard
onMessage={(msg) => {
setError("");
setMessage(msg);
}}
onError={(msg) => {
setMessage("");
setError(msg);
}}
/>
2026-07-10 12:54:45 +00:00
) : null}
2026-07-15 15:23:59 +00:00
<div className="hb-wizard-actions">
<Button type="button" onClick={() => void savePlacement()} disabled={Boolean(busy)}>
{t("common.save")}
</Button>
<Button
type="button"
variant="danger"
onClick={() => void clearExaKey()}
disabled={Boolean(busy) || !placement.exa_api_key_configured}
>
{busy === "placement-clear" ? t("common.loading") : t("settings.clearExaKey")}
</Button>
</div>
2026-07-10 05:10:31 +00:00
</div>
) : (
<p className="text-muted">{t("common.loading")}</p>
)}
</Card>
</>
);
}