import { useState, useRef, useEffect } from "react"; import { useConfigStore } from "@/store/config-store"; import { useTranslation } from "@/lib/i18n"; import { Modal } from "@/components/ui/Modal"; import { exportConfig, exportConfigToDirectory, importConfigFromFile, parseImportFile, saveLocalBackup, } from "@/lib/config"; import { packageSource, type PiConfig, type PiSettings, type UpdateCheckResult } from "@/types"; import { cn } from "@/lib/utils"; import { RECOMMENDED_PACKAGES } from "@/data/recommended-packages"; import { PackageBrowser } from "./PackageBrowser"; import { SkillsPage } from "./SkillsPage"; import { CommandsPage } from "./CommandsPage"; import { PiCliSettingsPage } from "./PiCliSettingsPage"; import { Download, Upload, RotateCcw, Package, Plus, X, Check, Palette, Type, LayoutGrid, Wrench, Settings as SettingsIcon, CloudDownload, RefreshCw, ZoomIn, Sparkles, Command, Terminal, Monitor, } from "lucide-react"; type SettingsTab = "appearance" | "models" | "cli" | "skills" | "commands" | "advanced"; // Visual palette previews for the theme swatch picker (mirrors pi-desktop). const THEME_SWATCHES: { value: "dark" | "light" | "light/dark"; labelKey: string; bg: string; dots: string[]; }[] = [ { value: "dark", labelKey: "settings.dark", bg: "linear-gradient(145deg, #14141c, #0a0a0f)", dots: ["#00d4aa", "#7c5cfc", "#2a2a35"] }, { value: "light", labelKey: "settings.light", bg: "linear-gradient(145deg, #ffffff, #e9ecf2)", dots: ["#00b894", "#7c5cfc", "#c9ced8"] }, { value: "light/dark", labelKey: "settings.system", bg: "linear-gradient(105deg, #0a0a0f 49.5%, #f1f2f6 50.5%)", dots: ["#00d4aa", "#7c5cfc", "#9aa0ab"] }, ]; const FONT_SIZE_KEY = "pi-font-size"; const UI_ZOOM_KEY = "pi-ui-zoom"; function Card({ icon: Icon, title, desc, children, }: { icon: typeof Palette; title: string; desc?: string; children: React.ReactNode; }) { return (

{title}

{desc &&

{desc}

}
{children}
); } function SettingRow({ label, children }: { label: string; children: React.ReactNode }) { return (
{label} {children}
); } export function SettingsPage() { const { t } = useTranslation(); const { settings, auth, modelsJson, allProviders, updateSettings, setTheme, addPackage, removePackage, importConfig: importConfigAction, resetToDefaults, } = useConfigStore(); const fileInputRef = useRef(null); const [activeTab, setActiveTab] = useState("appearance"); const [newPackage, setNewPackage] = useState(""); const [importError, setImportError] = useState(""); const [showResetConfirm, setShowResetConfirm] = useState(false); const [showPackageBrowser, setShowPackageBrowser] = useState(false); const [fontSize, setFontSize] = useState(() => { const saved = Number(localStorage.getItem(FONT_SIZE_KEY)); return saved >= 12 && saved <= 24 ? saved : 16; }); const [uiZoom, setUiZoom] = useState(() => { const saved = Number(localStorage.getItem(UI_ZOOM_KEY)); return saved >= 50 && saved <= 200 ? saved : 100; }); // pi core / extensions update check (Advanced tab). const [updateResult, setUpdateResult] = useState(null); const [checkingUpdates, setCheckingUpdates] = useState(false); const [updateError, setUpdateError] = useState(false); const [applying, setApplying] = useState(false); const [applyMessage, setApplyMessage] = useState<{ ok: number; failures: { name: string; message?: string }[] } | null>(null); const handleCheckUpdates = async () => { setCheckingUpdates(true); setUpdateError(false); setApplyMessage(null); try { const res = await fetch("/api/pi/check-updates"); if (!res.ok) throw new Error("check failed"); setUpdateResult(await res.json()); } catch { setUpdateError(true); } finally { setCheckingUpdates(false); } }; // Names of everything that can be updated: pi core (routed to `pi update` // server-side) plus every outdated extension. const updatableNames = [ ...(updateResult?.pi?.hasUpdate ? [updateResult.pi.name] : []), ...(updateResult?.extensions ?? []).filter((e) => e.hasUpdate).map((e) => e.name), ]; // One-click update, then re-check so the list reflects the new installed versions. const handleApplyUpdates = async () => { const names = updatableNames; if (names.length === 0) return; setApplying(true); setApplyMessage(null); try { const res = await fetch("/api/pi/apply-updates", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ names }), }); if (!res.ok) throw new Error("apply failed"); const { results } = (await res.json()) as { results: { name: string; success: boolean; message?: string }[]; }; const failures = results.filter((r) => !r.success).map(({ name, message }) => ({ name, message })); setApplyMessage({ ok: results.length - failures.length, failures }); const check = await fetch("/api/pi/check-updates"); if (check.ok) setUpdateResult(await check.json()); } catch { setUpdateError(true); } finally { setApplying(false); } }; // Apply + persist UI font size (root rem scaling). useEffect(() => { document.documentElement.style.fontSize = `${fontSize}px`; localStorage.setItem(FONT_SIZE_KEY, String(fontSize)); }, [fontSize]); // Apply + persist UI zoom (whole-interface percentage scaling). useEffect(() => { // Migrate away from the old browser zoom implementation. Keeping the // legacy `zoom` property would compound scaling with the new layout-aware // transform and make dense pages appear clipped or unexpectedly tiny. document.documentElement.style.zoom = ""; document.documentElement.style.setProperty("--ui-zoom", String(uiZoom / 100)); localStorage.setItem(UI_ZOOM_KEY, String(uiZoom)); }, [uiZoom]); const handleImportFromInput = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const text = await file.text(); const result = parseImportFile(text); if (result) { await importConfigAction(result); setImportError(""); } else { setImportError(t("settings.import_error")); } // Reset input so the same file can be selected again e.target.value = ""; }; const handleImportClick = async () => { const { config, cancelled } = await importConfigFromFile(); if (config) { await importConfigAction(config); setImportError(""); return; } if (!cancelled) { // API unavailable or parse failed → fallback to hidden file input fileInputRef.current?.click(); } }; const handleExport = async () => { const cfg: PiConfig = { settings: settings ?? { theme: "dark", packages: [], enabledModels: [] }, auth: auth ?? {}, modelsJson: modelsJson ?? { providers: {} }, }; saveLocalBackup(cfg); const { ok, cancelled } = await exportConfigToDirectory(cfg); if (!ok && !cancelled) { // API unavailable or write failed → fallback to download exportConfig(cfg); } }; // ── Default model select: composite `providerId/modelId` values, but the // settings file stores the bare model id (that's what pi expects on disk). const modelValue = (providerId: string, modelId: string) => `${providerId}/${modelId}`; // Only providers that are actually usable belong in the defaults lists: a saved // API key (auth.json / models.json override) or a custom provider. A stale // saved value is kept so the selects don't render blank. const providerOptions = allProviders.filter( (p) => p.type === "custom" || p.hasAuth || !!p.apiKey || !!auth?.[p.id]?.key || p.id === settings?.defaultProvider ); const modelProviders = providerOptions.filter((p) => p.models.length > 0); const scopedId = settings?.defaultProvider; const groupedModelOptions = scopedId ? [...modelProviders.filter((p) => p.id === scopedId), ...modelProviders.filter((p) => p.id !== scopedId)] : modelProviders; const selectedModelValue = (() => { const id = settings?.defaultModel; if (!id) return ""; const dp = settings?.defaultProvider; if (dp && allProviders.find((p) => p.id === dp)?.models.some((m) => m.id === id)) { return modelValue(dp, id); } const owner = allProviders.find((p) => p.models.some((m) => m.id === id)); return owner ? modelValue(owner.id, id) : id; // stale ids display as-is })(); const handleDefaultModelChange = (v: string) => { if (!v) { updateSettings({ defaultModel: undefined }); return; } const idx = v.indexOf("/"); // model ids may themselves contain '/' const providerId = idx >= 0 ? v.slice(0, idx) : ""; const modelId = idx >= 0 ? v.slice(idx + 1) : v; // Keep the provider in sync so the (provider, model) pair stays consistent. updateSettings(providerId ? { defaultProvider: providerId, defaultModel: modelId } : { defaultModel: modelId }); }; const handleDefaultProviderChange = (v: string) => { // Drop a default model that doesn't belong to the newly picked provider. let clearModel = false; if (v && settings?.defaultModel) { const provider = allProviders.find((p) => p.id === v); if (provider && !provider.models.some((m) => m.id === settings.defaultModel)) { clearModel = true; } } updateSettings(clearModel ? { defaultProvider: v, defaultModel: undefined } : { defaultProvider: v || undefined }); }; const tabs: { key: SettingsTab; icon: typeof Palette; label: string }[] = [ { key: "appearance", icon: Palette, label: t("settings.appearance") }, { key: "models", icon: LayoutGrid, label: t("settings.tab_models") }, { key: "cli", icon: Terminal, label: "CLI 设置" }, { key: "skills", icon: Sparkles, label: "Skills" }, { key: "commands", icon: Command, label: "命令" }, { key: "advanced", icon: Wrench, label: t("settings.tab_advanced") }, ]; const selectCls = "rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-white"; const installedPackages = settings?.packages ?? []; const installedPackageIds = new Set(installedPackages.map(packageSource)); return (
{/* ── Hero ─────────────────────────────────────────── */}
pi · workspace

{t("settings.title")}

{t("settings.subtitle")}

{/* ── Tab nav ──────────────────────────────────────── */} {/* ── Appearance ───────────────────────────────────── */} {activeTab === "appearance" && (
{THEME_SWATCHES.map((s) => { const active = (settings?.theme ?? "light/dark") === s.value; return ( ); })}

{t("settings.show_native_desc")}

setFontSize(Number(e.target.value))} className="flex-1 accent-blue-500" /> {fontSize}px
setUiZoom(Number(e.currentTarget.value))} onChange={(e) => setUiZoom(Number(e.target.value))} className="flex-1 accent-blue-500" /> {uiZoom}%
)} {/* ── Models ───────────────────────────────────────── */} {activeTab === "models" && (
)} {activeTab === "skills" && } {activeTab === "commands" && } {activeTab === "cli" && } {/* ── Advanced ─────────────────────────────────────── */} {activeTab === "advanced" && (
{updatableNames.length > 0 && ( )}
{updateError &&

{t("settings.updates_failed")}

} {applyMessage && (
{applyMessage.ok > 0 && (

{t("settings.update_success", String(applyMessage.ok))}

)} {applyMessage.failures.length > 0 && (

{t("settings.update_failed_names", String(applyMessage.failures.length), applyMessage.failures.map((failure) => failure.name).join(", "))}

{applyMessage.failures.map((failure) => failure.message && (

{failure.name}: {failure.message}

))}
)}
)} {updateResult && (() => { const rows = [...(updateResult.pi ? [updateResult.pi] : []), ...updateResult.extensions]; const updatable = rows.filter((r) => r.hasUpdate).length; return (

0 ? "text-amber-400" : "text-emerald-400")}> {updatable > 0 ? t("settings.updates_summary", String(updatable)) : t("settings.updates_all_latest")}

{rows.map((r) => (
{r.name} {updateResult.pi && r.name === updateResult.pi.name && ( core )}
{r.latest === null ? `${r.installed} → ?` : r.hasUpdate ? `${r.installed} → ${r.latest}` : r.installed} {r.latest === null ? ( {t("settings.updates_lookup_failed")} ) : r.hasUpdate ? ( {t("settings.update_available")} ) : ( {t("settings.updates_up_to_date")} )}
))} {updatable > 0 && (

{t("settings.updates_hint")}

)}
); })()}
{installedPackages.length > 0 && (
{installedPackages.map((pkg) => { const id = packageSource(pkg); return ( {id} ); })}
)} {(settings?.packages ?? []).length === 0 && (

{t("settings.no_packages")}

)} {/* Recommended packages — one-click install */} {(() => { const installed = installedPackageIds; const recommended = RECOMMENDED_PACKAGES.filter((p) => !installed.has(p.id)); if (recommended.length === 0) return null; return (

{t("settings.recommended_packages")}

{recommended.map((pkg) => (

{pkg.name}

{t(pkg.descKey)}

))}
); })()}
{t("settings.add_custom_package")}
setNewPackage(e.target.value)} placeholder={t("settings.package_placeholder")} className="flex-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-white placeholder-gray-500" onKeyDown={(e) => { if (e.key === "Enter" && newPackage.trim()) { addPackage(newPackage.trim()); setNewPackage(""); } }} />
{importError &&

{importError}

}
)} setShowPackageBrowser(false)} installed={installedPackageIds} onInstall={(id) => addPackage(id)} /> {/* Reset Confirm */} setShowResetConfirm(false)} title={t("settings.reset_title")} >

{t("settings.reset_confirm")}

); }