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(); + } +}