From 43ceef3363317ad14013b943b539be97b75d0825 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 15:04:06 -0700 Subject: [PATCH] =?UTF-8?q?AI:=20Powered=20Templates=20=E2=80=94=20generat?= =?UTF-8?q?e=20a=20list=20from=20a=20description?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Describe a list in plain language, get a proposed schema plus starter rows, edit both, then confirm it into a real list. The web app's Powered Templates tab, as a self-contained card in the new-list flow. Views/ListsView.xaml is contended by #161/#163/#164, so this uses the pattern that worked on #55/#56: everything lives in PoweredTemplatePanel and its own ViewModel, and the host contributes exactly one line — — which is the whole diff to ListsView.xaml. No code-behind edit, no new resource, no visibility rule: the control collapses itself when #10's IsAiAvailable gate is closed, so a free account sees nothing at all. ## The DSL, live-verified for the first time The list artifact was contract-transcribed only until now — #137's own doc comment says the shape was never exercised. One /suggest call settled it: dsl: { name, description, fields: [ { key, type, label, required, displayOrder, options?, defaultValue?, visibility? } ] } rows: [ { : value } ] // flat maps, null is a legal cell value Nothing in this repo had the dsl wrapper's name/description members, nor the per-field displayOrder, options, defaultValue or visibility.condition — all of which turned up unannounced on the very first call. That is the whole argument for AiListDsl being a deliberately *partial* projection: it parses the five members the column editor needs and round-trips every other member byte-for- byte out of a retained raw element. Renaming a label must not silently drop a conditional-visibility rule the model wrote, and /generate re-validates the DSL server-side, so sending back exactly what came out — minus the deliberate edits — is the only safe default. That round-trip is verified, not assumed: a scratch harness replays the real /suggest body through AiListDsl + AiListArtifactPayload, drops a column, renames a label and flips a required, and asserts 20 properties of the result — including that visibility.condition, options[] and defaultValue all survive the edit, that displayOrder is re-numbered without gaps, and that untouched cells keep their original JSON kind (5 stays a number, null stays null) rather than being re-typed from their string rendering. AiListArtifactPayload exists alongside AiListArtifact because they are opposite directions: the existing type is the read side, so its cells are already parsed JsonElements, whereas cells a user just typed are strings the client types per column. Forcing those back through JsonElement would mean serializing and re-parsing each cell individually just to satisfy the read type. ## Quota discipline /suggest and /generate are two units, so both are pre-flighted. Suggest checks the 300-word powered_template cap (mirrored, with a live word counter that turns amber before the button is pressed). Confirm checks a non-empty title, at least one column, the documented 20-field ceiling, unique keys, and the documented ^[a-z][a-z0-9_-]*$ key rule — user-added columns are slugified into that shape locally, because learning a key was illegal from a failed /generate costs a unit. Field keys are intentionally not editable: rows address cells by key, so retyping one would orphan a column's data silently. Column type pickers offer only the types this client has seen the server emit (text, number, select) plus whatever types the artifact in hand already uses. Inventing a DSL type would be a guess the server bills a unit to refuse. Confirm treats /generate's {listId} as a hint, not a fact — that envelope is still contract-transcribed — and re-fetches with GET /api/lists/{id} before handing the host anything. If no id comes back, it refreshes the host's browser and says plainly that a retry would duplicate the list and spend another credit, rather than claiming either outcome. /suggest calls made: 1 (powered_template, "a reading list with title, author, status, and rating"). POST /api/ai/generate was NOT called — it persists to a shared account — so the confirm path's response handling is the one part of this that is reasoned rather than observed. Closes #14 Co-Authored-By: Claude Opus 5 --- .../Models/AiListArtifactPayload.cs | 64 +++ InterlinedList/Models/AiListDsl.cs | 253 +++++++++++ .../ViewModels/AiListPreviewViewModels.cs | 243 ++++++++++ .../PoweredTemplatePanelViewModel.cs | 382 ++++++++++++++++ InterlinedList/Views/ListsView.xaml | 3 + .../Views/PoweredTemplatePanel.xaml | 416 ++++++++++++++++++ .../Views/PoweredTemplatePanel.xaml.cs | 80 ++++ 7 files changed, 1441 insertions(+) create mode 100644 InterlinedList/Models/AiListArtifactPayload.cs create mode 100644 InterlinedList/Models/AiListDsl.cs create mode 100644 InterlinedList/ViewModels/AiListPreviewViewModels.cs create mode 100644 InterlinedList/ViewModels/PoweredTemplatePanelViewModel.cs create mode 100644 InterlinedList/Views/PoweredTemplatePanel.xaml create mode 100644 InterlinedList/Views/PoweredTemplatePanel.xaml.cs diff --git a/InterlinedList/Models/AiListArtifactPayload.cs b/InterlinedList/Models/AiListArtifactPayload.cs new file mode 100644 index 0000000..1ab7aa3 --- /dev/null +++ b/InterlinedList/Models/AiListArtifactPayload.cs @@ -0,0 +1,64 @@ +using System.Text.Json; + +namespace InterlinedList.Models; + +/// +/// The kind: "list" artifact on its way out — what a user's edits +/// to a Powered Template preview get serialized into for +/// POST /api/ai/generate. +/// +/// Why this exists alongside . That type is +/// the read side: it deserializes a /suggest response, so its +/// Rows are Dictionary<string, JsonElement> — every cell +/// already a parsed JSON value. Cells a user has just typed aren't: they're +/// strings, numbers, bools and nulls that the client decides on per column +/// type. Forcing those through would mean serializing +/// each cell to a document and re-parsing it one value at a time, purely to +/// satisfy the read type. So the write side takes object? cells and lets +/// do it once, for the whole artifact. +/// +/// The wire shape is identical to what /suggest returned — verified live +/// 2026-09-16: +/// +/// { "kind":"list", "title":"Reading List", "description":"Track books…", +/// "dsl":{ "name":…, "description":…, "fields":[…] }, +/// "rows":[ {"title":"The Hobbit","author":"J.R.R. Tolkien","status":"Finished","rating":5}, +/// {"title":"Project Hail Mary","author":"Andy Weir","status":"Reading","rating":null} ] } +/// +/// Note rows are flat maps keyed by field key, and null is a legitimate +/// cell value — so unset cells are sent as null rather than omitted. +/// +public sealed class AiListArtifactPayload : IAiArtifactPayload +{ + public AiArtifactKind Kind => AiArtifactKind.List; + + public string Title { get; init; } = ""; + + public string? Description { get; init; } + + /// + /// The List Schema DSL object, rebuilt by + /// . Stays a + /// because the DSL is only partially reverse engineered and every member + /// this client doesn't understand has to survive the round trip untouched. + /// + public JsonElement? Dsl { get; init; } + + /// Starter rows: one flat map per row, keyed by field key. + public List> Rows { get; init; } = new(); + + public object ToWire() + { + var wire = new Dictionary + { + ["kind"] = Kind.ToWire(), + ["title"] = Title + }; + + if (Description is { Length: > 0 }) wire["description"] = Description; + if (Dsl is { } dsl && dsl.ValueKind != JsonValueKind.Undefined) wire["dsl"] = dsl; + if (Rows.Count > 0) wire["rows"] = Rows; + + return wire; + } +} diff --git a/InterlinedList/Models/AiListDsl.cs b/InterlinedList/Models/AiListDsl.cs new file mode 100644 index 0000000..f1fc3ab --- /dev/null +++ b/InterlinedList/Models/AiListDsl.cs @@ -0,0 +1,253 @@ +using System.Text.Json; + +namespace InterlinedList.Models; + +/// +/// A readable, editable view over the dsl object inside a +/// powered_template list artifact — enough to render the proposed +/// columns (type, label, required) in a table and let a user fix them +/// before confirming, which is what #14 asks for and what a raw JSON blob +/// can't give you. +/// +/// Live-verified shape (POST /api/ai/suggest, feature +/// powered_template, input "a reading list with title, author, status, +/// and rating", 2026-09-16 — one quota unit): +/// +/// "dsl": { +/// "name": "Reading List", +/// "description": "A personal list to track books, …", +/// "fields": [ +/// { "key":"title", "type":"text", "label":"Title", "required":true, "displayOrder":0 }, +/// { "key":"author", "type":"text", "label":"Author", "required":true, "displayOrder":1 }, +/// { "key":"status", "type":"select", "label":"Status", "required":true, "displayOrder":2, +/// "options":["To Read","Reading","Finished","Abandoned"], "defaultValue":"To Read" }, +/// { "key":"rating", "type":"number", "label":"Rating (1-5)", "required":false, "displayOrder":3, +/// "visibility":{ "condition":{ "field":"status","operator":"equals","value":"Finished" } } } +/// ] +/// } +/// +/// Before this, the list artifact was contract-transcribed only (#137's doc +/// comment says as much) — the dsl wrapper's name/description +/// members and the per-field displayOrder, options, +/// defaultValue and visibility.condition members were not on +/// record anywhere in this repo until now. +/// +/// Why every field keeps its raw element. CLAUDE.md records the List +/// Schema DSL as only partially reverse engineered, and the probe above proves +/// it: visibility.condition and defaultValue showed up unannounced +/// on the very first call. So this type is deliberately a partial +/// projection — it parses the five members the column editor needs and +/// round-trips everything else byte-for-byte out of . Editing a +/// label must not silently drop a conditional-visibility rule the model wrote, +/// and /generate re-validates the DSL server-side, so sending back exactly what +/// came out (minus the deliberate edits) is the only safe default. +/// +public sealed class AiListDsl +{ + /// The DSL's own name. Distinct from the artifact's title, which is what the list is called. + public string? Name { get; init; } + + public string? Description { get; init; } + + public List Fields { get; init; } = new(); + + /// The whole dsl object as it arrived, so unknown top-level members survive an edit. + public JsonElement Raw { get; init; } + + /// Documented ceiling: a DSL carries at most 20 fields. + public const int MaxFields = 20; + + /// + /// Documented key rule: ^[a-z][a-z0-9_-]*$. Mirrored so a + /// user-added column can be given a legal key locally instead of learning + /// it was illegal from a failed /generate — which would cost a quota unit. + /// + public static bool IsValidKey(string? key) => + key is { Length: > 0 } + && key[0] is >= 'a' and <= 'z' + && key.All(c => c is >= 'a' and <= 'z' or >= '0' and <= '9' or '_' or '-'); + + /// Slugify a user-typed label into a legal key, or null if nothing usable survives. + public static string? KeyFromLabel(string? label) + { + if (string.IsNullOrWhiteSpace(label)) return null; + + var chars = label.Trim().ToLowerInvariant() + .Select(c => c is >= 'a' and <= 'z' or >= '0' and <= '9' ? c : '_') + .ToArray(); + + var slug = new string(chars).Trim('_'); + while (slug.Contains("__")) slug = slug.Replace("__", "_"); + + // A key must start with a letter; prefix rather than drop a leading digit. + if (slug.Length > 0 && slug[0] is >= '0' and <= '9') slug = "f_" + slug; + + return IsValidKey(slug) ? slug : null; + } + + public static AiListDsl? FromJson(JsonElement? element) + { + if (element is not { } dsl || dsl.ValueKind != JsonValueKind.Object) return null; + + var fields = new List(); + if (dsl.TryGetProperty("fields", out var fieldsElement) && fieldsElement.ValueKind == JsonValueKind.Array) + { + foreach (var field in fieldsElement.EnumerateArray()) + { + if (field.ValueKind != JsonValueKind.Object) continue; + fields.Add(AiListDslField.FromJson(field)); + } + } + + return new AiListDsl + { + Name = dsl.StringOrNull("name"), + Description = dsl.StringOrNull("description"), + Fields = fields.OrderBy(f => f.DisplayOrder).ToList(), + Raw = dsl.Clone() + }; + } + + /// + /// Rebuild the dsl object from an edited field set, preserving every + /// top-level member that isn't fields (or the two this type owns). + /// is re-numbered into displayOrder so a + /// user who deleted a column doesn't leave a gap in the sequence. + /// + public JsonElement ToElement(string? name, string? description, IReadOnlyList fields) + { + ArgumentNullException.ThrowIfNull(fields); + + var wire = new Dictionary(); + + if (Raw.ValueKind == JsonValueKind.Object) + { + foreach (var property in Raw.EnumerateObject()) + { + if (property.NameEquals("fields") || property.NameEquals("name") || property.NameEquals("description")) + continue; + wire[property.Name] = property.Value; + } + } + + if (name is { Length: > 0 }) wire["name"] = name; + if (description is { Length: > 0 }) wire["description"] = description; + + wire["fields"] = fields + .Select((field, index) => field.ToWire(index)) + .ToList(); + + return AiJson.ToElement(wire); + } + + /// An empty DSL, for building one from scratch if a suggestion came back without one. + public static AiListDsl Empty() => new() { Raw = AiJson.ToElement(new Dictionary()) }; +} + +/// +/// One proposed column. The four members #14's preview has to show and let a +/// user change — , , , +/// — are parsed; everything else (options, +/// defaultValue, visibility, and whatever the DSL grows next) +/// rides along untouched in . +/// +public sealed class AiListDslField +{ + /// Stable identifier, also the key each starter row uses. ^[a-z][a-z0-9_-]*$. + public string Key { get; init; } = ""; + + /// Wire type. Observed live: text, number, select. + public string Type { get; init; } = DefaultType; + + /// Human label shown as the column header. + public string Label { get; init; } = ""; + + public bool Required { get; init; } + + public int DisplayOrder { get; init; } + + /// The field object exactly as it arrived (empty for a user-added column). + public JsonElement Raw { get; init; } + + public const string DefaultType = "text"; + + /// + /// The field types this client has actually seen the server emit. It is + /// explicitly not the complete DSL type set — the DSL is only + /// partially reverse engineered, so a column editor offers these plus + /// whatever types the artifact in hand already uses, and invents nothing. + /// + public static IReadOnlyList ObservedTypes { get; } = new[] { "text", "number", "select" }; + + /// Options for a select column, read out of for display. + public IReadOnlyList Options + { + get + { + if (Raw.ValueKind != JsonValueKind.Object + || !Raw.TryGetProperty("options", out var options) + || options.ValueKind != JsonValueKind.Array) + return Array.Empty(); + + return options.EnumerateArray() + .Select(o => o.ValueKind == JsonValueKind.String ? o.GetString() : o.ToString()) + .Where(o => !string.IsNullOrEmpty(o)) + .Select(o => o!) + .ToList(); + } + } + + /// + /// True when this field carries DSL members the column editor doesn't + /// surface (options, defaultValue, a visibility condition, …). The preview + /// says so, so a user isn't surprised that a column does more than its + /// three editable attributes suggest. + /// + public bool HasExtraRules => + Raw.ValueKind == JsonValueKind.Object + && Raw.EnumerateObject().Any(p => + !p.NameEquals("key") && !p.NameEquals("type") && !p.NameEquals("label") + && !p.NameEquals("required") && !p.NameEquals("displayOrder")); + + public static AiListDslField FromJson(JsonElement field) => new() + { + Key = field.StringOrNull("key") ?? "", + Type = field.StringOrNull("type") ?? DefaultType, + Label = field.StringOrNull("label") ?? field.StringOrNull("key") ?? "", + Required = field.TryGetProperty("required", out var required) && required.ValueKind == JsonValueKind.True, + DisplayOrder = field.TryGetProperty("displayOrder", out var order) + && order.ValueKind == JsonValueKind.Number + && order.TryGetInt32(out var value) + ? value + : int.MaxValue, + Raw = field.Clone() + }; + + /// + /// Serialize back, overwriting only the five members this type owns and + /// copying every other member across verbatim. + /// + public Dictionary ToWire(int displayOrder) + { + var wire = new Dictionary(); + + if (Raw.ValueKind == JsonValueKind.Object) + { + foreach (var property in Raw.EnumerateObject()) + { + if (property.NameEquals("key") || property.NameEquals("type") || property.NameEquals("label") + || property.NameEquals("required") || property.NameEquals("displayOrder")) + continue; + wire[property.Name] = property.Value; + } + } + + wire["key"] = Key; + wire["type"] = Type; + wire["label"] = Label; + wire["required"] = Required; + wire["displayOrder"] = displayOrder; + + return wire; + } +} diff --git a/InterlinedList/ViewModels/AiListPreviewViewModels.cs b/InterlinedList/ViewModels/AiListPreviewViewModels.cs new file mode 100644 index 0000000..a2ef61e --- /dev/null +++ b/InterlinedList/ViewModels/AiListPreviewViewModels.cs @@ -0,0 +1,243 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text.Json; +using CommunityToolkit.Mvvm.ComponentModel; +using InterlinedList.Models; + +namespace InterlinedList.ViewModels; + +/// +/// One editable column in a Powered Template preview. Surfaces exactly the +/// three attributes #14 asks for — type, label, required — and nothing else. +/// +/// is deliberately not editable. Every starter row +/// addresses its cells by field key, so letting a user retype a key would +/// silently orphan that column's data. Labels are what people actually want to +/// change; a key is only ever chosen once, when a column is added, and is +/// slugified into the documented ^[a-z][a-z0-9_-]*$ shape at that point. +/// +public sealed partial class AiListColumnViewModel : ObservableObject +{ + private readonly AiListDslField _source; + + public AiListColumnViewModel(AiListDslField source, IEnumerable? extraTypes = null) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + + Key = source.Key; + label = source.Label.Length > 0 ? source.Label : source.Key; + type = source.Type; + required = source.Required; + + // Offer the types this client has actually seen the server emit, plus + // whatever types this artifact already uses — never an invented one. + // The DSL is only partially reverse engineered (CLAUDE.md), and a type + // the server rejects would cost a quota unit to discover. + AvailableTypes = AiListDslField.ObservedTypes + .Concat(extraTypes ?? Array.Empty()) + .Append(source.Type) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Distinct(StringComparer.Ordinal) + .OrderBy(t => t, StringComparer.Ordinal) + .ToList(); + } + + /// Stable field key. Fixed for the lifetime of the column — see the type remarks. + public string Key { get; } + + [ObservableProperty] + private string label; + + [ObservableProperty] + private string type; + + [ObservableProperty] + private bool required; + + public IReadOnlyList AvailableTypes { get; } + + /// + /// A note when the model attached DSL rules this editor doesn't show + /// (options, defaultValue, a visibility.condition). + /// They're round-tripped untouched, and saying so beats a user wondering + /// where the dropdown choices went. + /// + public string? ExtraRulesNote + { + get + { + if (!_source.HasExtraRules) return null; + + var options = _source.Options; + return options.Count > 0 + ? $"choices: {string.Join(" / ", options)}" + : "has extra rules (kept as-is)"; + } + } + + public bool HasExtraRules => _source.HasExtraRules; + + /// Project the edits back onto the source field, preserving its unparsed members. + public AiListDslField ToField() => new() + { + Key = Key, + Type = Type, + Label = Label, + Required = Required, + DisplayOrder = _source.DisplayOrder, + Raw = _source.Raw + }; + + /// A column the user added by hand. Its raw element is empty, so it has no hidden rules. + public static AiListColumnViewModel NewColumn(string key, string label, IEnumerable? extraTypes = null) + => new(new AiListDslField + { + Key = key, + Label = label, + Type = AiListDslField.DefaultType, + Required = false, + DisplayOrder = int.MaxValue, + Raw = default + }, extraTypes); +} + +/// +/// One cell of a starter row. +/// +/// Unedited cells round-trip byte-for-byte. The preview has to show +/// values as text (a WPF TextBox holds a string, not a JSON number), but +/// re-serializing "5" back into JSON is a guess about whether it was +/// 5, 5.0 or "5". So this keeps the original +/// and only re-types a cell the user actually +/// changed — which is most of them, never. +/// +public sealed partial class AiListCellViewModel : ObservableObject +{ + private readonly JsonElement _original; + private readonly string _originalText; + + public AiListCellViewModel(string columnKey, JsonElement? original) + { + ColumnKey = columnKey; + _original = original ?? default; + _originalText = ToText(_original); + text = _originalText; + } + + public string ColumnKey { get; } + + [ObservableProperty] + private string text; + + public bool WasEdited => !string.Equals(Text, _originalText, StringComparison.Ordinal); + + /// + /// The value to send. An untouched cell returns its original element + /// verbatim; an edited one is re-typed from , + /// falling back to a string rather than dropping input the client can't + /// parse as a number. + /// + public object? ToWireValue(string columnType) + { + if (!WasEdited) + return _original.ValueKind == JsonValueKind.Undefined ? null : _original; + + if (string.IsNullOrWhiteSpace(Text)) return null; + + var value = Text.Trim(); + + switch (columnType) + { + case "number": + if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var whole)) + return whole; + if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var real)) + return real; + // Keep what they typed. The server validates starter rows and + // drops the ones that fail rather than failing the artifact, so + // a bad number costs a row, not the list. + return value; + + case "boolean": + case "checkbox": + return bool.TryParse(value, out var flag) ? flag : value; + + default: + return value; + } + } + + private static string ToText(JsonElement element) => element.ValueKind switch + { + JsonValueKind.Undefined or JsonValueKind.Null => "", + JsonValueKind.String => element.GetString() ?? "", + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Number => element.GetRawText(), + // An array/object inside a starter row cell has never been observed; + // show its JSON so at least nothing is hidden from the user. + _ => element.GetRawText() + }; +} + +/// +/// One starter row, as a cell per column in column order. The row's own cells +/// are rebuilt whenever a column is added or removed, so the table stays +/// rectangular and the header always lines up with the body. +/// +public sealed class AiListRowViewModel +{ + public ObservableCollection Cells { get; } = new(); + + public static AiListRowViewModel FromWire( + IReadOnlyList columns, + IReadOnlyDictionary? values) + { + var row = new AiListRowViewModel(); + foreach (var column in columns) + { + JsonElement? value = values is not null && values.TryGetValue(column.Key, out var found) ? found : null; + row.Cells.Add(new AiListCellViewModel(column.Key, value)); + } + return row; + } + + public static AiListRowViewModel Blank(IReadOnlyList columns) + { + var row = new AiListRowViewModel(); + foreach (var column in columns) + row.Cells.Add(new AiListCellViewModel(column.Key, null)); + return row; + } + + public void AddCellFor(AiListColumnViewModel column) + => Cells.Add(new AiListCellViewModel(column.Key, null)); + + public void RemoveCellFor(AiListColumnViewModel column) + { + var cell = Cells.FirstOrDefault(c => c.ColumnKey == column.Key); + if (cell is not null) Cells.Remove(cell); + } + + /// True when every cell is blank — an empty row is worth dropping rather than sending. + public bool IsEmpty => Cells.All(c => string.IsNullOrWhiteSpace(c.Text)); + + /// + /// The row object /generate expects: a flat map keyed by field key + /// (verified live — starter rows came back as + /// {"title":"The Hobbit","author":"J.R.R. Tolkien","status":"Finished","rating":5}, + /// with null for an unset value). + /// + public Dictionary ToWire(IReadOnlyList columns) + { + var wire = new Dictionary(); + + foreach (var column in columns) + { + var cell = Cells.FirstOrDefault(c => c.ColumnKey == column.Key); + wire[column.Key] = cell?.ToWireValue(column.Type); + } + + return wire; + } +} diff --git a/InterlinedList/ViewModels/PoweredTemplatePanelViewModel.cs b/InterlinedList/ViewModels/PoweredTemplatePanelViewModel.cs new file mode 100644 index 0000000..c9d2753 --- /dev/null +++ b/InterlinedList/ViewModels/PoweredTemplatePanelViewModel.cs @@ -0,0 +1,382 @@ +using System.Collections.ObjectModel; +using System.Windows.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using InterlinedList.Models; +using InterlinedList.Services; + +namespace InterlinedList.ViewModels; + +/// +/// Powered Templates: describe a list in plain language, get a proposed schema +/// plus starter rows back, edit it, then confirm it into a real list. +/// +/// Two calls, two quota units. /suggest previews without writing; +/// /generate persists. Both count against the 50-a-day allowance, so +/// every check that can be made locally is made before either leaves — the +/// 300-word input cap for powered_template, and on confirm: a non-empty +/// title, at least one column, the documented 20-field ceiling, and unique +/// legal field keys. Each of those caught locally is a unit not spent learning +/// it from a 422. +/// +/// Read-after-write on confirm. /generate's envelope is +/// contract-transcribed, not live-verified (it writes to a shared account), so +/// its {listId} is treated as a hint: the panel re-fetches the list with +/// GET /api/lists/{id} and hands the host that, per the repo's +/// read-after-write rule. If the id isn't there, it still refreshes the host's +/// list browser and says so rather than claiming a success it can't prove. +/// +public sealed partial class PoweredTemplatePanelViewModel : AiPanelViewModelBase +{ + private AiListDsl? _dsl; + private string? _dslName; + private string? _dslDescription; + + public PoweredTemplatePanelViewModel(SessionService session, AiAvailabilityService? availability = null) + : base(session, availability) + { + } + + /// Set by the hosting control from its OpenListCommand dependency property. + public ICommand? OpenListCommand { get; set; } + + /// Set by the hosting control from its RefreshCommand dependency property. + public ICommand? RefreshCommand { get; set; } + + // ── Input ─────────────────────────────────────────────────────────────────── + + /// Whether the panel is expanded. Collapsed by default — it's an option, not the default path. + [ObservableProperty] + private bool isOpen; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(WordCount))] + [NotifyPropertyChangedFor(nameof(WordCountLabel))] + [NotifyPropertyChangedFor(nameof(IsOverWordCap))] + private string description = ""; + + public int WordCount => AiFeatureLimits.CountWords(Description); + + public int MaxWords => AiFeatureLimits.For(AiFeature.PoweredTemplate).MaxInputWords; + + public string WordCountLabel => $"{WordCount} / {MaxWords} words"; + + /// + /// Shown before the button is pressed. The cap is mirrored from the server + /// precisely so an over-long description never costs a unit to reject. + /// + public bool IsOverWordCap => WordCount > MaxWords; + + partial void OnDescriptionChanged(string value) => SuggestCommand.NotifyCanExecuteChanged(); + + // ── Preview ───────────────────────────────────────────────────────────────── + + /// True once a suggestion has come back and is waiting to be confirmed or discarded. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ColumnCountLabel))] + private bool hasPreview; + + /// The proposed list title, editable before confirming. + [ObservableProperty] + private string listTitle = ""; + + /// The proposed list description, editable before confirming. + [ObservableProperty] + private string listDescription = ""; + + public ObservableCollection Columns { get; } = new(); + + public ObservableCollection Rows { get; } = new(); + + /// Label for a new column the user is adding. + [ObservableProperty] + private string newColumnLabel = ""; + + public string ColumnCountLabel => $"{Columns.Count} of {AiListDsl.MaxFields} columns"; + + /// What the model charged for the suggestion, for the "powered by" byline. + [ObservableProperty] + private string? usageLabel; + + // ── Commands ──────────────────────────────────────────────────────────────── + + protected override void NotifyAiCommandsChanged() + { + SuggestCommand.NotifyCanExecuteChanged(); + ConfirmCommand.NotifyCanExecuteChanged(); + } + + [RelayCommand] + private void ToggleOpen() + { + IsOpen = !IsOpen; + if (IsOpen) _ = EnsureStatusLoadedAsync(); + } + + private bool CanSuggest() => CanRunAi && !string.IsNullOrWhiteSpace(Description); + + [RelayCommand(CanExecute = nameof(CanSuggest))] + private async Task SuggestAsync(CancellationToken ct) + { + // Pre-flight. A 422 for an over-long input would cost nothing, but the + // same check also gives an instant answer instead of a round-trip. + if (!AiFeatureLimits.TryValidateInput(AiFeature.PoweredTemplate, Description, out var inputError)) + { + RejectLocally(inputError!); + return; + } + + var result = await RunAiAsync( + "Drafting a list from your description…", + token => Session.Api.AiSuggestAsync(AiFeature.PoweredTemplate, Description.Trim(), ct: token), + r => r.Quota, + ct); + + if (result is null) return; + + if (result.Artifact.AsList() is not { } list) + { + Notice = new AiNotice(AiNoticeKind.ModelDeclined, + $"Expected a list but the AI returned a \"{result.Artifact.Kind}\". " + + "That attempt still counted against today's allowance — try describing the list differently.", + Spent: true); + return; + } + + LoadPreview(list); + UsageLabel = result.Usage?.Display; + Notice = AiNotice.Info("Draft ready. Edit the columns and starter rows below, then create the list."); + } + + private void LoadPreview(AiListArtifact list) + { + _dsl = AiListDsl.FromJson(list.Dsl) ?? AiListDsl.Empty(); + _dslName = _dsl.Name; + _dslDescription = _dsl.Description; + + ListTitle = list.Title; + ListDescription = list.Description ?? ""; + + // The types this artifact actually uses, so a per-column picker can + // offer them without this client inventing DSL types it hasn't seen. + var artifactTypes = _dsl.Fields.Select(f => f.Type).ToList(); + + Columns.Clear(); + foreach (var field in _dsl.Fields) + Columns.Add(new AiListColumnViewModel(field, artifactTypes)); + + Rows.Clear(); + foreach (var row in list.Rows) + Rows.Add(AiListRowViewModel.FromWire(Columns, row)); + + HasPreview = true; + OnPropertyChanged(nameof(ColumnCountLabel)); + AddColumnCommand.NotifyCanExecuteChanged(); + ConfirmCommand.NotifyCanExecuteChanged(); + } + + [RelayCommand] + private void Discard() + { + HasPreview = false; + Columns.Clear(); + Rows.Clear(); + ListTitle = ""; + ListDescription = ""; + UsageLabel = null; + NewColumnLabel = ""; + _dsl = null; + ClearNotice(); + OnPropertyChanged(nameof(ColumnCountLabel)); + AddColumnCommand.NotifyCanExecuteChanged(); + ConfirmCommand.NotifyCanExecuteChanged(); + } + + [RelayCommand] + private void RemoveColumn(AiListColumnViewModel column) + { + if (column is null || !Columns.Remove(column)) return; + + // Keep the table rectangular: every row loses the matching cell. + foreach (var row in Rows) + row.RemoveCellFor(column); + + OnPropertyChanged(nameof(ColumnCountLabel)); + AddColumnCommand.NotifyCanExecuteChanged(); + ConfirmCommand.NotifyCanExecuteChanged(); + } + + private bool CanAddColumn() => HasPreview && Columns.Count < AiListDsl.MaxFields; + + [RelayCommand(CanExecute = nameof(CanAddColumn))] + private void AddColumn() + { + var label = NewColumnLabel.Trim(); + if (label.Length == 0) + { + RejectLocally("Give the new column a label first."); + return; + } + + // Legal-key rule mirrored from the DSL contract so an illegal key can't + // reach /generate — a rejected /generate costs a unit. + var key = AiListDsl.KeyFromLabel(label); + if (key is null) + { + RejectLocally($"\"{label}\" doesn't produce a usable column key — use letters, digits, spaces, - or _."); + return; + } + + if (Columns.Any(c => c.Key == key)) + { + RejectLocally($"There's already a column with the key \"{key}\"."); + return; + } + + var column = AiListColumnViewModel.NewColumn(key, label, Columns.Select(c => c.Type)); + Columns.Add(column); + + foreach (var row in Rows) + row.AddCellFor(column); + + NewColumnLabel = ""; + ClearNotice(); + OnPropertyChanged(nameof(ColumnCountLabel)); + AddColumnCommand.NotifyCanExecuteChanged(); + ConfirmCommand.NotifyCanExecuteChanged(); + } + + [RelayCommand] + private void AddRow() + { + if (Columns.Count == 0) return; + Rows.Add(AiListRowViewModel.Blank(Columns)); + } + + [RelayCommand] + private void RemoveRow(AiListRowViewModel row) + { + if (row is not null) Rows.Remove(row); + } + + private bool CanConfirm() => CanRunAi && HasPreview && Columns.Count > 0; + + [RelayCommand(CanExecute = nameof(CanConfirm))] + private async Task ConfirmAsync(CancellationToken ct) + { + if (!TryBuildArtifact(out var artifact, out var error)) + { + RejectLocally(error!); + return; + } + + var result = await RunAiAsync( + "Creating your list…", + token => Session.Api.AiGenerateAsync(AiFeature.PoweredTemplate, artifact!, ct: token), + r => r.Quota, + ct); + + if (result is null) return; + + // The /generate envelope is unverified, so the id is a hint. Prove the + // list exists with a fresh GET before telling the host to open it. + if (result.Created.ListId is { Length: > 0 } listId) + { + var created = await RunApiAsync("Loading the new list…", token => Session.Api.GetListAsync(listId, token), ct); + + RefreshCommand?.Execute(null); + + if (created is not null) + { + Notice = AiNotice.Info($"Created \"{created.Title}\"."); + Discard(); + IsOpen = false; + OpenListCommand?.Execute(created); + return; + } + + // Written, but the read-back failed — don't claim more than that. + Notice = AiNotice.Info("The list was created, but loading it back failed. It's in the list browser on the left."); + Discard(); + return; + } + + // No id came back. Something was probably written, so refresh rather + // than assert either outcome. + RefreshCommand?.Execute(null); + Notice = new AiNotice(AiNoticeKind.Error, + "The server accepted the list but didn't return its id. Check the list browser on the left before trying again — a second attempt would create a duplicate and spend another AI credit."); + } + + /// + /// Build the artifact to persist, running every check that would otherwise + /// come back as a 422 from a /generate that already cost a unit. + /// + private bool TryBuildArtifact(out AiArtifact? artifact, out string? error) + { + artifact = null; + error = null; + + if (string.IsNullOrWhiteSpace(ListTitle)) + { + error = "Give the list a title before creating it."; + return false; + } + + if (Columns.Count == 0) + { + error = "A list needs at least one column."; + return false; + } + + if (Columns.Count > AiListDsl.MaxFields) + { + error = $"A list schema takes at most {AiListDsl.MaxFields} columns (there are {Columns.Count})."; + return false; + } + + var blankLabel = Columns.FirstOrDefault(c => string.IsNullOrWhiteSpace(c.Label)); + if (blankLabel is not null) + { + error = $"The \"{blankLabel.Key}\" column needs a label."; + return false; + } + + var duplicate = Columns.GroupBy(c => c.Key, StringComparer.Ordinal).FirstOrDefault(g => g.Count() > 1); + if (duplicate is not null) + { + error = $"Two columns share the key \"{duplicate.Key}\"."; + return false; + } + + var illegal = Columns.FirstOrDefault(c => !AiListDsl.IsValidKey(c.Key)); + if (illegal is not null) + { + error = $"The column key \"{illegal.Key}\" isn't valid (lowercase letters, digits, - and _, starting with a letter)."; + return false; + } + + var fields = Columns.Select(c => c.ToField()).ToList(); + var dsl = (_dsl ?? AiListDsl.Empty()).ToElement( + _dslName ?? ListTitle.Trim(), + _dslDescription ?? (ListDescription.Length > 0 ? ListDescription.Trim() : null), + fields); + + // An all-blank row is noise; the server drops rows that fail schema + // validation anyway, so don't send them in the first place. + var rows = Rows + .Where(r => !r.IsEmpty) + .Select(r => r.ToWire(Columns)) + .ToList(); + + artifact = AiArtifact.FromPayload(new AiListArtifactPayload + { + Title = ListTitle.Trim(), + Description = ListDescription.Length > 0 ? ListDescription.Trim() : null, + Dsl = dsl, + Rows = rows + }); + + return true; + } +} diff --git a/InterlinedList/Views/ListsView.xaml b/InterlinedList/Views/ListsView.xaml index eaf3e0f..8cd9b00 100644 --- a/InterlinedList/Views/ListsView.xaml +++ b/InterlinedList/Views/ListsView.xaml @@ -345,6 +345,9 @@ HorizontalScrollBarVisibility="Disabled" Padding="20,12"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +