From 2096843d5032b410eef4da110c66e80068faaf11 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 14:55:14 -0700 Subject: [PATCH] AI: status fetch, subscriber gate and daily-quota surfacing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the shared plumbing every AI affordance in the app hangs off, so the gating rule lives in one place instead of being re-derived per feature. `AiAvailabilityService` fetches GET /api/ai/status once per session (single-flighted, so panels constructed in the same frame share one request), caches it, and exposes the one `IsAiAvailable` gate: subscriber AND providers non-empty. Unknown is false — a status fetch that fails leaves AI hidden rather than guessing "yes" and showing a subscriber-only control to a free account. The cache is per-session, not per-process: it resets on CurrentUser change, and a generation counter makes an in-flight GET discard its own result so a response for the previous account can't repopulate the cache after a sign-out. The gate deliberately excludes quota, unlike AiStatus.CanUseAi. A subscriber who has spent today's 50 should still see the controls with an in-place "out until tomorrow" line, not watch them disappear — so exhaustion is surfaced through IsQuotaExhausted/QuotaLabel instead of hiding anything. `AiQuotaChip` is the "45 of 50 left today" line #10 asks for wherever an AI action is offered — one XAML tag, no attributes, no host code-behind, and it collapses itself when the gate is closed. It binds a label derived from `AiQuota.RemainingOrComputed`, never `.Remaining`: /api/ai/status sends `remaining` but /api/ai/suggest does not (re-verified live), so after a suggestion the raw field is null and only the computed value is correct. `AiNotice` + `AiNoticeBar` render failures in place. quota_exceeded and rate_limited get distinct amber treatments with their own labels rather than collapsing into one red error, and nothing in the path is a MessageBox, Popup or adorner — the messages are a bound Border and two TextBlocks, so they structurally cannot block the dispatcher. `AiPanelViewModelBase.RunAiAsync` is the single funnel all /api/ai/* traffic goes through, which is what makes three rules structural rather than aspirational: it never retries (a failed call that reached the model already spent a unit, so an auto-retry silently doubles the bill), it refuses to start when the gate is closed (a stale binding can't spend anything), and it folds the echoed quota back into the service on success while marking the allowance gone on a 429 — so the chip never disagrees with the message beside it. Live re-verification (test account, deviceLabel issue-10-15-probe): GET /api/ai/status returns {"subscriber":true,"providers":["anthropic"], "defaultModels":{anthropic,openai,gemini},"quota":{usedToday,dailyLimit, remaining}} exactly as #9 recorded. Four input-validation 422s cost zero quota (usedToday unchanged across all four) with precise server prose worth surfacing verbatim — "List not found.", "A list must be selected.", "Only http(s) URLs are supported.", "Document not found." One probe cost a unit and is worth recording: an unrecognized context.mode is NOT cheap-rejected — it falls through to the model and comes back 422 invalid_ai_output, billed. The four-value enum already prevents that, but it confirms pre-flight has to be exhaustive, not best-effort. No AI control is hosted on this branch — nothing on it offers an AI action yet. The first consumers are the next two PRs in the stack (#14 Powered Templates, #15 Powered Document), both of which route every affordance through `IsAiAvailable` and drop in `AiQuotaChip` + `AiNoticeBar`. Closes #10 Co-Authored-By: Claude Opus 5 --- .../Services/AiAvailabilityService.cs | 278 ++++++++++++++++++ InterlinedList/ViewModels/AiNotice.cs | 111 +++++++ .../ViewModels/AiPanelViewModelBase.cs | 242 +++++++++++++++ InterlinedList/Views/AiConverters.cs | 81 +++++ InterlinedList/Views/AiNoticeBar.xaml | 45 +++ InterlinedList/Views/AiNoticeBar.xaml.cs | 32 ++ InterlinedList/Views/AiQuotaChip.xaml | 52 ++++ InterlinedList/Views/AiQuotaChip.xaml.cs | 37 +++ 8 files changed, 878 insertions(+) create mode 100644 InterlinedList/Services/AiAvailabilityService.cs create mode 100644 InterlinedList/ViewModels/AiNotice.cs create mode 100644 InterlinedList/ViewModels/AiPanelViewModelBase.cs create mode 100644 InterlinedList/Views/AiConverters.cs create mode 100644 InterlinedList/Views/AiNoticeBar.xaml create mode 100644 InterlinedList/Views/AiNoticeBar.xaml.cs create mode 100644 InterlinedList/Views/AiQuotaChip.xaml create mode 100644 InterlinedList/Views/AiQuotaChip.xaml.cs diff --git a/InterlinedList/Services/AiAvailabilityService.cs b/InterlinedList/Services/AiAvailabilityService.cs new file mode 100644 index 0000000..06aa36b --- /dev/null +++ b/InterlinedList/Services/AiAvailabilityService.cs @@ -0,0 +1,278 @@ +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; +using InterlinedList.Models; + +namespace InterlinedList.Services; + +/// +/// The single source of truth for "may this account be shown AI controls at +/// all, and how much of today's quota is left". Fetches +/// GET /api/ai/status once per session, caches it, and exposes +/// — the one gate every AI affordance in the app +/// binds its visibility to. +/// +/// Why this exists as its own singleton rather than a field on +/// : every AI panel needs the same answer and must +/// not each spend a round-trip on it, and adding a member to +/// would conflict with several other open branches +/// that also touch it. is created lazily off +/// , so nothing else in the app changes. +/// +/// The gate deliberately excludes quota. +/// also requires unspent quota, which is the wrong rule for visibility: +/// a subscriber who has used all 50 of today's generations should still see the +/// AI controls with a "you're out until tomorrow" line in place, not watch them +/// vanish. So visibility = Subscriber && providers non-empty, and +/// quota exhaustion is reported through / +/// instead. +/// +/// Verified live 2026-09-16 on the test account: +/// {"subscriber":true,"providers":["anthropic"],"defaultModels":{…}, +/// "quota":{"usedToday":5,"dailyLimit":50,"remaining":45}} +/// +/// Threading: this is an bound directly by +/// views, so mutate it from the UI thread. Every public method here is awaited +/// from a ViewModel command (WPF resumes continuations on the dispatcher), and +/// / are synchronous callbacks +/// made from the same place. +/// +public sealed partial class AiAvailabilityService : ObservableObject +{ + private static readonly Lazy LazyShared = + new(() => new AiAvailabilityService(AppServices.Session)); + + /// The process-wide instance. Panels use this rather than newing one up. + public static AiAvailabilityService Shared => LazyShared.Value; + + private readonly SessionService _session; + + /// + /// Single-flight guard: several AI panels can be constructed in the same + /// frame (switching to Lists then Documents), and they must share one + /// in-flight GET rather than each issuing their own. + /// + private Task? _inFlight; + + /// + /// Bumped by and . A response + /// that comes back stamped with an older generation is discarded: signing + /// out (or into another account) while a status GET is in flight must not + /// let the previous account's subscriber flag land in the cache and open + /// the gate for someone who isn't entitled to it. + /// + private int _generation; + + public AiAvailabilityService(SessionService session) + { + _session = session ?? throw new ArgumentNullException(nameof(session)); + + // A different account has a different subscriber state and a different + // quota, so the cache is per-session, not per-process. + _session.PropertyChanged += OnSessionChanged; + } + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsLoaded))] + [NotifyPropertyChangedFor(nameof(IsAiAvailable))] + [NotifyPropertyChangedFor(nameof(Quota))] + [NotifyPropertyChangedFor(nameof(RemainingToday))] + [NotifyPropertyChangedFor(nameof(DailyLimit))] + [NotifyPropertyChangedFor(nameof(QuotaLabel))] + [NotifyPropertyChangedFor(nameof(IsQuotaExhausted))] + [NotifyPropertyChangedFor(nameof(DefaultModel))] + [NotifyPropertyChangedFor(nameof(UnavailableReason))] + private AiStatus? status; + + /// True while the one-per-session GET is running. + [ObservableProperty] + private bool isLoading; + + /// + /// Set when the status fetch itself failed (offline, 401, 5xx). AI controls + /// stay hidden in that case — the app can't prove the account is entitled, + /// and guessing "yes" would show a subscriber-only control to a free + /// account, which #10 forbids outright. + /// + [ObservableProperty] + private string? loadError; + + private void OnSessionChanged(object? sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(SessionService.CurrentUser)) + Reset(); + } + + // ── The gate ──────────────────────────────────────────────────────────────── + + public bool IsLoaded => Status is not null; + + /// + /// The gate. Bind every AI button, tab, panel and menu entry's + /// visibility to this and nothing else. + /// + /// providers: [] means the server has no ANTHROPIC_API_KEY, so + /// a call would come back 409 no_provider_configured — there is + /// nothing a user could do about it, so the control is hidden rather than + /// shown-and-failing. subscriber: false is a free account, and per + /// #10 those see no AI controls at all (not a disabled one, not an upsell). + /// Unknown (not loaded yet, or the fetch failed) is also false: hidden is + /// the safe default in both directions. + /// + public bool IsAiAvailable => Status is { Subscriber: true } status && status.HasProviderConfigured; + + /// The model /suggest will run, for the "powered by" byline. Anthropic-only app-wide. + public string? DefaultModel => Status?.DefaultModel; + + /// Why AI isn't offered, when it isn't. Diagnostic only — never shown to a free account. + public string? UnavailableReason => Status is null + ? LoadError ?? "AI status hasn't loaded yet." + : Status.UnavailableReason; + + // ── Quota surfacing ───────────────────────────────────────────────────────── + + public AiQuota? Quota => Status?.Quota; + + public int RemainingToday => Status?.Quota.RemainingOrComputed ?? 0; + + public int DailyLimit => Status?.Quota.DailyLimit ?? AiFeatureLimits.DailyGenerationLimit; + + /// "45 of 50 left today" — the line #10 asks to appear wherever an AI action is offered. + public string QuotaLabel => Status is null + ? "Checking AI quota…" + : $"{RemainingToday} of {DailyLimit} left today"; + + public bool IsQuotaExhausted => Status?.Quota.IsExhausted ?? false; + + // ── Loading ───────────────────────────────────────────────────────────────── + + /// + /// Fetch the status if it hasn't been fetched for this session yet. + /// Idempotent and safe to call from every panel's constructor: concurrent + /// callers await the same request, and a completed one is a no-op. + /// + public Task EnsureLoadedAsync(CancellationToken ct = default) + { + if (Status is not null) return Task.CompletedTask; + return _inFlight ??= LoadAsync(_generation, ct); + } + + /// + /// Re-fetch on demand — after a subscription change, or to reconcile the + /// quota against the server (a /suggest echoes usedToday but omits + /// remaining, so a long session's computed figure can drift if the + /// same account is used elsewhere). + /// + public Task RefreshAsync(CancellationToken ct = default) + => _inFlight = LoadAsync(++_generation, ct); + + private async Task LoadAsync(int generation, CancellationToken ct) + { + IsLoading = true; + LoadError = null; + try + { + var status = await _session.Api.GetAiStatusAsync(ct); + + // Superseded by a Reset (sign-out / account switch) or a newer + // Refresh while we were waiting — drop it on the floor. + if (generation != _generation) return null; + + Status = status; + return status; + } + catch (AiApiException ex) + { + // 401 here means the token is gone; anything else means the server + // couldn't answer. Either way AI stays hidden. + if (generation == _generation) LoadError = ex.UserMessage; + AppLog.Warn($"GET /api/ai/status failed ({ex.StatusCode} {ex.RawCode}): {ex.Message}"); + return null; + } + catch (OperationCanceledException) + { + return null; + } + catch (Exception ex) + { + if (generation == _generation) LoadError = "Couldn't check whether AI is available."; + AppLog.Error("GET /api/ai/status failed.", ex); + return null; + } + finally + { + if (generation == _generation) + { + IsLoading = false; + // Let the next EnsureLoadedAsync retry a failed fetch instead + // of caching the failure for the rest of the session. + if (Status is null) _inFlight = null; + } + } + } + + // ── Quota bookkeeping ─────────────────────────────────────────────────────── + + /// + /// Fold the quota a /suggest or /generate echoed back into the cached + /// status, so the chip counts down without another GET. + /// + /// Shape note: the echoed object has usedToday and + /// dailyLimit but no remaining (verified live, twice), + /// so this stores into + /// — keeping the cached status shaped like a + /// /status response and letting the UI keep binding one property. + /// + public void ApplyQuota(AiQuota? quota) + { + if (quota is null || Status is not { } status) return; + + var limit = quota.DailyLimit > 0 ? quota.DailyLimit : status.Quota.DailyLimit; + + Status = new AiStatus + { + Subscriber = status.Subscriber, + Providers = status.Providers, + DefaultModels = status.DefaultModels, + Quota = new AiQuota + { + UsedToday = quota.UsedToday, + DailyLimit = limit, + Remaining = Math.Max(0, limit - quota.UsedToday) + } + }; + } + + /// + /// A 429 quota_exceeded arrived, so today's allowance is gone + /// whatever the cached figure said — reflect that immediately instead of + /// leaving a stale "3 left today" next to a "you're out" message. + /// + public void MarkQuotaExhausted() + { + if (Status is not { } status) return; + + var limit = status.Quota.DailyLimit > 0 ? status.Quota.DailyLimit : AiFeatureLimits.DailyGenerationLimit; + + Status = new AiStatus + { + Subscriber = status.Subscriber, + Providers = status.Providers, + DefaultModels = status.DefaultModels, + Quota = new AiQuota { UsedToday = limit, DailyLimit = limit, Remaining = 0 } + }; + } + + /// + /// Drop the cache (sign-out, or a switch to another account). Bumping the + /// generation makes any in-flight GET discard its own result, so a response + /// for the previous account can't repopulate the cache afterwards. + /// + public void Reset() + { + _generation++; + _inFlight = null; + IsLoading = false; + LoadError = null; + Status = null; + } +} diff --git a/InterlinedList/ViewModels/AiNotice.cs b/InterlinedList/ViewModels/AiNotice.cs new file mode 100644 index 0000000..ff4e016 --- /dev/null +++ b/InterlinedList/ViewModels/AiNotice.cs @@ -0,0 +1,111 @@ +using InterlinedList.Models; +using InterlinedList.Services; + +namespace InterlinedList.ViewModels; + +/// +/// How an should read and look. Kept distinct from +/// because several codes collapse to the same +/// treatment while two of them — and +/// — must stay visibly different from each other and +/// from a generic failure. That's the whole point of #10's last two acceptance +/// criteria. +/// +public enum AiNoticeKind +{ + /// Neutral progress/result copy ("Draft ready — review it below"). + Info, + + /// The user's input needs changing before it's worth spending a unit. Actionable, their side. + Input, + + /// 429 quota_exceeded — today's 50 are gone. Comes back tomorrow, not sooner. + QuotaExceeded, + + /// 429 rate_limited — the 15/60s window tripped. Comes back in seconds. + RateLimited, + + /// 422 invalid_ai_output / refused — the model answered unusably. A unit was still spent. + ModelDeclined, + + /// Everything else: provider_error, 401, an unknown code, a transport failure. + Error +} + +/// +/// One non-blocking, in-place message from an AI call. Rendered as a line of +/// text inside the panel that raised it — never a MessageBox, never +/// anything that blocks the dispatcher (#10). +/// +/// exists because this API's quota accounting is +/// asymmetric: an input-validation rejection costs nothing (verified live — +/// usedToday was unchanged across four of them), but any call that +/// reaches the model costs one of the 50 even when it fails. A user who just +/// lost a unit to invalid_ai_output deserves to be told so, and told to +/// re-word rather than mash the button. +/// +public sealed record AiNotice(AiNoticeKind Kind, string Text, bool Spent = false) +{ + /// True for the states worth an amber (live/pending) treatment rather than red. + public bool IsTransient => Kind is AiNoticeKind.RateLimited or AiNoticeKind.QuotaExceeded or AiNoticeKind.ModelDeclined; + + public bool IsQuotaExceeded => Kind == AiNoticeKind.QuotaExceeded; + public bool IsRateLimited => Kind == AiNoticeKind.RateLimited; + + public static AiNotice Info(string text) => new(AiNoticeKind.Info, text); + + /// A client-side pre-flight rejection. No unit spent — that's the reason pre-flight exists. + public static AiNotice Input(string text) => new(AiNoticeKind.Input, text, Spent: false); + + /// + /// Map a failed AI call onto its message. Branches on + /// only — never on the server's prose — + /// except for invalid_input, where the server's wording is specific + /// and better than anything generic ("List not found.", "A list must be + /// selected.", "Only http(s) URLs are supported." — all verified live). + /// + public static AiNotice From(AiApiException ex) + { + ArgumentNullException.ThrowIfNull(ex); + + return ex.Code switch + { + AiErrorCode.QuotaExceeded => new( + AiNoticeKind.QuotaExceeded, + $"Today's AI allowance is used up ({AiFeatureLimits.DailyGenerationLimit} per rolling 24 hours). " + + "It frees up again as the oldest of today's requests ages out — try again tomorrow.", + Spent: false), + + AiErrorCode.RateLimited => new( + AiNoticeKind.RateLimited, + ex.RetryAfter is { } wait + ? $"Too many AI requests in the last minute. Try again in {Math.Max(1, (int)Math.Ceiling(wait.TotalSeconds))} seconds." + : $"Too many AI requests in the last minute (the limit is {AiFeatureLimits.RateLimitRequestsPerMinute}). Wait a moment and try again.", + Spent: false), + + // Both of these got as far as the model, so a unit is gone. Say so: + // it's the difference between "try again" and "try again, but + // change something first". + AiErrorCode.InvalidAiOutput => new( + AiNoticeKind.ModelDeclined, + "The AI's answer came back in a shape this app couldn't use. That attempt still counted against today's allowance — re-word the request before trying again.", + Spent: true), + + AiErrorCode.Refused => new( + AiNoticeKind.ModelDeclined, + "The AI declined to answer that. That attempt still counted against today's allowance — try different wording.", + Spent: true), + + // The server's own message is the useful one here, and a local + // pre-flight rejection reuses the same code with nothing spent. + AiErrorCode.InvalidInput => new(AiNoticeKind.Input, ex.UserMessage, Spent: false), + + AiErrorCode.ProviderError => new( + AiNoticeKind.Error, + "The AI provider didn't answer. That attempt may still have counted against today's allowance — give it a minute before trying again.", + Spent: true), + + _ => new(AiNoticeKind.Error, ex.UserMessage, Spent: !ex.IsLocal) + }; + } +} diff --git a/InterlinedList/ViewModels/AiPanelViewModelBase.cs b/InterlinedList/ViewModels/AiPanelViewModelBase.cs new file mode 100644 index 0000000..c999d5c --- /dev/null +++ b/InterlinedList/ViewModels/AiPanelViewModelBase.cs @@ -0,0 +1,242 @@ +using System.ComponentModel; +using System.Net.Http; +using CommunityToolkit.Mvvm.ComponentModel; +using InterlinedList.Models; +using InterlinedList.Services; + +namespace InterlinedList.ViewModels; + +/// +/// What every AI panel in this app shares: the one availability gate, the +/// remaining-quota line, a single in-place instead of any +/// dialog, and one funnel that all /api/ai/* traffic goes through. +/// +/// The funnel is the point. is the only place +/// AI calls are made from, which is what makes three otherwise easy-to-forget +/// rules structural rather than aspirational: +/// +/// +/// It never retries. A failed call that reached the model already +/// spent one of the 50 daily generations, so an automatic retry silently +/// doubles the bill. The user re-asks, or nobody does. +/// It refuses to start at all when is false, +/// so a stale bound button can't fire a call that would answer +/// 403 subscription_required or 409 no_provider_configured. +/// It folds the echoed quota back into +/// on success and marks the +/// allowance gone on quota_exceeded, so the chip never disagrees with +/// the message next to it. +/// +/// +/// Subclasses do the feature-specific pre-flight (word caps, required source +/// references) before calling in — every rejection caught there is a quota unit +/// that was never spent, which is the entire reason the caps are mirrored +/// client-side. +/// +public abstract partial class AiPanelViewModelBase : ObservableObject +{ + protected SessionService Session { get; } + + /// The shared gate. Views bind visibility to Availability.IsAiAvailable. + public AiAvailabilityService Availability { get; } + + protected AiPanelViewModelBase(SessionService session, AiAvailabilityService? availability = null) + { + Session = session ?? throw new ArgumentNullException(nameof(session)); + Availability = availability ?? AiAvailabilityService.Shared; + + // Re-raise the gate and the quota line as our own properties so a panel's + // XAML can bind them without reaching through two DataContext levels. + Availability.PropertyChanged += OnAvailabilityChanged; + } + + private void OnAvailabilityChanged(object? sender, PropertyChangedEventArgs e) + { + switch (e.PropertyName) + { + case nameof(AiAvailabilityService.Status): + case nameof(AiAvailabilityService.LoadError): + OnPropertyChanged(nameof(IsAiAvailable)); + OnPropertyChanged(nameof(QuotaLabel)); + OnPropertyChanged(nameof(IsQuotaExhausted)); + OnPropertyChanged(nameof(CanRunAi)); + NotifyAiCommandsChanged(); + break; + } + } + + /// The gate — see . + public bool IsAiAvailable => Availability.IsAiAvailable; + + /// "45 of 50 left today". + public string QuotaLabel => Availability.QuotaLabel; + + public bool IsQuotaExhausted => Availability.IsQuotaExhausted; + + /// + /// True when a call is worth attempting: available, nothing already in + /// flight, and today's allowance isn't spent. Subclasses AND this with their + /// own input checks in their commands' CanExecute. + /// + public bool CanRunAi => IsAiAvailable && !IsBusy && !IsQuotaExhausted; + + /// A /suggest or /generate is in flight. Drives the amber pending state. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanRunAi))] + private bool isBusy; + + /// What the panel is doing right now, for the pending line ("Drafting your list…"). + [ObservableProperty] + private string? busyText; + + /// + /// The single in-place message slot. Non-blocking by construction: it's a + /// bound string, not a dialog. + /// + [ObservableProperty] + private AiNotice? notice; + + partial void OnIsBusyChanged(bool value) => NotifyAiCommandsChanged(); + + /// + /// Raise CanExecuteChanged for whichever commands depend on + /// . Subclasses override because the generated + /// …Command properties only exist on them. + /// + protected virtual void NotifyAiCommandsChanged() { } + + /// Called from a view's constructor; safe to call repeatedly. + public Task EnsureStatusLoadedAsync() => Availability.EnsureLoadedAsync(); + + protected void ClearNotice() => Notice = null; + + /// + /// Fail a request locally, with the same code the server would have used, + /// without spending a unit. This is what a pre-flight rejection looks like. + /// + protected void RejectLocally(string message) => Notice = AiNotice.Input(message); + + /// + /// Run one AI call. Returns null on any failure, having already written the + /// right in-place . Does not retry, ever. + /// + /// Pending copy while it runs. + /// The single /api/ai/* call to make. + /// + /// Pulls the echoed quota out of the result so the chip can count down. + /// Note the echoed object has no remaining field — that's handled in + /// . + /// + protected async Task RunAiAsync( + string busyText, + Func> call, + Func? quotaOf = null, + CancellationToken ct = default) + where T : class + { + ArgumentNullException.ThrowIfNull(call); + + // Rule 2: a stale binding must not be able to spend anything. + if (!IsAiAvailable) + { + Notice = new AiNotice(AiNoticeKind.Error, + Availability.UnavailableReason ?? "AI isn't available on this account."); + return null; + } + + if (IsBusy) return null; + + IsBusy = true; + BusyText = busyText; + Notice = null; + + try + { + var result = await call(ct); + + if (quotaOf is not null) + Availability.ApplyQuota(quotaOf(result)); + + return result; + } + catch (AiApiException ex) + { + Notice = AiNotice.From(ex); + + // Keep the chip honest: a 429 quota_exceeded is the server saying + // the allowance is gone regardless of what we had cached. + if (ex.Code == AiErrorCode.QuotaExceeded) + Availability.MarkQuotaExhausted(); + + // A 401 means the token died mid-session; the gate should stop + // claiming AI is available until it's re-checked. + if (ex.Code == AiErrorCode.Unauthorized) + Availability.Reset(); + + AppLog.Warn($"AI call failed ({ex.StatusCode} {ex.RawCode}): {ex.Message}"); + return null; + } + catch (OperationCanceledException) + { + // A cancelled call may or may not have reached the model — say + // nothing rather than guess. + return null; + } + catch (HttpRequestException ex) + { + Notice = new AiNotice(AiNoticeKind.Error, "Couldn't reach InterlinedList. Check your connection and try again."); + AppLog.Warn($"AI call transport failure: {ex.Message}"); + return null; + } + catch (Exception ex) + { + Notice = new AiNotice(AiNoticeKind.Error, "The AI request failed unexpectedly."); + AppLog.Error("Unexpected failure during an AI call.", ex); + return null; + } + finally + { + IsBusy = false; + BusyText = null; + } + } + + /// + /// Run a plain (non-AI) follow-up call — the read-after-write GET that + /// confirms what /generate actually created. Separate from + /// because it spends no quota and throws the + /// ordinary exception type. + /// + protected async Task RunApiAsync(string busyText, Func> call, CancellationToken ct = default) + where T : class + { + ArgumentNullException.ThrowIfNull(call); + + IsBusy = true; + BusyText = busyText; + try + { + return await call(ct); + } + catch (InterlinedApiException ex) + { + Notice = new AiNotice(AiNoticeKind.Error, ex.Message); + return null; + } + catch (OperationCanceledException) + { + return null; + } + catch (Exception ex) + { + Notice = new AiNotice(AiNoticeKind.Error, "The request failed unexpectedly."); + AppLog.Error("Unexpected failure during an AI follow-up call.", ex); + return null; + } + finally + { + IsBusy = false; + BusyText = null; + } + } +} diff --git a/InterlinedList/Views/AiConverters.cs b/InterlinedList/Views/AiConverters.cs new file mode 100644 index 0000000..b97dfdd --- /dev/null +++ b/InterlinedList/Views/AiConverters.cs @@ -0,0 +1,81 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; +using System.Windows.Media; +using InterlinedList.ViewModels; + +namespace InterlinedList.Views; + +/// +/// Visible when the bound object is non-null. The AI panels use one nullable +/// / artifact slot each, so "is something there" is the +/// visibility question over and over. +/// +public sealed class NotNullToVisibilityConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + value is null ? Visibility.Collapsed : Visibility.Visible; + + public object ConvertBack(object value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} + +/// Collapsed when the bound bool is true (the inverse of BooleanToVisibilityConverter). +public sealed class InverseBoolToVisibilityConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + value is true ? Visibility.Collapsed : Visibility.Visible; + + public object ConvertBack(object value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} + +/// +/// An to its Strata colour. Amber #F0A830 — the +/// brand's live/pending accent — covers the three "come back and try again" +/// states, because they are states, not errors: the request was well-formed and +/// the app is working correctly. Only a genuine failure gets the error red, and +/// input problems get plain body text since they're just instructions. +/// +/// Returns a resource key rather than a literal so both themes' brushes apply; +/// the two fixed hexes are the pair already used for error/amber text +/// throughout the app (see the ErrorBanner styles in Lists/Documents). +/// +public sealed class AiNoticeBrushConverter : IValueConverter +{ + private static readonly SolidColorBrush ErrorBrush = new(Color.FromRgb(0xE8, 0x11, 0x23)); + private static readonly SolidColorBrush AmberBrush = new(Color.FromRgb(0xF0, 0xA8, 0x30)); + + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => value switch + { + AiNoticeKind.QuotaExceeded or AiNoticeKind.RateLimited or AiNoticeKind.ModelDeclined => AmberBrush, + AiNoticeKind.Error => ErrorBrush, + // Input and Info read as guidance; let them inherit the body colour via + // the app's muted text brush, resolved by the caller's DynamicResource. + _ => Application.Current?.TryFindResource("TextBodyBrush") as Brush ?? (Brush)AmberBrush + }; + + public object ConvertBack(object value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} + +/// +/// An to the short label that prefixes the message, +/// so quota_exceeded and rate_limited are told apart at a glance +/// rather than reading as the same red failure (#10). +/// +public sealed class AiNoticeLabelConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => value switch + { + AiNoticeKind.QuotaExceeded => "DAILY LIMIT", + AiNoticeKind.RateLimited => "SLOW DOWN", + AiNoticeKind.ModelDeclined => "TRY DIFFERENT WORDING", + AiNoticeKind.Input => "CHECK THIS", + AiNoticeKind.Error => "FAILED", + _ => "AI" + }; + + public object ConvertBack(object value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/InterlinedList/Views/AiNoticeBar.xaml b/InterlinedList/Views/AiNoticeBar.xaml new file mode 100644 index 0000000..3c18dd6 --- /dev/null +++ b/InterlinedList/Views/AiNoticeBar.xaml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + diff --git a/InterlinedList/Views/AiNoticeBar.xaml.cs b/InterlinedList/Views/AiNoticeBar.xaml.cs new file mode 100644 index 0000000..a65c12d --- /dev/null +++ b/InterlinedList/Views/AiNoticeBar.xaml.cs @@ -0,0 +1,32 @@ +using System.Windows; +using System.Windows.Controls; +using InterlinedList.ViewModels; + +namespace InterlinedList.Views; + +/// +/// Renders one in place. Hosts pass the notice in and +/// need nothing else: +/// +/// +/// <local:AiNoticeBar Notice="{Binding Notice}"/> +/// +/// +/// A null notice collapses the control, so a host never needs its own +/// visibility rule. Nothing here blocks the dispatcher — that's the whole +/// reason AI messages are a control rather than a dialog (#10). +/// +public partial class AiNoticeBar : UserControl +{ + public static readonly DependencyProperty NoticeProperty = + DependencyProperty.Register(nameof(Notice), typeof(AiNotice), typeof(AiNoticeBar), new PropertyMetadata(null)); + + public AiNoticeBar() => InitializeComponent(); + + /// The message to show, or null to show nothing. + public AiNotice? Notice + { + get => (AiNotice?)GetValue(NoticeProperty); + set => SetValue(NoticeProperty, value); + } +} diff --git a/InterlinedList/Views/AiQuotaChip.xaml b/InterlinedList/Views/AiQuotaChip.xaml new file mode 100644 index 0000000..11f0825 --- /dev/null +++ b/InterlinedList/Views/AiQuotaChip.xaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + diff --git a/InterlinedList/Views/AiQuotaChip.xaml.cs b/InterlinedList/Views/AiQuotaChip.xaml.cs new file mode 100644 index 0000000..728eed1 --- /dev/null +++ b/InterlinedList/Views/AiQuotaChip.xaml.cs @@ -0,0 +1,37 @@ +using System.Windows.Controls; +using InterlinedList.Services; + +namespace InterlinedList.Views; + +/// +/// The remaining-daily-quota chip #10 asks for wherever an AI action is +/// offered. Drop it in with no attributes and no code-behind in the host: +/// +/// +/// <local:AiQuotaChip/> +/// +/// +/// It reads directly rather than +/// taking a dependency property, because there is exactly one answer per +/// session and every instance wants the same one. It also hides itself when the +/// gate is closed, so a host never needs its own visibility binding for it. +/// +/// It binds QuotaLabel, which is derived from +/// — not from +/// AiQuota.Remaining. That matters: /api/ai/status sends +/// remaining but /api/ai/suggest does not (verified live), so +/// after a suggestion the raw field is null and only the computed one is right. +/// +public partial class AiQuotaChip : UserControl +{ + public AiQuotaChip() + { + InitializeComponent(); + DataContext = AiAvailabilityService.Shared; + + // Cheap safety net: if this chip is the first AI surface a user reaches, + // make sure the once-per-session status fetch has been kicked off. + // EnsureLoadedAsync is idempotent and single-flighted. + _ = AiAvailabilityService.Shared.EnsureLoadedAsync(); + } +}