Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
278 changes: 278 additions & 0 deletions InterlinedList/Services/AiAvailabilityService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
using InterlinedList.Models;

namespace InterlinedList.Services;

/// <summary>
/// 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
/// <c>GET /api/ai/status</c> once per session, caches it, and exposes
/// <see cref="IsAiAvailable"/> — the one gate every AI affordance in the app
/// binds its visibility to.
///
/// <b>Why this exists as its own singleton rather than a field on
/// <see cref="AppServices"/>:</b> every AI panel needs the same answer and must
/// not each spend a round-trip on it, and adding a member to
/// <see cref="AppServices"/> would conflict with several other open branches
/// that also touch it. <see cref="Shared"/> is created lazily off
/// <see cref="AppServices.Session"/>, so nothing else in the app changes.
///
/// <b>The gate deliberately excludes quota.</b> <see cref="AiStatus.CanUseAi"/>
/// also requires unspent quota, which is the wrong rule for <i>visibility</i>:
/// 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 = <c>Subscriber &amp;&amp; providers non-empty</c>, and
/// quota exhaustion is reported through <see cref="IsQuotaExhausted"/> /
/// <see cref="QuotaLabel"/> instead.
///
/// Verified live 2026-09-16 on the test account:
/// <c>{"subscriber":true,"providers":["anthropic"],"defaultModels":{…},
/// "quota":{"usedToday":5,"dailyLimit":50,"remaining":45}}</c>
///
/// Threading: this is an <see cref="ObservableObject"/> 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
/// <see cref="ApplyQuota"/> / <see cref="Reset"/> are synchronous callbacks
/// made from the same place.
/// </summary>
public sealed partial class AiAvailabilityService : ObservableObject
{
private static readonly Lazy<AiAvailabilityService> LazyShared =
new(() => new AiAvailabilityService(AppServices.Session));

/// <summary>The process-wide instance. Panels use this rather than newing one up.</summary>
public static AiAvailabilityService Shared => LazyShared.Value;

private readonly SessionService _session;

/// <summary>
/// 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.
/// </summary>
private Task<AiStatus?>? _inFlight;

/// <summary>
/// Bumped by <see cref="Reset"/> and <see cref="RefreshAsync"/>. 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.
/// </summary>
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;

/// <summary>True while the one-per-session GET is running.</summary>
[ObservableProperty]
private bool isLoading;

/// <summary>
/// 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.
/// </summary>
[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;

/// <summary>
/// <b>The</b> gate. Bind every AI button, tab, panel and menu entry's
/// visibility to this and nothing else.
///
/// <c>providers: []</c> means the server has no <c>ANTHROPIC_API_KEY</c>, so
/// a call would come back <c>409 no_provider_configured</c> — there is
/// nothing a user could do about it, so the control is hidden rather than
/// shown-and-failing. <c>subscriber: false</c> 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.
/// </summary>
public bool IsAiAvailable => Status is { Subscriber: true } status && status.HasProviderConfigured;

/// <summary>The model /suggest will run, for the "powered by" byline. Anthropic-only app-wide.</summary>
public string? DefaultModel => Status?.DefaultModel;

/// <summary>Why AI isn't offered, when it isn't. Diagnostic only — never shown to a free account.</summary>
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;

/// <summary>"45 of 50 left today" — the line #10 asks to appear wherever an AI action is offered.</summary>
public string QuotaLabel => Status is null
? "Checking AI quota…"
: $"{RemainingToday} of {DailyLimit} left today";

public bool IsQuotaExhausted => Status?.Quota.IsExhausted ?? false;

// ── Loading ─────────────────────────────────────────────────────────────────

/// <summary>
/// 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.
/// </summary>
public Task EnsureLoadedAsync(CancellationToken ct = default)
{
if (Status is not null) return Task.CompletedTask;
return _inFlight ??= LoadAsync(_generation, ct);
}

/// <summary>
/// Re-fetch on demand — after a subscription change, or to reconcile the
/// quota against the server (a /suggest echoes usedToday but omits
/// <c>remaining</c>, so a long session's computed figure can drift if the
/// same account is used elsewhere).
/// </summary>
public Task RefreshAsync(CancellationToken ct = default)
=> _inFlight = LoadAsync(++_generation, ct);

private async Task<AiStatus?> 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 ───────────────────────────────────────────────────────

/// <summary>
/// Fold the quota a /suggest or /generate echoed back into the cached
/// status, so the chip counts down without another GET.
///
/// <b>Shape note:</b> the echoed object has <c>usedToday</c> and
/// <c>dailyLimit</c> but <b>no <c>remaining</c></b> (verified live, twice),
/// so this stores <see cref="AiQuota.RemainingOrComputed"/> into
/// <see cref="AiQuota.Remaining"/> — keeping the cached status shaped like a
/// /status response and letting the UI keep binding one property.
/// </summary>
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)
}
};
}

/// <summary>
/// A <c>429 quota_exceeded</c> 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.
/// </summary>
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 }
};
}

/// <summary>
/// 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.
/// </summary>
public void Reset()
{
_generation++;
_inFlight = null;
IsLoading = false;
LoadError = null;
Status = null;
}
}
111 changes: 111 additions & 0 deletions InterlinedList/ViewModels/AiNotice.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using InterlinedList.Models;
using InterlinedList.Services;

namespace InterlinedList.ViewModels;

/// <summary>
/// How an <see cref="AiNotice"/> should read and look. Kept distinct from
/// <see cref="AiErrorCode"/> because several codes collapse to the same
/// treatment while two of them — <see cref="QuotaExceeded"/> and
/// <see cref="RateLimited"/> — must stay visibly different from each other and
/// from a generic failure. That's the whole point of #10's last two acceptance
/// criteria.
/// </summary>
public enum AiNoticeKind
{
/// <summary>Neutral progress/result copy ("Draft ready — review it below").</summary>
Info,

/// <summary>The user's input needs changing before it's worth spending a unit. Actionable, their side.</summary>
Input,

/// <summary>429 quota_exceeded — today's 50 are gone. Comes back tomorrow, not sooner.</summary>
QuotaExceeded,

/// <summary>429 rate_limited — the 15/60s window tripped. Comes back in seconds.</summary>
RateLimited,

/// <summary>422 invalid_ai_output / refused — the model answered unusably. A unit was still spent.</summary>
ModelDeclined,

/// <summary>Everything else: provider_error, 401, an unknown code, a transport failure.</summary>
Error
}

/// <summary>
/// One non-blocking, in-place message from an AI call. Rendered as a line of
/// text inside the panel that raised it — never a <c>MessageBox</c>, never
/// anything that blocks the dispatcher (#10).
///
/// <see cref="Spent"/> exists because this API's quota accounting is
/// asymmetric: an input-validation rejection costs nothing (verified live —
/// <c>usedToday</c> 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 <c>invalid_ai_output</c> deserves to be told so, and told to
/// re-word rather than mash the button.
/// </summary>
public sealed record AiNotice(AiNoticeKind Kind, string Text, bool Spent = false)
{
/// <summary>True for the states worth an amber (live/pending) treatment rather than red.</summary>
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);

/// <summary>A client-side pre-flight rejection. No unit spent — that's the reason pre-flight exists.</summary>
public static AiNotice Input(string text) => new(AiNoticeKind.Input, text, Spent: false);

/// <summary>
/// Map a failed AI call onto its message. Branches on
/// <see cref="AiApiException.Code"/> only — never on the server's prose —
/// except for <c>invalid_input</c>, 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).
/// </summary>
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)
};
}
}
Loading
Loading