2026-09-19 18:05:30 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* THE USER SETTINGS RECORD's shape + sanitizer (settings-storage wave,
|
|
|
|
|
|
* 2026-09-19): one versioned, DELTA-shaped record at the data-dir seam
|
|
|
|
|
|
* (config.SETTINGS_FILE) holding every non-secret product preference.
|
|
|
|
|
|
*
|
|
|
|
|
|
* DELTA SEMANTICS (owner decision, 2026-09-19): the record stores EXPLICIT
|
|
|
|
|
|
* user choices only — ABSENT keys resolve to the factory defaults compiled
|
|
|
|
|
|
* into this module (FACTORY_SETTINGS) and config.ts's MODELS (the compiled
|
|
|
|
|
|
* factory layer for model roles). A software update that ADDS a knob (new
|
|
|
|
|
|
* tool, new role, new appearance field) therefore needs no migration: old
|
|
|
|
|
|
* records simply lack the new keys. `version` is a PRIVATE schema counter
|
|
|
|
|
|
* for BREAKING shape changes only (field renamed / meaning changed) —
|
|
|
|
|
|
* unrelated to the app release version. Migrations live in the adapter
|
|
|
|
|
|
* (src/store/settings.ts), not here.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Sanitizer contract (mirrors render-settings.ts discipline): whatever
|
|
|
|
|
|
* arrives from disk or the network passes through sanitizeUserSettings
|
|
|
|
|
|
* before use — never trusted raw. Strict on known fields, PRESERVES
|
|
|
|
|
|
* unknown keys verbatim (a newer record must never be destroyed by an
|
|
|
|
|
|
* older app reading it). Invalid known values degrade gracefully:
|
|
|
|
|
|
* out-of-range numbers CLAMP, wrong-typed/invalid fields DROP (resolve to
|
|
|
|
|
|
* factory default), never throw.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Browser-safe: pure logic + types only, no node imports. The resolved
|
|
|
|
|
|
* (fully concrete) view lives in settings-resolution.ts.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
|
|
|
|
import type { ChatSettings } from "./contracts/chat.ts";
|
2026-09-22 00:02:07 +03:00
|
|
|
|
import type { SearchEngineId } from "./search.ts";
|
2026-09-19 18:05:30 +03:00
|
|
|
|
|
|
|
|
|
|
/** The schema version this code understands. Bump ONLY for breaking shape
|
|
|
|
|
|
* changes (a field renamed/meaning changed); pure additions never bump. */
|
|
|
|
|
|
export const CURRENT_SETTINGS_VERSION = 1;
|
|
|
|
|
|
|
|
|
|
|
|
/** The background-agent roles whose models the Agent settings expose.
|
|
|
|
|
|
* Derived from config.ts's MODELS minus the conversation pair — prober and
|
|
|
|
|
|
* teacher stay chat-settings-controlled (one controller, the model picker).
|
|
|
|
|
|
* Keep in sync with config.ts's MODELS (tests pin the set). */
|
|
|
|
|
|
export const BACKGROUND_MODEL_ROLES = [
|
|
|
|
|
|
"researcher",
|
|
|
|
|
|
"demoBuilder",
|
|
|
|
|
|
"visualMaker",
|
|
|
|
|
|
"extractor",
|
|
|
|
|
|
"auditor",
|
|
|
|
|
|
"labeler",
|
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
|
|
export type BackgroundModelRole = (typeof BACKGROUND_MODEL_ROLES)[number];
|
|
|
|
|
|
|
|
|
|
|
|
/** A per-role model/thinking override — every field independent (absent
|
|
|
|
|
|
* field = that aspect falls back to config.ts's pin for the role). */
|
|
|
|
|
|
export interface RoleModelOverride {
|
|
|
|
|
|
/** The model swap: both coordinates or neither (mirrors ChatSettings.model). */
|
|
|
|
|
|
model?: { provider: string; id: string };
|
|
|
|
|
|
/** The thinking budget swap. */
|
|
|
|
|
|
thinkingLevel?: ThinkingLevel;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 00:14:56 +03:00
|
|
|
|
// ─── Model slots (model slots wave, 2026-09-20) ──────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
/** The capability tiers a user can pin models into (owner-settled mapping):
|
|
|
|
|
|
* strong = prober + teacher (also the per-session default-teacher
|
|
|
|
|
|
* starting point); cheap = researcher, demo-builder, extractor, auditor,
|
|
|
|
|
|
* labeler; vision = visual-maker + pdf-reader. Resolution per role:
|
|
|
|
|
|
* ROLE OVERRIDE → slot → factory pin. */
|
|
|
|
|
|
export const MODEL_SLOTS = ["strong", "cheap", "vision"] as const;
|
|
|
|
|
|
export type ModelSlotName = (typeof MODEL_SLOTS)[number];
|
|
|
|
|
|
|
|
|
|
|
|
/** One slot's override — same per-field independence as a role override:
|
|
|
|
|
|
* absent model keeps the tier's current model, absent thinkingLevel keeps
|
|
|
|
|
|
* its thinking budget. */
|
|
|
|
|
|
export interface ModelSlot {
|
|
|
|
|
|
/** The model swap: both coordinates or neither. */
|
|
|
|
|
|
model?: { provider: string; id: string };
|
|
|
|
|
|
/** The thinking budget swap. */
|
|
|
|
|
|
thinkingLevel?: ThinkingLevel;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** The `models` settings section (owner decision 2026-09-20: a dedicated
|
|
|
|
|
|
* section — slots are engine-level choices, not one session's). All slots
|
|
|
|
|
|
* optional: an absent slot means every role of its tier runs whatever the
|
|
|
|
|
|
* deeper tiers of the chain resolve to (per-role override → factory pin). */
|
|
|
|
|
|
export interface ModelsSettings {
|
|
|
|
|
|
strong?: ModelSlot;
|
|
|
|
|
|
cheap?: ModelSlot;
|
|
|
|
|
|
vision?: ModelSlot;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Which slot a role draws from (model slots wave, 2026-09-20). Includes the
|
|
|
|
|
|
* conversation pair and pdfReader (not BackgroundModelRoles — the slot
|
|
|
|
|
|
* tier spans every role; only background roles surface per-role overrides). */
|
|
|
|
|
|
export const ROLE_SLOT: Readonly<Record<string, ModelSlotName>> = {
|
|
|
|
|
|
prober: "strong",
|
|
|
|
|
|
teacher: "strong",
|
|
|
|
|
|
researcher: "cheap",
|
|
|
|
|
|
demoBuilder: "cheap",
|
|
|
|
|
|
extractor: "cheap",
|
|
|
|
|
|
auditor: "cheap",
|
|
|
|
|
|
labeler: "cheap",
|
|
|
|
|
|
visualMaker: "vision",
|
|
|
|
|
|
pdfReader: "vision",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-19 18:05:30 +03:00
|
|
|
|
/** General settings — app-level behavior. Version DISPLAY and plans login
|
|
|
|
|
|
* live outside the record (package.json / auth.json) by owner decision. */
|
|
|
|
|
|
export interface GeneralSettings {
|
|
|
|
|
|
/** Auto-update the app (default ON). Mechanism ships in a later wave. */
|
|
|
|
|
|
autoUpdate?: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Agent settings — everything about the non-conversation machinery. */
|
|
|
|
|
|
export interface AgentSettings {
|
|
|
|
|
|
/** Background-role model/thinking overrides (absent role = config pin). */
|
|
|
|
|
|
models?: Partial<Record<BackgroundModelRole, RoleModelOverride>>;
|
|
|
|
|
|
/** Tool toggles keyed by tool id (absent key = enabled). User-level
|
|
|
|
|
|
* only — no per-chat override (owner decision 2026-09-19). */
|
|
|
|
|
|
tools?: Record<string, boolean>;
|
2026-09-22 00:02:07 +03:00
|
|
|
|
/** Search-engine preference (search wave, 2026-09-20). OPTIONAL by
|
|
|
|
|
|
* owner decision: setting a key auto-activates its engine — the user
|
|
|
|
|
|
* never chooses an engine to make search work. An explicit choice only
|
|
|
|
|
|
* pins the preference (e.g. when BOTH keys are present); the chosen
|
|
|
|
|
|
* engine must still have its key, else search is honestly unavailable.
|
|
|
|
|
|
* Absent + no key = unavailable (no silent fallback, no default). */
|
|
|
|
|
|
search?: { engine?: SearchEngineId };
|
2026-09-19 18:05:30 +03:00
|
|
|
|
/** System-prompt override SWITCHES keyed by segment path (absent key =
|
|
|
|
|
|
* off). Switch-on requires the override file to exist (enforced by the
|
|
|
|
|
|
* route); the switch is explicit so users keep a file on disk but run
|
|
|
|
|
|
* the source default to compare (owner decision 2026-09-19). */
|
|
|
|
|
|
prompts?: Record<string, boolean>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Markdown-rendering knobs — the bare-bones set (owner decision). */
|
|
|
|
|
|
export interface MdRenderingSettings {
|
|
|
|
|
|
/** Heading font weight (100–900, default 900). CSS resolves the nearest
|
|
|
|
|
|
* available weight of the chosen font — no font introspection needed. */
|
|
|
|
|
|
headingWeight?: number;
|
|
|
|
|
|
/** Color for **bold** spans (default #ffffff). */
|
|
|
|
|
|
boldColor?: string | null;
|
|
|
|
|
|
/** Color for *italic* spans (default #ffffff). */
|
|
|
|
|
|
italicColor?: string | null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Appearance settings — mode, colors, type, markdown emphasis. */
|
|
|
|
|
|
export interface AppearanceSettings {
|
|
|
|
|
|
/** "dark" | "light" (default dark). */
|
|
|
|
|
|
mode?: "dark" | "light";
|
|
|
|
|
|
/** The one user-chosen color; everything else derives per-mode in code.
|
|
|
|
|
|
* null = the mode's default background. */
|
|
|
|
|
|
background?: string | null;
|
|
|
|
|
|
/** The main reading font (everything that is Source Sans 3 today), by
|
|
|
|
|
|
* name — user-chosen from installed fonts. null = Source Sans 3. */
|
|
|
|
|
|
fontFamily?: string | null;
|
|
|
|
|
|
/** Reading font size in px (12–24, default 16). Headings render as
|
|
|
|
|
|
* multiples of this, so one knob scales the document coherently. */
|
|
|
|
|
|
proseFontSize?: number;
|
|
|
|
|
|
/** Session names in CMU Serif (default true — today's look). */
|
|
|
|
|
|
sessionNameSerif?: boolean;
|
|
|
|
|
|
mdRendering?: MdRenderingSettings;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Knowledge-graph settings — a strict dependency ladder (owner decision
|
|
|
|
|
|
* 2026-09-19): graph → embedding → clustering → labeling, each rung only
|
|
|
|
|
|
* meaningful when its parent is on (the UI grays dependents out; stored
|
|
|
|
|
|
* values persist but resolve inert). All default true. */
|
|
|
|
|
|
export interface GraphSettings {
|
|
|
|
|
|
/** MASTER: off = no extraction, no auditor, no embedding, no
|
|
|
|
|
|
* graph-context injection, no clustering. Data stays stored. */
|
|
|
|
|
|
enabled?: boolean;
|
|
|
|
|
|
/** Vector embedding + graph-context injection (consumes vectors even
|
|
|
|
|
|
* without clustering — hence its own rung). */
|
|
|
|
|
|
embedding?: boolean;
|
|
|
|
|
|
/** Cluster computation + settle (needs embedding). */
|
|
|
|
|
|
clustering?: boolean;
|
|
|
|
|
|
/** The LLM cluster-namer (needs clustering). */
|
|
|
|
|
|
labeling?: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** THE record. Every section optional (delta); unknown top-level keys are
|
|
|
|
|
|
* preserved verbatim by the sanitizer (forward-compat with newer records). */
|
|
|
|
|
|
export interface UserSettings {
|
|
|
|
|
|
version: number;
|
|
|
|
|
|
general?: GeneralSettings;
|
|
|
|
|
|
/** Today's default-settings.json content — the new-session surfaces'
|
|
|
|
|
|
* controls. NOT shown in the settings panel (owner decision). */
|
|
|
|
|
|
chat?: ChatSettings;
|
|
|
|
|
|
agent?: AgentSettings;
|
2026-09-22 00:14:56 +03:00
|
|
|
|
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
|
|
|
|
|
|
models?: ModelsSettings;
|
2026-09-19 18:05:30 +03:00
|
|
|
|
appearance?: AppearanceSettings;
|
|
|
|
|
|
graph?: GraphSettings;
|
|
|
|
|
|
[key: string]: unknown;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Factory defaults for everything core can pin (owner decisions 2026-09-19).
|
|
|
|
|
|
* Model-role defaults live in config.ts's MODELS (the compiled factory
|
|
|
|
|
|
* layer); the chat section has NO factory model (BYOK first boot doctrine
|
|
|
|
|
|
* — "no model" until the user picks one); tools default on; prompts
|
|
|
|
|
|
* default off — hence their absence here. */
|
|
|
|
|
|
export interface FactorySettings {
|
|
|
|
|
|
version: number;
|
|
|
|
|
|
general: Required<GeneralSettings>;
|
|
|
|
|
|
appearance: {
|
|
|
|
|
|
mode: "dark" | "light";
|
|
|
|
|
|
background: string | null;
|
|
|
|
|
|
fontFamily: string | null;
|
|
|
|
|
|
proseFontSize: number;
|
|
|
|
|
|
sessionNameSerif: boolean;
|
|
|
|
|
|
mdRendering: {
|
|
|
|
|
|
headingWeight: number;
|
|
|
|
|
|
boldColor: string | null;
|
|
|
|
|
|
italicColor: string | null;
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
graph: Required<GraphSettings>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const FACTORY_SETTINGS: FactorySettings = {
|
|
|
|
|
|
version: CURRENT_SETTINGS_VERSION,
|
|
|
|
|
|
general: { autoUpdate: true },
|
|
|
|
|
|
appearance: {
|
|
|
|
|
|
mode: "dark",
|
|
|
|
|
|
background: null, // the dark default (#141414) lives in the mode's derivation curve
|
|
|
|
|
|
fontFamily: null, // Source Sans 3
|
|
|
|
|
|
proseFontSize: 16,
|
|
|
|
|
|
sessionNameSerif: true,
|
|
|
|
|
|
mdRendering: { headingWeight: 900, boldColor: null, italicColor: null },
|
|
|
|
|
|
},
|
|
|
|
|
|
graph: { enabled: true, embedding: true, clustering: true, labeling: true },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Sanitizer ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
const PROSE_FONT_MIN = 12;
|
|
|
|
|
|
const PROSE_FONT_MAX = 24;
|
|
|
|
|
|
|
|
|
|
|
|
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max);
|
|
|
|
|
|
|
|
|
|
|
|
/** #rgb / #rrggbb (case-insensitive) — the only color format we accept. */
|
|
|
|
|
|
export function isHexColor(value: string): boolean {
|
|
|
|
|
|
return /^#[0-9a-fA-F]{3}$/.test(value) || /^#[0-9a-fA-F]{6}$/.test(value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Coerce an arbitrary hex-or-null field: null and valid hex pass through,
|
|
|
|
|
|
* anything else becomes null (the factory/per-mode default). */
|
|
|
|
|
|
function sanitizeHexOrNull(value: unknown): string | null {
|
|
|
|
|
|
if (value === null) return null;
|
|
|
|
|
|
if (typeof value === "string" && isHexColor(value)) return value;
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const boolOrDrop = (value: unknown): boolean | undefined =>
|
|
|
|
|
|
typeof value === "boolean" ? value : undefined;
|
|
|
|
|
|
|
|
|
|
|
|
function sanitizeGeneral(raw: Record<string, unknown>): GeneralSettings {
|
|
|
|
|
|
const out: GeneralSettings = {};
|
|
|
|
|
|
const autoUpdate = boolOrDrop(raw.autoUpdate);
|
|
|
|
|
|
if (autoUpdate !== undefined) out.autoUpdate = autoUpdate;
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const THINKING_LEVELS: readonly ThinkingLevel[] = [
|
|
|
|
|
|
"off",
|
|
|
|
|
|
"minimal",
|
|
|
|
|
|
"low",
|
|
|
|
|
|
"medium",
|
|
|
|
|
|
"high",
|
|
|
|
|
|
"xhigh",
|
|
|
|
|
|
"max",
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
function sanitizeRoleOverride(raw: unknown): RoleModelOverride | undefined {
|
|
|
|
|
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
|
|
|
|
const r = raw as Record<string, unknown>;
|
|
|
|
|
|
const out: RoleModelOverride = {};
|
|
|
|
|
|
if (
|
|
|
|
|
|
typeof r.model === "object" &&
|
|
|
|
|
|
r.model !== null &&
|
|
|
|
|
|
!Array.isArray(r.model) &&
|
|
|
|
|
|
typeof (r.model as Record<string, unknown>).provider === "string" &&
|
|
|
|
|
|
typeof (r.model as Record<string, unknown>).id === "string"
|
|
|
|
|
|
) {
|
|
|
|
|
|
out.model = {
|
|
|
|
|
|
provider: (r.model as { provider: string }).provider,
|
|
|
|
|
|
id: (r.model as { id: string }).id,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (typeof r.thinkingLevel === "string" && THINKING_LEVELS.includes(r.thinkingLevel as ThinkingLevel)) {
|
|
|
|
|
|
out.thinkingLevel = r.thinkingLevel as ThinkingLevel;
|
|
|
|
|
|
}
|
|
|
|
|
|
return Object.keys(out).length ? out : undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 00:14:56 +03:00
|
|
|
|
/** The models section (model slots wave, 2026-09-20): each slot keeps
|
|
|
|
|
|
* only well-formed parts — the same shape rules as a role override, so a
|
|
|
|
|
|
* stale client can't park junk in a slot. */
|
|
|
|
|
|
function sanitizeModels(raw: Record<string, unknown>): ModelsSettings {
|
|
|
|
|
|
const out: ModelsSettings = {};
|
|
|
|
|
|
for (const slot of MODEL_SLOTS) {
|
|
|
|
|
|
const cleaned = sanitizeRoleOverride(raw[slot]);
|
|
|
|
|
|
if (cleaned) out[slot] = cleaned;
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 18:05:30 +03:00
|
|
|
|
function sanitizeBooleanMap(raw: unknown): Record<string, boolean> | undefined {
|
|
|
|
|
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
|
|
|
|
const out: Record<string, boolean> = {};
|
|
|
|
|
|
for (const [key, value] of Object.entries(raw)) {
|
|
|
|
|
|
if (typeof value === "boolean") out[key] = value;
|
|
|
|
|
|
}
|
|
|
|
|
|
return Object.keys(out).length ? out : undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sanitizeAgent(raw: Record<string, unknown>): AgentSettings {
|
|
|
|
|
|
const out: AgentSettings = {};
|
|
|
|
|
|
if (raw.models !== null && typeof raw.models === "object" && !Array.isArray(raw.models)) {
|
|
|
|
|
|
const models: Partial<Record<BackgroundModelRole, RoleModelOverride>> = {};
|
|
|
|
|
|
for (const [role, override] of Object.entries(raw.models as Record<string, unknown>)) {
|
|
|
|
|
|
if ((BACKGROUND_MODEL_ROLES as readonly string[]).includes(role)) {
|
|
|
|
|
|
const cleaned = sanitizeRoleOverride(override);
|
|
|
|
|
|
if (cleaned) models[role as BackgroundModelRole] = cleaned;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Object.keys(models).length) out.models = models;
|
|
|
|
|
|
}
|
|
|
|
|
|
const tools = sanitizeBooleanMap(raw.tools);
|
|
|
|
|
|
if (tools) out.tools = tools;
|
|
|
|
|
|
const prompts = sanitizeBooleanMap(raw.prompts);
|
|
|
|
|
|
if (prompts) out.prompts = prompts;
|
2026-09-22 00:02:07 +03:00
|
|
|
|
// Search preference: lenient read side (an unknown engine id drops;
|
|
|
|
|
|
// the write side's parser is strict — a stale client can't brick the
|
|
|
|
|
|
// record, it just loses the pin and auto-detection resumes).
|
|
|
|
|
|
if (raw.search !== null && typeof raw.search === "object" && !Array.isArray(raw.search)) {
|
|
|
|
|
|
const engine = (raw.search as Record<string, unknown>).engine;
|
|
|
|
|
|
if (engine === "tavily" || engine === "brave") out.search = { engine };
|
|
|
|
|
|
}
|
2026-09-19 18:05:30 +03:00
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sanitizeMdRendering(raw: unknown): MdRenderingSettings {
|
|
|
|
|
|
const out: MdRenderingSettings = {};
|
|
|
|
|
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return out;
|
|
|
|
|
|
const r = raw as Record<string, unknown>;
|
|
|
|
|
|
if (typeof r.headingWeight === "number" && Number.isFinite(r.headingWeight)) {
|
|
|
|
|
|
out.headingWeight = clamp(Math.round(r.headingWeight), 100, 900);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (r.boldColor !== undefined) out.boldColor = sanitizeHexOrNull(r.boldColor);
|
|
|
|
|
|
if (r.italicColor !== undefined) out.italicColor = sanitizeHexOrNull(r.italicColor);
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sanitizeAppearance(raw: Record<string, unknown>): AppearanceSettings {
|
|
|
|
|
|
const out: AppearanceSettings = {};
|
|
|
|
|
|
if (raw.mode === "dark" || raw.mode === "light") out.mode = raw.mode;
|
|
|
|
|
|
if (raw.background !== undefined) out.background = sanitizeHexOrNull(raw.background);
|
|
|
|
|
|
if (raw.fontFamily !== undefined) {
|
|
|
|
|
|
out.fontFamily = typeof raw.fontFamily === "string" && raw.fontFamily.trim() ? raw.fontFamily : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (typeof raw.proseFontSize === "number" && Number.isFinite(raw.proseFontSize)) {
|
|
|
|
|
|
out.proseFontSize = clamp(Math.round(raw.proseFontSize), PROSE_FONT_MIN, PROSE_FONT_MAX);
|
|
|
|
|
|
}
|
|
|
|
|
|
const sessionNameSerif = boolOrDrop(raw.sessionNameSerif);
|
|
|
|
|
|
if (sessionNameSerif !== undefined) out.sessionNameSerif = sessionNameSerif;
|
|
|
|
|
|
if (raw.mdRendering !== undefined) out.mdRendering = sanitizeMdRendering(raw.mdRendering);
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sanitizeGraph(raw: Record<string, unknown>): GraphSettings {
|
|
|
|
|
|
const out: GraphSettings = {};
|
|
|
|
|
|
const enabled = boolOrDrop(raw.enabled);
|
|
|
|
|
|
if (enabled !== undefined) out.enabled = enabled;
|
|
|
|
|
|
const embedding = boolOrDrop(raw.embedding);
|
|
|
|
|
|
if (embedding !== undefined) out.embedding = embedding;
|
|
|
|
|
|
const clustering = boolOrDrop(raw.clustering);
|
|
|
|
|
|
if (clustering !== undefined) out.clustering = clustering;
|
|
|
|
|
|
const labeling = boolOrDrop(raw.labeling);
|
|
|
|
|
|
if (labeling !== undefined) out.labeling = labeling;
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Coerce arbitrary JSON (disk file, network body) into a valid delta record:
|
|
|
|
|
|
* known fields validated strictly (invalid ones drop/clamp per module doc),
|
|
|
|
|
|
* UNKNOWN KEYS PRESERVED VERBATIM (a newer record must survive an older
|
|
|
|
|
|
* app). Never throws. Non-objects → null.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function sanitizeUserSettings(raw: unknown): UserSettings | null {
|
|
|
|
|
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
|
|
|
|
const r = raw as Record<string, unknown>;
|
|
|
|
|
|
const out: UserSettings = {
|
|
|
|
|
|
version: typeof r.version === "number" && Number.isInteger(r.version) && r.version >= 1 ? r.version : CURRENT_SETTINGS_VERSION,
|
|
|
|
|
|
};
|
|
|
|
|
|
if (r.general !== null && typeof r.general === "object" && !Array.isArray(r.general)) {
|
|
|
|
|
|
out.general = sanitizeGeneral(r.general as Record<string, unknown>);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (r.chat !== null && typeof r.chat === "object" && !Array.isArray(r.chat)) {
|
|
|
|
|
|
// The chat section is ChatSettings — its fields are plain and the
|
|
|
|
|
|
// section is written exclusively through the validated route
|
|
|
|
|
|
// (parseSettingsBody + validateChatSettings), so pass through as-is;
|
|
|
|
|
|
// a hand-corrupted value fails loudly downstream, same as today.
|
|
|
|
|
|
out.chat = r.chat as ChatSettings;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) {
|
|
|
|
|
|
out.agent = sanitizeAgent(r.agent as Record<string, unknown>);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (r.appearance !== null && typeof r.appearance === "object" && !Array.isArray(r.appearance)) {
|
|
|
|
|
|
out.appearance = sanitizeAppearance(r.appearance as Record<string, unknown>);
|
|
|
|
|
|
}
|
2026-09-22 00:14:56 +03:00
|
|
|
|
if (r.models !== null && typeof r.models === "object" && !Array.isArray(r.models)) {
|
|
|
|
|
|
const models = sanitizeModels(r.models as Record<string, unknown>);
|
|
|
|
|
|
if (Object.keys(models).length) out.models = models;
|
|
|
|
|
|
}
|
2026-09-19 18:05:30 +03:00
|
|
|
|
if (r.graph !== null && typeof r.graph === "object" && !Array.isArray(r.graph)) {
|
|
|
|
|
|
out.graph = sanitizeGraph(r.graph as Record<string, unknown>);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Unknown top-level keys ride along untouched (forward-compat).
|
|
|
|
|
|
for (const [key, value] of Object.entries(r)) {
|
|
|
|
|
|
if (!(key in out)) out[key] = value;
|
|
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|