using System.IO; using System.Text.Json; using System.Text.Json.Serialization; namespace LlamaApp; /// /// User-configured application settings, persisted as JSON in the app's /// per-user local data folder. Holds the Hugging Face access token (for /// authenticated downloads / private repos) and the local models cache /// directory (where GGUF files live, shared with the HF cache layout). /// public sealed class Settings { // Declared BEFORE Current so its static field initializer runs first. // Static field initializers run in textual order, and Current's // initializer calls — if SettingsPath were declared // below it, Load would see SettingsPath == null (still its default), // would return false, and every saved setting // (HuggingFace token, cache directory, startup hint) would be silently // discarded on every launch. Keep this above any member that calls Load. private static readonly string SettingsPath = Path.Combine( Common.AppData.Root, "settings.json"); /// Singleton instance; loaded lazily on first access and cached. public static Settings Current { get; } = Load(); static Settings() { // Make sure the directory exists so Save() never throws on a missing dir. try { Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!); } catch { /* best-effort */ } } /// /// Hugging Face access token (hf_…). Optional — only needed for downloading /// private/gated repos. Never serialized: writes it to /// through DPAPI (bound to this /// user on this machine), so the settings file carries no working /// credential if it is copied or stolen. Leave empty for anonymous access /// to public repos. /// [JsonIgnore] public string HuggingFaceToken { get; set; } = ""; /// /// DPAPI-protected form of — the only /// representation ever written to settings.json. Written by /// ; restored (best-effort, empty on any failure) by /// . Not for direct use. /// [JsonPropertyName("ProtectedHuggingFaceToken")] public string ProtectedHuggingFaceToken { get; set; } = ""; /// /// Local directory where downloaded GGUF models are cached. Defaults to the /// standard Hugging Face cache (%USERPROFILE%\.cache\huggingface\hub) /// so models are shared with llama.cpp and other HF-aware tools. /// public string CacheDirectory { get; set; } = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "huggingface", "hub"); /// /// Port the local llama server listens on (default 9931). Read once at /// startup when the singleton is created /// (App.OnLaunched), so a changed value takes effect on the next app /// launch. Valid range: 1–65535; out-of-range values fall back to the /// default at startup. /// public int ServerPort { get; set; } = Llama.LlamaManager.DefaultServerPort; /// /// IPv4 address the llama server binds to (--host): the loopback /// 127.0.0.1 (the default, reachable only from this machine), /// 0.0.0.0 for every interface, or a specific interface's address /// from Settings → Llama. Read once at startup when the /// singleton is created, so a changed /// value takes effect on the next app launch — the app's own REST client /// follows the same address (loopback when the server binds all /// interfaces). /// public string ListenAddress { get; set; } = LlamaApp.Common.ListenAddresses.Localhost; /// /// Key the app generates and hands to the llama server via the /// LLAMA_API_KEY environment variable /// when is not loopback, so a network-exposed /// server isn't an open control API. Reused across restarts (and across the /// switch back to a non-loopback bind) so an adopted server stays /// reachable. Empty for the default loopback bind, which needs no key. /// /// Like , never serialized: /// writes it to /// through DPAPI (bound to this user on this machine), so a copied or /// stolen settings file carries no working credential. The server reads a /// materialized copy at launch (see LlamaManager.StartServerAsync); /// the key is a LAN access token for the model server — not a user /// identity credential — but it belongs behind the same door as the HF /// token. /// [JsonIgnore] public string ServerApiKey { get; set; } = ""; /// /// DPAPI-protected form of — the only /// representation ever written to settings.json. Written by /// ; restored (best-effort, empty on any failure) by /// . Not for direct use. /// [JsonPropertyName("ProtectedServerApiKey")] public string ProtectedServerApiKey { get; set; } = ""; /// /// Seconds of idleness after which the llama server unloads the model from /// memory: 300 (5 min), 900 (15 min), 3600 (1 hour), or -1 (never, the /// default). Handed to the server as --sleep-idle-seconds at launch /// (see ), so a changed /// value takes effect on the next server start. An idled-out model stays /// listed and wakes transparently on the next request. /// public int IdleUnloadSeconds { get; set; } = -1; /// /// Maximum number of models the llama server loads simultaneously in /// router mode: 0 (the default) means unlimited, any positive value caps /// how many per-model child servers run at once. Handed to the server as /// --models-max at launch (see /// ), so a changed value /// takes effect on the next server start. /// public int MaxLoadedModels { get; set; } = 0; /// /// KV cache quantization type for the key tensor, handed to the server as /// --cache-type-k at launch. One of f32, f16, /// bf16, q8_0, q4_0, q4_1, iq4_nl, /// q5_0, or q5_1. Defaults to f16, llama.cpp's own /// default; lower-precision types shrink the KV cache so a larger context /// fits in GPU memory. A changed value takes effect on the next server /// start. /// public string CacheTypeK { get; set; } = "f16"; /// /// KV cache quantization type for the value tensor, handed to the server /// as --cache-type-v at launch. Same value set and default as /// . K and V can be quantized independently. /// public string CacheTypeV { get; set; } = "f16"; /// /// Free-form extra llama serve arguments, appended after the /// built-in flags so they can override them (llama.cpp honors the last /// occurrence). Stored exactly as typed — including newlines, which the /// tokenizer treats as whitespace — and split into argv tokens at launch /// (see ). Applies the /// next time the llama server starts; a bad flag makes the server exit /// with an error, which the app surfaces as the failure reason. /// public string CustomServeArguments { get; set; } = ""; /// /// Whether Llama should launch automatically when the user signs in to /// Windows. The authoritative state is the presence of the startup /// shortcut managed by (in the user's Startup /// folder); this value is a persisted hint so the Settings checkbox can /// reflect intent on first open before re-reading the OS state. /// public bool LaunchAtStartup { get; set; } = false; /// /// Whether the one-time first-run hint ("Llama lives in the system /// tray; Alt+Space opens the chat overlay") has been shown. Persisted so /// the toast fires exactly once, on the first launch. /// public bool TrayHintShown { get; set; } = false; /// /// Per-model context-length preferences chosen in the model details view, /// keyed by the server model id (repo:quant) — context length is a /// per-model choice, not a global one (a 1B model and a 70B model want very /// different defaults). Applied as ctx_size on the next /// /models/load (see ). /// public Dictionary ModelContextLengths { get; set; } = new(); /// /// Per-model speculative-decoding draft heads, keyed by the server model /// id (repo:quant): the value is the llama.cpp --spec-type /// token (draft-dflash, draft-dspark, draft-mtp), or /// an empty string for "checked, the repo ships none" (a negative cache /// so the Hub isn't re-queried on every session). Detected from the /// repo's file listing (see ) when the /// model is downloaded and for cached models at startup; rendered into /// the --models-preset INI so the server downloads and wires the /// sidecar at the model's next load. Models that ship no head carry no /// spec decoding — the setting is not user-editable. /// public Dictionary ModelSpeculativeTypes { get; set; } = new(); /// /// Folders enrolled for model discovery (Settings → General → Model /// folders): places the user already keeps GGUF models — another drive, /// a private stash — scanned recursively and surfaced as loadable models /// without copying or moving anything (each discovery becomes a /// model = path section in the llama server's --models-preset /// INI). Kept strictly separate from — that /// stays where llama downloads its models, and cache contents are never /// scanned from here (enrolling %LOCALAPPDATA% must not duplicate the /// cache). Paths as given by the folder picker; missing folders are /// skipped at scan time. Takes effect at the next server start, and /// immediately (preset reload) when changed while the server runs. /// public List ModelFolders { get; set; } = []; private static Settings Load() { try { if (File.Exists(SettingsPath)) { var json = File.ReadAllText(SettingsPath); // One parse: deserialization and the legacy-secret migration // read from the same JsonDocument. using var doc = JsonDocument.Parse(json); var s = doc.RootElement.Deserialize(SettingsJsonContext.Default.Settings); if (s != null) { RestoreSecrets(s, doc.RootElement); return s; } } } catch (Exception ex) { // Corrupt or unreadable settings — fall back to defaults rather than // crashing the app. The user can re-enter values in the Settings UI. Common.Log.Warn(ex, "settings load failed; using defaults"); } return new Settings(); } /// /// Moves the token from the raw JSON into : /// the DPAPI blob wins; a file written before the token was protected /// still carries the plaintext field and is migrated (and will be /// rewritten protected on the next ). Best-effort — a /// blob that can't be decrypted (another machine/user, corruption) reads /// back as empty and the token is simply unset. Internal for tests. /// internal static void RestoreSecrets(Settings s, string json) { try { using var doc = JsonDocument.Parse(json); RestoreSecrets(s, doc.RootElement); } catch (JsonException) { // Handled by Load's caller path — keep whatever deserialized. } } /// /// against an already /// parsed document, so the settings load parses settings.json once. /// internal static void RestoreSecrets(Settings s, JsonElement root) { if (root.ValueKind != JsonValueKind.Object) return; if (root.TryGetProperty("HuggingFaceToken", out var legacy) && legacy.ValueKind == JsonValueKind.String) { var plaintext = legacy.GetString() ?? ""; if (plaintext.Length > 0 && string.IsNullOrWhiteSpace(s.ProtectedHuggingFaceToken)) s.HuggingFaceToken = plaintext; } if (root.TryGetProperty("ProtectedHuggingFaceToken", out var blob) && blob.ValueKind == JsonValueKind.String) { var plaintext = Common.SecretProtector.Unprotect(blob.GetString()); if (plaintext.Length > 0) s.HuggingFaceToken = plaintext; } // The server API key: same legacy-plaintext-then-blob order as the // token above. The plaintext "ServerApiKey" property predates // DPAPI protection; it is adopted only when no protected form // exists, and disappears from the file on the next Save (the // property is [JsonIgnore]). if (root.TryGetProperty("ServerApiKey", out var legacyKey) && legacyKey.ValueKind == JsonValueKind.String) { var plaintext = legacyKey.GetString() ?? ""; if (plaintext.Length > 0 && string.IsNullOrWhiteSpace(s.ProtectedServerApiKey)) s.ServerApiKey = plaintext; } if (root.TryGetProperty("ProtectedServerApiKey", out var keyBlob) && keyBlob.ValueKind == JsonValueKind.String) { var plaintext = Common.SecretProtector.Unprotect(keyBlob.GetString()); if (plaintext.Length > 0) s.ServerApiKey = plaintext; } } /// /// Persists the current values to settings.json with the Hugging /// Face token and the server API key DPAPI-protected. Best-effort: a /// failure (e.g. disk full) is swallowed and returns false rather than /// surfacing in the UI flow. /// public bool Save() { try { ProtectedHuggingFaceToken = Common.SecretProtector.Protect(HuggingFaceToken); ProtectedServerApiKey = Common.SecretProtector.Protect(ServerApiKey); // The three collections below are shared BY REFERENCE with // background readers/writers (LlamaManager's INI renderer and // folder scan, the draft-head poller — see // DraftHeadDetection.Apply's lock note), and the serializer // traverses them. Save takes the same per-collection locks those // sites use, in declaration order (no lock holder ever acquires // another, so nesting here is deadlock-free). Without this, a // mutation racing the serialization failed the whole save to a // caught "collection was modified". string json; lock (ModelContextLengths) lock (ModelSpeculativeTypes) lock (ModelFolders) json = JsonSerializer.Serialize(this, SettingsJsonContext.Default.Settings); File.WriteAllText(SettingsPath, json); return true; } catch (Exception ex) { Common.Log.Warn(ex, "settings save failed"); return false; } } }