"use client"; /* Settings: project config, split into a secondary-nav of focused sections (General · Members · API Keys · Model Pricing · Budgets · Knowledge · Versioning · Observability · Advanced). Config-backed sections share one Save; Members, API Keys, Model Pricing and Secrets manage their own persistence. */ import { useCallback, useEffect, useMemo, useState } from "react"; import { Icon } from "../icons"; import { EmptyState, Field, Modal, Segmented, Tabs, Toggle } from "../primitives"; import { api, clearTokens, InviteResult, MeResult, ProjectVersion, Secret, TeamMember } from "@/lib/api"; import { useEmbeddingModels, useModels, useRerankerModels } from "@/lib/models"; const ROLES = ["owner", "admin", "editor", "viewer", "connector"]; // Embedder + reranker models (and which one is default) come from the backend catalog now // (useEmbeddingModels / useRerankerModels) - the frontend hardcodes no model lists. const DEFAULT_CHILD_CHUNK_SIZE = 300; type SectionId = | "general" | "members" | "apikeys" | "pricing" | "budgets" | "guardrails" | "knowledge" | "versioning" | "observability" | "advanced" | "history"; const SECTIONS: { id: SectionId; label: string; icon: string; savesConfig?: boolean }[] = [ { id: "general", label: "General", icon: "sliders", savesConfig: true }, { id: "members", label: "Members & Roles", icon: "user" }, { id: "apikeys", label: "API Keys", icon: "secret" }, { id: "pricing", label: "Model Pricing", icon: "coins" }, { id: "budgets", label: "Budgets & Quotas", icon: "bolt", savesConfig: true }, { id: "guardrails", label: "Guardrails & Egress", icon: "shield-check", savesConfig: true }, { id: "knowledge", label: "Knowledge & Embeddings", icon: "knowledge", savesConfig: true }, { id: "versioning", label: "Versioning", icon: "clock", savesConfig: true }, { id: "observability", label: "Observability & Retention", icon: "traces", savesConfig: true }, { id: "advanced", label: "Advanced", icon: "settings", savesConfig: true }, { id: "history", label: "History", icon: "clock" }, ]; // Which settings section each project-config field belongs to, so History can group changes // under the same tab names as the nav. Top-level snapshot fields + config.* keys. Anything not // listed falls through to "advanced". Members/API-key values are intentionally NOT diffed here // (Members isn't config-backed; secrets are masked below). const FIELD_SECTION: Record = { name: "general", description: "general", slug: "general", status: "general", default_model: "general", budgets: "budgets", default_middleware: "guardrails", egress: "guardrails", rag_defaults: "knowledge", version_history_limit: "versioning", versioning: "versioning", observability: "observability", scheduler: "advanced", model_pricing: "pricing", provider_credentials: "apikeys", }; // The config-backed sections that get a History tab (Members isn't config-backed → skipped). const HISTORY_TABS: SectionId[] = ["general", "budgets", "guardrails", "knowledge", "versioning", "observability", "apikeys", "pricing", "advanced"]; // Field paths whose values must never be shown in a diff (secrets / credentials). const MASK_RE = /credential|secret|token|api[_-]?key|password/i; export function SettingsScreen({ project, onDeleteProject }: { project: any; onDeleteProject?: (project: { id: string; name: string }) => Promise | void }) { const [section, setSection] = useState("general"); const MODELS = useModels(); const embeddingModels = useEmbeddingModels(); const rerankerModels = useRerankerModels(); const [config, setConfig] = useState>({}); const [meta, setMeta] = useState<{ name: string; description: string }>({ name: "", description: "" }); const [secrets, setSecrets] = useState([]); const [save, setSave] = useState<"idle" | "saving" | "saved">("idle"); const [open, setOpen] = useState(false); const [secForm, setSecForm] = useState({ name: "", value: "", kind: "api_key" }); const [pkeys, setPkeys] = useState>({}); const [keySave, setKeySave] = useState<"idle" | "saving" | "saved">("idle"); const [confirmDelete, setConfirmDelete] = useState(false); const [deleting, setDeleting] = useState(false); const [tenantId, setTenantId] = useState(""); const [copiedWs, setCopiedWs] = useState(false); const [copiedPid, setCopiedPid] = useState(false); const reloadSecrets = useCallback(() => { if (project?.id) api.listSecrets(project.id).then(setSecrets).catch(() => {}); }, [project?.id]); useEffect(() => { if (!project?.id) return; api.getProject(project.id).then((p) => { setConfig(p.config || {}); setMeta({ name: p.name || "", description: p.description || "" }); }).catch(() => {}); reloadSecrets(); }, [project?.id, reloadSecrets]); // The workspace (tenant) id — surfaced in General so a user whose email spans multiple // workspaces can supply it at MCP OAuth login. Account-level, so it's independent of project. useEffect(() => { api.me().then((m) => setTenantId(m.tenant_id || "")).catch(() => {}); }, []); const setCfg = (patch: Record) => setConfig((c) => ({ ...c, ...patch })); const features = config.features || {}; const budgets = config.budgets || {}; const rag = config.rag_defaults || {}; const versioning = config.versioning || {}; const observability = config.observability || {}; const scheduler = config.scheduler || {}; const defaultEmbedding = embeddingModels.find((m) => m.default)?.id ?? ""; const defaultReranker = rerankerModels.find((m) => m.default)?.id ?? ""; const embeddingModel = rag.embedding_model || defaultEmbedding; const setRag = (patch: Record) => setCfg({ rag_defaults: { ...rag, ...patch } }); async function persist() { setSave("saving"); try { await api.updateProject(project.id, { name: meta.name || undefined, description: meta.description, config }); setSave("saved"); setTimeout(() => setSave("idle"), 1400); } catch { setSave("idle"); } } async function addSecret() { if (!secForm.name.trim()) return; await api.createSecret(project.id, { name: secForm.name, value: secForm.value, kind: secForm.kind }); setOpen(false); setSecForm({ name: "", value: "", kind: "api_key" }); reloadSecrets(); } async function removeSecret(name: string) { let used: { type: string; label: string }[] = []; try { used = (await api.secretUsage(project.id, name)).references; } catch { /* fall back to a plain confirm */ } const detail = used.length ? `\n\nIn use by ${used.length}:\n` + used.map((r) => `• ${r.label} - ${r.type.replace(/_/g, " ")}`).join("\n") + `\n\nDeleting will break these.` : ""; if (!window.confirm(`Delete secret "${name}"?${detail}`)) return; await api.deleteSecret(project.id, name, true); reloadSecrets(); } async function saveKeys() { setKeySave("saving"); const pc = { ...(config.provider_credentials || {}) }; for (const [prov, val] of Object.entries(pkeys)) { if (!val.trim()) continue; const name = `${prov}_key`; await api.createSecret(project.id, { name, value: val, kind: "api_key" }); pc[prov] = `secret://proj/${name}`; } const newConfig = { ...config, provider_credentials: pc }; setConfig(newConfig); await api.updateProject(project.id, { config: newConfig }); setPkeys({}); reloadSecrets(); setKeySave("saved"); setTimeout(() => setKeySave("idle"), 1400); } const PROVIDERS: [string, string][] = [["openai", "OpenAI"], ["anthropic", "Anthropic"], ["google_genai", "Google"]]; const pcreds = config.provider_credentials || {}; const activeMeta = SECTIONS.find((s) => s.id === section)!; return (
{/* secondary nav */} {/* content */}
{activeMeta.label}
{activeMeta.savesConfig && ( )}
{section === "general" && ( <> setMeta((m) => ({ ...m, name: e.target.value }))} placeholder="Project name" />