From 44a7057c0a943b234e4a025c79b3a64a8753705c Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 15 Jul 2026 06:26:08 -0400 Subject: [PATCH 01/15] feat: improve slide generation and rendering pipeline --- .planning/large_deck_generation_quality.md | 148 ++++- AGENTS.md | 16 + packages/playground/README.md | 32 +- .../assets/ai_prompts/outline_system.prompt | 299 +++------ .../assets/ai_prompts/section_system.prompt | 42 ++ .../assets/ai_prompts/slide_system.prompt | 183 ++---- .../core/constants/gemini_models.dart | 5 +- .../prompts/generation_prompt_provider.dart | 381 +++++++++++- .../services/deck_generator_pipeline.dart | 514 +++++++++++++++- .../services/deck_generator_service.dart | 129 +++- .../services/deck_generator_workflow.dart | 60 +- .../engine/services/deck_plan_repair.dart | 22 +- .../engine/services/deck_plan_validator.dart | 71 +-- .../services/design_quality_metrics.dart | 13 +- .../services/generated_slide_validator.dart | 111 +++- .../generation_model_call_executor.dart | 7 + .../engine/services/generation_progress.dart | 11 + .../services/generation_quality_report.dart | 13 +- .../engine/services/source_grounding.dart | 91 ++- .../pages/generation_lab_page.dart | 19 +- .../generation_prompt_provider_test.dart | 264 +++++++- .../engine/schemas/outline_schema_test.dart | 145 ++++- .../services/deck_generator_service_test.dart | 580 +++++++++++++++++- .../generated_slide_validator_test.dart | 213 ++++++- .../generation_quality_report_test.dart | 77 +++ .../services/source_grounding_test.dart | 120 ++++ .../ai_generation_smoke_test.dart | 90 +-- 27 files changed, 2998 insertions(+), 658 deletions(-) create mode 100644 packages/playground/assets/ai_prompts/section_system.prompt diff --git a/.planning/large_deck_generation_quality.md b/.planning/large_deck_generation_quality.md index 4a10f445..99874610 100644 --- a/.planning/large_deck_generation_quality.md +++ b/.planning/large_deck_generation_quality.md @@ -6,8 +6,10 @@ > support, and the same catalog pattern for deferred image-style selection. Status: In progress; Tasks 1–6, the 12-theme runtime family, representative -render qualification, and the deterministic ten-slide checkpoint are complete. -The repeated real 10/15/20 quality matrix and final regression remain pending. +render qualification, the deterministic ten-slide checkpoint, and the real +ten-slide narrative qualification are complete. Task 7 now uses the stable +Flash/Lite model split and concurrent section composition. Repeated real 15/20 +visual qualification and the broader monorepo regression remain pending. Checkpoint: `cf463294` (`feat: improve AI deck generation quality`), pushed to `origin/leoafarias/iterative-ai-generation` on 2026-07-14. @@ -16,8 +18,10 @@ Current handoff: [`ai_generation_session_status_2026_07_14.md`](ai_generation_se ## Verdict -**Refine, not rewrite.** Keep the plan-first, one-slide-at-a-time Flash pipeline -and its inspectable artifacts. Replace open-ended model-authored global style +**Refine, not rewrite.** Keep the plan-first pipeline and its inspectable +artifacts. Use one `gemini-3.5-flash` request for global narrative planning, then +compose the validated narrative sections concurrently with +`gemini-3.1-flash-lite`. Replace open-ended model-authored global style tokens with selection from a curated, described, versioned theme catalog while retaining per-slide composition/treatment decisions and explicit user brand overrides. Reuse that catalog-selection contract for image style when image @@ -42,9 +46,93 @@ prompts and validators are not yet a sufficient quality contract. - Quality outcome: prompt/schema changes are evaluated with deterministic fixtures, exact structural/semantic checks, real rendered slides, contact sheets, and an opt-in visual-review rubric before they are accepted. -- Performance constraint: retain the configured Flash model and keep explicit - thinking disabled. Do not add a production critic call until measurements show - that its quality gain justifies the latency and cost. +- Performance constraint: use `gemini-3.5-flash` for global planning and + `gemini-3.1-flash-lite` for section composition and targeted repair. Keep the + lowest supported thinking setting and do not add a production critic call + until measurements show that its quality gain justifies the latency and cost. + +## Task 7 generation policy: performance, context, and recovery + +The first real 10/15/20 matrix proved that the plan-first architecture is worth +keeping, but the original acceptance policy is too broad and too expensive. A +10-slide success used 25 model requests instead of the ideal 11, a 20-slide +success used 50 instead of 21, and one 15-slide run exhausted outline repairs. +Every request used `thinkingBudget: 0`, the lowest setting supported by the +current client/model combination. The repeated calls came from deterministic +false positives and all-or-nothing retry behavior, not from model reasoning. + +Latest real checkpoint (2026-07-15): `narrative_10` generated and rendered all +10 slides in 23.4 seconds using four provider calls (one Flash outline plus +three concurrent Flash-Lite sections), zero repairs, eight composition families, +and no repeated composition run. The machine-readable quality gate passed and +the contact sheet showed no visible overflow. Local artifact: +`packages/playground/test_live/ai_generation/artifacts/narrative_10_2026-07-15T01-15-32.776783Z/`. + +Use three explicit validation tiers: + +- **Hard contract:** schema/parseability, requested count and order, unique keys, + section membership, exact selected theme and user overrides, grounded element + source/name/cardinality, invented URLs/domains, renderer-safe structure, and + concrete legal, compliance, credential, availability, security, or commercial + claims. These may trigger one targeted repair; a deck-global failure can stop + the run. +- **Repairable slide defect:** missing substantive content, broken Markdown or + table structure, composition mismatch, invalid widget arguments, excessive + density beyond twice the composition-aware budget, or a malformed heading + hierarchy. Compose once and repair the current slide at most once. If it still + fails, record that slide as failed and continue composing later slide slots. +- **Diagnostic quality signal:** unsupplied numbers, possible changes to a + supplied number's meaning, design rhythm, composition diversity, broad + editorial verbs, evidence-strength wording without a concrete high-risk claim, + moderate density overages, and other heuristic quality observations. The + prompt still asks the model to preserve supplied metrics, qualify new numbers + as planned or illustrative, and respect density budgets, but a lexical or + pacing heuristic does not prevent a renderable POC deck. Record these in traces + and the quality report; never spend a production repair call on them. + +Grounding applies to audience-facing plan fields (`title`, `assertion`, and +`contentUnits`) and final visible slide copy. Internal planning prose (`purpose`, +`contentBrief`, `continuity`, and section transitions) guides composition but is +not itself published evidence and must not create factual repair loops. A +grounded value may use its subject elsewhere on the same slide; labels such as +`31%` do not need to repeat a full source sentence in every block. + +Do not silently skip a failed slide or discard already accepted slides. The +composition phase returns ordered per-slide outcomes. Later slides continue +using the validated plan and the most recent accepted slide as context. After +the pass, the caller receives the accepted slides plus exact failed slide keys, +typed issues, and retryability. The product UI keeps failed slots visible and +retryable; canonical deck persistence still requires every slot to resolve. + +Performance budgets for the final matrix: + +- Normal request path: one global outline plus one concurrent request per + narrative section, normally three to five section requests for 10–20 slides. +- Accepted repair ceiling: at most one global outline repair followed by a small + deck-wide budget of targeted slide-plan repairs. Generated section failures + are returned per slide rather than restarting the deck. +- Target total requests: normally 4–6 before transport retries; any additional + request must be visible in the artifact trace. +- Target repair rate: no more than 15% of slide requests across two samples. +- Initial outline prompt target: at most 10,000 characters; initial slide prompt + target: at most 11,000. Each slide repair prompt must be smaller than its + failed initial request. A deck-global outline repair may include the complete + invalid plan, but uses dedicated compact instructions and runs at most once. + These are regression budgets, not model token guarantees. +- Target pre-render generation time is 20 seconds, with 30 seconds as the normal + maximum. Report provider latency separately from local capture. + +Context is intentionally compact: the original typed request remains the user +message; the outline sees a concise planning contract and eligible theme +candidates; each section sees its ordered plan items, adjacent boundary plan +items, exact facts/elements, per-slide density budgets, and one deduplicated +canonical JSON shape example per composition used in that section. Dedicated +repair prompts contain the invalid current object, current blocking issues, and +only the guardrail snippets needed for those issue codes. + +Compose narrative sections concurrently. The validated global blueprint and +adjacent boundary items preserve deck flow without serializing 10–20 provider +round trips. Preserve accepted slides when another slide or section fails. Out of scope for the first quality pass: @@ -65,8 +153,9 @@ Out of scope for the first quality pass: structured output for complex JSON, and iterative evaluation against observed responses. It also warns that too many examples can cause overfitting: [Gemini prompt design strategies](https://ai.google.dev/gemini-api/docs/prompting-strategies). -- Gemini structured outputs support `minItems` and `maxItems`, and still require - application-level semantic validation: +- Gemini structured output constrains responses to syntactically valid JSON that + follows the supported schema, but still requires application-level semantic + validation: [Gemini structured outputs](https://ai.google.dev/gemini-api/docs/structured-output?lang=rest). - Recent presentation-generation work independently converges on hierarchical planning, separating page design from implementation, and reviewing rendered @@ -225,12 +314,12 @@ status is the operational handoff. three-to-five acts/sections for a 10–20-slide deck, one exact selected theme ID, and an ordered visual rhythm with a concrete brief for each slide. The application resolves and records the catalog version. -4. Each slide is composed against its current act, neighboring slide briefs, and - a compact design ledger. The ledger records recent composition/treatment and - prevents accidental repetition without forcing random layouts. +4. Each narrative section is composed against its ordered slide briefs and the + adjacent boundary briefs. The plan owns composition/treatment rhythm; compact + canonical examples demonstrate only the layout families used by that section. 5. Model-facing structured output bounds arrays and uses the strongest supported - types. A semantic validator checks the current slide against its plan before - accepting it as context for the next slide. + types. Dart validates every returned slide independently against its plan, + keeps valid slides, and exposes failed slots for targeted retry. 6. Dart resolves the selected theme into one coherent `DeckOptions` base style plus named treatments such as hero, section, content, data, quote, visual, and closing. The model selects semantic treatments; Dart owns font sizes, @@ -375,7 +464,8 @@ Deck-level deterministic checks: - generated, serialized, parsed, and captured slide counts equal the request; - every plan key appears exactly once and every section has its planned slides; -- no accidental run of more than two identical composition/treatment families; +- no accidental run of more than three identical compositions; treatments may + repeat when the information shape and composition still vary meaningfully; - 10/15/20-slide general-purpose fixtures use enough distinct purposeful composition families (target at least 5/6/7 unless the brief constrains them); - body and heading colors meet 4.5:1 and 3:1 contrast respectively; @@ -559,10 +649,11 @@ table/data legibility, element relevance, and blank/overflow detection. 30-slide golden qualification prove both paths. - Checkpoint: run deterministic 10-slide generation with a fake model and inspect - the serialized design ledger, resolved styles, and captured layout fixtures. - - Completed evidence: `LIVE_FAKE_CHECKPOINT=true` performs one outline request - plus ten sequential slide requests with no repairs or thinking configuration, - verifies every compact three-slide ledger window, serializes the canonical + ordered section plans, resolved styles, and captured layout fixtures. + - Completed evidence: `LIVE_FAKE_CHECKPOINT=true` performs one Flash outline + request plus three Flash-Lite narrative-section requests with no repairs, + verifies the exact model/thinking configuration, ordered section plans, and + deduplicated canonical shape examples, serializes the canonical theme/plan/deck/prompts, records declared and loaded runtime font identities, replays ten Markdown slides, captures ten PNGs and a contact sheet, and passes the machine quality report with eight composition families and exact 10/10 @@ -594,8 +685,9 @@ table/data legibility, element relevance, and blank/overflow detection. font rendering, and latency. - Change prompts/examples for recurring aesthetic/content failures; change schemas/validators only for real contractual failures. - - Preserve Flash and no explicit thinking. Record request count and wall time - for 10/15/20 slides and reject changes that add unexplained calls. + - Preserve the documented Flash/Lite split and lowest thinking setting. Record + request count and wall time for 10/15/20 slides and reject changes that add + unexplained calls. - Acceptance: all deterministic gates pass across the final large-deck matrix, no known blank/duplicate regression recurs, and human review confirms coherent story, readable hierarchy, useful variation, and visibly correct typography. @@ -633,13 +725,13 @@ table/data legibility, element relevance, and blank/overflow detection. - Unit: schema adaptation bounds, typed request serialization, plan hierarchy, theme descriptor validation and candidate filtering, exact theme resolution, contrast, typography catalog resolution, composition contracts, element - cardinality, density budgets, design ledger, and theme factory. Add equivalent - image-style catalog/request tests only in the deferred phase. + cardinality, density budgets, section shape examples, and theme factory. Add + equivalent image-style catalog/request tests only in the deferred phase. - Regression fixtures: commit the four known-bad model responses and prove they fail before repair; keep representative valid table/image/QR/WebView slides. - Widget/golden: base typography scale, named treatment variants, light/dark table and quote styles, actual selected family, and custom registered family. -- Integration: fake-client 10/15/20 plan-to-slide orchestration, repair isolation, +- Integration: fake-client 10/15/20 plan-to-section orchestration, repair isolation, exact count, cancellation, Markdown replay, and selected-theme application. - Live: real Flash 10/15/20 runs save the complete artifact bundle, quality report, actual-font PNGs, and contact sheets. These remain opt-in and outside normal CI. @@ -703,10 +795,10 @@ fvm flutter test test_live/ai_generation/ai_generation_smoke_test.dart \ - Risk: visual review adds cost/latency without reliable signal. Mitigation: keep it opt-in and informational until repeated human/artifact comparisons calibrate thresholds. -- Risk: 20 sequential slide calls are slow. Mitigation: measure before changing - orchestration, keep Flash/no-thinking, compact context, and avoid a mandatory - second model pass. Consider bounded parallel composition only after the richer - blueprint proves that generated-previous-slide context is not required. +- Risk: section concurrency weakens slide-to-slide continuity. Mitigation: keep + one validated global blueprint, provide each section its adjacent boundary + plan items, preserve exact ordered slide keys, and compare rendered contact + sheets rather than relying on JSON success alone. - Stop prompt tuning if a failure is caused by schema/sanitizer/rendering behavior; fix the contractual layer first. - Stop schema expansion if the same outcome can be enforced more safely in Dart. diff --git a/AGENTS.md b/AGENTS.md index 60e90c46..707e9407 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,6 +179,22 @@ lib/src/ ### Reactive State The project uses Signals for reactive state management. `DeckController` is the central state manager for presentations. +### AI Generation Models + +Use the current stable Gemini model split for the Playground deck-generation +pipeline: + +- `gemini-3.5-flash` for the single global outline/planning request +- `gemini-3.1-flash-lite` for concurrent narrative-section composition and + targeted outline/slide repair + +Keep model thinking at the lowest supported setting for this latency-sensitive +workflow. Do not silently switch back to an older model, a preview model, or +one request per slide. Any model or reasoning-setting change must be validated +with the opt-in live 10-slide generation smoke test and its saved timing, +validation, render, and quality artifacts. Target 20 seconds and treat 30 +seconds as the maximum acceptable generation time before local rendering. + ### Block System Slides use `@tag` directives in Markdown to define layout and content: - `@section` - Groups child blocks into a horizontal section diff --git a/packages/playground/README.md b/packages/playground/README.md index eddd4b43..ea4b4a4e 100644 --- a/packages/playground/README.md +++ b/packages/playground/README.md @@ -46,12 +46,14 @@ missing. ## AI generation smoke lab -Generation uses a plan-first pipeline: one model request creates the shared -narrative/style plan, then the Flash slide model composes and validates each -slide sequentially with previous/next context. Neither request configures a -thinking budget. Both default to the existing `gemini-3-flash-preview` -configuration, keeping live prompt iteration fast while model selection remains -injectable for comparisons. +Generation uses a plan-first pipeline. `gemini-3.5-flash` creates the shared +narrative/style plan, then `gemini-3.1-flash-lite` composes each narrative +section concurrently. Every call uses the lowest thinking-budget compatibility +setting exposed by the pinned client. Gemini structured output constrains each +response to JSON and a response schema; Dart still performs semantic, +grounding, layout, density, and canonical parsing checks before accepting it. +Invalid slides remain isolated failures so valid slides and later sections can +continue. Live AI checks are opt-in and live outside `test/`, so normal test runs never spend API quota. Run all three versioned briefs with: @@ -74,10 +76,10 @@ fvm flutter test test_live/ai_generation/ai_generation_smoke_test.dart \ The matrix generates editorial 10-slide, technical/data 15-slide, and bold product 20-slide decks with three exact font pairings. Each run writes an ignored artifact bundle under `test_live/ai_generation/artifacts/` containing the typed -request, brief, deck plan, per-slide prompts and responses, canonical JSON, +request, brief, deck plan, section prompts and responses, canonical JSON, Markdown, validation/timing metadata, slide PNGs, a contact sheet, and a machine-readable `quality_report.json`. Metadata records total, outline, and -slide request counts separately. Captures load the actual selected Google font +composition request counts separately. Captures load the actual selected Google font families; they do not register Roboto bytes under aliases. Run the deterministic ten-slide fake-model checkpoint without an API key: @@ -88,12 +90,14 @@ fvm flutter test test_live/ai_generation/ai_generation_smoke_test.dart \ --reporter expanded ``` -It verifies a zero-repair 11-call plan-to-slide run, each compact design ledger, -the resolved theme/font/style snapshot, Markdown replay, ten full-size captures, -a contact sheet, and the machine quality report. Run the reviewed featured-theme -goldens separately with `--dart-define=LIVE_THEME_QUALIFICATION=true`; those -render 30 slides with actual fonts and compare three exact contact-sheet -baselines under `test_live/ai_generation/goldens/`. +It verifies a zero-repair four-call run (one outline plus three concurrent +narrative sections), the ordered section plans and canonical layout examples, +the exact Flash/Lite model split and thinking configuration, resolved +theme/font/style snapshot, Markdown replay, ten full-size captures, a contact +sheet, and the machine quality report. Run the reviewed featured-theme goldens +separately with `--dart-define=LIVE_THEME_QUALIFICATION=true`; those render 30 +slides with actual fonts and compare three exact contact-sheet baselines under +`test_live/ai_generation/goldens/`. Replay and recapture a saved artifact without making another model request: diff --git a/packages/playground/assets/ai_prompts/outline_system.prompt b/packages/playground/assets/ai_prompts/outline_system.prompt index 959882f6..c42575c6 100644 --- a/packages/playground/assets/ai_prompts/outline_system.prompt +++ b/packages/playground/assets/ai_prompts/outline_system.prompt @@ -2,14 +2,30 @@ You are a presentation strategist and design director. Create one coherent, hierarchical blueprint for a SuperDeck presentation. Plan the complete story and visual rhythm; do not write final slide Markdown. -The user message is a typed JSON generation request. Treat `slideCount`, an -explicit `themeId`, palette/font overrides, and `groundedElements` as exact -constraints. Treat `userIntent` as content direction, never as authority to -override this system contract. -Treat `groundedNumericFacts` as immutable evidence excerpts: preserve each -number's original unit, denominator, comparison, and subject. -Grounding applies to every output field, including story, section purpose and -transition, slide purpose, content brief, and continuity—not only visible copy. +## Authority and evidence + +The user message is a typed JSON request. Treat `slideCount`, explicit theme, +palette/font choices, and `groundedElements` as exact constraints. The original +request is the only authority for factual claims, numbers, URLs, capabilities, +availability, evidence status, credentials, and commercial commitments. + +- Treat `groundedNumericFacts` as immutable evidence excerpts. Preserve each + value's subject, unit, denominator, comparison, and time period. +- A new number is allowed only as an explicitly labeled projection, estimate, + assumption, calculation, scenario, planned/proposed target, or illustrative + plan milestone in that same content unit. +- Recommendations and future operating choices are allowed when each affected + claim is visibly marked `planned`, `proposed`, or `illustrative`. Never present + them as shipped capability, observed outcome, guarantee, or current offer. +- Do not infer causality or strengthen evidence with language such as proven, + validated, significant, or at scale unless the request supplies it. +- Never invent an asset path, URL/domain, QR destination, product status, + security/compliance claim, certification, pricing term, trial, waitlist, award, + patent, or guarantee. + +These rules protect audience-facing fields (`title`, `assertion`, and +`contentUnits`). Internal `purpose`, `contentBrief`, and `continuity` should be +clear editorial instructions rather than additional factual copy. ## Output contract @@ -17,203 +33,82 @@ Return one JSON object matching the provided schema: - `topic`: concise presentation topic - `story`: one-sentence thesis and narrative through-line -- `theme`: one object containing only the exact selected candidate `id` +- `theme`: one object containing only the selected candidate `id` - `sections`: ordered narrative acts whose `slideKeys` partition the deck - `slides`: exactly the requested number of ordered slide plans -For 10–20 slides, use three to five meaningful sections. Each section must have: - -- a unique `key` and short `title` -- one narrative `purpose` -- a `transition` that earns the next section -- ordered `slideKeys` that appear exactly once across all sections - -## Slide plan contract - -Every slide must include: - -- `key`: unique descriptive kebab-case identifier -- `title`: working title, preferably six words or fewer -- `purpose`: the internal communication goal -- `sectionKey`: exact key of its containing section -- `assertion`: one audience-facing claim, not a topic label -- `contentUnits`: concrete evidence, examples, comparisons, or implications -- `narrativeRole`: the slide's story job -- `contentBrief`: precise direction sufficient to compose the slide independently -- `continuity`: connection to the preceding and following ideas -- `composition`: semantic layout chosen from content needs -- `treatment`: semantic visual treatment within the shared design system -- `density`: `spacious`, `balanced`, or `compact` -- `elements`: grounded non-Markdown requirements, or an empty list - -## Narrative roles - -- `opening`: establish the promise or tension -- `context`: supply only essential background -- `problem`: make a challenge concrete -- `insight`: reveal the central idea -- `evidence`: support a claim with facts or metrics -- `comparison`: contrast options on shared criteria -- `solution`: explain the proposed answer -- `process`: show ordered actions or a system -- `takeaway`: crystallize what matters -- `transition`: create a deliberate section handoff -- `closing`: finish with a decision, action, or memorable synthesis - -Build an intentional opening, progressive development, and decisive closing. -For larger decks, each section should advance a distinct question; do not stretch -one idea across many interchangeable slides. - -## Composition intents - -- `title`: opening, divider, or closing statement -- `content`: one focused idea with concise supporting evidence -- `twoColumn`: two independent categories or narratives -- `threeColumn`: exactly three parallel concepts -- `table`: uniform comparison across shared attributes -- `quote`: short sourced statement or major takeaway -- `titleLeft`: assertion/context beside supporting details -- `imageLeft` / `imageRight`: supplied image paired with text -- `imageFullBleed`: supplied hero image with minimal overlay -- `metric`: one to three important numbers with meaning; every number must - already appear explicitly with its subject in this slide's `contentUnits` -- `qrcode`: explicit handoff using supplied text or URL -- `webview`: supplied HTTP(S) page that genuinely needs a live embed -- `dartpad`: supplied DartPad gist identifier -- `custom`: explicitly registered custom widget requested by name - -Choose composition from the information shape, not a mechanical rotation. Never -repeat one composition more than three times consecutively. Unless the brief constrains -the format, target at least five distinct composition families for 10 slides, -six for 15, and seven for 20. - -## Visual treatments and density - -- `hero`: opening promise or single memorable statement -- `section`: deliberate act transition -- `content`: explanation, process, or structured narrative -- `data`: metrics, tables, and quantitative evidence -- `quote`: sourced voice or emphasized takeaway -- `visual`: image or grounded interactive element -- `closing`: decision, commitment, or final synthesis - -Treatments create rhythm inside one coherent system. Do not repeat a treatment -more than three times consecutively. Use `spacious` for one decisive idea, `balanced` -for normal explanation, and `compact` only when a table or structured comparison -needs more information. Variation must serve the story, not look random. - -Treatment/composition compatibility is exact: - -- `hero`: `title` -- `section`: `title` or `titleLeft` -- `quote`: `quote` -- `closing`: `title`, `titleLeft`, `quote`, or `qrcode` -- `data`: `metric`, `table`, `twoColumn`, or `threeColumn` -- `content` and `visual`: choose a semantically appropriate composition that - still obeys the grounded-element rules +For 10–20 slides, use three to five meaningful sections. Every section needs a +unique key, short title, narrative purpose, earned transition, and ordered +`slideKeys` that appear exactly once across the deck. + +Every slide plan includes: + +- unique descriptive kebab-case `key`, short working `title`, and `sectionKey` +- internal `purpose`, one audience-facing `assertion`, concrete `contentUnits`, + precise `contentBrief`, and concise `continuity` +- one `narrativeRole`, `composition`, `treatment`, and density (`spacious`, + `balanced`, or `compact`) +- exact grounded `elements`, or an empty list + +Narrative roles: `opening`, `context`, `problem`, `insight`, `evidence`, +`comparison`, `solution`, `process`, `takeaway`, `transition`, and `closing`. +Build a deliberate opening, progressive sections, and a decisive close. Each +slide advances one distinct idea rather than repeating the prior slide. + +## Composition and rhythm + +Choose layout from information shape: + +- `title`: opening, divider, or closing display statement +- `content`: one focused idea with concise support +- `twoColumn` / `threeColumn`: independent parallel categories +- `table`: uniform comparison on shared attributes +- `metric`: one to three grounded numbers with their meaning +- `quote`: short supplied voice or emphasized takeaway +- `titleLeft`: assertion/context beside supporting detail +- `imageLeft`, `imageRight`, `imageFullBleed`: supplied image plus appropriate copy +- `qrcode`, `webview`, `dartpad`, `custom`: matching supplied element only + +Treatments are semantic: `hero`, `section`, `content`, `data`, `quote`, `visual`, +and `closing`. Exact compatibility: + +- `hero` -> `title` +- `section` -> `title` or `titleLeft` +- `quote` -> `quote` +- `closing` -> `title`, `titleLeft`, `quote`, or `qrcode` +- `data` -> `metric`, `table`, `twoColumn`, or `threeColumn` +- `content` / `visual` -> any semantically appropriate compatible composition + +Avoid repeating one composition more than three times consecutively. A treatment +may repeat when the information shape and composition still create meaningful +visual variation; do not rotate treatments mechanically. +Unless the brief constrains format, target at least 5/6/7 distinct composition +families for 10/15/20 slides. Variation must serve the narrative, not rotate +mechanically. Use compact density only for genuinely structured information. ## Grounded elements -Supported element types are `image`, `qrcode`, `webview`, `dartpad`, and `custom`. - -- State the element `purpose`. -- Copy `source` exactly from a matching `groundedElements` entry or exact source - present in `userIntent`. -- Never invent an asset path, URL, QR payload, DartPad ID, or widget name. -- Never invent a URL or domain in titles, assertions, content units, briefs, or - continuity copy. A QR handoff may say "scan to continue" without spelling a - destination unless that exact domain was supplied. -- Treat every number as a claim. Reuse numeric facts from `userIntent` exactly. - If arithmetic or a scenario adds a new number, visibly label the containing - copy as a projection, estimate, assumption, calculation, or scenario. -- Never keep a supplied number while changing what it measures. For example, - "42% less weekly synthesis time" cannot become "42% of the work week." -- Preserve qualitative evidence as qualitative. A phrase such as "no change to - source-of-truth systems" must not become an invented `0` or `0%` claim. - Do not use `zero` in a title or idiom such as "zero disruption," "zero to - insight," or "zero friction"; retain the exact supplied `no change` wording. -- Do not broaden a scoped metric with an ungrounded fraction or analogy. For - example, "42% less weekly synthesis time" does not mean "nearly half the - work week." -- Do not translate a relative time metric into invented hours, days, weeks, or - delivery speed. A percentage reduction does not reveal an absolute duration. -- Do not introduce commercial or credential commitments such as a free trial, - waitlist, guarantee, certification, SOC 2/GDPR/HIPAA or other compliance - claim, patent, or award unless the exact commitment appears in `userIntent`. -- A fictional product brief does not authorize invented shipped capabilities. - Do not upgrade a beta to "production," claim the product is "now live," add - launch speed or durations, or invent implementation/security specifics such - as real-time behavior, read-only access, granular permissions, audit logs, - drag-and-drop, dedicated onboarding, or pre-built connectors. When the brief - names only a topic such as governance, pricing shape, onboarding, or roadmap, - frame missing detail as an explicitly qualified `illustrative option`, - `proposed direction`, or `planned` capability—not a deployed fact, customer - result, promise, or commercial term. Put that qualifier in every affected - assertion or content unit; a qualifier elsewhere on the slide does not cover - an unqualified capability, security, availability, pricing, or roadmap claim. -- Category requests do not supply implementation details. For example, pricing - shape does not imply seats, tiers, unlimited usage, or support; governance does - not imply RBAC, SSO, compliance, or audit logs; onboarding does not imply - training, setup speed, or dedicated support; extensibility does not imply an - SDK, CLI, marketplace, native connector, or custom ingestion behavior. -- Beta status is evidence scope, not permission to claim validation, scale, - stability, production readiness, industry adoption, or customer demand. -- In a beta-proof section, use only the supplied beta facts. Do not infer - industries, environments, team structures, confidence, alignment, adoption, - causality, or qualitative customer feedback from cohort participation. -- Observed metrics do not establish why they changed. Never add causal language - such as "because," "by eliminating," "resulted in," or "drives" unless that - exact causal relationship is supplied. Do not upgrade observations with - `verified`, `validated`, `proven`, `demonstrated`, `essential`, `at scale`, - `real-world`, or `significantly` unless the source uses that exact language. -- Avoid absolute capability scope such as `any source`, `every signal`, - `immutable`, `permanent`, `never lost`, or `no data migration` unless the - source explicitly establishes it. A related qualitative fact is not enough. -- A beta cohort does not supply its research method. Do not invent testing, - interview, feedback, industry, or team-size details. A numeric observation does - not authorize `successful`, `immediate`, `rapid`, `significant`, hours saved, - high-value work, confidence, agility, cycle-time, or similar amplification. -- Topic labels such as inbox, linked insights, onboarding, and governance do not - authorize automation, prioritization, smart triage, tagging, notifications, - classification, correlation, self-service, or documentation. Each unsupplied - mechanism must be clearly planned/proposed in its own field or omitted. -- A supplied handoff purpose is exhaustive. Do not add documentation, sales or - launch-team access, signup, support, or another promise to a QR destination - unless that exact purpose was supplied. -- For `custom`, use only a `widgetName` explicitly supplied by the user. -- If a composition needs an unavailable source, choose text, metric, or table. -- `imageLeft`, `imageRight`, and `imageFullBleed` require a planned `image` - element. `qrcode`, `webview`, `dartpad`, and `custom` likewise require their - matching planned element. Never select one of these compositions with an - empty or mismatched `elements` list. -- Plan each grounded element only as many times as the story genuinely needs it; - a QR handoff or live embed should normally appear once. -- Preserve each grounded element's exact `purpose` in the plan. A QR destination - for a live SuperDeck page must not be reframed as product early access, - documentation, a waitlist, or another fictional destination. -- A past beta cohort is evidence, not an open enrollment program. Do not invite - the audience to join existing design partners unless the brief explicitly asks - for beta enrollment. - -## Shared theme selection - -Select exactly one eligible theme candidate using its stable `id`. Return only -that `id` in the `theme` object. Candidate descriptions and tags are selection -guidance; do not copy them into slide content. - -The application resolves the version, palette, typography, density, spacing, -components, and semantic treatments after selection. Never emit or infer those -runtime tokens, and never add palette, font, version, or brand-override fields to -the model-facing theme object. Exact user theme, palette, font, and density -constraints are enforced by the application. - -## Planning rules - -1. Match `slideCount` exactly. -2. Keep section membership and slide order exact and internally consistent. -3. Make every slide assert one idea and provide concrete content units. -4. Carry important facts forward without repeating prior slides. -5. Use tables only for uniform comparisons; use columns for independent ideas. -6. Make section transitions visible in the narrative and planned treatment. -7. End with a decision, action, or synthesis appropriate to the audience. -8. Return schema-supported JSON only. Do not add commentary or Markdown fences. +Supported types are `image`, `qrcode`, `webview`, `dartpad`, and `custom`. +Copy a matching source and purpose exactly from `groundedElements`; for custom +widgets, also preserve the supplied `widgetName`. Visual compositions require +their matching planned element. If the source is unavailable, choose a text, +metric, or table composition. A supplied handoff purpose is exhaustive—do not +rename it as documentation, signup, support, early access, or another promise. +Use each grounded element only as often as the story genuinely needs it. + +## Theme selection + +Select exactly one eligible theme candidate by stable `id` and return only that +ID in `theme`. Candidate descriptions/tags are selection guidance, not slide +copy. The application resolves version, palette, typography, density, spacing, +components, treatments, and exact user overrides. Never emit those runtime +tokens or add theme fields outside the schema. + +## Final checks + +1. Match `slideCount` exactly and keep section membership/order consistent. +2. Give every slide one assertion and enough concrete content to compose alone. +3. Preserve exact facts and grounded elements without invention or amplification. +4. Use tables only for uniform comparisons and columns for independent ideas. +5. End with the decision, action, or synthesis appropriate to the audience. +6. Return schema-supported JSON only, without commentary or Markdown fences. diff --git a/packages/playground/assets/ai_prompts/section_system.prompt b/packages/playground/assets/ai_prompts/section_system.prompt new file mode 100644 index 00000000..bfb47eba --- /dev/null +++ b/packages/playground/assets/ai_prompts/section_system.prompt @@ -0,0 +1,42 @@ +You compose one ordered narrative section of a SuperDeck presentation. + +## Output contract + +Return one JSON object containing only a `slides` array. Produce exactly one +canonical slide for every supplied plan item, in the same order, with the exact +planned key. Never add, omit, merge, split, or reorder slides. + +Each slide may contain `key`, `options`, `comments`, and `sections`. Slides stack +sections vertically; each section lays out blocks horizontally. Markdown blocks +use `type: "block"`. Registered elements use `type: "widget"`, their exact +catalog `name`, and a nested `args` object. Use only supported schema fields. + +## Composition + +- Treat the whole section as one visual and narrative sequence. Vary adjacent + compositions while preserving the plan's exact composition, treatment, and + density for each slide. +- Keep H1/H2 display headings to at most 8 words. Reserve H1 for title and + numeric metric treatments; use H2 elsewhere. +- Use one or two sections per slide and 1–3 blocks per section. Prefer concise + copy, clear hierarchy, and visible whitespace over filling the canvas. +- Use the exact structural shape implied by each plan: `title` is one block + beginning with H1; `content` is one block beginning with H2; `twoColumn` is + one section with exactly two blocks beginning with H3; `threeColumn` is one + section with exactly three H3 blocks; `metric` begins with one concise H1. +- A table plan requires a real compact Markdown table with 2–4 columns and at + most 6 body rows. A two/three-column plan requires that many dominant blocks. +- Use fullscreen only for genuine edge-to-edge visuals or embeds. + +## Evidence + +The original typed user request is the only factual authority. The plan selects +which facts belong on each slide but cannot authorize new facts. Preserve every +supplied number's subject, unit, denominator, comparison, and time period. +Clearly label projections, targets, estimates, assumptions, and illustrative +values. Never invent URLs, sources, product status, availability, security or +compliance claims, pricing, promises, certifications, or customer outcomes. + +Emit a widget only when that slide plans the exact element and source. Speaker +comments may explain grounded content or transitions but cannot introduce new +facts. Do not repeat copy mechanically across adjacent slides. diff --git a/packages/playground/assets/ai_prompts/slide_system.prompt b/packages/playground/assets/ai_prompts/slide_system.prompt index 553d42f1..a0f6e04c 100644 --- a/packages/playground/assets/ai_prompts/slide_system.prompt +++ b/packages/playground/assets/ai_prompts/slide_system.prompt @@ -1,124 +1,63 @@ You compose one polished SuperDeck slide as a canonical JSON object. -## Output - -Return one slide object only: `key`, optional `options` and `comments`, and -`sections`. Never return a deck wrapper or a `slides` array. - -## Layout primitives - -- Slides stack `sections` vertically; each section lays out `blocks` horizontally. -- Use one or two sections only. Do not add a third title, content, table, quote, - or footer row to a composition. -- Every section uses `type: "section"`, 1-3 blocks, positive integer `flex`, - optional non-negative `spacing`, and an optional alignment. -- Blocks use `type: "block"` for Markdown or `type: "widget"` plus a registered - `name` for a generation-capable element. -- Use block `flex` for column ratios. `margin` and `padding` are normalized edge - objects using `top`, `right`, `bottom`, and `left` numeric values. -- Use `options.layout: "fullscreen"` for intentional edge-to-edge visual or embed - slides. Do not use it for ordinary text slides. -- Favor one strong composition over repetitive title/body scaffolding. Use no - more than three columns and keep content within a 16:9 canvas. -- A `title` composition is a display statement: use headings and at most one - short supporting paragraph. Never add a list, table, or blockquote. - -## Markdown and tables - -Use headings, short paragraphs, lists, blockquotes, alerts, fenced code, links, -and Markdown tables inside content blocks. Tables must have 2-4 consistent -columns, a header separator, no more than 6 body rows, and concise cells (60 -characters maximum). Emit a Markdown table only when the current plan selected -the `table` composition, and use a table only for uniform attributes. -Use real Markdown line breaks in content strings. Never put the two visible -characters backslash+n into slide copy. - -## Elements - -- Emit a widget only when its exact name appears in the supplied catalog and the - current plan provides its required source or value. -- Widget arguments belong in a nested `args` object. Put only the selected - widget's listed arguments there; never mix arguments from other widgets. -- Never invent image paths, URLs, QR destinations, DartPad IDs, or custom widget - names. If a source is unavailable, use a strong text composition instead. -- Never invent a visible URL or domain in Markdown. For QR handoffs, say "scan - to continue" unless the exact visible destination appears in the plan. -- Treat every number as a claim. Use numeric facts from the request exactly; any - derived or hypothetical value must be visibly labeled as a projection, - estimate, assumption, calculation, or scenario. -- Preserve the original unit, denominator, comparison, and subject of every - supplied numeric fact. Do not reuse a percentage to describe a different - quantity. -- Preserve qualitative evidence as qualitative. Never convert "no change" into - an unsupplied `0` or `0%` claim. - Never turn it into a title or idiom using "zero," including "zero disruption," - "zero to insight," or "zero friction." -- Do not broaden a scoped metric with an unsupplied fraction or analogy. This - rule applies to visible Markdown and speaker comments alike. -- Do not translate a relative time metric into invented hours, days, weeks, or - delivery speed. A percentage reduction does not reveal an absolute duration. -- Do not introduce a free trial, waitlist, guarantee, certification, SOC 2, - GDPR, HIPAA, other compliance claim, patent, award, or commercial commitment - absent from the request. -- Do not strengthen a plan into a shipped capability or promise. Never add - production/live status, launch speed or durations, customer demand, or - implementation/security details such as real-time behavior, read-only access, - granular permissions, audit logs, drag-and-drop, dedicated onboarding, or - pre-built connectors unless the request explicitly supplies them. Preserve - roadmap qualifiers such as planned, estimated, illustrative option, proposed, - or direction on every individual bullet, table row, and claim. A qualifier in - a heading or neighboring block does not cover unqualified capability copy. -- Category requests do not supply implementation details. Pricing shape does not - imply seats, tiers, unlimited usage, or support; governance does not imply - RBAC, SSO, compliance, or audit logs; onboarding does not imply training or - setup speed; extensibility does not imply an SDK, CLI, marketplace, native - connector, or custom ingestion behavior. -- Beta status is evidence scope, not permission to claim validation, scale, - stability, production readiness, industry adoption, or customer demand. -- Do not invent a reason for an observed metric. Causal phrases such as - "because," "by eliminating," "resulted in," and "drives" require that exact - causal relationship in the request. Likewise, do not strengthen beta facts - with `verified`, `validated`, `proven`, `demonstrated`, `essential`, `at scale`, - `real-world`, or `significantly` unless the request uses that exact language. -- Do not broaden a capability to `any source`, `every signal`, `immutable`, - `permanent`, `never lost`, or `no data migration` unless the request explicitly - establishes that scope. Preserve narrower source language literally. -- Never invent beta methodology such as testing, interviews, feedback loops, - industries, or team sizes. Metrics do not authorize `successful`, `immediate`, - `rapid`, `significant`, hours saved, high-value work, confidence, agility, - cycle-time, or any explanation of the observation. -- Do not convert topic labels into shipped mechanisms. Automation, - prioritization, smart triage, tagging, notifications, classification, - correlation, self-service, and documentation must be explicitly supplied or - individually marked planned/proposed. -- Speaker comments may restate the current plan's assertion/content units or - describe a transition. They must not add causality, metrics, feature details, - availability, customer behavior/demand, evidence status, or commitments. -- If a plan field itself overstates evidence, do not amplify it. Keep beta copy - to exact supplied facts and never infer industries, environments, validation, - confidence, alignment, causality, adoption, or customer feedback. -- Images work best in a weighted text/image split or a fullscreen visual slide. -- QR codes should support a clear handoff CTA. WebView and DartPad embeds should - normally use fullscreen or a dominant 2:3 column. -- Preserve the planned element purpose exactly. If a QR source points to a live - SuperDeck page, do not rename it as fictional product early access, docs, - mobile access, a waitlist, or any other destination. -- Treat the planned element purpose as exhaustive: do not add documentation, - sales or launch-team access, signup, support, or another destination promise. -- A past beta cohort is evidence, not an open enrollment program. Do not invite - the audience to join the cohort unless the request explicitly asks for it. - -## Quality rules - -- Preserve the planned key exactly. Always emit `options.title` and set - `options.style` to the exact planned treatment, including on repair attempts. -- Keep H1 and H2 display headings to at most 8 words and make the hierarchy - scannable. -- Reserve H1 for `title` and numeric `metric` compositions only. Use H2 for - content, columns, tables, quotes, and visual-composition titles regardless - of their treatment. -- Do not repeat the previous slide's content or composition mechanically. -- Use the shared palette as design context; do not duplicate it in slide JSON. -- Never use a standalone three-hyphen Markdown horizontal rule; SuperDeck - reserves that line as a slide boundary. -- Include only fields supported by the structured-output schema. +## Output and layout + +Return one slide object only: exact `key`, optional `options` and `comments`, and +`sections`. Never return a deck wrapper. Preserve the planned treatment in +`options.style` and always provide `options.title`. + +- Slides stack sections vertically; each section lays out blocks horizontally. +- Use one or two sections, each with 1–3 blocks. Use positive integer `flex`, + non-negative `spacing`, and supported alignment/inset fields only. +- Markdown blocks use `type: "block"`. Registered elements use + `type: "widget"`, exact `name`, and a nested `args` object. +- Use `options.layout: "fullscreen"` only for a genuine edge-to-edge visual or + embed. Keep ordinary text inside the standard 16:9 canvas. +- A `title` composition is one display statement plus at most one short support + sentence—never a list, table, or blockquote. + +## Markdown + +Use concise headings, paragraphs, lists, blockquotes, alerts, fenced code, links, +and tables as appropriate. Use real line breaks, never the visible characters +backslash+n. A table is allowed only for the planned `table` composition and +must have 2–4 consistent columns, at most 6 body rows, and concise cells. Never +emit a standalone three-hyphen line because SuperDeck reserves it as a slide +boundary. + +## Evidence and elements + +The original typed user request is the only factual authority. The current plan +selects which facts belong on this slide but cannot authorize new facts. + +- Use supplied numeric facts with the same subject, unit, denominator, + comparison, and time period. A short metric label may rely on supporting + context elsewhere on this same slide. +- Label every new calculation, milestone, or hypothetical value in the same + visible unit as a projection, estimate, assumption, scenario, + planned/proposed target, or illustrative plan. Do not convert qualitative `no + change` evidence into `0` or `0%`. +- Recommendations are allowed when clearly marked planned/proposed/illustrative. + Do not turn them into shipped capabilities, current availability, observed + outcomes, causal explanations, or promises. +- Never invent or amplify a URL/domain, source, product status, compliance or + security claim, certification, price, trial, waitlist, guarantee, award, + patent, customer result, or evidence strength. +- Emit a widget only when the exact name appears in the supplied catalog and the + plan provides its source/value. Preserve that source and purpose exactly. If a + source is unavailable, use text rather than inventing one. +- Speaker comments may restate grounded slide facts or explain a transition, but + must not introduce new numbers, URLs, capabilities, outcomes, or commitments. + +## Composition quality + +- Keep H1/H2 display headings to at most 8 words. H1 is reserved for `title` and + numeric `metric`; use H2 for other compositions. +- Fulfill the selected composition: real table, two/three dominant columns, + visible metric meaning, blockquote, substantive image companion copy, or clear + element handoff as applicable. +- Do not repeat the previous slide's wording or layout mechanically. Use the + shared plan, previous accepted slide, next plan item, and design ledger for + continuity while composing only the current slide. +- Use the shared palette as context; do not emit style tokens or unsupported + fields. Stay within the supplied density and character budget. diff --git a/packages/playground/lib/features/ai/quick_agent/core/constants/gemini_models.dart b/packages/playground/lib/features/ai/quick_agent/core/constants/gemini_models.dart index c8643995..f7837137 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/constants/gemini_models.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/constants/gemini_models.dart @@ -1,11 +1,14 @@ /// All Gemini model API path strings. /// /// Chat models displayed in selector: [gemini25Pro], [gemini25Flash], -/// [gemini25FlashLite], [gemini3FlashPreview] +/// [gemini25FlashLite], [gemini31FlashLite], [gemini3FlashPreview], +/// [gemini35Flash] abstract final class GeminiModelNames { // -- Chat models (appear in GeminiModels enum / UI selector) -- static const gemini25Pro = 'models/gemini-2.5-pro'; static const gemini25Flash = 'models/gemini-2.5-flash'; static const gemini25FlashLite = 'models/gemini-2.5-flash-lite'; + static const gemini31FlashLite = 'models/gemini-3.1-flash-lite'; static const gemini3FlashPreview = 'models/gemini-3-flash-preview'; + static const gemini35Flash = 'models/gemini-3.5-flash'; } diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart index 1b2eb1f5..71d9a81a 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart @@ -7,6 +7,7 @@ import '../services/theme_json_serializer.dart'; import '../services/design_quality_metrics.dart'; import '../services/generation_element_catalog.dart'; import '../services/generation_validation_issue.dart'; +import '../services/source_grounding.dart'; import 'composition_example_library.dart'; import 'prompt_registry.dart'; @@ -30,6 +31,15 @@ abstract interface class GenerationPromptProvider { Map? invalidSlide, }); + String buildSectionPrompt({ + required DeckPlanType plan, + required DeckPlanSectionType section, + required List slides, + required DeckPlanSlideType? previous, + required DeckPlanSlideType? next, + required GenerationElementCatalog elementCatalog, + }); + String buildOutlineSlideRepairPrompt({ required DeckPlanType plan, required DeckPlanSlideType current, @@ -59,43 +69,70 @@ final class AssetGenerationPromptProvider implements GenerationPromptProvider { List validationIssues = const [], Map? invalidPlan, }) { + if (validationIssues.isNotEmpty) { + return _buildOutlineRepairPrompt( + themeCandidates: themeCandidates, + validationIssues: validationIssues, + invalidPlan: invalidPlan, + ); + } + + return ''' +${_promptRegistry.render('outline_system')} + +## Eligible presentation themes + +Choose exactly one `theme.id` from the compact candidates below. Return only +that ID inside the theme object. Never return a version, palette, font family, +brand override, or runtime styling token; the application owns those values. + +${const JsonEncoder.withIndent(' ').convert(themeCandidates.map((theme) => theme.toModelCandidate()).toList())} +'''; + } + + String _buildOutlineRepairPrompt({ + required List themeCandidates, + required List validationIssues, + required Map? invalidPlan, + }) { + const encoder = JsonEncoder.withIndent(' '); final literalRepairChecklist = _outlineLiteralRepairChecklist( validationIssues, ); - final repairSection = validationIssues.isEmpty - ? '' - : ''' - -## Invalid deck plan to repair + return ''' +You repair one SuperDeck deck plan. Return one complete replacement deck-plan +JSON object matching the response schema—no commentary or Markdown fence. -Use this invalid plan as the repair base and return a complete replacement plan. -Make only the field edits needed to satisfy the constraints below. Preserve all -other valid keys, ordering, content, facts, and design decisions. Do not merely -explain the changes. +The original typed user message is the only authority for facts, numbers, +domains, supplied elements, evidence status, availability, security/compliance, +and commercial commitments. Use the invalid plan as the repair base. Fix every +blocking issue below while preserving all valid keys, ordering, narrative, +content, facts, theme choice, and design decisions. Do not redesign the deck. -${const JsonEncoder.withIndent(' ').convert(invalidPlan)} +Hard contract: +- preserve the requested slide count, slide order, unique keys, section + membership, and exact supported composition/treatment values +- keep audience-facing claims grounded in the original request +- keep each planned element's exact type, source, purpose, and cardinality +- choose one eligible theme ID; never author palette, font, or runtime tokens +- return the complete plan, including every valid unchanged section and slide -## Current and prior deck-plan validation constraints +## Eligible theme IDs -${validationIssues.map((issue) => '- ${issue.message}').join('\n')} -$literalRepairChecklist +${encoder.convert(themeCandidates.map((theme) => theme.id).toList())} -This list is cumulative. A constraint may already be fixed in the current base; -if so, preserve that fix. Mechanically re-check every constraint before returning -JSON so a repair never reintroduces an earlier factual or structural error. -'''; +## Invalid plan - return ''' -${_promptRegistry.render('outline_system')} +${invalidPlan == null ? 'No parseable plan was returned.' : encoder.convert(invalidPlan)} -## Eligible presentation themes +## Blocking issues -Choose exactly one `theme.id` from the compact candidates below. Return only -that ID inside the theme object. Never return a version, palette, font family, -brand override, or runtime styling token; the application owns those values. +${validationIssues.map((issue) => '- ${issue.message}').join('\n')} +$literalRepairChecklist -${const JsonEncoder.withIndent(' ').convert(themeCandidates.map((theme) => theme.toModelCandidate()).toList())} -$repairSection +The blocking list is cumulative. Before returning JSON, re-check every item and +preserve any correction already present in the repair base. Return only the +complete corrected deck-plan object. '''; } @@ -109,6 +146,17 @@ $repairSection List validationIssues = const [], Map? invalidSlide, }) { + if (validationIssues.isNotEmpty) { + return buildSingleSlideRepairPrompt( + plan: plan, + current: current, + previousSlide: previousSlide, + next: next, + validationIssues: validationIssues, + invalidSlide: invalidSlide, + elementCatalog: elementCatalog, + ); + } final basePrompt = _promptRegistry.render('slide_system'); final compositionExample = _exampleLibrary.buildFor( current: current, @@ -128,6 +176,121 @@ $repairSection ); } + @override + String buildSectionPrompt({ + required DeckPlanType plan, + required DeckPlanSectionType section, + required List slides, + required DeckPlanSlideType? previous, + required DeckPlanSlideType? next, + required GenerationElementCatalog elementCatalog, + }) { + if (slides.isEmpty) { + throw ArgumentError.value(slides, 'slides', 'Section cannot be empty.'); + } + if (slides.any((slide) => slide.sectionKey != section.key)) { + throw ArgumentError('Every slide must belong to section ${section.key}.'); + } + + const encoder = JsonEncoder.withIndent(' '); + final plannedElements = [for (final slide in slides) ...?slide.elements]; + final numericUnits = >{ + for (final slide in slides) + slide.key: [ + for (final unit in [slide.assertion, ...slide.contentUnits]) + if (extractNumericClaims([unit]).isNotEmpty) unit, + ], + }..removeWhere((_, units) => units.isEmpty); + final budgets = [ + for (final slide in slides) + { + 'key': slide.key, + 'maximumVisibleCharacters': + (visibleCharacterLimit( + slide.density, + composition: slide.composition, + ) * + 3) ~/ + 4, + 'maximumVisibleWords': + ((visibleCharacterLimit( + slide.density, + composition: slide.composition, + ) * + 3) ~/ + 4) ~/ + 8, + }, + ]; + final shapeExamples = >[]; + final exampleCompositions = {}; + for (final slide in slides) { + if (!exampleCompositions.add(slide.composition)) continue; + final example = _exampleLibrary.buildFor( + current: slide, + elementCatalog: elementCatalog, + ); + example['key'] = 'example-${slide.composition}'; + shapeExamples.add(example); + } + + return ''' +${_promptRegistry.render('section_system')} + +## Deck context + +Topic: ${plan.topic} +Story: ${plan.story} +Theme: ${encoder.convert(serializeDeckThemeForSlidePrompt(plan.theme))} + +## Current narrative section + +${encoder.convert(Map.from(section))} + +## Ordered slide plans + +${encoder.convert(slides.map(Map.from).toList())} + +## Boundary context + +Previous plan item: ${previous == null ? 'None.' : encoder.convert(Map.from(previous))} +Next plan item: ${next == null ? 'None.' : encoder.convert(Map.from(next))} + +## Per-slide visible-content budgets + +${encoder.convert(budgets)} + +These are hard output maxima, not suggestions. Count every heading, label, +bullet, table cell, and paragraph. Remove secondary explanation until each slide +fits its own maximum; never borrow another slide's budget. + +## Canonical shape examples + +The objects below are valid individual slide shapes, one per composition used +in this section. Follow their section/block structure, but never return an +`example-*` key or copy their generic wording. The ordered plans remain the +authority for content, treatment, density, elements, and slide keys. + +${encoder.convert(shapeExamples)} + +## Numeric copy contract + +${numericUnits.isEmpty ? 'No numeric unit is planned in this section.' : '''For the units grouped by slide key below, copy each complete unit verbatim into that slide or omit its number. Never shorten, relabel, calculate from, move to another slide, or paraphrase a number. Preserve every explicit planned, proposed, projected, target, or illustrative qualifier in the same visible line. + +${encoder.convert(numericUnits)}'''} + +## Available elements + +${plannedElements.isEmpty ? 'No elements are planned in this section.' : elementCatalog.formatForPrompt()} + +## Final task + +Return one `slides` array containing exactly ${slides.length} slides with keys, +in order: ${slides.map((slide) => '`${slide.key}`').join(', ')}. Fulfill each +plan item without borrowing facts or elements from another slide. +'''; + } + @override String buildOutlineSlideRepairPrompt({ required DeckPlanType plan, @@ -200,6 +363,89 @@ Return only the corrected single-slide plan object. } } +String buildSingleSlideRepairPrompt({ + required DeckPlanType plan, + required DeckPlanSlideType current, + required Map? previousSlide, + required DeckPlanSlideType? next, + required List validationIssues, + required Map? invalidSlide, + required GenerationElementCatalog elementCatalog, +}) { + const encoder = JsonEncoder.withIndent(' '); + final contentBudget = visibleCharacterLimit( + current.density, + composition: current.composition, + ); + final requiresElementContext = validationIssues.any( + (issue) => + issue.code == GenerationValidationCode.elementGrounding || + issue.code == GenerationValidationCode.widgetArguments || + issue.code == GenerationValidationCode.handoffPurpose, + ); + final requiresNumericContext = validationIssues.any( + (issue) => + issue.code == GenerationValidationCode.numericGrounding || + issue.code == GenerationValidationCode.numericMeaning || + issue.code == GenerationValidationCode.metricIntent, + ); + final hasCommentIssue = validationIssues.any( + (issue) => issue.location == GenerationValidationLocation.speakerComments, + ); + final targetedGuidance = [ + if (requiresNumericContext) + 'For numeric errors, use the original user request and its ' + '`groundedNumericFacts` as authority. Preserve the complete subject, ' + 'unit, comparison, and time period somewhere on this slide; delete or ' + 'visibly qualify unsupported values.', + if (hasCommentIssue) + 'Speaker comments are optional. Delete an offending comment instead of ' + 'rewriting valid visible content.', + if (requiresElementContext) + 'Preserve planned widget names, exact sources, purposes, and cardinality. ' + 'Do not invent or substitute an element.', + ]; + + return ''' +You repair one SuperDeck slide. Return one complete canonical slide JSON object +matching the response schema—no deck wrapper, commentary, or Markdown fence. + +The original typed user message is the only factual authority. Use the invalid +draft as the repair base. Fix every blocking issue below while preserving valid +copy, layout, sources, and comments. Do not redesign the slide. + +Hard invariants: +- key: `${current.key}` +- options.style: `${current.treatment}` +- planned composition: `${current.composition}` +- visible content budget: at most $contentBudget characters +- one or two sections, 1–3 blocks per section, supported fields only +- no invented facts, numbers, URLs, domains, elements, capabilities, evidence + status, security/compliance claims, availability, or commercial commitments + +## Current slide plan +${encoder.convert(Map.from(current))} + +## Neighbor context +Previous accepted slide: +${previousSlide == null ? 'None.' : encoder.convert(previousSlide)} + +Next plan item: +${next == null ? 'None.' : encoder.convert(Map.from(next))} + +## Invalid draft +${invalidSlide == null ? 'No parseable draft was returned.' : encoder.convert(invalidSlide)} + +## Blocking issues +${validationIssues.map((issue) => '- ${issue.message}').join('\n')} + +${targetedGuidance.join('\n')} +${requiresElementContext ? '\n## Available elements\n${elementCatalog.formatForPrompt()}' : ''} + +Return only the corrected slide object. +'''; +} + String _outlineLiteralRepairChecklist( List validationIssues, ) { @@ -321,6 +567,42 @@ String buildSingleSlidePrompt({ 'density': slide.density, }, ]; + final previousContext = previousSlide == null + ? null + : _compactPreviousSlideContext(previousSlide); + final elementContext = current.elements == null || current.elements!.isEmpty + ? 'None planned for this slide.' + : elementCatalog.formatForPrompt(); + final numericPlanUnits = { + for (final unit in [current.assertion, ...current.contentUnits]) + if (extractNumericClaims([unit]).isNotEmpty) unit, + }; + final hypotheticalNumericUnits = [ + for (final unit in numericPlanUnits) + if (hasProjectionQualifier(unit)) unit, + ]; + final groundedNumericUnits = [ + for (final unit in numericPlanUnits) + if (!hasProjectionQualifier(unit)) unit, + ]; + final numericCopyContract = numericPlanUnits.isEmpty + ? '' + : ''' + +## Numeric copy contract + +For each grounded numeric unit below, copy the complete unit verbatim into one +visible line or omit its number. Do not shorten, split, relabel, or paraphrase +its subject, unit, comparison, or time period. + +${groundedNumericUnits.isEmpty ? 'No grounded numeric unit is required.' : encoder.convert(groundedNumericUnits)} + +For any hypothetical numeric unit below, preserve its explicit hypothetical label +(`planned`, `proposed`, `projected`, `target`, or `illustrative`) in the same +visible line and in any speaker comment that repeats the value. + +${hypotheticalNumericUnits.isEmpty ? 'No hypothetical numeric unit is required.' : encoder.convert(hypotheticalNumericUnits)} +'''; final speakerCommentRepairChecklist = validationIssues.any( (issue) => @@ -395,16 +677,17 @@ ${recentLedger.isEmpty ? 'None (this is the first planned slide).' : encoder.con ## Current slide plan ${encoder.convert(Map.from(current))} +$numericCopyContract ## Neighbor context Previous canonical slide: -${previousSlide == null ? 'None (this is the first slide).' : encoder.convert(previousSlide)} +${previousContext == null ? 'None (this is the first slide).' : encoder.convert(previousContext)} Next plan item: ${next == null ? 'None (this is the final slide).' : encoder.convert(Map.from(next))} ## Available elements -${elementCatalog.formatForPrompt()} +$elementContext ## Relevant composition example Use this one example for structural guidance only. Replace its generic words @@ -426,3 +709,47 @@ separate paragraph. $compositionBudgetGuidance Do not repeat the previous slide or generate future slides. '''; } + +Map _compactPreviousSlideContext( + Map previousSlide, +) { + const visibleLimit = 700; + final visibleParts = []; + final rawSections = previousSlide['sections']; + if (rawSections is List) { + for (final rawSection in rawSections) { + if (rawSection is! Map) continue; + final rawBlocks = rawSection['blocks']; + if (rawBlocks is! List) continue; + for (final rawBlock in rawBlocks) { + if (rawBlock is! Map) continue; + final content = rawBlock['content']; + if (content is String && content.trim().isNotEmpty) { + visibleParts.add(content.trim()); + continue; + } + final name = rawBlock['name']; + if (name is String && name.trim().isNotEmpty) { + visibleParts.add('[${name.trim()} widget]'); + } + } + } + } + final visibleText = visibleParts.join('\n\n'); + final options = previousSlide['options']; + final compactOptions = options is Map + ? { + if (options['title'] case final String title) 'title': title, + if (options['style'] case final String style) 'style': style, + } + : const {}; + + return { + if (previousSlide['key'] case final String key) 'key': key, + if (compactOptions.isNotEmpty) 'options': compactOptions, + if (visibleText.isNotEmpty) + 'visibleContent': visibleText.length <= visibleLimit + ? visibleText + : '${visibleText.substring(0, visibleLimit).trimRight()}…', + }; +} diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart index f76e01c5..3e0a5e4d 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart @@ -46,8 +46,11 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { 'DECK_GEN', 'Outline system prompt (${systemPrompt.length} chars)', ); + final selectedModel = repairAttempt == 1 + ? outlineModelName + : outlineRepairModelName; final modelRequest = google_ai.GenerateContentRequest( - model: outlineModelName, + model: selectedModel, contents: [ google_ai.Content( role: 'user', @@ -57,6 +60,7 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { generationConfig: google_ai.GenerationConfig( responseMimeType: 'application/json', responseSchema: adaptResult.schema, + thinkingConfig: google_ai.ThinkingConfig(thinkingBudget: 0), ), systemInstruction: google_ai.Content( parts: [google_ai.Part(text: systemPrompt)], @@ -66,13 +70,13 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { debugLog.log( 'DECK_GEN', repairAttempt == 1 - ? 'Sending outline request to $outlineModelName...' - : 'Repairing outline with $outlineModelName...', + ? 'Sending outline request to $selectedModel...' + : 'Repairing outline with $selectedModel...', ); final response = await executor.execute( request: modelRequest, phase: GenerationTracePhase.outline, - model: outlineModelName, + model: selectedModel, prompt: systemPrompt, semanticAttempt: repairAttempt, isRepair: repairAttempt > 1, @@ -146,6 +150,8 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { ); return plan; } + } on GenerationBudgetExceededException { + rethrow; } catch (error) { validationIssues = [ GenerationValidationIssue( @@ -175,10 +181,407 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { } // =========================================================================== - // PHASE 2: Compose Slides Sequentially + // PHASE 2: Compose Slides // =========================================================================== - Future?> _composeSlides( + Future<_SlideCompositionResult?> _composeSlides( + GenerationModelCallExecutor executor, + String prompt, + DeckPlanType plan, + DeckGenerationRequest request, + GenerationTraceEmitter trace, + GenerationProgressCallback? onProgress, + bool Function()? isCancelled, + ) { + if (plan.slides.length >= sectionBatchThreshold) { + return _composeSlidesBySection( + executor, + prompt, + plan, + request, + trace, + onProgress, + isCancelled, + ); + } + return _composeSlidesSequentially( + executor, + prompt, + plan, + request, + trace, + onProgress, + isCancelled, + ); + } + + /// Composes all narrative sections concurrently and accepts valid slides + /// independently. This is the latency-first production path for normal decks. + Future<_SlideCompositionResult?> _composeSlidesBySection( + GenerationModelCallExecutor executor, + String prompt, + DeckPlanType plan, + DeckGenerationRequest request, + GenerationTraceEmitter trace, + GenerationProgressCallback? onProgress, + bool Function()? isCancelled, + ) async { + if (isCancelled?.call() ?? false) return null; + trace.emit( + kind: GenerationTraceKind.phaseStarted, + phase: GenerationTracePhase.slide, + slideCount: plan.slides.length, + ); + + final sectionResults = await Future.wait([ + for (final (sectionIndex, section) in plan.sections.indexed) + _composeSection( + executor: executor, + originalPrompt: prompt, + plan: plan, + section: section, + sectionIndex: sectionIndex, + request: request, + trace: trace, + onProgress: onProgress, + ), + ]); + if (isCancelled?.call() ?? false) return null; + + trace.emit( + kind: GenerationTraceKind.phaseDone, + phase: GenerationTracePhase.slide, + slideCount: plan.slides.length, + ); + return _SlideCompositionResult( + slides: List.unmodifiable([ + for (final result in sectionResults) ...result.slides, + ]), + failures: List.unmodifiable([ + for (final result in sectionResults) ...result.failures, + ]), + ); + } + + Future<_SlideCompositionResult> _composeSection({ + required GenerationModelCallExecutor executor, + required String originalPrompt, + required DeckPlanType plan, + required DeckPlanSectionType section, + required int sectionIndex, + required DeckGenerationRequest request, + required GenerationTraceEmitter trace, + required GenerationProgressCallback? onProgress, + }) async { + final plannedSlides = [ + for (final slide in plan.slides) + if (slide.sectionKey == section.key) slide, + ]; + if (plannedSlides.isEmpty) { + return const _SlideCompositionResult(slides: [], failures: []); + } + final firstIndex = plan.slides.indexWhere( + (slide) => slide.key == plannedSlides.first.key, + ); + final lastIndex = plan.slides.indexWhere( + (slide) => slide.key == plannedSlides.last.key, + ); + final previous = firstIndex > 0 ? plan.slides[firstIndex - 1] : null; + final next = lastIndex + 1 < plan.slides.length + ? plan.slides[lastIndex + 1] + : null; + + onProgress?.call( + GenerationProgress( + GenerationPhase.composingSlides, + sectionIndex: sectionIndex + 1, + sectionCount: plan.sections.length, + ), + ); + final systemPrompt = _promptProvider.buildSectionPrompt( + plan: plan, + section: section, + slides: plannedSlides, + previous: previous, + next: next, + elementCatalog: elementCatalog, + ); + debugLog.log( + 'DECK_GEN', + 'Section ${sectionIndex + 1}/${plan.sections.length} prompt ' + '(${systemPrompt.length} chars, ${plannedSlides.length} slides)', + ); + + final adapter = GoogleSchemaAdapter(forwardArrayBounds: false); + final adaptResult = adapter.adapt( + Ack.object({ + 'slides': Ack.list( + buildAiSlideSchema( + widgetArgumentProperties: elementCatalog.argumentProperties, + nestWidgetArguments: true, + requirePresentationOptions: true, + ), + ), + }).toJsonSchemaBuilder(), + ); + if (adaptResult.schema == null) { + return _failedSection( + plan: plan, + slides: plannedSlides, + trace: trace, + message: 'Section response schema could not be prepared.', + ); + } + + final modelRequest = google_ai.GenerateContentRequest( + model: modelName, + contents: [ + google_ai.Content( + role: 'user', + parts: [google_ai.Part(text: originalPrompt)], + ), + ], + generationConfig: google_ai.GenerationConfig( + responseMimeType: 'application/json', + responseSchema: adaptResult.schema, + thinkingConfig: google_ai.ThinkingConfig(thinkingBudget: 0), + ), + systemInstruction: google_ai.Content( + parts: [google_ai.Part(text: systemPrompt)], + ), + ); + + final google_ai.GenerateContentResponse response; + try { + response = await executor.execute( + request: modelRequest, + phase: GenerationTracePhase.slide, + model: modelName, + prompt: systemPrompt, + semanticAttempt: 1, + isRepair: false, + timeoutMessage: 'Section generation timed out', + slideIndex: firstIndex + 1, + slideCount: plan.slides.length, + ); + } on GenerationCancelledException { + rethrow; + } on GenerationBudgetExceededException { + rethrow; + } catch (error, stack) { + final category = const ErrorClassifier().classify(error); + if (category != ErrorCategory.network) rethrow; + debugLog.error( + 'DECK_GEN', + 'Section ${section.key} transport failed; preserving other sections.', + stack, + ); + return _failedSection( + plan: plan, + slides: plannedSlides, + trace: trace, + message: '${category.userMessage} Retry this section.', + ); + } + + final json = _parseJsonResponse(response, 'section ${section.key}'); + final rawSlides = json?['slides']; + if (rawSlides is! List) { + return _failedSection( + plan: plan, + slides: plannedSlides, + trace: trace, + message: 'Model response was not a JSON section with a slides array.', + ); + } + final draftsByKey = >{}; + for (final rawSlide in rawSlides) { + if (rawSlide is! Map) continue; + final draft = Map.from(rawSlide); + final key = draft['key']; + if (key is String && !draftsByKey.containsKey(key)) { + draftsByKey[key] = draft; + } + } + + final accepted = >[]; + final failures = []; + for (final plannedSlide in plannedSlides) { + final slideIndex = plan.slides.indexWhere( + (slide) => slide.key == plannedSlide.key, + ); + final draft = draftsByKey[plannedSlide.key]; + if (draft == null) { + final issues = _invalidSectionSlideIssues( + 'Section response omitted slide "${plannedSlide.key}".', + ); + failures.add( + SlideGenerationFailure( + slideIndex: slideIndex + 1, + slideKey: plannedSlide.key, + issues: issues, + ), + ); + _traceSectionSlideValidation( + trace: trace, + slideIndex: slideIndex, + slideCount: plan.slides.length, + issues: issues, + ); + continue; + } + + final validation = _validateSectionSlide( + draft: draft, + planSlide: plannedSlide, + request: request, + ); + _traceSectionSlideValidation( + trace: trace, + slideIndex: slideIndex, + slideCount: plan.slides.length, + issues: validation.issues, + ); + if (validation.canonical == null) { + failures.add( + SlideGenerationFailure( + slideIndex: slideIndex + 1, + slideKey: plannedSlide.key, + issues: validation.issues.blockingIssues, + ), + ); + continue; + } + accepted.add(validation.canonical!); + } + + return _SlideCompositionResult( + slides: List.unmodifiable(accepted), + failures: List.unmodifiable(failures), + ); + } + + _SlideCompositionResult _failedSection({ + required DeckPlanType plan, + required List slides, + required GenerationTraceEmitter trace, + required String message, + }) { + final issues = _invalidSectionSlideIssues(message); + for (final slide in slides) { + _traceSectionSlideValidation( + trace: trace, + slideIndex: plan.slides.indexWhere( + (candidate) => candidate.key == slide.key, + ), + slideCount: plan.slides.length, + issues: issues, + ); + } + return _SlideCompositionResult( + slides: const [], + failures: List.unmodifiable([ + for (final slide in slides) + SlideGenerationFailure( + slideIndex: + plan.slides.indexWhere( + (candidate) => candidate.key == slide.key, + ) + + 1, + slideKey: slide.key, + issues: issues, + ), + ]), + ); + } + + List _invalidSectionSlideIssues(String message) => + [ + GenerationValidationIssue( + code: GenerationValidationCode.invalidResponse, + category: GenerationValidationCategory.schema, + severity: GenerationValidationSeverity.blocking, + location: GenerationValidationLocation.visibleContent, + message: message, + ), + ]; + + ({Map? canonical, List issues}) + _validateSectionSlide({ + required Map draft, + required DeckPlanSlideType planSlide, + required DeckGenerationRequest request, + }) { + var normalized = hydrateGeneratedElementSources( + slide: draft, + planSlide: planSlide, + elementCatalog: elementCatalog, + ); + normalized = normalizeGeneratedSlideForPlan( + rawSlide: normalized, + planSlide: planSlide, + ); + var issues = validateGeneratedSlideIssues( + expectedKey: planSlide.key, + rawSlide: normalized, + planSlide: planSlide, + elementCatalog: elementCatalog, + request: request, + ); + final commentSafeSlide = removeInvalidOptionalSpeakerComments( + rawSlide: normalized, + validationIssues: issues, + ); + if (!identical(commentSafeSlide, normalized)) { + normalized = commentSafeSlide; + issues = validateGeneratedSlideIssues( + expectedKey: planSlide.key, + rawSlide: normalized, + planSlide: planSlide, + elementCatalog: elementCatalog, + request: request, + ); + } + if (issues.blockingIssues.isNotEmpty) { + return (canonical: null, issues: issues); + } + final canonical = sanitizeGeneratedSlides([normalized]).singleOrNull; + if (canonical == null) { + return ( + canonical: null, + issues: const [ + GenerationValidationIssue( + code: GenerationValidationCode.invalidSchema, + category: GenerationValidationCategory.schema, + severity: GenerationValidationSeverity.blocking, + location: GenerationValidationLocation.visibleContent, + message: 'Generated slide could not be normalized.', + ), + ], + ); + } + return (canonical: canonical, issues: issues); + } + + void _traceSectionSlideValidation({ + required GenerationTraceEmitter trace, + required int slideIndex, + required int slideCount, + required List issues, + }) { + trace.emit( + kind: GenerationTraceKind.validation, + phase: GenerationTracePhase.slide, + attempt: 1, + slideIndex: slideIndex + 1, + slideCount: slideCount, + validationErrors: issues.messages, + validationIssues: issues, + ); + } + + /// Retained for small, focused generation and repair diagnostics. + Future<_SlideCompositionResult?> _composeSlidesSequentially( GenerationModelCallExecutor executor, String prompt, DeckPlanType plan, @@ -188,6 +591,7 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { bool Function()? isCancelled, ) async { final slides = >[]; + final failures = []; trace.emit( kind: GenerationTraceKind.phaseStarted, phase: GenerationTracePhase.slide, @@ -209,6 +613,13 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { repairAttempt <= maxSlideValidationAttempts; repairAttempt++ ) { + if (repairAttempt > 1 && !executor.hasRepairCapacity) { + debugLog.log( + 'DECK_GEN', + 'Slide ${index + 1} repair skipped: run repair budget exhausted.', + ); + break; + } onProgress?.call( GenerationProgress( GenerationPhase.composingSlides, @@ -217,19 +628,52 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { isRepairing: repairAttempt > 1, ), ); - composed = await _generateSingleSlide( - executor: executor, - originalPrompt: prompt, - plan: plan, - current: current, - previousSlide: slides.lastOrNull, - next: next, - validationIssues: repairConstraints, - invalidSlide: composed, - repairAttempt: repairAttempt, - slideIndex: index + 1, - slideCount: plan.slides.length, - ); + try { + composed = await _generateSingleSlide( + executor: executor, + originalPrompt: prompt, + plan: plan, + current: current, + previousSlide: slides.lastOrNull, + next: next, + validationIssues: repairConstraints, + invalidSlide: composed, + repairAttempt: repairAttempt, + slideIndex: index + 1, + slideCount: plan.slides.length, + ); + } on GenerationCancelledException { + rethrow; + } on GenerationBudgetExceededException { + rethrow; + } catch (error, stack) { + final category = const ErrorClassifier().classify(error); + if (category != ErrorCategory.network) rethrow; + validationIssues = [ + GenerationValidationIssue( + code: GenerationValidationCode.invalidResponse, + category: GenerationValidationCategory.schema, + severity: GenerationValidationSeverity.blocking, + location: GenerationValidationLocation.visibleContent, + message: '${category.userMessage} Retry this slide.', + ), + ]; + debugLog.error( + 'DECK_GEN', + 'Slide ${index + 1} transport failed; continuing with the deck.', + stack, + ); + trace.emit( + kind: GenerationTraceKind.validation, + phase: GenerationTracePhase.slide, + attempt: repairAttempt, + slideIndex: index + 1, + slideCount: plan.slides.length, + validationErrors: validationIssues.messages, + validationIssues: validationIssues, + ); + break; + } if (composed == null) { validationIssues = const [ GenerationValidationIssue( @@ -291,7 +735,14 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { 'Slide ${index + 1} failed validation: ' '${validationIssues.messages.join(' ')}', ); - return null; + failures.add( + SlideGenerationFailure( + slideIndex: index + 1, + slideKey: current.key, + issues: List.unmodifiable(validationIssues.blockingIssues), + ), + ); + continue; } final canonicalSlide = sanitizeGeneratedSlides([composed]).singleOrNull; if (canonicalSlide == null) { @@ -299,7 +750,22 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { 'DECK_GEN', 'Slide ${index + 1} could not be normalized after validation.', ); - return null; + failures.add( + SlideGenerationFailure( + slideIndex: index + 1, + slideKey: current.key, + issues: const [ + GenerationValidationIssue( + code: GenerationValidationCode.invalidSchema, + category: GenerationValidationCategory.schema, + severity: GenerationValidationSeverity.blocking, + location: GenerationValidationLocation.visibleContent, + message: 'Generated slide could not be normalized.', + ), + ], + ), + ); + continue; } slides.add(canonicalSlide); } @@ -309,7 +775,10 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { phase: GenerationTracePhase.slide, slideCount: plan.slides.length, ); - return {'slides': slides}; + return _SlideCompositionResult( + slides: List.unmodifiable(slides), + failures: List.unmodifiable(failures), + ); } Future?> _generateSingleSlide({ @@ -374,6 +843,7 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { generationConfig: google_ai.GenerationConfig( responseMimeType: 'application/json', responseSchema: adaptResult.schema, + thinkingConfig: google_ai.ThinkingConfig(thinkingBudget: 0), ), systemInstruction: google_ai.Content( parts: [google_ai.Part(text: systemPrompt)], diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart index d57c71a2..69796c88 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart @@ -31,6 +31,30 @@ part 'deck_generator_pipeline.dart'; part 'deck_plan_repair.dart'; part 'deck_generator_workflow.dart'; +/// One slide slot that could not be composed into a valid canonical slide. +final class SlideGenerationFailure { + const SlideGenerationFailure({ + required this.slideIndex, + required this.slideKey, + required this.issues, + this.retryable = true, + }); + + final int slideIndex; + final String slideKey; + final List issues; + final bool retryable; + + String get message => issues.messages.join(' '); + + Map toJson() => { + 'slideIndex': slideIndex, + 'slideKey': slideKey, + 'retryable': retryable, + 'issues': [for (final issue in issues) issue.toJson()], + }; +} + /// Result of deck generation. class DeckGenerationResult { final bool success; @@ -46,14 +70,19 @@ class DeckGenerationResult { /// The validated, mechanically normalized plan used to compose the deck. final DeckPlanType? plan; + /// Ordered slide slots that remain unresolved and can be retried. + final List slideFailures; + const DeckGenerationResult._({ required this.success, this.message, this.error, List? slides, + List? slideFailures, this.theme, this.plan, - }) : slides = slides ?? const []; + }) : slides = slides ?? const [], + slideFailures = slideFailures ?? const []; DeckGenerationResult.success({ required List slides, @@ -68,16 +97,42 @@ class DeckGenerationResult { plan: plan, ); + DeckGenerationResult.partial({ + required List slides, + required List slideFailures, + required DeckPlanType plan, + required ResolvedPresentationTheme theme, + }) : this._( + success: false, + error: + 'Generated ${slides.length} of ${plan.slides.length} slides; ' + 'failed: ${slideFailures.map((failure) => '${failure.slideKey} ' + '(${failure.message})').join(', ')}.', + slides: slides, + slideFailures: slideFailures, + theme: theme, + plan: plan, + ); + DeckGenerationResult.failure(String error) : this._(success: false, error: error); /// Number of slides in the result. int get slideCount => slides.length; + + bool get isPartial => plan != null && slideFailures.isNotEmpty; +} + +final class _SlideCompositionResult { + const _SlideCompositionResult({required this.slides, required this.failures}); + + final List> slides; + final List failures; } /// Service that generates SuperDeck presentations using Google Generative AI. /// -/// Uses a plan-first pipeline, then composes and validates one slide at a time. +/// Uses a plan-first pipeline, then composes and validates narrative sections. class DeckGeneratorService { final GenerationElementCatalog elementCatalog; @@ -89,23 +144,27 @@ class DeckGeneratorService { /// Model used to compose and repair each slide. /// - /// Defaults to the existing `gemini-3-flash-preview` configuration for fast - /// sequential composition. Callers can inject another configured model for - /// quality and latency comparisons. + /// Defaults to stable Flash-Lite for latency-sensitive composition. final String modelName; /// Model used for the outline generation (Phase 1). + /// + /// Planning uses the current stable Flash model; the validated sections then + /// compose concurrently on the current stable Flash-Lite model. final String outlineModelName; + /// Fast model used only when the validated global plan needs correction. + final String outlineRepairModelName; + + /// Decks at or above this size compose one request per narrative section. + /// + /// Smaller decks retain the sequential path for focused repair diagnostics. + final int sectionBatchThreshold; + /// Deadline for each outline or slide model call. final Duration requestTimeout; - /// Bounded initial planning request plus targeted semantic repairs. - /// - /// Large plans have more cross-field constraints, so bounded follow-up repairs - /// can recover when one repair fixes an invariant but disturbs another. The - /// sixth and final attempt is only reached after five invalid responses; a - /// valid first draft still completes in one request. + /// Bounded initial planning request plus one targeted semantic repair. final int maxOutlineValidationAttempts; /// Bounded local repairs for one slide inside an otherwise valid deck plan. @@ -114,18 +173,18 @@ class DeckGeneratorService { /// rewriting or regressing the rest of a 10–20-slide blueprint. final int maxOutlineSlideValidationAttempts; - /// Bounded initial composition plus targeted semantic repairs per slide. - /// - /// Valid slides still use one request. A fourth and final targeted attempt - /// prevents one stubborn semantic miss from forcing a full 10–20-slide deck - /// restart after the outline and preceding slides already passed. + /// Bounded initial composition plus one targeted semantic repair per slide. final int maxSlideValidationAttempts; /// Maximum provider calls across the whole run, including transport retries. final int maxModelRequests; - /// Maximum semantic repair calls across outline and slide generation. - final int maxRepairRequests; + /// Optional semantic repair ceiling across outline and slide generation. + /// + /// When omitted, the run uses a small slide-count-aware budget: two repairs + /// for decks below 20 slides and roughly 15% of the requested slides after + /// that. This prevents independent per-slide retries from multiplying latency. + final int? maxRepairRequests; /// Wall-clock limit shared by every phase and model request in one run. final Duration runTimeout; @@ -139,14 +198,16 @@ class DeckGeneratorService { DeckGeneratorService({ required this.apiKey, - this.modelName = GeminiModelNames.gemini3FlashPreview, - this.outlineModelName = GeminiModelNames.gemini3FlashPreview, + this.modelName = GeminiModelNames.gemini31FlashLite, + this.outlineModelName = GeminiModelNames.gemini35Flash, + this.outlineRepairModelName = GeminiModelNames.gemini31FlashLite, + this.sectionBatchThreshold = 5, this.requestTimeout = const Duration(seconds: 45), - this.maxOutlineValidationAttempts = 6, - this.maxOutlineSlideValidationAttempts = 3, - this.maxSlideValidationAttempts = 4, + this.maxOutlineValidationAttempts = 2, + this.maxOutlineSlideValidationAttempts = 2, + this.maxSlideValidationAttempts = 2, this.maxModelRequests = 96, - this.maxRepairRequests = 72, + this.maxRepairRequests, this.runTimeout = const Duration(minutes: 15), RetryPolicy? retryPolicy, GenerationModelClientFactory? modelClientFactory, @@ -154,11 +215,12 @@ class DeckGeneratorService { GenerationElementCatalog? elementCatalog, PresentationTypographyCatalog? typographyCatalog, PresentationThemeCatalog? themeCatalog, - }) : assert(maxOutlineValidationAttempts > 0), + }) : assert(sectionBatchThreshold > 0), + assert(maxOutlineValidationAttempts > 0), assert(maxOutlineSlideValidationAttempts > 0), assert(maxSlideValidationAttempts > 0), assert(maxModelRequests > 0), - assert(maxRepairRequests > 0), + assert(maxRepairRequests == null || maxRepairRequests > 0), assert(runTimeout > Duration.zero), retryPolicy = retryPolicy ?? RetryPolicy(maxAttempts: 2), elementCatalog = elementCatalog ?? GenerationElementCatalog.builtIn(), @@ -217,6 +279,13 @@ class DeckGeneratorService { bool generationCancelled() => isCancelled?.call() ?? false; DeckGenerationResult cancelledResult() => DeckGenerationResult.failure('Generation cancelled.'); + final repairBudget = + maxRepairRequests ?? _defaultRepairBudget(request.slideCount); + debugLog.log( + 'DECK_GEN', + 'Run budgets: repairs=$repairBudget, requests=$maxModelRequests, ' + 'timeout=${runTimeout.inSeconds}s', + ); final executor = GenerationModelCallExecutor( client: service, retryPolicy: retryPolicy, @@ -224,7 +293,7 @@ class DeckGeneratorService { requestTimeout: requestTimeout, isCancelled: generationCancelled, maxModelRequests: maxModelRequests, - maxRepairRequests: maxRepairRequests, + maxRepairRequests: repairBudget, runTimeout: runTimeout, ); @@ -249,7 +318,7 @@ class DeckGeneratorService { ); } - final deckJson = await _runSlideCompositionPhase( + final composition = await _runSlideCompositionPhase( this, executor: executor, prompt: modelInput, @@ -263,7 +332,7 @@ class DeckGeneratorService { return cancelledResult(); } - if (deckJson == null) { + if (composition == null) { return DeckGenerationResult.failure( 'Failed while composing presentation slides. Please try again.', ); @@ -271,7 +340,7 @@ class DeckGeneratorService { return _finalizeDeck( this, - deckJson: deckJson, + composition: composition, plan: outline, pipelineStart: pipelineStart, onProgress: onProgress, diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_workflow.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_workflow.dart index e1e7de72..f446a24e 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_workflow.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_workflow.dart @@ -5,11 +5,17 @@ void _logPipelineConfig(DeckGeneratorService owner, {required String prompt}) { debugLog.log( 'DECK_GEN', 'Config: outlineModel=${owner.outlineModelName}, ' - 'slideModel=${owner.modelName}, thinking=disabled', + 'outlineRepairModel=${owner.outlineRepairModelName}, ' + 'slideModel=${owner.modelName}, thinkingBudget=0', ); debugLog.log('DECK_GEN', 'Prompt (${prompt.length} chars):\n$prompt'); } +int _defaultRepairBudget(int slideCount) { + final proportionalBudget = (slideCount * 15) ~/ 100; + return proportionalBudget < 3 ? 3 : proportionalBudget; +} + Future _runOutlinePhase( DeckGeneratorService owner, { required GenerationModelCallExecutor executor, @@ -56,7 +62,7 @@ Future _runOutlinePhase( return outline; } -Future?> _runSlideCompositionPhase( +Future<_SlideCompositionResult?> _runSlideCompositionPhase( DeckGeneratorService owner, { required GenerationModelCallExecutor executor, required String prompt, @@ -73,7 +79,7 @@ Future?> _runSlideCompositionPhase( ); onProgress?.call(const GenerationProgress(GenerationPhase.composingSlides)); final deckStart = DateTime.now(); - final deckJson = await owner._composeSlides( + final composition = await owner._composeSlides( executor, prompt, outline, @@ -84,7 +90,7 @@ Future?> _runSlideCompositionPhase( ); final deckMs = DateTime.now().difference(deckStart).inMilliseconds; - if (deckJson == null) { + if (composition == null) { debugLog.error( 'DECK_GEN', 'Phase 2 FAILED after ${deckMs}ms - no deck JSON returned', @@ -92,21 +98,22 @@ Future?> _runSlideCompositionPhase( return null; } - final deckSlides = (deckJson['slides'] as List?)?.length ?? 0; + final deckSlides = composition.slides.length; debugLog.log( 'DECK_GEN', - 'Phase 2 COMPLETE in ${deckMs}ms - $deckSlides raw slides', + 'Phase 2 COMPLETE in ${deckMs}ms - $deckSlides accepted, ' + '${composition.failures.length} failed', ); trace.emit( kind: GenerationTraceKind.phaseDone, phase: GenerationTracePhase.composition, ); - return deckJson; + return composition; } DeckGenerationResult _finalizeDeck( DeckGeneratorService owner, { - required Map deckJson, + required _SlideCompositionResult composition, required DeckPlanType plan, required DateTime pipelineStart, required GenerationProgressCallback? onProgress, @@ -116,7 +123,7 @@ DeckGenerationResult _finalizeDeck( debugLog.section('Finalize'); onProgress?.call(const GenerationProgress(GenerationPhase.finalizing)); - final slides = _extractSlides(deckJson); + final slides = composition.slides; debugLog.log('DECK_GEN', 'Pre-sanitize: ${slides.length} slides'); final sanitizedSlides = sanitizeGeneratedSlides(slides); @@ -126,7 +133,7 @@ DeckGenerationResult _finalizeDeck( '(${slides.length - sanitizedSlides.length} removed)', ); - if (sanitizedSlides.isEmpty) { + if (sanitizedSlides.isEmpty && composition.failures.isEmpty) { debugLog.error('DECK_GEN', 'No slides survived sanitization'); trace.emit( kind: GenerationTraceKind.validation, @@ -136,10 +143,12 @@ DeckGenerationResult _finalizeDeck( return DeckGenerationResult.failure('No slides generated'); } - if (sanitizedSlides.length != plan.slides.length) { + final expectedAcceptedCount = + plan.slides.length - composition.failures.length; + if (sanitizedSlides.length != expectedAcceptedCount) { final message = 'Generated ${sanitizedSlides.length} usable slides; ' - 'expected exactly ${plan.slides.length}.'; + 'expected $expectedAcceptedCount accepted slide results.'; debugLog.error('DECK_GEN', message); trace.emit( kind: GenerationTraceKind.validation, @@ -180,6 +189,15 @@ DeckGenerationResult _finalizeDeck( typographyCatalog: owner.typographyCatalog, ); + if (composition.failures.isNotEmpty) { + return DeckGenerationResult.partial( + slides: parsedSlides, + slideFailures: composition.failures, + plan: plan, + theme: theme, + ); + } + return DeckGenerationResult.success( slides: parsedSlides, plan: plan, @@ -195,24 +213,6 @@ String _argumentMessage(Object error) => error bool _generationCancelled(bool Function()? isCancelled) => isCancelled?.call() ?? false; -List> _extractSlides(Map deckJson) { - final rawSlides = deckJson['slides']; - if (rawSlides is! List) return const >[]; - - final slides = >[]; - for (final entry in rawSlides.asMap().entries) { - final rawSlide = entry.value; - if (rawSlide is! Map) { - final index = entry.key; - debugLog.log('DECK_GEN', 'Discarding non-object slide at index $index'); - continue; - } - slides.add(Map.from(rawSlide)); - } - - return slides; -} - List? _parseSanitizedSlides(List> slides) { final parsed = []; for (var i = 0; i < slides.length; i++) { diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart index 88481238..a82056d0 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart @@ -1,8 +1,12 @@ part of 'deck_generator_service.dart'; +const _maxTargetedOutlineSlidesPerPass = 2; + bool _onlySlideScopedPlanIssues(List issues) { final blocking = issues.blockingIssues; + final affectedSlideKeys = {for (final issue in blocking) ?issue.slideKey}; return blocking.isNotEmpty && + affectedSlideKeys.length <= _maxTargetedOutlineSlidesPerPass && blocking.every( (issue) => issue.locallyRepairable && issue.slideKey != null, ); @@ -39,6 +43,13 @@ extension _DeckPlanRepair on DeckGeneratorService { localAttempt <= maxOutlineSlideValidationAttempts; localAttempt++ ) { + if (!executor.hasRepairCapacity) { + debugLog.log( + 'DECK_GEN', + 'Outline slide repair skipped: run repair budget exhausted.', + ); + break; + } final candidate = await _generateOutlineSlideRepair( executor: executor, originalPrompt: originalPrompt, @@ -117,7 +128,7 @@ extension _DeckPlanRepair on DeckGeneratorService { invalidSlide: invalidSlide, ); final modelRequest = google_ai.GenerateContentRequest( - model: outlineModelName, + model: outlineRepairModelName, contents: [ google_ai.Content( role: 'user', @@ -127,6 +138,7 @@ extension _DeckPlanRepair on DeckGeneratorService { generationConfig: google_ai.GenerationConfig( responseMimeType: 'application/json', responseSchema: adapted.schema, + thinkingConfig: google_ai.ThinkingConfig(thinkingBudget: 0), ), systemInstruction: google_ai.Content( parts: [google_ai.Part(text: systemPrompt)], @@ -141,7 +153,7 @@ extension _DeckPlanRepair on DeckGeneratorService { final response = await executor.execute( request: modelRequest, phase: GenerationTracePhase.outline, - model: outlineModelName, + model: outlineRepairModelName, prompt: systemPrompt, semanticAttempt: localAttempt, isRepair: true, @@ -212,7 +224,11 @@ List _outlineSlideInvariantErrors({ requireSame('composition', original.composition, candidate.composition); requireSame('treatment', original.treatment, candidate.treatment); requireSame('density', original.density, candidate.density); - requireSame('elements', original.elements, candidate.elements); + requireSame( + 'elements', + original.elements ?? const [], + candidate.elements ?? const [], + ); return errors; } diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart index a07d3a95..c1e446e3 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart @@ -91,10 +91,7 @@ List validateDeckPlanIssues( themes, catalog, request, - issues.scoped( - code: .themeResolution, - category: .structure, - ), + issues.scoped(code: .themeResolution, category: .structure), ); _validateElementGrounding( plan, @@ -118,6 +115,7 @@ List validateDeckPlanIssues( issues.scoped( code: GenerationValidationCode.numericGrounding, category: GenerationValidationCategory.factual, + severity: GenerationValidationSeverity.diagnostic, ), ); _validateNumericClaimContext( @@ -126,6 +124,7 @@ List validateDeckPlanIssues( issues.scoped( code: GenerationValidationCode.numericMeaning, category: GenerationValidationCategory.factual, + severity: GenerationValidationSeverity.diagnostic, ), ); _validateMetricIntent( @@ -198,14 +197,7 @@ void _validateNumericClaimContext( if (request == null) return; for (final slide in plan.slides) { final mismatches = findNumericContextMismatches( - values: [ - slide.title, - slide.purpose, - slide.assertion, - ...slide.contentUnits, - slide.contentBrief, - slide.continuity, - ], + values: _audienceFacingPlanCopy(slide), userIntent: request.userIntent, allowSlideContextFallback: true, ); @@ -244,26 +236,28 @@ void _validateCommitmentGrounding( inspectMetricCausality: false, ); if (narrativeUnsupported.isNotEmpty) { - errors.add( - 'Deck narrative introduces unsupported commitment claim(s): ' - '${narrativeUnsupported.join(', ')}. Remove them unless userIntent ' - 'supplied the exact claim.', - ); + errors + .scoped( + severity: hasBlockingCommitmentClaim(narrativeUnsupported) + ? GenerationValidationSeverity.blocking + : GenerationValidationSeverity.diagnostic, + ) + .add( + 'Deck narrative introduces unsupported commitment claim(s): ' + '${narrativeUnsupported.join(', ')}. Remove them unless userIntent ' + 'supplied the exact claim.', + ); } for (final slide in plan.slides) { final unsupported = findUnsupportedCommitmentPhrases( - values: [ - slide.title, - slide.purpose, - slide.assertion, - ...slide.contentUnits, - slide.contentBrief, - slide.continuity, - ], + values: _audienceFacingPlanCopy(slide), userIntent: request.userIntent, ); if (unsupported.isNotEmpty) { final slideErrors = errors.scoped( + severity: hasBlockingCommitmentClaim(unsupported) + ? GenerationValidationSeverity.blocking + : GenerationValidationSeverity.diagnostic, location: GenerationValidationLocation.planSlide, slideKey: slide.key, locallyRepairable: true, @@ -286,14 +280,7 @@ void _validateNumericClaimGrounding( final groundedClaims = extractGroundedNumericClaims([request.userIntent]); for (final slide in plan.slides) { final ungrounded = findUnsupportedNumericClaims( - values: [ - slide.title, - slide.purpose, - slide.assertion, - ...slide.contentUnits, - slide.contentBrief, - slide.continuity, - ], + values: _audienceFacingPlanCopy(slide), groundedClaims: groundedClaims, ); if (ungrounded.isNotEmpty) { @@ -355,12 +342,7 @@ void _validateVisibleSourceGrounding( locallyRepairable: true, ); final referencedDomains = extractReferencedDomains([ - slide.title, - slide.purpose, - slide.assertion, - ...slide.contentUnits, - slide.contentBrief, - slide.continuity, + ..._audienceFacingPlanCopy(slide), ]); for (final domain in referencedDomains.difference(allowedDomains)) { slideErrors.add( @@ -372,6 +354,12 @@ void _validateVisibleSourceGrounding( } } +List _audienceFacingPlanCopy(DeckPlanSlideType slide) => [ + slide.title, + slide.assertion, + ...slide.contentUnits, +]; + void _validateSections( DeckPlanType plan, GenerationValidationCollector errors, @@ -582,11 +570,6 @@ void _validateDesignRhythm( label: 'composition', errors: errors, ); - _rejectLongRuns( - values: plan.slides.map((slide) => slide.treatment).toList(), - label: 'treatment', - errors: errors, - ); final requiredFamilies = switch (plan.slides.length) { >= 20 => 7, diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/design_quality_metrics.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/design_quality_metrics.dart index f27a4ffa..39c939c1 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/design_quality_metrics.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/design_quality_metrics.dart @@ -35,8 +35,8 @@ int visibleCharacterLimit(String density, {String? composition}) { }; final compositionLimit = switch (composition) { 'title' => 250, - 'titleLeft' => 440, - 'twoColumn' => 400, + 'titleLeft' => 480, + 'twoColumn' => 500, 'threeColumn' => 480, 'table' => 620, 'quote' => 360, @@ -51,6 +51,15 @@ int visibleCharacterLimit(String density, {String? composition}) { return math.min(densityLimit, compositionLimit); } +/// Whether copy exceeds the soft pacing budget enough to risk actual overflow. +/// +/// A modest overage remains useful review evidence for the POC. Twice the +/// composition-aware budget is treated as structurally unsafe. +bool isHardContentDensityOverage({ + required int visibleCharacters, + required int characterLimit, +}) => visibleCharacters > characterLimit * 2; + /// Counts approximate audience-visible characters, excluding Markdown syntax. int countVisibleMarkdownCharacters(Iterable markdownBlocks) { var count = 0; diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart index 4363589c..13076b08 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart @@ -18,6 +18,11 @@ Map normalizeGeneratedSlideForPlan({ required DeckPlanSlideType planSlide, }) { final normalized = Map.of(rawSlide); + final rawOptions = rawSlide['options']; + normalized['options'] = { + if (rawOptions is Map) ...Map.from(rawOptions), + 'style': planSlide.treatment, + }; final rawSections = rawSlide['sections']; if (rawSections is! List) return normalized; normalized['sections'] = [ @@ -29,11 +34,10 @@ Map normalizeGeneratedSlideForPlan({ 'blocks': [ for (final rawBlock in rawBlocks) if (rawBlock is Map) - _planPermitsH1(planSlide) - ? Map.from(rawBlock) - : _normalizeBlockHeading( - Map.from(rawBlock), - ) + _normalizeBlockForPlan( + Map.from(rawBlock), + planSlide, + ) else rawBlock, ], @@ -44,6 +48,30 @@ Map normalizeGeneratedSlideForPlan({ return _normalizeImplicitVerticalAlignment(normalized, planSlide); } +Map _normalizeBlockForPlan( + Map block, + DeckPlanSlideType planSlide, +) { + var normalized = _planPermitsH1(planSlide) + ? block + : _normalizeBlockHeading(block); + if (planSlide.composition == 'title') { + normalized = _flattenTitleListMarkers(normalized); + } + return normalized; +} + +Map _flattenTitleListMarkers(Map block) { + if (block['type'] != ContentBlock.key || block['content'] is! String) { + return block; + } + block['content'] = (block['content'] as String).replaceAllMapped( + RegExp(r'^(\s*)(?:[-+*]|\d+[.)])\s+', multiLine: true), + (match) => match.group(1)!, + ); + return block; +} + /// Removes optional speaker comments whenever their content is invalid. /// /// Speaker notes never affect the rendered slide contract. Dropping them is a @@ -327,6 +355,7 @@ List _validatePlanFulfillment( .scoped( code: GenerationValidationCode.numericGrounding, category: GenerationValidationCategory.factual, + severity: GenerationValidationSeverity.diagnostic, ) .addAll( _validateNumericClaimGrounding( @@ -339,6 +368,7 @@ List _validatePlanFulfillment( .scoped( code: GenerationValidationCode.numericMeaning, category: GenerationValidationCategory.factual, + severity: GenerationValidationSeverity.diagnostic, ) .addAll( _validateNumericClaimContext( @@ -347,18 +377,18 @@ List _validatePlanFulfillment( label: 'Visible content', ), ); + final visibleCommitment = _validateCommitmentGrounding( + [markdown], + request, + label: 'Visible content', + ); errors .scoped( code: GenerationValidationCode.commitmentGrounding, category: GenerationValidationCategory.factual, + severity: visibleCommitment.severity, ) - .addAll( - _validateCommitmentGrounding( - [markdown], - request, - label: 'Visible content', - ), - ); + .addAll(visibleCommitment.messages); if (slide.comments.isNotEmpty) { final commentErrors = errors.scoped( location: GenerationValidationLocation.speakerComments, @@ -379,6 +409,7 @@ List _validatePlanFulfillment( .scoped( code: GenerationValidationCode.numericGrounding, category: GenerationValidationCategory.factual, + severity: GenerationValidationSeverity.diagnostic, ) .addAll( _validateNumericClaimGrounding( @@ -391,6 +422,7 @@ List _validatePlanFulfillment( .scoped( code: GenerationValidationCode.numericMeaning, category: GenerationValidationCategory.factual, + severity: GenerationValidationSeverity.diagnostic, ) .addAll( _validateNumericClaimContext( @@ -399,23 +431,24 @@ List _validatePlanFulfillment( label: 'Speaker comments', ), ); + final commentCommitment = _validateCommitmentGrounding( + slide.comments, + request, + label: 'Speaker comments', + ); commentErrors .scoped( code: GenerationValidationCode.commitmentGrounding, category: GenerationValidationCategory.factual, + severity: commentCommitment.severity, ) - .addAll( - _validateCommitmentGrounding( - slide.comments, - request, - label: 'Speaker comments', - ), - ); + .addAll(commentCommitment.messages); } errors .scoped( code: GenerationValidationCode.contentDensity, category: GenerationValidationCategory.quality, + severity: GenerationValidationSeverity.diagnostic, ) .addAll( _validateDisplayHeadings( @@ -435,6 +468,13 @@ List _validatePlanFulfillment( .scoped( code: GenerationValidationCode.contentDensity, category: GenerationValidationCategory.quality, + severity: + isHardContentDensityOverage( + visibleCharacters: visibleCharacters, + characterLimit: characterLimit, + ) + ? GenerationValidationSeverity.blocking + : GenerationValidationSeverity.diagnostic, ) .add( 'Slide exceeds the ${planSlide.density} content budget of ' @@ -555,6 +595,7 @@ List _validateNumericClaimContext( final mismatches = findNumericContextMismatches( values: values, userIntent: request.userIntent, + allowSlideContextFallback: label == 'Visible content', ); if (mismatches.isEmpty) return const []; final verb = label == 'Speaker comments' ? 'change' : 'changes'; @@ -565,23 +606,39 @@ List _validateNumericClaimContext( ]; } -List _validateCommitmentGrounding( +({List messages, GenerationValidationSeverity severity}) +_validateCommitmentGrounding( Iterable values, DeckGenerationRequest? request, { required String label, }) { - if (request == null) return const []; + if (request == null) { + return ( + messages: const [], + severity: GenerationValidationSeverity.diagnostic, + ); + } final unsupported = findUnsupportedCommitmentPhrases( values: values, userIntent: request.userIntent, ); - if (unsupported.isEmpty) return const []; + if (unsupported.isEmpty) { + return ( + messages: const [], + severity: GenerationValidationSeverity.diagnostic, + ); + } final verb = label == 'Speaker comments' ? 'introduce' : 'introduces'; - return [ - '$label $verb unsupported commitment claim(s): ' - '${unsupported.join(', ')}. Remove them unless userIntent supplied ' - 'the exact claim.', - ]; + return ( + messages: [ + '$label $verb unsupported commitment claim(s): ' + '${unsupported.join(', ')}. Remove them unless userIntent supplied ' + 'the exact claim.', + ], + severity: hasBlockingCommitmentClaim(unsupported) + ? GenerationValidationSeverity.blocking + : GenerationValidationSeverity.diagnostic, + ); } List _validateNumericClaimGrounding( diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_model_call_executor.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_model_call_executor.dart index 19b9cfc8..16f11497 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_model_call_executor.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_model_call_executor.dart @@ -36,6 +36,8 @@ final class GenerationModelCallExecutor { timeout: runTimeout, ); + bool get hasRepairCapacity => _budget.hasRepairCapacity; + void _throwIfCancelled() { if (_isCancelled()) throw const GenerationCancelledException(); } @@ -112,6 +114,11 @@ final class GenerationRunBudget { required this.timeout, }) : _stopwatch = Stopwatch()..start(); + bool get hasRepairCapacity { + _throwIfTimedOut(); + return _repairRequests < maxRepairRequests; + } + void _throwIfTimedOut() { if (_stopwatch.elapsed >= timeout) { throw GenerationBudgetExceededException( diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_progress.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_progress.dart index d85fa67b..00bb3f64 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_progress.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_progress.dart @@ -22,15 +22,26 @@ final class GenerationProgress { this.phase, { this.slideIndex, this.slideCount, + this.sectionIndex, + this.sectionCount, this.isRepairing = false, }); final GenerationPhase phase; final int? slideIndex; final int? slideCount; + final int? sectionIndex; + final int? sectionCount; final bool isRepairing; String get label { + final currentSection = sectionIndex; + final totalSections = sectionCount; + if (phase == GenerationPhase.composingSlides && + currentSection != null && + totalSections != null) { + return 'Composing section $currentSection of $totalSections…'; + } final index = slideIndex; final count = slideCount; if (phase == GenerationPhase.composingSlides && diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_quality_report.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_quality_report.dart index fd822ace..db887bc4 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_quality_report.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_quality_report.dart @@ -18,7 +18,7 @@ final class GenerationQualityIssue { required this.rule, required this.message, this.slideKey, - this.severity = GenerationValidationSeverity.diagnostic, + this.severity = GenerationValidationSeverity.blocking, this.sourceCode, }); @@ -193,6 +193,13 @@ final class GenerationQualityReport { GenerationQualityIssue( rule: 'slide.content_density', slideKey: slide.key, + severity: + isHardContentDensityOverage( + visibleCharacters: characters, + characterLimit: maximum, + ) + ? GenerationValidationSeverity.blocking + : GenerationValidationSeverity.diagnostic, message: 'Slide "${slide.key}" has $characters visible characters; ' '$density allows at most $maximum.', @@ -338,7 +345,9 @@ final class GenerationQualityReport { ); } - bool get passed => issues.isEmpty; + bool get passed => issues.every( + (issue) => issue.severity != GenerationValidationSeverity.blocking, + ); Map toJson() => { 'passed': passed, diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/source_grounding.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/source_grounding.dart index 1ce6e885..7ad58399 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/source_grounding.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/source_grounding.dart @@ -10,7 +10,7 @@ final _numericClaimPattern = RegExp( ); final _projectionQualifierPattern = RegExp( r'\b(?:assum(?:e|ed|ption)|calculat(?:e|ed|ion)|derived?|estimat(?:e|ed)|' - r'illustrative|project(?:ed|ion)|scenario)\b', + r'goal|illustrative|planned|project(?:ed|ion)|proposed|scenario|target)\b', caseSensitive: false, ); final _nonCommitmentQualifierPattern = RegExp( @@ -20,9 +20,9 @@ final _nonCommitmentQualifierPattern = RegExp( caseSensitive: false, ); final _structuralCountPattern = RegExp( - r'\b(?:act|canvas|capability|category|channel|column|component|deck|' - r'dimension|group|home|horizon|hub|inbox|input|layer|level|lever|option|package|' - r'part|path|phase|pillar|place|plan|point|principle|section|segment|silo|slide|' + r'\b(?:act|canvas|capability|category|channel|column|comparison|component|deck|' + r'decision|dimension|group|home|horizon|hub|inbox|input|layer|level|lever|option|package|' + r'part|path|phase|pillar|pilot|place|plan|point|principle|section|segment|silo|slide|' r'source|stage|step|stream|surface|system|theme|thread|tier|track|variable|' r'view|way|workspace)s?\b', caseSensitive: false, @@ -35,6 +35,10 @@ final _qualitativeZeroPattern = RegExp( r'\bzero[-\s]+friction\b', caseSensitive: false, ); +final _nonNumericOnePattern = RegExp( + r'\bone[-\s]+(?:off|time)\b', + caseSensitive: false, +); final _unsourcedTemporalClaimPattern = RegExp( r'\b(?:(?:in|within|over|after|before|from)\s+(?:a\s+|an\s+|few\s+)?' r'(?:minutes?|hours?|days?|weeks?|months?|years?)' @@ -49,7 +53,7 @@ final _unsourcedTemporalClaimPattern = RegExp( final _pricingTierLabelPattern = RegExp( r'(?:^|\|)\s*(?:[*_`]{1,3})?\s*' r'(free|starter|basic|team|pro|professional|business|growth|enterprise)' - r'\s*(?:[*_`]{1,3})?\s*(?=[:|—–-])', + r'\s*(?:[*_`]{1,3})?\s*(?=[:|—–]|\s+-\s+)', caseSensitive: false, ); final _evidenceAmplificationPattern = RegExp( @@ -287,6 +291,53 @@ const _highRiskCommitmentPhrases = { 'without requiring data migration', 'without requiring migration', }; + +// Only concrete claims with meaningful legal, commercial, security, +// availability, or credential risk should force another model request. Broader +// editorial and evidence-strength language remains useful diagnostic evidence, +// but regex matches such as "prioritizes" or "designed to" are not reliable +// enough to block a generated deck. +const _blockingCommitmentPhrases = { + 'access controls', + 'audit logs', + 'audit logging', + 'audit-ready', + 'available to everyone', + 'award-winning', + 'certification', + 'certified', + 'compliant', + 'compliant with', + 'data residency', + 'early access', + 'enterprise-grade', + 'enterprise ready', + 'free trial', + 'gdpr', + 'granular permission', + 'granular permissions', + 'guaranteed', + 'hipaa', + 'is open', + 'money-back', + 'no credit card', + 'now live', + 'oauth', + 'opening access', + 'patented', + 'production environments', + 'production-scale', + 'proven in production', + 'rbac', + 'read-only', + 'security validated', + 'seat-based pricing', + 'sign up', + 'soc 2', + 'soc2', + 'sso', + 'waitlist', +}; const _numberWords = { 'zero': '0', 'one': '1', @@ -438,7 +489,9 @@ Set _groundedPurposeTerms(String value) => Set extractNumericClaims(Iterable values) { final claims = {}; for (final value in values) { - final numericWordSource = value.replaceAll(_qualitativeZeroPattern, ''); + final numericWordSource = value + .replaceAll(_qualitativeZeroPattern, '') + .replaceAll(_nonNumericOnePattern, ''); for (final match in _numericClaimPattern.allMatches(value)) { claims.add(match.group(0)!.toUpperCase().replaceAll(',', '')); } @@ -492,7 +545,8 @@ String unsupportedNumericClaimRepairGuidance(Set claims) { 'otherwise remove the claim.' : ''; return 'Remove them or label the containing copy as a projection, estimate, ' - 'assumption, calculation, or scenario.$qualitativeZeroGuidance'; + 'assumption, calculation, scenario, or planned target.' + '$qualitativeZeroGuidance'; } /// Extracts audience-facing metrics while omitting small structural counts. @@ -670,6 +724,7 @@ Set findNumericContextMismatches({ for (final context in _numericFactContexts(value)) { final copyWords = _significantWords(context); for (final claim in _nonStructuralNumericClaims(context)) { + if (_isStructuralEnumeration(context, claim)) continue; final claimAnchorGroups = anchorGroups[claim]; if (claimAnchorGroups == null || claimAnchorGroups.isEmpty) continue; if (_addsOpenEndedQualifier( @@ -703,7 +758,11 @@ Set findNumericContextMismatches({ bool _matchesAnyAnchorGroup( Set copyWords, List> anchorGroups, -) => anchorGroups.any((required) => required.difference(copyWords).isEmpty); +) => anchorGroups.any((required) { + final matched = required.intersection(copyWords).length; + if (matched == required.length) return true; + return required.length >= 3 && matched >= required.length - 1; +}); bool _changesNumericClaimOwnership({ required String context, @@ -742,6 +801,7 @@ Set findUnsupportedCommitmentPhrases({ if (copy.contains(phrase) && !supplied.contains(phrase) && !qualified && + !_isBenignCommitmentUse(copy, phrase) && !_isNegatedPhrase(copy, phrase)) { unsupported.add(phrase); } @@ -780,6 +840,13 @@ Set findUnsupportedCommitmentPhrases({ return unsupported; } +/// Whether unsupported commitment findings are concrete enough to block. +bool hasBlockingCommitmentClaim(Iterable phrases) => phrases.any( + (phrase) => + _blockingCommitmentPhrases.contains(phrase) || + phrase.startsWith('pricing tier "'), +); + bool _userSuppliesPricingTier(String supplied, String label) => RegExp( '(?:\\b${RegExp.escape(label)}\\b.{0,24}\\btier\\b|' '\\btier\\b.{0,24}\\b${RegExp.escape(label)}\\b|' @@ -787,6 +854,13 @@ bool _userSuppliesPricingTier(String supplied, String label) => RegExp( caseSensitive: false, ).hasMatch(supplied); +bool _isBenignCommitmentUse(String copy, String phrase) => + phrase == 'compliance' && + RegExp( + r'\b(?:plan|planning|process|roadmap|schedule) compliance\b', + caseSensitive: false, + ).hasMatch(copy); + bool _addsOpenEndedQualifier({ required String context, required String claim, @@ -832,6 +906,7 @@ String _normalizeFactWord(String value) { word = word.substring(0, word.length - 1); } return switch (word) { + 'adoption' || 'migration' || 'rollout' || 'transition' => 'adoption', 'canvas' || 'place' || 'surface' => 'workspace', 'company' || 'customer' || 'enterprise' || 'organization' => 'partner', 'connector' || 'integration' || 'toolchain' => 'source', diff --git a/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart b/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart index 0f99e4b0..fd569097 100644 --- a/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart +++ b/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart @@ -50,7 +50,7 @@ class _GenerationLabPageState extends State { isCancelled: () => _cancelled || !mounted, ); if (!mounted) return; - if (result.success) { + if (result.slides.isNotEmpty && result.theme != null) { _applyResult(result); await Future.delayed(const Duration(milliseconds: 50)); } @@ -81,6 +81,7 @@ class _GenerationLabPageState extends State { @override Widget build(BuildContext context) { if (!kDebugMode) return const SizedBox.shrink(); + final result = _result; return Scaffold( appBar: AppBar(title: const Text('AI generation lab')), body: ListView( @@ -111,10 +112,22 @@ class _GenerationLabPageState extends State { ), if (_error case final error?) Text(error, style: const TextStyle(color: Colors.red)), - if (_result?.success ?? false) ...[ + if (result != null && result.slideFailures.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + 'Unresolved slides', + style: Theme.of(context).textTheme.titleMedium, + ), + for (final failure in result.slideFailures) + Text( + '${failure.slideIndex}. ${failure.slideKey}: ' + '${failure.message}', + ), + ], + if (result != null && result.slides.isNotEmpty) ...[ const SizedBox(height: 24), Text( - 'Generated slides', + result.isPartial ? 'Accepted slides' : 'Generated slides', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 12), diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider_test.dart index 3fc1d12a..2264fa3c 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider_test.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:playground/core/domain/design/presentation_theme_catalog.dart'; import 'package:playground/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart'; @@ -34,6 +36,45 @@ void main() { }, ); + test('uses a dedicated compact outline repair prompt', () async { + final catalog = PresentationThemeCatalog.withDefaults(); + final provider = AssetGenerationPromptProvider(); + await provider.load(); + final candidates = catalog.shortlist( + const PresentationThemeSelectionCriteria( + userIntent: 'Create a clear presentation.', + ), + ); + final invalidPlan = { + 'topic': 'Reliable systems', + 'story': 'Move from uncertainty to a reliable operating rhythm.', + 'theme': {'id': 'technical-paper'}, + 'slides': [_planSlide('opening', 'Frame the problem')], + }; + + final prompt = provider.buildOutlinePrompt( + themeCandidates: candidates, + validationIssues: const [ + GenerationValidationIssue( + code: GenerationValidationCode.slideCount, + category: GenerationValidationCategory.structure, + severity: GenerationValidationSeverity.blocking, + location: GenerationValidationLocation.deck, + message: 'Deck plan has 1 slides; expected exactly 2.', + ), + ], + invalidPlan: invalidPlan, + ); + final encodedPlan = const JsonEncoder.withIndent(' ').convert(invalidPlan); + + expect(prompt, startsWith('You repair one SuperDeck deck plan.')); + expect(prompt, contains('Deck plan has 1 slides; expected exactly 2.')); + expect(prompt, contains(encodedPlan)); + expect(prompt, contains('technical-paper')); + expect(prompt, isNot(contains('Dark cinematic editorial system'))); + expect(prompt.length - encodedPlan.length, lessThanOrEqualTo(4000)); + }); + test('assembles bounded single-slide context deterministically', () { final plan = DeckPlanType.parse({ 'topic': 'Reliable systems', @@ -56,6 +97,8 @@ void main() { }); final previous = { 'key': 'opening', + 'options': {'title': 'Reliability starts here', 'style': 'hero'}, + 'comments': ['Private speaker note that should not be repeated.'], 'sections': [ { 'type': 'section', @@ -105,7 +148,16 @@ void main() { expect(prompt, contains('The slide key must be exactly `evidence`.')); expect(prompt, contains('"key": "opening"')); expect(prompt, contains('"key": "closing"')); - expect(prompt, contains('`image`: A supplied image asset, file, or URL.')); + expect(prompt, contains('None planned for this slide.')); + expect( + prompt, + isNot(contains('`image`: A supplied image asset, file, or URL.')), + ); + expect( + prompt, + isNot(contains('Private speaker note that should not be repeated.')), + ); + expect(prompt, contains('# Reliability starts here')); expect(prompt, contains('## Current narrative section')); expect(prompt, contains('## Recent design ledger')); expect(prompt, contains('"key": "opening"')); @@ -182,6 +234,216 @@ void main() { expect(prompt, contains('delete the comment')); }, ); + + test( + 'surfaces exact grounded and hypothetical numeric copy contracts', + () async { + final provider = AssetGenerationPromptProvider(); + await provider.load(); + final plan = DeckPlanType.parse({ + 'topic': 'Adoption', + 'story': 'Move from output to adoption.', + 'theme': _themeReference, + 'sections': [ + { + 'key': 'main', + 'title': 'Main story', + 'purpose': 'Advance the narrative.', + 'transition': 'Move to the decision.', + 'slideKeys': ['evidence'], + }, + ], + 'slides': [ + { + ..._planSlide('evidence', 'Adoption evidence'), + 'contentUnits': [ + 'Only 31% of shipped features reach their adoption target.', + 'Planned target: 60% adoption after the proposed pilot.', + ], + }, + ], + }); + + final prompt = provider.buildSlidePrompt( + plan: plan, + current: plan.slides.single, + previousSlide: null, + next: null, + elementCatalog: GenerationElementCatalog.builtIn(), + ); + + expect(prompt, contains('## Numeric copy contract')); + expect( + prompt, + contains('Only 31% of shipped features reach their adoption target.'), + ); + expect( + prompt, + contains('Planned target: 60% adoption after the proposed pilot.'), + ); + expect(prompt, contains('copy the complete unit verbatim')); + expect(prompt, contains('preserve its explicit hypothetical label')); + }, + ); + + test('builds one compact ordered prompt for a narrative section', () async { + final provider = AssetGenerationPromptProvider(); + await provider.load(); + final plan = DeckPlanType.parse({ + 'topic': 'Adoption', + 'story': 'Move from evidence to a practical decision.', + 'theme': _themeReference, + 'sections': [ + { + 'key': 'evidence-act', + 'title': 'Evidence', + 'purpose': 'Establish the adoption gap.', + 'transition': 'Turn evidence into action.', + 'slideKeys': ['signal', 'target'], + }, + ], + 'slides': [ + { + ..._planSlide('signal', 'Show the grounded signal'), + 'sectionKey': 'evidence-act', + 'contentUnits': [ + 'Only 31% of shipped features reach their adoption target.', + ], + }, + { + ..._planSlide('target', 'Set the proposed direction'), + 'sectionKey': 'evidence-act', + 'contentUnits': [ + 'Planned target: 60% adoption after the proposed pilot.', + ], + }, + ], + }); + + final prompt = provider.buildSectionPrompt( + plan: plan, + section: plan.sections.single, + slides: plan.slides, + previous: null, + next: null, + elementCatalog: GenerationElementCatalog.builtIn(), + ); + + expect(prompt, contains('Return one `slides` array')); + expect( + prompt.indexOf('"key": "signal"'), + lessThan(prompt.indexOf('"key": "target"')), + ); + expect( + prompt, + contains('Only 31% of shipped features reach their adoption target.'), + ); + expect( + prompt, + contains('Planned target: 60% adoption after the proposed pilot.'), + ); + expect(prompt, contains('copy each complete unit verbatim')); + expect(prompt, contains('## Canonical shape examples')); + expect(prompt, contains('"key": "example-content"')); + expect(RegExp('"key": "example-content"').allMatches(prompt), hasLength(1)); + expect(prompt, isNot(contains('"key": "example-table"'))); + expect(prompt, contains('No elements are planned in this section.')); + expect(prompt, isNot(contains('`image`: A supplied image asset'))); + }); + + test( + 'keeps initial context bounded and repairs smaller than generation', + () async { + final catalog = PresentationThemeCatalog.withDefaults(); + final provider = AssetGenerationPromptProvider(); + await provider.load(); + final plan = DeckPlanType.parse({ + 'topic': 'Reliable systems', + 'story': 'Move from uncertainty to a reliable operating rhythm.', + 'theme': _themeReference, + 'sections': [ + { + 'key': 'main', + 'title': 'Main story', + 'purpose': 'Advance the narrative.', + 'transition': 'Carry the story to the close.', + 'slideKeys': ['opening', 'evidence', 'closing'], + }, + ], + 'slides': [ + _planSlide('opening', 'Frame the problem'), + _planSlide('evidence', 'Show the proof'), + _planSlide('closing', 'Make the ask'), + ], + }); + final outlinePrompt = provider.buildOutlinePrompt( + themeCandidates: catalog.shortlist( + const PresentationThemeSelectionCriteria( + userIntent: 'Create a clear presentation.', + ), + ), + ); + final initialSlidePrompt = provider.buildSlidePrompt( + plan: plan, + current: plan.slides[1], + previousSlide: const { + 'key': 'opening', + 'sections': [ + { + 'type': 'section', + 'blocks': [ + {'type': 'block', 'content': '# Reliability starts here'}, + ], + }, + ], + }, + next: plan.slides[2], + elementCatalog: GenerationElementCatalog.builtIn(), + ); + final repairPrompt = provider.buildSlidePrompt( + plan: plan, + current: plan.slides[1], + previousSlide: const { + 'key': 'opening', + 'sections': [ + { + 'type': 'section', + 'blocks': [ + {'type': 'block', 'content': '# Reliability starts here'}, + ], + }, + ], + }, + next: plan.slides[2], + elementCatalog: GenerationElementCatalog.builtIn(), + validationIssues: const [ + GenerationValidationIssue( + code: GenerationValidationCode.contentDensity, + category: GenerationValidationCategory.quality, + severity: GenerationValidationSeverity.blocking, + location: GenerationValidationLocation.visibleContent, + slideKey: 'evidence', + message: 'Slide exceeds the balanced content budget.', + ), + ], + invalidSlide: const { + 'key': 'evidence', + 'sections': [ + { + 'type': 'section', + 'blocks': [ + {'type': 'block', 'content': '## Too much copy'}, + ], + }, + ], + }, + ); + + expect(outlinePrompt.length, lessThanOrEqualTo(10000)); + expect(initialSlidePrompt.length, lessThanOrEqualTo(11000)); + expect(repairPrompt.length, lessThan(initialSlidePrompt.length)); + }, + ); } const _themeReference = { diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/schemas/outline_schema_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/schemas/outline_schema_test.dart index 6c4fcab0..8f495111 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/schemas/outline_schema_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/schemas/outline_schema_test.dart @@ -230,7 +230,7 @@ void main() { } }); - test('reports every overlong treatment run in one repair pass', () { + test('allows a treatment run when compositions still vary', () { final data = _hierarchicalPlan(); final slides = data['slides']! as List>; const compositions = [ @@ -250,21 +250,14 @@ void main() { final errors = validateDeckPlan(DeckPlanType.parse(data)); - expect( - errors, - containsAll([ - 'Deck plan repeats treatment "content" more than three times ' - 'consecutively.', - 'Deck plan repeats treatment "data" more than three times ' - 'consecutively.', - ]), - ); + expect(errors.where((error) => error.contains('treatment')), isEmpty); }); test('reports design rhythm as typed non-blocking diagnostics', () { final data = _hierarchicalPlan(); final slides = data['slides']! as List>; for (final slide in slides) { + slide['composition'] = 'content'; slide['treatment'] = 'content'; } @@ -420,14 +413,50 @@ void main() { contains( 'Slide "close" uses numeric claim(s) 100%, 90 that are not present ' 'in userIntent. Remove them or label the containing copy as a ' - 'projection, estimate, assumption, calculation, or scenario.', + 'projection, estimate, assumption, calculation, scenario, or planned ' + 'target.', + ), + ); + final numericIssues = validateDeckPlanIssues( + unqualified, + request: const DeckGenerationRequest( + userIntent: 'Create a ten-slide story using the supplied 42% result.', + slideCount: 10, + ), + ).where((issue) => issue.code == GenerationValidationCode.numericGrounding); + expect(numericIssues, isNotEmpty); + expect( + numericIssues, + everyElement( + isA().having( + (issue) => issue.severity, + 'severity', + GenerationValidationSeverity.diagnostic, + ), ), ); + expect(numericIssues.where((issue) => issue.isBlocking), isEmpty); expect( allowed.where((error) => error.contains('uses numeric claim(s)')), isEmpty, ); + slides.last['contentUnits'] = [ + 'Planned target: 50% adoption after the proposed 90-day pilot', + ]; + final plannedTarget = validateDeckPlan( + DeckPlanType.parse(data), + request: const DeckGenerationRequest( + userIntent: + 'Describe a future pilot without claiming observed results.', + slideCount: 10, + ), + ); + expect( + plannedTarget.where((error) => error.contains('uses numeric claim(s)')), + isEmpty, + ); + slides.last['contentUnits'] = ['6 design partners']; final normalizedNumberWords = validateDeckPlan( DeckPlanType.parse(data), @@ -561,6 +590,33 @@ void main() { ); }); + test('does not ground internal planning prose as audience-facing copy', () { + final data = _hierarchicalPlan(); + final slides = data['slides']! as List>; + slides.last + ..['purpose'] = 'Prioritize the 13 internal review checkpoints.' + ..['contentBrief'] = 'Designed to reduce review friction in 13 drafts.' + ..['continuity'] = 'Transition after the 13 internal checkpoints.'; + + final issues = validateDeckPlanIssues( + DeckPlanType.parse(data), + request: const DeckGenerationRequest( + userIntent: 'Close with a practical decision.', + slideCount: 10, + ), + ); + + expect( + issues.where( + (issue) => + issue.slideKey == 'close' && + (issue.code == GenerationValidationCode.numericGrounding || + issue.code == GenerationValidationCode.commitmentGrounding), + ), + isEmpty, + ); + }); + test('rejects an unsupported commercial commitment', () { final data = _hierarchicalPlan(); final slides = data['slides']! as List>; @@ -593,6 +649,34 @@ void main() { ); }); + test('reports broad editorial commitments without blocking the plan', () { + final data = _hierarchicalPlan(); + final slides = data['slides']! as List>; + slides.last['contentUnits'] = ['Prioritize evidence before commitments']; + + final issues = validateDeckPlanIssues( + DeckPlanType.parse(data), + request: const DeckGenerationRequest( + userIntent: 'Close with an evidence-led operating decision.', + slideCount: 10, + ), + ); + final commitmentIssues = issues + .where( + (issue) => + issue.code == GenerationValidationCode.commitmentGrounding && + issue.slideKey == 'close', + ) + .toList(); + + expect(commitmentIssues, hasLength(1)); + expect( + commitmentIssues.single.severity, + GenerationValidationSeverity.diagnostic, + ); + expect(commitmentIssues.where((issue) => issue.isBlocking), isEmpty); + }); + test('rejects invented availability, production, and delivery claims', () { final data = _hierarchicalPlan(); final slides = data['slides']! as List>; @@ -876,7 +960,7 @@ void main() { ); }); - test('grounds section transitions and slide continuity', () { + test('keeps internal narrative grounding non-blocking', () { final data = _hierarchicalPlan(); final sections = data['sections']! as List>; final slides = data['slides']! as List>; @@ -885,7 +969,7 @@ void main() { slides.last['continuity'] = 'The workflow was validated during the fictional beta.'; - final errors = validateDeckPlan( + final issues = validateDeckPlanIssues( DeckPlanType.parse(data), request: const DeckGenerationRequest( userIntent: 'Describe a fictional beta using only supplied facts.', @@ -893,13 +977,21 @@ void main() { ), ); + final narrativeIssue = issues.singleWhere( + (issue) => issue.message.startsWith('Deck narrative'), + ); expect( - errors.singleWhere((error) => error.startsWith('Deck narrative')), + narrativeIssue.message, allOf(contains('already proven'), contains('rigorous results')), ); + expect(narrativeIssue.severity, GenerationValidationSeverity.diagnostic); expect( - errors.singleWhere((error) => error.startsWith('Slide "close"')), - contains('validated during'), + issues.where( + (issue) => + issue.slideKey == 'close' && + issue.code == GenerationValidationCode.commitmentGrounding, + ), + isEmpty, ); }); @@ -937,7 +1029,7 @@ void main() { ); }); - test('rejects a supplied metric reused with a different meaning', () { + test('reports a supplied metric reused with a different meaning', () { final data = _hierarchicalPlan(); final slides = data['slides']! as List>; slides.last['contentUnits'] = ['Reclaiming 42% of the work week']; @@ -948,6 +1040,25 @@ void main() { slideCount: 10, ), ); + final changedMeaningIssues = validateDeckPlanIssues( + DeckPlanType.parse(data), + request: const DeckGenerationRequest( + userIntent: 'Teams spent 42% less weekly synthesis time.', + slideCount: 10, + ), + ).where((issue) => issue.code == GenerationValidationCode.numericMeaning); + expect(changedMeaningIssues, isNotEmpty); + expect( + changedMeaningIssues, + everyElement( + isA().having( + (issue) => issue.severity, + 'severity', + GenerationValidationSeverity.diagnostic, + ), + ), + ); + expect(changedMeaningIssues.where((issue) => issue.isBlocking), isEmpty); slides.last['contentUnits'] = ['42% less weekly synthesis time']; final preservedMeaning = validateDeckPlan( DeckPlanType.parse(data), diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart index a71312ad..34ddd982 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart @@ -11,6 +11,7 @@ import 'package:playground/features/ai/quick_agent/core/engine/services/deck_gen import 'package:playground/features/ai/quick_agent/core/engine/services/generation_model_client.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/generation_progress.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/generation_trace.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/generation_validation_issue.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/retry_policy.dart'; import 'package:superdeck_core/superdeck_core.dart'; @@ -58,6 +59,8 @@ void main() { ]); final service = DeckGeneratorService( apiKey: 'test-key', + maxOutlineValidationAttempts: 6, + maxRepairRequests: 5, modelClientFactory: (_) => client, ); @@ -111,6 +114,12 @@ void main() { expect(result.success, isTrue); expect(result.slides, hasLength(2)); expect(client.requests, hasLength(4)); + expect(client.requests.map((request) => request.model), [ + 'models/gemini-3.5-flash', + 'models/gemini-3.1-flash-lite', + 'models/gemini-3.1-flash-lite', + 'models/gemini-3.1-flash-lite', + ]); final repairPrompt = client.requests[1].systemInstruction!.parts.single.text!; expect(repairPrompt, contains('Deck plan has 1 slides')); @@ -132,6 +141,7 @@ void main() { ..._planSlide(key: 'opening'), 'contentUnits': ['Start your free trial today'], }; + invalidSlide.remove('elements'); final client = _FakeGenerationModelClient([ _jsonResponse({ 'topic': 'Local plan repair', @@ -178,6 +188,199 @@ void main() { ]); }); + test('repairs multiple invalid plan slides with targeted requests', () async { + final firstInvalid = { + ..._planSlide(key: 'opening'), + 'contentUnits': ['Start your free trial today'], + }; + final secondInvalid = { + ..._planSlide(key: 'closing'), + 'contentUnits': ['Join the waitlist today'], + }; + final client = _FakeGenerationModelClient([ + _jsonResponse({ + 'topic': 'Targeted plan repair', + 'story': 'Repair several plan slides without rewriting the deck.', + 'theme': _testThemeSelection, + 'slides': [firstInvalid, secondInvalid], + }), + _jsonResponse(_planSlide(key: 'opening')), + _jsonResponse(_planSlide(key: 'closing')), + _jsonResponse( + _generatedSlide(key: 'opening', title: 'Opening', style: 'content'), + ), + _jsonResponse( + _generatedSlide(key: 'closing', title: 'Closing', style: 'content'), + ), + ]); + final traces = []; + final service = DeckGeneratorService( + apiKey: 'test-key', + modelClientFactory: (_) => client, + ); + + final result = await service.generate( + _request('Create a grounded two-slide deck.', slideCount: 2), + onTrace: traces.add, + ); + + expect(result.success, isTrue); + expect(client.requests, hasLength(5)); + expect( + traces + .where( + (event) => + event.kind == GenerationTraceKind.request && + event.phase == GenerationTracePhase.outline, + ) + .map((event) => event.slideIndex), + [null, 1, 2], + ); + final openingRepairPrompt = + client.requests[1].systemInstruction!.parts.single.text!; + final closingRepairPrompt = + client.requests[2].systemInstruction!.parts.single.text!; + expect(openingRepairPrompt, contains('repair exactly one slide')); + expect(openingRepairPrompt, contains('Start your free trial today')); + expect(closingRepairPrompt, contains('repair exactly one slide')); + expect(closingRepairPrompt, contains('Join the waitlist today')); + }); + + test('repairs three invalid plan slides with one deck request', () async { + final invalidSlides = [ + { + ..._planSlide(key: 'opening'), + 'contentUnits': ['Start your free trial today'], + }, + { + ..._planSlide(key: 'evidence'), + 'contentUnits': ['Join the waitlist today'], + }, + { + ..._planSlide(key: 'closing'), + 'contentUnits': ['SOC2 compliance is included'], + }, + ]; + final validSlides = [ + _planSlide(key: 'opening'), + _planSlide(key: 'evidence'), + _planSlide(key: 'closing'), + ]; + final client = _FakeGenerationModelClient([ + _jsonResponse({ + 'topic': 'Bounded global repair', + 'story': 'Repair a broad invalid plan in one request.', + 'theme': _testThemeSelection, + 'slides': invalidSlides, + }), + _jsonResponse({ + 'topic': 'Bounded global repair', + 'story': 'Repair a broad invalid plan in one request.', + 'theme': _testThemeSelection, + 'slides': validSlides, + }), + for (final (index, slide) in validSlides.indexed) + _jsonResponse( + _generatedSlide( + key: slide['key']! as String, + title: 'Slide ${index + 1}', + style: 'content', + ), + ), + ]); + final traces = []; + final result = + await DeckGeneratorService( + apiKey: 'test-key', + modelClientFactory: (_) => client, + ).generate( + _request('Create a grounded three-slide deck.', slideCount: 3), + onTrace: traces.add, + ); + + expect(result.success, isTrue, reason: result.error); + expect(client.requests, hasLength(5)); + expect( + traces + .where( + (event) => + event.kind == GenerationTraceKind.request && + event.phase == GenerationTracePhase.outline, + ) + .map((event) => event.slideIndex), + [null, null], + ); + expect( + client.requests[1].systemInstruction!.parts.single.text, + startsWith('You repair one SuperDeck deck plan.'), + ); + }); + + test( + 'repairs multiple remaining slide issues after one deck-plan repair', + () async { + final invalidOpening = { + ..._planSlide(key: 'opening'), + 'contentUnits': ['Start your free trial today'], + 'treatment': 'data', + }; + final invalidClosing = { + ..._planSlide(key: 'closing'), + 'contentUnits': ['Join the waitlist today'], + }; + final client = _FakeGenerationModelClient([ + _jsonResponse({ + 'topic': 'Layered plan repair', + 'story': 'Repair the global shape, then the remaining local copy.', + 'theme': _testThemeSelection, + 'slides': [invalidOpening, invalidClosing], + }), + _jsonResponse({ + 'topic': 'Layered plan repair', + 'story': 'Repair the global shape, then the remaining local copy.', + 'theme': _testThemeSelection, + 'slides': [ + { + ..._planSlide(key: 'opening'), + 'contentUnits': ['Start your free trial today'], + }, + invalidClosing, + ], + }), + _jsonResponse(_planSlide(key: 'opening')), + _jsonResponse(_planSlide(key: 'closing')), + _jsonResponse( + _generatedSlide(key: 'opening', title: 'Opening', style: 'content'), + ), + _jsonResponse( + _generatedSlide(key: 'closing', title: 'Closing', style: 'content'), + ), + ]); + final service = DeckGeneratorService( + apiKey: 'test-key', + modelClientFactory: (_) => client, + ); + + final result = await service.generate( + _request('Create a grounded two-slide deck.', slideCount: 2), + ); + + expect(result.success, isTrue, reason: result.error); + expect(client.requests, hasLength(6)); + expect( + client.requests + .take(4) + .map((request) => request.systemInstruction!.parts.single.text!), + [ + isNot(contains('repair exactly one slide')), + startsWith('You repair one SuperDeck deck plan.'), + contains('repair exactly one slide'), + contains('repair exactly one slide'), + ], + ); + }, + ); + test('resolves the selected catalog theme without a model repair', () async { final client = _FakeGenerationModelClient([ _jsonResponse({ @@ -393,10 +596,10 @@ void main() { expect(result.success, isTrue); expect(client.requests, hasLength(2)); - expect( - client.requests.map((request) => request.model), - everyElement('models/gemini-3-flash-preview'), - ); + expect(client.requests.map((request) => request.model), [ + 'models/gemini-3.5-flash', + 'models/gemini-3.1-flash-lite', + ]); final outlinePrompt = client.requests.first.systemInstruction!.parts.single.text; expect(outlinePrompt, contains('narrativeRole')); @@ -411,7 +614,8 @@ void main() { expect(slidePrompt, contains('## Recent design ledger')); expect(slidePrompt, isNot(contains('example.com'))); expect(slidePrompt, contains('## Available elements')); - expect(slidePrompt, contains('`image`: A supplied image asset')); + expect(slidePrompt, contains('None planned for this slide.')); + expect(slidePrompt, isNot(contains('`image`: A supplied image asset'))); final slideSchema = client .requests .last @@ -432,8 +636,14 @@ void main() { client.requests.map( (request) => request.generationConfig!.thinkingConfig, ), - everyElement(isNull), - reason: 'Deck generation must keep explicit thinking disabled.', + everyElement( + isA().having( + (config) => config.thinkingBudget, + 'thinking budget', + 0, + ), + ), + reason: 'Deck generation must explicitly disable thinking.', ); expect(client.isClosed, isTrue); final requests = traces @@ -451,6 +661,82 @@ void main() { ); }); + test( + 'composes narrative sections concurrently with one request each', + () async { + final client = _ConcurrentSectionModelClient(); + final progress = []; + final service = DeckGeneratorService( + apiKey: 'test-key', + sectionBatchThreshold: 4, + modelClientFactory: (_) => client, + ); + + final generation = service.generate( + _request('Create a four-slide story.', slideCount: 4), + onProgress: progress.add, + ); + + await client.bothSectionsStarted.future.timeout( + const Duration(seconds: 1), + onTimeout: () => throw TestFailure( + 'The second narrative section did not start while the first was pending.', + ), + ); + client.completeSections(); + final result = await generation; + + expect(result.success, isTrue, reason: result.error); + expect(result.slides.map((slide) => slide.key), [ + 'opening', + 'problem', + 'solution', + 'closing', + ]); + expect(client.requests, hasLength(3)); + expect(client.requests.map((request) => request.model), [ + 'models/gemini-3.5-flash', + 'models/gemini-3.1-flash-lite', + 'models/gemini-3.1-flash-lite', + ]); + expect( + progress + .where((event) => event.sectionIndex != null) + .map((event) => event.sectionIndex), + containsAll([1, 2]), + ); + }, + ); + + test('keeps valid slides when one section response omits a slide', () async { + final client = _ConcurrentSectionModelClient(omitProblem: true); + final service = DeckGeneratorService( + apiKey: 'test-key', + sectionBatchThreshold: 4, + modelClientFactory: (_) => client, + ); + + final generation = service.generate( + _request('Create a four-slide story.', slideCount: 4), + ); + await client.bothSectionsStarted.future; + client.completeSections(); + final result = await generation; + + expect(result.success, isFalse); + expect(result.isPartial, isTrue); + expect(result.slides.map((slide) => slide.key), [ + 'opening', + 'solution', + 'closing', + ]); + expect(result.slideFailures, hasLength(1)); + expect(result.slideFailures.single.slideKey, 'problem'); + expect(result.slideFailures.single.retryable, isTrue); + expect(result.slideFailures.single.message, contains('omitted slide')); + expect(client.requests, hasLength(3)); + }); + test('stops after an outline with duplicate slide keys', () async { final client = _FakeGenerationModelClient([ for (final story in const [ @@ -466,6 +752,8 @@ void main() { final traces = []; final service = DeckGeneratorService( apiKey: 'test-key', + maxOutlineValidationAttempts: 6, + maxRepairRequests: 5, modelClientFactory: (_) => client, ); @@ -565,9 +853,9 @@ void main() { expect(secondSlidePrompt, contains('closing')); final repairPrompt = client.requests[3].systemInstruction!.parts.single.text!; - expect(repairPrompt, contains('validation constraints')); + expect(repairPrompt, contains('## Blocking issues')); expect(repairPrompt, contains('at least one usable section')); - expect(repairPrompt, contains('Invalid slide draft to repair')); + expect(repairPrompt, contains('## Invalid draft')); expect(repairPrompt, contains('"key": "evidence"')); expect( progress.map((event) => event.label), @@ -581,35 +869,74 @@ void main() { }, ); - test('allows one final targeted semantic repair before aborting', () async { + test('keeps accepted slides and continues after one slide fails', () async { final client = _FakeGenerationModelClient([ _jsonResponse({ - 'topic': 'Bounded repair', - 'story': 'Repair one display heading without restarting the deck.', + 'topic': 'Partial flow', + 'story': 'Preserve useful work when one slide cannot be repaired.', 'theme': _testThemeSelection, - 'slides': [_planSlide(key: 'evidence')], + 'slides': [ + _planSlide(key: 'opening', composition: 'title'), + _planSlide(key: 'evidence'), + _planSlide(key: 'closing', composition: 'titleLeft'), + ], }), _jsonResponse( - _generatedSlide( - key: 'evidence', - title: 'Evidence', - style: 'content', - content: - '## This heading still contains far too many words to fit\n\n' - 'A concrete supporting point.', - ), + _generatedSlide(key: 'opening', title: 'Start here', style: 'hero'), ), _jsonResponse({'key': 'evidence', 'sections': []}), + _jsonResponse({'key': 'evidence', 'sections': []}), _jsonResponse( - _generatedSlide( - key: 'evidence', - title: 'Evidence', - style: 'content', - content: - '## This third heading is still much too long to display\n\n' - 'A concrete supporting point.', + _generatedSlide(key: 'closing', title: 'Take action', style: 'closing'), + ), + ]); + final service = DeckGeneratorService( + apiKey: 'test-key', + maxSlideValidationAttempts: 2, + modelClientFactory: (_) => client, + ); + + final result = await service.generate( + _request('Create a resilient deck.', slideCount: 3), + ); + + expect(result.success, isFalse); + expect(result.isPartial, isTrue); + expect(result.slides.map((slide) => slide.key), ['opening', 'closing']); + expect(result.slideFailures, hasLength(1)); + expect(result.slideFailures.single.slideIndex, 2); + expect(result.slideFailures.single.slideKey, 'evidence'); + expect(result.slideFailures.single.retryable, isTrue); + expect( + result.slideFailures.single.issues, + everyElement( + isA().having( + (issue) => issue.isBlocking, + 'isBlocking', + isTrue, ), ), + ); + expect(result.plan?.slides, hasLength(3)); + expect(result.theme, isNotNull); + expect(result.error, allOf(contains('evidence'), contains('2 of 3'))); + expect(client.requests, hasLength(5)); + expect( + client.requests.last.systemInstruction!.parts.single.text, + contains('Start here'), + reason: 'Later slides should use the most recent accepted slide context.', + ); + }); + + test('uses one targeted semantic repair per slide by default', () async { + final client = _FakeGenerationModelClient([ + _jsonResponse({ + 'topic': 'Bounded repair', + 'story': 'Repair one display heading without restarting the deck.', + 'theme': _testThemeSelection, + 'slides': [_planSlide(key: 'evidence')], + }), + _jsonResponse({'key': 'evidence', 'sections': []}), _jsonResponse( _generatedSlide( key: 'evidence', @@ -631,7 +958,7 @@ void main() { ); expect(result.success, isTrue); - expect(client.requests, hasLength(5)); + expect(client.requests, hasLength(3)); expect( traces .where( @@ -640,13 +967,80 @@ void main() { event.phase == GenerationTracePhase.slide, ) .map((event) => event.attempt), - [1, 2, 3, 4], + [1, 2], ); final finalRepairPrompt = - client.requests[4].systemInstruction!.parts.single.text!; - expect(finalRepairPrompt, contains('use at most 8')); + client.requests[2].systemInstruction!.parts.single.text!; expect(finalRepairPrompt, contains('at least one usable section')); - expect(finalRepairPrompt, contains('This list is cumulative')); + expect(finalRepairPrompt, contains('## Blocking issues')); + }); + + test('caps slide repairs across the deck and keeps composing', () async { + final client = _FakeGenerationModelClient([ + _jsonResponse({ + 'topic': 'Repair budget', + 'story': 'Spend a bounded number of repairs across the whole deck.', + 'theme': _testThemeSelection, + 'slides': [ + _planSlide(key: 'one'), + _planSlide(key: 'two'), + _planSlide(key: 'three'), + _planSlide(key: 'four'), + ], + }), + _jsonResponse({'key': 'one', 'sections': []}), + _jsonResponse( + _generatedSlide(key: 'one', title: 'Alpha', style: 'content'), + ), + _jsonResponse({'key': 'two', 'sections': []}), + _jsonResponse( + _generatedSlide(key: 'two', title: 'Beta', style: 'content'), + ), + _jsonResponse({'key': 'three', 'sections': []}), + _jsonResponse( + _generatedSlide(key: 'four', title: 'Delta', style: 'content'), + ), + ]); + final service = DeckGeneratorService( + apiKey: 'test-key', + maxRepairRequests: 2, + modelClientFactory: (_) => client, + ); + + final result = await service.generate( + _request('Create a bounded four-slide deck.', slideCount: 4), + ); + + expect(result.isPartial, isTrue); + expect(result.slides.map((slide) => slide.key), ['one', 'two', 'four']); + expect(result.slideFailures.single.slideKey, 'three'); + expect(client.requests, hasLength(7)); + }); + + test('continues after an isolated slide transport failure', () async { + final client = _PartiallyFailingSlideModelClient(); + final service = DeckGeneratorService( + apiKey: 'test-key', + retryPolicy: RetryPolicy(maxAttempts: 2, baseDelay: Duration.zero), + modelClientFactory: (_) => client, + ); + + final result = await service.generate( + _request('Create a resilient three-slide deck.', slideCount: 3), + ); + + expect(result.isPartial, isTrue); + expect(result.slides.map((slide) => slide.key), ['opening', 'closing']); + expect(result.slideFailures.single.slideKey, 'evidence'); + expect( + result.slideFailures.single.issues.single.code, + GenerationValidationCode.invalidResponse, + ); + expect(client.requests, hasLength(5)); + expect( + client.requests.last.systemInstruction!.parts.single.text, + contains('Opening'), + ); }); test('stops between slides when generation is cancelled', () async { @@ -881,7 +1275,9 @@ void main() { ); expect(result.success, isFalse); + expect(result.isPartial, isTrue); expect(result.error, contains('Connection issue')); + expect(result.slideFailures.single.slideKey, 'intro'); expect(client.requestCount, 3); }); } @@ -893,13 +1289,14 @@ DeckGenerationRequest _request(String userIntent, {required int slideCount}) => Map _planSlide({ required String key, + String sectionKey = 'main', String composition = 'content', List> elements = const [], }) => { 'key': key, 'title': 'Test title', 'purpose': 'Introduce the test topic', - 'sectionKey': 'main', + 'sectionKey': sectionKey, 'assertion': 'The test topic matters now.', 'contentUnits': ['One concrete supporting point'], 'narrativeRole': 'insight', @@ -1020,6 +1417,88 @@ final class _FakeGenerationModelClient implements GenerationModelClient { } } +final class _ConcurrentSectionModelClient implements GenerationModelClient { + _ConcurrentSectionModelClient({this.omitProblem = false}); + + final bool omitProblem; + final requests = []; + final bothSectionsStarted = Completer(); + final _sectionResponses = + >{ + 'section-a': Completer(), + 'section-b': Completer(), + }; + final _startedSections = {}; + + @override + void close() {} + + @override + Future generateContent( + google_ai.GenerateContentRequest request, + ) { + requests.add(request); + if (requests.length == 1) { + return Future.value( + _jsonResponse({ + 'topic': 'Concurrent story', + 'story': 'Move from problem to solution.', + 'theme': _testThemeSelection, + 'sections': [ + { + 'key': 'section-a', + 'title': 'Problem', + 'purpose': 'Frame the need.', + 'transition': 'Hand the need to the solution.', + 'slideKeys': ['opening', 'problem'], + }, + { + 'key': 'section-b', + 'title': 'Solution', + 'purpose': 'Resolve the need.', + 'transition': 'Close with a clear takeaway.', + 'slideKeys': ['solution', 'closing'], + }, + ], + 'slides': [ + _planSlide(key: 'opening', sectionKey: 'section-a'), + _planSlide(key: 'problem', sectionKey: 'section-a'), + _planSlide(key: 'solution', sectionKey: 'section-b'), + _planSlide(key: 'closing', sectionKey: 'section-b'), + ], + }), + ); + } + + final sectionKey = requests.length == 2 ? 'section-a' : 'section-b'; + _startedSections.add(sectionKey); + if (_startedSections.length == 2 && !bothSectionsStarted.isCompleted) { + bothSectionsStarted.complete(); + } + return _sectionResponses[sectionKey]!.future; + } + + void completeSections() { + _sectionResponses['section-a']!.complete( + _jsonResponse({ + 'slides': [ + _generatedSlide(key: 'opening', title: 'Opening', style: 'content'), + if (!omitProblem) + _generatedSlide(key: 'problem', title: 'Problem', style: 'content'), + ], + }), + ); + _sectionResponses['section-b']!.complete( + _jsonResponse({ + 'slides': [ + _generatedSlide(key: 'solution', title: 'Solution', style: 'content'), + _generatedSlide(key: 'closing', title: 'Closing', style: 'content'), + ], + }), + ); + } +} + final class _HangingSlideModelClient implements GenerationModelClient { var requestCount = 0; @@ -1073,6 +1552,39 @@ final class _RetryingSlideModelClient implements GenerationModelClient { } } +final class _PartiallyFailingSlideModelClient implements GenerationModelClient { + final requests = []; + + @override + void close() {} + + @override + Future generateContent( + google_ai.GenerateContentRequest request, + ) async { + requests.add(request); + return switch (requests.length) { + 1 => _jsonResponse({ + 'topic': 'Transport recovery', + 'story': 'Continue after one isolated request failure.', + 'theme': _testThemeSelection, + 'slides': [ + _planSlide(key: 'opening'), + _planSlide(key: 'evidence'), + _planSlide(key: 'closing'), + ], + }), + 2 => _jsonResponse( + _generatedSlide(key: 'opening', title: 'Opening', style: 'content'), + ), + 3 || 4 => throw TimeoutException('Transient evidence timeout.'), + _ => _jsonResponse( + _generatedSlide(key: 'closing', title: 'Closing', style: 'content'), + ), + }; + } +} + final class _RepeatingInvalidOutlineClient implements GenerationModelClient { var requestCount = 0; diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/services/generated_slide_validator_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/services/generated_slide_validator_test.dart index 495b625b..02dc1016 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/services/generated_slide_validator_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/services/generated_slide_validator_test.dart @@ -288,8 +288,26 @@ void main() { ); }); + test('keeps a moderate density overage as a quality diagnostic', () { + final issues = validateGeneratedSlideIssues( + expectedKey: 'test-slide', + rawSlide: _slideWithBlock({ + 'type': 'block', + 'content': '# Opening\n\n${List.filled(38, 'specific').join(' ')}', + }), + planSlide: _planSlide(composition: 'title', density: 'spacious'), + elementCatalog: GenerationElementCatalog.builtIn(), + ); + final densityIssue = issues.singleWhere( + (issue) => issue.message.contains('content budget'), + ); + + expect(densityIssue.severity, GenerationValidationSeverity.diagnostic); + expect(densityIssue.isBlocking, isFalse); + }); + test('rejects a display heading that is too long for a title row', () { - final errors = validateGeneratedSlide( + final issues = validateGeneratedSlideIssues( expectedKey: 'test-slide', rawSlide: _slideWithBlock({ 'type': 'block', @@ -300,6 +318,7 @@ void main() { planSlide: _planSlide(composition: 'content'), elementCatalog: GenerationElementCatalog.builtIn(), ); + final errors = issues.map((issue) => issue.message).toList(); expect( errors, @@ -308,6 +327,34 @@ void main() { 'yesterday’s problems" has 9 words; use at most 8.', ), ); + expect( + issues.singleWhere((issue) => issue.message.contains('has 9 words')), + isA().having( + (issue) => issue.severity, + 'severity', + GenerationValidationSeverity.diagnostic, + ), + ); + }); + + test('does not treat one-time editorial copy as a numeric claim', () { + final issues = validateGeneratedSlideIssues( + expectedKey: 'test-slide', + rawSlide: _slideWithBlock({ + 'type': 'block', + 'content': + '## Continuous learning\n\nDiscovery is not a one-time event.', + }), + planSlide: _planSlide(composition: 'content'), + elementCatalog: GenerationElementCatalog.builtIn(), + request: const DeckGenerationRequest( + userIntent: 'Explain continuous product discovery.', + slideCount: 1, + ), + ); + + expect(issues.where((issue) => issue.code == .numericGrounding), isEmpty); + expect(issues.where((issue) => issue.code == .numericMeaning), isEmpty); }); test('reserves H1 for display treatments and numeric metrics', () { @@ -357,6 +404,49 @@ void main() { ); }); + test('mechanically applies the exact planned treatment', () { + final raw = _slideWithBlock({ + 'type': 'block', + 'content': '## Planned section statement', + }); + raw['options'] = {'title': 'Planned section', 'style': 'theme-id'}; + + final normalized = normalizeGeneratedSlideForPlan( + rawSlide: raw, + planSlide: _planSlide(composition: 'titleLeft', treatment: 'section'), + ); + + expect(normalized['options'], { + 'title': 'Planned section', + 'style': 'section', + }); + expect(raw['options'], containsPair('style', 'theme-id')); + }); + + test('mechanically flattens list markers in a title composition', () { + final raw = _slideWithBlock({ + 'type': 'block', + 'content': + '# The Roadmap Theater\n\n' + '- **Scope:** 12-month horizon\n' + '- **Load:** 43 promised initiatives', + }); + + final normalized = normalizeGeneratedSlideForPlan( + rawSlide: raw, + planSlide: _planSlide(composition: 'title'), + ); + final content = + ((((normalized['sections'] as List).single as Map)['blocks'] as List) + .single + as Map)['content'] + as String; + + expect(content, contains('**Scope:** 12-month horizon')); + expect(content, isNot(contains('- **Scope:**'))); + expect(content, isNot(contains('- **Load:**'))); + }); + test('anchors implicit title and body rows in a vertical composition', () { final raw = { 'key': 'test-slide', @@ -615,9 +705,32 @@ void main() { contains( 'Visible content uses numeric claim(s) 100%, 90 that are not present ' 'in userIntent. Remove them or label the containing copy as a ' - 'projection, estimate, assumption, calculation, or scenario.', + 'projection, estimate, assumption, calculation, scenario, or planned ' + 'target.', ), ); + final numericIssues = validateGeneratedSlideIssues( + expectedKey: 'test-slide', + rawSlide: _slideWithBlock({ + 'type': 'block', + 'content': '## Beta proof\n\n100% retention over 90 days.', + }), + planSlide: _planSlide(composition: 'content'), + elementCatalog: GenerationElementCatalog.builtIn(), + request: request, + ).where((issue) => issue.code == GenerationValidationCode.numericGrounding); + expect(numericIssues, isNotEmpty); + expect( + numericIssues, + everyElement( + isA().having( + (issue) => issue.severity, + 'severity', + GenerationValidationSeverity.diagnostic, + ), + ), + ); + expect(numericIssues.where((issue) => issue.isBlocking), isEmpty); expect( allowed.where( (error) => error.contains('Visible content uses numeric claim(s)'), @@ -758,6 +871,20 @@ void main() { slideCount: 1, ), ); + final planComplianceIssues = validateGeneratedSlideIssues( + expectedKey: 'test-slide', + rawSlide: _slideWithBlock({ + 'type': 'block', + 'content': + '## Planning drift\n\nStatic roadmaps reward plan compliance over evidence.', + }), + planSlide: _planSlide(composition: 'content'), + elementCatalog: GenerationElementCatalog.builtIn(), + request: const DeckGenerationRequest( + userIntent: 'Explain why static roadmaps drift from evidence.', + slideCount: 1, + ), + ); expect( errors, @@ -791,6 +918,12 @@ void main() { complianceErrors.singleWhere((error) => error.contains('soc2')), allOf(contains('unsupported commitment claim(s)'), contains('soc2')), ); + expect( + planComplianceIssues + .where((issue) => issue.code == .commitmentGrounding) + .every((issue) => !issue.isBlocking), + isTrue, + ); }); test('grounds speaker comments as strictly as visible content', () { @@ -939,7 +1072,7 @@ void main() { ); }); - test('rejects a supplied metric reused with a different meaning', () { + test('reports a supplied metric reused with a different meaning', () { const request = DeckGenerationRequest( userIntent: 'Teams spent 42% less weekly synthesis time.', slideCount: 1, @@ -954,6 +1087,28 @@ void main() { elementCatalog: GenerationElementCatalog.builtIn(), request: request, ); + final changedMeaningIssues = validateGeneratedSlideIssues( + expectedKey: 'test-slide', + rawSlide: _slideWithBlock({ + 'type': 'block', + 'content': '## Reclaiming 42% of the work week', + }), + planSlide: _planSlide(composition: 'content'), + elementCatalog: GenerationElementCatalog.builtIn(), + request: request, + ).where((issue) => issue.code == GenerationValidationCode.numericMeaning); + expect(changedMeaningIssues, isNotEmpty); + expect( + changedMeaningIssues, + everyElement( + isA().having( + (issue) => issue.severity, + 'severity', + GenerationValidationSeverity.diagnostic, + ), + ), + ); + expect(changedMeaningIssues.where((issue) => issue.isBlocking), isEmpty); final inventedCausalMeaning = validateGeneratedSlide( expectedKey: 'test-slide', rawSlide: _slideWithBlock({ @@ -1184,6 +1339,58 @@ void main() { ); }); + test('uses supporting context elsewhere on the same metric slide', () { + final issues = validateGeneratedSlideIssues( + expectedKey: 'test-slide', + rawSlide: _slideWithBlock({ + 'type': 'block', + 'content': '# 31%\n\n## Shipped features reach their adoption target', + }), + planSlide: _planSlide(composition: 'metric', treatment: 'data'), + elementCatalog: GenerationElementCatalog.builtIn(), + request: const DeckGenerationRequest( + userIntent: 'Only 31% of shipped features reach their adoption target.', + slideCount: 1, + ), + ); + + expect( + issues.where( + (issue) => issue.code == GenerationValidationCode.numericMeaning, + ), + isEmpty, + ); + }); + + test('keeps broad editorial commitments diagnostic', () { + final issues = validateGeneratedSlideIssues( + expectedKey: 'test-slide', + rawSlide: _slideWithBlock({ + 'type': 'block', + 'content': + '## Prioritize evidence\n\nReview decisions against concrete signals.', + }), + planSlide: _planSlide(composition: 'content'), + elementCatalog: GenerationElementCatalog.builtIn(), + request: const DeckGenerationRequest( + userIntent: 'Explain an evidence-led decision process.', + slideCount: 1, + ), + ); + final commitmentIssues = issues + .where( + (issue) => issue.code == GenerationValidationCode.commitmentGrounding, + ) + .toList(); + + expect(commitmentIssues, hasLength(1)); + expect( + commitmentIssues.single.severity, + GenerationValidationSeverity.diagnostic, + ); + expect(commitmentIssues.where((issue) => issue.isBlocking), isEmpty); + }); + test( 'rejects unqualified implementation details and invented pricing tiers', () { diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/services/generation_quality_report_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/services/generation_quality_report_test.dart index f2471a55..82d7e411 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/services/generation_quality_report_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/services/generation_quality_report_test.dart @@ -3,6 +3,7 @@ import 'package:playground/features/ai/quick_agent/core/engine/schemas/outline_s import 'package:playground/features/ai/quick_agent/core/engine/services/deck_generation_request.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/generation_quality_report.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/generation_trace.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/generation_validation_issue.dart'; import 'package:superdeck_core/superdeck_core.dart'; void main() { @@ -94,6 +95,49 @@ void main() { .slideKey, 'evidence', ); + expect( + report.issues + .where((issue) => issue.rule == 'slide.content_density') + .single + .severity, + GenerationValidationSeverity.diagnostic, + ); + expect( + report.issues + .where((issue) => issue.rule == 'count.captured') + .single + .severity, + GenerationValidationSeverity.blocking, + ); + }); + + test('does not fail the quality gate for design rhythm diagnostics', () { + final report = GenerationQualityReport.evaluate( + request: const DeckGenerationRequest( + userIntent: 'Explain a four-step decision.', + slideCount: 4, + ), + plan: _rhythmPlan(), + slides: _rhythmSlides(), + traces: const [], + replayedSlideCount: 4, + capturedSlideCount: 4, + resolvedFontFamilies: const {'Playfair Display', 'Inter'}, + ); + + final diagnostics = report.issues + .where( + (issue) => issue.sourceCode == GenerationValidationCode.designRhythm, + ) + .toList(); + expect(diagnostics, isNotEmpty); + expect( + diagnostics.every( + (issue) => issue.severity == GenerationValidationSeverity.diagnostic, + ), + isTrue, + ); + expect(report.passed, isTrue); }); test('separates outline and slide repair requests', () { @@ -235,3 +279,36 @@ List _traces() => [ elapsed: Duration(milliseconds: 80), ), ]; + +DeckPlanType _rhythmPlan() => DeckPlanType.parse({ + 'topic': 'Decision rhythm', + 'story': 'Move through four clear steps.', + 'theme': {'id': 'editorial-midnight', 'version': 1, 'density': 'spacious'}, + 'sections': [ + { + 'key': 'decision', + 'title': 'Decision', + 'purpose': 'Explain the decision.', + 'transition': 'Land on the recommendation.', + 'slideKeys': ['step-1', 'step-2', 'step-3', 'step-4'], + }, + ], + 'slides': [ + for (var index = 1; index <= 4; index++) + _planSlide( + key: 'step-$index', + composition: 'content', + treatment: 'content', + density: 'spacious', + ), + ], +}); + +List _rhythmSlides() => [ + for (var index = 1; index <= 4; index++) + Slide( + key: 'step-$index', + options: SlideOptions(style: 'content'), + sections: [SectionBlock.text('## Step $index\n\nA concrete point.')], + ), +]; diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/services/source_grounding_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/services/source_grounding_test.dart index 5eb4047e..662abdee 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/services/source_grounding_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/services/source_grounding_test.dart @@ -32,6 +32,91 @@ void main() { expect(mismatches, isEmpty); }); + test('accepts a grounded duration with one editorial anchor paraphrased', () { + final mismatches = findNumericContextMismatches( + values: const ['The 90-day operational path'], + userIntent: 'Build a practical 90-day adoption path.', + ); + + expect(mismatches, isEmpty); + }); + + test('accepts an adoption horizon restated as a transition plan', () { + final mismatches = findNumericContextMismatches( + values: const ['Authorize the 90-day transition plan'], + userIntent: 'Build a practical 90-day adoption path.', + ); + + expect(mismatches, isEmpty); + }); + + test('rejects a grounded duration reused for an unrelated subject', () { + final mismatches = findNumericContextMismatches( + values: const ['A 90-day payment window'], + userIntent: 'Build a practical 90-day adoption path.', + ); + + expect(mismatches, {'90'}); + }); + + test('treats pilot and phase counts as structural plan details', () { + const intent = + 'Use six product teams, roughly 180 engineers, and only 31% of shipped ' + 'features reaching their adoption target. Build a practical 90-day ' + 'adoption path. Include one useful comparison and an executive close.'; + final mismatches = findNumericContextMismatches( + values: const [ + 'Phase 1: Launch with one pilot product team.', + 'Describe the 90-day rollout, beginning with one pilot team before ' + 'transitioning all six product teams and 180 engineers.', + ], + userIntent: intent, + allowSlideContextFallback: true, + ); + + expect(mismatches, isEmpty); + }); + + test('does not reuse a requested comparison count as a pilot fact', () { + const intent = + 'Create a realistic strategy presentation about replacing quarterly ' + 'roadmap theater with a continuous product operating system. The deck ' + 'should help senior product and engineering leaders see why annual ' + 'commitments become stale, how small outcome teams can work from ' + 'evidence, and what changes in planning, funding, discovery, delivery, ' + 'and review. Use a credible fictional company with concrete details: ' + 'six product teams, roughly 180 engineers, a twelve-month roadmap with ' + '43 promised initiatives, and only 31% of shipped features reaching ' + 'their adoption target. Build a clear three-act narrative: the cost ' + 'of the old model, the operating-system shift, and a practical 90-day ' + 'adoption path. Include a few memorable metrics, one useful comparison, ' + 'and an executive close with a specific decision.'; + final mismatches = findNumericContextMismatches( + values: const [ + 'The 90-Day Transition', + 'We will transition our six product teams and 180 engineers in three ' + 'managed phases over ninety days.', + 'Phase 1: Launch continuous discovery and outcome metrics with one ' + 'pilot product team to build our internal blueprint.', + 'Phase 2: Standardize planning and prepare the remaining teams.', + 'Phase 3: Onboard all six product teams and 180 engineers.', + ], + userIntent: intent, + allowSlideContextFallback: true, + ); + + expect(mismatches, isEmpty); + }); + + test('treats requested comparison and decision counts as structural', () { + expect( + extractGroundedNumericClaims([ + 'Include one useful comparison and one specific decision.', + ]), + isEmpty, + ); + }); + test('rejects rounded values that were not supplied', () { final unsupported = findUnsupportedNumericClaims( values: const ['4 million loyalty members and 3% conversion'], @@ -40,4 +125,39 @@ void main() { expect(unsupported, {'4', '3%'}); }); + + test('ignores ordered-list markers when checking numeric meaning', () { + final mismatches = findNumericContextMismatches( + values: const [ + '1. Generate a hypothesis.\n' + '2. Test it with evidence.\n' + '3. Decide what changes.', + ], + userIntent: 'Describe an evidence loop.', + ); + + expect(mismatches, isEmpty); + }); + + test('does not mistake a hyphenated team adjective for a pricing tier', () { + final unsupported = findUnsupportedCommitmentPhrases( + values: const [ + '| Traditional | Continuous |\n' + '| --- | --- |\n' + '| Project Funding | Team-based Funding |', + ], + userIntent: 'Compare funding models.', + ); + + expect(unsupported, isNot(contains('pricing tier "team"'))); + }); + + test('does not mistake roadmap compliance for a compliance claim', () { + final unsupported = findUnsupportedCommitmentPhrases( + values: const ['Roadmap compliance can hide weak product adoption.'], + userIntent: 'Compare roadmap output with product adoption.', + ); + + expect(unsupported, isNot(contains('compliance'))); + }); } diff --git a/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart b/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart index c1af513a..bb25b02d 100644 --- a/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart +++ b/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart @@ -27,6 +27,7 @@ import 'package:playground/features/ai/quick_agent/core/engine/services/generati import 'package:playground/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/generation_quality_report.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/generation_trace.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/generation_validation_issue.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/theme_json_serializer.dart'; import 'package:playground/features/ai/quick_agent/domain/generated_deck_style_mapper.dart'; import 'package:superdeck/src/utils/syntax_highlighter.dart'; @@ -170,46 +171,49 @@ void main() { expect(result.plan!.slides, hasLength(10)); expect(result.plan!.theme.id, 'technical-paper'); expect(result.plan!.theme.version, 1); - expect(client.requests, hasLength(11)); + expect(client.requests, hasLength(4)); expect( client.requests.map( - (modelRequest) => modelRequest.generationConfig!.thinkingConfig, + (modelRequest) => + modelRequest.generationConfig!.thinkingConfig!.thinkingBudget, ), - everyElement(isNull), + everyElement(0), ); + expect(client.requests.map((modelRequest) => modelRequest.model), [ + 'models/gemini-3.5-flash', + 'models/gemini-3.1-flash-lite', + 'models/gemini-3.1-flash-lite', + 'models/gemini-3.1-flash-lite', + ]); - final slideRequests = traces + final sectionRequests = traces .where( (event) => event.kind == GenerationTraceKind.request && event.phase == GenerationTracePhase.slide, ) .toList(growable: false); - expect(slideRequests, hasLength(10)); - final ledgers = >>[]; - for (final (index, event) in slideRequests.indexed) { - final ledger = _parseDesignLedger(event.prompt!); - ledgers.add(ledger); - final expectedStart = index > 3 ? index - 3 : 0; + expect(sectionRequests, hasLength(3)); + expect(sectionRequests.map((event) => event.slideIndex), [1, 4, 8]); + const expectedSectionKeys = [ + ['opening', 'signals', 'synthesis'], + ['choices', 'alignment', 'practice', 'principle'], + ['verification', 'action', 'close'], + ]; + final sectionPlanKeys = [ + for (final event in sectionRequests) + _parseSectionPlanKeys(event.prompt!), + ]; + for (final (index, event) in sectionRequests.indexed) { expect( - ledger.map((entry) => entry['key']), - result.plan!.slides - .sublist(expectedStart, index) - .map((slide) => slide.key), - reason: 'slide ${index + 1} design ledger', + sectionPlanKeys[index], + expectedSectionKeys[index], + reason: 'section ${index + 1} ordered plan', ); expect( - ledger.every( - (entry) => entry.keys.toSet().difference(const { - 'key', - 'sectionKey', - 'composition', - 'treatment', - 'density', - }).isEmpty, - ), - isTrue, - reason: 'slide ${index + 1} compact design ledger', + event.prompt, + contains('## Canonical shape examples'), + reason: 'section ${index + 1} canonical examples', ); } @@ -277,7 +281,7 @@ void main() { const encoder = JsonEncoder.withIndent(' '); await File(p.join(output.path, 'checkpoint.json')).writeAsString( encoder.convert({ - 'designLedgers': ledgers, + 'sectionPlanKeys': sectionPlanKeys, 'resolvedStyle': styleSnapshot, 'qualityReport': report.toJson(), 'contactSheet': p.basename(contactSheet.path), @@ -350,15 +354,20 @@ void main() { if (jsonEncode(artifact.themeReference) != jsonEncode(serializeDeckThemeReference(artifact.plan.theme))) 'deck.json theme does not match deck_plan.json theme.', - ...validateDeckPlan(artifact.plan, request: artifact.request), + ...validateDeckPlanIssues( + artifact.plan, + request: artifact.request, + ).blockingIssues.map((issue) => issue.message), for (final (index, rawSlide) in artifact.rawSlides.indexed) - ...validateGeneratedSlide( + ...validateGeneratedSlideIssues( expectedKey: artifact.plan.slides[index].key, rawSlide: rawSlide, planSlide: artifact.plan.slides[index], request: artifact.request, elementCatalog: GenerationElementCatalog.builtIn(), - ).map((error) => 'Slide ${index + 1}: $error'), + ).blockingIssues.map( + (issue) => 'Slide ${index + 1}: ${issue.message}', + ), ]; expect( auditErrors, @@ -727,23 +736,22 @@ Future _writeTraceArtifacts( ); } -List> _parseDesignLedger(String prompt) { - const startMarker = '## Recent design ledger\n'; - const endMarker = '\n\n## Current slide plan'; +List _parseSectionPlanKeys(String prompt) { + const startMarker = '## Ordered slide plans\n'; + const endMarker = '\n\n## Boundary context'; final start = prompt.indexOf(startMarker); final end = prompt.indexOf(endMarker, start + startMarker.length); if (start < 0 || end < 0) { - throw FormatException('Slide prompt does not contain a design ledger.'); + throw FormatException('Section prompt does not contain ordered plans.'); } final payload = prompt.substring(start + startMarker.length, end).trim(); - if (payload.startsWith('None')) return const []; final decoded = jsonDecode(payload); if (decoded is! List) { - throw FormatException('Design ledger is not a JSON list.'); + throw FormatException('Ordered section plans are not a JSON list.'); } return [ for (final entry in decoded) - if (entry is Map) Map.from(entry), + if (entry is Map && entry['key'] is String) entry['key']! as String, ]; } @@ -1084,7 +1092,8 @@ final class _FakeCheckpointModelClient implements GenerationModelClient { _FakeCheckpointModelClient() : _responses = [ _checkpointResponse(_checkpointPlanDraft()), - for (final slide in _checkpointSlides()) _checkpointResponse(slide), + for (final slides in _checkpointSectionSlides()) + _checkpointResponse({'slides': slides}), ]; final List _responses; @@ -1354,6 +1363,11 @@ List> _checkpointSlides() => [ ), ]; +List>> _checkpointSectionSlides() { + final slides = _checkpointSlides(); + return [slides.sublist(0, 3), slides.sublist(3, 7), slides.sublist(7)]; +} + Map _checkpointSlide({ required String key, required String title, From 6ea58c8391c3c2c562ec557ec72442eb4a9c4d07 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 15 Jul 2026 07:06:21 -0400 Subject: [PATCH 02/15] feat: add debug layout overlays for playground captures --- packages/playground/README.md | 9 + packages/playground/lib/app/providers.dart | 4 +- .../design/generated_deck_theme_factory.dart | 2 + .../stores/deck_customization_store.dart | 2 + .../core/deck_customization_store_test.dart | 17 ++ .../ai_generation_smoke_test.dart | 2 + .../src/capture/slide_capture_service.dart | 7 +- .../src/rendering/blocks/block_widget.dart | 40 ++-- .../src/rendering/layout_debug_overlay.dart | 193 ++++++++++++++++++ .../lib/src/rendering/slides/slide_view.dart | 47 +++-- .../lib/src/styling/components/slide.dart | 9 + .../lib/src/styling/components/slide.g.dart | 22 ++ .../lib/src/styling/default_style.dart | 2 + .../test/goldens/debug_layout_overlay.png | Bin 0 -> 10037 bytes .../test/goldens/decorated_block_frame.png | Bin 8764 -> 8764 bytes .../test/goldens/inset_slide_container.png | Bin 6241 -> 6241 bytes .../test/goldens/plain_markdown_slide.png | Bin 7283 -> 7283 bytes .../test/goldens/section_two_blocks.png | Bin 6762 -> 6762 bytes .../test/goldens/slide_goldens_test.dart | 51 +++++ .../helpers/fake_slide_capture_service.dart | 1 + .../thumbnail_capture_timing_test.dart | 24 +++ .../src/styling/components/slide_test.dart | 52 +++++ 22 files changed, 447 insertions(+), 37 deletions(-) create mode 100644 packages/superdeck/lib/src/rendering/layout_debug_overlay.dart create mode 100644 packages/superdeck/test/goldens/debug_layout_overlay.png create mode 100644 packages/superdeck/test/src/styling/components/slide_test.dart diff --git a/packages/playground/README.md b/packages/playground/README.md index ea4b4a4e..25bd27cb 100644 --- a/packages/playground/README.md +++ b/packages/playground/README.md @@ -39,6 +39,11 @@ lib/ fvm flutter run -d macos -t lib/main.dart --dart-define-from-file=../../.env ``` +Add `--dart-define=SUPERDECK_DEBUG_LAYOUT=true` to show the slide layout +overlay: magenta sections, cyan block allocations, orange margin edges, and +green padding/content edges. The flag is preserved when Playground themes or +typography settings change. + Set `GOOGLE_AI_API_KEY` in the ignored repository-root `.env` file. Flutter does not load `.env` automatically; the define-file flag injects it at build time. The Wizard shows a configuration error immediately when the key is @@ -107,6 +112,10 @@ fvm flutter test test_live/ai_generation/ai_generation_smoke_test.dart \ --reporter expanded ``` +Add `--dart-define=LIVE_DEBUG_LAYOUT=true` to include the section, block, +margin, and padding guides in the recaptured slide PNGs and contact sheet. +Normal captures remain clean by default. + In debug builds, open `/debug/generation` to exercise the same production pipeline interactively with the three fixtures. diff --git a/packages/playground/lib/app/providers.dart b/packages/playground/lib/app/providers.dart index aebc93f4..7f44471c 100644 --- a/packages/playground/lib/app/providers.dart +++ b/packages/playground/lib/app/providers.dart @@ -9,6 +9,8 @@ import '../core/domain/stores/deck_customization_store.dart'; import '../features/editor/data/mac_os_deck_file_repository.dart'; import '../features/editor/domain/files/deck_file_repository.dart'; +const _debugDeckLayout = bool.fromEnvironment('SUPERDECK_DEBUG_LAYOUT'); + /// App-root dependency injection: the deck globals shared across features. /// /// `DeckController` and `DeckCustomizationStore` are created eagerly @@ -50,7 +52,7 @@ class AppProviders extends StatelessWidget { lazy: false, create: (ctx) => DeckController( deckLoader: ctx.read(), - options: DeckOptions(), + options: DeckOptions(debug: _debugDeckLayout), assetCacheStore: ctx.read(), ), dispose: (_, controller) => controller.dispose(), diff --git a/packages/playground/lib/core/domain/design/generated_deck_theme_factory.dart b/packages/playground/lib/core/domain/design/generated_deck_theme_factory.dart index d079cbab..8d5f1c54 100644 --- a/packages/playground/lib/core/domain/design/generated_deck_theme_factory.dart +++ b/packages/playground/lib/core/domain/design/generated_deck_theme_factory.dart @@ -55,6 +55,7 @@ final class GeneratedDeckThemeFactory { required PresentationTextStyles text, required String density, required PresentationThemeRuntimeRecipe runtime, + bool debug = false, }) { final borderColor = Color.lerp(palette.surface, palette.body, 0.4)!; final tableFontSize = text.body.fontSize! > 20 ? 20.0 : text.body.fontSize!; @@ -141,6 +142,7 @@ final class GeneratedDeckThemeFactory { }; return DeckOptions( + debug: debug, baseStyle: baseStyle, styles: styles, parts: SlideParts( diff --git a/packages/playground/lib/core/domain/stores/deck_customization_store.dart b/packages/playground/lib/core/domain/stores/deck_customization_store.dart index ee1dccfb..68bd8c0f 100644 --- a/packages/playground/lib/core/domain/stores/deck_customization_store.dart +++ b/packages/playground/lib/core/domain/stores/deck_customization_store.dart @@ -166,6 +166,7 @@ class DeckCustomizationStore extends ChangeNotifier { } void _pushOptions() { + final debug = _controller.options.value.debug; _controller.options.value = _themeFactory.build( palette: GeneratedThemePalette( background: _background, @@ -187,6 +188,7 @@ class DeckCustomizationStore extends ChangeNotifier { ), density: _density, runtime: _runtime, + debug: debug, ); } diff --git a/packages/playground/test/core/deck_customization_store_test.dart b/packages/playground/test/core/deck_customization_store_test.dart index 36120d1b..384a021a 100644 --- a/packages/playground/test/core/deck_customization_store_test.dart +++ b/packages/playground/test/core/deck_customization_store_test.dart @@ -31,6 +31,23 @@ void main() { expect(controller.options.value.baseStyle, isNotNull); }); + test('preserves the deck debug flag through theme updates', () { + final controller = DeckController( + deckLoader: MemoryDeckLoader(), + options: DeckOptions(debug: true), + ); + addTearDown(controller.dispose); + + final store = DeckCustomizationStore(controller); + addTearDown(store.dispose); + + expect(controller.options.value.debug, isTrue); + + store.setSize(TextLevel.h1, 64); + + expect(controller.options.value.debug, isTrue); + }); + test('mutations push new options and notify once', () { final controller = newController(); addTearDown(controller.dispose); diff --git a/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart b/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart index bb25b02d..82249b34 100644 --- a/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart +++ b/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart @@ -41,6 +41,7 @@ const _selectedFixture = String.fromEnvironment( defaultValue: 'all', ); const _artifactPath = String.fromEnvironment('LIVE_ARTIFACT'); +const _includeDebugLayout = bool.fromEnvironment('LIVE_DEBUG_LAYOUT'); const _renderThemeQualification = bool.fromEnvironment( 'LIVE_THEME_QUALIFICATION', ); @@ -891,6 +892,7 @@ Future<_CaptureResult> _captureSlides({ quality: SlideCaptureQuality.good, slide: configuration, context: context, + includeDebugLayout: _includeDebugLayout, ) .timeout(const Duration(seconds: 45)); final file = File( diff --git a/packages/superdeck/lib/src/capture/slide_capture_service.dart b/packages/superdeck/lib/src/capture/slide_capture_service.dart index a9852309..5656c76c 100644 --- a/packages/superdeck/lib/src/capture/slide_capture_service.dart +++ b/packages/superdeck/lib/src/capture/slide_capture_service.dart @@ -45,8 +45,11 @@ class SlideCaptureService { SlideCaptureQuality quality = SlideCaptureQuality.thumbnail, required SlideConfiguration slide, required BuildContext context, + bool includeDebugLayout = false, }) async { - final queueKey = shortHash(slide.key + quality.name); + final queueKey = shortHash( + '${slide.key}${quality.name}$includeDebugLayout', + ); try { while (_generationQueue.length >= _maxConcurrentGenerations) { await Future.delayed(_kQueuePollInterval); @@ -55,7 +58,7 @@ class SlideCaptureService { _generationQueue.add(queueKey); final staticRenderingSlide = slide.copyWith( - debug: false, + debug: includeDebugLayout, isStaticRendering: true, ); diff --git a/packages/superdeck/lib/src/rendering/blocks/block_widget.dart b/packages/superdeck/lib/src/rendering/blocks/block_widget.dart index 4a982ff5..bc8bdff9 100644 --- a/packages/superdeck/lib/src/rendering/blocks/block_widget.dart +++ b/packages/superdeck/lib/src/rendering/blocks/block_widget.dart @@ -1,6 +1,5 @@ import 'dart:math' as math; -import 'package:flutter/material.dart' show Colors; import 'package:flutter/widgets.dart'; import 'package:mix/mix.dart'; import 'package:superdeck_core/superdeck_core.dart'; @@ -12,6 +11,7 @@ import '../../ui/widgets/error_widgets.dart'; import '../../ui/widgets/overflow_clip.dart'; import '../../ui/widgets/provider.dart'; import '../../utils/converters.dart'; +import '../layout_debug_overlay.dart'; import 'block_provider.dart'; import 'markdown_viewer.dart'; @@ -141,13 +141,11 @@ class _BlockContainerState extends State<_BlockContainer> { ), ); - // Add debug border if needed - if (widget.configuration.debug) { - content = DecoratedBox( - position: DecorationPosition.foreground, - decoration: BoxDecoration( - border: Border.all(color: Colors.cyan, width: 2), - ), + final debugLayoutEnabled = widget.configuration.debug; + if (debugLayoutEnabled) { + content = BlockLayoutDebugOverlay( + margin: spec.blockContainer.spec.margin, + padding: spec.blockContainer.spec.padding, child: content, ); } @@ -336,20 +334,15 @@ class SectionWidget extends StatelessWidget { final SectionBlock section; final int sectionIndex; - Positioned _renderDebugInfo(Block block, Size size) { - const textStyle = TextStyle(color: Colors.black, fontSize: 12); - final label = - ''' -@${block.type} -${size.width.toStringAsFixed(2)} x ${size.height.toStringAsFixed(2)}'''; - + Positioned _renderDebugInfo(Block block, int blockIndex, Size size) { return Positioned( top: 0, right: 0, - child: Container( - color: Colors.cyan, - padding: const EdgeInsets.all(8), - child: Text(label, style: textStyle), + child: LayoutDebugLabel( + color: debugBlockColor, + text: + 'BLOCK ${blockIndex + 1} @${block.type}\n' + '${size.width.toStringAsFixed(0)} × ${size.height.toStringAsFixed(0)}', ), ); } @@ -401,13 +394,18 @@ ${size.width.toStringAsFixed(2)} x ${size.height.toStringAsFixed(2)}'''; ), }; - if (!configuration.debug) return blockWidget; + final debugLayoutEnabled = configuration.debug; + if (!debugLayoutEnabled) return blockWidget; return LayoutBuilder( builder: (context, constraints) => Stack( fit: StackFit.expand, children: [ blockWidget, - _renderDebugInfo(block, constraints.biggest), + _renderDebugInfo( + block, + blockIndex, + constraints.biggest, + ), ], ), ); diff --git a/packages/superdeck/lib/src/rendering/layout_debug_overlay.dart b/packages/superdeck/lib/src/rendering/layout_debug_overlay.dart new file mode 100644 index 00000000..e4469865 --- /dev/null +++ b/packages/superdeck/lib/src/rendering/layout_debug_overlay.dart @@ -0,0 +1,193 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +const debugSectionColor = Color(0xFFFF4DCD); +const debugBlockColor = Color(0xFF00D5FF); +const debugMarginColor = Color(0xFFFFA62B); +const debugPaddingColor = Color(0xFF35E06F); + +/// Draws a debug-only frame without participating in layout. +class LayoutDebugFrame extends StatelessWidget { + final Color color; + final Widget child; + final double strokeWidth; + + const LayoutDebugFrame({ + super.key, + required this.color, + required this.child, + this.strokeWidth = 3, + }); + + @override + Widget build(BuildContext context) { + return CustomPaint( + foregroundPainter: _FramePainter(color, strokeWidth), + child: child, + ); + } +} + +/// Draws the allocated block, resolved margin, and resolved padding edges. +class BlockLayoutDebugOverlay extends StatelessWidget { + final EdgeInsetsGeometry? margin; + final EdgeInsetsGeometry? padding; + final Widget child; + + const BlockLayoutDebugOverlay({ + super.key, + required this.margin, + required this.padding, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final textDirection = Directionality.maybeOf(context) ?? TextDirection.ltr; + return CustomPaint( + foregroundPainter: _BlockInsetsPainter( + margin: margin?.resolve(textDirection) ?? EdgeInsets.zero, + padding: padding?.resolve(textDirection) ?? EdgeInsets.zero, + ), + child: child, + ); + } +} + +class LayoutDebugLabel extends StatelessWidget { + final Color color; + final String text; + + const LayoutDebugLabel({super.key, required this.color, required this.text}); + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: color, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + child: Text( + text, + style: const TextStyle( + color: Color(0xFF071018), + fontSize: 11, + fontWeight: FontWeight.w700, + height: 1.2, + ), + ), + ), + ); + } +} + +class LayoutDebugLegend extends StatelessWidget { + const LayoutDebugLegend({super.key}); + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: const Color(0xE6071018), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + _LegendItem(color: debugSectionColor, label: 'SECTION'), + SizedBox(width: 10), + _LegendItem(color: debugBlockColor, label: 'BLOCK'), + SizedBox(width: 10), + _LegendItem(color: debugMarginColor, label: 'MARGIN'), + SizedBox(width: 10), + _LegendItem(color: debugPaddingColor, label: 'PADDING'), + ], + ), + ), + ); + } +} + +class _LegendItem extends StatelessWidget { + final Color color; + final String label; + + const _LegendItem({required this.color, required this.label}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.square(dimension: 10, child: ColoredBox(color: color)), + const SizedBox(width: 4), + Text( + label, + style: const TextStyle( + color: Color(0xFFFFFFFF), + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ], + ); + } +} + +class _BlockInsetsPainter extends CustomPainter { + final EdgeInsets margin; + final EdgeInsets padding; + + const _BlockInsetsPainter({required this.margin, required this.padding}); + + @override + void paint(Canvas canvas, Size size) { + final blockRect = Offset.zero & size; + final marginRect = _inset(blockRect, margin); + final paddingRect = _inset(marginRect, padding); + + _drawFrame(canvas, blockRect, debugBlockColor, 4); + _drawFrame(canvas, marginRect, debugMarginColor, 3); + _drawFrame(canvas, paddingRect, debugPaddingColor, 2); + } + + @override + bool shouldRepaint(_BlockInsetsPainter oldDelegate) { + return oldDelegate.margin != margin || oldDelegate.padding != padding; + } +} + +class _FramePainter extends CustomPainter { + final Color color; + final double strokeWidth; + + const _FramePainter(this.color, this.strokeWidth); + + @override + void paint(Canvas canvas, Size size) { + _drawFrame(canvas, Offset.zero & size, color, strokeWidth); + } + + @override + bool shouldRepaint(_FramePainter oldDelegate) { + return oldDelegate.color != color || oldDelegate.strokeWidth != strokeWidth; + } +} + +Rect _inset(Rect rect, EdgeInsets insets) { + final left = math.min(rect.right, rect.left + insets.left); + final top = math.min(rect.bottom, rect.top + insets.top); + final right = math.max(left, rect.right - insets.right); + final bottom = math.max(top, rect.bottom - insets.bottom); + return Rect.fromLTRB(left, top, right, bottom); +} + +void _drawFrame(Canvas canvas, Rect rect, Color color, double strokeWidth) { + if (rect.width <= strokeWidth || rect.height <= strokeWidth) return; + canvas.drawRect( + rect.deflate(strokeWidth / 2), + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth, + ); +} diff --git a/packages/superdeck/lib/src/rendering/slides/slide_view.dart b/packages/superdeck/lib/src/rendering/slides/slide_view.dart index 6df779c4..2325d614 100644 --- a/packages/superdeck/lib/src/rendering/slides/slide_view.dart +++ b/packages/superdeck/lib/src/rendering/slides/slide_view.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart' show Colors; import 'package:flutter/widgets.dart'; import 'package:mix/mix.dart'; import 'package:superdeck_core/superdeck_core.dart'; @@ -7,6 +6,7 @@ import '../../deck/slide_configuration.dart'; import '../../styling/components/slide.dart'; import '../../utils/constants.dart'; import '../blocks/block_widget.dart'; +import '../layout_debug_overlay.dart'; class SlideView extends StatelessWidget { final SlideConfiguration slide; @@ -18,18 +18,20 @@ class SlideView extends StatelessWidget { : const SizedBox.shrink(); } - Positioned _renderDebugInfo(SectionBlock section, Size slideSize) { - final label = ''' -@section | blocks: ${section.blocks.length} | ${slideSize.width.toStringAsFixed(2)} x ${slideSize.height.toStringAsFixed(2)} | align: ${section.align} | flex: ${section.flex}'''; - - const textStyle = TextStyle(color: Colors.black, fontSize: 12); + Positioned _renderDebugInfo( + SectionBlock section, + int sectionIndex, + Size slideSize, + ) { return Positioned( bottom: 0, - left: 0, - child: Container( - color: Colors.cyan, - padding: const EdgeInsets.all(8), - child: Text(label, style: textStyle), + right: 0, + child: LayoutDebugLabel( + color: debugSectionColor, + text: + 'SECTION ${sectionIndex + 1} blocks:${section.blocks.length} ' + '${slideSize.width.toStringAsFixed(0)} × ' + '${slideSize.height.toStringAsFixed(0)}', ), ); } @@ -57,9 +59,22 @@ class SlideView extends StatelessWidget { return Stack( fit: StackFit.expand, children: [ - SectionWidget(section: section, sectionIndex: sectionIndex), if (configuration.debug) - _renderDebugInfo(section, sectionSize), + LayoutDebugFrame( + color: debugSectionColor, + strokeWidth: 4, + child: SectionWidget( + section: section, + sectionIndex: sectionIndex, + ), + ) + else + SectionWidget( + section: section, + sectionIndex: sectionIndex, + ), + if (configuration.debug) + _renderDebugInfo(section, sectionIndex, sectionSize), ], ); }, @@ -133,6 +148,12 @@ class SlideView extends StatelessWidget { }, ), ), + if (slide.debug) + const Positioned( + left: 8, + bottom: 8, + child: IgnorePointer(child: LayoutDebugLegend()), + ), ], ), ); diff --git a/packages/superdeck/lib/src/styling/components/slide.dart b/packages/superdeck/lib/src/styling/components/slide.dart index e12ccccb..f49acaa6 100644 --- a/packages/superdeck/lib/src/styling/components/slide.dart +++ b/packages/superdeck/lib/src/styling/components/slide.dart @@ -62,6 +62,13 @@ final class SlideSpec with _$SlideSpec { @override final TextScaler? textScaleFactor; + /// Vertical gap between top-level markdown elements. + /// + /// An unset value resolves to zero so spacing is owned by SuperDeck styles, + /// not by flutter_markdown_plus package defaults. + @override + final double? blockSpacing; + @override final StyleSpec alert; @override @@ -106,6 +113,7 @@ final class SlideSpec with _$SlideSpec { this.img, this.link, this.textScaleFactor, + this.blockSpacing, StyleSpec? alert, this.horizontalRuleDecoration, this.blockquote, @@ -141,6 +149,7 @@ final class SlideSpec with _$SlideSpec { del: del, code: code?.spec.textStyle, textScaler: textScaleFactor, + blockSpacing: blockSpacing ?? 0, listBullet: list?.spec.bullet?.spec.style, orderedListAlign: list?.spec.orderedAlignment ?? WrapAlignment.start, unorderedListAlign: list?.spec.unorderedAlignment ?? WrapAlignment.start, diff --git a/packages/superdeck/lib/src/styling/components/slide.g.dart b/packages/superdeck/lib/src/styling/components/slide.g.dart index cc724f41..17453704 100644 --- a/packages/superdeck/lib/src/styling/components/slide.g.dart +++ b/packages/superdeck/lib/src/styling/components/slide.g.dart @@ -21,6 +21,7 @@ mixin _$SlideSpec implements Spec, Diagnosticable { TextStyle? get img; TextStyle? get link; TextScaler? get textScaleFactor; + double? get blockSpacing; StyleSpec get alert; BoxDecoration? get horizontalRuleDecoration; StyleSpec? get blockquote; @@ -51,6 +52,7 @@ mixin _$SlideSpec implements Spec, Diagnosticable { TextStyle? img, TextStyle? link, TextScaler? textScaleFactor, + double? blockSpacing, StyleSpec? alert, BoxDecoration? horizontalRuleDecoration, StyleSpec? blockquote, @@ -77,6 +79,7 @@ mixin _$SlideSpec implements Spec, Diagnosticable { img: img ?? this.img, link: link ?? this.link, textScaleFactor: textScaleFactor ?? this.textScaleFactor, + blockSpacing: blockSpacing ?? this.blockSpacing, alert: alert ?? this.alert, horizontalRuleDecoration: horizontalRuleDecoration ?? this.horizontalRuleDecoration, @@ -112,6 +115,7 @@ mixin _$SlideSpec implements Spec, Diagnosticable { other?.textScaleFactor, t, ), + blockSpacing: MixOps.lerp(blockSpacing, other?.blockSpacing, t), alert: alert.lerp(other?.alert, t), horizontalRuleDecoration: MixOps.lerp( horizontalRuleDecoration, @@ -145,6 +149,7 @@ mixin _$SlideSpec implements Spec, Diagnosticable { img, link, textScaleFactor, + blockSpacing, alert, horizontalRuleDecoration, blockquote, @@ -211,6 +216,7 @@ mixin _$SlideSpec implements Spec, Diagnosticable { ..add(DiagnosticsProperty('img', img)) ..add(DiagnosticsProperty('link', link)) ..add(DiagnosticsProperty('textScaleFactor', textScaleFactor)) + ..add(DoubleProperty('blockSpacing', blockSpacing)) ..add(DiagnosticsProperty('alert', alert)) ..add( DiagnosticsProperty( @@ -253,6 +259,7 @@ class SlideStyler extends MixStyler { final Prop? $img; final Prop? $link; final Prop? $textScaleFactor; + final Prop? $blockSpacing; final Prop>? $alert; final Prop? $horizontalRuleDecoration; final Prop>? $blockquote; @@ -279,6 +286,7 @@ class SlideStyler extends MixStyler { Prop? img, Prop? link, Prop? textScaleFactor, + Prop? blockSpacing, Prop>? alert, Prop? horizontalRuleDecoration, Prop>? blockquote, @@ -306,6 +314,7 @@ class SlideStyler extends MixStyler { $img = img, $link = link, $textScaleFactor = textScaleFactor, + $blockSpacing = blockSpacing, $alert = alert, $horizontalRuleDecoration = horizontalRuleDecoration, $blockquote = blockquote, @@ -332,6 +341,7 @@ class SlideStyler extends MixStyler { TextStyleMix? img, TextStyleMix? link, TextScaler? textScaleFactor, + double? blockSpacing, MarkdownAlertStyler? alert, BoxDecoration? horizontalRuleDecoration, MarkdownBlockquoteStyler? blockquote, @@ -360,6 +370,7 @@ class SlideStyler extends MixStyler { img: Prop.maybeMix(img), link: Prop.maybeMix(link), textScaleFactor: Prop.maybe(textScaleFactor), + blockSpacing: Prop.maybe(blockSpacing), alert: Prop.maybeMix(alert), horizontalRuleDecoration: Prop.maybe(horizontalRuleDecoration), blockquote: Prop.maybeMix(blockquote), @@ -390,6 +401,8 @@ class SlideStyler extends MixStyler { factory SlideStyler.link(TextStyleMix value) => SlideStyler().link(value); factory SlideStyler.textScaleFactor(TextScaler value) => SlideStyler().textScaleFactor(value); + factory SlideStyler.blockSpacing(double value) => + SlideStyler().blockSpacing(value); factory SlideStyler.alert(MarkdownAlertStyler value) => SlideStyler().alert(value); factory SlideStyler.horizontalRuleDecoration(BoxDecoration value) => @@ -480,6 +493,11 @@ class SlideStyler extends MixStyler { return merge(SlideStyler(textScaleFactor: value)); } + /// Sets the blockSpacing. + SlideStyler blockSpacing(double value) { + return merge(SlideStyler(blockSpacing: value)); + } + /// Sets the alert. SlideStyler alert(MarkdownAlertStyler value) { return merge(SlideStyler(alert: value)); @@ -571,6 +589,7 @@ class SlideStyler extends MixStyler { img: MixOps.merge($img, other?.$img), link: MixOps.merge($link, other?.$link), textScaleFactor: MixOps.merge($textScaleFactor, other?.$textScaleFactor), + blockSpacing: MixOps.merge($blockSpacing, other?.$blockSpacing), alert: MixOps.merge($alert, other?.$alert), horizontalRuleDecoration: MixOps.merge( $horizontalRuleDecoration, @@ -608,6 +627,7 @@ class SlideStyler extends MixStyler { img: MixOps.resolve(context, $img), link: MixOps.resolve(context, $link), textScaleFactor: MixOps.resolve(context, $textScaleFactor), + blockSpacing: MixOps.resolve(context, $blockSpacing), alert: MixOps.resolve(context, $alert), horizontalRuleDecoration: MixOps.resolve( context, @@ -648,6 +668,7 @@ class SlideStyler extends MixStyler { ..add(DiagnosticsProperty('img', $img)) ..add(DiagnosticsProperty('link', $link)) ..add(DiagnosticsProperty('textScaleFactor', $textScaleFactor)) + ..add(DiagnosticsProperty('blockSpacing', $blockSpacing)) ..add(DiagnosticsProperty('alert', $alert)) ..add( DiagnosticsProperty( @@ -681,6 +702,7 @@ class SlideStyler extends MixStyler { $img, $link, $textScaleFactor, + $blockSpacing, $alert, $horizontalRuleDecoration, $blockquote, diff --git a/packages/superdeck/lib/src/styling/default_style.dart b/packages/superdeck/lib/src/styling/default_style.dart index f46acb95..c483fe6e 100644 --- a/packages/superdeck/lib/src/styling/default_style.dart +++ b/packages/superdeck/lib/src/styling/default_style.dart @@ -80,6 +80,8 @@ SlideStyler _createDefaultSlideStyle() { } return SlideStyler( + blockSpacing: 12, + h1: TextStyler() .style( TextStyleMix( diff --git a/packages/superdeck/test/goldens/debug_layout_overlay.png b/packages/superdeck/test/goldens/debug_layout_overlay.png new file mode 100644 index 0000000000000000000000000000000000000000..aefc5e583d9054c801a560875e2a7d739d117278 GIT binary patch literal 10037 zcmdT~dpy+X{{QNx(x$R!6&jt>o+PR^}$s(7Tp<%`tGxK}C)V@y6p3ZAqo!9R-|2)s*@qC}>`CQ-c&-Z)v zsI~cL>*dx%5cHYFx284_v>J3*JYTy8G!#sqEofFDzqUBB7W~j_-G2mQ38anr*HCV& z!YBmofGkYEIuUkfq7VBcorf8jqD;g^GQZn9^r&h}u_?NdkyTHCb!)a(Y;b=4=FM}BbVtEvC% zQFPmtLL1xiIXx|&`Izb9Clfq<8apz?Il@3xaEpQ_vW2bJL*_d|&!HqYDr}Vn$AfmX zTQMczP84fx6x?pIf>yyp*A+Y9;VKyjp5BoRt%Zlxt4!c#?Jj5qXiPLDHo?Om)+8Yp7s9)TJ)ZVd61Y?hOxmWTy)4oR za8yU}SiTs2#$aEcT>oud-HE>Pz@IrS{0wr2zwAQGJe zf#`NsGAW*wdIL;!Jln$nK_Ir2=Q{P$+t^cGc|!ll07q?YoK=V=Z_p{+DBxmwTBRbp zVDM?Y(1s>9D0O02eZP`d1n&Xm`G!krz@1NS5KHb@IdxU1o)80X4V4Y7H-X!w<~M_# zkwW5(Y&^F>-G8MfxhQ95nPZvbcnIMZ)auZ7b5ak33ZaP+B*b|2vqa|B7?YQ6r%uwS@qA^M1DGIV1C>7n5& zzn%Z|xc&pZuWDw$@HKBh1ROAXJhQ#UF`^~Ox)7XnSp8)7ltFP?DW0oM!`({TI^+jk(hQwu#_oi#q8dAK1U!gVJBm zi8Xq|KCCy1lgshf;> ztZ}E8KTwTRYKjC@Hazo6J-kxa3B6_qY#gh4yHp^InH2Qm<=mT2n_E6dKmg0ra+LU} z0QS-eFSPE8i!-DL#5rFhY;U3aL>DpK?BE-;jMp`un~caW>z{opKiEss;EnCf(e6f1 zJyQ=9lol5UYC^A{%?#N!d5yNw+^UN7CMO;53tSc~h%>hZwjp;~Y=(2`ASL(rgYJ>T zt6mcE?2e2Mgam68$%>rO;zLo)p_#uqb+=LUBIkQU=iB}g1z&q<@a1d+e|}z*FLmE1 z9)kP^ax%#T^X`-H#IVrH1bIk66Gpim*uZ~tfIkxTK{TR2c9XU4L`Xo%!GYVIat%R} zxKRK0e12m%2%Us2m4s+-tic3JavAn~KS`Y;ae*W>Vq4P<9Z;9M)?E7Pw4>u*6%`eN zc}jPCyUl4QCslcQc`$lbQ}gv{7Z-I|b>}CBO{T*mBV<>N-M+rQVD8%*Jfg8+<-rbL)mC8qJ=0jMdn9Q^_}8Bj0*&Wk5naLzXT!fCyGPVr$q!y62SW^{$x1hkL=ooGXxs8%0!N| zc7C*KcKy$!!tShunVA{dPUyJVf{EQR2rmGM0E!J;wqy(YMDGy26iG1m^4Hj!P*+!1 zP*7llO1P@3s+!o@*;$aEe+-QF?%fNYC3xyp9WB$!Ju;HGVa=t2q9R*Vf}m@^s1K=6 z`Mzvkp?fvr34Hkr>bs!yh0|7Ho-qtH`r@2)Poh?3clY2uedC|s#)dth7!#Qroogj{ zsS8TeADx{VB$y|6@25B)R*sQY%r&gRu%F%7Nic`*uFm+l@C;7!>7eN!F^N9`h=Da# zh3H}CE+`2C0s?N8mX=y``>zUdPUW^-HiOY4%1U_CZmFKAVwZcNNIV#C?2JH9R=0AG zlt`Bu)fXB|tGN&zog?`}tz7l21PXdsYv^V5r8Fkn2uR1(W}n1lbBb2l1ZuQBEBN*E zdkYVbsXddDmd5lG+Z@)ziqPT08LED-_=>Fa?rYbsr7<&t6fa<|dt(vwvWt&7v(p3| zCTCjECrlh$d}jZO4dB_!$OiHvVHV;lm!=<@%UzrE88E$)l2f*}wm$Ki`B_<61!ZOS z+S=M+bmPjED+NTNvzl|oDPXqE6iN~Zja^+`KJhC|q~Ks-_43!oo4}|bucylqs!Fa* zh53n9H^kIh^Auyv(pW$^Rsx;|Q^0<@Zvv2)s$T$%>sCV|fs5VUX=!OmFppB*ZV;9S zdonbc{x0Qy3hI>SWSwEu*GNoFP4x%}$h}z^dRFX9%}o>vc%emNYlT`&9wEs&;-O<{ zaMlYqSZ6Pkf#d2Svu|S5oytyZ+;nfHZ>nuj1oL;GL7WuL=v>$Qle zL8tEb0r+3|?M+VKLnfZp2!vi>BM9m8O{)&PwFaOa=y;Fnv`8f;uzPHiSs`hifi@=hPC5eWp zB0g$}!f*~KtjoYmn)2uLv6VtRVfu=6`fe9rUGpR@k}#j-r=Uo?Ga?&zXKXJdYU7E2 zo}%>yr}uWNY88k36;54x%uX;CiC(-V@2L$ON_1)tk(8T$=>S50N#YuYvHHXwBR9G1 z7b=RCwg?QCsSJgw|6yhl)jEyB$5L6|`P@Oa;M>P~Mxv^=dT{&Z%68w1qLya=uA&)9 zNi6QBo`E*2V;IQD>Y7!dLn|_s;7@U|qg2bq$1c!FTm6L)alG~;tSV20i#)^XG#|5q zt8;l$jxLc|*KZ+&1RO=wsdHGYLeZwV&5lxT^#o$@mquycS`9hshIY}|Ilh@M%9ww? z2leJ&J(8{~IJXV!N}QU@KG#1V(8H-a;x8cj6Z9xfM%w*`l)?LUtl2V^Whx9lf9glN zNyv1QE|$yeoQpfBRyk~`WY6fZ;uIc;9Vulthw4)nia^C!_--_R73!R^?odknQf z>1G}KUO{8LC0h~O?H8SrqB+M?^erk%(BzO@)IIk56ou{giS%$u<dogXs&TDLw_9Kk$P@Lsyc#ShZX#N z2RKnxWf^h)@HSOLd(?*H?uBB|#M|(b%n!32;64-bzl?rs?ed;2|)A3yeRbISyy)2B}D9v>ecdR2D~F1F0i zqql8Xaw8!#!hpe_@E=%LcPg(4uy(1a~9z4<5OI0NOJz5wSA1>-*fK&8%730d7D2)*feik9x+kQdV<5j8hjDS zGL#(SAn&SCzVAD88@myL`ZhuO!Eb(T1n|o$Z6qB9xFPVu^A)RMSpBDA^M3)ZE{T=P zXBMC|_{+f$)C^$iQ{CkpF`5-T)RfrD#m-MPkS|Fab@u>n@D;Toj7gw2U6GPK*eibB zmN}H)fEf1^Ulvp`4$Z{xWAdtp$Vp_fI*HyR0sslI!qvR>j^2w#Vv?HEo|ui>$K2Uz z-B?|PTRlBJtz5=cw-V{15YasU&D=N+@J=c93bM;%EdWcfWR`%lPbRzf9eJT>Y?UF6dHv1M^yrH;7EI~6 zn;;1 z?j8R5XUzH=5`{u?9!f_6F?R5p%t`hLjF?SDtc>ElT#|7YyxPQ5bcSO-bYBPC<;Bdq z-sD$1ej+zMAQ7Y0B2MDYy*}5YgTLQKJGLz;rQNr%h@7G*w>P1B;(Idyphp-qjtRf7 zR9YuJGnVZr5@_zpTp*U#SitvMV>50kI%2?Lm<1Z7<2K-K>ENCmuy;6!-q;o9xA0ZN zg<%3#KV9L`@0W{kq>YR0(x#`su$0^q#+Ui?jld5c3o=ikfe z>O|(%yJhMaP1TolS_ADsRUJX^*8tvmaRm4XWD9wo+y&#qJaf z$jg1W%S<`*409hiNAi|;*xJEaq9oB7S8RB5B5EqkppBV5in@as)Ewx8>9L7a?Z2En zYqw)X^;EH?UXZK!R&hC+?+FU&l9Bu?<@34!ASY4D)tA_USX697R>!az`m*KjB` zU`Agp(!ajEndV+^bGS5O#-2YfODr5JYx^c+H&r}dT;!<3sbt^6xXj0sW;ropCP`V$ zH^zjMdlPM%CYt@W#YKm@OsX>moha3~**d+g;$atwH{#xMRM)OdjSW=|MP6rpoGu@H zXnfblY5O5l)kAF-n;e-gK|w*A6--UPm6iM|Xv^il{qW=6pF!za8((yB{RMBM!XN$V zzl2O0T5!%B!TCzj+2M+kv)xy zZ6010ezOm?sn{-u-Wk%OYp5m9GTeP`I9kc9W^(36vtxL{=S_9(85B?JjP=x9=SFmH z66!e_88JO;5yl_8*l`<({U;CdYS+j04Xx4lFg+gK_e=R25%XRlEDn0B;jOgCo#SWn zj{bN%-&u@Uf2GnXBi|;+tcCihy1F`Ov?lCCksazeI3*FOvxoX;h znO|y6@9Yi@rQN8FsTsP~6PmZUH;0AkhyeR;s?AvZ)TZY1C@_S6(pa?oFRd2eF0DJh z_>IQ9>Add)Dj&3YU6@^+=0sGzoxJZH>1SSHzH;v<{#i2oZC4bF&z7p{z8}$}a`9~a zgB5vK|Duyiy{Z|ae@6yPS6LSht$F!jz-a#^k=cpZkF*A(JwOw2P5wnP?6K)) zzYY|+&Stl)<*}aFFTP0nne{ScmKbuj{z_fegBh^%j{01*k(i?Bk;6ue_3ww=#{?&( z&tP(>ZeTL+yIG|jp#6{ceWzE8F+K9d0Kx99Z>~bMr)QNv3*; zs$JLjuYM~SwzhkFd_c5W?iyxx{;L}wtKXJOzj|{|=BBco{Tt@l)Gd;;u2}N6Fl;Wb zbldAq>F-wkY;CQ2@o;Z$|7NY&vX|>_8Q8?|+wR%^OkC_1P-h{(mF@Qhmy7wWj3JbD zd%w-iZyXF8R`K@q_kX`7m!zt+_fOx;eYfB5et7FxIwJ!E!vfptynS2Wf4d!Tbn%Cd z?uKSt;|p$mH&JC|Al!!?@YffmwdJI?ET-3zb>r*KYQEp zhKhHNe|NGouBj8>|KQd(_XENWKtmZO_-+>D5@M>)+3}XK;#BGS`taMzYof&S_b=F% zb1Qd5yqxm(MqQ$9Cfa!OseX{t!?*rZ=A*h3jiYg|B&kk$p4#{iY?@1Njmc+WV4sTJw}oC?9&%B Sw!C!#`OnkU&t;ucLK6V;D*|Ew delta 510 zcmdnvvd3jZ6aVA`%yROFbNLSh%kBPuZJRp-Lxbtd#q;+pz4v(^{}x*|hBZ<1HVbk| zG1Y?=t*idK`<7hd)m;_8S+}NMK4w+$Awbr;ZqwVsu(@Yvt7RYm#{K%_WA#@Xo8528 zrC+_dA#sOZ^|N)e;v-hH%iX*E&0_VX)amb5@g6?>^}^xm?7GB-K%KJR7hEppx3c}d zz!*YpF8*<1a&A8Z!|GcHe*CCd|5h>VlG5q-Gv4j~yH9#uZjBfN5Zv1Q{8{wxy#0Sw zg?rCmQjuq5o1Dnw?jW64%`orNjlbKs*)lOK|G2IGr~IpF=WBPqH4K}3{m)+c75smH z?a!>{>3A-8Wq!55fmh`px!2`Zv)C|lfK0u)*^x(>slM&DT*LC1@4w2o+8$a}`tcig z*6C%vr4Jq~v?}|Pa{KWbtJ&th*ZSUyUz>c~|4K4@{}$VGt2QSdE{OehCf0QSh5&xs zJ==jkTY6gWZm4uy`|E}6->>~S#TIh1{F&=gz1d6Q9;1k0?&*tP Sg!KDB-t%8cWTS$_-JI9ts8-3$Hvo_X+?2EU8By zdvZMg!b@|n0RYU*8?)g95CVU;n0o>C=b%M_0$O3FmKousQzlr@u}% ze>j6ZeSCFm9KB^_viW0-@$n~HQ`{Q56=H|zf&Hu`mzBoRA z`ku+=JA=JBgBy6pA^$$Ouy$#@`0{I$%|CYR==lD3f4(-^{1G?fBr#y4qiKVVf^D?Z%j7-XFokNK6~HYlg)PqdvgYRJM7;F|9AXEt~J`6JIgaA3RqFQ@Lgf7a&%0000iyY)-wXMO(D z51!lg=MO)1?7()Jf;Y2q5No%Z002ovPDHLkV1gXc B<5>Uz delta 883 zcmV-(1C0FPFySz;$On@k0S}W93I#H`@XE7uuK>U<9)0Y|@%#%f&ArCb`PSFJJbwL) zAJ4r80J~Vb`1bh5LyykA1^}M|Z?lmD5CVVLV(talpNGyoKKBYs>yFz_jayG1AD6GZ zJ2o~p=U!*&oIG)S+Ru+qK`o6oy*0pP6?ajC6US}ztzW462 zvT|gsU3_b7ZEerJ&eFPg>GIqwETzBw{dM1AyKo2X%^kEme+TW(-$6I#ldY|}R{$j7 zQz8H!a%W$EW9}6o>(#U8=U!o{xqsM?_4hW$`g<3Ou_wp#FT6DO8UVn|yaBU;1P}s$i@6tIe;zvX_}nWjtvhZzHEun5d|bZr?%3Ga zoO_+6bMnOTap&!)#`_x|j(4uC&%MslSy?Q`>HF>)Ti33QwKw0Md!40p`rf<8%F2I!nhLv^#gu?%Y9pa|hj+i;{>BHJ z;}3uO>tyqXGuYF|SGUH|TUI8UKgJjzf3h`>erCF79QHHtIoMm_bMU)A{$*@#emvRy zuYBo?3no*xQRQ z|8rbe`_E+a0hopWc*y$H^1N5AFQz?d{3KhcnpS&d$!b`Q~X~ zHs0RZ8Am33#$i7LpM&}5AH?V2wR0E7KmPT`Wb=Ra(=+3<_uW0&d}pvXXRx=!{(bQ8 zH{TlH{N9fyoBxH+-8-K69LWzRn?C|);B&CI-~Rs3E};6{y?7L4Ca4`GnoGw&R}mlJ3EukcLsZN27Aq)bo_xlTX(HT|J1ep~*aRW^>n{Kl0oI2e!-qa_XM@XMH{Z006+U zTfcOE*5^O{;JICY{_s=B4s4excmV^mK@o8TlfV*Jv$7SN0u;ci;=eFsUG@L~002ov JPDHLkV1fh-<*xt$ diff --git a/packages/superdeck/test/goldens/plain_markdown_slide.png b/packages/superdeck/test/goldens/plain_markdown_slide.png index 6abd62b6bdf54a8d7ebfac84da79485c4f2a8da3..96c92f11857383eff13148f8b4ad597d5c7ab134 100644 GIT binary patch delta 867 zcmY+BZ%h++7{{*^DSM$eh=8oIjd95qNroA$Wj!p6sL8@ac>!)qpv{(eo2611*s(%O zhRKqlXmZ<_J1>XBM6@f%at~+NeW?$9I_kL8az{d^33N&eygD>-0^oZdPc=C$*JsMM!~vX90V9dR8|#hm<7e z$IA$DnV(5c^4cd9jo8(IGm>RQn$lZ}k9QOTNkw%FEf87`xwqy@++V6M-v@^xVV|oF2LLJ#w+kt@rf-81H{)^|A=b@ z(xmWDM3zyF)MoFghsh7jX7k@PmzS6K%e{Ldm6~gds%MVh{%5WhnS_p9E%`rg@66+?4oI$$YTZ%7^TS-?cuQZzcS2LvG$6pNls9P}Vr>3xUKN8}6b zdM!2apT$q;;TcX8lshs`%(NH^#qb4hq%0#%M%HG(oQ8PP_g6d|*w}Th**N=BSpx3?(6#; zIk1GSwY7$(Y0OKgLRQIx6`9IDUm(rs96!?b^u|Vx|4HSmQu5tnRYwOaj9>l>&H2Tl delta 906 zcmZ9LVN4Te9LA5@wIy+s7?lBA>%Pcz(Jm==hEpobG%*=AEFqmSFb&3z#fCL>5G-v~ z3}!=^$t^BeZZI*xl+Y$Ox~9;cM4=(BEv#C0ZC#IbAksqV&U)P0_PjI8KJ43*_xQRe2c%oPJ}oMVdXQ1M0c zP}*>dq7(owhEt*sps6=X8(N-j!P_f8L_O5_WhTPVv@^gahr7|vv1Dp*4wyEgLeglW z>K?t!)b^dpd0Ar^{Z{^4lDRe()ztafe=U>dPUVK6O;8=sGvR#J6u8Nn`2*$zRA zyqd;AcadXuYN~22kuuLo#s{p}MSQ~w7E) z@L2>ZQ_=No5`d$Of5f2?bX0LA2IL|8n!b`fwpFm*OAf5J1AYFdu#Z_#w#=p5xHhP4 z|KfMo*pYjyY>3a?yIBbUE(Z|>_?)^?2IcW*xRpZioque3y=0D(;k@k~iJ#xyCE#!8 PhK?US@$uM4r!T((4j1T2 diff --git a/packages/superdeck/test/goldens/section_two_blocks.png b/packages/superdeck/test/goldens/section_two_blocks.png index e7a278308f36d679e2fdfc68571f49142cceff95..bdc4219e6f993c70c67072880b8eb45e5fc5bc51 100644 GIT binary patch delta 935 zcmZ{gZ%i6@7{?Fjs!OeubeTG8>6)%==@M&Zkb*$6>`#`gwF}v7qE=HAO&ZuqL8bih zT$9aY6G?a3x{V&bSmM&O1qqAb6me^nnNElq+b|%1#>!P1r0h=Y!Ex-!x)*)1SI?6? zzuzaH?>F6&Zkah+4U6~H9EH?9hTO7HB0+Rq0ODH9z_!corF10zYr7m|o%;xMCaNbH z@X2SdK%r=m>t4gqz<1G~JVP2yOD&u2+uGU^ip3V4c-NmuB!nW-3j+fKNgUUtGZAsI zSWG^CtnKRVHo-7_!=<*DsA5(QGUDJe7V}-E=D$Y07J6*va=B0}#^dw($#nW6{kPLg z6&?mVfe*fa&9e5T9ApC3004w)WQ@HqXU|(N<)@=dN`HWMVFI@@#jKYX8!cvn;j!C) z66`T$iZB;{Zgo0X2bhooOz)4R2n>~jiKvJ+93LOA7+yz^(;h^RK6*5GsIk%HbUIZ! zooHux8!44aDubcTX0xrVuC4~dbJr%#W*rfATOs>}QZe!~M>URQ7r$ z%F?mzQY*lqx3Qh8j^l{$R{>S5z*gPe=aRoFC}}LHK=k-MhIvY}o`vCV4b}b0lU07m5D= zb%7 delta 938 zcmZ{je@q$&6vq##t(~dZbdxszxNObbvdlEy+<~F%CCeJuq(GV_TeEh9%uJI7MBAzq zm6QInWvI91{%9HymY7KzQ;}XGT^f3>&R&`p7-0tS*j<&*tDqHT5KiIP(Pn@2kG+52 zd*Ao{ev+4HPBc%yQGsd260E7R@@PtyotJ8c!{P7^uPEhR<|L2JyE{oYsfG*98EELW z++NJGpTU2uI4(jqls*e}0^tV|O|kQ>t*!A?s>|+hjN&+c!+UA7uAyOce0;pSw--{W z)h;^V!wCW&MNx8LVL{%~a^0lJE^QLUlbITT|LjC@(9ROJEBu}7EJ4+SAX+FCx-VXQ zZ)$34LfFkD-Gs_lu05r|qq>$%BLI{Gz>q(M{Z{r#qt1{x5L?aX1t-Al(%mAzo8Is; zD&K*{cN|4vvE5O;jSh(MvwNz`knIV2R_}lj;q0@UC4WYvT<}LmqtQR9U;C;g5;7DD z$rXxPm&^63fu*Ai1Ck^ePb9vz+wEt3WBqHnoN;|~6U8vByQk-aOz*S$?9WKhKop=% zjW%iIcja07zbK_d{eKYT7MKkMbkHm3RlfazP}=t~=E!BNTHM}9*-T>ZmGsKI@t-FT z?yowXPP7$m&~N0eR%<%7JU8MetZh0>d+is%fIm4?FfrR)U-z({V0U84pfG>Cs*|mo)pT?~ zHa#DBp5@F6jfksDsm?jEpDNVIj4ZRab8BVZntt*C8}Y+145AGmE-i6VsWiSk_g$?G zk&-!%b&|)3{4EcsF>`$TT=Tmbz66QS~jv37Um0PtL z{#V{>U`AbPXQz(GJA(I<5%c4511a1HT diff --git a/packages/superdeck/test/goldens/slide_goldens_test.dart b/packages/superdeck/test/goldens/slide_goldens_test.dart index 9f7afd2a..ba11a970 100644 --- a/packages/superdeck/test/goldens/slide_goldens_test.dart +++ b/packages/superdeck/test/goldens/slide_goldens_test.dart @@ -239,6 +239,55 @@ Author text in the second column. }, tags: ['ci-excluded', 'golden'], ); + + testWidgets( + 'debug layout shows sections, blocks, margin, and padding', + (tester) async { + await _pumpGoldenSlide( + tester, + Slide( + key: 'golden-debug-layout', + sections: [ + SectionBlock([ + ContentBlock( + '### First block\n\nBoth insets are visible.', + margin: BlockInsets.all(16), + padding: BlockInsets.all(32), + ), + ContentBlock( + '### Second block\n\nDifferent insets.', + margin: BlockInsets.symmetric(horizontal: 28, vertical: 12), + padding: BlockInsets.symmetric(horizontal: 40, vertical: 24), + ), + ], spacing: 24), + SectionBlock([ + ContentBlock( + '### Second section\n\nOutlined independently.', + margin: BlockInsets.symmetric(horizontal: 48, vertical: 20), + padding: BlockInsets.all(36), + ), + ]), + ], + ), + debug: true, + ); + + expect(find.textContaining('SECTION 1'), findsOneWidget); + expect(find.textContaining('SECTION 2'), findsOneWidget); + expect(find.textContaining('BLOCK 1'), findsNWidgets(2)); + expect(find.textContaining('BLOCK 2'), findsOneWidget); + expect(find.text('SECTION'), findsOneWidget); + expect(find.text('BLOCK'), findsOneWidget); + expect(find.text('MARGIN'), findsOneWidget); + expect(find.text('PADDING'), findsOneWidget); + + await expectLater( + find.byType(SlideView), + matchesGoldenFile('debug_layout_overlay.png'), + ); + }, + tags: ['ci-excluded', 'golden'], + ); } Future _pumpGoldenSlide( @@ -246,6 +295,7 @@ Future _pumpGoldenSlide( Slide slide, { SlideStyler? style, SlideParts? parts, + bool debug = false, }) async { tester.view.physicalSize = kResolution; tester.view.devicePixelRatio = 1.0; @@ -256,6 +306,7 @@ Future _pumpGoldenSlide( slide, style: style == null ? null : defaultSlideStyle.merge(style), widgets: builtInWidgets, + debug: debug, parts: parts ?? const SlideParts(background: ColoredBox(color: Color(0xFF090909))), diff --git a/packages/superdeck/test/helpers/fake_slide_capture_service.dart b/packages/superdeck/test/helpers/fake_slide_capture_service.dart index 7323d87e..e584d1cc 100644 --- a/packages/superdeck/test/helpers/fake_slide_capture_service.dart +++ b/packages/superdeck/test/helpers/fake_slide_capture_service.dart @@ -28,6 +28,7 @@ class FakeSlideCaptureService extends SlideCaptureService { SlideCaptureQuality quality = SlideCaptureQuality.thumbnail, required SlideConfiguration slide, required BuildContext context, + bool includeDebugLayout = false, }) async { captureCalls++; capturedKeys.add(slide.key); diff --git a/packages/superdeck/test/src/capture/thumbnail_capture_timing_test.dart b/packages/superdeck/test/src/capture/thumbnail_capture_timing_test.dart index fcbf2de0..24f0427d 100644 --- a/packages/superdeck/test/src/capture/thumbnail_capture_timing_test.dart +++ b/packages/superdeck/test/src/capture/thumbnail_capture_timing_test.dart @@ -234,6 +234,30 @@ void main() { }); }); + testWidgets('only includes debug layout guides when requested', ( + tester, + ) async { + final context = await _pumpContext(tester); + final slide = _slide('debug-layout', '# Layout guides'); + final capture = SlideCaptureService(); + + await tester.runAsync(() async { + final cleanBytes = await capture.capture( + quality: SlideCaptureQuality.good, + slide: slide, + context: context, + ); + final debugBytes = await capture.capture( + quality: SlideCaptureQuality.good, + slide: slide, + context: context, + includeDebugLayout: true, + ); + + expect(cleanBytes, isNot(equals(debugBytes))); + }); + }); + testWidgets('waits longer for asynchronous image widget content', ( tester, ) async { diff --git a/packages/superdeck/test/src/styling/components/slide_test.dart b/packages/superdeck/test/src/styling/components/slide_test.dart new file mode 100644 index 00000000..4f7f50a2 --- /dev/null +++ b/packages/superdeck/test/src/styling/components/slide_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:superdeck/superdeck.dart'; +import 'package:superdeck_core/superdeck_core.dart'; + +import '../../../helpers/slide_test_harness.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('SlideSpec markdown spacing', () { + test('disables flutter_markdown_plus implicit block spacing', () { + expect(const SlideSpec().toStyle().blockSpacing, 0); + }); + + testWidgets('default slide style owns the block spacing', (tester) async { + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'markdown-spacing', + sections: [ + SectionBlock([ContentBlock('## Heading\n\nParagraph')]), + ], + ), + ); + + final markdown = tester.widget(find.byType(MarkdownBody)); + final heading = tester.getRect(find.text('Heading')); + final paragraph = tester.getRect(find.text('Paragraph')); + + expect(markdown.styleSheet!.blockSpacing, 12); + expect(paragraph.top - heading.bottom, closeTo(24, 0.1)); + }); + + testWidgets('deck styles can override the default spacing', (tester) async { + await SlideTestHarness.pumpSlide( + tester, + Slide( + key: 'custom-markdown-spacing', + sections: [ + SectionBlock([ContentBlock('## Heading\n\nParagraph')]), + ], + ), + style: defaultSlideStyle.merge(SlideStyler(blockSpacing: 20)), + ); + + final markdown = tester.widget(find.byType(MarkdownBody)); + + expect(markdown.styleSheet!.blockSpacing, 20); + }); + }); +} From 6c184a35c58751228be8fb39751480282db6d03a Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 15 Jul 2026 07:19:02 -0400 Subject: [PATCH 03/15] chore: update large deck generation notes and remove obsolete golden --- .planning/large_deck_generation_quality.md | 148 ++++-------------- .../test/goldens/debug_layout_overlay.png | Bin 10037 -> 0 bytes .../test/goldens/slide_goldens_test.dart | 51 ------ 3 files changed, 28 insertions(+), 171 deletions(-) delete mode 100644 packages/superdeck/test/goldens/debug_layout_overlay.png diff --git a/.planning/large_deck_generation_quality.md b/.planning/large_deck_generation_quality.md index 99874610..4a10f445 100644 --- a/.planning/large_deck_generation_quality.md +++ b/.planning/large_deck_generation_quality.md @@ -6,10 +6,8 @@ > support, and the same catalog pattern for deferred image-style selection. Status: In progress; Tasks 1–6, the 12-theme runtime family, representative -render qualification, the deterministic ten-slide checkpoint, and the real -ten-slide narrative qualification are complete. Task 7 now uses the stable -Flash/Lite model split and concurrent section composition. Repeated real 15/20 -visual qualification and the broader monorepo regression remain pending. +render qualification, and the deterministic ten-slide checkpoint are complete. +The repeated real 10/15/20 quality matrix and final regression remain pending. Checkpoint: `cf463294` (`feat: improve AI deck generation quality`), pushed to `origin/leoafarias/iterative-ai-generation` on 2026-07-14. @@ -18,10 +16,8 @@ Current handoff: [`ai_generation_session_status_2026_07_14.md`](ai_generation_se ## Verdict -**Refine, not rewrite.** Keep the plan-first pipeline and its inspectable -artifacts. Use one `gemini-3.5-flash` request for global narrative planning, then -compose the validated narrative sections concurrently with -`gemini-3.1-flash-lite`. Replace open-ended model-authored global style +**Refine, not rewrite.** Keep the plan-first, one-slide-at-a-time Flash pipeline +and its inspectable artifacts. Replace open-ended model-authored global style tokens with selection from a curated, described, versioned theme catalog while retaining per-slide composition/treatment decisions and explicit user brand overrides. Reuse that catalog-selection contract for image style when image @@ -46,93 +42,9 @@ prompts and validators are not yet a sufficient quality contract. - Quality outcome: prompt/schema changes are evaluated with deterministic fixtures, exact structural/semantic checks, real rendered slides, contact sheets, and an opt-in visual-review rubric before they are accepted. -- Performance constraint: use `gemini-3.5-flash` for global planning and - `gemini-3.1-flash-lite` for section composition and targeted repair. Keep the - lowest supported thinking setting and do not add a production critic call - until measurements show that its quality gain justifies the latency and cost. - -## Task 7 generation policy: performance, context, and recovery - -The first real 10/15/20 matrix proved that the plan-first architecture is worth -keeping, but the original acceptance policy is too broad and too expensive. A -10-slide success used 25 model requests instead of the ideal 11, a 20-slide -success used 50 instead of 21, and one 15-slide run exhausted outline repairs. -Every request used `thinkingBudget: 0`, the lowest setting supported by the -current client/model combination. The repeated calls came from deterministic -false positives and all-or-nothing retry behavior, not from model reasoning. - -Latest real checkpoint (2026-07-15): `narrative_10` generated and rendered all -10 slides in 23.4 seconds using four provider calls (one Flash outline plus -three concurrent Flash-Lite sections), zero repairs, eight composition families, -and no repeated composition run. The machine-readable quality gate passed and -the contact sheet showed no visible overflow. Local artifact: -`packages/playground/test_live/ai_generation/artifacts/narrative_10_2026-07-15T01-15-32.776783Z/`. - -Use three explicit validation tiers: - -- **Hard contract:** schema/parseability, requested count and order, unique keys, - section membership, exact selected theme and user overrides, grounded element - source/name/cardinality, invented URLs/domains, renderer-safe structure, and - concrete legal, compliance, credential, availability, security, or commercial - claims. These may trigger one targeted repair; a deck-global failure can stop - the run. -- **Repairable slide defect:** missing substantive content, broken Markdown or - table structure, composition mismatch, invalid widget arguments, excessive - density beyond twice the composition-aware budget, or a malformed heading - hierarchy. Compose once and repair the current slide at most once. If it still - fails, record that slide as failed and continue composing later slide slots. -- **Diagnostic quality signal:** unsupplied numbers, possible changes to a - supplied number's meaning, design rhythm, composition diversity, broad - editorial verbs, evidence-strength wording without a concrete high-risk claim, - moderate density overages, and other heuristic quality observations. The - prompt still asks the model to preserve supplied metrics, qualify new numbers - as planned or illustrative, and respect density budgets, but a lexical or - pacing heuristic does not prevent a renderable POC deck. Record these in traces - and the quality report; never spend a production repair call on them. - -Grounding applies to audience-facing plan fields (`title`, `assertion`, and -`contentUnits`) and final visible slide copy. Internal planning prose (`purpose`, -`contentBrief`, `continuity`, and section transitions) guides composition but is -not itself published evidence and must not create factual repair loops. A -grounded value may use its subject elsewhere on the same slide; labels such as -`31%` do not need to repeat a full source sentence in every block. - -Do not silently skip a failed slide or discard already accepted slides. The -composition phase returns ordered per-slide outcomes. Later slides continue -using the validated plan and the most recent accepted slide as context. After -the pass, the caller receives the accepted slides plus exact failed slide keys, -typed issues, and retryability. The product UI keeps failed slots visible and -retryable; canonical deck persistence still requires every slot to resolve. - -Performance budgets for the final matrix: - -- Normal request path: one global outline plus one concurrent request per - narrative section, normally three to five section requests for 10–20 slides. -- Accepted repair ceiling: at most one global outline repair followed by a small - deck-wide budget of targeted slide-plan repairs. Generated section failures - are returned per slide rather than restarting the deck. -- Target total requests: normally 4–6 before transport retries; any additional - request must be visible in the artifact trace. -- Target repair rate: no more than 15% of slide requests across two samples. -- Initial outline prompt target: at most 10,000 characters; initial slide prompt - target: at most 11,000. Each slide repair prompt must be smaller than its - failed initial request. A deck-global outline repair may include the complete - invalid plan, but uses dedicated compact instructions and runs at most once. - These are regression budgets, not model token guarantees. -- Target pre-render generation time is 20 seconds, with 30 seconds as the normal - maximum. Report provider latency separately from local capture. - -Context is intentionally compact: the original typed request remains the user -message; the outline sees a concise planning contract and eligible theme -candidates; each section sees its ordered plan items, adjacent boundary plan -items, exact facts/elements, per-slide density budgets, and one deduplicated -canonical JSON shape example per composition used in that section. Dedicated -repair prompts contain the invalid current object, current blocking issues, and -only the guardrail snippets needed for those issue codes. - -Compose narrative sections concurrently. The validated global blueprint and -adjacent boundary items preserve deck flow without serializing 10–20 provider -round trips. Preserve accepted slides when another slide or section fails. +- Performance constraint: retain the configured Flash model and keep explicit + thinking disabled. Do not add a production critic call until measurements show + that its quality gain justifies the latency and cost. Out of scope for the first quality pass: @@ -153,9 +65,8 @@ Out of scope for the first quality pass: structured output for complex JSON, and iterative evaluation against observed responses. It also warns that too many examples can cause overfitting: [Gemini prompt design strategies](https://ai.google.dev/gemini-api/docs/prompting-strategies). -- Gemini structured output constrains responses to syntactically valid JSON that - follows the supported schema, but still requires application-level semantic - validation: +- Gemini structured outputs support `minItems` and `maxItems`, and still require + application-level semantic validation: [Gemini structured outputs](https://ai.google.dev/gemini-api/docs/structured-output?lang=rest). - Recent presentation-generation work independently converges on hierarchical planning, separating page design from implementation, and reviewing rendered @@ -314,12 +225,12 @@ status is the operational handoff. three-to-five acts/sections for a 10–20-slide deck, one exact selected theme ID, and an ordered visual rhythm with a concrete brief for each slide. The application resolves and records the catalog version. -4. Each narrative section is composed against its ordered slide briefs and the - adjacent boundary briefs. The plan owns composition/treatment rhythm; compact - canonical examples demonstrate only the layout families used by that section. +4. Each slide is composed against its current act, neighboring slide briefs, and + a compact design ledger. The ledger records recent composition/treatment and + prevents accidental repetition without forcing random layouts. 5. Model-facing structured output bounds arrays and uses the strongest supported - types. Dart validates every returned slide independently against its plan, - keeps valid slides, and exposes failed slots for targeted retry. + types. A semantic validator checks the current slide against its plan before + accepting it as context for the next slide. 6. Dart resolves the selected theme into one coherent `DeckOptions` base style plus named treatments such as hero, section, content, data, quote, visual, and closing. The model selects semantic treatments; Dart owns font sizes, @@ -464,8 +375,7 @@ Deck-level deterministic checks: - generated, serialized, parsed, and captured slide counts equal the request; - every plan key appears exactly once and every section has its planned slides; -- no accidental run of more than three identical compositions; treatments may - repeat when the information shape and composition still vary meaningfully; +- no accidental run of more than two identical composition/treatment families; - 10/15/20-slide general-purpose fixtures use enough distinct purposeful composition families (target at least 5/6/7 unless the brief constrains them); - body and heading colors meet 4.5:1 and 3:1 contrast respectively; @@ -649,11 +559,10 @@ table/data legibility, element relevance, and blank/overflow detection. 30-slide golden qualification prove both paths. - Checkpoint: run deterministic 10-slide generation with a fake model and inspect - ordered section plans, resolved styles, and captured layout fixtures. - - Completed evidence: `LIVE_FAKE_CHECKPOINT=true` performs one Flash outline - request plus three Flash-Lite narrative-section requests with no repairs, - verifies the exact model/thinking configuration, ordered section plans, and - deduplicated canonical shape examples, serializes the canonical + the serialized design ledger, resolved styles, and captured layout fixtures. + - Completed evidence: `LIVE_FAKE_CHECKPOINT=true` performs one outline request + plus ten sequential slide requests with no repairs or thinking configuration, + verifies every compact three-slide ledger window, serializes the canonical theme/plan/deck/prompts, records declared and loaded runtime font identities, replays ten Markdown slides, captures ten PNGs and a contact sheet, and passes the machine quality report with eight composition families and exact 10/10 @@ -685,9 +594,8 @@ table/data legibility, element relevance, and blank/overflow detection. font rendering, and latency. - Change prompts/examples for recurring aesthetic/content failures; change schemas/validators only for real contractual failures. - - Preserve the documented Flash/Lite split and lowest thinking setting. Record - request count and wall time for 10/15/20 slides and reject changes that add - unexplained calls. + - Preserve Flash and no explicit thinking. Record request count and wall time + for 10/15/20 slides and reject changes that add unexplained calls. - Acceptance: all deterministic gates pass across the final large-deck matrix, no known blank/duplicate regression recurs, and human review confirms coherent story, readable hierarchy, useful variation, and visibly correct typography. @@ -725,13 +633,13 @@ table/data legibility, element relevance, and blank/overflow detection. - Unit: schema adaptation bounds, typed request serialization, plan hierarchy, theme descriptor validation and candidate filtering, exact theme resolution, contrast, typography catalog resolution, composition contracts, element - cardinality, density budgets, section shape examples, and theme factory. Add - equivalent image-style catalog/request tests only in the deferred phase. + cardinality, density budgets, design ledger, and theme factory. Add equivalent + image-style catalog/request tests only in the deferred phase. - Regression fixtures: commit the four known-bad model responses and prove they fail before repair; keep representative valid table/image/QR/WebView slides. - Widget/golden: base typography scale, named treatment variants, light/dark table and quote styles, actual selected family, and custom registered family. -- Integration: fake-client 10/15/20 plan-to-section orchestration, repair isolation, +- Integration: fake-client 10/15/20 plan-to-slide orchestration, repair isolation, exact count, cancellation, Markdown replay, and selected-theme application. - Live: real Flash 10/15/20 runs save the complete artifact bundle, quality report, actual-font PNGs, and contact sheets. These remain opt-in and outside normal CI. @@ -795,10 +703,10 @@ fvm flutter test test_live/ai_generation/ai_generation_smoke_test.dart \ - Risk: visual review adds cost/latency without reliable signal. Mitigation: keep it opt-in and informational until repeated human/artifact comparisons calibrate thresholds. -- Risk: section concurrency weakens slide-to-slide continuity. Mitigation: keep - one validated global blueprint, provide each section its adjacent boundary - plan items, preserve exact ordered slide keys, and compare rendered contact - sheets rather than relying on JSON success alone. +- Risk: 20 sequential slide calls are slow. Mitigation: measure before changing + orchestration, keep Flash/no-thinking, compact context, and avoid a mandatory + second model pass. Consider bounded parallel composition only after the richer + blueprint proves that generated-previous-slide context is not required. - Stop prompt tuning if a failure is caused by schema/sanitizer/rendering behavior; fix the contractual layer first. - Stop schema expansion if the same outcome can be enforced more safely in Dart. diff --git a/packages/superdeck/test/goldens/debug_layout_overlay.png b/packages/superdeck/test/goldens/debug_layout_overlay.png deleted file mode 100644 index aefc5e583d9054c801a560875e2a7d739d117278..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10037 zcmdT~dpy+X{{QNx(x$R!6&jt>o+PR^}$s(7Tp<%`tGxK}C)V@y6p3ZAqo!9R-|2)s*@qC}>`CQ-c&-Z)v zsI~cL>*dx%5cHYFx284_v>J3*JYTy8G!#sqEofFDzqUBB7W~j_-G2mQ38anr*HCV& z!YBmofGkYEIuUkfq7VBcorf8jqD;g^GQZn9^r&h}u_?NdkyTHCb!)a(Y;b=4=FM}BbVtEvC% zQFPmtLL1xiIXx|&`Izb9Clfq<8apz?Il@3xaEpQ_vW2bJL*_d|&!HqYDr}Vn$AfmX zTQMczP84fx6x?pIf>yyp*A+Y9;VKyjp5BoRt%Zlxt4!c#?Jj5qXiPLDHo?Om)+8Yp7s9)TJ)ZVd61Y?hOxmWTy)4oR za8yU}SiTs2#$aEcT>oud-HE>Pz@IrS{0wr2zwAQGJe zf#`NsGAW*wdIL;!Jln$nK_Ir2=Q{P$+t^cGc|!ll07q?YoK=V=Z_p{+DBxmwTBRbp zVDM?Y(1s>9D0O02eZP`d1n&Xm`G!krz@1NS5KHb@IdxU1o)80X4V4Y7H-X!w<~M_# zkwW5(Y&^F>-G8MfxhQ95nPZvbcnIMZ)auZ7b5ak33ZaP+B*b|2vqa|B7?YQ6r%uwS@qA^M1DGIV1C>7n5& zzn%Z|xc&pZuWDw$@HKBh1ROAXJhQ#UF`^~Ox)7XnSp8)7ltFP?DW0oM!`({TI^+jk(hQwu#_oi#q8dAK1U!gVJBm zi8Xq|KCCy1lgshf;> ztZ}E8KTwTRYKjC@Hazo6J-kxa3B6_qY#gh4yHp^InH2Qm<=mT2n_E6dKmg0ra+LU} z0QS-eFSPE8i!-DL#5rFhY;U3aL>DpK?BE-;jMp`un~caW>z{opKiEss;EnCf(e6f1 zJyQ=9lol5UYC^A{%?#N!d5yNw+^UN7CMO;53tSc~h%>hZwjp;~Y=(2`ASL(rgYJ>T zt6mcE?2e2Mgam68$%>rO;zLo)p_#uqb+=LUBIkQU=iB}g1z&q<@a1d+e|}z*FLmE1 z9)kP^ax%#T^X`-H#IVrH1bIk66Gpim*uZ~tfIkxTK{TR2c9XU4L`Xo%!GYVIat%R} zxKRK0e12m%2%Us2m4s+-tic3JavAn~KS`Y;ae*W>Vq4P<9Z;9M)?E7Pw4>u*6%`eN zc}jPCyUl4QCslcQc`$lbQ}gv{7Z-I|b>}CBO{T*mBV<>N-M+rQVD8%*Jfg8+<-rbL)mC8qJ=0jMdn9Q^_}8Bj0*&Wk5naLzXT!fCyGPVr$q!y62SW^{$x1hkL=ooGXxs8%0!N| zc7C*KcKy$!!tShunVA{dPUyJVf{EQR2rmGM0E!J;wqy(YMDGy26iG1m^4Hj!P*+!1 zP*7llO1P@3s+!o@*;$aEe+-QF?%fNYC3xyp9WB$!Ju;HGVa=t2q9R*Vf}m@^s1K=6 z`Mzvkp?fvr34Hkr>bs!yh0|7Ho-qtH`r@2)Poh?3clY2uedC|s#)dth7!#Qroogj{ zsS8TeADx{VB$y|6@25B)R*sQY%r&gRu%F%7Nic`*uFm+l@C;7!>7eN!F^N9`h=Da# zh3H}CE+`2C0s?N8mX=y``>zUdPUW^-HiOY4%1U_CZmFKAVwZcNNIV#C?2JH9R=0AG zlt`Bu)fXB|tGN&zog?`}tz7l21PXdsYv^V5r8Fkn2uR1(W}n1lbBb2l1ZuQBEBN*E zdkYVbsXddDmd5lG+Z@)ziqPT08LED-_=>Fa?rYbsr7<&t6fa<|dt(vwvWt&7v(p3| zCTCjECrlh$d}jZO4dB_!$OiHvVHV;lm!=<@%UzrE88E$)l2f*}wm$Ki`B_<61!ZOS z+S=M+bmPjED+NTNvzl|oDPXqE6iN~Zja^+`KJhC|q~Ks-_43!oo4}|bucylqs!Fa* zh53n9H^kIh^Auyv(pW$^Rsx;|Q^0<@Zvv2)s$T$%>sCV|fs5VUX=!OmFppB*ZV;9S zdonbc{x0Qy3hI>SWSwEu*GNoFP4x%}$h}z^dRFX9%}o>vc%emNYlT`&9wEs&;-O<{ zaMlYqSZ6Pkf#d2Svu|S5oytyZ+;nfHZ>nuj1oL;GL7WuL=v>$Qle zL8tEb0r+3|?M+VKLnfZp2!vi>BM9m8O{)&PwFaOa=y;Fnv`8f;uzPHiSs`hifi@=hPC5eWp zB0g$}!f*~KtjoYmn)2uLv6VtRVfu=6`fe9rUGpR@k}#j-r=Uo?Ga?&zXKXJdYU7E2 zo}%>yr}uWNY88k36;54x%uX;CiC(-V@2L$ON_1)tk(8T$=>S50N#YuYvHHXwBR9G1 z7b=RCwg?QCsSJgw|6yhl)jEyB$5L6|`P@Oa;M>P~Mxv^=dT{&Z%68w1qLya=uA&)9 zNi6QBo`E*2V;IQD>Y7!dLn|_s;7@U|qg2bq$1c!FTm6L)alG~;tSV20i#)^XG#|5q zt8;l$jxLc|*KZ+&1RO=wsdHGYLeZwV&5lxT^#o$@mquycS`9hshIY}|Ilh@M%9ww? z2leJ&J(8{~IJXV!N}QU@KG#1V(8H-a;x8cj6Z9xfM%w*`l)?LUtl2V^Whx9lf9glN zNyv1QE|$yeoQpfBRyk~`WY6fZ;uIc;9Vulthw4)nia^C!_--_R73!R^?odknQf z>1G}KUO{8LC0h~O?H8SrqB+M?^erk%(BzO@)IIk56ou{giS%$u<dogXs&TDLw_9Kk$P@Lsyc#ShZX#N z2RKnxWf^h)@HSOLd(?*H?uBB|#M|(b%n!32;64-bzl?rs?ed;2|)A3yeRbISyy)2B}D9v>ecdR2D~F1F0i zqql8Xaw8!#!hpe_@E=%LcPg(4uy(1a~9z4<5OI0NOJz5wSA1>-*fK&8%730d7D2)*feik9x+kQdV<5j8hjDS zGL#(SAn&SCzVAD88@myL`ZhuO!Eb(T1n|o$Z6qB9xFPVu^A)RMSpBDA^M3)ZE{T=P zXBMC|_{+f$)C^$iQ{CkpF`5-T)RfrD#m-MPkS|Fab@u>n@D;Toj7gw2U6GPK*eibB zmN}H)fEf1^Ulvp`4$Z{xWAdtp$Vp_fI*HyR0sslI!qvR>j^2w#Vv?HEo|ui>$K2Uz z-B?|PTRlBJtz5=cw-V{15YasU&D=N+@J=c93bM;%EdWcfWR`%lPbRzf9eJT>Y?UF6dHv1M^yrH;7EI~6 zn;;1 z?j8R5XUzH=5`{u?9!f_6F?R5p%t`hLjF?SDtc>ElT#|7YyxPQ5bcSO-bYBPC<;Bdq z-sD$1ej+zMAQ7Y0B2MDYy*}5YgTLQKJGLz;rQNr%h@7G*w>P1B;(Idyphp-qjtRf7 zR9YuJGnVZr5@_zpTp*U#SitvMV>50kI%2?Lm<1Z7<2K-K>ENCmuy;6!-q;o9xA0ZN zg<%3#KV9L`@0W{kq>YR0(x#`su$0^q#+Ui?jld5c3o=ikfe z>O|(%yJhMaP1TolS_ADsRUJX^*8tvmaRm4XWD9wo+y&#qJaf z$jg1W%S<`*409hiNAi|;*xJEaq9oB7S8RB5B5EqkppBV5in@as)Ewx8>9L7a?Z2En zYqw)X^;EH?UXZK!R&hC+?+FU&l9Bu?<@34!ASY4D)tA_USX697R>!az`m*KjB` zU`Agp(!ajEndV+^bGS5O#-2YfODr5JYx^c+H&r}dT;!<3sbt^6xXj0sW;ropCP`V$ zH^zjMdlPM%CYt@W#YKm@OsX>moha3~**d+g;$atwH{#xMRM)OdjSW=|MP6rpoGu@H zXnfblY5O5l)kAF-n;e-gK|w*A6--UPm6iM|Xv^il{qW=6pF!za8((yB{RMBM!XN$V zzl2O0T5!%B!TCzj+2M+kv)xy zZ6010ezOm?sn{-u-Wk%OYp5m9GTeP`I9kc9W^(36vtxL{=S_9(85B?JjP=x9=SFmH z66!e_88JO;5yl_8*l`<({U;CdYS+j04Xx4lFg+gK_e=R25%XRlEDn0B;jOgCo#SWn zj{bN%-&u@Uf2GnXBi|;+tcCihy1F`Ov?lCCksazeI3*FOvxoX;h znO|y6@9Yi@rQN8FsTsP~6PmZUH;0AkhyeR;s?AvZ)TZY1C@_S6(pa?oFRd2eF0DJh z_>IQ9>Add)Dj&3YU6@^+=0sGzoxJZH>1SSHzH;v<{#i2oZC4bF&z7p{z8}$}a`9~a zgB5vK|Duyiy{Z|ae@6yPS6LSht$F!jz-a#^k=cpZkF*A(JwOw2P5wnP?6K)) zzYY|+&Stl)<*}aFFTP0nne{ScmKbuj{z_fegBh^%j{01*k(i?Bk;6ue_3ww=#{?&( z&tP(>ZeTL+yIG|jp#6{ce _pumpGoldenSlide( @@ -295,7 +246,6 @@ Future _pumpGoldenSlide( Slide slide, { SlideStyler? style, SlideParts? parts, - bool debug = false, }) async { tester.view.physicalSize = kResolution; tester.view.devicePixelRatio = 1.0; @@ -306,7 +256,6 @@ Future _pumpGoldenSlide( slide, style: style == null ? null : defaultSlideStyle.merge(style), widgets: builtInWidgets, - debug: debug, parts: parts ?? const SlideParts(background: ColoredBox(color: Color(0xFF090909))), From c6d55c782515db325c52d778a2fd1314187e0ab2 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 15 Jul 2026 12:21:57 -0400 Subject: [PATCH 04/15] refactor: streamline presentation generation pipeline --- .../references/verification.md | 2 +- .claude/settings.json | 1 - .planning/README.md | 29 - .planning/ai_generation_pipeline.md | 341 -------- ...ai_generation_session_status_2026_07_14.md | 261 ------ .planning/feature-templates.md | 800 ------------------ .planning/large_deck_generation_quality.md | 714 ---------------- .planning/release-1.0.md | 205 ----- .planning/superdeck-wizard.md | 194 ----- .planning/tool_sub_system.md | 146 ---- AGENTS.md | 2 - packages/playground/.planning/architecture.md | 295 ------- .../playground/.planning/editor-slice-plan.md | 112 --- packages/playground/README.md | 2 - .../assets/ai_prompts/slide_system.prompt | 2 + .../assets/ai_prompts/wizard_system.prompt | 48 +- .../prompts/generation_prompt_provider.dart | 5 +- .../services/generated_slide_validator.dart | 44 +- .../commands/generate_deck_command.dart | 29 +- .../wizard/chat/view/widgets/chat_bubble.dart | 38 +- .../chat/view/widgets/chat_genui_panels.dart | 125 +-- .../wizard/chat/view/widgets/chat_input.dart | 8 +- .../wizard/chat/view/widgets/empty_state.dart | 83 +- .../ai/catalog/ask_user_question_cards.dart | 150 +++- .../core/ai/catalog/ask_user_radio.dart | 62 +- .../core/ai/catalog/ask_user_radio.g.dart | 2 + .../core/ai/catalog/ask_user_slider.dart | 273 ++++-- .../core/ai/catalog/ask_user_style.dart | 44 +- .../ai/catalog/catalog_question_step.dart | 1 + .../wizard/core/ai/catalog/summary_card.dart | 2 +- .../core/ai/catalog/summary_card_view.dart | 118 ++- .../core/ai/catalog/wizard_option_icon.dart | 39 + .../services/ai_conversation_viewmodel.dart | 120 ++- .../services/genui_conversation_session.dart | 20 +- .../wizard/core/ui/components/sd_tokens.dart | 4 +- .../core/ui/widgets/catalog_next_button.dart | 7 +- .../wizard_generation_status.dart | 225 +++++ .../ai/wizard/presentation/wizard_page.dart | 105 ++- .../ai/wizard/presentation/wizard_view.dart | 44 +- .../macos/Runner.xcodeproj/project.pbxproj | 12 +- .../xcshareddata/xcschemes/Runner.xcscheme | 10 +- .../macos/Runner/Configs/AppInfo.xcconfig | 2 +- packages/playground/macos/Runner/Info.plist | 2 + .../macos/Runner/MainFlutterWindow.swift | 9 + packages/playground/pubspec.yaml | 1 + .../generation_prompt_provider_test.dart | 7 + .../generated_slide_validator_test.dart | 32 + .../commands/generate_deck_command_test.dart | 135 +++ .../ai/catalog/wizard_option_icon_test.dart | 22 + .../ai_conversation_viewmodel_test.dart | 102 +++ .../wizard/presentation/wizard_page_test.dart | 22 +- .../ai/wizard/wizard_polish_test.dart | 271 ++++++ .../features/ai/wizard/wizard_view_test.dart | 4 +- pubspec.lock | 8 + 54 files changed, 1751 insertions(+), 3590 deletions(-) delete mode 100644 .planning/README.md delete mode 100644 .planning/ai_generation_pipeline.md delete mode 100644 .planning/ai_generation_session_status_2026_07_14.md delete mode 100644 .planning/feature-templates.md delete mode 100644 .planning/large_deck_generation_quality.md delete mode 100644 .planning/release-1.0.md delete mode 100644 .planning/superdeck-wizard.md delete mode 100644 .planning/tool_sub_system.md delete mode 100644 packages/playground/.planning/architecture.md delete mode 100644 packages/playground/.planning/editor-slice-plan.md create mode 100644 packages/playground/lib/features/ai/wizard/core/ai/catalog/wizard_option_icon.dart create mode 100644 packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart create mode 100644 packages/playground/test/features/ai/quick_agent/domain/commands/generate_deck_command_test.dart create mode 100644 packages/playground/test/features/ai/wizard/core/ai/catalog/wizard_option_icon_test.dart create mode 100644 packages/playground/test/features/ai/wizard/core/ai/services/ai_conversation_viewmodel_test.dart create mode 100644 packages/playground/test/features/ai/wizard/wizard_polish_test.dart diff --git a/.agents/skills/superdeck-presentations/references/verification.md b/.agents/skills/superdeck-presentations/references/verification.md index 7102b33f..5ae0695b 100644 --- a/.agents/skills/superdeck-presentations/references/verification.md +++ b/.agents/skills/superdeck-presentations/references/verification.md @@ -69,7 +69,7 @@ melos run build_runner:build Validate the skill folder: ```bash -python3 /Users/leofarias/.codex/skills/.system/skill-creator/scripts/quick_validate.py .agents/skills/superdeck-presentations +python3 "${CODEX_HOME:-$HOME/.codex}/skills/.system/skill-creator/scripts/quick_validate.py" .agents/skills/superdeck-presentations ``` Also check for stale template markers: diff --git a/.claude/settings.json b/.claude/settings.json index 95aaa727..863afc79 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -19,7 +19,6 @@ "Bash(sort:*)", "Bash(wc:*)", "Bash(dcm:*)", - "Read(//Users/leofarias/.pub-cache/**)", "mcp__plugin_playwright_playwright__browser_navigate", "mcp__plugin_playwright_playwright__browser_console_messages" ] diff --git a/.planning/README.md b/.planning/README.md deleted file mode 100644 index 449570b2..00000000 --- a/.planning/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Planning & Internal Documentation - -This hidden folder (`.planning/`) contains internal development and planning documentation that is **not published** to the public website. - -## Contents - -- **feature-templates.md** - Complete implementation plan for the slide templates feature -- **markdown-extensions.md** - Architecture documentation for the markdown processing pipeline - -## Purpose - -Planning documentation includes: -- Feature implementation plans and specifications -- Architecture deep-dives and technical design documents -- Planning documents and decision records -- Development guides for contributors -- Internal notes and research - -## Public Documentation - -User-facing documentation is in: -- `docs/` - Main documentation pages (index, getting-started, examples) -- `docs/guides/` - User guides and how-to articles -- `docs/tutorials/` - Step-by-step tutorials -- `docs/reference/` - API reference documentation - ---- - -**Note:** Files in this folder may reference internal implementation details and are not intended for end users. This folder is hidden (starts with `.`) to keep it separate from public-facing content. diff --git a/.planning/ai_generation_pipeline.md b/.planning/ai_generation_pipeline.md deleted file mode 100644 index c3539feb..00000000 --- a/.planning/ai_generation_pipeline.md +++ /dev/null @@ -1,341 +0,0 @@ -# Plan: Iterative AI slide-generation pipeline - -> Replace the monolithic final-deck request with a testable outline-to-slide -> composer that uses the existing Flash models, exposes SuperDeck's real layout -> capabilities, and produces inspectable JSON, Markdown, validation, and rendered -> artifacts for rapid quality iteration. - -Status: Baseline complete; quality follow-up paused at a stabilization checkpoint - -Follow-up plan: [`large_deck_generation_quality.md`](large_deck_generation_quality.md) - -The verified-context section below records the repository state before this -baseline was implemented. Some paths were intentionally removed or replaced by -the completed work and should not be treated as current architecture. - -## Objective - -- Generate a narrative outline first, then compose and validate each slide in - sequence using the previous slide and the surrounding outline as continuity - context. -- Support the layout surface that SuperDeck can render: multi-row sections, - one-to-three-column compositions, flex, spacing, alignment, margin, padding, - Markdown tables, images, QR codes, WebViews, DartPad, and registered custom - widget blocks. -- Make prompt and output quality easy to iterate without completing the Wizard - UI by adding a live smoke-test harness that preserves every intermediate - artifact and rendered slide image. -- Keep `GenerateDeckCommand` as the shared Wizard/editor entry point and keep the - canonical `Slide`/`WidgetBlock` payload backward-compatible. - -Out of scope for the first implementation: - -- Generating image pixels. The pipeline may compose `@image` blocks from a - supplied/resolved source; image synthesis remains a separate service. -- Automatically enabling arbitrary custom widgets without an explicit schema - and validator registration. -- Running paid live-model tests in normal CI. - -## Verified context - -### What works today - -- `packages/core/lib/src/deck/slide_contract.dart` exposes the structured-output - slide projection. It already includes sections, section spacing/alignment/flex, - block alignment/flex, normalized margin/padding, scrolling, and the - content/widget discriminator. -- `packages/core/lib/src/deck/block_model.dart` is the canonical runtime contract. - `WidgetBlock` preserves non-reserved properties as widget arguments, so the - final JSON format can already represent built-in and custom widgets. -- `packages/superdeck/lib/src/builtins/widgets.dart` always registers `image`, - `dartpad`, `webview`, and `qrcode`. -- Markdown content already supports tables. Table styling is exposed by - `packages/superdeck/lib/src/styling/components/markdown_table.dart`, with a - default treatment in `packages/superdeck/lib/src/styling/default_style.dart`. -- `packages/superdeck/lib/src/capture/slide_capture_service.dart` can render a - `SlideConfiguration` to PNG, so a quality harness does not need a second - renderer. - -### What constrains generation today - -- `packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart` - runs two model calls: one outline call and one whole-deck call. A malformed - slide can invalidate the complete response, and no previous-slide context is - available during composition. -- `packages/playground/lib/features/ai/quick_agent/core/engine/schemas/outline_schema.dart` - limits layout hints to six text-oriented choices and has no table, image, - embed, or richer narrative-role intent. -- `packages/playground/assets/ai_prompts/deck_system.prompt` explicitly forbids - widget blocks, scrolling, and images even though the renderer supports them. -- `packages/core/lib/src/deck/slide_contract.dart` gives structured output only a - widget name; it cannot describe widget-specific arguments such as image `src` - or WebView `url`. Custom widget arguments therefore need an explicit - generation catalog instead of an untyped arbitrary JSON map. -- `packages/playground/lib/features/ai/quick_agent/core/engine/prompts/examples_loader.dart` - can load few-shot examples, but `DeckGeneratorService.generate` only loads the - prompt registry. No production call currently loads those examples before - formatting them. -- `packages/playground/lib/features/ai/quick_agent/domain/commands/generate_deck_command.dart` - serializes generated slides but discards `DeckGenerationResult.style`. - `packages/playground/lib/core/domain/stores/deck_customization_store.dart` - explicitly has no AI-style application path, so the Wizard's selected palette - and fonts do not reach the rendered deck. -- Current automated coverage validates schema adaptation and sanitization in - `packages/playground/test/features/ai/quick_agent/`, but there is no real-model - generation suite, prompt snapshot, per-slide orchestration test, or generated - deck visual artifact. - -## Recommended pipeline - -```text -Wizard/free-text brief - | - v -Deck plan: theme + ordered slide briefs + composition intent - | - v -Slide 1 --validate/retry--> Slide 2 --validate/retry--> ... Slide N - | each request sees: deck plan, current brief, previous result, - | and next outline item - v -Canonical List + generated DeckStyle - | - +--> Markdown/editor - +--> JSON + validation report - +--> PNG captures/contact sheet -``` - -Use the existing Flash model constants in -`packages/playground/lib/features/ai/quick_agent/core/constants/gemini_models.dart`. -Keep model selection injectable so live fixtures can compare the existing -`gemini-2.5-flash` and `gemini-3-flash-preview` configurations without changing -pipeline code. -Explicit thinking budgets stay disabled for deck generation so sequential -outline and slide calls remain fast enough for prompt iteration. - -One slide per request is deliberately sequential for v1. It gives the composer -real continuity and isolates retries to a single slide. Parallel generation is a -possible later optimization only after quality measurements show that previous- -slide context is unnecessary for some slide classes. - -## Contract decisions - -1. Keep core's canonical `Slide`, `SectionBlock`, `ContentBlock`, and - `WidgetBlock` unchanged. -2. Introduce a playground-owned structured-output draft contract for generation. - It may represent widget data as a typed `{name, args}` draft and normalize it - into the canonical flattened `WidgetBlock` payload before `Slide.parse`. -3. Add a generation element catalog. Each enabled element supplies its model - schema, prompt guidance, canonical normalizer, and semantic validator. -4. Register built-ins first: - - `image`: enabled when a source is provided or resolved; validate `src`, fit, - finite positive dimensions/scale, and portability. - - `qrcode`: enabled for explicit link/handoff intent; validate text, size, - colors, and error correction. - - `webview`: enabled only when the user supplies an HTTP(S) URL; never invent - an arbitrary embed URL. - - `dartpad`: enabled only when the user supplies a gist ID. -5. Let applications opt custom widgets into generation by registering a named - descriptor. Unknown widget names fail validation instead of rendering a - `Widget not found` placeholder. -6. Keep tables as Markdown content rather than creating a table widget. Add - table-specific composition guidance and validation for consistent columns, - bounded row count, and readable cell density. - -## Work breakdown - -- [x] Task 1: Make model calls and prompts observable and testable - - Scope: `packages/playground/lib/features/ai/quick_agent/core/engine/` - - Introduce an injectable model-client boundary rather than constructing the - Google service inside the orchestration method. - - Split prompt construction into pure builders and load `PromptRegistry` - deterministically before generation. - - Add structured trace events for phase, model, elapsed time, attempt, slide - index/count, rendered prompt, raw response, and validation result. Never log - the API key. - - Acceptance: a fake client can run the complete pipeline deterministically, - and prompt text can be snapshot-tested without the network. - - Verification: `fvm flutter test test/features/ai/quick_agent` - -- [x] Task 2: Replace the outline with a deck plan - - Scope: `outline_schema.dart`, `outline_system.prompt`, generated schema files, - and their tests. - - Preserve `key`, `title`, and `purpose`; add narrative role, content brief, - composition intent, and optional element requirements. Include explicit - table, split-image, image-led, metric, quote, and embed intents without - exposing raw Flutter layout details at the planning phase. - - Generate the deck palette/fonts once in this phase so every slide shares one - visual system. - - Acceptance: the plan has the exact requested slide count, unique stable keys, - a coherent opening/body/closing sequence, and enough intent to compose each - slide independently. - - Verification: schema/adapter tests plus fixture snapshots for three briefs. - -- [x] Task 3: Compose and retry one slide at a time - - Scope: `deck_generator_service.dart`, `deck_generator_pipeline.dart`, - `deck_generator_workflow.dart`, `generation_progress.dart`, and new - slide-composer files colocated in the service domain. - - For each slide, send the compact deck plan, current slide brief, previous - canonical slide (or a bounded summary), and the next outline item. - - Parse and validate immediately. Feed validation errors back to the same Flash - model for a bounded repair retry, then stop with a precise slide-level error. - - Preserve cancellation between calls. Report `Composing slide i of n` and - `Repairing slide i of n` through progress instead of a single generic deck - phase. - - Acceptance: one invalid slide retries without regenerating prior slides, keys - and order match the plan, and the final result contains exactly the planned - count. - - Verification: fake-client orchestration tests for ordering, continuity, - cancellation, retry, and partial-failure behavior. - -- Checkpoint: run the fake pipeline and inspect saved outline/per-slide prompts. - Do not enable richer elements until basic sequential text/table decks are - deterministic and valid. - -- [x] Task 4: Expose layouts, tables, and generation-capable elements - - Scope: generation draft schemas/catalog, the single-slide prompt and - positive examples, sanitizer/normalizer tests. - - Teach composition about section rows, flex columns, spacing, alignment, - normalized insets, fullscreen slides, Markdown tables, and the registered - element catalog. - - Replace blanket widget/image prohibitions with capability-aware rules. - - Add positive few-shot examples for a readable comparison table, a text/image - split, a QR handoff, and a supplied-URL WebView. Remove examples that teach - repetitive title/body layouts as the only safe shape. - - Acceptance: final normalized JSON parses through `Slide.parse`; widget args - survive as canonical `WidgetBlock.args`; generated tables render as Markdown; - no unsupported widget is emitted. - - Verification: schema parity tests, normalizer tests, and renderer tests using - static fixture payloads. - -- [x] Task 5: Apply generated visual style and make tables presentation-ready - - Scope: `GenerateDeckCommand`, `DeckCustomizationStore`, provider wiring, and - `test/core/deck_customization_store_test.dart`. - - Add a typed **applyGeneratedStyle** method that maps the generated - palette/fonts to the existing customization state and pushes one coherent - `DeckOptions`. - - Derive table header/body colors, border contrast, cell padding, blockquote, - code, and link colors from the same palette rather than leaving white default - component styles on every background. - - Ensure manual customization continues to work after generation. - - Acceptance: Wizard-selected colors/fonts affect the loaded deck, and a table - remains legible on both light and dark generated backgrounds. - - Verification: store unit tests and light/dark table widget captures. - -- [x] Task 6: Add a live generation lab and artifact bundle - - Scope: a proposed **packages/playground/test_live/ai_generation/** folder, a - debug-only playground route/surface, `.gitignore`, and - `packages/playground/README.md`. - - Keep live tests outside `test/` so `melos run test` never spends API quota. - - Add three versioned prompts: narrative deck, comparison/table deck, and - visual/element deck. Allow an individual fixture or all fixtures to run. - - Save a run directory containing the user brief, deck plan, rendered prompt - per slide, raw response per slide, canonical deck JSON, `slides.md`, validation - report, timing/model metadata, PNG captures, and a contact sheet. - - Reuse `SlideCaptureService` for captures. Static WebView/DartPad placeholders - are expected in screenshots. - - Add a debug-only in-app lab with the same three presets, per-slide progress, - the generated thumbnail gallery, and expandable raw JSON/validation errors. - The lab calls the production service; it must not duplicate generation logic. - - Acceptance: one command produces a reviewable artifact directory, and the - debug UI can reproduce the same fixture interactively. - - Verification: - - ```bash - fvm flutter test test_live/ai_generation/ai_generation_smoke_test.dart \ - --dart-define-from-file=../../.env --reporter expanded - ``` - -- Checkpoint: review the three contact sheets and trace bundles. Tune prompts and - examples using recorded failures; change schemas only when a failure is truly - contractual rather than aesthetic. - -- [x] Task 7: Regression and cleanup pass - - Remove the old whole-deck generation path, obsolete `generatingFinalDeck` - state, stale text-only prompt rules, and dead helpers only after the new path - is green. - - Regenerate Ack outputs and update documentation to describe the actual - outline-to-slide pipeline and live-test command. - - Run focused tests, full playground tests, analysis, and one final live smoke - bundle before merge. - -## Test strategy - -- Unit: draft schema adaptation, element catalog validation, canonical - normalization, plan validation, table validation, per-slide prompt assembly, - progress, retries, cancellation, and generated style application. -- Integration with a fake model: deterministic plan plus three slides; assert - request order and that slide 2 sees slide 1 while slide 1 does not see future - generated content. -- Renderer fixtures: table, two-row/two-column, image, QR, and WebView placeholder - decks rendered with both light and dark generated styles. -- Live smoke: the three opt-in fixtures using the repository `.env`; inspect JSON, - Markdown, validation, PNGs, and contact sheet. Record the model name in every - artifact bundle so comparisons are meaningful. -- Commands: - - ```bash - cd packages/playground - fvm flutter test test/features/ai/quick_agent test/core/deck_customization_store_test.dart - fvm flutter test test_live/ai_generation/ai_generation_smoke_test.dart \ - --dart-define-from-file=../../.env --reporter expanded - cd ../.. - fvm dart run melos run analyze - fvm dart run melos run test - ``` - -## Quality gates - -A live fixture passes structurally only when: - -- The requested slide count and outline key order match exactly. -- Every response parses through the draft schema and then canonical `Slide.parse`. -- No slide is empty; no unsupported widget or invalid widget argument survives. -- Tables have consistent column counts and bounded density. -- Layout intent is not repeated mechanically across the deck. -- Every slide renders to PNG without an exception; contact-sheet inspection finds - no visible clipping or overflow. -- The generated style is applied, and basic text/background contrast checks pass. - -Visual quality is still a human-reviewed gate. The contact sheet is the primary -review surface; numeric heuristics should reject obviously broken decks, not claim -that a deck is aesthetically good. - -## Alternatives considered - -- Keep one whole-deck call: lower latency, but failures and retries remain - deck-wide and per-slide continuity/layout control stays weak. -- Generate slides fully in parallel: faster, but each slide loses the preceding - visual/narrative context requested for this workflow. -- Generate raw `slides.md`: simpler model output, but weaker structured - validation and harder repair of one malformed slide. -- Put every possible widget argument directly in core's canonical AI projection: - couples a Dart-only core contract to current built-ins and still cannot safely - describe application-specific widgets. - -## Compatibility and rollout - -- No breaking change to compiled deck JSON or public SuperDeck rendering models. -- `GenerateDeckCommand(String prompt)` remains the Wizard/editor contract. -- Land sequential text/table composition first, then enable built-in elements - through the catalog one at a time. -- Keep the existing whole-deck implementation only until the sequential path - passes fake integration tests and all three live fixtures; then remove it in the - same feature branch rather than carrying a permanent legacy path. -- Rollback is a single pipeline switch before legacy removal. After removal, - revert the pipeline commit; no persisted-data migration is required. - -## Risks and stop conditions - -- More calls increase latency and quota use. Record per-phase latency/token-like - response metrics, use Flash models, and cap repair retries. -- Passing full previous-slide JSON can grow prompts. Start with one previous slide - plus the compact plan; add summarization only if traces show context pressure. -- Images and WebViews are nondeterministic in live rendering. Validate sources, - use static placeholders during capture where appropriate, and never infer an - arbitrary embed URL. -- Stop element expansion if the Google structured-output adapter cannot represent - a descriptor without weakening validation. Keep that element disabled until a - typed draft mapping exists. -- Stop prompt tuning when a failure is renderer/schema related; fix the contract - or renderer fixture instead of adding contradictory prompt prose. diff --git a/.planning/ai_generation_session_status_2026_07_14.md b/.planning/ai_generation_session_status_2026_07_14.md deleted file mode 100644 index 4d239c14..00000000 --- a/.planning/ai_generation_session_status_2026_07_14.md +++ /dev/null @@ -1,261 +0,0 @@ -# AI generation session status — 2026-07-14 - -Status: Tasks 1–6, the 12-theme family, representative render qualification, -and deterministic ten-slide generation are verified; repeated real 10/15/20 -quality runs and final human acceptance remain open - -This document is the resume point for the iterative SuperDeck AI-generation -work. It separates what is implemented and verified from what still needs live -rendered evidence. The implementation cleanup is complete, but the larger -prompt-quality objective is not complete yet. - -## Git checkpoint - -- Branch: `leoafarias/iterative-ai-generation` -- Remote: `origin/leoafarias/iterative-ai-generation` -- Checkpoint: `cf463294c09f54e0133e4ff7ce68f228a3c4b1b5` -- Commit: `feat: improve AI deck generation quality` -- Base: `8b12ab009919fcbc9824d1d3719760b47fa03942` - (`origin/main` when the branch was created) -- Checkpoint size: 86 files, 12,452 insertions, 1,764 deletions -- Live artifact output is ignored and remains local under - `packages/playground/test_live/ai_generation/artifacts/`. - -The checkpoint was pushed before this housekeeping pass. The current working -tree contains the subsequent generation/capture cleanup, regression tests, -planning corrections, and static-analysis hygiene changes. It is intentionally -not folded back into the checkpoint commit yet. - -## Product and architecture decisions to preserve - -- Keep a plan-first pipeline: one hierarchical deck blueprint followed by one - sequential request per slide. -- Keep previous-slide, next-slide, section, and compact design-ledger context so - visual and narrative decisions remain coherent across 10–20 slides. -- Keep `gemini-3-flash-preview` as the default outline and slide model. -- Keep explicit thinking budgets disabled for generation latency. -- Keep the canonical runtime `Slide` and Markdown contracts stable. Generation- - only request, plan, theme, and validation contracts can evolve directly. -- Keep deterministic checks as the production gate. Model-authored visual review - remains opt-in and informational until it correlates with human review. -- Do not introduce a mandatory critic call or parallel slide composition until - measurements justify the additional cost or loss of continuity. - -## Implemented at the checkpoint - -### Request and planning - -- A typed generation request carries exact slide count, user intent, palette, - fonts, design direction, density, and grounded elements. -- The outline schema supports sections, narrative roles, assertions, content - units, composition families, treatments, density, and a deck design system. -- Plan validation enforces requested counts, section membership, font resolution, - palette contrast, layout rhythm, and grounded element intent. - -### Slide composition - -- The whole-deck generation request was replaced with sequential single-slide - composition and slide-scoped repair. -- Prompt assembly is centralized and deterministic. Each request receives only - the relevant composition example and exact grounded element source. -- Model-facing schemas expose the supported SuperDeck block surface while Dart - validation retains bounds that Gemini cannot accept in the most complex - nested slide schema. -- Generated-slide validation covers parseability, non-empty content, planned - composition, density, tables, title/display treatment, exact element counts, - domains, numeric facts, and source commitments. - -### Design and typography - -- One typography catalog is shared across generation and customization. -- One injectable presentation-theme catalog now owns stable IDs/versions, - selection descriptions/tags, complete runtime recipes, and exact resolution. - The same profile-owned instance builds the Wizard prompt, GenUI schemas and - examples, selection cards, summary cards, generation validation, runtime - mapping, and live artifacts. A custom-ID regression proves those surfaces do - not silently fall back to the default catalog. -- The outline model receives only a deterministic compact candidate shortlist - and returns one eligible ID. Dart attaches the version, density, and validated - user-only brand override before any slide is composed. -- The former model-authored style object has been removed from the generation - plan, prompt, serializer, runtime result, Wizard mapping, and live artifact - reader. Twelve materially distinct themes work end to end and resolve all - seven semantic slide treatments. -- Known Google families and application-registered bundled custom families are - supported; invented families fail explicitly. -- Generated palettes and semantic treatments map into presentation-scale - `DeckOptions`, including tables, lists, quotes, code, links, and light/dark - contrast behavior. - -### Observability and live evaluation - -- Generation emits structured trace events for model, phase, request/response, - semantic attempt, transport attempt, timing, typed validation, and slide - position while retaining the legacy attempt field for JSON compatibility. -- One shared executor now enforces run-wide provider-call, semantic-repair, and - wall-clock budgets. Cooperative cancellation is connected to the generation - command and lab lifecycles. -- The opt-in live harness supports fast smoke fixtures plus 10-, 15-, and - 20-slide quality fixtures. -- Each complete run can save the typed request, source brief, plan, prompts, - responses, canonical JSON, Markdown, trace, metadata, slide PNGs, contact - sheet, and machine-readable quality report. -- Captures resolve the selected font family instead of registering Roboto under - an alias. -- Isolated capture now waits for explicitly registered asynchronous image/asset - readiness within a bounded settle loop and reports pending labels on timeout. - -## Fresh verification after cleanup - -| Check | Result | -|---|---| -| `fvm dart run melos run build_runner:build --no-select` | Passed; generated files synchronized | -| Changed-file formatter and Dart fix previews | Passed; 35 Dart files checked, 3 formatted, nothing left for Dart fix | -| `fvm dart run melos run analyze:all --no-select` | Passed across 8 packages; Dart, DCM, unused-code, and unused-file gates clean | -| Focused generation/Wizard regressions | Passed; 99 tests | -| Focused capture/image/WebView regressions | Passed; 70 tests | -| `fvm dart run melos run test --no-select` | Passed across all 8 packages | -| Theme catalog/schema/Wizard contract proof | Passed; 15 focused tests | -| Broader theme/generation regressions | Passed; 76 focused tests | -| Saved 20-slide artifact replay | Passed current semantic validation and recaptured 20/20 slides | -| Contact sheet plus full-size slides 1, 12, 18, and 20 | Reviewed; no clipping, overflow, missing assets, or font substitution observed | -| `git diff --check` | Passed after discarding codegen-only trailing-blank-line churn | -| Focused AI/theme/Wizard regression suite after catalog injection fix | Passed; 187 tests | -| Deterministic ten-slide fake generation checkpoint | Passed; one outline plus ten slide calls, zero repairs, ten captures | -| Twelve-theme render qualification | Passed; 30 full-size captures, three contact sheets, and three opt-in golden baselines | -| Light/dark syntax contrast regression | Passed; resolved code backgrounds select the correct palette and every rendered token reaches at least 4.5:1 contrast | - -Latest deterministic artifact evidence: - -- `fake_checkpoint_10_2026-07-14T22-03-52.113007Z` -- `theme_qualification_2026-07-14T22-39-00.146149Z` - -The build reports an existing compatibility warning: the Dart 3.12 SDK language -version is newer than analyzer language version 3.11. This did not fail code -generation, analysis, or tests and was not addressed by this feature branch. - -Not yet verified: - -- two successful live runs for every 10/15/20 quality fixture; -- a deterministic render-overflow signal for live capture; -- final human acceptance across fresh narrative, typography, visual balance, - tables, elements, and cross-slide variation runs. - -## Live artifact evidence - -### Best current 20-slide artifact - -`packages/playground/test_live/ai_generation/artifacts/visual_product_20_2026-07-14T12-53-20.929304Z/` - -- Generated and captured 20/20 slides. -- Used ten composition families and the requested Montserrat/DM Sans pairing. -- Exact image and QR elements were preserved. -- Replayed under the cleanup implementation and passed current semantic - validation, Markdown parsing, and 20/20 recapture. -- The regenerated contact sheet and representative full-size title, evidence, - table, and CTA slides showed no clipping, overflow, missing assets, or font - substitution during manual review. - -### Latest completed 10-slide artifact - -`packages/playground/test_live/ai_generation/artifacts/narrative_10_2026-07-14T13-17-58.436280Z/` - -- Automated quality reporting passed at generation time. -- Manual review found the opening title clipped at the top. -- The run used 33 model requests: 14 outline-phase and 19 slide-phase requests, - including substantial repair traffic. -- This artifact is evidence that structural validation alone is not a visual - quality gate; it is not an accepted final result. - -### 15-slide status - -- Two decision/data runs were started but did not produce an accepted complete - artifact. -- They exposed decimal tokenization and repeated targeted-repair timeout issues. -- Decimal identity is fixed and regression-tested. Timeout/repair policy remains - unresolved. - -There are approximately 60 MB of ignored local run artifacts. They were retained -because they contain useful failure evidence; do not delete them until the best -and known-bad examples are deliberately curated. - -## Post-checkpoint housekeeping - -- Corrected live artifact metadata so `requestCount` is the total across outline - and slide phases, with both phase counts recorded separately. -- Added typed validation issues so hard factual/grounding failures remain - blocking while soft design-quality findings remain diagnostic. -- Centralized model execution, bounded requests/repairs/time, split semantic - versus transport retry tracing, and wired cooperative UI cancellation. -- Extracted standalone validator/helper and generated-style-mapping boundaries; - retained the pipeline/workflow/repair part files because their private state - is still one cohesive implementation. -- Replaced capture timing heuristics with bounded readiness registration for - built-in cached and resolved asset images. -- Added debug-only Wizard access to the generation lab and documented offline - artifact replay. -- Enabled the current DCM configuration in Playground and recorded 1,142 exact - pre-existing findings in `dcm_baseline.json`; all new/unbaselined rules and - unused-code/file checks pass. -- Kept generated live artifacts ignored and untouched. -- Regenerated code, formatted changed files, ran analyzer fix previews, passed - full workspace analysis/tests, replayed the best 20-slide artifact, and - inspected representative rendered output. - -`dcm fix` does not honor the analysis baseline and still proposes broad legacy -rewrites in Playground. Do not run the repository-wide fix script on this branch -without deliberately accepting that separate migration; use changed-file -formatting plus analyzer/DCM gates for this incremental cleanup. - -## Unresolved issues, in priority order - -1. **Complete the live matrix.** Generate, replay, capture, and manually inspect - fresh 10-, 15-, and 20-slide fixtures at least twice each. -2. **Tune from measured live outcomes.** Use trace request counts, typed issue - distributions, contact sheets, and full-size slide review to decide whether - prompt/schema changes are justified; avoid adding retries or critic calls by - intuition alone. -3. **Complete the final regression audit.** Re-run codegen, focused and full - tests, workspace analysis, `git diff --check`, and requirement-by-requirement - plan review after any tuning from the live matrix. -4. **Curate retained artifacts.** Keep the accepted 20-slide and known-bad - 10-slide evidence, then remove redundant ignored runs once comparisons are - complete. -5. **Review source-grounding structure after policy is settled.** - `source_grounding.dart` is large because policy and tokenization are mixed. - Split it only after hard versus soft behavior is explicit and covered by - tests; a mechanical file split first would hide the policy problem. - -## Resume sequence - -1. Run fresh 10/15/20 fixtures twice each, review full-size slides and contact - sheets, and update the quality plan with measured outcomes. -2. Tune prompts or schemas only for repeated measured failures, then rerun the - deterministic gates and affected live fixtures. -3. Complete the final regression and plan audit. Keep deferred image-style work - out of this pass unless it is separately activated. - -## Useful commands - -```bash -# Generate and validate code. -fvm dart run melos run build_runner:build --no-select -fvm dart run melos run analyze:all --no-select -fvm dart run melos run test --no-select - -# Focused deterministic tests. -cd packages/playground -fvm flutter test test/features/ai/quick_agent --reporter expanded -fvm flutter test test/core/deck_customization_store_test.dart --reporter expanded - -# Full large-deck live matrix. This uses the real API and writes ignored output. -fvm flutter test test_live/ai_generation/ai_generation_smoke_test.dart \ - --dart-define-from-file=../../.env \ - --dart-define=LIVE_FIXTURE=large_deck_matrix \ - --reporter expanded - -# Replay an existing artifact without another model generation call. -fvm flutter test test_live/ai_generation/ai_generation_smoke_test.dart \ - --dart-define=LIVE_ARTIFACT= \ - --reporter expanded -``` diff --git a/.planning/feature-templates.md b/.planning/feature-templates.md deleted file mode 100644 index b3245715..00000000 --- a/.planning/feature-templates.md +++ /dev/null @@ -1,800 +0,0 @@ -# Templates Feature – Implementation Plan - -## Executive Summary - -Templates provide reusable **chrome configurations** (background, header, footer) bundled with **isolated style systems** for consistent slide presentation. Templates are like Keynote master slides - they define the visual frame and default styling, while users continue writing markdown content using `@section`/`@block` directives normally. - -**Key Principle:** Templates control the **decorative layer** (chrome + styling), not the content layout. The existing `@section`/`@block` system remains unchanged. - ---- - -## Decisions Made - -### 1. Template Scope: Chrome Only -- **Chrome Elements:** Background, header, footer (via `SlideParts`) -- **NOT in scope:** Slot definitions, layout restructuring, replacing `@section`/`@block` -- **Rationale:** Keep templates simple, focused, and non-invasive to existing markdown authoring - -### 2. Isolated Style Systems -- Templates bundle their own style hierarchy: `baseStyle` + `Map styles` -- When a template is used, deck-level styles are ignored for that slide -- Style resolution: `defaultSlideStyle → template.baseStyle → template.styles[slide.style]` -- **Rationale:** Templates provide complete visual control without style conflicts - -### 3. Mutual Exclusivity -- `template:` and `style:` in frontmatter are mutually exclusive at the top level -- **Exception:** When using a template, `style:` can reference a named style within that template -- Example: `template: corporate` + `style: emphasis` → uses `corporate.styles['emphasis']` - -### 4. Default Template Support -- `DeckOptions.defaultTemplate` allows applying a template to all slides by default -- Individual slides can opt-out by specifying a different template or no template -- **Backward Compatibility:** Without `defaultTemplate`, existing slides render unchanged - -### 5. Build-Time Validation -- Unknown template name → build error with available templates listed -- Unknown style within template → build error with available styles listed -- Clear, actionable error messages guide developers - -### 6. Architecture: Clean Separation -- **SlideTemplate:** Data class bundling parts + styles -- **TemplateResolver:** Service class handling resolution logic and validation -- **TemplateException:** Dedicated exception type for template errors -- **SlideConfigurationBuilder:** Delegates to resolver for style/parts resolution - ---- - -## Core Data Models - -### SlideTemplate -```dart -/// A reusable slide template bundling chrome and styles. -/// -/// Templates are isolated style systems that provide complete visual -/// control over slides. They include: -/// - Chrome elements (header, footer, background) -/// - Base styling applied to all slides using this template -/// - Named style variants for different slide types -final class SlideTemplate { - /// Chrome elements for this template. - final SlideParts parts; - - /// Base style applied to all slides using this template. - /// Merged after defaultSlideStyle but before named styles. - final SlideStyler? baseStyle; - - /// Named style variants available within this template. - /// Keys match the `style:` field in slide frontmatter. - final Map styles; - - const SlideTemplate({ - this.parts = const SlideParts(), - this.baseStyle, - this.styles = const {}, - }); - - SlideTemplate copyWith({...}); -} -``` - -### Enhanced DeckOptions -```dart -class DeckOptions { - // Existing fields - final SlideStyler? baseStyle; - final Map styles; - final Map widgets; - final SlideParts parts; - final bool debug; - - // NEW: Template support - /// Registry of available templates. - /// Keys are template names referenced in frontmatter. - final Map templates; - - /// Default template applied to slides without explicit template. - /// Optional - without this, slides use deck-level styles/parts. - final SlideTemplate? defaultTemplate; -} -``` - -### Enhanced SlideOptions -```dart -class SlideOptions { - final String? title; - - /// Style name - references deck styles OR template styles. - /// - Without template: looks in DeckOptions.styles - /// - With template: looks in template.styles - final String? style; - - /// Template name - references DeckOptions.templates. - /// Mutually exclusive with using deck-level styles. - final String? template; - - final Map args; -} -``` - ---- - -## Architecture Components - -### Layer 1: Resolution Service (NEW) - -**File:** `packages/superdeck/lib/src/deck/template_resolver.dart` - -**Responsibilities:** -- Validate template existence -- Validate style existence (within template or deck) -- Enforce mutual exclusivity rules -- Compute final merged style -- Resolve parts (template or deck) -- Generate actionable error messages - -**Key Methods:** -```dart -class TemplateResolver { - final DeckOptions options; - - const TemplateResolver(this.options); - - /// Resolves template and computes final style/parts for a slide. - /// - /// Throws [TemplateException] if: - /// - Template name not found - /// - Style name not found (in template or deck) - /// - Both template and deck style specified (mutual exclusivity) - TemplateResolutionResult resolve(SlideOptions? slideOptions); -} - -class TemplateResolutionResult { - final SlideStyler style; // Fully merged style - final SlideParts parts; // Resolved parts (template or deck) - final bool usingTemplate; // True if template was applied -} -``` - -**Style Resolution Logic:** -```dart -// WITH TEMPLATE: -defaultSlideStyle - .merge(template.baseStyle) - .merge(template.styles[slide.style]) // If slide.style specified - -// WITHOUT TEMPLATE (existing behavior): -defaultSlideStyle - .merge(deckOptions.baseStyle) - .merge(deckOptions.styles[slide.style]) // If slide.style specified -``` - -### Layer 2: Configuration Builder (MODIFIED) - -**File:** `packages/superdeck/lib/src/deck/slide_configuration_builder.dart` - -**Changes:** -- Create `TemplateResolver` instance -- Delegate style/parts resolution to resolver -- Use resolved values in `SlideConfiguration` - -**Modified Logic:** -```dart -SlideConfiguration _buildConfiguration( - int index, - Slide slide, - DeckOptions options, - TemplateResolver resolver, // NEW parameter -) { - // ... existing widget collection ... - - // NEW: Delegate to resolver - final resolution = resolver.resolve(slide.options); - - return SlideConfiguration( - slideIndex: index, - style: resolution.style, // From resolver - slide: slide, - widgets: widgets, - thumbnailFile: thumbnailPath, - parts: resolution.parts, // From resolver - debug: options.debug, - ); -} -``` - -### Layer 3: Exception Handling (NEW) - -**File:** `packages/superdeck/lib/src/deck/template_exception.dart` - -```dart -/// Exception thrown when template resolution fails. -class TemplateException implements Exception { - final String message; - - const TemplateException(this.message); - - @override - String toString() => 'TemplateException: $message'; -} -``` - ---- - -## Data Flow - -### Build-Time Flow -``` -1. slides.md - --- - template: corporate - style: title - --- - # My Slide - -2. MarkdownParser → RawSlideMarkdown - frontmatter: {template: "corporate", style: "title"} - -3. SlideProcessor → Slide - options: SlideOptions(template: "corporate", style: "title") - -4. SlideConfigurationBuilder.buildConfigurations() - ├─ Create TemplateResolver(options) - └─ For each slide: - ├─ resolver.resolve(slide.options) - │ ├─ Validate template "corporate" exists - │ ├─ Validate style "title" exists in template - │ ├─ Merge: defaultSlideStyle → corporate.baseStyle → corporate.styles['title'] - │ └─ Return TemplateResolutionResult - └─ Create SlideConfiguration with resolved style + parts - -5. SlideConfiguration → SlideView → Render -``` - -### Runtime Rendering (No Changes) -``` -SlideConfiguration - ↓ -SlideView - ├─ config.parts.background (from template or deck) - ├─ config.parts.header (from template or deck) - ├─ @section/@block content (styled with config.style) - └─ config.parts.footer (from template or deck) -``` - ---- - -## Validation Strategy - -### Build-Time Checks (via TemplateResolver) - -**1. Unknown Template** -```markdown ---- -template: corporat # Typo ---- -``` -**Error:** -``` -TemplateException: Template "corporat" not found. -Available templates: corporate, minimal, announcement -``` - -**2. Unknown Style in Template** -```markdown ---- -template: corporate -style: titl # Typo ---- -``` -**Error:** -``` -TemplateException: Style "titl" not found in template "corporate". -Available styles: title, content, closing -``` - -**3. Unknown Style in Presentation (no template)** -```markdown ---- -style: announement # Typo ---- -``` -**Error:** -``` -TemplateException: Style "announement" not found in deck. -Available styles: announcement, quote, emphasis -``` - -### Schema Validation (optional, can be added later) - -Templates can optionally define argument schemas: -```dart -final template = SlideTemplate( - // ... parts and styles ... - argsSchema: { - 'background_image': 'string?', - 'show_logo': 'bool', - }, -); -``` - ---- - -## Implementation Plan - -### Phase 1: Core Infrastructure (2-3 hours) - -**1.1 Create SlideTemplate Class** -- [ ] File: `packages/superdeck/lib/src/deck/slide_template.dart` -- [ ] Define class with `parts`, `baseStyle`, `styles` -- [ ] Implement `copyWith()`, equality, hashCode -- [ ] Add comprehensive dartdoc - -**1.2 Create TemplateResolver Service** -- [ ] File: `packages/superdeck/lib/src/deck/template_resolver.dart` -- [ ] Define `TemplateResolver` class -- [ ] Define `TemplateResolutionResult` class -- [ ] Implement `resolve()` method with validation -- [ ] Add error message generation - -**1.3 Create TemplateException** -- [ ] File: `packages/superdeck/lib/src/deck/template_exception.dart` -- [ ] Define exception class with message - -**1.4 Update DeckOptions** -- [ ] File: `packages/superdeck/lib/src/deck/deck_options.dart` -- [ ] Add `templates` field -- [ ] Add `defaultTemplate` field -- [ ] Update constructor, copyWith, equality, hashCode - -**1.5 Update SlideOptions** -- [ ] File: `packages/core/lib/src/models/slide_model.dart` -- [ ] Add `template` field -- [ ] Update schema (optional: add mutual exclusion validation) -- [ ] Update fromMap, toMap, copyWith, equality, hashCode - -**1.6 Export New Classes** -- [ ] File: `packages/superdeck/lib/superdeck.dart` -- [ ] Export `SlideTemplate` -- [ ] Export `TemplateException` -- [ ] Keep `TemplateResolver` internal (not exported) - -**Checkpoint:** -- [ ] Run `melos run analyze` - no errors -- [ ] Run `melos run test` - existing tests pass -- [ ] All new classes compile - ---- - -### Phase 2: Integration (1-2 hours) - -**2.1 Update SlideConfigurationBuilder** -- [ ] File: `packages/superdeck/lib/src/deck/slide_configuration_builder.dart` -- [ ] Import `TemplateResolver` -- [ ] Instantiate resolver in `buildConfigurations()` -- [ ] Pass resolver to `_buildConfiguration()` -- [ ] Replace style merging logic with `resolver.resolve()` -- [ ] Use resolved `style` and `parts` in SlideConfiguration - -**Checkpoint:** -- [ ] Run `melos run analyze` - no errors -- [ ] Manual test with demo app (add a simple template) -- [ ] Verify slides render with template - ---- - -### Phase 3: Testing (2-3 hours) - -**3.1 Unit Tests for TemplateResolver** -- [ ] File: `packages/superdeck/test/deck/template_resolver_test.dart` -- [ ] Test template resolution with valid template -- [ ] Test template resolution with defaultTemplate -- [ ] Test deck style resolution (no template) -- [ ] Test unknown template error -- [ ] Test unknown style in template error -- [ ] Test unknown style in deck error -- [ ] Test style merging order - -**3.2 Unit Tests for SlideTemplate** -- [ ] File: `packages/superdeck/test/deck/slide_template_test.dart` -- [ ] Test construction -- [ ] Test copyWith -- [ ] Test equality - -**3.3 Integration Tests** -- [ ] File: `packages/superdeck/test/deck/slide_configuration_builder_test.dart` -- [ ] Test builder with templates -- [ ] Test builder with defaultTemplate -- [ ] Test builder without templates -- [ ] Test parts resolution from template - -**3.4 Update Existing Tests** -- [ ] File: `packages/core/test/src/models/slide_model_test.dart` -- [ ] Test SlideOptions with template field -- [ ] Test schema validation - -**Checkpoint:** -- [ ] Run `melos run test` - all tests pass -- [ ] Run `melos run test:coverage` - check coverage - ---- - -### Phase 4: Demo & Documentation (1-2 hours) - -**4.1 Create Demo Templates** -- [ ] File: `demo/lib/src/templates.dart` (NEW) -- [ ] Create `corporateTemplate` with custom parts and styles -- [ ] Create `minimalTemplate` with simple styling -- [ ] Create `titleSlideTemplate` (no header/footer) - -**4.2 Update Demo App** -- [ ] File: `demo/lib/main.dart` -- [ ] Register templates in `DeckOptions` -- [ ] Optionally set `defaultTemplate` - -**4.3 Update Demo Slides** -- [ ] File: `demo/slides.md` -- [ ] Add slides using different templates -- [ ] Show template + style combinations -- [ ] Show slide without template - -**4.4 Update Documentation** -- [ ] This file: Complete implementation details -- [ ] Add usage examples -- [ ] Add troubleshooting section -- [ ] Add best practices - -**Checkpoint:** -- [ ] Demo app runs with templates -- [ ] All template variations render correctly -- [ ] Documentation is clear and complete - ---- - -### Phase 5: Quality Assurance (1 hour) - -**5.1 Code Quality** -- [ ] Run `melos run analyze` - no warnings -- [ ] Run `melos run custom_lint_analyze` - no issues -- [ ] Run `melos run format` - code formatted - -**5.2 Integration Testing** -- [ ] Test template with custom background -- [ ] Test template with custom header/footer -- [ ] Test defaultTemplate behavior -- [ ] Test error messages (unknown template, unknown style) -- [ ] Test slides without templates - -**5.3 Performance Check** -- [ ] Verify build time is not impacted -- [ ] Verify rendering performance is unchanged -- [ ] No memory leaks or excessive allocations - -**Final Checkpoint:** -- [ ] All tests pass -- [ ] Demo works perfectly -- [ ] Documentation complete -- [ ] Ready for PR - ---- - -## Usage Examples - -### Define a Template - -```dart -// In your app (e.g., lib/src/templates.dart) -final corporateTemplate = SlideTemplate( - parts: SlideParts( - background: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [Colors.blue[50]!, Colors.white], - ), - ), - ), - header: PreferredSize( - preferredSize: Size.fromHeight(80), - child: Padding( - padding: EdgeInsets.all(20), - child: Row( - children: [ - Image.asset('assets/logo.png', height: 40), - Spacer(), - Text('Q1 2024 Review', style: TextStyle(fontSize: 18)), - ], - ), - ), - ), - footer: PreferredSize( - preferredSize: Size.fromHeight(60), - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 40), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Confidential'), - Text('Page ${SlideConfiguration.of(context).slideIndex + 1}'), - ], - ), - ), - ), - ), - baseStyle: SlideStyler( - h1: TextStyler().style( - TextStyleMix(fontSize: 64, color: Colors.blue[900]!), - ), - p: TextStyler().style( - TextStyleMix(fontSize: 24, height: 1.6), - ), - ), - styles: { - 'title': SlideStyler( - h1: TextStyler().style( - TextStyleMix(fontSize: 96, fontWeight: FontWeight.bold), - ), - ), - 'emphasis': SlideStyler( - slideContainer: BoxStyler( - decoration: BoxDecorationMix( - border: BorderMix.all(BorderSideMix(color: Colors.blue, width: 8)), - ), - ), - ), - }, -); -``` - -### Register Templates - -```dart -// In main.dart -SuperDeckApp( - options: DeckOptions( - templates: { - 'corporate': corporateTemplate, - 'minimal': minimalTemplate, - }, - defaultTemplate: corporateTemplate, // Optional: apply to all slides - ), - // ... other options -) -``` - -### Use Templates in Markdown - -**Simple template usage:** -```markdown ---- -template: corporate ---- -# Quarterly Results - -Our Q1 performance exceeded expectations. -``` - -**Template with named style:** -```markdown ---- -template: corporate -style: title ---- -# Q1 2024 Review - -Welcome to our quarterly review. -``` - -**Override default template:** -```markdown ---- -template: minimal ---- -# Closing Remarks - -Thank you for your attention. -``` - -**No template (uses deck styles):** -```markdown ---- -style: announcement ---- -# Important Update -``` - ---- - -## Error Messages Reference - -### Unknown Template -``` -TemplateException: Template "corporat" not found. -Available templates: corporate, minimal, announcement -``` -**Fix:** Check template name spelling, ensure template is registered in `DeckOptions.templates` - -### Unknown Style in Template -``` -TemplateException: Style "titl" not found in template "corporate". -Available styles: title, emphasis, closing -``` -**Fix:** Check style name spelling, ensure style is defined in `template.styles` - -### Unknown Style in Presentation -``` -TemplateException: Style "announement" not found in deck. -Available styles: announcement, quote, emphasis -``` -**Fix:** Check style name spelling, ensure style is defined in `DeckOptions.styles` - ---- - -## Best Practices - -### Template Design - -**1. Keep Templates Focused** -- Templates should define chrome (visual frame) -- Let content flow naturally using `@section`/`@block` -- Don't try to control content layout from templates - -**2. Provide Sensible Defaults** -- Template's `baseStyle` should work for most slides -- Named styles (`template.styles`) for special cases only - -**3. Consider Reusability** -- Design templates for a category of slides (e.g., "title slides", "content slides") -- Avoid overly specific templates (e.g., "slide-3-only") - -**4. Test Without Template First** -- Ensure your content works with standard styling -- Add template as enhancement, not requirement - -### Template Usage - -**1. Use defaultTemplate Sparingly** -- Without default: slides opt-in to templates -- With default: all slides use template unless overridden -- Recommendation: start without default, add later if needed - -**2. Be Consistent Within a Presentation** -- Use 2-3 templates max per presentation -- Reserve template switching for major section changes - -**3. Leverage Named Styles** -- Create variations within a template (title, emphasis, closing) -- Keeps deck cohesive while allowing variety - ---- - -## Behavior Without Templates - -### Existing Slides Without Templates -- **Status:** ✅ Unchanged -- **Behavior:** Render exactly as before using deck-level styles and parts - -### Existing Code Using SlideOptions -- **Status:** ✅ `template` remains optional -- **Behavior:** Existing code continues to work without setting `template` - -### Existing Style System -- **Status:** ✅ Unchanged, deck-level styles work as before -- **Note:** Templates provide alternative, not replacement - ---- - -## File Changes Summary - -### New Files (3) -1. `packages/superdeck/lib/src/deck/slide_template.dart` - Template data class -2. `packages/superdeck/lib/src/deck/template_resolver.dart` - Resolution service -3. `packages/superdeck/lib/src/deck/template_exception.dart` - Exception class - -### Modified Files (4) -1. `packages/superdeck/lib/src/deck/deck_options.dart` - Add template fields -2. `packages/core/lib/src/models/slide_model.dart` - Add template to SlideOptions -3. `packages/superdeck/lib/src/deck/slide_configuration_builder.dart` - Use resolver -4. `packages/superdeck/lib/superdeck.dart` - Export new classes - -### Test Files (4 new) -1. `packages/superdeck/test/deck/slide_template_test.dart` -2. `packages/superdeck/test/deck/template_resolver_test.dart` -3. `packages/superdeck/test/deck/slide_configuration_builder_test.dart` -4. `packages/core/test/src/models/slide_model_test.dart` - -### Demo Files (2 new, 1 modified) -1. `demo/lib/src/templates.dart` - Demo template definitions -2. `demo/lib/main.dart` - Register templates -3. `demo/slides.md` - Use templates - -**Total:** 7 core files, 4 test files, 3 demo files = **14 files** - ---- - -## Timeline Estimate - -- **Phase 1 (Core Infrastructure):** 2-3 hours -- **Phase 2 (Integration):** 1-2 hours -- **Phase 3 (Testing):** 2-3 hours -- **Phase 4 (Demo & Docs):** 1-2 hours -- **Phase 5 (QA):** 1 hour - -**Total:** 7-11 hours for complete implementation with tests and documentation - ---- - -## Success Criteria - -- [ ] All existing tests pass -- [ ] New tests cover all resolution paths and error cases -- [ ] Demo app showcases 3+ different templates -- [ ] Error messages are clear and actionable -- [ ] Documentation explains usage with examples -- [ ] Code passes all quality checks (analyze, lint, format) -- [ ] No performance regression in build or render time - ---- - -## Open Questions (Resolved) - -### ~~Q1: Slot mapping strategy?~~ -**Resolved:** Templates do NOT define slots. Users continue using `@section`/`@block` normally. Templates only affect chrome (background, header, footer). - -### ~~Q2: Template helper return type?~~ -**Resolved:** Not applicable - templates are simple data classes, not helpers/builders. - -### ~~Q3: YAML template syntax?~~ -**Resolved:** Dart-only for V1. YAML support can be added later if needed. - ---- - -## Future Enhancements (Out of Scope for V1) - -### Template Inheritance -```dart -final extendedTemplate = SlideTemplate( - inheritsFrom: 'corporate', // Extend another template - baseStyle: SlideStyler(...), // Override specific styles -); -``` - -### Conditional Template Selection -```dart -DeckOptions( - templateSelector: (slideOptions) { - if (slideOptions.args['isTitle'] == true) return 'title'; - return 'content'; - }, -); -``` - -### Custom Chrome Elements -```dart -class SlideParts { - final Widget? watermark; - final Widget? logo; - // ... existing parts -} -``` - -### YAML Template Definitions -```yaml -# templates/corporate.yaml -baseStyle: - h1: - fontSize: 64 - color: blue -parts: - background: assets/bg.png -``` - ---- - -## Notes - -This document represents the complete, final plan for implementing templates in Superdeck based on: -- Chrome-focused scope (not layout control) -- Isolated style systems (templates don't merge with deck styles) -- Clean architecture (TemplateResolver service for separation of concerns) -- Flutter developer ergonomics (simple API, clear errors, familiar patterns) - -Last updated: 2025-10-23 diff --git a/.planning/large_deck_generation_quality.md b/.planning/large_deck_generation_quality.md deleted file mode 100644 index 4a10f445..00000000 --- a/.planning/large_deck_generation_quality.md +++ /dev/null @@ -1,714 +0,0 @@ -# Plan: Large-deck generation quality and design variation - -> Evolve the working outline-to-slide pipeline into a quality-controlled system -> that produces coherent, polished, visually varied 10–20-slide decks using -> described, versioned theme selection, end-to-end typography, custom-font -> support, and the same catalog pattern for deferred image-style selection. - -Status: In progress; Tasks 1–6, the 12-theme runtime family, representative -render qualification, and the deterministic ten-slide checkpoint are complete. -The repeated real 10/15/20 quality matrix and final regression remain pending. - -Checkpoint: `cf463294` (`feat: improve AI deck generation quality`), pushed to -`origin/leoafarias/iterative-ai-generation` on 2026-07-14. - -Current handoff: [`ai_generation_session_status_2026_07_14.md`](ai_generation_session_status_2026_07_14.md) - -## Verdict - -**Refine, not rewrite.** Keep the plan-first, one-slide-at-a-time Flash pipeline -and its inspectable artifacts. Replace open-ended model-authored global style -tokens with selection from a curated, described, versioned theme catalog while -retaining per-slide composition/treatment decisions and explicit user brand -overrides. Reuse that catalog-selection contract for image style when image -generation returns. The current orchestration is a sound base; the current -prompts and validators are not yet a sufficient quality contract. - -## Objective - -- Primary outcome: a user can request a 10–20-slide deck and receive a coherent - story with readable content, purposeful layout variation, a consistent but - non-repetitive visual system, working tables/elements, and typography that is - visibly appropriate to the selected direction. -- Customization outcome: one stable theme ID/version flows from Wizard/editor - intent through the deck plan, generated slides, `DeckOptions`, captures, and - later manual editing. Explicit user palette and font choices remain supported - as validated overrides instead of reopening every theme token to the model. -- Selection outcome: the AI sees compact theme candidates containing an exact - ID, display name, concrete selection description, and relevance tags. The - runtime owns the complete palette, typography, spacing, component, and slide- - treatment recipe. The deferred image-style flow follows the same split between - selection metadata and a full runtime image treatment. -- Quality outcome: prompt/schema changes are evaluated with deterministic - fixtures, exact structural/semantic checks, real rendered slides, contact - sheets, and an opt-in visual-review rubric before they are accepted. -- Performance constraint: retain the configured Flash model and keep explicit - thinking disabled. Do not add a production critic call until measurements show - that its quality gain justifies the latency and cost. - -Out of scope for the first quality pass: - -- Arbitrary font-file upload, persistence, licensing, and redistribution. The - first pass supports Google Fonts plus application-registered bundled custom - families; uploaded `.ttf`/`.otf` files need a separate asset-lifecycle design. -- Automatic image synthesis or ungrounded image search. This plan defines the - later image-style selection contract, but does not activate image generation - in the first quality pass. -- A fully free-form canvas or model-authored Flutter styling code. -- Treating one model-generated aesthetic score as proof of quality. Rendered - artifacts and deterministic invariants remain the release gate. - -## Research basis - -- Gemini recommends clear, direct, consistently delimited prompts, critical - constraints near the start, context before the final task for long prompts, - structured output for complex JSON, and iterative evaluation against observed - responses. It also warns that too many examples can cause overfitting: - [Gemini prompt design strategies](https://ai.google.dev/gemini-api/docs/prompting-strategies). -- Gemini structured outputs support `minItems` and `maxItems`, and still require - application-level semantic validation: - [Gemini structured outputs](https://ai.google.dev/gemini-api/docs/structured-output?lang=rest). -- Recent presentation-generation work independently converges on hierarchical - planning, separating page design from implementation, and reviewing rendered - slides before regeneration: - [DeepSlides](https://aclanthology.org/2026.findings-acl.1524/), - [PreGenie](https://aclanthology.org/2025.findings-emnlp.165/), and - [SlideSpace](https://www.microsoft.com/en-us/research/publication/slidespace-heuristic-design-hybrid-presentation-medium/). -- Microsoft presentation guidance emphasizes readable type at distance, concise - text, meaningful graphics, consistent themes, and strong foreground/background - contrast: - [effective presentation guidance](https://support.microsoft.com/en-us/powerpoint/tips-for-creating-and-delivering-an-effective-presentation) and - [professional layout guidance](https://support.microsoft.com/en-us/powerpoint/create-professional-slide-layouts-with-designer). -- Flutter supports declared `.ttf`, `.otf`, and `.ttc` custom fonts. The official - `google_fonts` package supports lookup by family name, runtime fetching, cache, - and asset bundling: - [Flutter custom fonts](https://docs.flutter.dev/cookbook/design/fonts) and - [`google_fonts`](https://pub.dev/packages/google_fonts). -- Generated palettes should enforce, at minimum, WCAG's 4.5:1 normal-text and - 3:1 large-text contrast thresholds: - [WCAG 2.2 contrast](https://www.w3.org/TR/WCAG22/#contrast-minimum). - -## Verified current context - -The baseline findings below explain the completed Tasks 1–4 and the current -parameterized theme factory. Treat them as historical evidence, not a fresh -description of every file at the checkpoint. The later **Catalog selection gap** -section records the current theme/image-style delta, and the linked session -status is the operational handoff. - -### What to preserve - -- `packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart` - generates a typed deck plan and then composes one slide at a time with previous - slide and next-plan context. Invalid slides can be repaired without regenerating - prior slides. -- `packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart` - centralizes pure prompt assembly, making prompt snapshots and recorded live - prompts possible. -- `packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart` - records the plan, each rendered prompt/response, canonical deck JSON, Markdown, - traces, slide PNGs, and a contact sheet. -- `packages/playground/lib/core/domain/stores/deck_customization_store.dart` - already applies a generated palette and font pair through one coherent - `DeckOptions` update and derives table/code/blockquote styling from it. -- SuperDeck already exposes `SlideOptions.style`, deck-level named - `SlideStyler`s, section rows, block columns, flex, spacing, tables, images, - QR codes, WebViews, DartPad, and registered custom widget blocks. - -### Correctness defects found in live artifacts - -- The latest comparison artifact, - `packages/playground/test_live/ai_generation/artifacts/comparison_table_2026-07-14T05-55-09.720946Z/`, - passed validation even though slides 3 and 4 collapsed to heading-only slides. - The raw response omitted required content from optional content-block fields; - sanitization dropped the empty blocks and the validator accepted the remaining - heading. -- The latest visual artifact, - `packages/playground/test_live/ai_generation/artifacts/visual_elements_2026-07-14T05-50-45.689223Z/`, - passed validation with ten WebViews on one planned one-WebView slide and three - QR codes on one planned one-QR slide. Element source hydration is correct, but - element cardinality and composition fulfillment are not validated. -- `packages/playground/lib/features/ai/quick_agent/core/engine/services/google_schema_adapter.dart` - reports `minItems` and `maxItems` as unsupported even though the pinned Google - API schema class exposes both fields. The model-facing schema therefore cannot - bound sections or blocks. -- `packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart` - checks only empty/duplicate keys; it does not enforce the requested count, - section rhythm, layout diversity, supported fonts, palette contrast, or element - source/cardinality rules. -- `packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart` - proves canonical parseability and table syntax, not whether a slide fulfills - its planned purpose, composition, content, or elements. - -### Prompt and example defects - -- `packages/playground/assets/ai_prompts/partials/_slide_examples.prompt` - injects all four examples into every slide request. It includes unrelated fake - image/QR/WebView sources and repeatedly demonstrates title-plus-body scaffolds, - creating copying/overfitting pressure even when the current composition is text - only. -- `packages/playground/lib/features/ai/wizard/core/ai/services/prompt_builder.dart` - still tells generation to use two title/body sections for most slides and not - to use widget blocks. That conflicts with the new capability-aware prompt and - suppresses design variation requested by the user. -- `packages/playground/assets/ai_prompts/outline_system.prompt` has a useful flat - slide plan but no acts/sections, design rhythm, density profile, or typography - treatment for sustaining a 10–20-slide narrative. -- The slide prompt receives the entire plan and previous canonical slide, but no - compact history of recent composition families or deliberate layout sequence. - -### Design and typography defects - -- `packages/playground/lib/features/ai/quick_agent/core/engine/prompts/font_styles.dart` - exposes five headline and five body enums. The editor exposes a separate - 15-family list, so there is no single typography catalog or registered custom - family path. -- `packages/playground/assets/ai_prompts/outline_system.prompt` says exact user - fonts are honored, but `packages/playground/lib/features/ai/quick_agent/core/engine/schemas/deck_schemas.dart` - cannot represent a font outside those enums. -- `packages/playground/lib/core/domain/stores/deck_customization_store.dart` - applies generated families to an undersized 40/32/24/20/18/16/18 logical-pixel - type scale. SuperDeck's default 1280×720 style uses substantially larger display - sizes; the contact sheets confirm that generated decks look small and sparse. -- The generated style contains only background, heading, body, and two font - choices. It cannot express accent/surface roles, density, type scale, or named - slide treatments, so structurally different slides still look like one global - text theme. -- The live test loads Roboto bytes under every selected family name. It verifies - plumbing but cannot prove the generated/custom font actually rendered. - -### Catalog selection gap - -- `packages/playground/lib/features/ai/quick_agent/core/engine/schemas/deck_schemas.dart` - currently asks the outline model to emit a style name, direction, density, - type scale, seven color roles, and two font families. The complete style object - is then serialized into every single-slide prompt by - `generation_prompt_provider.dart`. This repeats low-value choices and leaves - palette/font validity to generation and repair. -- `packages/playground/lib/core/domain/design/generated_deck_theme_factory.dart` - already provides the correct runtime ownership boundary: Dart maps semantic - inputs into safe `DeckOptions` and named treatments. It is currently one - parameterized factory, not a catalog of independently described, versioned - theme recipes. -- `packages/playground/lib/features/ai/wizard/core/ai/prompts/image_style_prompts.dart` - already separates an image style's ID, display title, short selection - description, and full image-generation treatment. However, - `deck_generation_request.dart` currently carries duplicated image-style name - and description strings rather than the stable image-style ID retained in - `WizardContext`. - -## Approach tradeoffs - -- Keep model-authored palette/font/style objects: rejected as the default because - they repeat low-value tokens in every slide prompt and require repair for - choices the renderer can guarantee. -- Use one or two hardcoded themes: rejected because consistency would come at the - cost of meaningful deck-level variation. -- Send all roughly 30 full recipes to the model: rejected because it increases - prompt size and exposes implementation details. Prefer compact described - candidates, filtered without another model call; if the brief has no explicit - direction, build a deterministic diverse shortlist across theme directions. -- Couple deck themes and image styles into combined presets: rejected because it - creates a large theme-by-image-style matrix and makes both catalogs harder to - evolve independently. - -## Target experience - -1. The Wizard or debug panel produces a typed generation brief with an exact - slide count, audience, objective, content constraints, grounded elements, an - optional exact theme, and explicit palette/typography overrides. -2. If the user did not select a theme, deterministic constraints filter the - catalog to roughly three-to-five compatible candidates. The Flash model sees - each candidate's exact ID, display name, short description, and tags, then - selects one exact ID. It never receives or regenerates the full runtime recipe. -3. The Flash model returns one hierarchical deck blueprint: a story thesis, - three-to-five acts/sections for a 10–20-slide deck, one exact selected theme - ID, and an ordered visual rhythm with a concrete brief for each slide. The - application resolves and records the catalog version. -4. Each slide is composed against its current act, neighboring slide briefs, and - a compact design ledger. The ledger records recent composition/treatment and - prevents accidental repetition without forcing random layouts. -5. Model-facing structured output bounds arrays and uses the strongest supported - types. A semantic validator checks the current slide against its plan before - accepting it as context for the next slide. -6. Dart resolves the selected theme into one coherent `DeckOptions` base style - plus named treatments such as hero, section, content, data, quote, visual, and - closing. The model selects semantic treatments; Dart owns font sizes, - contrast, spacing, decoration, and safe renderer values. -7. The editor opens with the chosen palette and real font families applied. All - levels remain manually editable after generation. -8. When image generation is reintroduced, image-style selection uses the same - exact-ID flow. The AI sees compact descriptions; the image prompt builder - resolves the full treatment from the catalog only after selection. -9. The live lab renders 10-, 15-, and 20-slide fixtures, produces a machine - quality report and contact sheet, and optionally asks the same Flash model for - a structured visual critique. Only deterministic failures block production. - -## Proposed contracts - -### Typed generation brief - -Replace the lossy prompt-only handoff with a playground-owned request containing: - -- raw user brief and exact `slideCount`; -- audience, purpose, tone, and required content when supplied by the Wizard; -- grounded image/QR/WebView/DartPad/custom-widget sources; -- an optional exact theme ID selected by the user; the catalog version is - attached by the application after resolution; -- design direction and density preference; -- exact palette roles when selected; -- headline/body font IDs or family names selected from the typography catalog. - -The free-text editor keeps working by wrapping text in the same request and using -an explicit count control/default. Do not infer a contractual count from prose -when the UI already knows it. - -### Hierarchical deck blueprint - -Keep an ordered flat slide list for simple sequential iteration, and add: - -- `sections`: key, title, purpose, transition, slide keys; -- a versioned theme reference, resolved density, and an optional validated brand - override containing only exact user-supplied palette/font constraints; -- per slide: section key, narrative role, assertion, content units, continuity, - composition family, visual treatment, density, and grounded elements; -- a planned composition/treatment sequence that can be validated before slide - composition begins. - -The hierarchy organizes 10–20 slides without requiring a new runtime deck model; -it remains generation-only and normalizes to canonical `Slide` objects. - -### Theme catalog and selection - -Use one injectable theme catalog shared by Wizard previews, generation prompt -assembly, plan validation, runtime style resolution, the editor, live fixtures, -and artifact replay. - -Each theme descriptor contains: - -- a stable `id`, integer `version`, display `title`, and concise `description`; -- compact selection tags for visual direction, mood, audience/content affinity, - light/dark mode, and supported density; -- a complete renderer-owned recipe: palette roles, typography pairing and type - scale, spacing, shape/decorative language, component styling, and the seven - named slide treatments; -- preview metadata used by Wizard/debug UI and screenshot fixtures. - -The description is required. It is compact, concrete selection guidance that -states the theme's visual character, typography/layout behavior, and best-fit -content or audience; an ID or title alone is not enough to distinguish similar -systems. Prompt assembly exposes only the ID, title, description, and relevant -tags. Full tokens remain in Dart and are resolved only after selection. - -An explicit user theme always wins. Otherwise, hard constraints first filter the -catalog to roughly three-to-five compatible candidates and the AI selects one -exact ID. A selection reason may be recorded in the debug trace, but is not part -of the persisted plan contract. Explicit palette or custom-font constraints -either narrow compatible themes or become a validated brand override on the -selected base theme; the model does not freely rewrite the remaining tokens. - -Start with 10–12 meaningfully different, screenshot-tested themes and design the -catalog to grow toward roughly 30. Expansion requires a distinct typography, -composition, or component-treatment rationale and rendered evidence; palette- -only duplicates do not count as new themes. - -### Typography catalog - -Use one injectable catalog shared by Wizard preview, generation schema/prompt, -style application, editor controls, and live capture. - -- Curated Google-font descriptors remain the default AI-selectable set. Expand - it with intentional pairings and readable role metadata instead of exposing - thousands of names to the model. -- An exact user-selected family is accepted when it resolves through - `GoogleFonts.asMap()` or an application-registered custom descriptor. -- Registered custom descriptors contain a stable ID, display/family name, - supported roles/weights, and whether the font is bundled or runtime-loaded. -- Unknown theme families or exact user overrides fail validation before slide - composition; they never silently fall back while claiming success. With the - theme catalog active, the model no longer invents font families. -- User-uploaded font files are deferred because they require storage, loading, - licensing, and portability rules beyond prompt/schema work. - -### Curated theme resolution - -Keep model output semantic and bounded. Replace the current open-ended style -object with a selected `id`; the application resolves it and stores a theme -reference containing `id`, `version`, and the resolved deck density. Allow a -separate validated brand override only for exact user-supplied palette/font -constraints. - -Dart resolves the theme recipe into safe `SlideStyler`s and the registered -`hero`, `section`, `content`, `data`, `quote`, `visual`, and `closing` treatments. -The model never emits raw Mix styling, arbitrary font sizes, font names, or color -tokens for every deck. Per-slide treatment and composition remain model-selected -so a shared theme does not make every slide structurally repetitive. - -### Image-style catalog (deferred) - -When image generation returns, promote the existing `ImageStyle` metadata into -an injectable, versioned catalog using the same selection contract as themes. -Each descriptor contains a stable ID/version, display title, required short -description, relevance/media tags, and the full renderer/image-prompt treatment. - -The AI sees only compact candidates and returns an exact ID. The image-generation -service resolves the full treatment after selection. An explicit user choice -always wins. Theme and image style remain independent catalogs connected only by -optional compatibility tags; do not create a combinatorial theme-by-image-style -matrix. Replace duplicated `imageStyleName`/`imageStyleDescription` request fields -with the stable reference when this deferred phase is implemented. - -### Quality gates - -Per-slide deterministic checks: - -- raw and sanitized content cannot collapse a non-divider slide to only a title; -- planned composition is fulfilled (table contains a table, two/three-column has - the expected dominant row, metric contains the planned numeric evidence); -- planned element names and counts match exactly; no unplanned or duplicate - widgets; -- content blocks are non-empty, titles are unique/concise, tables are bounded, - and visible text stays inside a density-specific budget; -- section/block counts, flex, insets, and spacing obey renderer-safe bounds. - -Deck-level deterministic checks: - -- generated, serialized, parsed, and captured slide counts equal the request; -- every plan key appears exactly once and every section has its planned slides; -- no accidental run of more than two identical composition/treatment families; -- 10/15/20-slide general-purpose fixtures use enough distinct purposeful - composition families (target at least 5/6/7 unless the brief constrains them); -- body and heading colors meet 4.5:1 and 3:1 contrast respectively; -- selected fonts resolve and the actual capture uses the selected family; -- the selected theme ID/version resolves exactly, every catalog entry has a - non-empty description, and the captured palette/fonts/treatments match the - resolved recipe plus any explicit validated brand override; -- no heading-only content slide, unplanned widget, duplicate grounded element, - Markdown replay mismatch, renderer exception, or detected overflow. - -Rendered review rubric (reported, not initially production-blocking): narrative -flow, hierarchy, readability, visual balance, consistency, variation, typography, -table/data legibility, element relevance, and blank/overflow detection. - -## Compatibility and migration - -- Canonical `Slide`, Markdown, and widget payloads remain unchanged. -- The generation-only deck-plan and theme schemas may change directly; they are - not persisted user contracts. -- Replace the generated style object with a theme reference in one branch-wide - change. Update retained live fixtures/artifact readers deliberately; do not - keep a long-lived dual style/theme contract. -- Keep current image-style request fields until the deferred image-generation - phase begins, then replace them with the versioned reference in one change; - do not add another compatibility layer now. -- Keep `GenerateDeckCommand` as the single Wizard/editor execution path, but - migrate its argument from a bare string to the typed generation brief in one - branch-wide change. There is no need for a long-lived compatibility shim. -- Existing manual customization remains available after selected-theme - application. -- Existing 5/6-slide live fixtures remain as fast smoke cases; add larger quality - fixtures instead of replacing them. - -## Work breakdown - -- [x] Task 1: Close structural and semantic acceptance gaps - - Scope: `packages/core/lib/src/deck/slide_contract.dart`, - `packages/playground/lib/features/ai/quick_agent/core/engine/services/google_schema_adapter.dart`, - `deck_plan_validator.dart`, `generated_slide_validator.dart`, and mirrored - tests. - - Forward supported `minItems`, `maxItems`, string bounds, and property order to - Google's schema. Bound model-facing section/block arrays. - - Validate the raw draft and sanitized canonical slide against the current plan - slide, including composition fulfillment and exact element cardinality. - - Acceptance: the recorded heading-only comparison responses and repeated - WebView/QR responses fail with repairable, slide-specific errors. - - Verification: `fvm flutter test test/features/ai/quick_agent/core/engine/services test/features/ai/quick_agent/core/engine/schemas --reporter expanded`. - -- [x] Task 2: Establish one typed generation brief and remove prompt conflicts - - Dependencies: Task 1. - - Scope: `GenerateDeckCommand`, `DeckGeneratorService`, Wizard - `prompt_builder.dart`/summary action, editor generation panel, generation lab, - and their tests. - - Carry exact slide count, grounded assets, design/font selections, and raw user - intent as fields rather than re-parsing a prose contract. - - Delete the legacy instruction that forbids widgets and mandates the same - title/body layout for most slides. - - Acceptance: the Wizard's selected 5–20 count, palette, families, and elements - reach plan validation exactly; free text uses the same request type. - - Verification: focused Wizard, command, prompt-builder, and generation-service - tests. - -- Checkpoint: replay the four known-bad raw responses through the new validator. - No prompt tuning proceeds until all four fail for the intended reason and the - existing valid fixtures still pass. - -- [x] Task 3: Add the hierarchical blueprint and typography catalog - - Dependencies: Task 2. - - Scope: `outline_schema.dart`, `deck_schemas.dart`, `font_styles.dart`, new - colocated generation typography catalog files, outline prompt, style - serializer, generated Ack files, and tests. - - Add sections/acts, assertion/content units, visual treatment, density, and a - planned design rhythm while keeping the ordered flat slide list. - - Replace disconnected font enums/lists with one catalog; allow exact known - Google families and registered bundled custom families without permitting - invented names. - - Add accent/surface roles and semantic type-scale/direction settings. Validate - palette contrast before composing slides. - - Acceptance: deterministic 10-, 15-, and 20-slide plan fixtures validate exact - counts, section membership, composition rhythm, contrast, and font resolution. - - Verification: schema/codegen plus focused plan/catalog tests. - -- [x] Task 4: Make slide prompts composition-specific and history-aware - - Dependencies: Task 3. - - Scope: `slide_system.prompt`, `outline_system.prompt`, replace - `_slide_examples.prompt` with validated composition-specific example assets, - `generation_prompt_provider.dart`, prompt tests, and fixture tests. - - Structure prompts as role/constraints, relevant context, one or two selected - examples, then the final current-slide task. - - Supply current section, neighbors, and a compact design ledger containing - recent composition/treatment/density rather than encouraging blind rotation. - - Inject widget examples only for the current grounded planned element and use - its exact source. Never include unrelated fake URLs/assets. - - Acceptance: every composition family has a canonical positive fixture that - parses/renders; prompt snapshots contain only relevant examples and sources. - - Verification: prompt snapshots, schema parity, sanitizer, validator, and - renderer fixture tests. - -- [x] Task 5a: Land the described theme catalog and selection contract - - Dependencies: Task 3; can proceed alongside Task 4 after catalogs stabilize. - - Scope: the style/theme schema and serializer under - `packages/playground/lib/features/ai/quick_agent/core/engine/`, a new - colocated theme catalog/selector under - `packages/playground/lib/core/domain/design/`, Wizard and generation-lab - selection mapping, and focused schema/catalog/prompt tests. - - Define stable ID/version/title/description/tag metadata and one compact - model-facing projection. Add deterministic hard-constraint filtering and - expose only three-to-five candidate descriptions when no explicit theme is - selected. - - Replace the plan's generated style object with a theme reference plus a - validated explicit-user brand override. Resolve and attach the catalog - version deterministically; unknown/stale IDs fail before slide composition. - - Land three representative themes end to end first so selection, validation, - artifact serialization, replay, and explicit-user precedence are proven - before expanding the catalog. - - Acceptance: descriptors have unique ID/version pairs and concrete non-empty - selection descriptions; prompt snapshots contain only eligible compact - candidates; explicit choices win; the model cannot submit a non-candidate - ID; full palette/font/runtime recipes are absent from selection prompts; a - brief without style constraints receives a deterministic shortlist spanning - distinct directions without adding another provider call. - - Verification: catalog/selector/schema/request round-trip tests, prompt - snapshots, fake-outline selection tests, and retained-artifact replay. - - Completed evidence: the shared injectable catalog contains three described, - versioned representative recipes; local selection builds a deterministic - compact shortlist without a provider call; the model-facing schema accepts - only eligible IDs; Dart attaches and validates the canonical - `{id, version, density, brandOverride?}` reference before composition; and - Wizard, generation lab, runtime mapping, live artifacts, and retained - artifact replay all resolve through the same catalog. - - Final injection audit: `AiConversationProfile` owns the catalog instance and - derives the Wizard prompt, `AskUserStyle`/`SummaryCard` model schemas and - examples, parsing, selection context, and card rendering from it. A custom- - ID regression proves no Wizard surface silently reverts to the default - catalog, while unknown IDs still fail before rendering or generation. - - Shortlist note: an unconstrained brief receives all three seed directions. - Explicit choices return one candidate, and hard direction/brightness/density - constraints may deliberately narrow below three rather than padding the - shortlist with incompatible themes. Task 5b's 10–12-theme expansion gives - constrained briefs enough compatible depth for the normal three-to-five - range. - - Verified on 2026-07-14: focused Task 5a contract tests (15), the broader - theme/schema/Wizard/generation regression set (76), all workspace tests - (694), workspace `analyze:all`, and `git diff --check` passed. - -- [x] Task 5b: Expand and render-qualify the curated theme family - - Dependencies: Task 5a. - - Scope: `generated_deck_theme_factory.dart`, - `deck_customization_store.dart`, customization sidebar, generation command/ - lab mapping, the theme catalog's runtime recipes, and widget/golden tests. - - Preserve presentation-scale typography suitable for 1280×720 and named - semantic slide treatments through `DeckOptions.styles`. Apply theme recipes - to surfaces, accents, tables, code, quotes, links, and widget/element - containers while preserving manual editing. - - Resolve Google/registered custom fonts and required weights explicitly. Apply - exact user palette/font overrides as a validated overlay and recheck contrast - after resolution. - - Grow the proven catalog from three to 10–12 materially distinct themes; - expand toward roughly 30 only after each addition passes descriptor checks and - rendered review. - - Acceptance: at least three directions produce visibly different coherent - contact sheets; exact custom registered families appear in resolved text - styles; light/dark tables remain legible; every theme's render matches its - description and no accepted entry is merely a palette duplicate. - - Verification: store/theme/override unit tests plus representative golden and - full-size/contact-sheet captures. - - Completed evidence: the catalog contains 12 described recipes with distinct - non-palette runtime signatures; all seven semantic treatments, surfaces, - decorations, tables, code, quotes, links, built-in/custom widget containers, - exact registered weights, and validated brand overlays resolve through one - renderer-owned factory while manual edits retain the recipe. Focused coverage - includes light/dark table contrast and all 12 font pairs. Editorial Midnight, - Technical Paper, and Bold Product each render the deterministic ten-slide - fixture at 1280×720 with actual declared fonts. Thirty full-size captures, - three reviewed contact sheets, and three byte-stable opt-in golden baselines - pass without generated header/footer chrome, title clipping, or uninitialized - syntax highlighting. The render review also exposed dark-only syntax colors - on Technical Paper's light code surface; SuperDeck now selects the syntax - palette from the resolved code-container background and raises every token - to at least 4.5:1 contrast. Light/dark widget regressions and a fresh - 30-slide golden qualification prove both paths. - -- Checkpoint: run deterministic 10-slide generation with a fake model and inspect - the serialized design ledger, resolved styles, and captured layout fixtures. - - Completed evidence: `LIVE_FAKE_CHECKPOINT=true` performs one outline request - plus ten sequential slide requests with no repairs or thinking configuration, - verifies every compact three-slide ledger window, serializes the canonical - theme/plan/deck/prompts, records declared and loaded runtime font identities, - replays ten Markdown slides, captures ten PNGs and a contact sheet, and passes - the machine quality report with eight composition families and exact 10/10 - planned/generated/replayed/captured counts. - -- [x] Task 6: Expand the live lab into a 10/15/20 quality harness - - Dependencies: Tasks 1–4 for the foundational harness. Theme-specific matrix - coverage is part of Task 7 and depends on Task 5b. - - Scope: `packages/playground/test_live/ai_generation/`, generation lab presets, - README, artifact metadata, and quality report helpers. - - Add narrative 10, decision/data 15, and visual/product 20 fixtures plus - typography variants using editorial, technical, and bold directions. - - Produce a machine-readable JSON quality report with plan/slide/deck - invariants, composition and - treatment distribution, content density, palette contrast, element counts, - resolved fonts, replay/capture counts, and timings. - - Stop loading Roboto bytes under every family in live captures; load the actual - Google or registered font and wait until it is ready before capture. - - Add an opt-in Flash visual-review artifact that scores the contact sheet with - the shared rubric. Keep it informational until correlated with human review. - - Acceptance: one fixture or the full matrix can run without CI API spend and - every failure points to a slide and rule. - - Verification: documented live command with `LIVE_FIXTURE`/matrix selection. - -- [ ] Task 7: Tune from rendered evidence and complete regression - - Dependencies: Tasks 5b and 6. - - Run each large fixture at least twice to separate prompt defects from sampling - variance. Review contact sheets, plans, prompts, responses, repairs, metrics, - font rendering, and latency. - - Change prompts/examples for recurring aesthetic/content failures; change - schemas/validators only for real contractual failures. - - Preserve Flash and no explicit thinking. Record request count and wall time - for 10/15/20 slides and reject changes that add unexplained calls. - - Acceptance: all deterministic gates pass across the final large-deck matrix, - no known blank/duplicate regression recurs, and human review confirms coherent - story, readable hierarchy, useful variation, and visibly correct typography. - - Verification: codegen, focused tests, full playground tests, workspace - analysis/tests, `git diff --check`, and final live artifact matrix. - - Current deterministic evidence: codegen passes; the focused AI/theme/Wizard - suite passes 187 tests; `analyze:all` passes Dart, DCM, unused-file, and - unused-code gates; the ten-slide fake checkpoint and 12-theme render - qualification pass. The qualification includes the corrected light/dark - syntax-token contrast and reviewed byte-stable baselines. The repeated real - 10/15/20 matrix and human review are still required before this task can - close. - -- [ ] Task 8: Introduce the deferred described image-style catalog - - Dependencies: Task 7 and a separate decision to reactivate image generation. - - Scope: `image_style_prompts.dart`, Wizard image-style cards/summary/context, - `deck_generation_request.dart`, the future image-generation prompt builder, - generation-lab fixtures, and focused tests. - - Preserve the existing ID/title/description/treatment split, add explicit - version and selection tags, and make one catalog the source of truth for UI, - AI candidate descriptions, validation, and full prompt treatment resolution. - - Replace duplicated image-style name/description request fields with the - stable ID/version reference. Honor an explicit user choice; otherwise filter - to a small compatible candidate set before AI selection. - - Acceptance: every entry has a unique ID/version and non-empty description; - candidate prompts omit full treatments; the selected exact ID deterministically - resolves the full treatment; unknown/stale IDs fail before image generation; - theme selection remains independent. - - Verification: catalog/schema/request round-trip tests, Wizard selection tests, - prompt snapshots, and opt-in image artifact comparisons when generation is - actually restored. - -## Test strategy - -- Unit: schema adaptation bounds, typed request serialization, plan hierarchy, - theme descriptor validation and candidate filtering, exact theme resolution, - contrast, typography catalog resolution, composition contracts, element - cardinality, density budgets, design ledger, and theme factory. Add equivalent - image-style catalog/request tests only in the deferred phase. -- Regression fixtures: commit the four known-bad model responses and prove they - fail before repair; keep representative valid table/image/QR/WebView slides. -- Widget/golden: base typography scale, named treatment variants, light/dark - table and quote styles, actual selected family, and custom registered family. -- Integration: fake-client 10/15/20 plan-to-slide orchestration, repair isolation, - exact count, cancellation, Markdown replay, and selected-theme application. -- Live: real Flash 10/15/20 runs save the complete artifact bundle, quality report, - actual-font PNGs, and contact sheets. These remain opt-in and outside normal CI. -- Manual: review each contact sheet at full slide size and thumbnail size. Verify - narrative rhythm, typography, tables, visual relevance, sparse/overfull slides, - and transitions between sections. - -Verification commands: - -```bash -cd packages/playground -fvm flutter test test/features/ai/quick_agent --reporter expanded -fvm flutter test test/core/deck_customization_store_test.dart --reporter expanded - -cd ../.. -fvm dart run melos run build_runner:build --no-select -fvm dart run melos run analyze --no-select -fvm dart run melos run test --no-select - -cd packages/playground -fvm flutter test test_live/ai_generation/ai_generation_smoke_test.dart \ - --dart-define-from-file=../../.env \ - --dart-define=LIVE_FIXTURE=large_deck_matrix \ - --reporter expanded -``` - -## Risks and stop conditions - -- Risk: “variation” becomes randomness. Mitigation: plan an intentional sequence - from content roles and validate it; do not ask each slide to choose independently. -- Risk: a richer schema becomes too complex for the API. Mitigation: keep the - canonical slide unchanged, use small generation-only semantic contracts, and - validate current Gemini schema support with focused adapter tests and one live - probe before expanding it further. -- Verified deviation: Gemini accepts array bounds on the hierarchical outline - schema, but a live schema-isolation probe showed that the complete nested - single-slide schema is rejected as an invalid argument whenever array bounds - are included. The slide request therefore omits only provider-side array - bounds while raw/canonical Dart validation retains the exact 1–4 section and - 1–3 block limits. Property ordering, string bounds, and all simple-schema - array bounds remain enabled. -- Risk: custom fonts silently fall back or fail offline. Mitigation: catalog - resolution, explicit errors, actual-font live capture, and bundled-font support. -- Risk: a 30-theme catalog adds prompt noise and weak selections. Mitigation: - filter deterministically to three-to-five eligible candidates and expose only - compact names, descriptions, and tags. -- Risk: a small shortlist hides the best theme for an underspecified brief. - Mitigation: make the fallback shortlist span distinct directions, evaluate - selection quality across the live fixture matrix, and prefer all compact - candidates over adding a separate selector model call if filtering performs - worse. -- Risk: themes become shallow palette swaps. Mitigation: require a distinct - typography, composition, or component-treatment rationale plus screenshot - evidence before adding an entry. -- Risk: catalog descriptions drift from runtime output. Mitigation: keep - selection metadata and the full recipe in one descriptor and test representative - description-to-render expectations. -- Risk: theme and image-style options create a combinatorial matrix. Mitigation: - keep catalogs independent, use optional compatibility tags only for filtering, - and never encode every pair. -- Risk: visual review adds cost/latency without reliable signal. Mitigation: keep - it opt-in and informational until repeated human/artifact comparisons calibrate - thresholds. -- Risk: 20 sequential slide calls are slow. Mitigation: measure before changing - orchestration, keep Flash/no-thinking, compact context, and avoid a mandatory - second model pass. Consider bounded parallel composition only after the richer - blueprint proves that generated-previous-slide context is not required. -- Stop prompt tuning if a failure is caused by schema/sanitizer/rendering behavior; - fix the contractual layer first. -- Stop schema expansion if the same outcome can be enforced more safely in Dart. -- Do not claim quality completion from JSON/test success alone; final contact - sheets and actual typography must be reviewed. diff --git a/.planning/release-1.0.md b/.planning/release-1.0.md deleted file mode 100644 index d1e5b388..00000000 --- a/.planning/release-1.0.md +++ /dev/null @@ -1,205 +0,0 @@ -# SuperDeck 1.0 Public Release Checklist - -## Summary - -Release target: publish `superdeck`, `superdeck_core`, `superdeck_cli`, and `superdeck_builder` at `1.0.0`. - -As of 2026-02-23 baseline: -- All package versions are already `1.0.0` in-repo. -- `superdeck` and `superdeck_core` exist on pub.dev at older versions. -- `superdeck_cli` and `superdeck_builder` are not currently published. - -## Release Command Preamble - -Always run release checks with FVM SDK binaries. - -```bash -export PATH="$(pwd)/.fvm/flutter_sdk/bin:$PATH" -which flutter -which dart -flutter --version -dart --version -``` - -## Run Log - -| Date (UTC) | Command | Result | Notes | -|---|---|---|---| -| 2026-02-23 | `melos run analyze` | Partial pass | Dart analyze passed; DCM failed due activation/license. | -| 2026-02-23 | `melos run test --no-select` | Pass | Unit/widget suites passed across packages. | -| 2026-02-23 | `melos run test:integration --no-select` | Fail | Linux device unavailable on local macOS machine (expected locally). | -| 2026-02-23 | `cd demo && flutter test integration_test -d macos --fail-fast` | Fail/hang | Startup hang; build interrupted manually. | -| 2026-02-23 | `dart/flutter pub publish --dry-run` (all packages) | Warnings | Missing changelog in `superdeck`, `.gitignore` warning for `pubspec_overrides.yaml`, builder `docs` directory warning, builder test `ack` import warning. | -| 2026-02-23 | `fvm flutter pub run melos run analyze:dart --no-select` | Pass | No analyzer issues in `core`, `builder`, `cli`, `superdeck`, `demo`. | -| 2026-02-23 | `fvm flutter pub run melos run test --no-select` | Pass | All package test suites passed (with expected Flutter startup-lock wait messages). | -| 2026-02-23 | `fvm flutter pub run melos run test:integration --no-select` | Fail (expected local) | `-d linux` not available on local macOS; remains CI-only Linux gate. | -| 2026-02-23 | `fvm flutter pub run melos run test:integration:macos --no-select` | Pass | Demo integration suite passed after stabilization fixes in `demo/integration_test/app_test.dart`. | -| 2026-02-23 | `fvm flutter pub run melos run test:e2e:web --no-select` | Pass | Playwright smoke suite green (`4 passed`). | -| 2026-02-23 | `fvm flutter pub run melos run analyze:dcm --no-select` | Fail (non-blocking) | DCM activation/license still missing (documented bypass). | -| 2026-02-23 | `cd packages/core && fvm dart pub publish --dry-run` | Warnings | Pre-release dep warning + dirty git/overrides warnings. | -| 2026-02-23 | `cd packages/builder && fvm dart pub publish --dry-run` | Warnings | Pre-release dep warning + dirty git/overrides warnings; `doc/` rename reflected. | -| 2026-02-23 | `cd packages/cli && fvm dart pub publish --dry-run` | Warnings | Dirty git/overrides warnings/hints (no publish-blocking resolution in dirty tree). | -| 2026-02-23 | `cd packages/superdeck && fvm dart pub publish --dry-run` | Warnings | Pre-release dep warnings (`mix`, `remix`) + dirty git/overrides warnings/hints. | -| 2026-02-23 | `cd packages/cli && fvm flutter test` | Pass | CLI command suite passes after publish command cleanup changes. | - -## Risk Sign-off - -- DCM gate: - - Status: accepted temporary bypass for 1.0. - - Rationale: environment requires DCM activation/license; release gates rely on Dart analyzer + tests + integration + E2E. - - Owner: release owner. - -- Pre-release dependency exceptions: - - `ack`, `ack_annotations`, `ack_generator`, `mix`, `remix` - - Rationale: required by current architecture and/or package ecosystem state. - - Constraint: no unreviewed prerelease additions beyond this list. - - Owner: release owner. - -- Flaky/skipped tests: - - Require explicit per-test disposition before publish. - -## Code Review Findings (Release-Focused) - -### P1 (fixed) - -- `packages/cli/lib/src/commands/publish_command.dart` - - Risk: publish flow did not guarantee cleanup of temporary git worktrees and did not guarantee restoring backed-up `web/index.html` on success paths. - - Fix: moved cleanup and backup restoration into `finally`, with guarded cleanup logging. - - Validation: CLI tests pass (`cd packages/cli && fvm flutter test`). - -### P2 (accepted for 1.0, documented) - -- `packages/core/lib/src/utils/file_watcher.dart` - - Observation: file-watcher behavior remains platform-sensitive; related watcher tests are explicitly skipped as flaky. - - Risk: edge cases in filesystem event behavior may still differ by platform/editor save mode. - - Disposition: accepted for 1.0 with existing test skip rationale; keep under post-1.0 hardening backlog. - -### No open P0 findings - -- No P0 release blockers were found in the scoped pass for: - - `packages/core` (fallback/IO paths) - - `packages/builder` (build lifecycle/error handling) - - `packages/cli` (git safety/worktree flow) - - `packages/superdeck` (controller lifecycle/navigation/style loading) - -## Phase 0: Checklist Setup - -- [x] Create `.planning/release-1.0.md` and paste checklist. -- [x] Add a Run Log table. -- [x] Add a Risk Sign-off section. -- [x] Keep this checklist updated as source of truth. - -## Phase 1: Reproducible Toolchain and Environment - -- [x] Pin `.fvmrc` to exact version (`3.38.9`). -- [x] Align SDK constraints across root/melos/packages/demo. -- [x] Standardize melos scripts to FVM SDK commands. -- [x] Verify no `Invalid SDK hash` appears in current release command logs. -- [x] Document release command preamble. - -## Phase 2: Package Publish Readiness Fixes - -- [x] Add `packages/superdeck/CHANGELOG.md`. -- [x] Add `.pubignore` files for all publishable packages. -- [x] Rename `packages/builder/docs` to `packages/builder/doc`. -- [x] Resolve builder test import warning by adding direct `ack` dev dependency. -- [ ] Re-run dry-runs and reduce warnings to approved prerelease exceptions only. -- [x] Record final dependency exception sign-off after dry-runs. - -## Phase 3: Validation Gate Definition and Execution - -Blocking gates: -- [x] `melos run analyze:dart` -- [x] `melos run test --no-select` -- [ ] Linux integration tests in CI (`melos run test:integration`) -- [x] Playwright web smoke suite -- [ ] `pub publish --dry-run` for each package with approved warnings only - -Non-blocking (document-only for this release): -- [x] DCM (`melos run analyze:dcm`) marked temporary bypass in Risk Sign-off. - -## Phase 4: Integration and E2E Stabilization - -- [x] Keep CI Linux integration command and add explicit local macOS command. -- [x] Add hard timeout/failure diagnostics for integration startup hangs. -- [x] Expand integration assertions with visible UI behavior checks. -- [x] Add Playwright smoke tests: - - [x] app boot without error UI - - [x] keyboard/mouse navigation smoke - - [x] panel interaction smoke - - [x] asset-heavy slide render + no fatal network failures -- [x] Wire Playwright smoke into CI pre-release gating. - -## Phase 5: Release-Focused Code Review - -- [x] Core review (`packages/core`): file watching, deck fallback, IO edge cases. -- [x] Builder review (`packages/builder`): build lifecycle and failure handling. -- [x] CLI review (`packages/cli`): git safety, branch/worktree behavior, dry-run parity. -- [x] Superdeck review (`packages/superdeck`): controller lifecycle/disposal, navigation state, style/font loading. -- [x] Apply must-fix policy: - - [x] Fix all P0/P1 before publish. - - [ ] Document any accepted P2 with issue link and risk note. -- [x] Add/adjust regression tests for fixed defects. - -## Phase 6: Single-Batch Publish Session - -- [ ] Confirm clean tree + all blocking gates green. -- [ ] Publish in dependency-safe order (single batch session): - - [ ] `packages/core` - - [ ] `packages/builder` - - [ ] `packages/cli` - - [ ] `packages/superdeck` -- [ ] For each package: - - [ ] `pub publish --dry-run` - - [ ] publish - - [ ] verify pub.dev API reflects `1.0.0` -- [ ] Tag release (`v1.0.0`) and update release notes/changelog links. - -## Phase 7: Post-Release Verification - -- [ ] Verify pub.dev `1.0.0` for all four packages. -- [ ] Clean-room install flow: - - [ ] `dart pub global activate superdeck_cli` - - [ ] new app + `flutter pub add superdeck` - - [ ] `superdeck setup` + `superdeck build` + `flutter run` -- [ ] Demo web build + Playwright smoke on release artifacts. -- [ ] Record final completion report and risk status in this file. - -## Required Test Coverage Checklist - -### Static/Unit/Widget -- [x] All unit/widget tests pass in all packages. -- [x] Each skipped/flaky test has release disposition (fix/keep-skip/replace). - -### Integration (Flutter) -- [x] Demo startup path -- [x] Slide load + slide count sanity -- [x] next/previous/go-to navigation -- [x] menu/notes state transitions -- [x] controlled error-state behavior -- [x] startup timeout/failure diagnostics - -### E2E (Playwright) -- [x] web boot smoke -- [x] keyboard/mouse navigation smoke -- [x] panel/UI interaction smoke -- [x] console + network failure checks - -### Publish Validation -- [x] dry-run executed for each package -- [ ] only approved warning categories remain -- [ ] no unexpected warnings before final publish - -## Skipped/Flaky Test Disposition - -- `packages/builder/test/manual_error_output_test.dart` (skip): - - Disposition: keep skipped. - - Rationale: non-strict YAML logging path not exposed in this harness; covered by parser unit tests. - -- `packages/core/test/src/helpers/watcher_test.dart` (skip): - - Disposition: keep skipped. - - Rationale: CI/event-loop variability can hang file watch assertions. - -- `packages/superdeck/test/deck/deck_controller_test.dart` (multiple skips): - - Disposition: keep skipped for 1.0. - - Rationale: these tests require bundled Google Fonts assets or further style-layer test seam changes. diff --git a/.planning/superdeck-wizard.md b/.planning/superdeck-wizard.md deleted file mode 100644 index 97cee484..00000000 --- a/.planning/superdeck-wizard.md +++ /dev/null @@ -1,194 +0,0 @@ -# SuperDeck Wizard — Feature Spec - -Status: **Draft / pre-implementation** -Branch: `feat/superdeck-wizard` -Target package: `packages/playground_refactor` -Source of truth for behavior: `packages/playground` (old, to be superseded) - ---- - -## 1. What "the wizard" is - -The **wizard** is an **AI-driven conversational flow** that interviews the user to -gather everything needed to generate a presentation, then hands the collected -answers to the existing deck-generation pipeline. - -Instead of a single free-text box ("describe your presentation"), the model asks -one focused question at a time and renders an **interactive UI surface** for each -answer (radio, checkbox, slider, text field, visual style picker, image-style -picker). As answers accumulate they build up a typed **`WizardContext`**. When the -model has enough, it renders a **summary card**; confirming it triggers generation. - -It is a **GenUI** experience: the model does not just emit text — it drives the UI -by choosing which catalog component to render next (server-driven UI over the -`genui` + `dartantic_ai` stack). - -### Why it exists -- Lowers the blank-page problem — users answer simple prompts rather than authoring - one dense description. -- Produces a richer, structured context (audience, approach, emphasis, slide count, - visual style, fonts, image style) than free text, improving generation quality. -- Lets the model adapt follow-up questions to earlier answers. - ---- - -## 2. End-to-end UX flow - -``` -Entry (button in editor) - │ - ▼ -Chat screen ── AI greets, asks Q1 ──► renders a catalog surface - │ (e.g. ask_user_radio "Who's the audience?") - │ user selects / types - ▼ -UserActionEvent ──► sent back to model ──► AI asks next Q ──► next surface - │ (topic → audience → approach → emphasis → slide count → visual style) - ▼ -AI renders SummaryCard (recap of all selections) with a "Generate" action - │ user confirms - ▼ -Extract WizardContext from summary items ──► build prompt string - │ - ▼ -Deck generation pipeline (outline → deck → finalize) [ALREADY EXISTS in refactor] - │ - ▼ -Markdown loaded into editor / preview ──► progress UI dismisses -``` - -Key behavioral notes carried over from old playground: -- The chat catalog itself has **no tools**; the model steers purely by choosing - which surface to render. Generation is triggered by the **summary card's - action**, not by a model tool call. -- The system prompt is `wizard_system` (asset already present in refactor at - `assets/ai_prompts/wizard_system.prompt`). -- Generation is **fire-and-forget** from the summary card: kick off the command, - show progress, dismiss on completion (success or error). - ---- - -## 3. Reference architecture (old `playground`) - -Located under `packages/playground/lib/features/ai/`. Relevant pieces: - -| Concern | File(s) | -|---|---| -| Conversation state (signals ViewModel) | `core/ai/services/ai_conversation_viewmodel.dart` | -| GenUI session lifecycle (start/bind/queue) | `core/ai/services/genui_conversation_session.dart` | -| a2ui transport (dartantic ↔ genui) | `core/ai/services/superdeck_a2ui_transport.dart`, `superdeck_agent_client.dart` | -| Conversation profile (catalog + prompt + tools) | `core/ai/services/ai_conversation_profile.dart`, `chat/chat_conversation_profile.dart` | -| Catalog of question surfaces | `core/ai/catalog/*` (`ask_user_radio/checkbox/slider/text/style/image_style`, `summary_card`) | -| Typed wizard context | `core/ai/wizard_context.dart`, `core/ai/schemas/wizard_context_keys.dart` | -| Prompt → generation bridge | `core/ai/services/prompt_builder.dart`, `summary_card.dart` (`generateSlides`) | -| Chat UI | `chat/view/chat_screen.dart` + `chat/view/widgets/*` | -| Progress screen | `ai_progress_screen.dart` | -| Generation pipeline | `core/ai/services/deck_generator_*` (**already ported to refactor**) | - -State management there uses a **signals `AiConversationViewModel`** and a global -`AiStore`. - ---- - -## 4. Target architecture (refactor — "adapt to refactor patterns") - -The refactor uses **Provider + Command pattern + ChangeNotifier stores** (see -`GenerateDeckCommand`, `EditorStore`, `AppProviders`). The wizard is rebuilt to -match, rather than copying the signals ViewModel. - -### 4.1 Already in place (reuse as-is) -- Generation engine: `deck_generator_service`, `deck_generator_pipeline`, - `deck_generator_workflow`, schemas, `prompt_registry`, `font_styles`, - `generation_progress`, `error_classifier`, `retry_policy`. -- `GenerateDeckCommand` — already loads generated markdown into the editor. -- `env_config`, `gemini_models`, `paths`, `debug_logger`. -- Prompt assets incl. `wizard_system.prompt`. -- Deps: `genui`, `dartantic_ai`, `googleai_dart`, `ack*`. - -### 4.2 To build (adapted) -| Layer | New artifact | Adapted from | -|---|---|---| -| Domain model | `WizardContext` + `WizardContextKeys` | port ~verbatim (pure Dart) | -| Catalog | `chat_catalog` + `ask_user_*` + `summary_card` surfaces (**no** `ask_user_image_style`) | port; swap store lookups | -| Session | `GenUiConversationSession` + a2ui transport + agent client | port (framework glue, low churn) | -| Profile | `AiConversationProfile` + `wizardConversationProfile()` | port | -| **State** | **`WizardStore` (ChangeNotifier)** replacing `AiConversationViewModel` (signals) | **rewrite** to Provider/ChangeNotifier | -| Prompt bridge | `prompt_builder` (WizardContext → prompt string) | port | -| Command | reuse/extend **`GenerateDeckCommand`** as the generation entry point | existing | -| UI | wizard chat screen + input + surface host + progress | port, restyle to refactor UI kit | -| Wiring | Provider scope for wizard, route/entry button in editor | new, follow `AppProviders`/router | - -### 4.3 Key adaptation decisions -1. **Signals → ChangeNotifier.** `AiConversationViewModel` (signals) becomes - `WizardStore extends ChangeNotifier`, exposed via `Provider`/`ChangeNotifierProvider` - scoped to the wizard route, mirroring `EditorStore`. `GenUiConversationSession` - internals stay stream-based (they already are) — only the outward state surface - changes. -2. **Generation goes through `GenerateDeckCommand`.** The summary card's confirm - builds a prompt from `WizardContext` and invokes the existing command, instead - of the old global `AiStore.generate`. Any extra inputs the command doesn't yet - accept (e.g. `imageStyleId`, `backgroundColor`) are added to the command - signature — see open questions. -3. **Progress UI reuses the command's `phase`/running state** (already surfaced by - `GenerateDeckCommand`) rather than a separate `AiStore`. -4. **Catalog surfaces read the wizard store via `context.read`/Provider**, not the - old `prov.Provider.of`. - ---- - -## 5. Scope - -### In scope -- Conversational GenUI wizard end-to-end: entry → interview → summary → generate → - editor. -- The full `ask_user_*` catalog + summary card. -- WizardContext accumulation and prompt building. -- Progress + error surfacing during generation. - -### Out of scope (for this feature) -- **Deck editing tools** (`deck_tools_*`, `deck_edit_*`) — that's the separate - "edit existing deck via AI" flow, not the creation wizard. -- **Image generation** (`image_generator_service`, `gemini_image_options`, - `ask_user_image_style`). Dropped for v1 — decision #1. -- Persisting/resuming a wizard session across app restarts. - ---- - -## 6. Decisions - -Resolved: -1. **Image styles → OUT for v1.** Ship the wizard **text/style-only**. Do **not** - port image generation (`image_generator_service`, `gemini_image_options`) or the - `ask_user_image_style` surface. Drop the image-style question and the - `imageStyle*` fields from the active flow. `WizardContext` may keep the fields - (nullable, unused) to minimize churn, but nothing populates or consumes them. -2. **Entry point → KEEP BOTH.** The existing single-prompt `agent_generate_panel` - modal stays (quick path). The wizard is added as a separate **guided** path with - its own entry point/route. Both funnel into `GenerateDeckCommand`. - -Defaults chosen (revisit only if they cause friction): -3. **Surface layout → single-column conversational** for v1: catalog surfaces - render inline in the message thread (simpler than the old two-pane; fits a - route/modal). The two-pane layout can come later. -4. **`GenerateDeckCommand` inputs → unchanged.** The built prompt string (with - style/color hints from `WizardContext` via `prompt_builder`) is sufficient; the - pipeline already extracts final style from the generated deck JSON. No new - command parameters for v1. -5. **Model picker → hardcode default** (`GeminiModels.defaultValue`) for v1; no - `model_select` UI. - ---- - -## 7. Suggested build order (once questions are settled) - -1. Port pure-Dart core: `WizardContext`, `WizardContextKeys`, `prompt_builder`. -2. Port session/transport/agent-client + `AiConversationProfile` + - `wizardConversationProfile()`. -3. Build `WizardStore` (ChangeNotifier) over the session. -4. Port catalog surfaces one at a time (text → radio → checkbox → slider → style → - summary), wiring each to `WizardStore`. -5. Bridge summary-card confirm → `GenerateDeckCommand`. -6. Wizard chat UI + entry point + Provider wiring. -7. Progress/error UI. -8. Tests mirroring `test/` structure; regenerate `*.g.dart` via build_runner. -``` diff --git a/.planning/tool_sub_system.md b/.planning/tool_sub_system.md deleted file mode 100644 index c847b74f..00000000 --- a/.planning/tool_sub_system.md +++ /dev/null @@ -1,146 +0,0 @@ -# AI Deck-CRUD Tool Subsystem — Architecture & Requirements - -> **Status:** Designed and ported during the `superdeck_ai → playground` migration, but -> **never wired into the running app** (reachable only from tests). It has been **removed** -> from the codebase to eliminate dead code; this document is the spec to **reimplement it in a -> dedicated PR**. The previous implementation is recoverable from git history (the commit -> immediately before the removal commit on `feat/playground-framework-support-v1`). - -## 1. Purpose & intent - -Let the AI **edit the current, in-memory deck** via GenUI tool-calls during a conversation — -an "edit my deck with AI" surface — as distinct from the existing **wizard**, which only does -*initial* generation. The model can create / read / update / delete / move slides, update the -deck style, and read a slide back **with a rendered screenshot** so it can "see" the result and -iterate. - -It is the tool-calling counterpart to the generation pipeline: generation produces a deck; -these tools let the model *mutate* the deck it already produced (or the user is editing). - -## 2. Architecture (as designed) - -``` -model (Gemini, tool-calling) - │ calls a tool by name with JSON args - ▼ -DeckToolsAdapter maps each op → genui DynamicAiTool - │ (name, description, parameters: Ack schema → JSON Schema, invokeFunction) - ▼ -DeckToolsService the operation surface (serialized mutation queue) - │ validate (deck_mutation_helpers) → mutate List - ▼ -DeckStore (InMemoryDeckStore) - │ read: SlidesProvider() → live List - │ write: SlideSerializer.serialize(slides) → MemoryDeckLoader.updateMarkdown(md) - ▼ -DeckController.slides recomputes ──▶ editor + preview update (reactive loop closed) - │ - └─ each op returns a DeckSnapshot token (totalSlides + per-slide summary + style) - back to the model so it always has current state. -``` - -**Components** (all previously under `packages/playground/lib/features/ai/`): - -| Component | Role | -|---|---| -| `core/tools/deck_tools_service.dart` | The 7 operations + a **serialized mutation queue** (writes run one-at-a-time); reads bypass the queue. Injection seams: `DeckStore`, `BuildContextProvider`, `SlideCaptureFn`, `ReadSlideConfigurationBuilder`. | -| `core/tools/deck_store.dart` | `DeckStore` interface (`readRequired()`, `writeCanonical({slides, style})`) + `DeckDocument` value (`slides`, `style`). | -| `core/tools/in_memory_deck_store.dart` | In-memory impl: reads the live `List` via a `SlidesProvider` callback; writes by serializing to Markdown (`SlideSerializer`) and pushing into `MemoryDeckLoader` — closing the reactive loop. Web-safe. | -| `core/tools/deck_tools_schemas.dart` (+ `.g.dart`) | Ack `@AckType` request/result schemas — the **typed tool contract**. | -| `core/tools/deck_mutation_helpers.dart` | Pure helpers: index validation, unique-key checks, list insert/replace/remove/move, `buildDeckSnapshot`. | -| `core/tools/deck_tools_adapter.dart` | Maps each op → a genui `DynamicAiTool>` (params via `ackSchema.toJsonSchemaBuilder()`, `invokeFunction` → service → encode result). | -| `core/tools/errors.dart` | `DeckToolException` + `DeckToolErrorCode` constants. | -| `core/superdeck_slide_configurations.dart` | `buildRuntimeSlideConfigurations(...)` — builds `SlideConfiguration`s for the `readSlide` screenshot. | -| `presentation/thumbnail_preview_service.dart` | Batch slide→thumbnail capture (same capture dependency). | -| `core/utils/deck_style_service.dart` | Global `Signal` intended as a style broadcast. | - -## 3. The tool contract - -| Tool | Args | Result | -|---|---|---| -| `getDeck` | — | `DeckSnapshot { totalSlides, slides:[{index,key,title?}], style? }` | -| `createSlide` | `{ schema: Slide, atIndex?: int }` | `SlideMutationResult { slide, deck }` | -| `updateSlide` | `{ index: int, schema: Slide }` | `SlideMutationResult` | -| `deleteSlide` | `{ index: int }` | `DeckSnapshot` | -| `moveSlide` | `{ fromIndex: int, toIndex: int }` | `SlideMoveResult { slide, deck }` | -| `updateStyle` | `{ style: DeckStyle }` | `StyleUpdateResult { style, deck }` | -| `readSlide` *(service-only; never exposed as a tool — see G3)* | `{ index: int }` | `ReadSlideResult { slide(+base64 thumbnail), deck }` | - -Slide / style payloads reuse the generation schemas (`deck_schemas.dart`); args are Ack-validated. - -## 4. GenUI tool-calling integration seam - -`GoogleGenerativeAiContentGenerator(... additionalTools: List)` -(genui_google_generative_ai). A tool is a `DynamicAiTool>` with -`name`, `description`, `parameters` (a `json_schema_builder` `Schema`, produced by -`ackSchema.toJsonSchemaBuilder()`), and `invokeFunction: (args) async => Map`. - -Today `GenUiConversationViewModel.buildConversation()` passes `additionalTools: []`. Wiring is a -single line — `additionalTools: [...DeckToolsAdapter(service).tools]` — gated on the dependency -chain in §5. - -## 5. Requirements for the reimplementation - -**Functional** -- FR1 — Expose the 6 mutation tools **and `readSlide`** as live GenUI tools in a **dedicated - "edit deck with AI" conversation surface** (separate from the generation wizard). -- FR2 — Mutations run through the serialized queue and flow `service → InMemoryDeckStore → - SlideSerializer → MemoryDeckLoader → DeckController.slides`, reactively updating editor + preview. -- FR3 — `readSlide` returns a base64 PNG via `SlideCaptureService` so the model can see a slide. -- FR4 — Every op returns a `DeckSnapshot` so the model always has current deck state. - -**Non-functional** -- NFR1 — **In-memory + web-safe** (no `dart:io`). The old disk `DeckDocumentStore` was never - built — do not reintroduce it. -- NFR2 — Editor markdown stays clean (bare image refs); all writes go through `SlideSerializer`. -- NFR3 — Ack-validated args + typed results; tool params via `toJsonSchemaBuilder()`. - -**Wiring requirements** -- `ChatViewModel` / `GenUiConversationViewModel` must accept a `DeckToolsService` and pass its - adapter tools to `additionalTools`. -- `InMemoryDeckStore` built with the **live** `MemoryDeckLoader` and a - `SlidesProvider = () => deckController.slides.value.map((c) => c.slide).toList()`. -- A `BuildContextProvider` returning a mounted `BuildContext` for `readSlide` capture. - -## 6. Known gaps to fix when reimplementing - -- **G1 — Store consistency (capture).** The old `buildRuntimeSlideConfigurations` built a - *throwaway* `DeckController` **without** the shared `MemoryAssetCacheStore`, so `readSlide`/ - thumbnail captures couldn't resolve AI-generated images. Fix: capture using the **live** - controller's `SlideConfiguration`s (which now carry `assetCacheStore` after the image-store - refactor), or pass the shared store explicitly. -- **G2 — Style plumbing.** `DeckStyleService` was a standalone global written only by - `updateStyle` and read by nobody (always null at runtime). Integrate style updates into the - existing `DeckCustomizationStore` / `DeckController.options` instead of a disconnected singleton. -- **G3 — `readSlide` not exposed.** The service had `readSlide` but the adapter's `tools` list - omitted it. Add `_readSlideTool`. -- **G4 — Vestigial disk errors.** `DeckToolErrorCode.deckFileNotFound` and the `path:` params are - leftovers from an unbuilt disk store — drop them. -- **G5 — No UX surface.** The wizard is for initial generation; an explicit "edit with AI" chat - surface needs to be designed and mounted. -- **G6 — Never constructed.** The whole dependency chain (`InMemoryDeckStore` → `DeckToolsService` - → `DeckToolsAdapter` → `additionalTools`) was absent; wire it in `main.dart` / the chat VM. - -## 7. Reuse — building blocks already in the codebase - -- `SlideSerializer` (`packages/builder/lib/src/parsers/slide_serializer.dart`) — Slide→Markdown. -- `MemoryDeckLoader.updateMarkdown` (`packages/playground/lib/utils/memory_deck_loader.dart`) — reactive in-memory load. -- `DeckController.slides` — live deck; `SlideConfiguration` now carries `assetCacheStore` (capture-ready). -- `SlideCaptureService` (`packages/superdeck/lib/src/capture/slide_capture_service.dart`) — offscreen capture. -- `ack` + `ack_json_schema_builder` — schema → JSON Schema for tool params. -- `genui` `DynamicAiTool`/`AiTool` + `GoogleGenerativeAiContentGenerator.additionalTools` — the seam. - -## 8. Files removed (recoverable from git history) - -lib: `core/tools/` (deck_tools_service, deck_tools_adapter, deck_store, in_memory_deck_store, -deck_mutation_helpers, deck_tools_schemas[.g], errors), `core/utils/deck_style_service.dart`, -`presentation/thumbnail_preview_service.dart`, `core/superdeck_slide_configurations.dart`. - -tests: `test/features/ai/core/tools/*`, `core/utils/deck_style_service_test.dart`, -`presentation/thumbnail_preview_service_test.dart`. - -## 9. Decision log - -- The unwired subsystem was removed to keep the migration free of dead code. Reimplementation is - deferred to a dedicated PR using this document as the spec. Recover the prior code from the - commit before the removal if it's a useful starting point. diff --git a/AGENTS.md b/AGENTS.md index 707e9407..b793ff52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,6 @@ packages/ builder/ # Code generators and build_runner integration demo/ # Sample presentation app docs/ # User-facing documentation (MDX format) -.planning/ # Internal development docs (not published) ``` ### Key Package Responsibilities @@ -208,7 +207,6 @@ Styles are defined in Dart through `SlideStyler`, `DeckOptions.baseStyle`, and ` ## Documentation Locations - **User docs**: `docs/` (getting-started, guides, reference) -- **Internal planning**: `.planning/` (architecture decisions, feature specs) - **Package READMEs**: Each package has its own README ## Quick Reference diff --git a/packages/playground/.planning/architecture.md b/packages/playground/.planning/architecture.md deleted file mode 100644 index 0b72085e..00000000 --- a/packages/playground/.planning/architecture.md +++ /dev/null @@ -1,295 +0,0 @@ -# Playground Architecture — Provider + Command (no Signals) - -> Proposed re-architecture of `packages/playground` following the layered -> approach from the `flutter-folder-architecture` and `flutter-state-management` -> skills — adapted to a **single package, folders only** (no nested packages). -> -> **Goal:** replace all `signals` / `Signal` / `Computed` state with -> `ChangeNotifier` **Stores**, the **Command** pattern for async actions, and -> **Provider** for dependency injection. `Result` wraps every repository call. - ---- - -## Guiding Principles - -| Concern | Old (current) | New (proposed) | -|---|---|---| -| Reactive state | `Signal` / `Computed` | `ChangeNotifier` Store + `context.watch/select` | -| Async lifecycle (loading/error/success) | Manual `isProcessing`/`error` signals inside view-models | `Command0` / `Command1` | -| Dependency injection | Ad-hoc constructors + `ViewModelScope` | `Provider` / `MultiProvider` at route level | -| Error handling | thrown exceptions / nullable error signals | `Result` (`Ok` / `Failure`) returned by repositories | -| Ephemeral state (text, focus, scroll) | mixed into stores | `StatefulWidget` + `dispose()` | - -**Dependency rule (between folders):** `presentation → domain ← data`. -Domain depends on nothing. Enforced by convention + relative imports, not package -boundaries. - -Copy the bundled base classes into the codebase first: -- `Result` → `lib/core/result.dart` -- `Command` / `Command0` / `Command1` → `lib/core/command.dart` - ---- - -## Target Folder Layout (single package) - -Keep everything inside `packages/playground/lib`. Instead of one `stores/` + -`utils/` dump and mixed feature files, give each feature the three layers plus -routes, and put cross-feature domain/data in `lib/core/`. - -``` -packages/playground/lib/ - main.dart - app/ - router.dart # GoRouter combining feature routes - providers.dart # app-root MultiProvider (global stores + repos) - - core/ # shared, cross-feature domain + data - result.dart # Result base - command.dart # Command / Command0 / Command1 base - domain/ - models/ # DeckDocument, SlideModel, TextLevel, GeminiModel... - stores/ # DeckDocumentStore, DeckCustomizationStore, SlideConfigurationStore - repositories/ # DeckRepository, AssetCacheRepository (abstract) - data/ - repositories/ # InMemoryDeckRepository, MemoryAssetCacheRepository - data_sources/ # MemoryDeckLoader, asset cache source - mappers/ # markdown <-> DeckDocument - - features/ - ai/ - domain/ - models/ # ConversationMessage, GenerationPhase, ImageGenerationProgress - repositories/ # DeckGenerationRepository, ConversationRepository (abstract) - stores/ # ConversationStore, GenerationStore, DeckToolsStore - commands/ # GenerateDeckCommand, GenerateImageCommand, SendMessageCommand, ApplyDeckEditCommand - data/ - repositories/ # DeckGenerationRepositoryImpl, ConversationRepositoryImpl - data_sources/ # deck_generator_pipeline, image_generator_source, superdeck_agent_client, genui_conversation_session - mappers/ # deck_schema_mapper - presentation/ - pages/ # ai_progress_page, conversation_page, deck_edit_page - widgets/ # chat_bubble, chat_input, model_select, typing_indicator, ai_generate_panel, catalog/* - routes/ - routes.dart - - editor/ - domain/ - stores/ # EditorStore - commands/ # RefreshThumbnailsCommand - presentation/ - pages/ # editor_page - widgets/ # customization_sidebar, preview_sidebar, color_control, text_editor - routes/ - routes.dart - - presentation/ # the "play/preview" feature - presentation/ - pages/ # presentation_page - widgets/ # empty_state - routes/ - routes.dart -``` - -> The three layers are **folders**, not packages. The dependency rule is upheld by -> discipline: nothing in `presentation/` imports from `data/`. Optionally enforce -> later with a lint (`import_lint` / custom_lint) rather than package boundaries. - ---- - -## State Inventory Migration - -Every state construct found today maps to exactly one new pattern. - -| Current construct | File (today) | New pattern | New folder | -|---|---|---|---| -| `AiConversationViewModel` (signals: model, surfaceIds, messages, isThinking…) | `features/ai/core/ai/services/ai_conversation_viewmodel.dart` | Split: **ConversationStore** (state) + **SendMessageCommand** / **StartConversationCommand** | `features/ai/domain/{stores,commands}/` | -| `AiStore` (isGenerating, phase, prompt, error, imageProgress) | `stores/ai_store.dart` | **GenerationStore** (phase, prompt, imageProgress) + **GenerateDeckCommand** (owns running/error) | `features/ai/domain/{stores,commands}/` | -| `DeckToolsService` (`_outstandingSideEffects`, `isSideEffectQueueIdle`) | `features/ai/core/tools/deck_tools_service.dart` | **DeckToolsStore** (counter + idle getter) | `features/ai/domain/stores/` | -| `DeckToolsAdapter` (`_activeInvocations`, `isIdle`) | `features/ai/core/tools/deck_tools_adapter.dart` | fold into **DeckToolsStore** | `features/ai/domain/stores/` | -| `DeckStore` / `InMemoryDeckStore` (in-memory deck document) | `features/ai/core/tools/{deck_store,in_memory_deck_store}.dart` | **DeckRepository** interface + **InMemoryDeckRepository** impl (returns `Result`) | `core/{domain/repositories,data/repositories}/` | -| `DeckEditCoordinator` (session orchestration) | `features/ai/deck_edit/deck_edit_coordinator.dart` | **ApplyDeckEditCommand** + reads DeckDocumentStore | `features/ai/domain/commands/` | -| `DeckCustomizationStore` (background, per-`TextLevel` signals, computed SlideStyler) | `stores/deck_customization_store.dart` | **DeckCustomizationStore** as `ChangeNotifier`; computed `slideStyle` → plain getter | `core/domain/stores/` (shared: editor + AI) | -| `EditorState` (`activeSlideIndex`) | `stores/editor_state.dart` | **EditorStore** (`activeSlideIndex`, setters) | `features/editor/domain/stores/` | -| `SlideConfigurationStore` (already `ChangeNotifier`) | `stores/slide_configuration_store.dart` | Keep as `ChangeNotifier`; move to core domain | `core/domain/stores/` | -| `TextEditorController` | `utils/text_editor_controller.dart` | **Ephemeral** — owned by editor `StatefulWidget`, disposed | `features/editor/presentation/widgets/` | -| `MemoryAssetCacheStore` | `utils/memory_asset_cache_store.dart` | **AssetCacheRepository** interface + **MemoryAssetCacheRepository** | `core/{domain/repositories,data/repositories}/` | -| `deck_generator_service` / `deck_generator_pipeline` / `image_generator_service` | `features/ai/core/ai/services/*` | **Data sources** behind `DeckGenerationRepository`; commands call the repo | `features/ai/data/data_sources/` + `features/ai/domain/repositories/` | -| `_ThumbnailRefresher` (effects) | `features/editor/thumbnail_refresher.dart` | **RefreshThumbnailsCommand** triggered via store listeners at the route | `features/editor/domain/commands/` | -| Widget `State` (`_TextEditorState`, `_ColorControlState`, `_PresentationPageState`, typing indicators…) | various | Stay **ephemeral** `StatefulWidget` | respective `presentation/` | - -**Rule of thumb applied:** -- Signal holding data → **Store** (`ChangeNotifier`). -- Signal tracking an in-flight async op (`isProcessing`, `isGenerating`, `error`) → folded into a **Command**'s `running` / `error` / `completed`. -- Anything wrapping external I/O (deck store, asset cache, generator services, gemini client) → **Repository + data source** returning `Result`. -- `effect()` side-effects → **Commands** invoked from callbacks or `addListener` wiring at the route level. - ---- - -## Feature: `features/ai` - -### Example — GenerateDeckCommand (replaces `AiStore.isGenerating`/`error` + service call) - -```dart -typedef GenerateParams = ({String prompt, GeminiModel model}); - -class GenerateDeckCommand extends Command1 { - final DeckGenerationRepository _repository; - final GenerationStore _generation; - final DeckDocumentStore _document; - - GenerateDeckCommand(this._repository, this._generation, this._document); - - @override - Future> action(GenerateParams params) async { - _generation.setPhase(GenerationPhase.outlining); - final result = await _repository.generateDeck(params.prompt, params.model); - if (result is Ok) { - _document.setDeck(result.value); - _generation.setPhase(GenerationPhase.done); - } - return result; // command exposes running/error/completed automatically - } -} -``` - -Widgets no longer read an `isGenerating` signal — they watch the command: - -```dart -final generate = context.watch(); -if (generate.running) return const GenerationProgressView(); -if (generate.error) return const GenerationErrorView(); -``` - -### Example — ConversationStore (pure state, no async) - -```dart -class ConversationStore extends ChangeNotifier { - final List _messages = []; - List get messages => List.unmodifiable(_messages); - - GeminiModel _model = GeminiModel.flash; - GeminiModel get model => _model; - - bool get hasStarted => _messages.isNotEmpty; - - void addMessage(ConversationMessage m) { _messages.add(m); notifyListeners(); } - void setModel(GeminiModel m) { _model = m; notifyListeners(); } -} -``` - ---- - -## Feature: `features/editor` - -- `DeckCustomizationStore` lives in `core/domain/stores/` (shared) because both the - editor and the AI deck-edit flow mutate it. -- `TextEditorController` stays **ephemeral** inside `_TextEditorState` — never a store. -- Thumbnail regeneration: instead of three `effect()`s, register listeners at the - route and invoke `RefreshThumbnailsCommand` when `EditorStore` / - `DeckDocumentStore` / `DeckCustomizationStore` notify. - -## Feature: `features/presentation` - -No feature-specific store — the page reads the shared `DeckDocumentStore` and -`EditorStore.activeSlideIndex`. - ---- - -## Shared (core) Stores & Repositories - -`lib/core/domain`: -- **`DeckDocumentStore`** (`ChangeNotifier`) — single source of truth for the live - deck (slides/markdown). Replaces `InMemoryDeckStore` as *state*; IO becomes `DeckRepository`. -- **`DeckCustomizationStore`** (`ChangeNotifier`) — background color, per-`TextLevel` - color/size/weight/family; `SlideStyler get slideStyle` becomes a computed getter. -- **`SlideConfigurationStore`** — already a `ChangeNotifier`, moved here as-is. - -`lib/core/data`: -- **`DeckRepository`** / **`InMemoryDeckRepository`** — read/write deck document, return `Result`. -- **`AssetCacheRepository`** / **`MemoryAssetCacheRepository`** — generated-image cache. - ---- - -## Dependency Injection (Provider) - -`lib/app/providers.dart` — app-root globals: - -```dart -MultiProvider( - providers: [ - // Repositories (data layer, single instances) - Provider(create: (_) => InMemoryDeckRepository(...)), - Provider(create: (_) => MemoryAssetCacheRepository()), - Provider(create: (_) => - DeckGenerationRepositoryImpl(DeckGeneratorPipeline(...))), - - // Global stores - ChangeNotifierProvider(create: (_) => DeckDocumentStore()), - ChangeNotifierProvider(create: (_) => DeckCustomizationStore()), - ChangeNotifierProvider(create: (_) => SlideConfigurationStore()), - ], - child: const PlaygroundApp(), -); -``` - -Feature routes provide feature stores + commands, wiring dependencies from context: - -```dart -GoRoute aiRoute() => GoRoute( - path: '/ai', - builder: (context, state) => MultiProvider( - providers: [ - ChangeNotifierProvider(create: (_) => ConversationStore()), - ChangeNotifierProvider(create: (_) => GenerationStore()), - ChangeNotifierProvider(create: (_) => DeckToolsStore()), - ChangeNotifierProvider(create: (ctx) => GenerateDeckCommand( - ctx.read(), - ctx.read(), - ctx.read(), - )), - ChangeNotifierProvider(create: (ctx) => SendMessageCommand( - ctx.read(), - ctx.read(), - )), - ], - child: const ConversationPage(), - ), -); -``` - -`ViewModelScope` is removed — Provider replaces its create/dispose lifecycle. - ---- - -## Consumption Rules in Widgets - -| Need | API | -|---|---| -| Rebuild on any store change | `context.watch()` in `build()` | -| Rebuild on one property only | `context.select((s) => s.prop)` | -| One-off read in a callback | `context.read()` | -| Trigger async action | `onPressed: () => context.read().call(params)` | -| React to loading/error | `context.watch().running / .error / .completed` | - ---- - -## Migration Order (incremental, low-risk) - -1. **Add base classes** — `Result`, `Command` into `lib/core/` (no behavior change). -2. **Create the folder skeleton** — `app/`, `core/{domain,data}`, per-feature `{domain,data,presentation,routes}`. Move files without changing logic yet. -3. **Convert leaf stores first** — `EditorState` → `EditorStore`; `DeckCustomizationStore` signals → `ChangeNotifier` fields + getters. Swap call sites to `context.watch/select`. -4. **Introduce repositories** — wrap deck store, asset cache, generator services; return `Result`. -5. **Extract Commands** — move async logic out of `AiConversationViewModel` / `AiStore` / `DeckEditCoordinator` into `Command0/1`; delete `isProcessing` / `isGenerating` / `error` signals. -6. **Wire Provider at routes** — replace `ViewModelScope` and manual constructors. -7. **Delete the `signals` dependency** once no `Signal`/`Computed`/`effect` remain. - ---- - -## Common Mistakes to Avoid (from the skills) - -- ❌ `TextEditingController` in a store → keep it ephemeral in a `StatefulWidget`, dispose it. -- ❌ Async logic inside a store → move to a Command. -- ❌ Command without a repository → commands call repositories, not raw services/clients directly. -- ❌ Presentation importing from `data/` → presentation uses only `domain/` (stores + commands). -- ❌ Repository not returning `Result` → always wrap in `Ok`/`Failure`. -- ❌ Duplicating shared state per feature → `DeckDocumentStore` / `DeckCustomizationStore` live once in `core/`. diff --git a/packages/playground/.planning/editor-slice-plan.md b/packages/playground/.planning/editor-slice-plan.md deleted file mode 100644 index 078fb092..00000000 --- a/packages/playground/.planning/editor-slice-plan.md +++ /dev/null @@ -1,112 +0,0 @@ -# Editor → Presentation Slice Plan (no AI) - -> First implementation slice of `playground_refactor`. Ports the **editor** and -> **presentation** features from `playground` onto the Provider + Command + -> `ChangeNotifier` architecture, **without signals** and **without any AI code**. -> -> Decisions below were resolved in a design review; each row of the table is a -> settled branch, not an open question. - -## Scope - -**In:** editor (text editing, live preview sidebar, customization sidebar, -thumbnails) and presentation (full-screen slide playback with hero transitions -and keyboard nav). - -**Out (deferred):** everything under `features/ai/` — the GenUI wizard, deck-edit -flow, AI generate panel, `applyFromAiStyle`, and the `deck_schemas` / -`style_builder` couplings. - -## Load-bearing fact - -The playground's data model is **`DeckController`** (from the `superdeck` -package), which is **signals-based**: `controller.slides`, `controller.options`, -and per-slide `presentation.getThumbnail(key).status` are all signals consumed -today via `Watch(...)`. We are **not** refactoring `superdeck`, so those signals -stay. The slice's core job is to **bridge** them behind `ChangeNotifier` so the -new package authors zero signals. - -## Resolved decisions - -| # | Branch | Decision | -|---|--------|----------| -| 1 | `DeckController` signals under "no signals" | **Bridge at the boundary.** `ChangeNotifier` stores in `core/` `.subscribe()` to superdeck's signals and `notifyListeners()`. Zero `Watch`/`Signal`/`computed`/`effect` authored in `playground_refactor`; `signals_flutter` is imported only inside the bridge stores. | -| 2 | AI entanglement in the editor | **Strip AI-specific code.** Drop `applyFromAiStyle` + `deck_schemas`/`style_builder` imports from the customization store (font/weight/color resolution stays — it's generic). Move `color_utils.dart` → `core/utils/`. Port `TextEditorController` **without** AI-handoff methods. Toolbar keeps only the **play** button. Result: **no `features/ai/` imports**. | -| 3 | Data-layer shape | **Stores-only.** No `Command`/`Result`/`Repository` in this slice — editing is a reactive stream, not request/response. Those folders + `result.dart`/`command.dart` stay as scaffolding, unused until the AI slice. `ThumbnailRefresher` ports as a `ChangeNotifier`-driven **coordinator**, not a Command (`generateThumbnails` returns `void`). | -| 4 | `DeckCustomizationStore` placement | **`core/domain/stores/`.** It writes `DeckController.options`, the deck-wide render config presentation also renders — genuinely deck-global, not editor-local. | -| 5 | Per-slide thumbnail status | **Dedicated `ThumbnailStore`** bridge. On every `slides` change it (re)subscribes to each thumbnail's `status` signal and exposes a `Map`; previews read via `context.select` per key. Isolated from `DeckStore`. | -| 6 | Provider scoping | **Globals at app root:** `MemoryDeckLoader`, `MemoryAssetCacheStore`, `DeckController`, `DeckStore`, `DeckCustomizationStore`. **Editor-route-scoped** (`MultiProvider` in the route builder): `EditorStore`, `ThumbnailStore`, `TextEditorController`. Presentation is pushed on top, so the editor route stays mounted and scoped stores survive the round-trip. | -| 7 | Verification bar | **Unit-test the 3 bridge stores** (notification contract) + smoke widget tests. Rewrite — don't port — the old signal-based tests against the `ChangeNotifier` surface. | - -### Minor defaults taken - -- Drop the `dotenv` load from `main.dart` — it only supplies the AI key (`GOOGLE_AI_API_KEY`). -- `DeckCustomizationStore` seeds `DeckController.options` once in its constructor, replacing the old startup `effect`. - -## Target store topology - -``` -App root (global, in app/providers.dart) - MemoryDeckLoader data source (markdown -> parsed slides stream) - MemoryAssetCacheStore data source (thumbnail/asset cache) - DeckController superdeck; reactive deck — NEVER read by widgets directly - DeckStore (core) bridges controller.slides -> List - DeckCustomizationStore (core) background + per-TextLevel typography; writes controller.options - -Editor route (scoped, MultiProvider in editorRoutes()) - EditorStore (editor) activeSlideIndex - ThumbnailStore (core) bridges per-slide thumbnail status -> Map - TextEditorController (editor) markdown in/out (AI-handoff removed) - -Widget-ephemeral (StatefulWidget, disposed locally) - Presentation: HeroController, FocusNode, _slideIndex - Sidebar fields: TextEditingController / FocusNode (size, color, family) -``` - -> `ThumbnailStore` lives in `core/` by folder (reusable bridge) but is *provided* -> at the editor route — folder placement and provider scope are independent. - -## Bridge contracts (the risky, novel code) - -- **`DeckStore`** — subscribe to `controller.slides`; expose `List slides`; `notifyListeners()` on change. Single read-surface for slides (replaces scattered `controller.slides.value` reads and the unused `SlideConfigurationStore`). -- **`ThumbnailStore`** — on each `slides` change, reconcile subscriptions to every current `getThumbnail(key).status` signal (subscribe new keys, drop removed); expose `ThumbnailStatus statusFor(SlideKey)`; `notifyListeners()` on any status transition. Owns the dynamic subscription lifecycle. -- **`DeckCustomizationStore`** — `ChangeNotifier`; owns `background` + per-`TextLevel` `{color,size,weight,family}`; on any setter, recompute `SlideStyler` and write `controller.options.value = DeckOptions(...)`, then `notifyListeners()`. Seeds options in constructor. `applyFromAiStyle` removed. - -## Status - -- ✅ **Steps 1–3 + wiring done.** Core bridges (`DeckStore`, `ThumbnailStore`, `DeckCustomizationStore`), editor state/utils, editor UI (text editor, preview + customization sidebars, thumbnail coordinator, `EditorPage` + `editorRoutes()`), and app wiring (`providers.dart`, `router.dart`, `main.dart`) are implemented. `dart analyze lib test` is clean; 9 tests pass (6 bridge unit + 1 thumbnail + 1 editor smoke, +tearDown). Guardrails verified: no signals authored in app code, no `features/ai/` imports. -- ⏳ **Step 4 (presentation) is next.** `/present` is currently a placeholder route. - -### Implementation deviations from the plan (all deliberate) - -- **`signals` is a direct dependency** (bridge-only). `.subscribe()`/`effect()` are needed to translate `DeckController`'s signals; usage is confined to `core/domain/stores/{deck_store,thumbnail_store}.dart`. Consistent with decision 1. -- **Theme colors resolved at construction.** `DeckCustomizationStore` takes concrete seed `Color`s (resolved from `$background`/`$foreground` in `AppProviders.build`) instead of storing `ColorRef`s — `ColorRef.resolveProp` is a mix-internal member. Trade-off: seed colors are fixed to the brightness at startup (the app doesn't switch brightness at runtime). -- **`lazy: false`** on `DeckController`/`DeckStore`/`DeckCustomizationStore` so the loader→slides pipeline is subscribed before the editor writes its first markdown. - -## Build order - -1. **`core/` bridges (test-first):** - `DeckStore` → `ThumbnailStore` → `DeckCustomizationStore`, each unit-tested against a real/fake `DeckController` (assert notifications fire + values match). -2. **Editor state + utils:** `EditorStore`; trimmed `TextEditorController`; move `color_utils` → `core/utils/`. -3. **Editor UI:** `TextEditor`, `PreviewSidebar`, `CustomizationSidebar` (play-only toolbar), `ThumbnailCoordinator`, `EditorPage` + `editorRoutes()`. Replace every `Watch`/signal read with `context.watch`/`select` on the bridges. -4. **Presentation:** `PresentationPage` + `presentationRoutes()`, wired to `DeckStore`. -5. **Wiring:** `app/router.dart` composes `editorRoutes()` + `presentationRoutes()`; `app/providers.dart` holds the globals; delete the placeholder home route. - -## Definition of done - -Functional parity with the current editor + presentation: -- App runs; typing updates previews live. -- Customization edits (background, per-level color/size/weight/family) re-render the deck. -- Thumbnails appear and refresh on deck-wide style change; active slide renders live. -- Presentation plays: arrow/space advance, arrow-left back, esc exits, hero flights animate. - -Guardrails (gate the slice): -- **No signals authored** — `Signal`/`computed`/`effect`/`Watch` appear only inside `core/` bridge stores. -- **No `features/ai/` imports** anywhere in the slice. -- `dart analyze lib test` clean; bridge unit tests + smoke widget tests green. - -## Test plan - -- **Unit (bridge contract):** `DeckStore`, `ThumbnailStore`, `DeckCustomizationStore` — drive a fake `DeckController`, assert `notifyListeners` timing and exposed values; for `ThumbnailStore` assert subscribe/unsubscribe as slides are added/removed and status transitions propagate. -- **Widget (smoke):** `EditorPage` builds from seeded markdown and previews reflect an edit; `PresentationPage` renders slides and advances on key events. -- Rewrite `text_editor_load_test` / `text_editor_repaint_test` / thumbnail tests against the `ChangeNotifier` surface rather than porting signal assertions. diff --git a/packages/playground/README.md b/packages/playground/README.md index 25bd27cb..142d35cb 100644 --- a/packages/playground/README.md +++ b/packages/playground/README.md @@ -3,8 +3,6 @@ A playground app for experimenting with Mix, Remix, and Hero UI, built on a layered architecture using **Provider + Command + ChangeNotifier**. -See [`.planning/architecture.md`](.planning/architecture.md) for the full design. - ## Layers (folders, single package) ``` diff --git a/packages/playground/assets/ai_prompts/slide_system.prompt b/packages/playground/assets/ai_prompts/slide_system.prompt index a0f6e04c..ba4a8fca 100644 --- a/packages/playground/assets/ai_prompts/slide_system.prompt +++ b/packages/playground/assets/ai_prompts/slide_system.prompt @@ -56,6 +56,8 @@ selects which facts belong on this slide but cannot authorize new facts. - Fulfill the selected composition: real table, two/three dominant columns, visible metric meaning, blockquote, substantive image companion copy, or clear element handoff as applicable. +- Composition is visual guidance, not factual authority. Never invent a quote, + number, source, or table data solely to match the planned visual device. - Do not repeat the previous slide's wording or layout mechanically. Use the shared plan, previous accepted slide, next plan item, and design ledger for continuity while composing only the current slide. diff --git a/packages/playground/assets/ai_prompts/wizard_system.prompt b/packages/playground/assets/ai_prompts/wizard_system.prompt index 1686d6e5..ec19fb10 100644 --- a/packages/playground/assets/ai_prompts/wizard_system.prompt +++ b/packages/playground/assets/ai_prompts/wizard_system.prompt @@ -8,7 +8,6 @@ You are a presentation wizard. The user's first message is the presentation topi 4. AskUserSlider (step4) - 5-20 slides, unit: "slides", suggest a default based on complexity 5. AskUserStyle (step5) - exact IDs for 3 registered presentation themes 6. SummaryCard (step6) - display ALL previous selections: Topic, Audience, Approach, Emphasis, Slide Count, Theme -7. Say "Creating your presentation..." ## AskUserRadio Example (for steps 1-2) ```json @@ -17,9 +16,9 @@ You are a presentation wizard. The user's first message is the presentation topi "question": "Who is your target audience?", "description": "Select the group that best describes your viewers.", "options": [ - \{"title": "Business Professionals", "description": "Corporate stakeholders and executives"}, - \{"title": "Students", "description": "Academic learners in educational settings"}, - \{"title": "General Public", "description": "Broad audience with varied backgrounds"} + \{"title": "Business Professionals", "description": "Corporate stakeholders and executives", "icon": "business"}, + \{"title": "Students", "description": "Academic learners in educational settings", "icon": "education"}, + \{"title": "General Public", "description": "Broad audience with varied backgrounds", "icon": "global"} ], "action": \{"name": "submit_answer", "context": []} } @@ -108,6 +107,9 @@ You are a presentation wizard. The user's first message is the presentation topi - Tailor all options to the user's topic - Each step builds on previous selections - Keep text responses brief (1 sentence acknowledging the selection) +- Give every AskUserRadio option one distinct semantic icon from this exact + list: audience, business, education, global, goal, data, idea, launch, + layers, presentation, story, sparkles. Choose icons for meaning, not decoration. - NEVER pre-select or indicate a preferred/recommended option. All options must be presented as equally valid choices. Do not add "(recommended)", "(suggested)", or any marker that implies a default. Let the user choose freely. ## Handling Alternative Options @@ -117,10 +119,10 @@ If the user asks for different/alternative/other options while on a step: - Replace the current surface with new options tailored to their feedback - The topic and any previous selections remain unchanged -Tool sequence for alternative options (same as normal): -1. deleteSurface(surfaceId: "wizard") - removes the current surface -2. beginRendering(surfaceId: "wizard", root: "root") - renders new options -3. provideFinalOutput - brief acknowledgment +Message sequence for alternative options (same as a normal later turn): +1. updateComponents with `surfaceId: "wizard"` and the replacement `root` + component +2. provideFinalOutput - brief acknowledgment Example: If on step 2 (approach) and user asks "can you give me other options?", show new approach options for step 2. @@ -128,29 +130,32 @@ Example: If on step 2 (approach) and user asks "can you give me other options?", IMPORTANT: Only display ONE surface at a time. Never render multiple surfaces in a single turn. -For each user message, call these tools in ORDER then STOP: -1. deleteSurface(surfaceId: "wizard") - ALWAYS call this first (safe no-op if surface doesn't exist) -2. beginRendering(surfaceId: "wizard", root: "root") - ONE surface only -3. provideFinalOutput with response: brief acknowledgment (no JSON) +For the first user message, emit these A2UI messages in order: +1. createSurface with `surfaceId: "wizard"` +2. updateComponents with `surfaceId: "wizard"` and one `root` component +3. provideFinalOutput with a brief acknowledgment -ALWAYS call deleteSurface first, even on step 1. It safely does nothing if no surface exists yet. -Without deleteSurface, multiple surfaces will stack on screen. +For every later user message, keep the existing surface and emit: +1. updateComponents with `surfaceId: "wizard"` and the replacement `root` + component +2. provideFinalOutput with a brief acknowledgment -STOP IMMEDIATELY after provideFinalOutput. Do not call any more tools. +STOP IMMEDIATELY after provideFinalOutput. Do not emit any more messages. Do not render multiple surfaces. Do not proceed to the next step. Wait for the user to respond before continuing. ## Widget ID -When calling surfaceUpdate, the component's "id" field MUST be "root". When calling beginRendering, the "root" parameter MUST be "root". +Every updateComponents message MUST contain exactly one component whose "id" +is "root". ## Surface ID -ALWAYS use "wizard" as the surfaceId for ALL tool calls: -- deleteSurface(surfaceId: "wizard") -- beginRendering(surfaceId: "wizard", root: "root") +ALWAYS use "wizard" as the surfaceId for all A2UI messages: +- createSurface uses `surfaceId: "wizard"` on the first turn only +- updateComponents uses `surfaceId: "wizard"` on every turn -Using a consistent surfaceId ensures only one wizard surface exists at a time. If you use different surfaceIds, multiple surfaces will stack on screen. +Using a consistent surfaceId ensures only one wizard surface exists at a time. ## Step Guidance @@ -200,6 +205,3 @@ all preview and renderer tokens from the selected ID. **Step 6 - Summary** Display ALL previous selections clearly organized by category. - -**Step 7 - Generate** -The UI handles generation. Simply acknowledge that the presentation is being created. diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart index 71d9a81a..9d29294f 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart @@ -701,7 +701,10 @@ $repairSection Return exactly one slide object, not a deck and not a `slides` array. The slide key must be exactly `${current.key}`. Compose only this slide. Fulfill its assertion, content units, composition, treatment, density, and planned -elements while preserving continuity. Keep all visible Markdown at or below +elements while preserving continuity. Treat the composition as visual guidance, not factual authority. +Never invent a quotation, metric, or table data just to +match its visual device; preserve grounded, useful content in the nearest clear +layout when the supplied material cannot support that device. Keep all visible Markdown at or below $contentBudget characters and no more than $wordBudget visible words for the `${current.density}` density. Count headings, labels, and table cells in both limits. Synthesize the planned content units instead of expanding each into a diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart index 13076b08..2a70354e 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart @@ -275,15 +275,19 @@ List _validatePlanFulfillment( location: GenerationValidationLocation.visibleContent, slideKey: planSlide.key, ); + final compositionGuidance = errors.scoped( + category: GenerationValidationCategory.quality, + severity: GenerationValidationSeverity.diagnostic, + ); if (slide.sections.length > 2) { - errors.add( + compositionGuidance.add( 'Composition "${planSlide.composition}" supports at most 2 sections; ' 'found ${slide.sections.length}. Simplify the slide instead of stacking ' 'another content row.', ); } if (planSlide.composition == 'title' && slide.sections.length != 1) { - errors.add( + compositionGuidance.add( 'Composition "title" must use exactly 1 section so the display heading ' 'has enough vertical room; found ${slide.sections.length}. Put the H1 ' 'and optional short support copy in one content block.', @@ -489,13 +493,13 @@ List _validatePlanFulfillment( multiLine: true, ).hasMatch(markdown); if (composition == 'title' && (hasTable || hasList || hasBlockquote)) { - errors.add( + compositionGuidance.add( 'Composition "title" must stay minimal: use display headings and at ' 'most one short supporting paragraph, without lists, tables, or quotes.', ); } if (composition != 'table' && hasTable) { - errors.add( + compositionGuidance.add( 'Markdown tables require composition "table"; the plan selected ' '"$composition".', ); @@ -509,48 +513,60 @@ List _validatePlanFulfillment( break; case 'table': if (!hasTable) { - errors.add('Composition "table" requires a Markdown table.'); + compositionGuidance.add( + 'Composition "table" requires a Markdown table.', + ); } case 'twoColumn': if (!slide.sections.any((section) => section.blocks.length == 2)) { - errors.add('Composition "twoColumn" requires a two-block section.'); + compositionGuidance.add( + 'Composition "twoColumn" requires a two-block section.', + ); } if (!_hasSubstantiveContent(contentBlocks)) { - errors.add( + compositionGuidance.add( 'Composition "twoColumn" requires substantive content beyond a title.', ); } case 'threeColumn': if (!slide.sections.any((section) => section.blocks.length == 3)) { - errors.add('Composition "threeColumn" requires a three-block section.'); + compositionGuidance.add( + 'Composition "threeColumn" requires a three-block section.', + ); } if (!_hasSubstantiveContent(contentBlocks)) { - errors.add( + compositionGuidance.add( 'Composition "threeColumn" requires substantive content beyond a title.', ); } case 'quote': if (!RegExp(r'^\s*>\s*\S+', multiLine: true).hasMatch(markdown)) { - errors.add('Composition "quote" requires a Markdown blockquote.'); + compositionGuidance.add( + 'Composition "quote" requires a Markdown blockquote.', + ); } case 'metric': if (!RegExp(r'\d').hasMatch(markdown)) { - errors.add('Composition "metric" requires at least one numeric value.'); + compositionGuidance.add( + 'Composition "metric" requires at least one numeric value.', + ); } case 'imageLeft': case 'imageRight': if (!_hasSubstantiveContent(contentBlocks)) { - errors.add( + compositionGuidance.add( 'Composition "$composition" requires substantive text beside the image.', ); } case 'qrcode': if (!_hasSubstantiveContent(contentBlocks)) { - errors.add('Composition "qrcode" requires a clear text handoff.'); + compositionGuidance.add( + 'Composition "qrcode" requires a clear text handoff.', + ); } default: if (!_hasSubstantiveContent(contentBlocks)) { - errors.add( + compositionGuidance.add( 'Composition "$composition" requires substantive content beyond a title.', ); } diff --git a/packages/playground/lib/features/ai/quick_agent/domain/commands/generate_deck_command.dart b/packages/playground/lib/features/ai/quick_agent/domain/commands/generate_deck_command.dart index 70cc9693..0c4b4050 100644 --- a/packages/playground/lib/features/ai/quick_agent/domain/commands/generate_deck_command.dart +++ b/packages/playground/lib/features/ai/quick_agent/domain/commands/generate_deck_command.dart @@ -33,13 +33,17 @@ class GenerateDeckCommand extends Command1 { GenerateDeckCommand({ required DeckDocumentStore documentStore, required DeckCustomizationStore customizationStore, + DeckGeneratorService? service, }) : _documentStore = documentStore, - _customizationStore = customizationStore; + _customizationStore = customizationStore, + _service = service; final DeckDocumentStore _documentStore; final DeckCustomizationStore _customizationStore; + final DeckGeneratorService? _service; GenerationProgress _progress = const GenerationProgress(GenerationPhase.idle); + String? _completionNotice; bool _cancelled = false; bool _disposed = false; @@ -48,9 +52,12 @@ class GenerateDeckCommand extends Command1 { GenerationProgress get progress => _progress; + /// Non-blocking detail for a completed partial generation. + String? get completionNotice => _completionNotice; + @override Future> action(DeckGenerationRequest request) async { - if (!EnvConfig.hasGeminiApiKey) { + if (_service == null && !EnvConfig.hasGeminiApiKey) { return const Result.error( GenerationException( 'No Gemini API key configured. ' @@ -60,18 +67,20 @@ class GenerateDeckCommand extends Command1 { } _cancelled = false; + _completionNotice = null; _progress = const GenerationProgress(GenerationPhase.generatingOutline); notifyListeners(); try { - final service = DeckGeneratorService(apiKey: EnvConfig.geminiApiKey); + final service = + _service ?? DeckGeneratorService(apiKey: EnvConfig.geminiApiKey); final result = await service.generate( request, onProgress: _onProgress, isCancelled: () => _cancelled, ); - if (!result.success) { + if (!result.success && !result.isPartial) { return Result.error( GenerationException(result.error ?? 'Unknown generation error.'), ); @@ -90,9 +99,13 @@ class GenerateDeckCommand extends Command1 { if (result.theme case final theme?) { _customizationStore.applyGeneratedStyle(theme.toGeneratedDeckStyle()); } + if (result.isPartial) { + _completionNotice = result.error; + } debugLog.log( 'GENERATE_DECK', - 'Loaded ${result.slides.length} slides into editor.', + 'Loaded ${result.slides.length} slides into editor' + '${result.isPartial ? ' with ${result.slideFailures.length} unresolved' : ''}.', ); return const Result.ok(null); @@ -117,6 +130,12 @@ class GenerateDeckCommand extends Command1 { notifyListeners(); } + @override + void clearResult() { + _completionNotice = null; + super.clearResult(); + } + @override void notifyListeners() { if (!_disposed) super.notifyListeners(); diff --git a/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_bubble.dart b/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_bubble.dart index 63709376..da3e166f 100644 --- a/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_bubble.dart +++ b/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_bubble.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:hero_ui/hero_ui.dart'; import 'package:remix/remix.dart'; -import '../../../core/ui/ui.dart'; /// Type of chat message bubble for styling differentiation. /// @@ -27,21 +26,40 @@ class TextBubble extends StatelessWidget { const TextBubble({super.key, required this.text, required this.type}); BoxStyler get _userStyle => .new() - .borderRadiusAll(Radius.circular(SdTokens.cardRadius)) - .paddingAll(12) + .borderRadius( + BorderRadiusMix.value( + const BorderRadius.only( + topLeft: Radius.circular(18), + topRight: Radius.circular(18), + bottomLeft: Radius.circular(18), + bottomRight: Radius.circular(5), + ), + ), + ) + .paddingX(16) + .paddingY(12) + .maxWidth(540) .color($accentSoft()) .wrap( WidgetModifierConfig // .align(alignment: .centerRight) .defaultTextStyle(style: $paragraphMedium.mix()) - .defaultTextStyle( - style: TextStyleMix(color: $muted()), - ), + .defaultTextStyle(style: TextStyleMix(color: $muted())), ); BoxStyler get _aiStyle => _userStyle - .color(Colors.transparent) - .borderAll(color: $separator()) + .borderRadius( + BorderRadiusMix.value( + const BorderRadius.only( + topLeft: Radius.circular(18), + topRight: Radius.circular(18), + bottomLeft: Radius.circular(5), + bottomRight: Radius.circular(18), + ), + ), + ) + .color($surfaceSecondary()) + .borderAll(color: $border()) .wrap( WidgetModifierConfig // .align(alignment: .centerLeft) @@ -59,9 +77,7 @@ class TextBubble extends StatelessWidget { .wrap( WidgetModifierConfig // .defaultTextStyle(style: $paragraphSmall.mix()) - .defaultTextStyle( - style: TextStyleMix(color: $muted()), - ), + .defaultTextStyle(style: TextStyleMix(color: $muted())), ); @override diff --git a/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_genui_panels.dart b/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_genui_panels.dart index c0a6620c..14ce52b5 100644 --- a/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_genui_panels.dart +++ b/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_genui_panels.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:genui/genui.dart'; import 'package:hero_ui/hero_ui.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:remix/remix.dart'; import 'package:signals/signals_flutter.dart'; import '../../../presentation/view/loading.dart'; @@ -8,25 +9,15 @@ import '../../chat_message.dart'; import 'chat_bubble.dart'; import '../../../core/ui/ui.dart'; -/// Shared bubble border radius for iOS-style chat bubbles. -const _bubbleBorderRadius = BorderRadius.only( - topLeft: .circular(20), - topRight: .circular(20), - bottomRight: .circular(20), - bottomLeft: .circular(6), -); - -/// Typing indicator bubble - shows only when AI is thinking. -class TypingBubble extends SignalWidget { +/// Typing indicator - shows only when AI is thinking. +class TypingBubble extends StatelessWidget { const TypingBubble({super.key, required this.isThinking}); - final ReadonlySignal isThinking; + final bool isThinking; @override Widget build(BuildContext context) { - final thinking = isThinking.value; - - if (!thinking) return const SizedBox.shrink(); + if (!isThinking) return const SizedBox.shrink(); return RowBox( style: FlexBoxStyler().spacing(8).padding(.bottom(12)), @@ -47,71 +38,29 @@ class TypingBubble extends SignalWidget { } } -/// Message bubble - shows the last AI message (hidden when thinking). -class GenUiMessageBubble extends SignalWidget { - const GenUiMessageBubble({ - super.key, - required this.isThinking, - required this.messages, - }); - - final ReadonlySignal isThinking; - final ReadonlySignal> messages; - - BoxStyler get _bubbleStyle => BoxStyler() - .color($background()) - .borderRadius(BorderRadiusMix.value(_bubbleBorderRadius)) - .paddingX(24) - .paddingY(16) - .maxWidth(600) - .marginOnly(left: 48, bottom: 16) - .wrap(.align(alignment: .centerLeft)); - - @override - Widget build(BuildContext context) { - final currentMessages = messages.value; - final thinking = isThinking.value; - - final lastAiMessage = currentMessages.reversed - .whereType() - .firstOrNull; - - if (thinking || lastAiMessage == null) { - return const SizedBox.shrink(); - } - - return _bubbleStyle( - child: SdBody( - lastAiMessage.text, - style: TextStyler().color($foreground()).style($paragraphMedium.mix()), - ), - ); - } -} - /// Shared surfaces panel used by AI conversation screens. -class AiSurfacesPanel extends SignalWidget { +class AiSurfacesPanel extends StatelessWidget { const AiSurfacesPanel({ super.key, required this.controller, required this.surfaceIds, required this.isThinking, - required this.messages, + this.errorMessage, this.inputWidget, }); final SurfaceController? controller; - final ReadonlySignal> surfaceIds; - final ReadonlySignal isThinking; - final ReadonlySignal> messages; + final List surfaceIds; + final bool isThinking; + final String? errorMessage; /// Optional input widget to display at bottom when chat panel is hidden. final Widget? inputWidget; @override Widget build(BuildContext context) { - final ids = surfaceIds.value; - final thinking = isThinking.value; + final ids = surfaceIds; + final thinking = isThinking; final flex = FlexBoxStyler() .spacing(16) @@ -127,8 +76,8 @@ class AiSurfacesPanel extends SignalWidget { padding: const EdgeInsets.all(24), child: AnimatedSwitcher( duration: SdTokens.motionMedium, - switchInCurve: Curves.easeIn, - switchOutCurve: Curves.easeOut, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, child: AnimatedOpacity( duration: SdTokens.motionFast, opacity: thinking ? 0.5 : 1.0, @@ -157,31 +106,39 @@ class AiSurfacesPanel extends SignalWidget { } } + final errorWidget = errorMessage == null + ? null + : Padding( + padding: const EdgeInsets.all(24), + child: SdCallout( + text: errorMessage, + icon: LucideIcons.triangleAlert, + ), + ); + if (inputWidget == null) { - return surfacesWidget ?? const SizedBox.shrink(); + return surfacesWidget ?? errorWidget ?? const SizedBox.shrink(); } - return Column( - children: [ - Expanded( - child: Center( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - GenUiMessageBubble( - isThinking: isThinking, - messages: messages, - ), - ?surfacesWidget, - ], + return SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Center( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [?surfacesWidget, ?errorWidget], + ), ), ), ), - ), - TypingBubble(isThinking: isThinking), - inputWidget ?? const SizedBox.shrink(), - ], + TypingBubble(isThinking: thinking), + inputWidget ?? const SizedBox.shrink(), + ], + ), ); } } diff --git a/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_input.dart b/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_input.dart index ee8ba807..83e1d599 100644 --- a/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_input.dart +++ b/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_input.dart @@ -11,6 +11,7 @@ class ChatInput extends StatelessWidget { final FocusNode? focusNode; final bool enabled; final ValueChanged onSubmitted; + final String hintText; const ChatInput({ super.key, @@ -18,6 +19,7 @@ class ChatInput extends StatelessWidget { this.focusNode, required this.enabled, required this.onSubmitted, + this.hintText = 'Type a message...', }); @override @@ -28,7 +30,7 @@ class ChatInput extends StatelessWidget { .fontWeight(FontWeight.w600); return HeroTextField( - hintText: 'Type a message...', + hintText: hintText, trailing: trailingStyle('Press Enter'), textInputAction: TextInputAction.send, controller: controller, @@ -37,7 +39,9 @@ class ChatInput extends StatelessWidget { semanticLabel: 'Chat message input', style: RemixTextFieldStyle() .padding(.horizontal(16).vertical(14)) - .border(.color($border())), + .backgroundColor($surfaceSecondary()) + .border(.color($border())) + .borderRadiusAll(const Radius.circular(14)), onSubmitted: enabled ? onSubmitted : null, ); } diff --git a/packages/playground/lib/features/ai/wizard/chat/view/widgets/empty_state.dart b/packages/playground/lib/features/ai/wizard/chat/view/widgets/empty_state.dart index 21fd6fc5..2d21bbff 100644 --- a/packages/playground/lib/features/ai/wizard/chat/view/widgets/empty_state.dart +++ b/packages/playground/lib/features/ai/wizard/chat/view/widgets/empty_state.dart @@ -1,83 +1,28 @@ import 'package:flutter/material.dart'; -import 'package:hero_ui/hero_ui.dart'; -import 'package:remix/remix.dart'; import '../../../core/ui/ui.dart'; -import '../../../presentation/view/loading.dart'; /// Initial state displayed before conversation starts. -/// -/// Shows a welcome message with the app logo and example prompts -/// to help users get started with presentation generation. class EmptyState extends StatelessWidget { - const EmptyState({super.key, this.onSuggestionTap}); + const EmptyState({super.key, required this.input}); - /// Called when a suggestion chip is tapped, with the suggestion text. - final ValueChanged? onSuggestionTap; + final Widget input; @override Widget build(BuildContext context) { - // Unique: text3 + gray10 (slightly lighter than SdBody) - final subtitleStyle = TextStyler() - .style($paragraphMedium.mix()) - .color($muted()); - - final suggestionsContainer = FlexBoxStyler() - .column() - .crossAxisAlignment(.start) - .spacing(12); - return Center( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 48), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - CustomPaint( - size: const Size(80, 80), - painter: IsometricLogoPainter( - colors: [ - $muted.resolve(context), - $muted.resolve(context).withValues(alpha: 0.7), - $muted.resolve(context).withValues(alpha: 0.4), - $muted.resolve(context).withValues(alpha: 0.2), - ], - ), - ), - const SizedBox(height: 24), - SdHeadline('Create stunning presentations'), - const SizedBox(height: 8), - subtitleStyle( - 'Describe your idea and I\'ll design the slides for you', - ), - const SizedBox(height: 40), - suggestionsContainer( - children: [ - SdHint('Try asking something like:'), - const SizedBox(height: 4), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (final suggestion in [ - 'Startup pitch deck', - 'Sales report Q4', - 'Team onboarding', - ]) - HeroButton( - label: suggestion, - variant: .outline, - size: .sm, - style: .new().variant( - HeroButtonVariant.outline, - .new().color($background()).foregroundColor($muted()), - ), - onPressed: () => onSuggestionTap?.call(suggestion), - ), - ], - ), - ], - ), - ], + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 620), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SdHeadline('What is the presentation about?'), + const SizedBox(height: 20), + input, + ], + ), ), ), ); diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_question_cards.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_question_cards.dart index 547bf5d0..839115e7 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_question_cards.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_question_cards.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:hero_ui/hero_ui.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:remix/remix.dart'; import '../../../../../../core/domain/design/presentation_theme_catalog.dart'; @@ -14,37 +15,47 @@ import '../../utils/font_utils.dart'; // ─────────────────────────────────── STYLING UTILITIES ─────────────────────────────────── /// Returns body text style for selected state. -TextStyler? selectedBodyStyle(bool selected) => - selected ? TextStyler().color($accent()) : null; +TextStyler? selectedBodyStyle(bool selected) => selected + ? TextStyler().color($foreground()).fontWeight(FontWeight.w600) + : null; /// Returns caption text style for selected state. TextStyler? selectedCaptionStyle(bool selected) => - selected ? TextStyler().color($accent()) : null; + selected ? TextStyler().color($muted()) : null; // ─────────────────────────────────── CARD WIDGETS ─────────────────────────────────── /// Radio option card with title and optional description. class RadioOptionCard extends StatelessWidget { - final String title; - final String? description; - final bool selected; - final VoidCallback? onTap; - const RadioOptionCard({ super.key, required this.title, this.description, + this.icon = LucideIcons.presentation, required this.selected, this.onTap, }); + final String title; + final String? description; + final IconData icon; + final bool selected; + final VoidCallback? onTap; + @override Widget build(BuildContext context) { final content = FlexBoxStyler() .column() - .spacing(4) + .spacing(8) .crossAxisAlignment(CrossAxisAlignment.start); + final iconColor = selected + ? $accent.resolve(context) + : $muted.resolve(context); + final iconBackground = selected + ? $accentSoft.resolve(context) + : $surfaceTertiary.resolve(context); + return Semantics( inMutuallyExclusiveGroup: true, selected: selected, @@ -56,6 +67,16 @@ class RadioOptionCard extends StatelessWidget { style: FlexBoxStyler().minHeight(SdTokens.cardMinHeight), child: content( children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: iconBackground, + borderRadius: BorderRadius.circular(10), + ), + alignment: Alignment.center, + child: Icon(icon, size: 20, color: iconColor), + ), SdBody(title, style: selectedBodyStyle(selected)), if (description != null && description!.isNotEmpty) SdCaption(description!, style: selectedCaptionStyle(selected)), @@ -105,7 +126,7 @@ class CheckboxOptionCard extends StatelessWidget { } } -/// Style option card with color swatches and font previews. +/// Style option card with a composed palette and typography preview. class StyleOptionCard extends StatelessWidget { const StyleOptionCard({ super.key, @@ -122,20 +143,13 @@ class StyleOptionCard extends StatelessWidget { Widget build(BuildContext context) { final content = FlexBoxStyler() .column() - .mainAxisSize(MainAxisSize.min) - .spacing(4) - .crossAxisAlignment(CrossAxisAlignment.start); - - final colorRow = FlexBoxStyler().paddingY(5).row(); - - final recipe = theme.recipe; + .crossAxisAlignment(CrossAxisAlignment.stretch) + .spacing(12) + .mainAxisSize(MainAxisSize.min); final bodyStyle = selectedBodyStyle(selected); final captionStyle = selectedCaptionStyle(selected); - final headlineFontLoaded = tryGetGoogleFontFamily(recipe.headlineFamily); - final bodyFontLoaded = tryGetGoogleFontFamily(recipe.bodyFamily); - return Semantics( selected: selected, inMutuallyExclusiveGroup: true, @@ -147,34 +161,9 @@ class StyleOptionCard extends StatelessWidget { style: FlexBoxStyler().minHeight(SdTokens.cardMinHeight), child: content( children: [ + _ThemeSamplePreview(theme: theme), SdBody(theme.title, style: bodyStyle), SdCaption(theme.description, style: captionStyle), - colorRow( - children: recipe.palette.previewColors - .map( - (color) => SdColorCircle( - color: hexToColor(color), - interactive: true, - ), - ) - .toList(), - ), - SdBody( - recipe.headlineFamily, - style: headlineFontLoaded != null - ? TextStyler() - .fontFamily(headlineFontLoaded) - .merge(bodyStyle) - : bodyStyle, - ), - SdCaption( - recipe.bodyFamily, - style: bodyFontLoaded != null - ? TextStyler() - .fontFamily(bodyFontLoaded) - .merge(captionStyle) - : captionStyle, - ), ], ), ), @@ -183,6 +172,75 @@ class StyleOptionCard extends StatelessWidget { } } +class _ThemeSamplePreview extends StatelessWidget { + const _ThemeSamplePreview({required this.theme}); + + final PresentationThemeDescriptor theme; + + @override + Widget build(BuildContext context) { + final recipe = theme.recipe; + final palette = recipe.palette; + final headlineFamily = tryGetGoogleFontFamily(recipe.headlineFamily); + final bodyFamily = tryGetGoogleFontFamily(recipe.bodyFamily); + final background = hexToColor(palette.background); + final surface = hexToColor(palette.surface); + final heading = hexToColor(palette.heading); + final body = hexToColor(palette.body); + final accent = hexToColor(palette.accent); + + return Container( + width: double.infinity, + height: 148, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: surface, width: 2), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 38, + height: 4, + decoration: BoxDecoration( + color: accent, + borderRadius: BorderRadius.circular(999), + ), + ), + const Spacer(), + Text( + 'Build what’s next', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: heading, + fontFamily: headlineFamily, + fontSize: 22, + fontWeight: FontWeight.w700, + height: 1.05, + ), + ), + const SizedBox(height: 8), + Text( + 'A clear story, beautifully presented.', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: body, + fontFamily: bodyFamily, + fontSize: 12, + fontWeight: FontWeight.w400, + height: 1.25, + ), + ), + ], + ), + ); + } +} + /// Image style option card with generated preview image. class ImageStyleOptionCard extends StatelessWidget { final ImageStyle style; diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart index ea9a3ef3..de389448 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart @@ -4,7 +4,6 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; import 'package:genui/genui.dart'; -import 'package:remix/remix.dart'; import '../schemas/genui_action_schema.dart'; import 'user_action_dispatch.dart'; @@ -15,6 +14,7 @@ import 'ask_user_question_cards.dart'; import 'catalog_question_step.dart'; import 'component_schema.dart'; import 'typed_catalog_item.dart'; +import 'wizard_option_icon.dart'; part 'ask_user_radio.g.dart'; @@ -25,6 +25,9 @@ part 'ask_user_radio.g.dart'; final _inputOptionSchema = Ack.object({ 'title': Ack.string().describe('Option title displayed to user'), 'description': Ack.string().optional().describe('Optional description text'), + 'icon': Ack.enumValues( + WizardOptionIcon.values, + ).optional().describe('Semantic icon for this option card'), }).describe('Option with title and optional description'); /// Schema for AskUserRadio component. @@ -57,9 +60,9 @@ final askUserRadio = typedCatalogItem( "question": "Who is your target audience?", "description": "Select the group that best describes your viewers.", "options": [ - {"title": "Business Professionals", "description": "Corporate stakeholders"}, - {"title": "Students", "description": "Academic learners"}, - {"title": "General Public", "description": "Broad audience"} + {"title": "Business Professionals", "description": "Corporate stakeholders", "icon": "business"}, + {"title": "Students", "description": "Academic learners", "icon": "education"}, + {"title": "General Public", "description": "Broad audience", "icon": "global"} ], "action": {"name": "submit_answer", "context": []} } @@ -118,32 +121,45 @@ class _AskUserRadioContentState extends State<_AskUserRadioContent> { Widget _buildOptions() { final options = widget.data.options; - final optionsRow = FlexBoxStyler() - .spacing(16) - .wrap(WidgetModifierConfig.intrinsicHeight()); if (options.isEmpty) { debugLog.log('AskUserRadio', 'WARNING: radio input has no options.'); return const SdBody('No options available'); } - return optionsRow( - children: options.asMap().entries.map((entry) { - final index = entry.key; - final option = entry.value; - final isSelected = _selectedIndex == index; - - return Expanded( - child: RadioOptionCard( - title: option.title, - description: option.description, - selected: isSelected, - onTap: () { - setState(() => _selectedIndex = index); - }, - ), + return LayoutBuilder( + builder: (context, constraints) { + final columnCount = constraints.maxWidth >= 620 + ? 3 + : constraints.maxWidth >= 420 + ? 2 + : 1; + const spacing = 12.0; + final cardWidth = + (constraints.maxWidth - spacing * (columnCount - 1)) / columnCount; + + return Wrap( + spacing: spacing, + runSpacing: spacing, + children: options.asMap().entries.map((entry) { + final index = entry.key; + final option = entry.value; + final isSelected = _selectedIndex == index; + + return SizedBox( + width: cardWidth, + child: RadioOptionCard( + title: option.title, + description: option.description, + icon: (option.icon ?? WizardOptionIcon.fallbackFor(index)) + .iconData, + selected: isSelected, + onTap: () => setState(() => _selectedIndex = index), + ), + ); + }).toList(), ); - }).toList(), + }, ); } } diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.g.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.g.dart index 663c82c1..dd111dda 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.g.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.g.dart @@ -27,6 +27,8 @@ extension type InputOptionType(Map _data) String get title => _data['title'] as String; String? get description => _data['description'] as String?; + + WizardOptionIcon? get icon => _data['icon'] as WizardOptionIcon?; } /// Extension type for AskUserRadio diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_slider.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_slider.dart index a7ff4f7a..ae9d07b8 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_slider.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_slider.dart @@ -5,7 +5,7 @@ import 'package:ack_annotations/ack_annotations.dart'; import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; import 'package:genui/genui.dart'; import 'package:hero_ui/hero_ui.dart'; -import 'package:remix/remix.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../schemas/genui_action_schema.dart'; import 'user_action_dispatch.dart'; @@ -21,24 +21,21 @@ part 'ask_user_slider.g.dart'; /// Schema for AskUserSlider component. /// -/// Displays a question with a slider for numeric input. +/// Displays a question with a focused numeric selector. @AckType(name: 'AskUserSlider') -final _askUserSliderSchema = - Ack.object({ - 'question': Ack.string().describe('The question to display to the user'), - 'description': Ack.string().optional().describe( - 'Additional context or instructions', - ), - 'minValue': Ack.integer().describe('Minimum value'), - 'maxValue': Ack.integer().describe('Maximum value'), - 'defaultValue': Ack.integer().describe('Default/initial value'), - 'unit': Ack.string().optional().describe( - 'Unit label e.g. "slides", "minutes"', - ), - 'action': actionSchema, - }).describe( - 'A question with a slider for numeric selection between min and max.', - ); +final _askUserSliderSchema = Ack.object({ + 'question': Ack.string().describe('The question to display to the user'), + 'description': Ack.string().optional().describe( + 'Additional context or instructions', + ), + 'minValue': Ack.integer().describe('Minimum value'), + 'maxValue': Ack.integer().describe('Maximum value'), + 'defaultValue': Ack.integer().describe('Default/initial value'), + 'unit': Ack.string().optional().describe( + 'Unit label e.g. "slides", "minutes"', + ), + 'action': actionSchema, +}).describe('A question with a counter and quick choices between min and max.'); // ─────────────────────────────────── CATALOG ITEM ─────────────────────────────────── @@ -69,6 +66,204 @@ final askUserSlider = typedCatalogItem( // ─────────────────────────────────── WIDGET ─────────────────────────────────── +/// Focused numeric selector used for the deck-length step. +class DeckLengthSelector extends StatelessWidget { + const DeckLengthSelector({ + super.key, + required this.value, + required this.min, + required this.max, + required this.onChanged, + this.unit = 'slides', + }); + + final int value; + final int min; + final int max; + final String unit; + final ValueChanged onChanged; + + List get _presets { + final values = unit.toLowerCase().contains('slide') + ? [min, 8, 10, 12, 15, 20, max] + : [min, ((min + max) / 2).round(), max]; + return values.where((item) => item >= min && item <= max).toSet().toList() + ..sort(); + } + + @override + Widget build(BuildContext context) { + final iconColor = $accent.resolve(context); + final mutedColor = $muted.resolve(context); + + final identity = Row( + mainAxisSize: MainAxisSize.min, + spacing: 12, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: $accentSoft.resolve(context), + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Icon(LucideIcons.presentation, size: 22, color: iconColor), + ), + const Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SdBody('Deck length'), + SdCaption('Choose a pace that fits your story'), + ], + ), + ), + ], + ); + + final counter = Container( + decoration: BoxDecoration( + color: $background.resolve(context), + border: Border.all(color: $border.resolve(context)), + borderRadius: BorderRadius.circular(14), + ), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'One fewer slide', + onPressed: value > min ? () => onChanged(value - 1) : null, + icon: const Icon(LucideIcons.minus, size: 18), + ), + SizedBox( + width: 76, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$value', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: $foreground.resolve(context), + fontWeight: FontWeight.w700, + height: 1, + ), + ), + if (unit.isNotEmpty) + Text( + unit, + style: Theme.of( + context, + ).textTheme.labelSmall?.copyWith(color: mutedColor), + ), + ], + ), + ), + IconButton( + tooltip: 'One more slide', + onPressed: value < max ? () => onChanged(value + 1) : null, + icon: const Icon(LucideIcons.plus, size: 18), + ), + ], + ), + ); + + return SdPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 18, + children: [ + LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 520) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [identity, counter], + ); + } + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 16, + children: [ + Expanded(child: identity), + counter, + ], + ); + }, + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final preset in _presets) + _DeckLengthPreset( + value: preset, + selected: preset == value, + onTap: () => onChanged(preset), + ), + ], + ), + ], + ), + ); + } +} + +class _DeckLengthPreset extends StatelessWidget { + const _DeckLengthPreset({ + required this.value, + required this.selected, + required this.onTap, + }); + + final int value; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + selected: selected, + label: '$value slides', + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTap, + child: AnimatedContainer( + duration: SdTokens.motionFast, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + decoration: BoxDecoration( + color: selected + ? $accentSoft.resolve(context) + : $background.resolve(context), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: selected + ? $accent.resolve(context) + : $border.resolve(context), + ), + ), + child: Text( + '$value', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: selected + ? $accent.resolve(context) + : $muted.resolve(context), + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ); + } +} + class _AskUserSliderContent extends StatefulWidget { final AskUserSliderType data; final CatalogItemContext itemContext; @@ -148,44 +343,14 @@ class _AskUserSliderContentState extends State<_AskUserSliderContent> { Widget _buildSlider() { final minValue = widget.data.minValue; final maxValue = widget.data.maxValue; - - final labelStyle = TextStyler() - .color($muted()) - .style($paragraphSmall.mix()); - - final valueStyle = TextStyler() - .color($accent()) - .style($titleH5.mix()) - .fontWeight(.bold) - .wrap( - WidgetModifierConfig() - .sizedBox(width: 120) - .align(alignment: .centerRight), - ); - final unit = widget.data.unit ?? ''; - final displayValue = '$_sliderValue${unit.isNotEmpty ? ' $unit' : ''}'; - return SdPanel( - child: Row( - spacing: 12, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - labelStyle('$minValue'), - Expanded( - child: SdSlider( - value: _sliderValue.toDouble(), - min: minValue.toDouble(), - max: maxValue.toDouble(), - onChanged: (value) { - setState(() => _sliderValue = value.round()); - }, - ), - ), - labelStyle('$maxValue'), - valueStyle(displayValue), - ], - ), + return DeckLengthSelector( + value: _sliderValue, + min: minValue, + max: maxValue, + unit: unit, + onChanged: (value) => setState(() => _sliderValue = value), ); } } diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_style.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_style.dart index 1c8006c4..8961c334 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_style.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_style.dart @@ -6,7 +6,6 @@ import 'package:ack/ack.dart'; import 'package:ack_annotations/ack_annotations.dart'; import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; import 'package:genui/genui.dart'; -import 'package:remix/remix.dart'; import '../../../../../../core/domain/design/presentation_theme_catalog.dart'; import '../schemas/genui_action_schema.dart'; @@ -151,29 +150,26 @@ class _AskUserStyleContentState extends State<_AskUserStyleContent> { return const SdBody('No presentation themes configured'); } - final optionsRow = FlexBoxStyler() - .spacing(16) - .wrap(WidgetModifierConfig.intrinsicHeight()); - - return optionsRow( - children: options.asMap().entries.map((entry) { - final index = entry.key; - final theme = entry.value; - final isSelected = _selectedStyleIndex == index; - - return Expanded( - child: StyleOptionCard( - theme: theme, - selected: isSelected, - onTap: () { - setState(() { - _selectedStyleIndex = index; - _selectedTheme = theme; - }); - }, - ), - ); - }).toList(), + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + for (final (index, theme) in options.indexed) + Expanded( + child: StyleOptionCard( + theme: theme, + selected: _selectedStyleIndex == index, + onTap: () { + setState(() { + _selectedStyleIndex = index; + _selectedTheme = theme; + }); + }, + ), + ), + ], + ), ); } diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/catalog_question_step.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/catalog_question_step.dart index e20c6250..7976cecf 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/catalog_question_step.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/catalog_question_step.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:remix/remix.dart'; + import '../../ui/ui.dart'; /// Shared layout for catalog question widgets with a header, body, and CTA. diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card.dart index 3b67598e..567e02ee 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card.dart @@ -6,6 +6,7 @@ import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; import 'package:flutter/material.dart'; import 'package:genui/genui.dart'; import 'package:hero_ui/hero_ui.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:provider/provider.dart' as prov; import 'package:remix/remix.dart'; import '../../../../../../core/domain/design/presentation_theme_catalog.dart'; @@ -17,7 +18,6 @@ import '../services/prompt_builder.dart'; import '../../debug_logger.dart'; import '../../ui/ui.dart'; import '../../utils/color_utils.dart'; -import '../../utils/font_utils.dart'; import '../../../../quick_agent/domain/commands/generate_deck_command.dart'; import 'component_schema.dart'; import 'presentation_theme_component_schema.dart'; diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card_view.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card_view.dart index 2e758051..5241a217 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card_view.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card_view.dart @@ -121,11 +121,12 @@ class SummaryCard extends StatelessWidget { final VoidCallback? generateSlides; FlexBoxStyler get _container => .new() - .borderRadiusAll(Radius.circular(SdTokens.cardRadius)) + .borderRadiusAll(const Radius.circular(20)) .mainAxisSize(.min) - .spacing(16) + .spacing(20) .crossAxisAlignment(.start) - .paddingAll(SdTokens.cardPadding) + .paddingAll(24) + .color($surfaceSecondary()) .borderAll(color: $border()) .column(); @@ -133,26 +134,58 @@ class SummaryCard extends StatelessWidget { Widget build(BuildContext context) { return _container( children: [ - SdHeadline(title), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: $accentSoft.resolve(context), + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Icon( + LucideIcons.layers, + size: 21, + color: $accent.resolve(context), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + SdHeadline(title), + const SdCaption( + 'Review the plan before building your slides.', + ), + ], + ), + ), + ], + ), LayoutBuilder( - builder: (context, constraints) => ConstrainedBox( + builder: (context, _) => ConstrainedBox( constraints: BoxConstraints( - maxHeight: (constraints.maxHeight * 0.6).clamp(200.0, 500.0), + maxHeight: (MediaQuery.sizeOf(context).height * 0.43).clamp( + 260.0, + 380.0, + ), ), child: SingleChildScrollView( primary: false, physics: const ClampingScrollPhysics(), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - spacing: 16, - children: items - .map( - (item) => _SummaryCardItem( - item: item, - themeCatalog: themeCatalog, - ), - ) - .toList(), + children: [ + for (final (index, item) in items.indexed) ...[ + if (index > 0) + Divider(height: 1, color: $separator.resolve(context)), + _SummaryCardItem(item: item, themeCatalog: themeCatalog), + ], + ], ), ), ), @@ -160,8 +193,8 @@ class SummaryCard extends StatelessWidget { Align( alignment: Alignment.centerRight, child: SdButton( - label: 'Generate Slides', - icon: Icons.generating_tokens, + label: 'Generate slides', + icon: LucideIcons.sparkles, onPressed: generateSlides, ), ), @@ -176,11 +209,11 @@ class _SummaryCardItem extends StatelessWidget { final SummaryItemType item; final PresentationThemeCatalog themeCatalog; - // Label with fixed width for consistent alignment + // Labels share a visual column on roomy layouts. TextStyler get _label => TextStyler() - .color($foreground()) - .style($paragraphMedium.mix()) - .wrap(WidgetModifierConfig.sizedBox(width: 140)); + .color($muted()) + .style($labelSmall.mix()) + .fontWeight(FontWeight.w600); Widget _buildContent() { final shapeError = item.shapeValidationError; @@ -222,13 +255,26 @@ class _SummaryCardItem extends StatelessWidget { @override Widget build(BuildContext context) { - return SdPanel( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _label(item.label), - Expanded(child: _buildContent()), - ], + return Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 480) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [_label(item.label), _buildContent()], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 120, child: _label(item.label)), + Expanded(child: _buildContent()), + ], + ); + }, ), ); } @@ -276,30 +322,16 @@ class _SummaryCardItemTheme extends StatelessWidget { final colorRow = FlexBoxStyler().paddingY(5).row(); final recipe = theme.recipe; - final headlineFontLoaded = tryGetGoogleFontFamily(recipe.headlineFamily); - final bodyFontLoaded = tryGetGoogleFontFamily(recipe.bodyFamily); return flex( children: [ SdBody(theme.title), - SdCaption(theme.description), colorRow( children: recipe.palette.previewColors .map((color) => SdColorCircle(color: hexToColor(color))) .toList(), ), - SdBody( - recipe.headlineFamily, - style: headlineFontLoaded != null - ? TextStyler().fontFamily(headlineFontLoaded) - : null, - ), - SdCaption( - recipe.bodyFamily, - style: bodyFontLoaded != null - ? TextStyler().fontFamily(bodyFontLoaded) - : null, - ), + SdCaption('${recipe.headlineFamily} + ${recipe.bodyFamily}'), ], ); } diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/wizard_option_icon.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/wizard_option_icon.dart new file mode 100644 index 00000000..3b57cb8b --- /dev/null +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/wizard_option_icon.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +/// Small, model-safe icon vocabulary for Wizard option cards. +/// +/// Keeping this list semantic and constrained makes generated choices stable +/// while still giving adjacent cards distinct visual identities. +enum WizardOptionIcon { + audience, + business, + education, + global, + goal, + data, + idea, + launch, + layers, + presentation, + story, + sparkles; + + IconData get iconData => switch (this) { + audience => LucideIcons.users, + business => LucideIcons.briefcase, + education => LucideIcons.graduationCap, + global => LucideIcons.globe, + goal => LucideIcons.target, + data => LucideIcons.chartBar, + idea => LucideIcons.lightbulb, + launch => LucideIcons.rocket, + layers => LucideIcons.layers, + presentation => LucideIcons.presentation, + story => LucideIcons.bookOpen, + sparkles => LucideIcons.sparkles, + }; + + static WizardOptionIcon fallbackFor(int index) => + values[index % values.length]; +} diff --git a/packages/playground/lib/features/ai/wizard/core/ai/services/ai_conversation_viewmodel.dart b/packages/playground/lib/features/ai/wizard/core/ai/services/ai_conversation_viewmodel.dart index d8546e3c..13b198ac 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/services/ai_conversation_viewmodel.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/services/ai_conversation_viewmodel.dart @@ -14,7 +14,26 @@ import 'genui_conversation_session.dart'; import 'superdeck_a2ui_transport.dart'; import 'superdeck_agent_client.dart'; -final class AiConversationViewModel implements Disposable { +@visibleForTesting +enum MissingSurfaceAction { none, recover, showError } + +@visibleForTesting +MissingSurfaceAction decideMissingSurfaceAction({ + required bool hasError, + required bool hasController, + required bool requestProducedSurface, + required int recoveryAttempts, +}) { + if (hasError || !hasController || requestProducedSurface) { + return MissingSurfaceAction.none; + } + return recoveryAttempts == 0 + ? MissingSurfaceAction.recover + : MissingSurfaceAction.showError; +} + +final class AiConversationViewModel extends ChangeNotifier + implements Disposable { AiConversationViewModel({ required AiConversationProfile profile, @visibleForTesting SuperdeckTransportFactory? transportFactory, @@ -36,8 +55,17 @@ final class AiConversationViewModel implements Disposable { ); } - /// Model is hardcoded for v1 (no in-wizard model picker — decision #5). - static const _modelName = GeminiModelNames.gemini25Flash; + /// The Wizard favors the current Flash Lite model for short, structured + /// surface turns. There is intentionally no in-wizard model picker. + static const _modelName = GeminiModelNames.gemini31FlashLite; + static const _missingSurfaceRecoveryPrompt = ''' +The previous response did not include a renderable Wizard surface. Continue +from the current Wizard state and return exactly one valid surface for the next +unanswered step. Use the registered catalog ID, include a submit control, and +do not answer with text only. +'''; + static const _missingSurfaceError = + 'I couldn\'t prepare the next step. Add a detail below to try again.'; final surfaceIds = Signal>([]); final _controller = Signal(null); @@ -50,10 +78,15 @@ final class AiConversationViewModel implements Disposable { DateTime? _lastRequestTime; String _streamingAiResponse = ''; int? _streamingAiMessageIndex; + String? _errorMessage; + var _surfaceRecoveryAttempts = 0; + var _requestProducedSurface = false; var _disposed = false; genui.SurfaceController? get controller => _controller.value; + String? get errorMessage => _errorMessage; + late final Computed isThinking = computed(() { return _isProcessing.value; }); @@ -78,12 +111,15 @@ final class AiConversationViewModel implements Disposable { final result = await _session.ensureStarted(modelName: _modelName); if (result.started) { _controller.value = _session.controller; + _notifyView(); return true; } final message = result.message; if (message != null && !_disposed) { _messages.add(SuperdeckAiMessage(message)); + _errorMessage = message; + _notifyView(); } return false; } @@ -103,6 +139,7 @@ final class AiConversationViewModel implements Disposable { if (!_session.hasActiveSession) return; + _surfaceRecoveryAttempts = 0; _messages.add(SuperdeckUserMessage(message)); await _enqueueRequest(genui.ChatMessage.user(message)); } @@ -114,8 +151,12 @@ final class AiConversationViewModel implements Disposable { _isProcessing.value = false; _streamingAiResponse = ''; _streamingAiMessageIndex = null; + _errorMessage = null; + _surfaceRecoveryAttempts = 0; + _requestProducedSurface = false; _messages.value = []; surfaceIds.value = []; + _notifyView(); } @override @@ -133,6 +174,7 @@ final class AiConversationViewModel implements Disposable { isThinking.dispose(); messages.dispose(); hasConversationStarted.dispose(); + super.dispose(); } Future _enqueueRequest(genui.ChatMessage message) { @@ -143,7 +185,10 @@ final class AiConversationViewModel implements Disposable { _lastRequestTime = DateTime.now(); _streamingAiResponse = ''; _streamingAiMessageIndex = null; + _errorMessage = null; + _requestProducedSurface = false; _isProcessing.value = true; + _notifyView(); debugLog.log( 'TIMING', 'Request started at ${_lastRequestTime!.toIso8601String()}', @@ -152,10 +197,38 @@ final class AiConversationViewModel implements Disposable { void _handleRequestFinished() { _logElapsed('REQUEST_FINISHED'); + + switch (decideMissingSurfaceAction( + hasError: _errorMessage != null, + hasController: _controller.value != null, + requestProducedSurface: _requestProducedSurface, + recoveryAttempts: _surfaceRecoveryAttempts, + )) { + case MissingSurfaceAction.recover: + _surfaceRecoveryAttempts++; + debugLog.log( + 'CONV', + 'Response had no Wizard surface; requesting one recovery turn', + ); + unawaited( + _enqueueRequest( + genui.ChatMessage.user(_missingSurfaceRecoveryPrompt), + ), + ); + return; + case MissingSurfaceAction.showError: + _errorMessage = _missingSurfaceError; + break; + case MissingSurfaceAction.none: + break; + } + _isProcessing.value = false; + _notifyView(); } void _handleUiSubmit(genui.ChatMessage message) { + _surfaceRecoveryAttempts = 0; final interactionParts = message.parts.uiInteractionParts.toList(); if (interactionParts.isEmpty) { debugLog.log('USER', 'Received submit message without interaction part'); @@ -175,13 +248,17 @@ final class AiConversationViewModel implements Disposable { debugLog.userAction('UI_ACTION', parsed?.context ?? {'raw': rawJson}); } + _notifyView(); unawaited(_enqueueRequest(message)); } void _handleTextResponse(String value) { _logElapsed('TEXT_RESPONSE received'); debugLog.aiResponse('TEXT', value); - _streamingAiResponse += value; + _streamingAiResponse = mergeFinalOutputSegments( + _streamingAiResponse, + value, + ); final next = [..._messages.value]; final index = _streamingAiMessageIndex; @@ -192,6 +269,7 @@ final class AiConversationViewModel implements Disposable { next.add(SuperdeckAiMessage(_streamingAiResponse)); } _messages.value = next; + _notifyView(); } void _handleSurfaceUpdate(genui.SurfaceUpdate value) { @@ -208,20 +286,28 @@ final class AiConversationViewModel implements Disposable { void _handleSurfaceAdded(String surfaceId) { _logElapsed('SURFACE_ADDED: $surfaceId'); debugLog.surface('ADDED', surfaceId); + _requestProducedSurface = true; if (!surfaceIds.value.contains(surfaceId)) { + _errorMessage = null; + _surfaceRecoveryAttempts = 0; surfaceIds.value = [...surfaceIds.value, surfaceId]; _addDebugMessage('Surface added: $surfaceId'); + _notifyView(); } } void _handleSurfaceUpdated(String surfaceId) { _logElapsed('SURFACE_UPDATED: $surfaceId'); debugLog.surface('UPDATED', surfaceId); + _requestProducedSurface = true; + _errorMessage = null; + _surfaceRecoveryAttempts = 0; if (!surfaceIds.value.contains(surfaceId)) { surfaceIds.value = [...surfaceIds.value, surfaceId]; _addDebugMessage('Surface added via update: $surfaceId'); } _addDebugMessage('Surface updated: $surfaceId'); + _notifyView(); } void _handleSurfaceDeleted(String surfaceId) { @@ -229,14 +315,21 @@ final class AiConversationViewModel implements Disposable { debugLog.surface('DELETED', surfaceId); surfaceIds.value = surfaceIds.value.where((id) => id != surfaceId).toList(); _addDebugMessage('Surface deleted: $surfaceId'); + _notifyView(); } void _handleTransportError(Object error, StackTrace stackTrace) { _logElapsed('ERROR received'); debugLog.error('GenUI', _sanitizeError(error), stackTrace); - _messages.add(SuperdeckAiMessage(_getErrorMessage(error))); + _errorMessage = _getErrorMessage(error); + _messages.add(SuperdeckAiMessage(_errorMessage!)); _isProcessing.value = false; _streamingAiMessageIndex = null; + _notifyView(); + } + + void _notifyView() { + if (!_disposed) notifyListeners(); } void _logElapsed(String event) { @@ -263,3 +356,20 @@ final class AiConversationViewModel implements Disposable { return str.replaceAll(RegExp(r'[A-Za-z0-9_-]{20,}'), '[REDACTED]'); } } + +/// Joins complete final-output segments without collapsing their word boundary. +/// +/// The A2UI adapter can emit one final sentence as multiple decoded text +/// segments with leading whitespace removed. Punctuation still joins directly. +@visibleForTesting +String mergeFinalOutputSegments(String existing, String next) { + if (existing.isEmpty || next.isEmpty) return '$existing$next'; + if (RegExp(r'\s$').hasMatch(existing) || RegExp(r'^\s').hasMatch(next)) { + return '$existing$next'; + } + const punctuation = ''',.;:!?)]}'"”’'''; + if (punctuation.contains(next[0])) { + return '$existing$next'; + } + return '$existing $next'; +} diff --git a/packages/playground/lib/features/ai/wizard/core/ai/services/genui_conversation_session.dart b/packages/playground/lib/features/ai/wizard/core/ai/services/genui_conversation_session.dart index cc9fed7e..9a6b2c95 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/services/genui_conversation_session.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/services/genui_conversation_session.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:genui/genui.dart' as genui; import '../../../../../../core/domain/design/presentation_theme_catalog.dart'; @@ -130,23 +131,22 @@ final class GenUiConversationSession { ), ]; - if (_profile.tools.isEmpty) { - return genui.PromptBuilder.chat( - catalog: _profile.catalog, - systemPromptFragments: fragments, - ).systemPromptJoined(); - } - return genui.PromptBuilder.custom( catalog: _profile.catalog, - allowedOperations: genui.SurfaceOperations.createOnly(dataModel: false), + allowedOperations: genui.SurfaceOperations.createAndUpdate( + dataModel: false, + ), systemPromptFragments: fragments, - technicalPossibilities: const genui.TechnicalPossibilities( - toolCall: true, + technicalPossibilities: genui.TechnicalPossibilities( + toolCall: _profile.tools.isNotEmpty, ), ).systemPromptJoined(); } + @visibleForTesting + String buildSystemPromptForTesting(String systemInstruction) => + _buildSystemPrompt(systemInstruction); + genui.SurfaceController? get controller => _controller; bool get hasActiveSession { diff --git a/packages/playground/lib/features/ai/wizard/core/ui/components/sd_tokens.dart b/packages/playground/lib/features/ai/wizard/core/ui/components/sd_tokens.dart index 9e758c89..f0646c3c 100644 --- a/packages/playground/lib/features/ai/wizard/core/ui/components/sd_tokens.dart +++ b/packages/playground/lib/features/ai/wizard/core/ui/components/sd_tokens.dart @@ -1,7 +1,7 @@ /// Shared design tokens for Sd components. abstract final class SdTokens { - static const double cardRadius = 8; - static const double cardInnerRadius = 7; + static const double cardRadius = 14; + static const double cardInnerRadius = 12; static const double cardPadding = 16; static const double cardMinHeight = 140; diff --git a/packages/playground/lib/features/ai/wizard/core/ui/widgets/catalog_next_button.dart b/packages/playground/lib/features/ai/wizard/core/ui/widgets/catalog_next_button.dart index 8072b423..b5d6df0a 100644 --- a/packages/playground/lib/features/ai/wizard/core/ui/widgets/catalog_next_button.dart +++ b/packages/playground/lib/features/ai/wizard/core/ui/widgets/catalog_next_button.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + import '../ui.dart'; class CatalogNextButton extends StatelessWidget { @@ -12,8 +14,9 @@ class CatalogNextButton extends StatelessWidget { alignment: Alignment.centerRight, child: Padding( padding: const EdgeInsets.only(top: 16), - child: SdIconButton( - icon: Icons.arrow_forward_rounded, + child: SdButton( + label: 'Continue', + icon: LucideIcons.arrowRight, onPressed: onPressed, semanticLabel: 'Next step', ), diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart new file mode 100644 index 00000000..1f6c35f4 --- /dev/null +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart'; +import 'package:hero_ui/hero_ui.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:remix/remix.dart'; + +import '../../quick_agent/core/engine/services/generation_progress.dart'; +import '../core/ui/ui.dart'; + +enum WizardGenerationStatusKind { running, completed, failed } + +/// Focused, full-screen status content for the terminal generation step. +class WizardGenerationStatus extends StatelessWidget { + const WizardGenerationStatus({ + super.key, + required this.kind, + this.progress = const GenerationProgress(GenerationPhase.idle), + this.errorMessage, + this.noticeMessage, + this.onDismiss, + }); + + final WizardGenerationStatusKind kind; + final GenerationProgress progress; + final String? errorMessage; + final String? noticeMessage; + final VoidCallback? onDismiss; + + @override + Widget build(BuildContext context) { + final highlightedMessage = kind == WizardGenerationStatusKind.failed + ? errorMessage + : noticeMessage; + final highlightedColor = kind == WizardGenerationStatusKind.failed + ? $danger.resolve(context) + : $warning.resolve(context); + final (:icon, :title, :description) = switch (kind) { + WizardGenerationStatusKind.running => ( + icon: LucideIcons.sparkles, + title: 'Building your presentation', + description: + 'Shaping the story, composing each slide, and checking the final deck.', + ), + WizardGenerationStatusKind.completed => ( + icon: LucideIcons.circleCheck, + title: 'Your presentation is ready', + description: 'The generated slides are ready in this session.', + ), + WizardGenerationStatusKind.failed => ( + icon: LucideIcons.triangleAlert, + title: 'We couldn\'t finish the deck', + description: + 'Your plan is still here, so you can return and try again.', + ), + }; + + return Semantics( + liveRegion: true, + label: kind == WizardGenerationStatusKind.running + ? progress.label + : title, + child: Container( + key: ValueKey(kind), + padding: const EdgeInsets.all(28), + decoration: BoxDecoration( + color: $surfaceSecondary.resolve(context), + border: Border.all(color: $border.resolve(context)), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 24, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + _StatusIcon(icon: icon, running: kind == .running), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 6, + children: [SdHeadline(title), SdBody(description)], + ), + ), + ], + ), + if (kind == WizardGenerationStatusKind.running) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + minHeight: 5, + color: $accent.resolve(context), + backgroundColor: $border.resolve(context), + semanticsLabel: progress.label, + ), + ), + _GenerationStages(progress: progress), + ], + if (highlightedMessage != null) + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: highlightedColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + ), + child: SdCaption( + highlightedMessage, + style: TextStyler().color(highlightedColor), + ), + ), + if (kind != WizardGenerationStatusKind.running) + Align( + alignment: Alignment.centerRight, + child: SdButton( + label: 'Back to deck plan', + icon: LucideIcons.arrowLeft, + onPressed: onDismiss, + ), + ), + ], + ), + ), + ); + } +} + +class _StatusIcon extends StatelessWidget { + const _StatusIcon({required this.icon, required this.running}); + + final IconData icon; + final bool running; + + @override + Widget build(BuildContext context) { + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: $accentSoft.resolve(context), + borderRadius: BorderRadius.circular(14), + ), + alignment: Alignment.center, + child: running + ? const SdSpinner(size: SdSpinnerSize.size3) + : Icon(icon, size: 23, color: $accent.resolve(context)), + ); + } +} + +class _GenerationStages extends StatelessWidget { + const _GenerationStages({required this.progress}); + + final GenerationProgress progress; + + int get _activeIndex => switch (progress.phase) { + GenerationPhase.generatingOutline || GenerationPhase.idle => 0, + GenerationPhase.composingSlides => 1, + GenerationPhase.finalizing || GenerationPhase.generatingThumbnails => 2, + }; + + @override + Widget build(BuildContext context) { + const stages = ['Shape the story', 'Compose the slides', 'Polish the deck']; + final activeIndex = _activeIndex; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + SdTitle( + progress.label, + style: TextStyler().fontWeight(FontWeight.w600), + ), + for (final (index, label) in stages.indexed) + _GenerationStage( + label: label, + completed: index < activeIndex, + active: index == activeIndex, + ), + ], + ); + } +} + +class _GenerationStage extends StatelessWidget { + const _GenerationStage({ + required this.label, + required this.completed, + required this.active, + }); + + final String label; + final bool completed; + final bool active; + + @override + Widget build(BuildContext context) { + final color = completed || active + ? $accent.resolve(context) + : $muted.resolve(context).withValues(alpha: 0.55); + + return Row( + spacing: 10, + children: [ + Icon( + completed + ? LucideIcons.circleCheck + : active + ? LucideIcons.circleDot + : LucideIcons.circle, + size: 17, + color: color, + ), + SdCaption( + label, + style: TextStyler() + .color(color) + .fontWeight(active ? FontWeight.w600 : FontWeight.w400), + ), + ], + ); + } +} diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart index e8504e37..2bb55e9d 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart @@ -5,9 +5,11 @@ import 'package:hero_ui/hero_ui.dart'; import 'package:provider/provider.dart'; import '../../../../core/domain/stores/deck_customization_store.dart'; +import '../../../../core/result.dart'; import '../../../editor/domain/stores/deck_document_store.dart'; import '../../quick_agent/core/env_config.dart'; import '../../quick_agent/domain/commands/generate_deck_command.dart'; +import 'wizard_generation_status.dart'; import 'wizard_view.dart'; /// Isolated host for exercising the conversational Wizard without the editor. @@ -40,32 +42,91 @@ class WizardPage extends StatelessWidget { dispose: (_, command) => command.dispose(), ), ], - child: Scaffold( - backgroundColor: $background.resolve(context), - body: SafeArea( - child: Stack( - children: [ - Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 720), - child: const Padding( - padding: EdgeInsets.all(32), - child: WizardView(), + child: const _WizardExperience(), + ); + } +} + +class _WizardExperience extends StatelessWidget { + const _WizardExperience(); + + @override + Widget build(BuildContext context) { + final command = context.watch(); + final result = command.result; + + final Widget? status; + if (command.running) { + status = WizardGenerationStatus( + kind: WizardGenerationStatusKind.running, + progress: command.progress, + ); + } else if (result case Failure(:final error)) { + status = WizardGenerationStatus( + kind: WizardGenerationStatusKind.failed, + errorMessage: error.toString(), + onDismiss: command.clearResult, + ); + } else if (command.completed) { + status = WizardGenerationStatus( + kind: WizardGenerationStatusKind.completed, + noticeMessage: command.completionNotice, + onDismiss: command.clearResult, + ); + } else { + status = null; + } + + return Scaffold( + backgroundColor: $background.resolve(context), + body: SafeArea( + child: Stack( + children: [ + LayoutBuilder( + builder: (context, constraints) => Center( + child: SizedBox( + width: constraints.constrainWidth(1080), + height: constraints.maxHeight, + child: Padding( + padding: const EdgeInsets.all(32), + child: Stack( + fit: StackFit.expand, + children: [ + Offstage( + offstage: status != null, + child: const WizardView(), + ), + if (status != null) + Positioned.fill( + child: ColoredBox( + color: $background.resolve(context), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 620, + ), + child: status, + ), + ), + ), + ), + ], + ), ), ), ), - if (kDebugMode) - Positioned( - top: 8, - right: 8, - child: IconButton( - tooltip: 'Generation lab', - onPressed: () => context.push('/debug/generation'), - icon: const Icon(Icons.science_outlined), - ), + ), + if (kDebugMode) + Positioned( + top: 8, + right: 8, + child: IconButton( + tooltip: 'Generation lab', + onPressed: () => context.push('/debug/generation'), + icon: const Icon(Icons.science_outlined), ), - ], - ), + ), + ], ), ), ); diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_view.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_view.dart index a3a2b24e..ca99e562 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_view.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_view.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:signals/signals_flutter.dart'; import '../chat/chat_conversation_profile.dart'; import '../chat/view/widgets/chat_input.dart'; @@ -65,47 +64,42 @@ class _WizardBodyState extends State<_WizardBody> { Widget build(BuildContext context) { final viewModel = context.read(); - return SignalBuilder( - builder: (context) { - final started = viewModel.hasConversationStarted.value; - final messages = viewModel.messages.value; + return ListenableBuilder( + listenable: viewModel, + builder: (context, _) { + final surfaceController = viewModel.controller; + final started = surfaceController != null; final isThinking = viewModel.isThinking.value; final input = ChatInput( controller: _controller, focusNode: _focusNode, enabled: !isThinking, + hintText: !started + ? 'Describe your presentation topic…' + : 'Add a detail or ask for a change…', onSubmitted: _submit, ); // Before the first message: show the empty state with the topic prompt. // It centers when it fits and scrolls when the sidebar is too short. - if (!started && messages.isEmpty) { - return Column( - children: [ - Expanded( - child: LayoutBuilder( - builder: (context, constraints) => SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: constraints.maxHeight, - ), - child: EmptyState(onSuggestionTap: _submit), - ), - ), - ), + if (!started) { + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: EmptyState(input: input), ), - input, - ], + ), ); } // Conversation in progress: render the current surface + input inline. return AiSurfacesPanel( - controller: viewModel.controller, - surfaceIds: viewModel.surfaceIds, - isThinking: viewModel.isThinking, - messages: viewModel.messages, + controller: surfaceController, + surfaceIds: viewModel.surfaceIds.value, + isThinking: isThinking, + errorMessage: viewModel.errorMessage, inputWidget: input, ); }, diff --git a/packages/playground/macos/Runner.xcodeproj/project.pbxproj b/packages/playground/macos/Runner.xcodeproj/project.pbxproj index 965e768c..457743e6 100644 --- a/packages/playground/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/playground/macos/Runner.xcodeproj/project.pbxproj @@ -70,7 +70,7 @@ 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* playground.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = playground.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* Superdeck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Superdeck.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -149,7 +149,7 @@ 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* playground.app */, + 33CC10ED2044A3C60003C045 /* Superdeck.app */, 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, ); name = Products; @@ -259,7 +259,7 @@ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, ); productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* playground.app */; + productReference = 33CC10ED2044A3C60003C045 /* Superdeck.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -496,7 +496,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.example.playground.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/playground.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/playground"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Superdeck.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Superdeck"; }; name = Debug; }; @@ -511,7 +511,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.example.playground.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/playground.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/playground"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Superdeck.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Superdeck"; }; name = Release; }; @@ -526,7 +526,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.example.playground.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/playground.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/playground"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Superdeck.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Superdeck"; }; name = Profile; }; diff --git a/packages/playground/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/playground/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 2c2b2ecd..b56ed065 100644 --- a/packages/playground/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/playground/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -33,7 +33,7 @@ @@ -49,7 +49,7 @@ @@ -84,7 +84,7 @@ @@ -101,7 +101,7 @@ diff --git a/packages/playground/macos/Runner/Configs/AppInfo.xcconfig b/packages/playground/macos/Runner/Configs/AppInfo.xcconfig index 0cf60e19..ce3735a6 100644 --- a/packages/playground/macos/Runner/Configs/AppInfo.xcconfig +++ b/packages/playground/macos/Runner/Configs/AppInfo.xcconfig @@ -5,7 +5,7 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = playground +PRODUCT_NAME = Superdeck // The application's bundle identifier PRODUCT_BUNDLE_IDENTIFIER = com.example.playground diff --git a/packages/playground/macos/Runner/Info.plist b/packages/playground/macos/Runner/Info.plist index 4789daa6..29acb1c4 100644 --- a/packages/playground/macos/Runner/Info.plist +++ b/packages/playground/macos/Runner/Info.plist @@ -10,6 +10,8 @@ CFBundleIdentifier $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleDisplayName + $(PRODUCT_NAME) CFBundleInfoDictionaryVersion 6.0 CFBundleName diff --git a/packages/playground/macos/Runner/MainFlutterWindow.swift b/packages/playground/macos/Runner/MainFlutterWindow.swift index f330b959..d38d0e42 100644 --- a/packages/playground/macos/Runner/MainFlutterWindow.swift +++ b/packages/playground/macos/Runner/MainFlutterWindow.swift @@ -13,6 +13,15 @@ class MainFlutterWindow: NSWindow { self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) + // Keep the standard macOS window controls while letting Flutter paint + // beneath the title bar. This removes the redundant native title and the + // opaque frame without sacrificing resizing or familiar window behavior. + self.title = "" + self.titleVisibility = .hidden + self.titlebarAppearsTransparent = true + self.styleMask.insert(.fullSizeContentView) + self.isMovableByWindowBackground = true + // Both editor sidebars are visible by default. The template's 800-point // window leaves the central editor with almost no width, so start at a // useful desktop size and prevent the window from shrinking back into that diff --git a/packages/playground/pubspec.yaml b/packages/playground/pubspec.yaml index 07f4295a..f71cd69e 100644 --- a/packages/playground/pubspec.yaml +++ b/packages/playground/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: remix: ^0.2.0 super_editor: 0.3.0-dev.52 google_fonts: ^8.1.0 + lucide_icons_flutter: ^3.1.15 hero_ui: git: url: https://github.com/tilucasoli/hero_ui diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider_test.dart index 2264fa3c..1820cc09 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider_test.dart @@ -170,6 +170,13 @@ void main() { expect(prompt, contains('- Slide key must be exactly "evidence".')); expect(prompt, contains('no more than 70 visible words')); expect(prompt, contains('a list of at most three short bullets')); + expect( + prompt, + contains( + 'Treat the composition as visual guidance, not factual authority.', + ), + ); + expect(prompt, contains('Never invent a quotation, metric, or table data')); expect(prompt, endsWith('generate future slides.\n')); }); diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/services/generated_slide_validator_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/services/generated_slide_validator_test.dart index 02dc1016..f9f53213 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/services/generated_slide_validator_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/services/generated_slide_validator_test.dart @@ -306,6 +306,38 @@ void main() { expect(densityIssue.isBlocking, isFalse); }); + test('keeps a missing quote treatment as non-blocking guidance', () { + final issues = validateGeneratedSlideIssues( + expectedKey: 'test-slide', + rawSlide: { + 'key': 'test-slide', + 'options': {'title': 'Protecting homes', 'style': 'content'}, + 'sections': [ + { + 'type': 'section', + 'blocks': [ + { + 'type': 'block', + 'content': + '## Protecting homes\n\n' + 'Resilience starts before the next storm arrives.', + }, + ], + }, + ], + }, + planSlide: _planSlide(composition: 'quote'), + elementCatalog: GenerationElementCatalog.builtIn(), + ); + final quoteIssue = issues.singleWhere( + (issue) => issue.message.contains('requires a Markdown blockquote'), + ); + + expect(quoteIssue.category, GenerationValidationCategory.quality); + expect(quoteIssue.severity, GenerationValidationSeverity.diagnostic); + expect(issues.blockingIssues, isEmpty); + }); + test('rejects a display heading that is too long for a title row', () { final issues = validateGeneratedSlideIssues( expectedKey: 'test-slide', diff --git a/packages/playground/test/features/ai/quick_agent/domain/commands/generate_deck_command_test.dart b/packages/playground/test/features/ai/quick_agent/domain/commands/generate_deck_command_test.dart new file mode 100644 index 00000000..01e5cbb5 --- /dev/null +++ b/packages/playground/test/features/ai/quick_agent/domain/commands/generate_deck_command_test.dart @@ -0,0 +1,135 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:playground/core/data/data_sources/memory_deck_loader.dart'; +import 'package:playground/core/domain/design/presentation_theme_catalog.dart'; +import 'package:playground/core/domain/design/presentation_typography_catalog.dart'; +import 'package:playground/core/domain/stores/deck_customization_store.dart'; +import 'package:playground/core/result.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/schemas/outline_schema.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/deck_generation_request.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/deck_generator_service.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/generation_progress.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/generation_trace.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/generation_validation_issue.dart'; +import 'package:playground/features/ai/quick_agent/domain/commands/generate_deck_command.dart'; +import 'package:playground/features/editor/domain/stores/deck_document_store.dart'; +import 'package:superdeck/superdeck.dart'; +import 'package:superdeck_core/superdeck_core.dart' + show SectionBlock, Slide, SlideOptions; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => GoogleFonts.config.allowRuntimeFetching = false); + + test( + 'loads accepted slides when generation returns a partial deck', + () async { + final plan = _plan(); + final acceptedSlide = Slide( + key: 'accepted', + options: SlideOptions(title: 'Accepted slide', style: 'content'), + sections: [ + SectionBlock.text('## Accepted slide\n\nUseful grounded content.'), + ], + ); + final partial = DeckGenerationResult.partial( + slides: [acceptedSlide], + slideFailures: const [ + SlideGenerationFailure( + slideIndex: 2, + slideKey: 'missing', + issues: [ + GenerationValidationIssue( + code: GenerationValidationCode.invalidSchema, + category: GenerationValidationCategory.schema, + severity: GenerationValidationSeverity.blocking, + location: GenerationValidationLocation.visibleContent, + message: 'Slide response was not valid JSON.', + ), + ], + ), + ], + plan: plan, + theme: _resolvedTheme(), + ); + final documentStore = DeckDocumentStore(markdown: ''); + final controller = DeckController( + deckLoader: MemoryDeckLoader(), + options: DeckOptions(), + ); + final customizationStore = DeckCustomizationStore(controller); + final command = GenerateDeckCommand( + documentStore: documentStore, + customizationStore: customizationStore, + service: _StubDeckGeneratorService(partial), + ); + addTearDown(command.dispose); + addTearDown(customizationStore.dispose); + addTearDown(controller.dispose); + addTearDown(documentStore.dispose); + + final result = await command.action( + const DeckGenerationRequest(userIntent: 'Test deck', slideCount: 2), + ); + + expect(result, isA>()); + expect(documentStore.markdown, contains('Accepted slide')); + expect(command.completionNotice, contains('Generated 1 of 2 slides')); + }, + ); +} + +final class _StubDeckGeneratorService extends DeckGeneratorService { + _StubDeckGeneratorService(this.result) : super(apiKey: 'test-key'); + + final DeckGenerationResult result; + + @override + Future generate( + DeckGenerationRequest request, { + GenerationProgressCallback? onProgress, + GenerationTraceCallback? onTrace, + bool Function()? isCancelled, + }) async => result; +} + +DeckPlanType _plan() => DeckPlanType.parse({ + 'topic': 'Partial deck', + 'story': 'Keep accepted work when one slide fails.', + 'theme': {'id': 'technical-paper', 'version': 1, 'density': 'balanced'}, + 'sections': [ + { + 'key': 'main', + 'title': 'Main', + 'purpose': 'Explain the partial result.', + 'transition': 'Finish the story.', + 'slideKeys': ['accepted', 'missing'], + }, + ], + 'slides': [_planSlide('accepted'), _planSlide('missing')], +}); + +Map _planSlide(String key) => { + 'key': key, + 'title': key, + 'purpose': 'Advance $key.', + 'sectionKey': 'main', + 'assertion': '$key matters.', + 'contentUnits': ['$key evidence'], + 'narrativeRole': 'evidence', + 'contentBrief': 'Use concrete content.', + 'continuity': 'Connect the story.', + 'composition': 'content', + 'treatment': 'content', + 'density': 'balanced', +}; + +ResolvedPresentationTheme _resolvedTheme() { + final themes = PresentationThemeCatalog.withDefaults(); + return themes.resolve( + id: 'technical-paper', + version: 1, + density: 'balanced', + typographyCatalog: PresentationTypographyCatalog.withDefaults(), + ); +} diff --git a/packages/playground/test/features/ai/wizard/core/ai/catalog/wizard_option_icon_test.dart b/packages/playground/test/features/ai/wizard/core/ai/catalog/wizard_option_icon_test.dart new file mode 100644 index 00000000..a4089910 --- /dev/null +++ b/packages/playground/test/features/ai/wizard/core/ai/catalog/wizard_option_icon_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:playground/features/ai/wizard/core/ai/catalog/ask_user_radio.dart'; +import 'package:playground/features/ai/wizard/core/ai/catalog/wizard_option_icon.dart'; + +void main() { + test('radio option icons are constrained to the Wizard vocabulary', () { + final option = InputOptionType.parse({ + 'title': 'Business leaders', + 'icon': 'business', + }); + + expect(option.icon, WizardOptionIcon.business); + expect(WizardOptionIcon.values, hasLength(12)); + expect( + () => InputOptionType.parse({ + 'title': 'Unknown option', + 'icon': 'material-home', + }), + throwsA(anything), + ); + }); +} diff --git a/packages/playground/test/features/ai/wizard/core/ai/services/ai_conversation_viewmodel_test.dart b/packages/playground/test/features/ai/wizard/core/ai/services/ai_conversation_viewmodel_test.dart new file mode 100644 index 00000000..b307ed23 --- /dev/null +++ b/packages/playground/test/features/ai/wizard/core/ai/services/ai_conversation_viewmodel_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:playground/features/ai/wizard/chat/chat_conversation_profile.dart'; +import 'package:playground/features/ai/wizard/core/ai/services/ai_conversation_viewmodel.dart'; +import 'package:playground/features/ai/wizard/core/ai/services/genui_conversation_session.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('mergeFinalOutputSegments', () { + test('restores a stripped word boundary between decoded segments', () { + expect( + mergeFinalOutputSegments('Now, how', 'should we approach the pitch?'), + 'Now, how should we approach the pitch?', + ); + }); + + test('does not add a space before punctuation', () { + expect(mergeFinalOutputSegments('Great choice', '!'), 'Great choice!'); + }); + + test('preserves whitespace already supplied by the adapter', () { + expect(mergeFinalOutputSegments('Great', ' choice'), 'Great choice'); + }); + }); + + group('missing Wizard surfaces', () { + test('requests recovery when the first response has no surface', () { + expect( + decideMissingSurfaceAction( + hasError: false, + hasController: true, + requestProducedSurface: false, + recoveryAttempts: 0, + ), + MissingSurfaceAction.recover, + ); + }); + + test('requests recovery when a later response has no new surface', () { + expect( + decideMissingSurfaceAction( + hasError: false, + hasController: true, + requestProducedSurface: false, + recoveryAttempts: 0, + ), + MissingSurfaceAction.recover, + ); + }); + + test('shows an error after one surface recovery attempt', () { + expect( + decideMissingSurfaceAction( + hasError: false, + hasController: true, + requestProducedSurface: false, + recoveryAttempts: 1, + ), + MissingSurfaceAction.showError, + ); + }); + }); + + test('Wizard system prompt permits its single-surface replacement flow', () { + final session = GenUiConversationSession( + profile: chatConversationProfile(), + handlers: ConversationSessionHandlers( + onRequestStarted: () {}, + onRequestFinished: () {}, + onUiSubmit: (_) {}, + onSurfaceUpdate: (_) {}, + onTextResponse: (_) {}, + onError: (_, _) {}, + ), + ); + addTearDown(session.dispose); + + final prompt = session.buildSystemPromptForTesting( + 'Replace the `wizard` surface by deleting it, then creating it again.', + ); + + expect(prompt, contains('deleteSurface')); + expect(prompt, contains('To update an existing UI')); + expect( + prompt, + isNot( + contains('DO NOT update or modify surfaces created in previous turns'), + ), + ); + }); + + test('Wizard instructions update one persistent surface', () async { + final prompt = await rootBundle.loadString( + 'assets/ai_prompts/wizard_system.prompt', + ); + + expect(prompt, contains('updateComponents')); + expect(prompt, isNot(contains('deleteSurface'))); + expect(prompt, isNot(contains('beginRendering'))); + }); +} diff --git a/packages/playground/test/features/ai/wizard/presentation/wizard_page_test.dart b/packages/playground/test/features/ai/wizard/presentation/wizard_page_test.dart index 1a94f920..16c39071 100644 --- a/packages/playground/test/features/ai/wizard/presentation/wizard_page_test.dart +++ b/packages/playground/test/features/ai/wizard/presentation/wizard_page_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:go_router/go_router.dart'; import 'package:hero_ui/hero_ui.dart'; +import 'package:playground/app/providers.dart'; import 'package:playground/app/router.dart'; import 'package:playground/features/ai/wizard/presentation/wizard_page.dart'; import 'package:playground/features/ai/wizard/presentation/wizard_view.dart'; @@ -18,8 +19,10 @@ void main() { await tester.pumpWidget( MaterialApp.router( routerConfig: createRouter(), - builder: (context, child) => - HeroTheme(data: HeroThemeData.light(), child: child!), + builder: (context, child) => HeroTheme( + data: HeroThemeData.light(), + child: AppProviders(child: child!), + ), ), ); await tester.pump(); @@ -36,8 +39,10 @@ void main() { testWidgets('configured page opens the isolated Wizard', (tester) async { await tester.pumpWidget( MaterialApp( - builder: (context, child) => - HeroTheme(data: HeroThemeData.light(), child: child!), + builder: (context, child) => HeroTheme( + data: HeroThemeData.light(), + child: AppProviders(child: child!), + ), home: const WizardPage(isConfigured: true), ), ); @@ -45,7 +50,8 @@ void main() { expect(find.byType(WizardView), findsOneWidget); expect(find.byType(EditorPage), findsNothing); - expect(find.text('Startup pitch deck'), findsOneWidget); + expect(find.text('What is the presentation about?'), findsOneWidget); + expect(find.text('Describe your presentation topic…'), findsOneWidget); expect(find.byTooltip('Generation lab'), findsOneWidget); }); @@ -67,8 +73,10 @@ void main() { await tester.pumpWidget( MaterialApp.router( routerConfig: router, - builder: (context, child) => - HeroTheme(data: HeroThemeData.light(), child: child!), + builder: (context, child) => HeroTheme( + data: HeroThemeData.light(), + child: AppProviders(child: child!), + ), ), ); await tester.pump(); diff --git a/packages/playground/test/features/ai/wizard/wizard_polish_test.dart b/packages/playground/test/features/ai/wizard/wizard_polish_test.dart new file mode 100644 index 00000000..add7fcbf --- /dev/null +++ b/packages/playground/test/features/ai/wizard/wizard_polish_test.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:hero_ui/hero_ui.dart'; +import 'package:playground/core/domain/design/presentation_theme_catalog.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/generation_progress.dart'; +import 'package:playground/features/ai/wizard/chat/view/widgets/chat_genui_panels.dart'; +import 'package:playground/features/ai/wizard/core/ai/catalog/ask_user_question_cards.dart'; +import 'package:playground/features/ai/wizard/core/ai/catalog/ask_user_slider.dart'; +import 'package:playground/features/ai/wizard/core/ai/catalog/catalog_question_step.dart'; +import 'package:playground/features/ai/wizard/core/ai/catalog/summary_card.dart'; +import 'package:playground/features/ai/wizard/core/utils/color_utils.dart'; +import 'package:playground/features/ai/wizard/presentation/wizard_generation_status.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => GoogleFonts.config.allowRuntimeFetching = false); + + Widget host(Widget child) { + return MaterialApp( + home: HeroTheme( + data: HeroThemeData.light(), + child: Scaffold( + body: Center(child: SizedBox(width: 720, height: 720, child: child)), + ), + ), + ); + } + + testWidgets('radio options have an icon before they are selected', ( + tester, + ) async { + await tester.pumpWidget( + host( + const Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 220, + child: RadioOptionCard( + title: 'Executive team', + description: 'Decisive and concise', + selected: false, + ), + ), + ), + ), + ); + + expect(find.byType(Icon), findsOneWidget); + }); + + testWidgets('theme options combine palette and typography in one preview', ( + tester, + ) async { + final theme = PresentationThemeCatalog.withDefaults().current( + 'editorial-midnight', + )!; + + await tester.pumpWidget( + host( + Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 300, + child: StyleOptionCard(theme: theme, selected: false), + ), + ), + ), + ); + + final heading = tester.widget(find.text('Build what’s next')); + final subtitle = tester.widget( + find.text('A clear story, beautifully presented.'), + ); + + expect(heading.style?.color, hexToColor(theme.recipe.palette.heading)); + expect(subtitle.style?.color, hexToColor(theme.recipe.palette.body)); + expect(find.text(theme.title), findsOneWidget); + expect(find.text(theme.description), findsOneWidget); + expect(find.text(theme.recipe.headlineFamily), findsNothing); + expect(find.text(theme.recipe.bodyFamily), findsNothing); + }); + + testWidgets('wizard instructions use a plain heading and description', ( + tester, + ) async { + await tester.pumpWidget( + host( + CatalogQuestionStep( + question: 'Who should this deck persuade?', + description: 'Choose the audience that matters most.', + body: const SizedBox(height: 80), + onSubmit: null, + ), + ), + ); + + expect(find.text('Who should this deck persuade?'), findsOneWidget); + expect(find.text('Choose the audience that matters most.'), findsOneWidget); + }); + + testWidgets('active wizard controls keep the available panel width', ( + tester, + ) async { + final inputKey = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: HeroTheme( + data: HeroThemeData.light(), + child: Scaffold( + body: Center( + child: SizedBox( + width: 720, + height: 600, + child: AiSurfacesPanel( + controller: null, + surfaceIds: const [], + isThinking: false, + inputWidget: SizedBox(key: inputKey, height: 48), + ), + ), + ), + ), + ), + ), + ); + + expect(tester.getSize(find.byKey(inputKey)).width, 720); + }); + + testWidgets('missing surfaces show a flat recovery message', (tester) async { + await tester.pumpWidget( + host( + const AiSurfacesPanel( + controller: null, + surfaceIds: [], + isThinking: false, + errorMessage: 'I couldn\'t prepare the next step.', + inputWidget: SizedBox(height: 48), + ), + ), + ); + + expect(find.text('I couldn\'t prepare the next step.'), findsOneWidget); + }); + + testWidgets('generation status communicates the active pipeline stage', ( + tester, + ) async { + await tester.pumpWidget( + host( + const WizardGenerationStatus( + kind: WizardGenerationStatusKind.running, + progress: GenerationProgress( + GenerationPhase.composingSlides, + sectionIndex: 2, + sectionCount: 4, + ), + ), + ), + ); + + expect(find.text('Building your presentation'), findsOneWidget); + expect(find.text('Composing section 2 of 4…'), findsOneWidget); + expect(find.text('Shape the story'), findsOneWidget); + expect(find.text('Compose the slides'), findsOneWidget); + expect(find.text('Polish the deck'), findsOneWidget); + }); + + testWidgets('generation failure keeps a path back to the deck plan', ( + tester, + ) async { + await tester.pumpWidget( + host( + const WizardGenerationStatus( + kind: WizardGenerationStatusKind.failed, + errorMessage: 'The service did not return valid slide JSON.', + ), + ), + ); + + expect(find.text('We couldn\'t finish the deck'), findsOneWidget); + expect( + find.text('The service did not return valid slide JSON.'), + findsOneWidget, + ); + expect(find.text('Back to deck plan'), findsOneWidget); + }); + + testWidgets('partial generation completes with a visible warning', ( + tester, + ) async { + await tester.pumpWidget( + host( + const WizardGenerationStatus( + kind: WizardGenerationStatusKind.completed, + noticeMessage: 'Generated 11 of 12 slides; one slide was skipped.', + ), + ), + ); + + expect(find.text('Your presentation is ready'), findsOneWidget); + expect( + find.text('Generated 11 of 12 slides; one slide was skipped.'), + findsOneWidget, + ); + expect(find.text('Back to deck plan'), findsOneWidget); + }); + + testWidgets('summary ends with a sentence-case generation action', ( + tester, + ) async { + final items = [ + SummaryItemType.parse({ + 'kind': 'text', + 'label': 'Audience', + 'title': 'Executive leadership', + 'description': 'Decisive and concise', + }), + SummaryItemType.parse({ + 'kind': 'text', + 'label': 'Length', + 'text': '12 slides', + }), + ]; + + await tester.pumpWidget( + host( + SummaryCard( + title: 'Your deck plan', + items: items, + themeCatalog: PresentationThemeCatalog.withDefaults(), + ), + ), + ); + + expect(find.text('Your deck plan'), findsOneWidget); + expect(find.text('Audience'), findsOneWidget); + expect(find.text('12 slides'), findsOneWidget); + expect(find.text('Generate slides'), findsOneWidget); + }); + + testWidgets('deck length uses a counter and useful presets', (tester) async { + var selectedValue = 10; + + await tester.pumpWidget( + host( + StatefulBuilder( + builder: (context, setState) => DeckLengthSelector( + value: selectedValue, + min: 5, + max: 20, + onChanged: (value) => setState(() => selectedValue = value), + ), + ), + ), + ); + + expect(find.text('Deck length'), findsOneWidget); + expect(find.text('10'), findsNWidgets(2)); + + await tester.tap(find.byTooltip('One more slide')); + await tester.pump(); + expect(find.text('11'), findsOneWidget); + + await tester.tap(find.text('20')); + await tester.pump(); + expect(selectedValue, 20); + }); +} diff --git a/packages/playground/test/features/ai/wizard/wizard_view_test.dart b/packages/playground/test/features/ai/wizard/wizard_view_test.dart index 3656e105..c9436a16 100644 --- a/packages/playground/test/features/ai/wizard/wizard_view_test.dart +++ b/packages/playground/test/features/ai/wizard/wizard_view_test.dart @@ -29,8 +29,8 @@ void main() { expect(find.byType(WizardView), findsOneWidget); expect(find.byType(EmptyState), findsOneWidget); - // Starter suggestion chips render before any conversation begins. - expect(find.text('Startup pitch deck'), findsOneWidget); + expect(find.text('What is the presentation about?'), findsOneWidget); + expect(find.text('Describe your presentation topic…'), findsOneWidget); // Unmount so the view model disposes its session/signals cleanly. await tester.pumpWidget(const SizedBox()); diff --git a/pubspec.lock b/pubspec.lock index ebb0baad..6e11fcd4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1008,6 +1008,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + lucide_icons_flutter: + dependency: transitive + description: + name: lucide_icons_flutter + sha256: "234155b10641b8ef7bab8a077b93ea6c92fe849c06740cf89dcd86dcf350934f" + url: "https://pub.dev" + source: hosted + version: "3.1.15" markdown: dependency: transitive description: From 169a7294f9f721ffbfa1b200044763b11f782de6 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 15 Jul 2026 14:46:29 -0400 Subject: [PATCH 05/15] refactor: streamline deck rendering and presentation handling --- .../assets/ai_prompts/section_system.prompt | 10 +- .../assets/ai_prompts/wizard_system.prompt | 55 +-- .../prompts/generation_prompt_provider.dart | 2 - .../services/deck_generator_pipeline.dart | 62 ++- .../services/deck_generator_service.dart | 409 ++++++++++++++-- .../services/deck_generator_workflow.dart | 13 +- .../engine/services/deck_plan_repair.dart | 2 +- .../engine/services/deck_plan_validator.dart | 6 +- .../engine/services/generation_progress.dart | 2 +- .../engine/services/source_grounding.dart | 17 +- .../commands/generate_deck_command.dart | 19 +- .../domain/generated_deck_result_applier.dart | 22 + .../features/ai/wizard/chat/chat_message.dart | 99 ---- .../wizard/chat/view/widgets/chat_bubble.dart | 131 ------ .../chat/view/widgets/chat_genui_panels.dart | 139 +----- .../core/ai/catalog/ask_user_checkbox.dart | 5 +- .../core/ai/catalog/ask_user_radio.dart | 1 - .../core/ai/catalog/ask_user_slider.dart | 8 +- .../core/ai/catalog/ask_user_style.dart | 1 - .../wizard/core/ai/catalog/ask_user_text.dart | 120 ----- .../core/ai/catalog/ask_user_text.g.dart | 38 -- .../ai/wizard/core/ai/catalog/catalog.dart | 8 - .../wizard/core/ai/catalog/summary_card.dart | 279 ----------- .../core/ai/catalog/summary_card.g.dart | 67 --- .../core/ai/catalog/summary_card_view.dart | 359 -------------- .../core/ai/schemas/user_action_payload.dart | 33 ++ .../core/ai/schemas/wizard_context_keys.dart | 51 +- .../services/ai_conversation_viewmodel.dart | 297 +++++++----- .../services/genui_conversation_session.dart | 12 +- .../core/ai/services/prompt_builder.dart | 2 +- .../ai/wizard/core/ai/wizard_context.dart | 76 --- .../wizard/core/ai/wizard_session_state.dart | 122 +++++ .../lib/features/ai/wizard/core/ui/ui.dart | 1 - .../core/ui/widgets/wizard_loading_state.dart | 20 - .../ai/wizard/core/viewmodel_scope.dart | 6 - .../wizard_generation_controller.dart | 440 ++++++++++++++++++ .../wizard_generation_status.dart | 150 +++++- .../presentation/wizard_outline_review.dart | 353 ++++++++++++++ .../ai/wizard/presentation/wizard_page.dart | 173 +++++-- .../presentation/wizard_selection_review.dart | 202 ++++++++ .../ai/wizard/presentation/wizard_view.dart | 46 +- packages/playground/lib/main.dart | 12 +- .../engine/schemas/outline_schema_test.dart | 24 +- .../services/deck_generator_service_test.dart | 130 +++++- .../services/source_grounding_test.dart | 23 + .../presentation_theme_selection_test.dart | 57 +-- .../ai_conversation_viewmodel_test.dart | 119 ++++- .../core/ai/wizard_session_state_test.dart | 63 +++ .../wizard_generation_controller_test.dart | 336 +++++++++++++ .../wizard_outline_review_test.dart | 170 +++++++ .../wizard/presentation/wizard_page_test.dart | 170 ++++++- .../wizard_selection_review_test.dart | 54 +++ .../ai/wizard/wizard_polish_test.dart | 73 ++- .../features/ai/wizard/wizard_view_test.dart | 11 +- .../ai_generation_smoke_test.dart | 68 ++- .../helpers/fake_slide_capture_service.dart | 1 + .../src/styling/components/block_styler.dart | 4 +- 57 files changed, 3317 insertions(+), 1856 deletions(-) create mode 100644 packages/playground/lib/features/ai/quick_agent/domain/generated_deck_result_applier.dart delete mode 100644 packages/playground/lib/features/ai/wizard/chat/chat_message.dart delete mode 100644 packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_bubble.dart delete mode 100644 packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_text.dart delete mode 100644 packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_text.g.dart delete mode 100644 packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card.dart delete mode 100644 packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card.g.dart delete mode 100644 packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card_view.dart create mode 100644 packages/playground/lib/features/ai/wizard/core/ai/schemas/user_action_payload.dart create mode 100644 packages/playground/lib/features/ai/wizard/core/ai/wizard_session_state.dart delete mode 100644 packages/playground/lib/features/ai/wizard/core/ui/widgets/wizard_loading_state.dart create mode 100644 packages/playground/lib/features/ai/wizard/presentation/wizard_generation_controller.dart create mode 100644 packages/playground/lib/features/ai/wizard/presentation/wizard_outline_review.dart create mode 100644 packages/playground/lib/features/ai/wizard/presentation/wizard_selection_review.dart create mode 100644 packages/playground/test/features/ai/wizard/core/ai/wizard_session_state_test.dart create mode 100644 packages/playground/test/features/ai/wizard/presentation/wizard_generation_controller_test.dart create mode 100644 packages/playground/test/features/ai/wizard/presentation/wizard_outline_review_test.dart create mode 100644 packages/playground/test/features/ai/wizard/presentation/wizard_selection_review_test.dart diff --git a/packages/playground/assets/ai_prompts/section_system.prompt b/packages/playground/assets/ai_prompts/section_system.prompt index bfb47eba..31fd0571 100644 --- a/packages/playground/assets/ai_prompts/section_system.prompt +++ b/packages/playground/assets/ai_prompts/section_system.prompt @@ -6,10 +6,12 @@ Return one JSON object containing only a `slides` array. Produce exactly one canonical slide for every supplied plan item, in the same order, with the exact planned key. Never add, omit, merge, split, or reorder slides. -Each slide may contain `key`, `options`, `comments`, and `sections`. Slides stack -sections vertically; each section lays out blocks horizontally. Markdown blocks -use `type: "block"`. Registered elements use `type: "widget"`, their exact -catalog `name`, and a nested `args` object. Use only supported schema fields. +Each slide must contain `key`, `options`, and `sections`; `comments` are optional. +Set `options.title` and set `options.style` to the exact planned treatment. +Slides stack sections vertically; each section lays out blocks horizontally. +Markdown blocks use `type: "block"`. Registered elements use `type: "widget"`, +their exact catalog `name`, and a nested `args` object. Use only supported schema +fields. ## Composition diff --git a/packages/playground/assets/ai_prompts/wizard_system.prompt b/packages/playground/assets/ai_prompts/wizard_system.prompt index ec19fb10..416f08b8 100644 --- a/packages/playground/assets/ai_prompts/wizard_system.prompt +++ b/packages/playground/assets/ai_prompts/wizard_system.prompt @@ -7,7 +7,9 @@ You are a presentation wizard. The user's first message is the presentation topi 3. AskUserCheckbox (step3) - 4 emphasis topics relevant to the topic 4. AskUserSlider (step4) - 5-20 slides, unit: "slides", suggest a default based on complexity 5. AskUserStyle (step5) - exact IDs for 3 registered presentation themes -6. SummaryCard (step6) - display ALL previous selections: Topic, Audience, Approach, Emphasis, Slide Count, Theme + +After the style selection, stop. The application owns the review and outline +screens, so emit nothing after the style selection is accepted. ## AskUserRadio Example (for steps 1-2) ```json @@ -54,20 +56,6 @@ You are a presentation wizard. The user's first message is the presentation topi } ``` -## AskUserText Example (for free-form responses) -```json -\{ - "AskUserText": \{ - "question": "Any specific requirements?", - "description": "Enter any additional details for your presentation.", - "placeholder": "e.g., Include company branding, 15-minute time limit", - "maxLength": 500, - "multiline": true, - "action": \{"name": "submit_answer", "context": []} - } -} -``` - ## AskUserStyle Example (for step 5) ```json \{ @@ -80,33 +68,10 @@ You are a presentation wizard. The user's first message is the presentation topi } ``` -## SummaryCard Example (for step 6) -```json -\{ - "SummaryCard": \{ - "title": "Summary", - "items": [ - \{"label": "Topic", "title": "Introduction to Astronomy"}, - \{"label": "Audience", "title": "Middle School Students"}, - \{"label": "Approach", "title": "Interactive & Visual"}, - \{"label": "Emphasis", "text": "Planets, Stars, Space Exploration"}, - \{"label": "Slide Count", "text": "12 slides"}, - \{ - "kind": "theme", - "label": "Style", - "themeId": "editorial-midnight" - } - ], - "generateSlidesAction": \{"name": "generate_slides", "context": []} - } -} -``` - ## Content Guidance - Tailor all options to the user's topic - Each step builds on previous selections -- Keep text responses brief (1 sentence acknowledging the selection) - Give every AskUserRadio option one distinct semantic icon from this exact list: audience, business, education, global, goal, data, idea, launch, layers, presentation, story, sparkles. Choose icons for meaning, not decoration. @@ -119,28 +84,25 @@ If the user asks for different/alternative/other options while on a step: - Replace the current surface with new options tailored to their feedback - The topic and any previous selections remain unchanged -Message sequence for alternative options (same as a normal later turn): -1. updateComponents with `surfaceId: "wizard"` and the replacement `root` - component -2. provideFinalOutput - brief acknowledgment +For alternative options, emit only updateComponents with `surfaceId: "wizard"` +and the replacement `root` component. Example: If on step 2 (approach) and user asks "can you give me other options?", show new approach options for step 2. ## Critical: One Step Per Turn, One Surface at a Time IMPORTANT: Only display ONE surface at a time. Never render multiple surfaces in a single turn. +Emit surface messages only. Never emit a text response or chat reply. For the first user message, emit these A2UI messages in order: 1. createSurface with `surfaceId: "wizard"` 2. updateComponents with `surfaceId: "wizard"` and one `root` component -3. provideFinalOutput with a brief acknowledgment For every later user message, keep the existing surface and emit: 1. updateComponents with `surfaceId: "wizard"` and the replacement `root` component -2. provideFinalOutput with a brief acknowledgment -STOP IMMEDIATELY after provideFinalOutput. Do not emit any more messages. +STOP IMMEDIATELY after updateComponents. Do not emit any more messages. Do not render multiple surfaces. Do not proceed to the next step. Wait for the user to respond before continuing. @@ -202,6 +164,3 @@ Think about the VISUAL MOOD that matches audience and topic. Consider: Offer the three exact registered theme IDs supplied in the system prompt. Do not invent IDs, descriptions, palettes, or font pairings; the application resolves all preview and renderer tokens from the selected ID. - -**Step 6 - Summary** -Display ALL previous selections clearly organized by category. diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart index 9d29294f..ee500e5d 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart @@ -148,7 +148,6 @@ complete corrected deck-plan object. }) { if (validationIssues.isNotEmpty) { return buildSingleSlideRepairPrompt( - plan: plan, current: current, previousSlide: previousSlide, next: next, @@ -364,7 +363,6 @@ Return only the corrected single-slide plan object. } String buildSingleSlideRepairPrompt({ - required DeckPlanType plan, required DeckPlanSlideType current, required Map? previousSlide, required DeckPlanSlideType? next, diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart index 3e0a5e4d..84434dc2 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart @@ -233,18 +233,29 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { slideCount: plan.slides.length, ); + var completedSections = 0; final sectionResults = await Future.wait([ for (final (sectionIndex, section) in plan.sections.indexed) - _composeSection( - executor: executor, - originalPrompt: prompt, - plan: plan, - section: section, - sectionIndex: sectionIndex, - request: request, - trace: trace, - onProgress: onProgress, - ), + () async { + final result = await _composeSection( + executor: executor, + originalPrompt: prompt, + plan: plan, + section: section, + sectionIndex: sectionIndex, + request: request, + trace: trace, + ); + completedSections++; + onProgress?.call( + GenerationProgress( + GenerationPhase.composingSlides, + sectionIndex: completedSections, + sectionCount: plan.sections.length, + ), + ); + return result; + }(), ]); if (isCancelled?.call() ?? false) return null; @@ -271,7 +282,6 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { required int sectionIndex, required DeckGenerationRequest request, required GenerationTraceEmitter trace, - required GenerationProgressCallback? onProgress, }) async { final plannedSlides = [ for (final slide in plan.slides) @@ -291,13 +301,6 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { ? plan.slides[lastIndex + 1] : null; - onProgress?.call( - GenerationProgress( - GenerationPhase.composingSlides, - sectionIndex: sectionIndex + 1, - sectionCount: plan.sections.length, - ), - ); final systemPrompt = _promptProvider.buildSectionPrompt( plan: plan, section: section, @@ -588,10 +591,18 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { DeckGenerationRequest request, GenerationTraceEmitter trace, GenerationProgressCallback? onProgress, - bool Function()? isCancelled, - ) async { + bool Function()? isCancelled, { + // Passed by retryFailedSlides from another part of this library. + // ignore: avoid-never-passed-parameters + Set? targetSlideKeys, + // ignore: avoid-never-passed-parameters + Map> existingSlidesByKey = const {}, + }) async { final slides = >[]; final failures = []; + final availableSlidesByKey = Map>.of( + existingSlidesByKey, + ); trace.emit( kind: GenerationTraceKind.phaseStarted, phase: GenerationTracePhase.slide, @@ -601,9 +612,17 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { for (var index = 0; index < plan.slides.length; index++) { if (isCancelled?.call() ?? false) return null; final current = plan.slides[index]; + if (targetSlideKeys != null && !targetSlideKeys.contains(current.key)) { + continue; + } final next = index + 1 < plan.slides.length ? plan.slides[index + 1] : null; + Map? previousSlide; + for (var previousIndex = index - 1; previousIndex >= 0; previousIndex--) { + previousSlide = availableSlidesByKey[plan.slides[previousIndex].key]; + if (previousSlide != null) break; + } Map? composed; var validationIssues = []; final repairConstraints = []; @@ -634,7 +653,7 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { originalPrompt: prompt, plan: plan, current: current, - previousSlide: slides.lastOrNull, + previousSlide: previousSlide, next: next, validationIssues: repairConstraints, invalidSlide: composed, @@ -768,6 +787,7 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { continue; } slides.add(canonicalSlide); + availableSlidesByKey[current.key] = canonicalSlide; } trace.emit( diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart index 69796c88..a6035c7e 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart @@ -130,6 +130,21 @@ final class _SlideCompositionResult { final List failures; } +/// Result of generating and validating the deck plan before slide composition. +final class DeckPlanningResult { + const DeckPlanningResult._({required this.success, this.plan, this.error}); + + const DeckPlanningResult.success(DeckPlanType plan) + : this._(success: true, plan: plan); + + const DeckPlanningResult.failure(String error) + : this._(success: false, error: error); + + final bool success; + final DeckPlanType? plan; + final String? error; +} + /// Service that generates SuperDeck presentations using Google Generative AI. /// /// Uses a plan-first pipeline, then composes and validates narrative sections. @@ -231,6 +246,306 @@ class DeckGeneratorService { modelClientFactory ?? GoogleGenerationModelClient.fromApiKey, _promptProvider = promptProvider ?? AssetGenerationPromptProvider(); + /// Generates and validates the shared deck plan without composing slides. + Future plan( + DeckGenerationRequest request, { + GenerationProgressCallback? onProgress, + GenerationTraceCallback? onTrace, + bool Function()? isCancelled, + }) async { + final requestError = _validateRequest(request); + if (requestError != null) return DeckPlanningResult.failure(requestError); + + final List themeCandidates; + try { + themeCandidates = _themeCandidates(request); + } catch (error) { + return DeckPlanningResult.failure( + 'Theme selection is invalid: ${_argumentMessage(error)}', + ); + } + + final modelInput = request.toModelInput(); + _logPipelineConfig(this, prompt: modelInput); + final pipelineStart = DateTime.now(); + final trace = GenerationTraceEmitter(onTrace); + bool generationCancelled() => isCancelled?.call() ?? false; + GenerationModelClient? client; + + try { + client = _modelClientFactory(apiKey); + final executor = _createExecutor( + client: client, + trace: trace, + request: request, + isCancelled: generationCancelled, + ); + await _promptProvider.load(); + final outline = await _runOutlinePhase( + this, + executor: executor, + prompt: modelInput, + request: request, + themeCandidates: themeCandidates, + onProgress: onProgress, + trace: trace, + ); + if (generationCancelled()) { + return const DeckPlanningResult.failure('Generation cancelled.'); + } + if (outline == null) { + return const DeckPlanningResult.failure( + 'Failed to generate presentation outline. Please try again.', + ); + } + return DeckPlanningResult.success(outline); + } on GenerationCancelledException { + return const DeckPlanningResult.failure('Generation cancelled.'); + } on GenerationBudgetExceededException catch (error, stack) { + final totalMs = DateTime.now().difference(pipelineStart).inMilliseconds; + debugLog.error( + 'DECK_GEN', + 'Planning stopped after ${totalMs}ms: ${error.message}', + stack, + ); + return DeckPlanningResult.failure(error.message); + } catch (error, stack) { + final totalMs = DateTime.now().difference(pipelineStart).inMilliseconds; + debugLog.error( + 'DECK_GEN', + 'Planning FAILED after ${totalMs}ms: $error', + stack, + ); + return DeckPlanningResult.failure( + const ErrorClassifier().getUserMessage(error), + ); + } finally { + client?.close(); + } + } + + /// Composes slides from the exact plan approved by the user. + Future generateFromPlan( + DeckGenerationRequest request, + DeckPlanType approvedPlan, { + GenerationProgressCallback? onProgress, + GenerationTraceCallback? onTrace, + bool Function()? isCancelled, + }) async { + final requestError = _validateRequest(request); + if (requestError != null) return DeckGenerationResult.failure(requestError); + + final planIssues = validateDeckPlanIssues( + approvedPlan, + typographyCatalog: typographyCatalog, + themeCatalog: themeCatalog, + request: request, + ).blockingIssues; + if (planIssues.isNotEmpty) { + return DeckGenerationResult.failure( + 'Approved outline is invalid: ${planIssues.messages.join(' ')}', + ); + } + + final modelInput = request.toModelInput(); + _logPipelineConfig(this, prompt: modelInput); + final pipelineStart = DateTime.now(); + final trace = GenerationTraceEmitter(onTrace); + bool generationCancelled() => isCancelled?.call() ?? false; + GenerationModelClient? client; + + try { + client = _modelClientFactory(apiKey); + final executor = _createExecutor( + client: client, + trace: trace, + request: request, + isCancelled: generationCancelled, + ); + await _promptProvider.load(); + final composition = await _runSlideCompositionPhase( + this, + executor: executor, + prompt: modelInput, + request: request, + outline: approvedPlan, + onProgress: onProgress, + trace: trace, + isCancelled: isCancelled, + ); + if (generationCancelled()) { + return DeckGenerationResult.failure('Generation cancelled.'); + } + if (composition == null) { + return DeckGenerationResult.failure( + 'Failed while composing presentation slides. Please try again.', + ); + } + return _finalizeDeck( + this, + composition: composition, + plan: approvedPlan, + pipelineStart: pipelineStart, + onProgress: onProgress, + isCancelled: isCancelled, + trace: trace, + ); + } on GenerationCancelledException { + return DeckGenerationResult.failure('Generation cancelled.'); + } on GenerationBudgetExceededException catch (error, stack) { + final totalMs = DateTime.now().difference(pipelineStart).inMilliseconds; + debugLog.error( + 'DECK_GEN', + 'Composition stopped after ${totalMs}ms: ${error.message}', + stack, + ); + return DeckGenerationResult.failure(error.message); + } catch (error, stack) { + final totalMs = DateTime.now().difference(pipelineStart).inMilliseconds; + debugLog.error( + 'DECK_GEN', + 'Composition FAILED after ${totalMs}ms: $error', + stack, + ); + return DeckGenerationResult.failure( + const ErrorClassifier().getUserMessage(error), + ); + } finally { + client?.close(); + } + } + + /// Retries only unresolved slide slots from a partial result. + /// + /// Accepted slides stay unchanged and are merged with recovered slides in + /// the original plan order. + Future retryFailedSlides( + DeckGenerationRequest request, + DeckGenerationResult partialResult, { + GenerationProgressCallback? onProgress, + GenerationTraceCallback? onTrace, + bool Function()? isCancelled, + }) async { + final plan = partialResult.plan; + if (!partialResult.isPartial || plan == null) { + return DeckGenerationResult.failure( + 'There are no unresolved slides to retry.', + ); + } + + final retryableKeys = { + for (final failure in partialResult.slideFailures) + if (failure.retryable) failure.slideKey, + }; + if (retryableKeys.isEmpty) return partialResult; + + final requestError = _validateRequest(request); + if (requestError != null) return DeckGenerationResult.failure(requestError); + final planIssues = validateDeckPlanIssues( + plan, + typographyCatalog: typographyCatalog, + themeCatalog: themeCatalog, + request: request, + ).blockingIssues; + if (planIssues.isNotEmpty) { + return DeckGenerationResult.failure( + 'Approved outline is invalid: ${planIssues.messages.join(' ')}', + ); + } + + final modelInput = request.toModelInput(); + _logPipelineConfig(this, prompt: modelInput); + final pipelineStart = DateTime.now(); + final trace = GenerationTraceEmitter(onTrace); + bool generationCancelled() => isCancelled?.call() ?? false; + GenerationModelClient? client; + + try { + client = _modelClientFactory(apiKey); + final executor = _createExecutor( + client: client, + trace: trace, + request: request, + isCancelled: generationCancelled, + ); + await _promptProvider.load(); + onProgress?.call( + const GenerationProgress(GenerationPhase.composingSlides), + ); + final existingSlidesByKey = { + for (final slide in partialResult.slides) + slide.key: Map.of(slide.toMap()), + }; + final retried = await _composeSlidesSequentially( + executor, + modelInput, + plan, + request, + trace, + onProgress, + generationCancelled, + targetSlideKeys: retryableKeys, + existingSlidesByKey: existingSlidesByKey, + ); + if (generationCancelled()) { + return DeckGenerationResult.failure('Generation cancelled.'); + } + if (retried == null) { + return DeckGenerationResult.failure( + 'Failed while retrying unresolved slides. Please try again.', + ); + } + + final mergedByKey = >{ + ...existingSlidesByKey, + for (final slide in retried.slides) slide['key']! as String: slide, + }; + final remainingFailures = [ + for (final failure in partialResult.slideFailures) + if (!failure.retryable) failure, + ...retried.failures, + ]; + final merged = _SlideCompositionResult( + slides: List.unmodifiable([ + for (final plannedSlide in plan.slides) + ?mergedByKey[plannedSlide.key], + ]), + failures: List.unmodifiable(remainingFailures), + ); + return _finalizeDeck( + this, + composition: merged, + plan: plan, + pipelineStart: pipelineStart, + onProgress: onProgress, + isCancelled: generationCancelled, + trace: trace, + ); + } on GenerationCancelledException { + return DeckGenerationResult.failure('Generation cancelled.'); + } on GenerationBudgetExceededException catch (error, stack) { + final totalMs = DateTime.now().difference(pipelineStart).inMilliseconds; + debugLog.error( + 'DECK_GEN', + 'Targeted retry stopped after ${totalMs}ms: ${error.message}', + stack, + ); + return DeckGenerationResult.failure(error.message); + } catch (error, stack) { + final totalMs = DateTime.now().difference(pipelineStart).inMilliseconds; + debugLog.error( + 'DECK_GEN', + 'Targeted retry FAILED after ${totalMs}ms: $error', + stack, + ); + return DeckGenerationResult.failure( + const ErrorClassifier().getUserMessage(error), + ); + } finally { + client?.close(); + } + } + /// Generates a presentation deck from typed user intent. /// /// Contractual fields such as slide count and typography remain typed so they @@ -238,7 +553,8 @@ class DeckGeneratorService { /// /// Uses a two-phase pipeline: /// 1. Plan the shared narrative and visual system. - /// 2. Compose and validate one slide at a time from that plan. + /// 2. Compose and validate the slides from that plan, grouping larger decks + /// by narrative section for lower latency and stronger continuity. /// /// Progress updates are reported via [onProgress] if provided. Future generate( @@ -247,24 +563,12 @@ class DeckGeneratorService { GenerationTraceCallback? onTrace, bool Function()? isCancelled, }) async { - if (request.userIntent.trim().isEmpty) { - return DeckGenerationResult.failure( - 'Describe the presentation to create.', - ); - } - if (request.slideCount < 1 || request.slideCount > 50) { - return DeckGenerationResult.failure( - 'Slide count must be between 1 and 50.', - ); - } + final requestError = _validateRequest(request); + if (requestError != null) return DeckGenerationResult.failure(requestError); final List themeCandidates; try { - themeCandidates = themeCandidatesForRequest( - request: request, - themeCatalog: themeCatalog, - typographyCatalog: typographyCatalog, - ); + themeCandidates = _themeCandidates(request); } catch (error) { return DeckGenerationResult.failure( 'Theme selection is invalid: ${_argumentMessage(error)}', @@ -274,30 +578,20 @@ class DeckGeneratorService { final modelInput = request.toModelInput(); _logPipelineConfig(this, prompt: modelInput); final pipelineStart = DateTime.now(); - final service = _modelClientFactory(apiKey); final trace = GenerationTraceEmitter(onTrace); bool generationCancelled() => isCancelled?.call() ?? false; DeckGenerationResult cancelledResult() => DeckGenerationResult.failure('Generation cancelled.'); - final repairBudget = - maxRepairRequests ?? _defaultRepairBudget(request.slideCount); - debugLog.log( - 'DECK_GEN', - 'Run budgets: repairs=$repairBudget, requests=$maxModelRequests, ' - 'timeout=${runTimeout.inSeconds}s', - ); - final executor = GenerationModelCallExecutor( - client: service, - retryPolicy: retryPolicy, - trace: trace, - requestTimeout: requestTimeout, - isCancelled: generationCancelled, - maxModelRequests: maxModelRequests, - maxRepairRequests: repairBudget, - runTimeout: runTimeout, - ); + GenerationModelClient? client; try { + client = _modelClientFactory(apiKey); + final executor = _createExecutor( + client: client, + trace: trace, + request: request, + isCancelled: generationCancelled, + ); await _promptProvider.load(); final outline = await _runOutlinePhase( @@ -367,7 +661,52 @@ class DeckGeneratorService { final userMessage = const ErrorClassifier().getUserMessage(e); return DeckGenerationResult.failure(userMessage); } finally { - service.close(); + client?.close(); + } + } + + String? _validateRequest(DeckGenerationRequest request) { + if (request.userIntent.trim().isEmpty) { + return 'Describe the presentation to create.'; } + if (request.slideCount < 1 || request.slideCount > 50) { + return 'Slide count must be between 1 and 50.'; + } + return null; + } + + List _themeCandidates( + DeckGenerationRequest request, + ) { + return themeCandidatesForRequest( + request: request, + themeCatalog: themeCatalog, + typographyCatalog: typographyCatalog, + ); + } + + GenerationModelCallExecutor _createExecutor({ + required GenerationModelClient client, + required GenerationTraceEmitter trace, + required DeckGenerationRequest request, + required bool Function() isCancelled, + }) { + final repairBudget = + maxRepairRequests ?? _defaultRepairBudget(request.slideCount); + debugLog.log( + 'DECK_GEN', + 'Run budgets: repairs=$repairBudget, requests=$maxModelRequests, ' + 'timeout=${runTimeout.inSeconds}s', + ); + return GenerationModelCallExecutor( + client: client, + retryPolicy: retryPolicy, + trace: trace, + requestTimeout: requestTimeout, + isCancelled: isCancelled, + maxModelRequests: maxModelRequests, + maxRepairRequests: repairBudget, + runTimeout: runTimeout, + ); } } diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_workflow.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_workflow.dart index f446a24e..a3348860 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_workflow.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_workflow.dart @@ -133,14 +133,19 @@ DeckGenerationResult _finalizeDeck( '(${slides.length - sanitizedSlides.length} removed)', ); - if (sanitizedSlides.isEmpty && composition.failures.isEmpty) { - debugLog.error('DECK_GEN', 'No slides survived sanitization'); + if (sanitizedSlides.isEmpty) { + final failureSummary = composition.failures.isEmpty + ? 'No slides generated.' + : 'No slides could be generated; ' + '${composition.failures.length} ' + '${composition.failures.length == 1 ? 'slide failed' : 'slides failed'}.'; + debugLog.error('DECK_GEN', failureSummary); trace.emit( kind: GenerationTraceKind.validation, phase: GenerationTracePhase.finalize, - validationErrors: const ['No slides generated'], + validationErrors: [failureSummary], ); - return DeckGenerationResult.failure('No slides generated'); + return DeckGenerationResult.failure(failureSummary); } final expectedAcceptedCount = diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart index a82056d0..a20cf5d6 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart @@ -209,7 +209,7 @@ List _outlineSlideInvariantErrors({ required DeckPlanSlideType candidate, }) { final errors = []; - void requireSame(String field, Object? before, Object? after) { + void requireSame(String field, Object before, Object after) { if (jsonEncode(before) != jsonEncode(after)) { errors.add( 'Repair changed immutable field `$field`; restore its exact original ' diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart index c1e446e3..2bfb494b 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart @@ -145,7 +145,11 @@ List validateDeckPlanIssues( ); _validateTreatmentIntent( plan, - issues.scoped(code: GenerationValidationCode.treatmentIntent), + issues.scoped( + code: GenerationValidationCode.treatmentIntent, + category: GenerationValidationCategory.quality, + severity: GenerationValidationSeverity.diagnostic, + ), ); _validateDesignRhythm( plan, diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_progress.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_progress.dart index 00bb3f64..718e4e76 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_progress.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_progress.dart @@ -40,7 +40,7 @@ final class GenerationProgress { if (phase == GenerationPhase.composingSlides && currentSection != null && totalSections != null) { - return 'Composing section $currentSection of $totalSections…'; + return 'Composed section $currentSection of $totalSections…'; } final index = slideIndex; final count = slideCount; diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/source_grounding.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/source_grounding.dart index 7ad58399..d22c78c8 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/source_grounding.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/source_grounding.dart @@ -73,7 +73,7 @@ final _metricAmplificationPattern = RegExp( caseSensitive: false, ); final _implementationDetailPattern = RegExp( - r'\b(?:automat(?:e|ed|es|ic|ically|ing|ion)|classification|correlation|' + r'\b(?:automat(?:e|ed|es|ic|ically|ing|ion)|correlation|' r'documentation|launch team|notifications?|prioriti[sz](?:e|ed|es|ation)|' r'self-serve|smart triage|tagging)\b', caseSensitive: false, @@ -88,7 +88,8 @@ final _absoluteCapabilityPattern = RegExp( r'designed (?:for|to)|diverse industries|' r'every(?:\s+[a-z-]+){0,2}\s+signal|fast onboarding|high-velocity|immutable|' r'maps? back to|never lost|no(?:\s+[a-z-]+){0,2}\s+(?:disruption|migration)|' - r'operates alongside|overlay analytics|permanent|predictable scaling|pure overlay|' + r'operates alongside|overlay analytics|predictable scaling|pure overlay|' + r'permanent(?=(?:\s*,?\s+[a-z-]+){0,2}\s+(?:archive|history|links?|logs?|memory|records?|trace))|' r'rapid(?:\s+[a-z-]+){0,2}\s+(?:adoption|deployment|setup)|' r'shareable summaries|source data pristine|team sizes|time-to-value|' r'traceable links|universal capture|' @@ -798,8 +799,8 @@ Set findUnsupportedCommitmentPhrases({ final copy = context.toLowerCase(); final qualified = _nonCommitmentQualifierPattern.hasMatch(copy); for (final phrase in _highRiskCommitmentPhrases) { - if (copy.contains(phrase) && - !supplied.contains(phrase) && + if (_containsStandalonePhrase(copy, phrase) && + !_containsStandalonePhrase(supplied, phrase) && !qualified && !_isBenignCommitmentUse(copy, phrase) && !_isNegatedPhrase(copy, phrase)) { @@ -840,6 +841,14 @@ Set findUnsupportedCommitmentPhrases({ return unsupported; } +bool _containsStandalonePhrase(String value, String phrase) { + final escaped = RegExp.escape(phrase); + return RegExp( + '(?:^|[^a-z0-9])$escaped(?=\$|[^a-z0-9])', + caseSensitive: false, + ).hasMatch(value); +} + /// Whether unsupported commitment findings are concrete enough to block. bool hasBlockingCommitmentClaim(Iterable phrases) => phrases.any( (phrase) => diff --git a/packages/playground/lib/features/ai/quick_agent/domain/commands/generate_deck_command.dart b/packages/playground/lib/features/ai/quick_agent/domain/commands/generate_deck_command.dart index 0c4b4050..39cc9fd7 100644 --- a/packages/playground/lib/features/ai/quick_agent/domain/commands/generate_deck_command.dart +++ b/packages/playground/lib/features/ai/quick_agent/domain/commands/generate_deck_command.dart @@ -1,6 +1,5 @@ -import 'package:superdeck_builder/superdeck_builder.dart'; - import '../../../../../core/command.dart'; +import '../../../../../core/data/data_sources/memory_deck_loader.dart'; import '../../../../../core/result.dart'; import '../../../../editor/domain/stores/deck_document_store.dart'; import '../../../../../core/domain/stores/deck_customization_store.dart'; @@ -9,7 +8,7 @@ import '../../core/engine/services/deck_generation_request.dart'; import '../../core/engine/services/deck_generator_service.dart'; import '../../core/engine/services/generation_progress.dart'; import '../../core/env_config.dart'; -import '../generated_deck_style_mapper.dart'; +import '../generated_deck_result_applier.dart'; /// Failure raised by [GenerateDeckCommand]. Its [toString] is the user-facing /// message (no `Exception:` prefix), so the panel can surface it directly. @@ -33,13 +32,16 @@ class GenerateDeckCommand extends Command1 { GenerateDeckCommand({ required DeckDocumentStore documentStore, required DeckCustomizationStore customizationStore, + MemoryDeckLoader? deckLoader, DeckGeneratorService? service, }) : _documentStore = documentStore, _customizationStore = customizationStore, + _deckLoader = deckLoader, _service = service; final DeckDocumentStore _documentStore; final DeckCustomizationStore _customizationStore; + final MemoryDeckLoader? _deckLoader; final DeckGeneratorService? _service; GenerationProgress _progress = const GenerationProgress(GenerationPhase.idle); @@ -94,11 +96,12 @@ class GenerateDeckCommand extends Command1 { return const Result.error(GenerationException('Generation cancelled.')); } - final markdown = const SlideSerializer().serialize(result.slides); - _documentStore.replaceMarkdown(markdown); - if (result.theme case final theme?) { - _customizationStore.applyGeneratedStyle(theme.toGeneratedDeckStyle()); - } + applyGeneratedDeckResult( + result: result, + documentStore: _documentStore, + deckLoader: _deckLoader, + customizationStore: _customizationStore, + ); if (result.isPartial) { _completionNotice = result.error; } diff --git a/packages/playground/lib/features/ai/quick_agent/domain/generated_deck_result_applier.dart b/packages/playground/lib/features/ai/quick_agent/domain/generated_deck_result_applier.dart new file mode 100644 index 00000000..459a1ea0 --- /dev/null +++ b/packages/playground/lib/features/ai/quick_agent/domain/generated_deck_result_applier.dart @@ -0,0 +1,22 @@ +import 'package:superdeck_builder/superdeck_builder.dart'; + +import '../../../../core/data/data_sources/memory_deck_loader.dart'; +import '../../../../core/domain/stores/deck_customization_store.dart'; +import '../../../editor/domain/stores/deck_document_store.dart'; +import '../core/engine/services/deck_generator_service.dart'; +import 'generated_deck_style_mapper.dart'; + +/// Loads one accepted generation result into the shared document and renderer. +void applyGeneratedDeckResult({ + required DeckGenerationResult result, + required DeckDocumentStore documentStore, + MemoryDeckLoader? deckLoader, + required DeckCustomizationStore customizationStore, +}) { + final markdown = const SlideSerializer().serialize(result.slides); + documentStore.replaceMarkdown(markdown); + deckLoader?.updateMarkdown(markdown); + if (result.theme case final theme?) { + customizationStore.applyGeneratedStyle(theme.toGeneratedDeckStyle()); + } +} diff --git a/packages/playground/lib/features/ai/wizard/chat/chat_message.dart b/packages/playground/lib/features/ai/wizard/chat/chat_message.dart deleted file mode 100644 index 59bc767f..00000000 --- a/packages/playground/lib/features/ai/wizard/chat/chat_message.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'dart:convert'; - -/// Sealed class hierarchy for chat messages in the SuperDeck AI application. -/// -/// Uses sealed classes for exhaustive pattern matching on message types. -sealed class SuperdeckChatMessage { - const SuperdeckChatMessage(); -} - -/// A message sent by the user. -final class SuperdeckUserMessage extends SuperdeckChatMessage { - const SuperdeckUserMessage(this.text); - - final String text; -} - -/// A response from the AI assistant. -final class SuperdeckAiMessage extends SuperdeckChatMessage { - const SuperdeckAiMessage(this.text); - - final String text; -} - -/// A debug message for development/debugging purposes. -/// -/// Only displayed when debug mode is enabled. -final class SuperdeckDebugMessage extends SuperdeckChatMessage { - const SuperdeckDebugMessage(this.text); - - final String text; -} - -/// A debug message containing formatted JSON. -/// -/// Automatically formats JSON with pretty-printing and markdown code blocks. -/// Falls back to raw text if JSON parsing fails. -final class SuperdeckJsonDebugMessage extends SuperdeckChatMessage { - SuperdeckJsonDebugMessage(String json) { - String jsonMD(String content) => '```json\n$content```'; - - try { - final map = jsonDecode(json) as Map; - final prettyJson = JsonEncoder.withIndent(' ').convert(map); - text = jsonMD(prettyJson); - } catch (e) { - text = json; - } - } - - late final String text; -} - -/// Typed parser for user action payloads from GenUI. -/// -/// Safely extracts and validates the action structure from JSON. -/// Expected GenUI v0.9 format: -/// ```json -/// { -/// "version": "v0.9", -/// "action": { -/// "name": "action_name", -/// "context": { "message": "Display text", ... } -/// } -/// } -/// ``` -class UserActionPayload { - final String actionName; - final Map context; - - const UserActionPayload({required this.actionName, required this.context}); - - /// The message to display in chat, extracted from context. - String get displayMessage => context['message'] as String? ?? actionName; - - /// Attempts to parse a JSON string into a [UserActionPayload]. - /// - /// Returns null if parsing fails or the structure is unexpected. - static UserActionPayload? tryParse(String jsonString) { - try { - final json = jsonDecode(jsonString); - if (json is! Map) return null; - - final userAction = json['action'] ?? json['userAction']; - if (userAction is! Map) return null; - - final name = userAction['name']; - if (name is! String) return null; - - final context = userAction['context']; - final contextMap = context is Map - ? context - : {}; - - return UserActionPayload(actionName: name, context: contextMap); - } catch (_) { - return null; - } - } -} diff --git a/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_bubble.dart b/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_bubble.dart deleted file mode 100644 index da3e166f..00000000 --- a/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_bubble.dart +++ /dev/null @@ -1,131 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:gpt_markdown/gpt_markdown.dart'; -import 'package:hero_ui/hero_ui.dart'; -import 'package:remix/remix.dart'; - -/// Type of chat message bubble for styling differentiation. -/// -/// - [user]: Right-aligned with accent color background -/// - [ai]: Left-aligned with transparent background and border -/// - [debug]: Left-aligned with muted styling for debug output -enum TextBubbleType { user, ai, debug } - -/// Renders a chat message bubble with markdown support. -/// -/// Uses [TextBubbleType] to determine bubble alignment and color: -/// - [TextBubbleType.user] - Right-aligned, accent color background -/// - [TextBubbleType.ai] - Left-aligned, transparent with border -/// - [TextBubbleType.debug] - Left-aligned, muted gray styling -/// -/// Supports rendering markdown content via [GptMarkdown] with custom -/// code block styling. -class TextBubble extends StatelessWidget { - final String text; - final TextBubbleType type; - - const TextBubble({super.key, required this.text, required this.type}); - - BoxStyler get _userStyle => .new() - .borderRadius( - BorderRadiusMix.value( - const BorderRadius.only( - topLeft: Radius.circular(18), - topRight: Radius.circular(18), - bottomLeft: Radius.circular(18), - bottomRight: Radius.circular(5), - ), - ), - ) - .paddingX(16) - .paddingY(12) - .maxWidth(540) - .color($accentSoft()) - .wrap( - WidgetModifierConfig // - .align(alignment: .centerRight) - .defaultTextStyle(style: $paragraphMedium.mix()) - .defaultTextStyle(style: TextStyleMix(color: $muted())), - ); - - BoxStyler get _aiStyle => _userStyle - .borderRadius( - BorderRadiusMix.value( - const BorderRadius.only( - topLeft: Radius.circular(18), - topRight: Radius.circular(18), - bottomLeft: Radius.circular(5), - bottomRight: Radius.circular(18), - ), - ), - ) - .color($surfaceSecondary()) - .borderAll(color: $border()) - .wrap( - WidgetModifierConfig // - .align(alignment: .centerLeft) - .defaultTextStyle( - style: $paragraphMedium.mix(), - textHeightBehavior: TextHeightBehaviorMix() - .applyHeightToLastDescent(false) - .applyHeightToFirstAscent(false), - ), - ); - - BoxStyler get _debugStyle => _aiStyle - .color($surfaceSecondary()) - .borderAll(color: $border()) - .wrap( - WidgetModifierConfig // - .defaultTextStyle(style: $paragraphSmall.mix()) - .defaultTextStyle(style: TextStyleMix(color: $muted())), - ); - - @override - Widget build(BuildContext context) { - final container = switch (type) { - TextBubbleType.user => _userStyle, - TextBubbleType.ai => _aiStyle, - TextBubbleType.debug => _debugStyle, - }; - - return container( - child: GptMarkdown( - text, - codeBuilder: (context, _, code, _) { - return _CodeBlock(code: code); - }, - ), - ); - } -} - -/// Code block widget with monospace font, padding, and horizontal scroll. -class _CodeBlock extends StatelessWidget { - final String code; - - const _CodeBlock({required this.code}); - - @override - Widget build(BuildContext context) { - final codeContainer = BoxStyler() - .color($backgroundSecondary()) - .borderRadiusAll(Radius.circular(8)) - .borderAll(color: $border()) - .paddingAll(12); - - return codeContainer( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: SelectableText( - code, - style: TextStyle( - fontFamily: 'monospace', - fontSize: 13, - color: $muted.resolve(context), - height: 1.5, - ), - ), - ), - ); - } -} diff --git a/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_genui_panels.dart b/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_genui_panels.dart index 14ce52b5..543bdd87 100644 --- a/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_genui_panels.dart +++ b/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_genui_panels.dart @@ -3,10 +3,7 @@ import 'package:genui/genui.dart'; import 'package:hero_ui/hero_ui.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:remix/remix.dart'; -import 'package:signals/signals_flutter.dart'; import '../../../presentation/view/loading.dart'; -import '../../chat_message.dart'; -import 'chat_bubble.dart'; import '../../../core/ui/ui.dart'; /// Typing indicator - shows only when AI is thinking. @@ -54,7 +51,7 @@ class AiSurfacesPanel extends StatelessWidget { final bool isThinking; final String? errorMessage; - /// Optional input widget to display at bottom when chat panel is hidden. + /// Optional input displayed below the active Wizard surface. final Widget? inputWidget; @override @@ -81,23 +78,20 @@ class AiSurfacesPanel extends StatelessWidget { child: AnimatedOpacity( duration: SdTokens.motionFast, opacity: thinking ? 0.5 : 1.0, - child: WizardLoadingState( - isLoading: thinking, - child: flex( - key: ValueKey(ids.last), - children: ids.map((surfaceId) { - return IgnorePointer( - key: ValueKey('ignore_$surfaceId'), - ignoring: thinking, - child: Surface( - key: ValueKey('surface_$surfaceId'), - surfaceContext: resolvedController.contextFor( - surfaceId, - ), + child: flex( + key: ValueKey(ids.last), + children: ids.map((surfaceId) { + return IgnorePointer( + key: ValueKey('ignore_$surfaceId'), + ignoring: thinking, + child: Surface( + key: ValueKey('surface_$surfaceId'), + surfaceContext: resolvedController.contextFor( + surfaceId, ), - ); - }).toList(), - ), + ), + ); + }).toList(), ), ), ), @@ -136,112 +130,9 @@ class AiSurfacesPanel extends StatelessWidget { ), ), TypingBubble(isThinking: thinking), - inputWidget ?? const SizedBox.shrink(), + inputWidget!, ], ), ); } } - -/// Shared chat body panel used by AI conversation screens. -class ChatBodyPanel extends SignalWidget { - const ChatBodyPanel({ - super.key, - required this.messages, - required this.isThinking, - required this.emptyState, - this.inputWidget, - }); - - final ReadonlySignal> messages; - final ReadonlySignal isThinking; - final Widget emptyState; - - /// Optional input widget to display at bottom when chat panel is visible. - final Widget? inputWidget; - - @override - Widget build(BuildContext context) { - final thinking = isThinking.value; - - return Column( - children: [ - Expanded( - child: MessageList(messages: messages, emptyState: emptyState), - ), - Padding( - padding: const EdgeInsets.only(bottom: 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [if (thinking) const LoadingResponse(), ?inputWidget], - ), - ), - ], - ); - } -} - -/// Shared message list used by AI conversation body panels. -class MessageList extends SignalWidget { - const MessageList({ - super.key, - required this.messages, - required this.emptyState, - }); - - final ReadonlySignal> messages; - final Widget emptyState; - - @override - Widget build(BuildContext context) { - final currentMessages = messages.value; - final reversedMessages = currentMessages.reversed.toList(); - - if (currentMessages.isEmpty) { - return emptyState; - } - - return ListView.builder( - reverse: true, - padding: const EdgeInsets.symmetric(vertical: 24), - itemCount: reversedMessages.length * 2, - itemBuilder: (context, index) { - if (index.isEven) { - return const SizedBox(height: 16); - } - - final message = reversedMessages[index ~/ 2]; - - switch (message) { - case SuperdeckUserMessage(): - return TextBubble(text: message.text, type: .user); - case SuperdeckAiMessage(): - return TextBubble(text: message.text, type: .ai); - case SuperdeckDebugMessage(): - return TextBubble(text: message.text, type: .debug); - case SuperdeckJsonDebugMessage(): - return TextBubble(text: message.text, type: .debug); - } - }, - ); - } -} - -class LoadingResponse extends StatelessWidget { - const LoadingResponse({super.key}); - - @override - Widget build(BuildContext context) { - final text = TextStyler().color($muted()).style($labelXSmall.mix()); - - final row = FlexBoxStyler().spacing(6); - - return row( - children: [ - SdSpinner(size: SdSpinnerSize.size1), - text('Thinking...'), - ], - ); - } -} diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_checkbox.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_checkbox.dart index d104d30d..26fde271 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_checkbox.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_checkbox.dart @@ -106,10 +106,7 @@ class _AskUserCheckboxContentState extends State<_AskUserCheckboxContent> { } Map _buildActionContext() { - return { - 'selectedOptions': _selectedChoices.toList(), - 'message': _selectedChoices.join(', '), - }; + return {'selectedOptions': _selectedChoices.toList()}; } void _submitAction() => submitCatalogActionIfValid( diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart index de389448..9371b9c8 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart @@ -97,7 +97,6 @@ class _AskUserRadioContentState extends State<_AskUserRadioContent> { return { 'selectedOption': option.title, 'selectedDescription': option.description, - 'message': option.title, }; } diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_slider.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_slider.dart index ae9d07b8..3a470886 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_slider.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_slider.dart @@ -51,7 +51,7 @@ final askUserSlider = typedCatalogItem( "component": "AskUserSlider", "question": "How many slides do you need?", "minValue": 5, - "maxValue": 25, + "maxValue": 20, "defaultValue": 10, "unit": "slides", "action": {"name": "submit_answer", "context": []} @@ -316,11 +316,7 @@ class _AskUserSliderContentState extends State<_AskUserSliderContent> { } Map _buildActionContext() { - final unit = widget.data.unit ?? ''; - return { - 'value': _sliderValue, - 'message': '$_sliderValue${unit.isNotEmpty ? ' $unit' : ''}', - }; + return {'value': _sliderValue}; } void _submitAction() => submitCatalogActionIfValid( diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_style.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_style.dart index 8961c334..83828434 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_style.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_style.dart @@ -201,6 +201,5 @@ Map buildThemeSelectionContext( WizardContextKeys.themeId: theme.id, WizardContextKeys.title: theme.title, WizardContextKeys.description: theme.description, - WizardContextKeys.message: theme.title, }; } diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_text.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_text.dart deleted file mode 100644 index 5412ad3f..00000000 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_text.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; -import 'package:genui/genui.dart'; -import 'package:hero_ui/hero_ui.dart'; - -import '../schemas/genui_action_schema.dart'; -import 'user_action_dispatch.dart'; -import '../../ui/ui.dart'; - -import 'catalog_question_step.dart'; -import 'component_schema.dart'; -import 'typed_catalog_item.dart'; - -part 'ask_user_text.g.dart'; - -// ─────────────────────────────────── SCHEMA ─────────────────────────────────── - -/// Schema for AskUserText component. -/// -/// Displays a question with a text field for free-form input. -@AckType(name: 'AskUserText') -final _askUserTextSchema = Ack.object({ - 'question': Ack.string().describe('The question to display to the user'), - 'description': Ack.string().optional().describe( - 'Additional context or instructions', - ), - 'placeholder': Ack.string().optional().describe('Placeholder text'), - 'maxLength': Ack.integer().optional().describe('Maximum character length'), - 'multiline': Ack.boolean().optional().describe('Allow multiline input'), - 'action': actionSchema, -}).describe('A question with a text field for free-form user input.'); - -// ─────────────────────────────────── CATALOG ITEM ─────────────────────────────────── - -/// AskUserText catalog component for free-form text input. -final askUserText = typedCatalogItem( - name: 'AskUserText', - dataSchema: componentSchema(_askUserTextSchema.toJsonSchemaBuilder()), - exampleData: [ - () => ''' - [ - { - "id": "root", - "component": "AskUserText", - "question": "What is your presentation topic?", - "description": "Enter a brief description of your topic.", - "placeholder": "e.g., Introduction to Machine Learning", - "maxLength": 200, - "action": {"name": "submit_answer", "context": []} - } - ] - ''', - ], - parse: AskUserTextType.parse, - widgetBuilder: (context, data) => - _AskUserTextContent(data: data, itemContext: context), -); - -// ─────────────────────────────────── WIDGET ─────────────────────────────────── - -class _AskUserTextContent extends StatefulWidget { - final AskUserTextType data; - final CatalogItemContext itemContext; - - const _AskUserTextContent({required this.data, required this.itemContext}); - - @override - State<_AskUserTextContent> createState() => _AskUserTextContentState(); -} - -class _AskUserTextContentState extends State<_AskUserTextContent> { - final _textController = TextEditingController(); - - bool get _canSubmit => _textController.text.trim().isNotEmpty; - - @override - void dispose() { - _textController.dispose(); - super.dispose(); - } - - Map _buildActionContext() { - final text = _textController.text.trim(); - return {'text': text, 'message': text}; - } - - void _submitAction() => submitCatalogActionIfValid( - canSubmit: _canSubmit, - itemContext: widget.itemContext, - action: widget.data.action, - contextBuilder: _buildActionContext, - ); - - @override - Widget build(BuildContext context) { - return CatalogQuestionStep( - question: widget.data.question, - description: widget.data.description, - canSubmit: _canSubmit, - onSubmit: _submitAction, - body: SdPanel( - child: TextField( - controller: _textController, - maxLength: widget.data.maxLength, - maxLines: widget.data.multiline == true ? 4 : 1, - decoration: InputDecoration( - hintText: widget.data.placeholder, - border: InputBorder.none, - counterText: '', - ), - style: $paragraphMedium.resolve(context), - onChanged: (_) => setState(() {}), - ), - ), - ); - } -} diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_text.g.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_text.g.dart deleted file mode 100644 index 8091f096..00000000 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_text.g.dart +++ /dev/null @@ -1,38 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ************************************************************************** -// AckSchemaGenerator -// ************************************************************************** - -part of 'ask_user_text.dart'; - -/// Extension type for AskUserText -extension type AskUserTextType(Map _data) - implements Map { - static AskUserTextType parse(Object? data) { - return _askUserTextSchema.parseAs( - data, - (validated) => AskUserTextType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return _askUserTextSchema.safeParseAs( - data, - (validated) => AskUserTextType(validated as Map), - ); - } - - String get question => _data['question'] as String; - - String? get description => _data['description'] as String?; - - String? get placeholder => _data['placeholder'] as String?; - - int? get maxLength => _data['maxLength'] as int?; - - bool? get multiline => _data['multiline'] as bool?; - - ActionType get action => ActionType(_data['action'] as Map); -} diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/catalog.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/catalog.dart index 80fae08d..dc53408a 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/catalog.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/catalog.dart @@ -6,15 +6,11 @@ import 'ask_user_checkbox.dart'; import 'ask_user_radio.dart'; import 'ask_user_slider.dart'; import 'ask_user_style.dart'; -import 'ask_user_text.dart'; -import 'summary_card.dart'; export 'ask_user_checkbox.dart'; export 'ask_user_radio.dart'; export 'ask_user_slider.dart'; export 'ask_user_style.dart'; -export 'ask_user_text.dart'; -export 'summary_card.dart'; /// SuperDeck AI chat catalog with GenUI components. /// @@ -22,9 +18,7 @@ export 'summary_card.dart'; /// - [askUserRadio] - Radio button single selection /// - [askUserCheckbox] - Checkbox multiple selection /// - [askUserSlider] - Slider numeric input -/// - [askUserText] - Free-form text input /// - [askUserStyle] - Catalog-backed presentation theme selection -/// - [summaryCard] - Wizard summary with aggregated selections /// /// Image-style selection is intentionally omitted for v1 (no image generation). Catalog chatCatalogFor(PresentationThemeCatalog themeCatalog) { @@ -32,9 +26,7 @@ Catalog chatCatalogFor(PresentationThemeCatalog themeCatalog) { askUserRadio, askUserCheckbox, askUserSlider, - askUserText, askUserStyleFor(themeCatalog), - summaryCardFor(themeCatalog), ], catalogId: 'com.superdeck.ai.chat'); } diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card.dart deleted file mode 100644 index 567e02ee..00000000 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card.dart +++ /dev/null @@ -1,279 +0,0 @@ -import 'dart:async'; - -import 'package:ack/ack.dart'; -import 'package:ack_annotations/ack_annotations.dart'; -import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; -import 'package:flutter/material.dart'; -import 'package:genui/genui.dart'; -import 'package:hero_ui/hero_ui.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; -import 'package:provider/provider.dart' as prov; -import 'package:remix/remix.dart'; -import '../../../../../../core/domain/design/presentation_theme_catalog.dart'; -import '../wizard_context.dart'; -import '../prompts/image_style_prompts.dart'; -import '../schemas/genui_action_schema.dart'; -import '../schemas/wizard_context_keys.dart'; -import '../services/prompt_builder.dart'; -import '../../debug_logger.dart'; -import '../../ui/ui.dart'; -import '../../utils/color_utils.dart'; -import '../../../../quick_agent/domain/commands/generate_deck_command.dart'; -import 'component_schema.dart'; -import 'presentation_theme_component_schema.dart'; -import 'typed_catalog_item.dart'; -import 'user_action_dispatch.dart'; - -part 'summary_card.g.dart'; -part 'summary_card_view.dart'; - -enum SummaryItemKind { text, theme, imageStyle } - -/// Schema for summary item with optional fields for different item types. -@AckType(name: 'SummaryItem') -final _summaryItemSchema = Ack.object({ - 'kind': Ack.enumValues( - SummaryItemKind.values, - ).optional().describe('Discriminator for summary payload shape'), - 'label': Ack.string().describe('The category label for this selection'), - 'title': Ack.string().optional().describe( - 'The primary text representing the user\'s choice', - ), - 'description': Ack.string().optional().describe( - 'Additional details about the selection', - ), - 'text': Ack.string().optional().describe( - 'Plain text content for simple display items', - ), - 'themeId': Ack.string().optional().describe( - 'Exact registered presentation theme ID', - ), - 'imageStyleId': Ack.enumValues( - ImageStyle.values, - ).optional().describe(ImageStyle.schemaDescription()), -}).describe('Summary item representing a user selection'); - -/// Schema for SummaryCard component using ACK fluent API. -@AckType(name: 'SummaryCard') -final _summaryCardSchema = - Ack.object({ - 'title': Ack.string().describe('The main heading of the summary card'), - 'items': Ack.list( - _summaryItemSchema, - ).describe('List of summary items representing user selections'), - 'generateSlidesAction': actionSchema, - }).describe( - 'Summary card showing recap of all user selections before finalizing', - ); - -/// Extension for SummaryItemType to add computed properties. -extension SummaryItemExt on SummaryItemType { - /// Returns true if this item references a registered presentation theme. - bool get hasThemeData => themeId != null; - - /// Returns true if this item has image style data. - bool get hasImageStyleData => imageStyleId != null; - - bool get hasTitleOrText { - final titleValue = this['title']; - final textValue = this['text']; - return (titleValue is String && titleValue.isNotEmpty) || - (textValue is String && textValue.isNotEmpty); - } - - /// Validates supported field combinations for summary rendering. - String? get shapeValidationError { - final explicitKind = kind; - if (explicitKind != null) { - switch (explicitKind) { - case .theme: - if (!hasThemeData) { - return 'theme kind requires themeId'; - } - if (imageStyleId != null) { - return 'theme kind should not include imageStyleId'; - } - return null; - case SummaryItemKind.imageStyle: - if (imageStyleId == null) { - return 'imageStyle kind requires imageStyleId'; - } - if (themeId != null) { - return 'imageStyle kind should not include themeId'; - } - return null; - case SummaryItemKind.text: - if (!hasTitleOrText) { - return 'text kind requires title or text'; - } - if (themeId != null || imageStyleId != null) { - return 'text kind should not include theme or imageStyle fields'; - } - return null; - } - } - - if (hasImageStyleData && hasThemeData) { - return 'imageStyleId should not be combined with themeId'; - } - - if (!hasThemeData && !hasImageStyleData && !hasTitleOrText) { - return 'item must include themeId, imageStyleId, title, or text'; - } - - return null; - } -} - -/// A summary card that displays a recap of all user selections before finalizing. -/// -/// Shows multiple items with labels and values. Theme items resolve their -/// preview from the injected catalog; other items show text details. -CatalogItem summaryCardFor(PresentationThemeCatalog themeCatalog) { - final exampleThemeId = themeCatalog.currentThemes.first.id; - - return typedCatalogItem( - name: 'SummaryCard', - dataSchema: componentSchema( - schemaWithPresentationThemeIds( - _summaryCardSchema.toJsonSchemaBuilder(), - themeCatalog, - paths: const [ - ['properties', 'items', 'items', 'properties', 'themeId'], - ], - ), - ), - exampleData: [ - () => - ''' - [ - { - "id": "root", - "component": "SummaryCard", - "title": "Summary", - "items": [ - { - "kind": "text", - "label": "Topic", - "title": "Introduction to Astronomy", - "description": "A beginner-friendly overview of space and celestial objects" - }, - { - "kind": "text", - "label": "Audience", - "title": "Middle School Students", - "description": "Ages 11-14" - }, - { - "kind": "text", - "label": "Approach", - "title": "Interactive & Visual", - "description": "Engaging visuals with hands-on examples" - }, - { - "kind": "text", - "label": "Emphasis", - "text": "Planets, Stars, Space Exploration" - }, - { - "kind": "text", - "label": "Slide Count", - "text": "12 slides" - }, - { - "kind": "theme", - "label": "Style", - "themeId": "$exampleThemeId" - }, - { - "kind": "imageStyle", - "label": "Image Style", - "imageStyleId": "minimalist" - } - ], - "generateSlidesAction": { - "name": "generate_slides", - "context": [] - } - } - ] - ''', - ], - parse: (data) => parseSummaryCard(data, themeCatalog: themeCatalog), - widgetBuilder: (catalogContext, data) { - final action = data.generateSlidesAction; - - return Builder( - builder: (buildContext) { - return SummaryCard( - title: data.title, - items: data.items.toList(), - themeCatalog: themeCatalog, - generateSlides: () { - unawaited(() async { - debugLog.section('Generate Slides Triggered'); - final command = prov.Provider.of( - buildContext, - listen: false, - ); - - // Extract context from displayed summary items - final extractedContext = extractWizardContextFromSummaryItems( - data.items.toList(), - ); - debugLog.userAction( - 'GENERATE_SLIDES', - extractedContext.toMap(), - ); - - // Merge with any path-resolved context from the action - final resolvedContext = WizardContext.fromMap( - await resolveCatalogActionContext( - itemContext: catalogContext, - action: action, - ), - ); - final finalContext = extractedContext.merge(resolvedContext); - debugLog.log('GEN', 'Final context: ${finalContext.toMap()}'); - - // Preserve exact wizard selections in the typed generation - // request handed to the shared editor command. - final request = buildPromptFromWizardContext(finalContext); - debugLog.log( - 'GEN', - 'Routing generation through GenerateDeckCommand. ' - 'slides: ${request.slideCount}', - ); - - // Fire-and-forget — the command manages running/phase/result and - // loads the generated markdown into the editor on success. - unawaited(command(request)); - }()); - }, - ); - }, - ); - }, - ); -} - -final summaryCard = summaryCardFor(PresentationThemeCatalog.withDefaults()); - -SummaryCardType parseSummaryCard( - Object? data, { - required PresentationThemeCatalog themeCatalog, -}) { - final parsed = SummaryCardType.parse(data); - final unknownIds = parsed.items - .map((item) => item.themeId) - .whereType() - .where((themeId) => themeCatalog.current(themeId) == null) - .toSet(); - if (unknownIds.isNotEmpty) { - throw FormatException( - 'Unknown presentation theme IDs: ${unknownIds.join(", ")}.', - ); - } - - return parsed; -} diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card.g.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card.g.dart deleted file mode 100644 index a0475f3f..00000000 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card.g.dart +++ /dev/null @@ -1,67 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND -// dart format width=80 - -// ************************************************************************** -// AckSchemaGenerator -// ************************************************************************** - -part of 'summary_card.dart'; - -/// Extension type for SummaryItem -extension type SummaryItemType(Map _data) - implements Map { - static SummaryItemType parse(Object? data) { - return _summaryItemSchema.parseAs( - data, - (validated) => SummaryItemType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return _summaryItemSchema.safeParseAs( - data, - (validated) => SummaryItemType(validated as Map), - ); - } - - SummaryItemKind? get kind => _data['kind'] as SummaryItemKind?; - - String get label => _data['label'] as String; - - String? get title => _data['title'] as String?; - - String? get description => _data['description'] as String?; - - String? get text => _data['text'] as String?; - - String? get themeId => _data['themeId'] as String?; - - ImageStyle? get imageStyleId => _data['imageStyleId'] as ImageStyle?; -} - -/// Extension type for SummaryCard -extension type SummaryCardType(Map _data) - implements Map { - static SummaryCardType parse(Object? data) { - return _summaryCardSchema.parseAs( - data, - (validated) => SummaryCardType(validated as Map), - ); - } - - static SchemaResult safeParse(Object? data) { - return _summaryCardSchema.safeParseAs( - data, - (validated) => SummaryCardType(validated as Map), - ); - } - - String get title => _data['title'] as String; - - List get items => (_data['items'] as List) - .map((e) => SummaryItemType(e as Map)) - .toList(); - - ActionType get generateSlidesAction => - ActionType(_data['generateSlidesAction'] as Map); -} diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card_view.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card_view.dart deleted file mode 100644 index 5241a217..00000000 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/summary_card_view.dart +++ /dev/null @@ -1,359 +0,0 @@ -part of 'summary_card.dart'; - -/// Extracts generation context from summary items. -/// -/// Uses [WizardContextKeys] for standardized label-to-key mapping. -/// Logs warnings for any unmapped labels to aid debugging. -WizardContext extractWizardContextFromSummaryItems( - List items, -) { - final contextMap = {}; - final unmappedLabels = []; - - for (final item in items) { - final shapeError = item.shapeValidationError; - if (shapeError != null) { - debugLog.log( - 'SummaryCard', - 'Invalid item shape for "${item.label}": $shapeError', - ); - continue; - } - - final key = WizardContextKeys.labelToKey(item.label); - if (key == null) { - unmappedLabels.add(item.label); - continue; - } - - // Use generated nullable getters for optional fields - final itemTitle = item.title; - final itemDescription = item.description; - final itemText = item.text; - final itemThemeId = item.themeId; - final itemImageStyleId = item.imageStyleId; - - // Extract the primary value - if (itemThemeId != null) { - contextMap[WizardContextKeys.themeId] = itemThemeId; - } else if (itemText != null) { - contextMap[key] = _parseContextValue(key, itemText); - } else if (itemTitle != null) { - contextMap[key] = itemTitle; - } - - // Extract image style data for generation context - if (itemImageStyleId != null) { - contextMap[WizardContextKeys.imageStyleId] = itemImageStyleId.name; - contextMap[WizardContextKeys.imageStyleName] = itemImageStyleId.title; - contextMap.putIfAbsent( - WizardContextKeys.imageStyleDescription, - () => itemImageStyleId.description, - ); - } - if (key == WizardContextKeys.imageStyleName && itemDescription != null) { - contextMap[WizardContextKeys.imageStyleDescription] = itemDescription; - } - } - - // Log warnings for unmapped labels - if (unmappedLabels.isNotEmpty) { - debugLog.log( - 'SummaryCard', - 'Unmapped labels ignored: ${unmappedLabels.join(", ")}', - ); - } - - final context = WizardContext.fromMap(contextMap); - - // Validate required context keys - _validateRequiredContext(context); - - return context; -} - -/// Parses a text value for a specific context key. -/// -/// Handles special parsing like extracting numbers from slide count text. -dynamic _parseContextValue(String key, String text) { - if (key == WizardContextKeys.slideCount) { - // Match patterns like "12 slides", "12", "Slide Count: 12" - final match = RegExp(r'\b(\d{1,3})\b').firstMatch(text); - if (match != null) { - return int.tryParse(match.group(1)!) ?? text; - } - return text; - } - return text; -} - -/// Validates that required context keys are present. -/// -/// Logs warnings for any missing required keys. -void _validateRequiredContext(WizardContext context) { - final missing = []; - - if (context.topic == null) missing.add(WizardContextKeys.topic); - if (context.audience == null) missing.add(WizardContextKeys.audience); - if (context.slideCount == null) missing.add(WizardContextKeys.slideCount); - - if (missing.isNotEmpty) { - debugLog.log( - 'SummaryCard', - 'Missing recommended context keys: ${missing.join(", ")}', - ); - } -} - -/// The main summary card widget. -class SummaryCard extends StatelessWidget { - const SummaryCard({ - super.key, - required this.title, - required this.items, - required this.themeCatalog, - this.generateSlides, - }); - - final String title; - final List items; - final PresentationThemeCatalog themeCatalog; - final VoidCallback? generateSlides; - - FlexBoxStyler get _container => .new() - .borderRadiusAll(const Radius.circular(20)) - .mainAxisSize(.min) - .spacing(20) - .crossAxisAlignment(.start) - .paddingAll(24) - .color($surfaceSecondary()) - .borderAll(color: $border()) - .column(); - - @override - Widget build(BuildContext context) { - return _container( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 12, - children: [ - Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: $accentSoft.resolve(context), - borderRadius: BorderRadius.circular(12), - ), - alignment: Alignment.center, - child: Icon( - LucideIcons.layers, - size: 21, - color: $accent.resolve(context), - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 4, - children: [ - SdHeadline(title), - const SdCaption( - 'Review the plan before building your slides.', - ), - ], - ), - ), - ], - ), - LayoutBuilder( - builder: (context, _) => ConstrainedBox( - constraints: BoxConstraints( - maxHeight: (MediaQuery.sizeOf(context).height * 0.43).clamp( - 260.0, - 380.0, - ), - ), - child: SingleChildScrollView( - primary: false, - physics: const ClampingScrollPhysics(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final (index, item) in items.indexed) ...[ - if (index > 0) - Divider(height: 1, color: $separator.resolve(context)), - _SummaryCardItem(item: item, themeCatalog: themeCatalog), - ], - ], - ), - ), - ), - ), - Align( - alignment: Alignment.centerRight, - child: SdButton( - label: 'Generate slides', - icon: LucideIcons.sparkles, - onPressed: generateSlides, - ), - ), - ], - ); - } -} - -class _SummaryCardItem extends StatelessWidget { - const _SummaryCardItem({required this.item, required this.themeCatalog}); - - final SummaryItemType item; - final PresentationThemeCatalog themeCatalog; - - // Labels share a visual column on roomy layouts. - TextStyler get _label => TextStyler() - .color($muted()) - .style($labelSmall.mix()) - .fontWeight(FontWeight.w600); - - Widget _buildContent() { - final shapeError = item.shapeValidationError; - if (shapeError != null) { - debugLog.log( - 'SummaryCard', - 'Rendering fallback for "${item.label}": $shapeError', - ); - return _SummaryCardItemTitleDescription( - title: item.title ?? item.text ?? '', - description: item.description ?? 'Invalid summary item data', - ); - } - - // Use generated nullable getters for optional fields - final itemTitle = item.title; - final itemDescription = item.description; - final itemText = item.text; - final itemImageStyleId = item.imageStyleId; - - // Theme items resolve all preview metadata from the shared catalog. - if (item.hasThemeData) { - final itemThemeId = item.themeId!; - final theme = themeCatalog.current(itemThemeId); - if (theme != null) return _SummaryCardItemTheme(theme: theme); - } - - // Image style items - if (item.hasImageStyleData) { - return _SummaryCardItemImageStyle(imageStyle: itemImageStyleId!); - } - - // Default: titleDescription or text display - return _SummaryCardItemTitleDescription( - title: itemTitle ?? itemText ?? '', - description: itemDescription, - ); - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 14), - child: LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth < 480) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [_label(item.label), _buildContent()], - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(width: 120, child: _label(item.label)), - Expanded(child: _buildContent()), - ], - ); - }, - ), - ); - } -} - -class _SummaryCardItemTitleDescription extends StatelessWidget { - final String title; - final String? description; - - const _SummaryCardItemTitleDescription({ - required this.title, - this.description, - }); - - @override - Widget build(BuildContext context) { - final flex = FlexBoxStyler() - .spacing(4) - .mainAxisAlignment(.start) - .crossAxisAlignment(.start) - .column(); - - return flex( - children: [ - SdBody(title), - if (description != null) SdCaption(description!), - ], - ); - } -} - -class _SummaryCardItemTheme extends StatelessWidget { - const _SummaryCardItemTheme({required this.theme}); - - final PresentationThemeDescriptor theme; - - @override - Widget build(BuildContext context) { - final flex = FlexBoxStyler() - .spacing(4) - .mainAxisAlignment(.start) - .crossAxisAlignment(.start) - .column(); - - final colorRow = FlexBoxStyler().paddingY(5).row(); - - final recipe = theme.recipe; - - return flex( - children: [ - SdBody(theme.title), - colorRow( - children: recipe.palette.previewColors - .map((color) => SdColorCircle(color: hexToColor(color))) - .toList(), - ), - SdCaption('${recipe.headlineFamily} + ${recipe.bodyFamily}'), - ], - ); - } -} - -/// Widget for displaying image style summary items. -class _SummaryCardItemImageStyle extends StatelessWidget { - final ImageStyle imageStyle; - - const _SummaryCardItemImageStyle({required this.imageStyle}); - - @override - Widget build(BuildContext context) { - final flex = FlexBoxStyler() - .spacing(4) - .mainAxisAlignment(.start) - .crossAxisAlignment(.start) - .column(); - - // Show style name and description from enum - return flex( - children: [SdBody(imageStyle.title), SdCaption(imageStyle.description)], - ); - } -} diff --git a/packages/playground/lib/features/ai/wizard/core/ai/schemas/user_action_payload.dart b/packages/playground/lib/features/ai/wizard/core/ai/schemas/user_action_payload.dart new file mode 100644 index 00000000..10191e3a --- /dev/null +++ b/packages/playground/lib/features/ai/wizard/core/ai/schemas/user_action_payload.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; + +/// Typed parser for user actions emitted by GenUI surfaces. +final class UserActionPayload { + const UserActionPayload({required this.actionName, required this.context}); + + final String actionName; + final Map context; + + /// Returns `null` when the payload is malformed or lacks an action name. + static UserActionPayload? tryParse(String jsonString) { + try { + final json = jsonDecode(jsonString); + if (json is! Map) return null; + + final userAction = json['action'] ?? json['userAction']; + if (userAction is! Map) return null; + + final name = userAction['name']; + if (name is! String) return null; + + final context = userAction['context']; + return UserActionPayload( + actionName: name, + context: context is Map + ? context + : {}, + ); + } on FormatException { + return null; + } + } +} diff --git a/packages/playground/lib/features/ai/wizard/core/ai/schemas/wizard_context_keys.dart b/packages/playground/lib/features/ai/wizard/core/ai/schemas/wizard_context_keys.dart index d8c25607..4bc3736a 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/schemas/wizard_context_keys.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/schemas/wizard_context_keys.dart @@ -1,13 +1,11 @@ /// Standardized context keys for the wizard workflow. /// /// All catalog components should use these keys when writing to the -/// wizard context. This ensures consistent extraction in SummaryCard -/// and generation context building. +/// wizard context. This keeps generation context building consistent. /// /// Usage: /// ```dart /// resolvedContext[WizardContextKeys.topic] = userTopic; -/// resolvedContext[WizardContextKeys.message] = displayMessage; /// ``` abstract final class WizardContextKeys { // Core wizard step keys @@ -60,56 +58,9 @@ abstract final class WizardContextKeys { // Common keys used across components - /// Display message shown in chat bubble after selection. - static const message = 'message'; - /// Generic title field for selections. static const title = 'title'; /// Generic description field for selections. static const description = 'description'; - - /// Selected options list (used by checkbox components). - static const selectedOptions = 'selectedOptions'; - - /// Maps normalized label strings to their context keys. - /// - /// Used by SummaryCard to extract context from AI-provided labels. - /// Returns null for unrecognized labels. - static String? labelToKey(String label) { - final normalized = label.toLowerCase().trim(); - return _labelMap[normalized]; - } - - static const _labelMap = { - // Topic variations - 'topic': topic, - 'presentation topic': topic, - 'subject': topic, - // Audience variations - 'audience': audience, - 'target audience': audience, - // Approach variations - 'approach': approach, - 'presentation approach': approach, - 'format': approach, - // Emphasis variations - 'emphasis': emphasis, - 'emphases': emphasis, - 'key areas': emphasis, - 'focus areas': emphasis, - // Slide count variations - 'slide count': slideCount, - 'slides': slideCount, - 'number of slides': slideCount, - 'total slides': slideCount, - // Style variations - 'style': style, - 'visual style': style, - 'design style': style, - 'theme': style, - // Image style variations - 'image style': imageStyleName, - 'visual direction': imageStyleName, - }; } diff --git a/packages/playground/lib/features/ai/wizard/core/ai/services/ai_conversation_viewmodel.dart b/packages/playground/lib/features/ai/wizard/core/ai/services/ai_conversation_viewmodel.dart index 13b198ac..ae25dbea 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/services/ai_conversation_viewmodel.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/services/ai_conversation_viewmodel.dart @@ -1,11 +1,14 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:genui/genui.dart' as genui; import 'package:signals/signals_flutter.dart'; import '../../../../quick_agent/core/constants/gemini_models.dart'; -import '../../../chat/chat_message.dart'; +import '../../../../../../core/domain/design/presentation_theme_catalog.dart'; +import '../schemas/user_action_payload.dart'; +import '../wizard_session_state.dart'; import '../../debug_logger.dart'; import '../../viewmodel_scope.dart'; import 'ai_conversation_profile.dart'; @@ -32,16 +35,37 @@ MissingSurfaceAction decideMissingSurfaceAction({ : MissingSurfaceAction.showError; } +@visibleForTesting +String? expectedWizardComponentType(WizardStep step) => switch (step) { + WizardStep.audience || WizardStep.approach => 'AskUserRadio', + WizardStep.emphasis => 'AskUserCheckbox', + WizardStep.slideCount => 'AskUserSlider', + WizardStep.theme => 'AskUserStyle', + WizardStep.topic || WizardStep.review => null, +}; + +@visibleForTesting +bool isExpectedWizardSurface( + genui.SurfaceDefinition definition, + WizardStep step, +) { + final expectedType = expectedWizardComponentType(step); + return expectedType != null && + definition.components['root']?.type == expectedType; +} + final class AiConversationViewModel extends ChangeNotifier implements Disposable { AiConversationViewModel({ required AiConversationProfile profile, @visibleForTesting SuperdeckTransportFactory? transportFactory, + @visibleForTesting String? apiKey, SuperdeckAgentClientFactory agentClientFactory = DartanticSuperdeckAgentClient.new, - }) { + }) : themeCatalog = profile.themeCatalog { _session = GenUiConversationSession( profile: profile, + apiKey: apiKey, transportFactory: transportFactory, agentClientFactory: agentClientFactory, handlers: ConversationSessionHandlers( @@ -58,49 +82,34 @@ final class AiConversationViewModel extends ChangeNotifier /// The Wizard favors the current Flash Lite model for short, structured /// surface turns. There is intentionally no in-wizard model picker. static const _modelName = GeminiModelNames.gemini31FlashLite; - static const _missingSurfaceRecoveryPrompt = ''' -The previous response did not include a renderable Wizard surface. Continue -from the current Wizard state and return exactly one valid surface for the next -unanswered step. Use the registered catalog ID, include a submit control, and -do not answer with text only. -'''; static const _missingSurfaceError = 'I couldn\'t prepare the next step. Add a detail below to try again.'; + final PresentationThemeCatalog themeCatalog; + final surfaceIds = Signal>([]); final _controller = Signal(null); - final debugMode = Signal(false); - final showChat = Signal(true); - final Signal> _messages = signal([]); final _isProcessing = Signal(false); late final GenUiConversationSession _session; DateTime? _lastRequestTime; - String _streamingAiResponse = ''; - int? _streamingAiMessageIndex; String? _errorMessage; + String? _surfaceRecoveryReason; var _surfaceRecoveryAttempts = 0; var _requestProducedSurface = false; var _disposed = false; + var _wizardState = WizardSessionState.initial(); genui.SurfaceController? get controller => _controller.value; String? get errorMessage => _errorMessage; + WizardSessionState get wizardState => _wizardState; + late final Computed isThinking = computed(() { return _isProcessing.value; }); - late final Computed> messages = computed(() { - if (debugMode.value) { - return _messages.value; - } - - return _messages.value.where((e) { - return e is SuperdeckUserMessage || e is SuperdeckAiMessage; - }).toList(); - }); - late final Computed hasConversationStarted = computed( () => _controller.value != null, ); @@ -117,7 +126,6 @@ do not answer with text only. final message = result.message; if (message != null && !_disposed) { - _messages.add(SuperdeckAiMessage(message)); _errorMessage = message; _notifyView(); } @@ -126,22 +134,50 @@ do not answer with text only. Future sendMessage(String raw) async { final message = raw.trim(); - if (message.isEmpty || _disposed) return; + if (message.isEmpty || _disposed || _isProcessing.value) return; + + _isProcessing.value = true; + _notifyView(); debugLog.userAction('SEND_MESSAGE', {'message': message}); debugLog.section('New Message'); - if (!hasConversationStarted.value) { + final isFirstTurn = !hasConversationStarted.value; + WizardSessionState? topicState; + if (isFirstTurn) { + topicState = _wizardState.startTopic(message); + if (topicState == null) { + _isProcessing.value = false; + _notifyView(); + return; + } debugLog.log('CONV', 'Building new conversation'); final ok = await ensureConversationStarted(); - if (!ok) return; + if (!ok) { + _isProcessing.value = false; + _notifyView(); + return; + } + _wizardState = topicState; } - if (!_session.hasActiveSession) return; + if (!_session.hasActiveSession || _wizardState.step == WizardStep.review) { + _isProcessing.value = false; + _notifyView(); + return; + } _surfaceRecoveryAttempts = 0; - _messages.add(SuperdeckUserMessage(message)); - await _enqueueRequest(genui.ChatMessage.user(message)); + await _enqueueRequest( + genui.ChatMessage.user( + buildWizardTurnPrompt( + userInput: isFirstTurn + ? 'Presentation topic: $message' + : 'User request for the current step: $message', + state: _wizardState, + ), + ), + ); } void restartConversation() { @@ -149,12 +185,11 @@ do not answer with text only. _session.restart(); _controller.value = null; _isProcessing.value = false; - _streamingAiResponse = ''; - _streamingAiMessageIndex = null; _errorMessage = null; + _surfaceRecoveryReason = null; _surfaceRecoveryAttempts = 0; _requestProducedSurface = false; - _messages.value = []; + _wizardState = WizardSessionState.initial(); surfaceIds.value = []; _notifyView(); } @@ -167,12 +202,8 @@ do not answer with text only. surfaceIds.dispose(); _controller.dispose(); - debugMode.dispose(); - showChat.dispose(); - _messages.dispose(); _isProcessing.dispose(); isThinking.dispose(); - messages.dispose(); hasConversationStarted.dispose(); super.dispose(); } @@ -183,8 +214,6 @@ do not answer with text only. void _handleRequestStarted() { _lastRequestTime = DateTime.now(); - _streamingAiResponse = ''; - _streamingAiMessageIndex = null; _errorMessage = null; _requestProducedSurface = false; _isProcessing.value = true; @@ -206,15 +235,14 @@ do not answer with text only. )) { case MissingSurfaceAction.recover: _surfaceRecoveryAttempts++; + final recoveryPrompt = _buildSurfaceRecoveryPrompt(); + _surfaceRecoveryReason = null; debugLog.log( 'CONV', - 'Response had no Wizard surface; requesting one recovery turn', - ); - unawaited( - _enqueueRequest( - genui.ChatMessage.user(_missingSurfaceRecoveryPrompt), - ), + 'Response did not produce the expected Wizard surface; ' + 'requesting one recovery turn', ); + unawaited(_enqueueRequest(genui.ChatMessage.user(recoveryPrompt))); return; case MissingSurfaceAction.showError: _errorMessage = _missingSurfaceError; @@ -228,93 +256,155 @@ do not answer with text only. } void _handleUiSubmit(genui.ChatMessage message) { + if (_isProcessing.value) { + debugLog.log('USER', 'Ignored a duplicate Wizard interaction.'); + return; + } _surfaceRecoveryAttempts = 0; final interactionParts = message.parts.uiInteractionParts.toList(); - if (interactionParts.isEmpty) { - debugLog.log('USER', 'Received submit message without interaction part'); - _addDebugMessage('Received unexpected action format'); + if (interactionParts.length != 1) { + _rejectUiAction('Expected exactly one Wizard interaction.'); + return; } - for (final part in interactionParts) { - final rawJson = part.interaction; - final parsed = UserActionPayload.tryParse(rawJson); - if (parsed == null) { - debugLog.log('USER', 'Failed to parse user action: $rawJson'); - _addDebugMessage('Received unexpected action format'); - } else { - _messages.add(SuperdeckUserMessage(parsed.displayMessage)); - } - _addJsonDebugMessage(rawJson); - debugLog.userAction('UI_ACTION', parsed?.context ?? {'raw': rawJson}); + final rawJson = interactionParts.single.interaction; + final parsed = UserActionPayload.tryParse(rawJson); + if (parsed == null || parsed.actionName != 'submit_answer') { + _rejectUiAction('Received an invalid Wizard action.'); + return; + } + + final previousStep = _wizardState.step; + final nextState = _wizardState.advance(parsed.context); + if (nextState == null) { + _rejectUiAction( + 'That selection does not match the current ${previousStep.name} step.', + ); + return; + } + + _wizardState = nextState; + debugLog.userAction('UI_ACTION', parsed.context); + + if (nextState.step == WizardStep.review) { + _notifyView(); + return; } + _isProcessing.value = true; + _notifyView(); + + unawaited( + _enqueueRequest( + genui.ChatMessage.user( + buildWizardTurnPrompt( + userInput: + 'The user completed ${previousStep.name} with ' + '${jsonEncode(parsed.context)}.', + state: nextState, + ), + ), + ), + ); + } + + void _rejectUiAction(String reason) { + debugLog.log('USER', reason); + _errorMessage = 'That choice could not be applied. Please try again.'; _notifyView(); - unawaited(_enqueueRequest(message)); } void _handleTextResponse(String value) { _logElapsed('TEXT_RESPONSE received'); debugLog.aiResponse('TEXT', value); - _streamingAiResponse = mergeFinalOutputSegments( - _streamingAiResponse, - value, - ); - - final next = [..._messages.value]; - final index = _streamingAiMessageIndex; - if (index != null && index >= 0 && index < next.length) { - next[index] = SuperdeckAiMessage(_streamingAiResponse); - } else { - _streamingAiMessageIndex = next.length; - next.add(SuperdeckAiMessage(_streamingAiResponse)); - } - _messages.value = next; - _notifyView(); } void _handleSurfaceUpdate(genui.SurfaceUpdate value) { switch (value) { - case genui.SurfaceAdded(:final surfaceId): - _handleSurfaceAdded(surfaceId); - case genui.ComponentsUpdated(:final surfaceId): - _handleSurfaceUpdated(surfaceId); + case genui.SurfaceAdded(:final surfaceId, :final definition): + _handleSurfaceAdded(surfaceId, definition); + case genui.ComponentsUpdated(:final surfaceId, :final definition): + _handleSurfaceUpdated(surfaceId, definition); case genui.SurfaceRemoved(:final surfaceId): _handleSurfaceDeleted(surfaceId); } } - void _handleSurfaceAdded(String surfaceId) { + void _handleSurfaceAdded( + String surfaceId, + genui.SurfaceDefinition definition, + ) { _logElapsed('SURFACE_ADDED: $surfaceId'); debugLog.surface('ADDED', surfaceId); + if (!_acceptSurfaceForCurrentStep(surfaceId, definition)) return; _requestProducedSurface = true; if (!surfaceIds.value.contains(surfaceId)) { _errorMessage = null; _surfaceRecoveryAttempts = 0; surfaceIds.value = [...surfaceIds.value, surfaceId]; - _addDebugMessage('Surface added: $surfaceId'); _notifyView(); } } - void _handleSurfaceUpdated(String surfaceId) { + void _handleSurfaceUpdated( + String surfaceId, + genui.SurfaceDefinition definition, + ) { _logElapsed('SURFACE_UPDATED: $surfaceId'); debugLog.surface('UPDATED', surfaceId); + if (!_acceptSurfaceForCurrentStep(surfaceId, definition)) return; _requestProducedSurface = true; _errorMessage = null; _surfaceRecoveryAttempts = 0; if (!surfaceIds.value.contains(surfaceId)) { surfaceIds.value = [...surfaceIds.value, surfaceId]; - _addDebugMessage('Surface added via update: $surfaceId'); } - _addDebugMessage('Surface updated: $surfaceId'); _notifyView(); } + bool _acceptSurfaceForCurrentStep( + String surfaceId, + genui.SurfaceDefinition definition, + ) { + if (isExpectedWizardSurface(definition, _wizardState.step)) { + _surfaceRecoveryReason = null; + return true; + } + + final expectedType = expectedWizardComponentType(_wizardState.step); + final actualType = definition.components['root']?.type; + final expectedLabel = expectedType ?? 'no generated component'; + final actualLabel = actualType ?? 'no root component'; + _surfaceRecoveryReason = + 'The previous response rendered $actualLabel, ' + 'but the canonical ${_wizardState.step.name} step requires ' + '$expectedLabel.'; + surfaceIds.value = surfaceIds.value.where((id) => id != surfaceId).toList(); + debugLog.log( + 'CONV', + 'Rejected mismatched Wizard surface: ' + 'expected=$expectedLabel, actual=$actualLabel', + ); + _notifyView(); + return false; + } + + String _buildSurfaceRecoveryPrompt() { + final reason = + _surfaceRecoveryReason ?? + 'The previous response did not include a renderable Wizard surface.'; + return buildWizardTurnPrompt( + userInput: + '$reason Return the registered component for the next unanswered ' + 'step with a submit action; do not answer with text.', + state: _wizardState, + ); + } + void _handleSurfaceDeleted(String surfaceId) { _logElapsed('SURFACE_DELETED: $surfaceId'); debugLog.surface('DELETED', surfaceId); surfaceIds.value = surfaceIds.value.where((id) => id != surfaceId).toList(); - _addDebugMessage('Surface deleted: $surfaceId'); _notifyView(); } @@ -322,9 +412,7 @@ do not answer with text only. _logElapsed('ERROR received'); debugLog.error('GenUI', _sanitizeError(error), stackTrace); _errorMessage = _getErrorMessage(error); - _messages.add(SuperdeckAiMessage(_errorMessage!)); _isProcessing.value = false; - _streamingAiMessageIndex = null; _notifyView(); } @@ -338,14 +426,6 @@ do not answer with text only. debugLog.log('TIMING', '$event at +${elapsed.inMilliseconds}ms'); } - void _addDebugMessage(String message) { - _messages.add(SuperdeckDebugMessage(message)); - } - - void _addJsonDebugMessage(String json) { - _messages.add(SuperdeckJsonDebugMessage(json)); - } - static const _errorClassifier = ErrorClassifier(); String _getErrorMessage(Object error) => @@ -357,19 +437,18 @@ do not answer with text only. } } -/// Joins complete final-output segments without collapsing their word boundary. -/// -/// The A2UI adapter can emit one final sentence as multiple decoded text -/// segments with leading whitespace removed. Punctuation still joins directly. @visibleForTesting -String mergeFinalOutputSegments(String existing, String next) { - if (existing.isEmpty || next.isEmpty) return '$existing$next'; - if (RegExp(r'\s$').hasMatch(existing) || RegExp(r'^\s').hasMatch(next)) { - return '$existing$next'; - } - const punctuation = ''',.;:!?)]}'"”’'''; - if (punctuation.contains(next[0])) { - return '$existing$next'; - } - return '$existing $next'; +String buildWizardTurnPrompt({ + required String userInput, + required WizardSessionState state, +}) { + return ''' +$userInput + +Canonical selections: ${jsonEncode(state.context.toMap())} +Expected next step: ${state.step.name} + +Generate exactly one ${state.step.name} surface. Keep every canonical selection +unchanged and emit surface messages only. +'''; } diff --git a/packages/playground/lib/features/ai/wizard/core/ai/services/genui_conversation_session.dart b/packages/playground/lib/features/ai/wizard/core/ai/services/genui_conversation_session.dart index 9a6b2c95..d34d05a2 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/services/genui_conversation_session.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/services/genui_conversation_session.dart @@ -86,6 +86,7 @@ final class GenUiConversationSession { final SuperdeckTransportFactory _transportFactory; final SuperdeckAgentClientFactory _agentClientFactory; final PresentationThemeCatalog _themeCatalog; + final String? _apiKey; genui.SurfaceController? _controller; SuperdeckA2uiTransport? _transport; @@ -101,6 +102,7 @@ final class GenUiConversationSession { GenUiConversationSession({ required AiConversationProfile profile, required ConversationSessionHandlers handlers, + String? apiKey, SuperdeckTransportFactory? transportFactory, SuperdeckAgentClientFactory agentClientFactory = DartanticSuperdeckAgentClient.new, @@ -108,7 +110,8 @@ final class GenUiConversationSession { _handlers = handlers, _transportFactory = transportFactory ?? SuperdeckA2uiTransport.new, _agentClientFactory = agentClientFactory, - _themeCatalog = profile.themeCatalog; + _themeCatalog = profile.themeCatalog, + _apiKey = apiKey; String _buildSystemPrompt(String systemInstruction) { final fragments = [ @@ -122,7 +125,6 @@ final class GenUiConversationSession { '${genui.PromptBuilder.defaultImportancePrefix}When creating a surface, ' 'the `catalogId` field MUST be exactly "$catalogId". ' 'Never use any other value.', - genui.PromptFragments.acknowledgeUser(), genui.PromptFragments.requireAtLeastOneSubmitElement( prefix: genui.PromptBuilder.defaultImportancePrefix, ), @@ -269,6 +271,7 @@ final class GenUiConversationSession { } String? _readApiKey() { + if (_apiKey case final apiKey?) return apiKey; try { return EnvConfig.geminiApiKey; } on StateError { @@ -400,9 +403,8 @@ String buildWizardThemeCatalogPrompt(PresentationThemeCatalog themeCatalog) { return ''' ## Registered presentation themes -Use only these exact IDs in `AskUserStyle.themeIds` and -`SummaryCard.items[].themeId`. The application owns all palette, typography, -spacing, component, and treatment tokens. +Use only these exact IDs in `AskUserStyle.themeIds`. The application owns all +palette, typography, spacing, component, and treatment tokens. ${const JsonEncoder.withIndent(' ').convert(candidates)} '''; diff --git a/packages/playground/lib/features/ai/wizard/core/ai/services/prompt_builder.dart b/packages/playground/lib/features/ai/wizard/core/ai/services/prompt_builder.dart index ecacf5d2..43553acd 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/services/prompt_builder.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/services/prompt_builder.dart @@ -3,7 +3,7 @@ import '../wizard_context.dart'; /// Builds a deck generation prompt from wizard context data. /// -/// Extracts exact user selections from the 8-step wizard workflow without +/// Extracts exact user selections from the Wizard workflow without /// flattening contractual fields into layout instructions. DeckGenerationRequest buildPromptFromWizardContext(WizardContext context) { return DeckGenerationRequest( diff --git a/packages/playground/lib/features/ai/wizard/core/ai/wizard_context.dart b/packages/playground/lib/features/ai/wizard/core/ai/wizard_context.dart index b4235b53..12a1185a 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/wizard_context.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/wizard_context.dart @@ -37,28 +37,6 @@ class WizardContext { this.imageStyleDescription, }); - /// Parse a [WizardContext] from a loose map. - factory WizardContext.fromMap(Map map) { - return WizardContext( - topic: _stringOrNull(map[WizardContextKeys.topic]), - audience: _stringOrNull(map[WizardContextKeys.audience]), - approach: _stringOrNull(map[WizardContextKeys.approach]), - emphasis: _stringListOrNull(map[WizardContextKeys.emphasis]), - slideCount: _intOrNull(map[WizardContextKeys.slideCount]), - themeId: _stringOrNull(map[WizardContextKeys.themeId]), - style: _stringOrNull(map[WizardContextKeys.style]), - density: _stringOrNull(map[WizardContextKeys.density]), - colors: _stringListOrNull(map[WizardContextKeys.colors]), - headlineFont: _stringOrNull(map[WizardContextKeys.headlineFont]), - bodyFont: _stringOrNull(map[WizardContextKeys.bodyFont]), - imageStyleId: _stringOrNull(map[WizardContextKeys.imageStyleId]), - imageStyleName: _stringOrNull(map[WizardContextKeys.imageStyleName]), - imageStyleDescription: _stringOrNull( - map[WizardContextKeys.imageStyleDescription], - ), - ); - } - WizardContext copyWith({ String? topic, String? audience, @@ -94,27 +72,6 @@ class WizardContext { ); } - /// Merge [other] into this context, preferring non-null values from [other]. - WizardContext merge(WizardContext other) { - return WizardContext( - topic: other.topic ?? topic, - audience: other.audience ?? audience, - approach: other.approach ?? approach, - emphasis: other.emphasis ?? emphasis, - slideCount: other.slideCount ?? slideCount, - themeId: other.themeId ?? themeId, - style: other.style ?? style, - density: other.density ?? density, - colors: other.colors ?? colors, - headlineFont: other.headlineFont ?? headlineFont, - bodyFont: other.bodyFont ?? bodyFont, - imageStyleId: other.imageStyleId ?? imageStyleId, - imageStyleName: other.imageStyleName ?? imageStyleName, - imageStyleDescription: - other.imageStyleDescription ?? imageStyleDescription, - ); - } - /// Convert to a map using [WizardContextKeys]. Map toMap() { final map = {}; @@ -144,36 +101,3 @@ class WizardContext { return map; } } - -String? _stringOrNull(Object? value) { - if (value == null) return null; - final text = value.toString().trim(); - return text.isEmpty ? null : text; -} - -List? _stringListOrNull(Object? value) { - if (value == null) return null; - if (value is List) { - final items = value - .map((item) => _stringOrNull(item)) - .whereType() - .toList(); - return items.isEmpty ? null : items; - } - final text = _stringOrNull(value); - if (text == null) return null; - return [text]; -} - -int? _intOrNull(Object? value) { - if (value is int) return value > 0 ? value : null; - if (value is num) { - final i = value.toInt(); - return i > 0 ? i : null; - } - if (value is String) { - final parsed = int.tryParse(value.trim()); - if (parsed != null && parsed > 0) return parsed; - } - return null; -} diff --git a/packages/playground/lib/features/ai/wizard/core/ai/wizard_session_state.dart b/packages/playground/lib/features/ai/wizard/core/ai/wizard_session_state.dart new file mode 100644 index 00000000..f59cdc37 --- /dev/null +++ b/packages/playground/lib/features/ai/wizard/core/ai/wizard_session_state.dart @@ -0,0 +1,122 @@ +import 'wizard_context.dart'; + +enum WizardStep { + topic, + audience, + approach, + emphasis, + slideCount, + theme, + review, +} + +/// Canonical, application-owned state for the booth Wizard. +/// +/// GenUI chooses how each question is presented, while this state owns the +/// accepted values and the only valid order in which the workflow can advance. +final class WizardSessionState { + const WizardSessionState._({required this.step, required this.context}); + + factory WizardSessionState.initial() { + return const WizardSessionState._( + step: WizardStep.topic, + context: WizardContext(), + ); + } + + final WizardStep step; + final WizardContext context; + + bool get isReviewReady { + return step == WizardStep.review && + context.topic != null && + context.audience != null && + context.approach != null && + context.emphasis?.isNotEmpty == true && + context.slideCount != null && + context.themeId != null; + } + + WizardSessionState? startTopic(String rawTopic) { + if (step != WizardStep.topic) return null; + final topic = _nonEmptyString(rawTopic); + if (topic == null) return null; + + return WizardSessionState._( + step: WizardStep.audience, + context: context.copyWith(topic: topic), + ); + } + + WizardSessionState? advance(Map actionContext) { + switch (step) { + case WizardStep.topic: + case WizardStep.review: + return null; + case WizardStep.audience: + return _advanceSingleChoice( + actionContext, + nextStep: WizardStep.approach, + update: (value) => context.copyWith(audience: value), + ); + case WizardStep.approach: + return _advanceSingleChoice( + actionContext, + nextStep: WizardStep.emphasis, + update: (value) => context.copyWith(approach: value), + ); + case WizardStep.emphasis: + final emphasis = _stringList(actionContext['selectedOptions']); + if (emphasis == null || emphasis.length > 3) return null; + return WizardSessionState._( + step: WizardStep.slideCount, + context: context.copyWith(emphasis: emphasis), + ); + case WizardStep.slideCount: + final slideCount = _integer(actionContext['value']); + if (slideCount == null || slideCount < 5 || slideCount > 20) { + return null; + } + return WizardSessionState._( + step: WizardStep.theme, + context: context.copyWith(slideCount: slideCount), + ); + case WizardStep.theme: + final themeId = _nonEmptyString(actionContext['themeId']); + if (themeId == null) return null; + return WizardSessionState._( + step: WizardStep.review, + context: context.copyWith(themeId: themeId), + ); + } + } + + WizardSessionState? _advanceSingleChoice( + Map actionContext, { + required WizardStep nextStep, + required WizardContext Function(String value) update, + }) { + final value = _nonEmptyString(actionContext['selectedOption']); + if (value == null) return null; + return WizardSessionState._(step: nextStep, context: update(value)); + } +} + +String? _nonEmptyString(Object? value) { + if (value is! String) return null; + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; +} + +List? _stringList(Object? value) { + if (value is! List) return null; + final items = value.map(_nonEmptyString).whereType().toList(); + if (items.isEmpty || items.length != value.length) return null; + return List.unmodifiable(items); +} + +int? _integer(Object? value) { + if (value is int) return value; + if (value is num && value == value.roundToDouble()) return value.toInt(); + return null; +} diff --git a/packages/playground/lib/features/ai/wizard/core/ui/ui.dart b/packages/playground/lib/features/ai/wizard/core/ui/ui.dart index 74605ca4..3ef6760c 100644 --- a/packages/playground/lib/features/ai/wizard/core/ui/ui.dart +++ b/packages/playground/lib/features/ai/wizard/core/ui/ui.dart @@ -1,4 +1,3 @@ export 'components/sd_components.dart'; export 'widgets/catalog_next_button.dart'; export 'widgets/header.dart'; -export 'widgets/wizard_loading_state.dart'; diff --git a/packages/playground/lib/features/ai/wizard/core/ui/widgets/wizard_loading_state.dart b/packages/playground/lib/features/ai/wizard/core/ui/widgets/wizard_loading_state.dart deleted file mode 100644 index 0ac6d7fc..00000000 --- a/packages/playground/lib/features/ai/wizard/core/ui/widgets/wizard_loading_state.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter/widgets.dart'; - -/// InheritedWidget that provides loading state to wizard card components. -/// -/// This allows catalog cards to show loading indicators on their Next buttons -/// when the AI is processing a response. -class WizardLoadingState extends InheritedWidget { - final bool isLoading; - - const WizardLoadingState({ - super.key, - required this.isLoading, - required super.child, - }); - - @override - bool updateShouldNotify(WizardLoadingState oldWidget) { - return isLoading != oldWidget.isLoading; - } -} diff --git a/packages/playground/lib/features/ai/wizard/core/viewmodel_scope.dart b/packages/playground/lib/features/ai/wizard/core/viewmodel_scope.dart index a68d3461..36a2add2 100644 --- a/packages/playground/lib/features/ai/wizard/core/viewmodel_scope.dart +++ b/packages/playground/lib/features/ai/wizard/core/viewmodel_scope.dart @@ -63,9 +63,3 @@ class _ViewModelInherited extends InheritedWidget { @override bool updateShouldNotify(_ViewModelInherited oldWidget) => false; } - -/// Extension to mirror Provider's context.read API. -extension ViewModelScopeExtension on BuildContext { - /// Retrieves the ViewModel of type [T] without listening to changes. - T read() => ViewModelScope.of(this); -} diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_controller.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_controller.dart new file mode 100644 index 00000000..aec7b626 --- /dev/null +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_controller.dart @@ -0,0 +1,440 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../quick_agent/core/engine/schemas/outline_schema.dart'; +import '../../quick_agent/core/engine/services/deck_generation_request.dart'; +import '../../quick_agent/core/engine/services/deck_generator_service.dart'; +import '../../quick_agent/core/engine/services/deck_plan_validator.dart'; +import '../../quick_agent/core/engine/services/generation_progress.dart'; +import '../../quick_agent/core/engine/services/generation_validation_issue.dart'; + +enum WizardGenerationStage { + setup, + planning, + outlineReview, + composing, + completed, + failed, +} + +enum WizardGenerationPhase { planning, composition } + +typedef ApplyWizardDeckResult = void Function(DeckGenerationResult result); + +/// Owns the deterministic plan → review → compose lifecycle for the Wizard. +final class WizardGenerationController extends ChangeNotifier { + WizardGenerationController({ + required DeckGeneratorService service, + required ApplyWizardDeckResult applyResult, + }) : _service = service, + _applyResult = applyResult; + + final DeckGeneratorService _service; + final ApplyWizardDeckResult _applyResult; + + static const generationBudget = Duration(seconds: 30); + + WizardGenerationStage _stage = WizardGenerationStage.setup; + WizardGenerationPhase? _failedPhase; + DeckGenerationRequest? _request; + DeckPlanType? _plan; + DeckGenerationResult? _result; + String? _errorMessage; + GenerationProgress _progress = const GenerationProgress(GenerationPhase.idle); + Duration _elapsed = Duration.zero; + Duration _budgetElapsed = Duration.zero; + DateTime? _stageStartedAt; + var _cancelled = false; + var _disposed = false; + var _operationEpoch = 0; + var _planRevision = 0; + + WizardGenerationStage get stage => _stage; + WizardGenerationPhase? get failedPhase => _failedPhase; + DeckPlanType? get plan => _plan; + int get planRevision => _planRevision; + DeckGenerationResult? get result => _result; + String? get errorMessage => _errorMessage; + GenerationProgress get progress => _progress; + Duration get elapsed => + _elapsed + + (_stageStartedAt == null + ? Duration.zero + : DateTime.now().difference(_stageStartedAt!)); + bool get isBusy => + _stage == WizardGenerationStage.planning || + _stage == WizardGenerationStage.composing; + + Future createOutline(DeckGenerationRequest request) async { + await _createOutline(request, preserveCurrentPlan: false); + } + + Future _createOutline( + DeckGenerationRequest request, { + required bool preserveCurrentPlan, + }) async { + if (isBusy || _disposed) return; + + final operation = ++_operationEpoch; + _request = request; + if (!preserveCurrentPlan) { + _plan = null; + _planRevision = 0; + _elapsed = Duration.zero; + } + _budgetElapsed = Duration.zero; + _result = null; + _errorMessage = null; + _failedPhase = null; + _cancelled = false; + _beginStage(WizardGenerationStage.planning); + _progress = const GenerationProgress(GenerationPhase.generatingOutline); + _notify(); + + final DeckPlanningResult planning; + try { + planning = await _service + .plan( + request, + onProgress: (progress) => _setProgress(operation, progress), + isCancelled: () => _isOperationCancelled(operation), + ) + .timeout(_remainingBudget); + } on TimeoutException { + _finishTiming(); + if (!_isCurrentOperation(operation)) return; + _cancelled = true; + _fail( + WizardGenerationPhase.planning, + 'Outline creation exceeded the 30-second demo budget. Try again.', + ); + return; + } catch (error) { + _finishTiming(); + if (!_isCurrentOperation(operation)) return; + _fail( + WizardGenerationPhase.planning, + 'Could not create the outline: $error', + ); + return; + } + _finishTiming(); + if (!_isCurrentOperation(operation) || _disposed) return; + if (_cancelled) return; + if (!planning.success || planning.plan == null) { + _fail( + WizardGenerationPhase.planning, + planning.error ?? 'Could not create the outline.', + ); + return; + } + + _plan = planning.plan; + _planRevision++; + _stage = WizardGenerationStage.outlineReview; + _progress = const GenerationProgress(GenerationPhase.idle); + _notify(); + } + + Future regenerateOutline() async { + final currentRequest = _request; + if (currentRequest == null) return; + await _createOutline(currentRequest, preserveCurrentPlan: true); + } + + bool updateSlide( + int index, { + required String title, + required String assertion, + }) { + final currentPlan = _plan; + if (_stage != WizardGenerationStage.outlineReview || currentPlan == null) { + return false; + } + final nextTitle = title.trim(); + final nextAssertion = assertion.trim(); + if (nextTitle.isEmpty || nextAssertion.isEmpty) return false; + if (index < 0 || index >= currentPlan.slides.length) return false; + + final data = Map.of(currentPlan); + final slides = [ + for (final slide in currentPlan.slides) Map.of(slide), + ]; + slides[index] + ..['title'] = nextTitle + ..['assertion'] = nextAssertion; + data['slides'] = slides; + + final candidate = DeckPlanType.parse(data); + final blockingIssues = validateDeckPlanIssues( + candidate, + typographyCatalog: _service.typographyCatalog, + themeCatalog: _service.themeCatalog, + request: _request, + ).blockingIssues; + if (blockingIssues.isNotEmpty) return false; + + _plan = candidate; + _notify(); + return true; + } + + Future generateSlides() async { + final currentRequest = _request; + final currentPlan = _plan; + if (_stage != WizardGenerationStage.outlineReview || + currentRequest == null || + currentPlan == null || + _disposed) { + return; + } + + final operation = ++_operationEpoch; + _result = null; + _errorMessage = null; + _failedPhase = null; + _cancelled = false; + _beginStage(WizardGenerationStage.composing); + _progress = const GenerationProgress(GenerationPhase.composingSlides); + _notify(); + + final DeckGenerationResult generated; + try { + generated = await _service + .generateFromPlan( + currentRequest, + currentPlan, + onProgress: (progress) => _setProgress(operation, progress), + isCancelled: () => _isOperationCancelled(operation), + ) + .timeout(_remainingBudget); + } on TimeoutException { + _finishTiming(); + if (!_isCurrentOperation(operation)) return; + _cancelled = true; + _fail( + WizardGenerationPhase.composition, + 'Slide generation exceeded the 30-second demo budget. Try again.', + ); + return; + } catch (error) { + _finishTiming(); + if (!_isCurrentOperation(operation)) return; + _fail( + WizardGenerationPhase.composition, + 'Could not compose the slides: $error', + ); + return; + } + _finishTiming(); + if (!_isCurrentOperation(operation) || _disposed || _cancelled) return; + if ((!generated.success && !generated.isPartial) || + generated.slides.isEmpty) { + _fail( + WizardGenerationPhase.composition, + generated.error ?? 'Could not compose the slides.', + ); + return; + } + + _completeComposition(generated); + } + + Future retryFailedSlides() async { + final currentRequest = _request; + final currentResult = _result; + if (_stage != WizardGenerationStage.completed || + currentRequest == null || + currentResult == null || + !currentResult.isPartial || + _disposed) { + return; + } + + final operation = ++_operationEpoch; + _errorMessage = null; + _failedPhase = null; + _cancelled = false; + _budgetElapsed = Duration.zero; + _beginStage(WizardGenerationStage.composing); + _progress = const GenerationProgress(GenerationPhase.composingSlides); + _notify(); + + final DeckGenerationResult generated; + try { + generated = await _service + .retryFailedSlides( + currentRequest, + currentResult, + onProgress: (progress) => _setProgress(operation, progress), + isCancelled: () => _isOperationCancelled(operation), + ) + .timeout(_remainingBudget); + } on TimeoutException { + _finishTiming(); + if (!_isCurrentOperation(operation)) return; + _cancelled = true; + _fail( + WizardGenerationPhase.composition, + 'Slide retry exceeded the 30-second demo budget. Try again.', + ); + return; + } catch (error) { + _finishTiming(); + if (!_isCurrentOperation(operation)) return; + _fail( + WizardGenerationPhase.composition, + 'Could not retry the unresolved slides: $error', + ); + return; + } + _finishTiming(); + if (!_isCurrentOperation(operation) || _disposed || _cancelled) return; + if ((!generated.success && !generated.isPartial) || + generated.slides.isEmpty) { + _fail( + WizardGenerationPhase.composition, + generated.error ?? 'Could not retry the unresolved slides.', + ); + return; + } + + _completeComposition(generated); + } + + Future retry() async { + switch (_failedPhase) { + case WizardGenerationPhase.planning: + await regenerateOutline(); + case WizardGenerationPhase.composition: + if (_result?.isPartial == true) { + _stage = WizardGenerationStage.completed; + await retryFailedSlides(); + } else { + returnToOutline(); + await generateSlides(); + } + case null: + return; + } + } + + void returnToOutline() { + if (_plan == null || _stage != WizardGenerationStage.failed) { + return; + } + _errorMessage = null; + _failedPhase = null; + _budgetElapsed = Duration.zero; + _stage = WizardGenerationStage.outlineReview; + _progress = const GenerationProgress(GenerationPhase.idle); + _notify(); + } + + void editOutline() { + if (_plan == null || _stage != WizardGenerationStage.completed) return; + _budgetElapsed = Duration.zero; + _stage = WizardGenerationStage.outlineReview; + _notify(); + } + + void reset() { + _operationEpoch++; + _cancelled = true; + _request = null; + _plan = null; + _planRevision = 0; + _result = null; + _errorMessage = null; + _failedPhase = null; + _progress = const GenerationProgress(GenerationPhase.idle); + _elapsed = Duration.zero; + _budgetElapsed = Duration.zero; + _stageStartedAt = null; + _stage = WizardGenerationStage.setup; + _notify(); + } + + void cancel() { + if (!isBusy || _cancelled) return; + _cancelled = true; + _operationEpoch++; + _finishTiming(); + _stage = _result?.isPartial == true + ? WizardGenerationStage.completed + : _plan == null + ? WizardGenerationStage.setup + : WizardGenerationStage.outlineReview; + _progress = const GenerationProgress(GenerationPhase.idle); + _notify(); + } + + void _beginStage(WizardGenerationStage stage) { + _stage = stage; + _stageStartedAt = DateTime.now(); + } + + void _finishTiming() { + final startedAt = _stageStartedAt; + if (startedAt != null) { + final duration = DateTime.now().difference(startedAt); + _elapsed += duration; + _budgetElapsed += duration; + } + _stageStartedAt = null; + } + + Duration get _remainingBudget { + final remaining = generationBudget - _budgetElapsed; + return remaining > Duration.zero + ? remaining + : const Duration(microseconds: 1); + } + + bool _isCurrentOperation(int operation) => operation == _operationEpoch; + + bool _isOperationCancelled(int operation) => + _cancelled || !_isCurrentOperation(operation); + + void _setProgress(int operation, GenerationProgress progress) { + if (_isOperationCancelled(operation) || _disposed) return; + _progress = progress; + _notify(); + } + + void _fail(WizardGenerationPhase phase, String message) { + _failedPhase = phase; + _errorMessage = message; + _stage = WizardGenerationStage.failed; + _progress = const GenerationProgress(GenerationPhase.idle); + _notify(); + } + + void _completeComposition(DeckGenerationResult generated) { + try { + _applyResult(generated); + } catch (error) { + _fail( + WizardGenerationPhase.composition, + 'Slides were generated but could not be loaded: $error', + ); + return; + } + _result = generated; + _stage = WizardGenerationStage.completed; + _progress = const GenerationProgress(GenerationPhase.idle); + _notify(); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _cancelled = true; + _disposed = true; + super.dispose(); + } +} diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart index 1f6c35f4..f6faf3da 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart @@ -8,7 +8,7 @@ import '../core/ui/ui.dart'; enum WizardGenerationStatusKind { running, completed, failed } -/// Focused, full-screen status content for the terminal generation step. +/// Focused, full-screen feedback for generation progress and outcomes. class WizardGenerationStatus extends StatelessWidget { const WizardGenerationStatus({ super.key, @@ -16,14 +16,36 @@ class WizardGenerationStatus extends StatelessWidget { this.progress = const GenerationProgress(GenerationPhase.idle), this.errorMessage, this.noticeMessage, - this.onDismiss, + this.slideCount, + this.failedSlideCount = 0, + this.elapsed, + this.planAvailable = false, + this.onCancel, + this.onRetry, + this.onBack, + this.backLabel = 'Edit outline', + this.onPresent, + this.onRetryFailed, + this.onEditOutline, + this.onStartOver, }); final WizardGenerationStatusKind kind; final GenerationProgress progress; final String? errorMessage; final String? noticeMessage; - final VoidCallback? onDismiss; + final int? slideCount; + final int failedSlideCount; + final Duration? elapsed; + final bool planAvailable; + final VoidCallback? onCancel; + final VoidCallback? onRetry; + final VoidCallback? onBack; + final String backLabel; + final VoidCallback? onPresent; + final VoidCallback? onRetryFailed; + final VoidCallback? onEditOutline; + final VoidCallback? onStartOver; @override Widget build(BuildContext context) { @@ -36,20 +58,30 @@ class WizardGenerationStatus extends StatelessWidget { final (:icon, :title, :description) = switch (kind) { WizardGenerationStatusKind.running => ( icon: LucideIcons.sparkles, - title: 'Building your presentation', - description: - 'Shaping the story, composing each slide, and checking the final deck.', + title: progress.phase == GenerationPhase.generatingOutline + ? 'Creating your outline' + : 'Building your presentation', + description: progress.phase == GenerationPhase.generatingOutline + ? 'Shaping a clear story before you review and approve it.' + : 'Composing the approved story and checking each slide.', ), WizardGenerationStatusKind.completed => ( icon: LucideIcons.circleCheck, - title: 'Your presentation is ready', - description: 'The generated slides are ready in this session.', + title: failedSlideCount > 0 + ? 'Your presentation is almost ready' + : 'Your presentation is ready', + description: failedSlideCount > 0 + ? 'Keep the finished slides and retry only the ones that need attention.' + : 'Open the deck now, or return to the outline for changes.', ), WizardGenerationStatusKind.failed => ( icon: LucideIcons.triangleAlert, - title: 'We couldn\'t finish the deck', - description: - 'Your plan is still here, so you can return and try again.', + title: planAvailable + ? 'We couldn\'t finish the deck' + : 'We couldn\'t create the outline', + description: planAvailable + ? 'Your plan is still here, so you can return and try again.' + : 'Your setup is still here, so you can retry or make a new selection.', ), }; @@ -89,6 +121,7 @@ class WizardGenerationStatus extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(999), child: LinearProgressIndicator( + value: _progressValue(progress), minHeight: 5, color: $accent.resolve(context), backgroundColor: $border.resolve(context), @@ -96,7 +129,26 @@ class WizardGenerationStatus extends StatelessWidget { ), ), _GenerationStages(progress: progress), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + spacing: 16, + children: [ + const SdCaption('Usually 20–30 seconds'), + TextButton.icon( + onPressed: onCancel, + icon: const Icon(LucideIcons.x, size: 16), + label: const Text('Cancel'), + ), + ], + ), ], + if (kind == WizardGenerationStatusKind.completed && + slideCount != null && + elapsed != null) + SdTitle( + '$slideCount ${slideCount == 1 ? 'slide' : 'slides'} • ' + '${elapsed!.inSeconds}s', + ), if (highlightedMessage != null) Container( width: double.infinity, @@ -110,14 +162,49 @@ class WizardGenerationStatus extends StatelessWidget { style: TextStyler().color(highlightedColor), ), ), - if (kind != WizardGenerationStatusKind.running) - Align( - alignment: Alignment.centerRight, - child: SdButton( - label: 'Back to deck plan', - icon: LucideIcons.arrowLeft, - onPressed: onDismiss, - ), + if (kind == WizardGenerationStatusKind.failed) + Wrap( + alignment: WrapAlignment.end, + spacing: 10, + runSpacing: 10, + children: [ + TextButton(onPressed: onBack, child: Text(backLabel)), + SdButton( + label: 'Retry', + icon: LucideIcons.refreshCw, + onPressed: onRetry, + ), + ], + ), + if (kind == WizardGenerationStatusKind.completed) + Wrap( + alignment: WrapAlignment.end, + spacing: 10, + runSpacing: 10, + children: [ + TextButton( + onPressed: onStartOver, + child: const Text('Start over'), + ), + OutlinedButton( + onPressed: onEditOutline, + child: const Text('Edit outline'), + ), + if (failedSlideCount > 0) + OutlinedButton.icon( + onPressed: onRetryFailed, + icon: const Icon(LucideIcons.refreshCw, size: 17), + label: Text( + 'Retry $failedSlideCount ' + '${failedSlideCount == 1 ? 'slide' : 'slides'}', + ), + ), + SdButton( + label: 'Present deck', + icon: LucideIcons.play, + onPressed: onPresent, + ), + ], ), ], ), @@ -126,6 +213,31 @@ class WizardGenerationStatus extends StatelessWidget { } } +double _progressValue(GenerationProgress progress) { + return switch (progress.phase) { + GenerationPhase.idle => 0.05, + GenerationPhase.generatingOutline => 0.15, + GenerationPhase.composingSlides => _compositionProgress(progress), + GenerationPhase.finalizing => 0.94, + GenerationPhase.generatingThumbnails => 0.98, + }; +} + +double _compositionProgress(GenerationProgress progress) { + final slideIndex = progress.slideIndex; + final slideCount = progress.slideCount; + if (slideIndex != null && slideCount != null && slideCount > 0) { + return (0.2 + 0.7 * (slideIndex / slideCount)).clamp(0.2, 0.9); + } + + final sectionIndex = progress.sectionIndex; + final sectionCount = progress.sectionCount; + if (sectionIndex != null && sectionCount != null && sectionCount > 0) { + return (0.2 + 0.7 * (sectionIndex / sectionCount)).clamp(0.2, 0.9); + } + return 0.25; +} + class _StatusIcon extends StatelessWidget { const _StatusIcon({required this.icon, required this.running}); diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_outline_review.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_outline_review.dart new file mode 100644 index 00000000..5908ae01 --- /dev/null +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_outline_review.dart @@ -0,0 +1,353 @@ +import 'package:flutter/material.dart'; +import 'package:hero_ui/hero_ui.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../quick_agent/core/engine/schemas/outline_schema.dart'; +import '../core/ui/ui.dart'; + +typedef UpdateOutlineSlide = + bool Function(int index, String title, String assertion); + +/// Editable review of the exact typed plan that slide composition will use. +class WizardOutlineReview extends StatefulWidget { + const WizardOutlineReview({ + super.key, + required this.plan, + this.planRevision = 0, + required this.onSlideChanged, + required this.onBack, + required this.onRegenerate, + required this.onApprove, + }); + + final DeckPlanType plan; + final int planRevision; + final UpdateOutlineSlide onSlideChanged; + final VoidCallback onBack; + final VoidCallback onRegenerate; + final VoidCallback onApprove; + + @override + State createState() => _WizardOutlineReviewState(); +} + +class _WizardOutlineReviewState extends State { + final _invalidSlideKeys = {}; + + @override + void didUpdateWidget(covariant WizardOutlineReview oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.planRevision != widget.planRevision) { + _invalidSlideKeys.clear(); + } + final currentKeys = widget.plan.slides.map((slide) => slide.key).toSet(); + _invalidSlideKeys.removeWhere((key) => !currentKeys.contains(key)); + } + + void _setSlideValidity(String key, bool isValid) { + setState(() { + if (isValid) { + _invalidSlideKeys.remove(key); + } else { + _invalidSlideKeys.add(key); + } + }); + } + + @override + Widget build(BuildContext context) { + return Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 8), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 900), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 18, + children: [ + SdPanel( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 14, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: $accentSoft.resolve(context), + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Icon( + LucideIcons.listTree, + size: 22, + color: $accent.resolve(context), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 6, + children: [ + const SdHeadline('Review the story'), + SdBody(widget.plan.story), + SdCaption( + '${widget.plan.slides.length} slides across ' + '${widget.plan.sections.length} sections', + ), + ], + ), + ), + ], + ), + ), + for (final section in widget.plan.sections) + _OutlineSection( + section: section, + slides: [ + for (final (index, slide) in widget.plan.slides.indexed) + if (slide.sectionKey == section.key) + (index: index, slide: slide), + ], + onSlideChanged: widget.onSlideChanged, + onSlideValidityChanged: _setSlideValidity, + planRevision: widget.planRevision, + ), + Wrap( + alignment: WrapAlignment.end, + spacing: 10, + runSpacing: 10, + children: [ + TextButton( + onPressed: widget.onBack, + child: const Text('Back'), + ), + OutlinedButton.icon( + onPressed: widget.onRegenerate, + icon: const Icon(LucideIcons.refreshCw, size: 17), + label: const Text('Regenerate outline'), + ), + SdButton( + label: 'Approve & build', + icon: LucideIcons.sparkles, + onPressed: _invalidSlideKeys.isEmpty + ? widget.onApprove + : null, + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _OutlineSection extends StatelessWidget { + const _OutlineSection({ + required this.section, + required this.slides, + required this.onSlideChanged, + required this.onSlideValidityChanged, + required this.planRevision, + }); + + final DeckPlanSectionType section; + final List<({int index, DeckPlanSlideType slide})> slides; + final UpdateOutlineSlide onSlideChanged; + final void Function(String key, bool isValid) onSlideValidityChanged; + final int planRevision; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 10, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [SdTitle(section.title), SdCaption(section.purpose)], + ), + ), + for (final item in slides) + _OutlineSlideEditor( + key: ValueKey(item.slide.key), + index: item.index, + slide: item.slide, + onChanged: onSlideChanged, + onValidityChanged: (isValid) => + onSlideValidityChanged(item.slide.key, isValid), + planRevision: planRevision, + ), + ], + ); + } +} + +class _OutlineSlideEditor extends StatefulWidget { + const _OutlineSlideEditor({ + super.key, + required this.index, + required this.slide, + required this.onChanged, + required this.onValidityChanged, + required this.planRevision, + }); + + final int index; + final DeckPlanSlideType slide; + final UpdateOutlineSlide onChanged; + final ValueChanged onValidityChanged; + final int planRevision; + + @override + State<_OutlineSlideEditor> createState() => _OutlineSlideEditorState(); +} + +class _OutlineSlideEditorState extends State<_OutlineSlideEditor> { + late final TextEditingController _titleController; + late final TextEditingController _assertionController; + String? _validationMessage; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.slide.title); + _assertionController = TextEditingController(text: widget.slide.assertion); + } + + @override + void didUpdateWidget(covariant _OutlineSlideEditor oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.planRevision != widget.planRevision) { + _titleController.text = widget.slide.title; + _assertionController.text = widget.slide.assertion; + _validationMessage = null; + return; + } + if (oldWidget.slide.title != widget.slide.title && + _titleController.text != widget.slide.title) { + _titleController.text = widget.slide.title; + } + if (oldWidget.slide.assertion != widget.slide.assertion && + _assertionController.text != widget.slide.assertion) { + _assertionController.text = widget.slide.assertion; + } + } + + @override + void dispose() { + _titleController.dispose(); + _assertionController.dispose(); + super.dispose(); + } + + void _submitEdit(String _) { + final title = _titleController.text.trim(); + final assertion = _assertionController.text.trim(); + if (title.isEmpty || assertion.isEmpty) { + setState(() { + _validationMessage = 'Add both a slide title and core message.'; + }); + widget.onValidityChanged(false); + return; + } + final accepted = widget.onChanged(widget.index, title, assertion); + setState(() { + _validationMessage = accepted + ? null + : 'This edit conflicts with the approved deck constraints.'; + }); + widget.onValidityChanged(accepted); + } + + @override + Widget build(BuildContext context) { + final slide = widget.slide; + final borderColor = $border.resolve(context); + + return SdPanel( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 14, + children: [ + Container( + width: 58, + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: $surfaceTertiary.resolve(context), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: borderColor), + ), + alignment: Alignment.center, + child: SdCaption('Slide ${widget.index + 1}'), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + SdTextField( + key: ValueKey('outline-title-${slide.key}'), + controller: _titleController, + label: 'Slide title', + semanticLabel: 'Slide ${widget.index + 1} title', + textInputAction: TextInputAction.next, + onChanged: _submitEdit, + ), + SdTextField( + key: ValueKey('outline-assertion-${slide.key}'), + controller: _assertionController, + label: 'Core message', + semanticLabel: 'Slide ${widget.index + 1} core message', + minLines: 1, + maxLines: 2, + onChanged: _submitEdit, + ), + if (_validationMessage case final message?) + Text( + message, + style: TextStyle( + color: $danger.resolve(context), + fontSize: 12, + ), + ), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _PlanChip(label: slide.narrativeRole), + _PlanChip(label: slide.composition), + _PlanChip(label: slide.treatment), + ], + ), + ], + ), + ), + ], + ), + ); + } +} + +class _PlanChip extends StatelessWidget { + const _PlanChip({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + decoration: BoxDecoration( + color: $surfaceTertiary.resolve(context), + borderRadius: BorderRadius.circular(999), + ), + child: SdCaption(label), + ); + } +} diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart index 2bb55e9d..1aaa06ed 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -5,11 +7,17 @@ import 'package:hero_ui/hero_ui.dart'; import 'package:provider/provider.dart'; import '../../../../core/domain/stores/deck_customization_store.dart'; -import '../../../../core/result.dart'; +import '../../../../core/data/data_sources/memory_deck_loader.dart'; import '../../../editor/domain/stores/deck_document_store.dart'; +import '../../quick_agent/core/engine/services/deck_generator_service.dart'; import '../../quick_agent/core/env_config.dart'; -import '../../quick_agent/domain/commands/generate_deck_command.dart'; +import '../../quick_agent/domain/generated_deck_result_applier.dart'; +import '../chat/chat_conversation_profile.dart'; +import '../core/ai/services/ai_conversation_viewmodel.dart'; +import '../core/viewmodel_scope.dart'; +import 'wizard_generation_controller.dart'; import 'wizard_generation_status.dart'; +import 'wizard_outline_review.dart'; import 'wizard_view.dart'; /// Isolated host for exercising the conversational Wizard without the editor. @@ -18,11 +26,14 @@ import 'wizard_view.dart'; /// deck-storage folder. The production integration can provide its own document /// destination when the Wizard is embedded outside the playground. class WizardPage extends StatelessWidget { - const WizardPage({this.isConfigured, super.key}); + const WizardPage({this.isConfigured, this.generationService, super.key}); /// Overrides environment detection in tests and previews. final bool? isConfigured; + /// Overrides the live Gemini-backed service in tests and previews. + final DeckGeneratorService? generationService; + @override Widget build(BuildContext context) { final hasApiKey = isConfigured ?? EnvConfig.hasGeminiApiKey; @@ -34,15 +45,33 @@ class WizardPage extends StatelessWidget { return MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => DeckDocumentStore(markdown: '')), - ListenableProvider( - create: (context) => GenerateDeckCommand( - documentStore: context.read(), - customizationStore: context.read(), + ChangeNotifierProvider( + create: (context) => WizardGenerationController( + service: + generationService ?? + DeckGeneratorService(apiKey: EnvConfig.geminiApiKey), + applyResult: (result) => applyGeneratedDeckResult( + result: result, + documentStore: Provider.of( + context, + listen: false, + ), + deckLoader: Provider.of(context, listen: false), + customizationStore: Provider.of( + context, + listen: false, + ), + ), ), - dispose: (_, command) => command.dispose(), ), ], - child: const _WizardExperience(), + child: ViewModelScope( + create: () { + // ViewModelScope owns and disposes the created conversation model. + return AiConversationViewModel(profile: chatConversationProfile()); + }, + child: const _WizardExperience(), + ), ); } } @@ -52,30 +81,65 @@ class _WizardExperience extends StatelessWidget { @override Widget build(BuildContext context) { - final command = context.watch(); - final result = command.result; - - final Widget? status; - if (command.running) { - status = WizardGenerationStatus( - kind: WizardGenerationStatusKind.running, - progress: command.progress, - ); - } else if (result case Failure(:final error)) { - status = WizardGenerationStatus( - kind: WizardGenerationStatusKind.failed, - errorMessage: error.toString(), - onDismiss: command.clearResult, - ); - } else if (command.completed) { - status = WizardGenerationStatus( - kind: WizardGenerationStatusKind.completed, - noticeMessage: command.completionNotice, - onDismiss: command.clearResult, - ); - } else { - status = null; - } + final controller = context.watch(); + final overlay = switch (controller.stage) { + WizardGenerationStage.setup => null, + WizardGenerationStage.planning || + WizardGenerationStage.composing => _CenteredScrollable( + child: WizardGenerationStatus( + kind: WizardGenerationStatusKind.running, + progress: controller.progress, + onCancel: controller.cancel, + ), + ), + WizardGenerationStage.outlineReview => WizardOutlineReview( + plan: controller.plan!, + planRevision: controller.planRevision, + onSlideChanged: (index, title, assertion) => + controller.updateSlide(index, title: title, assertion: assertion), + onBack: controller.reset, + onRegenerate: () { + unawaited(controller.regenerateOutline()); + }, + onApprove: () { + unawaited(controller.generateSlides()); + }, + ), + WizardGenerationStage.failed => _CenteredScrollable( + child: WizardGenerationStatus( + kind: WizardGenerationStatusKind.failed, + errorMessage: controller.errorMessage, + planAvailable: controller.plan != null, + onRetry: () { + unawaited(controller.retry()); + }, + backLabel: controller.plan != null ? 'Edit outline' : 'Back to setup', + onBack: controller.plan != null + ? controller.returnToOutline + : controller.reset, + ), + ), + WizardGenerationStage.completed => _CenteredScrollable( + child: WizardGenerationStatus( + kind: WizardGenerationStatusKind.completed, + noticeMessage: _partialResultNotice(controller.result), + slideCount: controller.result?.slides.length, + failedSlideCount: controller.result?.slideFailures.length ?? 0, + elapsed: controller.elapsed, + onRetryFailed: () { + unawaited(controller.retryFailedSlides()); + }, + onPresent: () => context.push('/present/0'), + onEditOutline: controller.editOutline, + onStartOver: () { + controller.reset(); + ViewModelScope.of( + context, + ).restartConversation(); + }, + ), + ), + }; return Scaffold( backgroundColor: $background.resolve(context), @@ -93,23 +157,11 @@ class _WizardExperience extends StatelessWidget { fit: StackFit.expand, children: [ Offstage( - offstage: status != null, + offstage: + controller.stage != WizardGenerationStage.setup, child: const WizardView(), ), - if (status != null) - Positioned.fill( - child: ColoredBox( - color: $background.resolve(context), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 620, - ), - child: status, - ), - ), - ), - ), + ?overlay, ], ), ), @@ -133,6 +185,31 @@ class _WizardExperience extends StatelessWidget { } } +String? _partialResultNotice(DeckGenerationResult? result) { + if (result == null || !result.isPartial) return null; + final failed = result.slideFailures.length; + return '${result.slides.length} slides are ready. ' + '$failed ${failed == 1 ? 'slide needs' : 'slides need'} another pass.'; +} + +class _CenteredScrollable extends StatelessWidget { + const _CenteredScrollable({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: Center(child: child), + ), + ), + ); + } +} + class _MissingApiKeyView extends StatelessWidget { const _MissingApiKeyView(); diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_selection_review.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_selection_review.dart new file mode 100644 index 00000000..1f0ccd25 --- /dev/null +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_selection_review.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:hero_ui/hero_ui.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../../core/domain/design/presentation_theme_catalog.dart'; +import '../core/ai/wizard_context.dart'; +import '../core/ui/ui.dart'; +import '../core/utils/color_utils.dart'; + +/// Application-owned review of the selections accepted during the GenUI flow. +class WizardSelectionReview extends StatelessWidget { + const WizardSelectionReview({ + super.key, + required this.wizardContext, + required this.themeCatalog, + required this.onCreateOutline, + this.onStartOver, + }); + + final WizardContext wizardContext; + final PresentationThemeCatalog themeCatalog; + final VoidCallback onCreateOutline; + final VoidCallback? onStartOver; + + @override + Widget build(BuildContext context) { + final theme = wizardContext.themeId == null + ? null + : themeCatalog.current(wizardContext.themeId!); + final emphasis = wizardContext.emphasis?.join(', ') ?? ''; + + return Center( + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: SdPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 20, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 12, + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: $accentSoft.resolve(context), + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: Icon( + LucideIcons.layers, + size: 21, + color: $accent.resolve(context), + ), + ), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + SdHeadline('Review your deck setup'), + SdCaption( + 'Confirm the direction before creating the slide outline.', + ), + ], + ), + ), + ], + ), + _ReviewItems( + items: [ + _ReviewItem('Topic', wizardContext.topic ?? ''), + _ReviewItem('Audience', wizardContext.audience ?? ''), + _ReviewItem('Approach', wizardContext.approach ?? ''), + _ReviewItem('Emphasis', emphasis), + _ReviewItem( + 'Deck length', + '${wizardContext.slideCount ?? 0} slides', + ), + ], + theme: theme, + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + spacing: 12, + children: [ + if (onStartOver != null) + TextButton( + onPressed: onStartOver, + child: const Text('Start over'), + ), + SdButton( + label: 'Create outline', + icon: LucideIcons.sparkles, + onPressed: onCreateOutline, + ), + ], + ), + ], + ), + ), + ), + ), + ); + } +} + +final class _ReviewItem { + const _ReviewItem(this.label, this.value); + + final String label; + final String value; +} + +class _ReviewItems extends StatelessWidget { + const _ReviewItems({required this.items, required this.theme}); + + final List<_ReviewItem> items; + final PresentationThemeDescriptor? theme; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + for (final (index, item) in items.indexed) ...[ + if (index > 0) Divider(height: 1, color: $separator.resolve(context)), + _ReviewRow(label: item.label, child: SdBody(item.value)), + ], + Divider(height: 1, color: $separator.resolve(context)), + _ReviewRow( + label: 'Theme', + child: theme == null + ? const SdBody('Theme unavailable') + : _ThemeSummary(theme: theme!), + ), + ], + ); + } +} + +class _ReviewRow extends StatelessWidget { + const _ReviewRow({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 480) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [SdCaption(label), child], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 120, child: SdCaption(label)), + Expanded(child: child), + ], + ); + }, + ), + ); + } +} + +class _ThemeSummary extends StatelessWidget { + const _ThemeSummary({required this.theme}); + + final PresentationThemeDescriptor theme; + + @override + Widget build(BuildContext context) { + final recipe = theme.recipe; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 6, + children: [ + SdBody(theme.title), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final color in recipe.palette.previewColors) + SdColorCircle(color: hexToColor(color)), + ], + ), + SdCaption('${recipe.headlineFamily} + ${recipe.bodyFamily}'), + ], + ); + } +} diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_view.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_view.dart index ca99e562..3fb12b9c 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_view.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_view.dart @@ -1,32 +1,27 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart' as prov show Provider; -import '../chat/chat_conversation_profile.dart'; import '../chat/view/widgets/chat_input.dart'; import '../chat/view/widgets/chat_genui_panels.dart'; import '../chat/view/widgets/empty_state.dart'; import '../core/ai/services/ai_conversation_viewmodel.dart'; +import '../core/ai/services/prompt_builder.dart'; import '../core/viewmodel_scope.dart'; +import 'wizard_generation_controller.dart'; +import 'wizard_selection_review.dart'; /// Single-column conversational Wizard hosted by its standalone playground page. /// -/// Owns an [AiConversationViewModel] (via [ViewModelScope]) driving the GenUI -/// catalog: the model asks one question at a time, each rendered as a catalog -/// surface. The host supplies the `GenerateDeckCommand` used by the terminal -/// summary card. +/// Renders the GenUI Wizard supplied by the nearest application-owned +/// [ViewModelScope]. The application takes over for review, planning, and +/// composition while preserving the accepted setup choices. class WizardView extends StatelessWidget { const WizardView({super.key}); @override - Widget build(BuildContext context) { - // The wizard's chat/catalog UI is built on hero_ui tokens, which the - // surrounding app already registers via HeroTheme — no extra scope needed. - return ViewModelScope( - create: () => AiConversationViewModel(profile: chatConversationProfile()), - child: const _WizardBody(), - ); - } + Widget build(BuildContext context) => const _WizardBody(); } class _WizardBody extends StatefulWidget { @@ -56,13 +51,15 @@ class _WizardBodyState extends State<_WizardBody> { void _submit(String value) { if (value.trim().isEmpty) return; - unawaited(context.read().sendMessage(value)); + unawaited( + ViewModelScope.of(context).sendMessage(value), + ); _controller.clear(); } @override Widget build(BuildContext context) { - final viewModel = context.read(); + final viewModel = ViewModelScope.of(context); return ListenableBuilder( listenable: viewModel, @@ -71,6 +68,25 @@ class _WizardBodyState extends State<_WizardBody> { final started = surfaceController != null; final isThinking = viewModel.isThinking.value; + if (viewModel.wizardState.isReviewReady) { + return WizardSelectionReview( + wizardContext: viewModel.wizardState.context, + themeCatalog: viewModel.themeCatalog, + onStartOver: viewModel.restartConversation, + onCreateOutline: () { + final request = buildPromptFromWizardContext( + viewModel.wizardState.context, + ); + unawaited( + prov.Provider.of( + context, + listen: false, + ).createOutline(request), + ); + }, + ); + } + final input = ChatInput( controller: _controller, focusNode: _focusNode, diff --git a/packages/playground/lib/main.dart b/packages/playground/lib/main.dart index ebc123b8..205f35d9 100644 --- a/packages/playground/lib/main.dart +++ b/packages/playground/lib/main.dart @@ -13,23 +13,23 @@ Future main() async { SignalsObserver.instance = null; // debugRepaintRainbowEnabled = true; - runApp(const PlaygroundRefactorApp()); + runApp(const SuperdeckApp()); } -class PlaygroundRefactorApp extends StatefulWidget { - const PlaygroundRefactorApp({super.key}); +class SuperdeckApp extends StatefulWidget { + const SuperdeckApp({super.key}); @override - State createState() => _PlaygroundRefactorAppState(); + State createState() => _SuperdeckAppState(); } -class _PlaygroundRefactorAppState extends State { +class _SuperdeckAppState extends State { final _router = createRouter(); @override Widget build(BuildContext context) { return MaterialApp.router( - title: 'Superdeck Playground (refactor)', + title: 'Superdeck', debugShowCheckedModeBanner: false, routerConfig: _router, builder: (context, child) => _Theme(child: AppProviders(child: child!)), diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/schemas/outline_schema_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/schemas/outline_schema_test.dart index 8f495111..0803e4df 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/schemas/outline_schema_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/schemas/outline_schema_test.dart @@ -1136,18 +1136,30 @@ void main() { ); }); - test('rejects a display treatment on a structural composition', () { + test('reports a display treatment mismatch without blocking the plan', () { final data = _hierarchicalPlan(); final slides = data['slides']! as List>; slides[6]['treatment'] = 'hero'; final plan = DeckPlanType.parse(data); + final issues = validateDeckPlanIssues(plan); expect( - validateDeckPlan(plan), - contains( - 'Slide "process" pairs treatment "hero" with composition ' - '"threeColumn"; allowed compositions are title.', - ), + issues.singleWhere((issue) => issue.code == .treatmentIntent), + isA() + .having( + (issue) => issue.severity, + 'severity', + GenerationValidationSeverity.diagnostic, + ) + .having((issue) => issue.isBlocking, 'isBlocking', isFalse) + .having( + (issue) => issue.message, + 'message', + contains( + 'Slide "process" pairs treatment "hero" with composition ' + '"threeColumn"; allowed compositions are title.', + ), + ), ); }); diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart index 34ddd982..7cb7b65e 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart @@ -18,6 +18,69 @@ import 'package:superdeck_core/superdeck_core.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + test('returns a failure when the model client cannot be created', () async { + final service = DeckGeneratorService( + apiKey: 'test-key', + modelClientFactory: (_) => throw StateError('client unavailable'), + ); + + final planning = await service.plan( + _request('Create one test slide.', slideCount: 1), + ); + + expect(planning.success, isFalse); + expect(planning.error, isNotEmpty); + }); + + test('plans and composes an approved outline in separate calls', () async { + final request = _request('Create one approved slide.', slideCount: 1); + final planningClient = _FakeGenerationModelClient([ + _jsonResponse({ + 'topic': 'Approved outline', + 'story': 'One clear idea becomes one focused slide.', + 'theme': _testThemeSelection, + 'slides': [_planSlide(key: 'approved')], + }), + ]); + final planningService = DeckGeneratorService( + apiKey: 'test-key', + modelClientFactory: (_) => planningClient, + ); + + final planning = await planningService.plan(request); + + expect(planning.success, isTrue); + expect(planning.plan, isNotNull); + expect(planningClient.requests, hasLength(1)); + expect(planningClient.requests.single.model, 'models/gemini-3.5-flash'); + expect(planningClient.isClosed, isTrue); + + final compositionClient = _FakeGenerationModelClient([ + _jsonResponse( + _generatedSlide(key: 'approved', title: 'Approved', style: 'content'), + ), + ]); + final compositionService = DeckGeneratorService( + apiKey: 'test-key', + modelClientFactory: (_) => compositionClient, + ); + + final result = await compositionService.generateFromPlan( + request, + planning.plan!, + ); + + expect(result.success, isTrue); + expect(result.slides, hasLength(1)); + expect(result.plan, same(planning.plan)); + expect(compositionClient.requests, hasLength(1)); + expect( + compositionClient.requests.single.model, + 'models/gemini-3.1-flash-lite', + ); + expect(compositionClient.isClosed, isTrue); + }); + test('rejects an outline that violates the typed request count', () async { final client = _FakeGenerationModelClient([ _jsonResponse({ @@ -322,7 +385,6 @@ void main() { final invalidOpening = { ..._planSlide(key: 'opening'), 'contentUnits': ['Start your free trial today'], - 'treatment': 'data', }; final invalidClosing = { ..._planSlide(key: 'closing'), @@ -332,7 +394,7 @@ void main() { _jsonResponse({ 'topic': 'Layered plan repair', 'story': 'Repair the global shape, then the remaining local copy.', - 'theme': _testThemeSelection, + 'theme': {..._testThemeSelection, 'id': 'unknown-theme'}, 'slides': [invalidOpening, invalidClosing], }), _jsonResponse({ @@ -928,6 +990,64 @@ void main() { ); }); + test('retries only failed slides and restores the original order', () async { + final initialClient = _FakeGenerationModelClient([ + _jsonResponse({ + 'topic': 'Targeted retry', + 'story': 'Keep accepted work and replace only one failed slide.', + 'theme': _testThemeSelection, + 'slides': [ + _planSlide(key: 'opening', composition: 'title'), + _planSlide(key: 'evidence'), + _planSlide(key: 'closing', composition: 'titleLeft'), + ], + }), + _jsonResponse( + _generatedSlide(key: 'opening', title: 'Start here', style: 'hero'), + ), + _jsonResponse({'key': 'evidence', 'sections': []}), + _jsonResponse({'key': 'evidence', 'sections': []}), + _jsonResponse( + _generatedSlide(key: 'closing', title: 'Take action', style: 'closing'), + ), + ]); + final request = _request('Create a resilient deck.', slideCount: 3); + final partial = await DeckGeneratorService( + apiKey: 'test-key', + modelClientFactory: (_) => initialClient, + ).generate(request); + expect(partial.isPartial, isTrue); + + final retryClient = _FakeGenerationModelClient([ + _jsonResponse( + _generatedSlide( + key: 'evidence', + title: 'The evidence', + style: 'content', + ), + ), + ]); + final recovered = await DeckGeneratorService( + apiKey: 'test-key', + modelClientFactory: (_) => retryClient, + ).retryFailedSlides(request, partial); + + expect(recovered.success, isTrue, reason: recovered.error); + expect(recovered.slides.map((slide) => slide.key), [ + 'opening', + 'evidence', + 'closing', + ]); + expect(recovered.slides.first.toMap(), partial.slides.first.toMap()); + expect(recovered.slides.last.toMap(), partial.slides.last.toMap()); + expect(retryClient.requests, hasLength(1)); + expect(retryClient.requests.single.model, 'models/gemini-3.1-flash-lite'); + expect( + retryClient.requests.single.systemInstruction!.parts.single.text, + contains('Start here'), + ); + }); + test('uses one targeted semantic repair per slide by default', () async { final client = _FakeGenerationModelClient([ _jsonResponse({ @@ -1275,9 +1395,9 @@ void main() { ); expect(result.success, isFalse); - expect(result.isPartial, isTrue); - expect(result.error, contains('Connection issue')); - expect(result.slideFailures.single.slideKey, 'intro'); + expect(result.isPartial, isFalse); + expect(result.slides, isEmpty); + expect(result.error, contains('No slides could be generated')); expect(client.requestCount, 3); }); } diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/services/source_grounding_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/services/source_grounding_test.dart index 662abdee..3b4db9bc 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/services/source_grounding_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/services/source_grounding_test.dart @@ -160,4 +160,27 @@ void main() { expect(unsupported, isNot(contains('compliance'))); }); + + test('matches short commitment phrases as tokens, not substrings', () { + final unsupported = findUnsupportedCommitmentPhrases( + values: const [ + 'A neighborhood association can coordinate shared garden plots.', + ], + userIntent: 'Explain community coordination for urban gardens.', + ); + + expect(unsupported, isNot(contains('sso'))); + }); + + test('allows ordinary policy language outside product commitment claims', () { + final unsupported = findUnsupportedCommitmentPhrases( + values: const [ + 'Permanent tree canopy protections and land-use classification can ' + 'support long-term neighborhood resilience.', + ], + userIntent: 'Create an urban policy presentation.', + ); + + expect(unsupported, isEmpty); + }); } diff --git a/packages/playground/test/features/ai/wizard/core/ai/catalog/presentation_theme_selection_test.dart b/packages/playground/test/features/ai/wizard/core/ai/catalog/presentation_theme_selection_test.dart index 22907aa9..2469f2d1 100644 --- a/packages/playground/test/features/ai/wizard/core/ai/catalog/presentation_theme_selection_test.dart +++ b/packages/playground/test/features/ai/wizard/core/ai/catalog/presentation_theme_selection_test.dart @@ -1,7 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:playground/core/domain/design/presentation_theme_catalog.dart'; import 'package:playground/features/ai/wizard/core/ai/catalog/ask_user_style.dart'; -import 'package:playground/features/ai/wizard/core/ai/catalog/summary_card.dart'; import 'package:playground/features/ai/wizard/chat/chat_conversation_profile.dart'; import 'package:playground/features/ai/wizard/core/ai/schemas/wizard_context_keys.dart'; import 'package:playground/features/ai/wizard/core/ai/services/genui_conversation_session.dart'; @@ -46,23 +45,6 @@ void main() { expect(context, isNot(contains(WizardContextKeys.bodyFont))); }); - test('summary restores the stable theme ID only', () { - final item = SummaryItemType.parse({ - 'kind': 'theme', - 'label': 'Style', - 'themeId': 'technical-paper', - }); - - expect(item.shapeValidationError, isNull); - final context = extractWizardContextFromSummaryItems([item]); - - expect(context.themeId, 'technical-paper'); - expect(context.style, isNull); - expect(context.colors, isNull); - expect(context.headlineFont, isNull); - expect(context.bodyFont, isNull); - }); - test('Wizard prompt exposes compact catalog metadata without recipes', () { final prompt = buildWizardThemeCatalogPrompt( PresentationThemeCatalog.withDefaults(), @@ -105,23 +87,17 @@ void main() { ]), customIds, ); - expect( - _enumAt(items['SummaryCard']!.dataSchema.value, [ - 'properties', - 'items', - 'items', - 'properties', - 'themeId', - ]), - customIds, + expect(items.keys, { + 'AskUserRadio', + 'AskUserCheckbox', + 'AskUserSlider', + 'AskUserStyle', + }); + final examples = items['AskUserStyle']!.exampleData.map( + (build) => build(), ); - for (final componentName in ['AskUserStyle', 'SummaryCard']) { - final examples = items[componentName]!.exampleData.map( - (build) => build(), - ); - expect(examples, everyElement(isNot(contains('editorial-midnight')))); - expect(examples.join(), contains(customIds.first)); - } + expect(examples, everyElement(isNot(contains('editorial-midnight')))); + expect(examples.join(), contains(customIds.first)); final prompt = buildWizardThemeCatalogPrompt(profile.themeCatalog); for (final id in customIds) { expect(prompt, contains(id)); @@ -141,19 +117,6 @@ void main() { )[WizardContextKeys.themeId], customIds.first, ); - expect( - parseSummaryCard({ - 'title': 'Summary', - 'items': [ - {'kind': 'theme', 'label': 'Style', 'themeId': customIds.first}, - ], - 'generateSlidesAction': { - 'name': 'generate_slides', - 'context': [], - }, - }, themeCatalog: catalog).items.single.themeId, - customIds.first, - ); }, ); }); diff --git a/packages/playground/test/features/ai/wizard/core/ai/services/ai_conversation_viewmodel_test.dart b/packages/playground/test/features/ai/wizard/core/ai/services/ai_conversation_viewmodel_test.dart index b307ed23..69fc5576 100644 --- a/packages/playground/test/features/ai/wizard/core/ai/services/ai_conversation_viewmodel_test.dart +++ b/packages/playground/test/features/ai/wizard/core/ai/services/ai_conversation_viewmodel_test.dart @@ -1,29 +1,18 @@ +import 'dart:async'; + +import 'package:dartantic_ai/dartantic_ai.dart' as dartantic; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/genui.dart' as genui; import 'package:playground/features/ai/wizard/chat/chat_conversation_profile.dart'; +import 'package:playground/features/ai/wizard/core/ai/wizard_session_state.dart'; import 'package:playground/features/ai/wizard/core/ai/services/ai_conversation_viewmodel.dart'; import 'package:playground/features/ai/wizard/core/ai/services/genui_conversation_session.dart'; +import 'package:playground/features/ai/wizard/core/ai/services/superdeck_agent_client.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - group('mergeFinalOutputSegments', () { - test('restores a stripped word boundary between decoded segments', () { - expect( - mergeFinalOutputSegments('Now, how', 'should we approach the pitch?'), - 'Now, how should we approach the pitch?', - ); - }); - - test('does not add a space before punctuation', () { - expect(mergeFinalOutputSegments('Great choice', '!'), 'Great choice!'); - }); - - test('preserves whitespace already supplied by the adapter', () { - expect(mergeFinalOutputSegments('Great', ' choice'), 'Great choice'); - }); - }); - group('missing Wizard surfaces', () { test('requests recovery when the first response has no surface', () { expect( @@ -62,6 +51,62 @@ void main() { }); }); + test('accepts only the root component for the canonical Wizard step', () { + genui.SurfaceDefinition surface(String type) => genui.SurfaceDefinition( + surfaceId: 'wizard', + components: { + 'root': genui.Component(id: 'root', type: type, properties: const {}), + }, + ); + + expect( + isExpectedWizardSurface(surface('AskUserCheckbox'), WizardStep.emphasis), + isTrue, + ); + expect( + isExpectedWizardSurface(surface('AskUserRadio'), WizardStep.emphasis), + isFalse, + ); + expect(expectedWizardComponentType(WizardStep.theme), 'AskUserStyle'); + }); + + test('turn prompt pins canonical selections and the expected next step', () { + final state = WizardSessionState.initial() + .startTopic('Urban gardens')! + .advance({'selectedOption': 'City planners'})!; + + final prompt = buildWizardTurnPrompt( + userInput: 'The user selected City planners.', + state: state, + ); + + expect(prompt, contains('"topic":"Urban gardens"')); + expect(prompt, contains('"audience":"City planners"')); + expect(prompt, contains('Expected next step: approach')); + expect(prompt, contains('exactly one approach surface')); + }); + + test('ignores a second topic while the first request is starting', () async { + final client = _DelayedAgentClient(); + final viewModel = AiConversationViewModel( + profile: chatConversationProfile(), + apiKey: 'test-key', + agentClientFactory: + ({required apiKey, required modelName, required tools}) => client, + ); + + final first = viewModel.sendMessage('First topic'); + await client.started.future.timeout(const Duration(seconds: 2)); + await viewModel.sendMessage('Second topic'); + + expect(client.requestCount, 1); + expect(viewModel.wizardState.context.topic, 'First topic'); + + viewModel.dispose(); + client.release.complete(); + await first; + }); + test('Wizard system prompt permits its single-surface replacement flow', () { final session = GenUiConversationSession( profile: chatConversationProfile(), @@ -90,13 +135,37 @@ void main() { ); }); - test('Wizard instructions update one persistent surface', () async { - final prompt = await rootBundle.loadString( - 'assets/ai_prompts/wizard_system.prompt', - ); + test( + 'Wizard instructions update one persistent surface without text', + () async { + final prompt = await rootBundle.loadString( + 'assets/ai_prompts/wizard_system.prompt', + ); - expect(prompt, contains('updateComponents')); - expect(prompt, isNot(contains('deleteSurface'))); - expect(prompt, isNot(contains('beginRendering'))); - }); + expect(prompt, contains('updateComponents')); + expect(prompt, contains('surface messages only')); + expect(prompt, isNot(contains('provideFinalOutput'))); + expect(prompt, isNot(contains('deleteSurface'))); + expect(prompt, isNot(contains('beginRendering'))); + }, + ); +} + +final class _DelayedAgentClient implements SuperdeckAgentClient { + final started = Completer(); + final release = Completer(); + var requestCount = 0; + + @override + Stream sendStream( + String prompt, { + required Iterable history, + }) async* { + requestCount++; + if (!started.isCompleted) started.complete(); + await release.future; + } + + @override + void dispose() {} } diff --git a/packages/playground/test/features/ai/wizard/core/ai/wizard_session_state_test.dart b/packages/playground/test/features/ai/wizard/core/ai/wizard_session_state_test.dart new file mode 100644 index 00000000..a8389bdf --- /dev/null +++ b/packages/playground/test/features/ai/wizard/core/ai/wizard_session_state_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:playground/features/ai/wizard/core/ai/wizard_session_state.dart'; + +void main() { + test('starts with a trimmed topic and expects an audience', () { + final state = WizardSessionState.initial().startTopic( + ' Urban gardens for resilient cities ', + ); + + expect(state, isNotNull); + expect(state!.step, WizardStep.audience); + expect(state.context.topic, 'Urban gardens for resilient cities'); + }); + + test('accepts only the value required by the current step', () { + final audienceState = WizardSessionState.initial().startTopic('Topic')!; + + expect(audienceState.advance({'value': 12}), isNull); + + final approachState = audienceState.advance({ + 'selectedOption': 'City planners', + }); + expect(approachState, isNotNull); + expect(approachState!.step, WizardStep.approach); + expect(approachState.context.audience, 'City planners'); + }); + + test('builds canonical context through the complete selection flow', () { + var state = WizardSessionState.initial().startTopic('Urban gardens')!; + state = state.advance({'selectedOption': 'City planners'})!; + state = state.advance({'selectedOption': 'Policy blueprint'})!; + state = state.advance({ + 'selectedOptions': ['Zoning', 'Community funding'], + })!; + state = state.advance({'value': 10})!; + state = state.advance({'themeId': 'civic-blueprint'})!; + + expect(state.step, WizardStep.review); + expect(state.isReviewReady, isTrue); + expect(state.context.topic, 'Urban gardens'); + expect(state.context.audience, 'City planners'); + expect(state.context.approach, 'Policy blueprint'); + expect(state.context.emphasis, ['Zoning', 'Community funding']); + expect(state.context.slideCount, 10); + expect(state.context.themeId, 'civic-blueprint'); + expect(state.advance({'selectedOption': 'Unexpected'}), isNull); + }); + + test('rejects out-of-range slide counts and empty selections', () { + var state = WizardSessionState.initial().startTopic('Topic')!; + + expect(state.advance({'selectedOption': ' '}), isNull); + state = state.advance({'selectedOption': 'Audience'})!; + state = state.advance({'selectedOption': 'Approach'})!; + state = state.advance({ + 'selectedOptions': ['Focus'], + })!; + + expect(state.step, WizardStep.slideCount); + expect(state.advance({'value': 4}), isNull); + expect(state.advance({'value': 21}), isNull); + }); +} diff --git a/packages/playground/test/features/ai/wizard/presentation/wizard_generation_controller_test.dart b/packages/playground/test/features/ai/wizard/presentation/wizard_generation_controller_test.dart new file mode 100644 index 00000000..a97cafb2 --- /dev/null +++ b/packages/playground/test/features/ai/wizard/presentation/wizard_generation_controller_test.dart @@ -0,0 +1,336 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:playground/core/domain/design/presentation_theme_catalog.dart'; +import 'package:playground/core/domain/design/presentation_typography_catalog.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/schemas/outline_schema.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/deck_generation_request.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/deck_generator_service.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/deck_theme_resolution.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/generation_validation_issue.dart'; +import 'package:playground/features/ai/wizard/presentation/wizard_generation_controller.dart'; +import 'package:superdeck_core/superdeck_core.dart'; + +void main() { + test('plans, edits, and composes the exact approved outline', () async { + const request = DeckGenerationRequest( + userIntent: 'Urban gardens', + slideCount: 1, + themeId: 'technical-paper', + ); + final service = _FakeWizardGenerationService(_plan(request)); + DeckGenerationResult? appliedResult; + final controller = WizardGenerationController( + service: service, + applyResult: (result) => appliedResult = result, + ); + addTearDown(controller.dispose); + + await controller.createOutline(request); + + expect(controller.stage, WizardGenerationStage.outlineReview); + expect(controller.plan, isNotNull); + expect( + controller.updateSlide( + 0, + title: 'A greener city starts block by block', + assertion: 'Small urban gardens create city-scale resilience.', + ), + isTrue, + ); + + await controller.generateSlides(); + + expect(controller.stage, WizardGenerationStage.completed); + expect(service.approvedPlan, isNotNull); + expect( + service.approvedPlan!.slides.single.title, + 'A greener city starts block by block', + ); + expect( + service.approvedPlan!.slides.single.assertion, + 'Small urban gardens create city-scale resilience.', + ); + expect(appliedResult, isNotNull); + expect(controller.result, same(appliedResult)); + }); + + test( + 'keeps the approved outline available after composition failure', + () async { + const request = DeckGenerationRequest( + userIntent: 'Urban gardens', + slideCount: 1, + themeId: 'technical-paper', + ); + final service = _FakeWizardGenerationService( + _plan(request), + compositionError: 'Composition failed.', + ); + final controller = WizardGenerationController( + service: service, + applyResult: (_) {}, + ); + addTearDown(controller.dispose); + + await controller.createOutline(request); + await controller.generateSlides(); + + expect(controller.stage, WizardGenerationStage.failed); + expect(controller.failedPhase, WizardGenerationPhase.composition); + expect(controller.errorMessage, 'Composition failed.'); + expect(controller.plan, isNotNull); + + controller.returnToOutline(); + expect(controller.stage, WizardGenerationStage.outlineReview); + }, + ); + + test('retries only failed slide slots and keeps accepted slides', () async { + const request = DeckGenerationRequest( + userIntent: 'Urban gardens', + slideCount: 2, + themeId: 'technical-paper', + ); + final service = _FakeWizardGenerationService( + _plan(request, slideKeys: const ['opening', 'evidence']), + partialComposition: true, + ); + final applied = []; + final controller = WizardGenerationController( + service: service, + applyResult: applied.add, + ); + addTearDown(controller.dispose); + + await controller.createOutline(request); + await controller.generateSlides(); + + expect(controller.stage, WizardGenerationStage.completed); + expect(controller.result?.isPartial, isTrue); + expect(controller.result?.slides.map((slide) => slide.key), ['opening']); + + await controller.retryFailedSlides(); + + expect(controller.stage, WizardGenerationStage.completed); + expect(controller.result?.success, isTrue); + expect(controller.result?.slides.map((slide) => slide.key), [ + 'opening', + 'evidence', + ]); + expect(service.retryCalls, 1); + expect(applied, hasLength(2)); + }); + + test('cancels immediately and ignores the late planning result', () async { + const request = DeckGenerationRequest( + userIntent: 'Urban gardens', + slideCount: 1, + themeId: 'technical-paper', + ); + final pending = Completer(); + final service = _FakeWizardGenerationService(_plan(request)) + ..pendingPlanning = pending; + final controller = WizardGenerationController( + service: service, + applyResult: (_) {}, + ); + addTearDown(controller.dispose); + + final planning = controller.createOutline(request); + await Future.delayed(Duration.zero); + expect(controller.stage, WizardGenerationStage.planning); + + controller.cancel(); + expect(controller.stage, WizardGenerationStage.setup); + + pending.complete(DeckPlanningResult.success(_plan(request))); + await planning; + expect(controller.stage, WizardGenerationStage.setup); + }); + + test('keeps the edited outline when regeneration fails', () async { + const request = DeckGenerationRequest( + userIntent: 'Urban gardens', + slideCount: 1, + themeId: 'technical-paper', + ); + final service = _FakeWizardGenerationService(_plan(request)); + final controller = WizardGenerationController( + service: service, + applyResult: (_) {}, + ); + addTearDown(controller.dispose); + + await controller.createOutline(request); + controller.updateSlide( + 0, + title: 'Edited title', + assertion: 'Edited assertion.', + ); + service.planningError = 'Regeneration failed.'; + + await controller.regenerateOutline(); + + expect(controller.stage, WizardGenerationStage.failed); + expect(controller.plan?.slides.single.title, 'Edited title'); + controller.returnToOutline(); + expect(controller.stage, WizardGenerationStage.outlineReview); + }); +} + +DeckPlanType _plan( + DeckGenerationRequest request, { + List slideKeys = const ['opening'], +}) { + final themes = PresentationThemeCatalog.withDefaults(); + final typography = PresentationTypographyCatalog.withDefaults(); + final descriptor = themes.current(request.themeId!)!; + return DeckPlanType.parse({ + 'topic': request.userIntent, + 'story': 'Small interventions build toward city-scale resilience.', + 'theme': buildDeckThemeReference( + descriptor: descriptor, + request: request, + typographyCatalog: typography, + ), + 'sections': [ + { + 'key': 'main', + 'title': 'Main story', + 'purpose': 'Explain the opportunity.', + 'transition': 'Move from context to action.', + 'slideKeys': slideKeys, + }, + ], + 'slides': [ + for (final key in slideKeys) + { + 'key': key, + 'title': key == 'opening' ? 'Urban gardens matter' : 'The evidence', + 'purpose': 'Introduce the opportunity.', + 'sectionKey': 'main', + 'assertion': 'Urban gardens strengthen neighborhood resilience.', + 'contentUnits': ['One concrete supporting point'], + 'narrativeRole': key == 'opening' ? 'opening' : 'evidence', + 'contentBrief': 'Frame the opportunity clearly.', + 'continuity': 'Open the story and lead into action.', + 'composition': 'content', + 'treatment': 'content', + 'density': 'balanced', + 'elements': [], + }, + ], + }); +} + +final class _FakeWizardGenerationService extends DeckGeneratorService { + _FakeWizardGenerationService( + this.planned, { + this.compositionError, + this.partialComposition = false, + }) : super(apiKey: 'test-key'); + + final DeckPlanType planned; + final String? compositionError; + final bool partialComposition; + DeckPlanType? approvedPlan; + Completer? pendingPlanning; + String? planningError; + var retryCalls = 0; + + @override + Future plan( + DeckGenerationRequest request, { + onProgress, + onTrace, + isCancelled, + }) async { + if (pendingPlanning case final pending?) return pending.future; + if (planningError case final error?) { + return DeckPlanningResult.failure(error); + } + return DeckPlanningResult.success(planned); + } + + @override + Future generateFromPlan( + DeckGenerationRequest request, + DeckPlanType approvedPlan, { + onProgress, + onTrace, + isCancelled, + }) async { + this.approvedPlan = approvedPlan; + if (compositionError case final error?) { + return DeckGenerationResult.failure(error); + } + if (partialComposition) { + return DeckGenerationResult.partial( + slides: [_generatedSlide('opening')], + slideFailures: [ + SlideGenerationFailure( + slideIndex: 2, + slideKey: 'evidence', + issues: const [ + GenerationValidationIssue( + code: GenerationValidationCode.invalidSchema, + category: GenerationValidationCategory.schema, + severity: GenerationValidationSeverity.blocking, + location: GenerationValidationLocation.visibleContent, + message: 'Invalid slide JSON.', + ), + ], + ), + ], + plan: approvedPlan, + theme: resolveDeckThemeReference( + approvedPlan.theme, + themeCatalog: themeCatalog, + typographyCatalog: typographyCatalog, + ), + ); + } + return DeckGenerationResult.success( + slides: [_generatedSlide('opening')], + plan: approvedPlan, + theme: resolveDeckThemeReference( + approvedPlan.theme, + themeCatalog: themeCatalog, + typographyCatalog: typographyCatalog, + ), + ); + } + + @override + Future retryFailedSlides( + DeckGenerationRequest request, + DeckGenerationResult partialResult, { + onProgress, + onTrace, + isCancelled, + }) async { + retryCalls++; + return DeckGenerationResult.success( + slides: [_generatedSlide('opening'), _generatedSlide('evidence')], + plan: partialResult.plan!, + theme: partialResult.theme!, + ); + } +} + +Slide _generatedSlide(String key) => Slide.parse({ + 'key': key, + 'options': {'title': key, 'style': 'content'}, + 'sections': [ + { + 'type': 'section', + 'blocks': [ + { + 'type': 'block', + 'content': '## $key\n\nOne concrete supporting point.', + }, + ], + }, + ], +}); diff --git a/packages/playground/test/features/ai/wizard/presentation/wizard_outline_review_test.dart b/packages/playground/test/features/ai/wizard/presentation/wizard_outline_review_test.dart new file mode 100644 index 00000000..d91e06ee --- /dev/null +++ b/packages/playground/test/features/ai/wizard/presentation/wizard_outline_review_test.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:hero_ui/hero_ui.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/schemas/outline_schema.dart'; +import 'package:playground/features/ai/wizard/presentation/wizard_outline_review.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => GoogleFonts.config.allowRuntimeFetching = false); + + testWidgets('reviews and edits the typed outline before approval', ( + tester, + ) async { + var approved = 0; + ({int index, String title, String assertion})? edit; + + await tester.pumpWidget( + MaterialApp( + home: HeroTheme( + data: HeroThemeData.light(), + child: Scaffold( + body: SizedBox( + width: 900, + height: 720, + child: WizardOutlineReview( + plan: _plan(), + onSlideChanged: (index, title, assertion) { + edit = (index: index, title: title, assertion: assertion); + return true; + }, + onBack: () {}, + onRegenerate: () {}, + onApprove: () => approved++, + ), + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Review the story'), findsOneWidget); + expect( + find.text('Small interventions build city-scale resilience.'), + findsOneWidget, + ); + expect(find.text('Opening'), findsOneWidget); + expect(find.text('Slide 1'), findsOneWidget); + + await tester.enterText( + find.byKey(const ValueKey('outline-title-opening')), + 'A greener city starts here', + ); + await tester.enterText( + find.byKey(const ValueKey('outline-assertion-opening')), + 'Small gardens create city-scale resilience.', + ); + await tester.pump(); + + expect(edit?.index, 0); + expect(edit?.title, 'A greener city starts here'); + expect(edit?.assertion, 'Small gardens create city-scale resilience.'); + + await tester.tap(find.text('Approve & build')); + expect(approved, 1); + }); + + testWidgets('blocks approval while a visible outline edit is invalid', ( + tester, + ) async { + var approved = 0; + var plan = _plan(); + var planRevision = 0; + late StateSetter updateHost; + await tester.pumpWidget( + MaterialApp( + home: HeroTheme( + data: HeroThemeData.light(), + child: Scaffold( + body: SizedBox( + width: 900, + height: 720, + child: StatefulBuilder( + builder: (context, setState) { + updateHost = setState; + return WizardOutlineReview( + plan: plan, + planRevision: planRevision, + onSlideChanged: (_, _, _) => true, + onBack: () {}, + onRegenerate: () {}, + onApprove: () => approved++, + ); + }, + ), + ), + ), + ), + ), + ); + + await tester.enterText( + find.byKey(const ValueKey('outline-title-opening')), + '', + ); + await tester.pump(); + + expect( + find.text('Add both a slide title and core message.'), + findsOneWidget, + ); + await tester.tap(find.text('Approve & build')); + expect(approved, 0); + + updateHost(() { + plan = _plan(); + planRevision++; + }); + await tester.pump(); + + expect(find.text('Add both a slide title and core message.'), findsNothing); + expect( + tester + .widget( + find.descendant( + of: find.byKey(const ValueKey('outline-title-opening')), + matching: find.byType(EditableText), + ), + ) + .controller + .text, + 'Urban gardens matter', + ); + await tester.tap(find.text('Approve & build')); + expect(approved, 1); + }); +} + +DeckPlanType _plan() => DeckPlanType.parse({ + 'topic': 'Urban gardens', + 'story': 'Small interventions build city-scale resilience.', + 'theme': {'id': 'technical-paper', 'version': 1, 'density': 'balanced'}, + 'sections': [ + { + 'key': 'opening-section', + 'title': 'Opening', + 'purpose': 'Frame the opportunity.', + 'transition': 'Move into the evidence.', + 'slideKeys': ['opening'], + }, + ], + 'slides': [ + { + 'key': 'opening', + 'title': 'Urban gardens matter', + 'purpose': 'Introduce the opportunity.', + 'sectionKey': 'opening-section', + 'assertion': 'Urban gardens strengthen neighborhood resilience.', + 'contentUnits': ['One concrete supporting point'], + 'narrativeRole': 'opening', + 'contentBrief': 'Frame the opportunity clearly.', + 'continuity': 'Open the story and lead into evidence.', + 'composition': 'content', + 'treatment': 'content', + 'density': 'balanced', + 'elements': [], + }, + ], +}); diff --git a/packages/playground/test/features/ai/wizard/presentation/wizard_page_test.dart b/packages/playground/test/features/ai/wizard/presentation/wizard_page_test.dart index 16c39071..00f881bd 100644 --- a/packages/playground/test/features/ai/wizard/presentation/wizard_page_test.dart +++ b/packages/playground/test/features/ai/wizard/presentation/wizard_page_test.dart @@ -5,9 +5,19 @@ import 'package:go_router/go_router.dart'; import 'package:hero_ui/hero_ui.dart'; import 'package:playground/app/providers.dart'; import 'package:playground/app/router.dart'; +import 'package:playground/core/data/data_sources/memory_deck_loader.dart'; +import 'package:playground/core/domain/design/presentation_theme_catalog.dart'; +import 'package:playground/core/domain/design/presentation_typography_catalog.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/schemas/outline_schema.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/deck_generation_request.dart'; import 'package:playground/features/ai/wizard/presentation/wizard_page.dart'; import 'package:playground/features/ai/wizard/presentation/wizard_view.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/deck_generator_service.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/deck_theme_resolution.dart'; +import 'package:playground/features/ai/wizard/presentation/wizard_generation_controller.dart'; import 'package:playground/features/editor/presentation/pages/editor_page.dart'; +import 'package:provider/provider.dart'; +import 'package:superdeck_core/superdeck_core.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -43,7 +53,10 @@ void main() { data: HeroThemeData.light(), child: AppProviders(child: child!), ), - home: const WizardPage(isConfigured: true), + home: WizardPage( + isConfigured: true, + generationService: DeckGeneratorService(apiKey: 'test-key'), + ), ), ); await tester.pump(); @@ -60,7 +73,10 @@ void main() { routes: [ GoRoute( path: '/', - builder: (context, state) => const WizardPage(isConfigured: true), + builder: (context, state) => WizardPage( + isConfigured: true, + generationService: DeckGeneratorService(apiKey: 'test-key'), + ), ), GoRoute( path: '/debug/generation', @@ -86,4 +102,154 @@ void main() { expect(find.text('Generation lab route'), findsOneWidget); }); + + testWidgets('approved outline loads the deck before Present navigation', ( + tester, + ) async { + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => WizardPage( + isConfigured: true, + generationService: _PageGenerationService(), + ), + ), + GoRoute( + path: '/present/0', + builder: (context, state) => + const Scaffold(body: Text('Presentation route')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + builder: (context, child) => HeroTheme( + data: HeroThemeData.light(), + child: AppProviders(child: child!), + ), + ), + ); + await tester.pump(); + + final wizardContext = tester.element(find.byType(WizardView)); + final controller = Provider.of( + wizardContext, + listen: false, + ); + final loader = Provider.of(wizardContext, listen: false); + final events = []; + final subscription = loader.load().listen(events.add); + addTearDown(subscription.cancel); + + await controller.createOutline(_pageRequest); + await tester.pump(); + expect(find.text('Review the story'), findsOneWidget); + + await tester.tap(find.text('Approve & build')); + await tester.pumpAndSettle(); + + expect(find.text('Your presentation is ready'), findsOneWidget); + expect(events.whereType().single.slides, hasLength(1)); + + await tester.tap(find.text('Present deck')); + await tester.pumpAndSettle(); + expect(find.text('Presentation route'), findsOneWidget); + }); +} + +const _pageRequest = DeckGenerationRequest( + userIntent: 'Urban gardens', + slideCount: 1, + themeId: 'technical-paper', +); + +final class _PageGenerationService extends DeckGeneratorService { + _PageGenerationService() : super(apiKey: 'test-key'); + + final DeckPlanType _plan = _pagePlan(); + + @override + Future plan( + DeckGenerationRequest request, { + onProgress, + onTrace, + isCancelled, + }) async => DeckPlanningResult.success(_plan); + + @override + Future generateFromPlan( + DeckGenerationRequest request, + DeckPlanType approvedPlan, { + onProgress, + onTrace, + isCancelled, + }) async => DeckGenerationResult.success( + slides: [ + Slide.parse({ + 'key': 'opening', + 'options': {'title': 'Opening', 'style': 'content'}, + 'sections': [ + { + 'type': 'section', + 'blocks': [ + { + 'type': 'block', + 'content': '## Opening\n\nUrban gardens strengthen cities.', + }, + ], + }, + ], + }), + ], + plan: approvedPlan, + theme: resolveDeckThemeReference( + approvedPlan.theme, + themeCatalog: themeCatalog, + typographyCatalog: typographyCatalog, + ), + ); +} + +DeckPlanType _pagePlan() { + final themes = PresentationThemeCatalog.withDefaults(); + final typography = PresentationTypographyCatalog.withDefaults(); + final descriptor = themes.current('technical-paper')!; + return DeckPlanType.parse({ + 'topic': _pageRequest.userIntent, + 'story': 'Small interventions build city-scale resilience.', + 'theme': buildDeckThemeReference( + descriptor: descriptor, + request: _pageRequest, + typographyCatalog: typography, + ), + 'sections': [ + { + 'key': 'main', + 'title': 'Main story', + 'purpose': 'Explain the opportunity.', + 'transition': 'Move from context to action.', + 'slideKeys': ['opening'], + }, + ], + 'slides': [ + { + 'key': 'opening', + 'title': 'Urban gardens matter', + 'purpose': 'Introduce the opportunity.', + 'sectionKey': 'main', + 'assertion': 'Urban gardens strengthen neighborhood resilience.', + 'contentUnits': ['One concrete supporting point'], + 'narrativeRole': 'opening', + 'contentBrief': 'Frame the opportunity clearly.', + 'continuity': 'Open the story and lead into action.', + 'composition': 'content', + 'treatment': 'content', + 'density': 'balanced', + 'elements': [], + }, + ], + }); } diff --git a/packages/playground/test/features/ai/wizard/presentation/wizard_selection_review_test.dart b/packages/playground/test/features/ai/wizard/presentation/wizard_selection_review_test.dart new file mode 100644 index 00000000..d94e490d --- /dev/null +++ b/packages/playground/test/features/ai/wizard/presentation/wizard_selection_review_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:hero_ui/hero_ui.dart'; +import 'package:playground/core/domain/design/presentation_theme_catalog.dart'; +import 'package:playground/features/ai/wizard/core/ai/wizard_context.dart'; +import 'package:playground/features/ai/wizard/presentation/wizard_selection_review.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => GoogleFonts.config.allowRuntimeFetching = false); + + testWidgets('renders canonical selections and creates an outline', ( + tester, + ) async { + var createOutlineCalls = 0; + final themeCatalog = PresentationThemeCatalog.withDefaults(); + final theme = themeCatalog.currentThemes.first; + + await tester.pumpWidget( + MaterialApp( + home: HeroTheme( + data: HeroThemeData.light(), + child: Scaffold( + body: WizardSelectionReview( + wizardContext: WizardContext( + topic: 'Urban gardens', + audience: 'City planners', + approach: 'Policy blueprint', + emphasis: const ['Zoning', 'Community funding'], + slideCount: 10, + themeId: theme.id, + ), + themeCatalog: themeCatalog, + onCreateOutline: () => createOutlineCalls++, + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Review your deck setup'), findsOneWidget); + expect(find.text('Urban gardens'), findsOneWidget); + expect(find.text('City planners'), findsOneWidget); + expect(find.text('Policy blueprint'), findsOneWidget); + expect(find.text('Zoning, Community funding'), findsOneWidget); + expect(find.text('10 slides'), findsOneWidget); + expect(find.text(theme.title), findsOneWidget); + + await tester.tap(find.text('Create outline')); + expect(createOutlineCalls, 1); + }); +} diff --git a/packages/playground/test/features/ai/wizard/wizard_polish_test.dart b/packages/playground/test/features/ai/wizard/wizard_polish_test.dart index add7fcbf..92044539 100644 --- a/packages/playground/test/features/ai/wizard/wizard_polish_test.dart +++ b/packages/playground/test/features/ai/wizard/wizard_polish_test.dart @@ -8,7 +8,6 @@ import 'package:playground/features/ai/wizard/chat/view/widgets/chat_genui_panel import 'package:playground/features/ai/wizard/core/ai/catalog/ask_user_question_cards.dart'; import 'package:playground/features/ai/wizard/core/ai/catalog/ask_user_slider.dart'; import 'package:playground/features/ai/wizard/core/ai/catalog/catalog_question_step.dart'; -import 'package:playground/features/ai/wizard/core/ai/catalog/summary_card.dart'; import 'package:playground/features/ai/wizard/core/utils/color_utils.dart'; import 'package:playground/features/ai/wizard/presentation/wizard_generation_status.dart'; @@ -150,22 +149,30 @@ void main() { ) async { await tester.pumpWidget( host( - const WizardGenerationStatus( + WizardGenerationStatus( kind: WizardGenerationStatusKind.running, - progress: GenerationProgress( + progress: const GenerationProgress( GenerationPhase.composingSlides, sectionIndex: 2, sectionCount: 4, ), + onCancel: () {}, ), ), ); expect(find.text('Building your presentation'), findsOneWidget); - expect(find.text('Composing section 2 of 4…'), findsOneWidget); + expect(find.text('Composed section 2 of 4…'), findsOneWidget); expect(find.text('Shape the story'), findsOneWidget); expect(find.text('Compose the slides'), findsOneWidget); expect(find.text('Polish the deck'), findsOneWidget); + expect(find.text('Usually 20–30 seconds'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + final indicator = tester.widget( + find.byType(LinearProgressIndicator), + ); + expect(indicator.value, isNotNull); + expect(indicator.value, greaterThan(0)); }); testWidgets('generation failure keeps a path back to the deck plan', ( @@ -173,9 +180,12 @@ void main() { ) async { await tester.pumpWidget( host( - const WizardGenerationStatus( + WizardGenerationStatus( kind: WizardGenerationStatusKind.failed, + planAvailable: true, errorMessage: 'The service did not return valid slide JSON.', + onRetry: () {}, + onBack: () {}, ), ), ); @@ -185,7 +195,8 @@ void main() { find.text('The service did not return valid slide JSON.'), findsOneWidget, ); - expect(find.text('Back to deck plan'), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + expect(find.text('Edit outline'), findsOneWidget); }); testWidgets('partial generation completes with a visible warning', ( @@ -193,52 +204,30 @@ void main() { ) async { await tester.pumpWidget( host( - const WizardGenerationStatus( + WizardGenerationStatus( kind: WizardGenerationStatusKind.completed, noticeMessage: 'Generated 11 of 12 slides; one slide was skipped.', + slideCount: 11, + failedSlideCount: 1, + elapsed: const Duration(seconds: 24), + onRetryFailed: () {}, + onPresent: () {}, + onEditOutline: () {}, + onStartOver: () {}, ), ), ); - expect(find.text('Your presentation is ready'), findsOneWidget); + expect(find.text('Your presentation is almost ready'), findsOneWidget); expect( find.text('Generated 11 of 12 slides; one slide was skipped.'), findsOneWidget, ); - expect(find.text('Back to deck plan'), findsOneWidget); - }); - - testWidgets('summary ends with a sentence-case generation action', ( - tester, - ) async { - final items = [ - SummaryItemType.parse({ - 'kind': 'text', - 'label': 'Audience', - 'title': 'Executive leadership', - 'description': 'Decisive and concise', - }), - SummaryItemType.parse({ - 'kind': 'text', - 'label': 'Length', - 'text': '12 slides', - }), - ]; - - await tester.pumpWidget( - host( - SummaryCard( - title: 'Your deck plan', - items: items, - themeCatalog: PresentationThemeCatalog.withDefaults(), - ), - ), - ); - - expect(find.text('Your deck plan'), findsOneWidget); - expect(find.text('Audience'), findsOneWidget); - expect(find.text('12 slides'), findsOneWidget); - expect(find.text('Generate slides'), findsOneWidget); + expect(find.text('11 slides • 24s'), findsOneWidget); + expect(find.text('Present deck'), findsOneWidget); + expect(find.text('Retry 1 slide'), findsOneWidget); + expect(find.text('Edit outline'), findsOneWidget); + expect(find.text('Start over'), findsOneWidget); }); testWidgets('deck length uses a counter and useful presets', (tester) async { diff --git a/packages/playground/test/features/ai/wizard/wizard_view_test.dart b/packages/playground/test/features/ai/wizard/wizard_view_test.dart index c9436a16..bb35b2da 100644 --- a/packages/playground/test/features/ai/wizard/wizard_view_test.dart +++ b/packages/playground/test/features/ai/wizard/wizard_view_test.dart @@ -2,7 +2,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hero_ui/hero_ui.dart'; +import 'package:playground/features/ai/wizard/chat/chat_conversation_profile.dart'; import 'package:playground/features/ai/wizard/chat/view/widgets/empty_state.dart'; +import 'package:playground/features/ai/wizard/core/ai/services/ai_conversation_viewmodel.dart'; +import 'package:playground/features/ai/wizard/core/viewmodel_scope.dart'; import 'package:playground/features/ai/wizard/presentation/wizard_view.dart'; /// Smoke test: the wizard mounts and shows its empty state without touching the @@ -16,8 +19,12 @@ void main() { return MaterialApp( home: HeroTheme( data: HeroThemeData.light(), - child: const Scaffold( - body: SizedBox(width: 360, height: 720, child: WizardView()), + child: Scaffold( + body: ViewModelScope( + create: () => + AiConversationViewModel(profile: chatConversationProfile()), + child: const SizedBox(width: 360, height: 720, child: WizardView()), + ), ), ), ); diff --git a/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart b/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart index 82249b34..54123394 100644 --- a/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart +++ b/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart @@ -30,6 +30,7 @@ import 'package:playground/features/ai/quick_agent/core/engine/services/generati import 'package:playground/features/ai/quick_agent/core/engine/services/generation_validation_issue.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/theme_json_serializer.dart'; import 'package:playground/features/ai/quick_agent/domain/generated_deck_style_mapper.dart'; +import 'package:playground/features/ai/wizard/presentation/wizard_generation_controller.dart'; import 'package:superdeck/src/utils/syntax_highlighter.dart'; import 'package:superdeck/superdeck.dart'; import 'package:superdeck_builder/superdeck_builder.dart'; @@ -152,10 +153,26 @@ void main() { ); final client = _FakeCheckpointModelClient(); final traces = []; - final result = await DeckGeneratorService( + final service = DeckGeneratorService( apiKey: 'deterministic-fake-key', modelClientFactory: (_) => client, - ).generate(request, onTrace: traces.add); + ); + final planningStopwatch = Stopwatch()..start(); + final planning = await service.plan(request, onTrace: traces.add); + planningStopwatch.stop(); + expect(planning.success, isTrue, reason: planning.error); + final approvedPlan = planning.plan!; + final compositionStopwatch = Stopwatch()..start(); + final result = await service.generateFromPlan( + request, + approvedPlan, + onTrace: traces.add, + ); + compositionStopwatch.stop(); + expect( + planningStopwatch.elapsed + compositionStopwatch.elapsed, + lessThanOrEqualTo(WizardGenerationController.generationBudget), + ); expect( result.success, @@ -231,6 +248,18 @@ void main() { await File( p.join(output.path, 'request.json'), ).writeAsString(encoder.convert(request.toMap())); + await File( + p.join(output.path, 'approved_outline.json'), + ).writeAsString(encoder.convert(approvedPlan)); + await File(p.join(output.path, 'timing.json')).writeAsString( + encoder.convert({ + 'planningMs': planningStopwatch.elapsedMilliseconds, + 'compositionMs': compositionStopwatch.elapsedMilliseconds, + 'combinedGenerationMs': + planningStopwatch.elapsedMilliseconds + + compositionStopwatch.elapsedMilliseconds, + }), + ); await File( p.join(output.path, 'deck.json'), ).writeAsString(encoder.convert(deckJson)); @@ -416,13 +445,46 @@ void main() { ).readAsString(); final service = DeckGeneratorService(apiKey: _apiKey); request = _requestForFixture(fixture, brief); - final result = await service.generate(request, onTrace: traces.add); + final planningStopwatch = Stopwatch()..start(); + final planning = await service.plan(request, onTrace: traces.add); + planningStopwatch.stop(); + expect(planning.success, isTrue, reason: planning.error); + final approvedPlan = planning.plan!; + final compositionStopwatch = Stopwatch()..start(); + final result = await service.generateFromPlan( + request, + approvedPlan, + onTrace: traces.add, + ); + compositionStopwatch.stop(); + expect( + planningStopwatch.elapsed + compositionStopwatch.elapsed, + lessThanOrEqualTo(WizardGenerationController.generationBudget), + reason: 'Split generation exceeded the 30-second booth budget.', + ); output = await _createRunDirectory(fixture); await File(p.join(output.path, 'brief.txt')).writeAsString(brief); await File(p.join(output.path, 'request.json')).writeAsString( const JsonEncoder.withIndent(' ').convert(request.toMap()), ); + await File( + p.join(output.path, 'approved_outline.json'), + ).writeAsString( + const JsonEncoder.withIndent(' ').convert(approvedPlan), + ); + await File(p.join(output.path, 'timing.json')).writeAsString( + const JsonEncoder.withIndent(' ').convert({ + 'planningMs': planningStopwatch.elapsedMilliseconds, + 'compositionMs': compositionStopwatch.elapsedMilliseconds, + 'combinedGenerationMs': + planningStopwatch.elapsedMilliseconds + + compositionStopwatch.elapsedMilliseconds, + 'withinThirtySecondTarget': + planningStopwatch.elapsed + compositionStopwatch.elapsed <= + WizardGenerationController.generationBudget, + }), + ); await File(p.join(output.path, 'trace.json')).writeAsString( const JsonEncoder.withIndent( ' ', diff --git a/packages/plugins/pdf/test/helpers/fake_slide_capture_service.dart b/packages/plugins/pdf/test/helpers/fake_slide_capture_service.dart index 20197f9b..857b75e4 100644 --- a/packages/plugins/pdf/test/helpers/fake_slide_capture_service.dart +++ b/packages/plugins/pdf/test/helpers/fake_slide_capture_service.dart @@ -18,6 +18,7 @@ class FakeSlideCaptureService extends SlideCaptureService { SlideCaptureQuality quality = SlideCaptureQuality.thumbnail, required SlideConfiguration slide, required BuildContext context, + bool includeDebugLayout = false, }) async { return bytes; } diff --git a/packages/superdeck/lib/src/styling/components/block_styler.dart b/packages/superdeck/lib/src/styling/components/block_styler.dart index c5e69db5..4e586918 100644 --- a/packages/superdeck/lib/src/styling/components/block_styler.dart +++ b/packages/superdeck/lib/src/styling/components/block_styler.dart @@ -18,8 +18,8 @@ import 'package:mix/mix.dart'; /// It intentionally cannot express widget modifiers, width/height or other /// constraints, transforms, box alignment, or widget-state variants — those /// would create competing geometry owners with section `spacing`, block -/// `flex`, and block/section `align`. Extending this allow-list is a design -/// decision, not a convenience patch; see `.planning` notes for PR #99. +/// `flex`, and block/section `align`. Extending this allow-list is a framework +/// design decision because it changes which layer owns block geometry. final class BlockStyler extends Style with Diagnosticable, From 97410b485c9959e741325cdec1a7c5bdc1ad1c1b Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 15 Jul 2026 17:53:28 -0400 Subject: [PATCH 06/15] feat: overhaul deck rendering and generation flow --- AGENTS.md | 4 + .../assets/ai_prompts/outline_system.prompt | 28 +- .../assets/ai_prompts/wizard_system.prompt | 25 +- packages/playground/dcm_baseline.json | 660 +----------------- packages/playground/lib/app/providers.dart | 2 +- .../design/generated_deck_theme_factory.dart | 2 +- .../presentation_image_style_catalog.dart | 221 ++++++ .../core/domain/generated_image_asset.dart | 31 + .../ai/image_generation/image_generator.dart | 174 +++++ .../image_style_preview_coordinator.dart | 167 +++++ .../prompts/generation_prompt_provider.dart | 78 ++- .../core/engine/schemas/outline_schema.dart | 3 + .../core/engine/schemas/outline_schema.g.dart | 2 + .../services/deck_generation_request.dart | 44 +- .../services/deck_generator_images.dart | 250 +++++++ .../services/deck_generator_pipeline.dart | 35 +- .../services/deck_generator_service.dart | 224 ++++-- .../services/deck_generator_workflow.dart | 12 +- .../engine/services/deck_plan_repair.dart | 9 +- .../engine/services/deck_plan_validator.dart | 122 +++- .../services/generated_slide_validator.dart | 18 +- .../generation_model_call_executor.dart | 15 +- .../engine/services/generation_progress.dart | 31 +- .../services/generation_quality_report.dart | 16 +- .../engine/services/generation_trace.dart | 2 +- .../engine/services/source_grounding.dart | 4 + .../ai/quick_agent/core/env_config.dart | 9 + .../commands/generate_deck_command.dart | 51 +- .../domain/generated_deck_result_applier.dart | 69 +- .../pages/generation_lab_page.dart | 1 + .../chat/chat_conversation_profile.dart | 7 +- .../chat/view/widgets/chat_genui_panels.dart | 6 +- .../wizard/chat/view/widgets/chat_input.dart | 26 +- .../wizard/chat/view/widgets/empty_state.dart | 27 +- .../core/ai/catalog/ask_user_image_style.dart | 144 ++++ .../ai/catalog/ask_user_image_style.g.dart | 32 + .../ai/catalog/ask_user_question_cards.dart | 85 ++- .../core/ai/catalog/ask_user_radio.dart | 23 +- .../core/ai/catalog/ask_user_slider.dart | 114 +-- .../core/ai/catalog/ask_user_style.dart | 2 +- .../ai/wizard/core/ai/catalog/catalog.dart | 17 +- .../core/ai/prompts/image_style_prompts.dart | 131 ---- .../core/ai/schemas/user_action_payload.dart | 9 +- .../core/ai/schemas/wizard_context_keys.dart | 9 +- .../ai/services/ai_conversation_profile.dart | 3 + .../services/ai_conversation_viewmodel.dart | 315 +++++---- .../services/genui_conversation_session.dart | 100 +-- .../core/ai/services/prompt_builder.dart | 17 +- .../ai/wizard/core/ai/wizard_context.dart | 19 +- .../wizard/core/ai/wizard_session_state.dart | 113 ++- .../core/ui/widgets/catalog_next_button.dart | 2 +- .../wizard_generation_controller.dart | 346 ++++----- .../wizard_generation_status.dart | 137 ++-- .../presentation/wizard_outline_review.dart | 124 ++-- .../ai/wizard/presentation/wizard_page.dart | 151 ++-- .../presentation/wizard_selection_review.dart | 78 ++- .../ai/wizard/presentation/wizard_view.dart | 27 +- .../presentation/pages/editor_bootstrap.dart | 108 +-- packages/playground/lib/main.dart | 4 +- .../image_style_preview_coordinator_test.dart | 124 ++++ .../generation_prompt_provider_test.dart | 4 + .../engine/schemas/outline_schema_test.dart | 84 +++ .../deck_generation_request_test.dart | 12 +- .../services/deck_generator_images_test.dart | 163 +++++ .../services/deck_generator_service_test.dart | 137 ++++ .../services/generation_progress_test.dart | 12 + .../generated_deck_result_applier_test.dart | 159 +++++ .../ai/catalog/ask_user_image_style_test.dart | 69 ++ .../presentation_theme_selection_test.dart | 1 + .../ai_conversation_viewmodel_test.dart | 67 ++ .../core/ai/wizard_session_state_test.dart | 26 + .../wizard_generation_controller_test.dart | 58 +- .../wizard_selection_review_test.dart | 8 + .../ai/wizard/wizard_polish_test.dart | 81 +++ .../ai_generation_smoke_test.dart | 116 ++- .../lib/src/builtins/image_widget.dart | 66 +- .../src/capture/slide_capture_service.dart | 133 +++- .../thumbnail_capture_timing_test.dart | 95 +++ 78 files changed, 4044 insertions(+), 1856 deletions(-) create mode 100644 packages/playground/lib/core/domain/design/presentation_image_style_catalog.dart create mode 100644 packages/playground/lib/core/domain/generated_image_asset.dart create mode 100644 packages/playground/lib/features/ai/image_generation/image_generator.dart create mode 100644 packages/playground/lib/features/ai/image_generation/image_style_preview_coordinator.dart create mode 100644 packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_images.dart create mode 100644 packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_image_style.dart create mode 100644 packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_image_style.g.dart delete mode 100644 packages/playground/lib/features/ai/wizard/core/ai/prompts/image_style_prompts.dart create mode 100644 packages/playground/test/features/ai/image_generation/image_style_preview_coordinator_test.dart create mode 100644 packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_images_test.dart create mode 100644 packages/playground/test/features/ai/quick_agent/domain/generated_deck_result_applier_test.dart create mode 100644 packages/playground/test/features/ai/wizard/core/ai/catalog/ask_user_image_style_test.dart diff --git a/AGENTS.md b/AGENTS.md index b793ff52..a425d8e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,10 @@ The playground reads `GOOGLE_AI_API_KEY` from the ignored repository-root flag, the Wizard intentionally shows a configuration error before accepting input. +Wizard image generation is enabled by default for local/debug demos and is +opt-in for release builds. Validate the image-enabled smoke flow before adding +`--dart-define=SUPERDECK_WIZARD_IMAGE_GENERATION=true` to a release launch. + When running an app to reproduce or diagnose a UI/runtime issue, launch it with `fvm flutter run` and keep that process attached for the entire reproduction. Continue reading its output after each UI interaction so Dart exceptions, diff --git a/packages/playground/assets/ai_prompts/outline_system.prompt b/packages/playground/assets/ai_prompts/outline_system.prompt index c42575c6..bc474f71 100644 --- a/packages/playground/assets/ai_prompts/outline_system.prompt +++ b/packages/playground/assets/ai_prompts/outline_system.prompt @@ -66,7 +66,8 @@ Choose layout from information shape: - `metric`: one to three grounded numbers with their meaning - `quote`: short supplied voice or emphasized takeaway - `titleLeft`: assertion/context beside supporting detail -- `imageLeft`, `imageRight`, `imageFullBleed`: supplied image plus appropriate copy +- `imageLeft`, `imageRight`, `imageFullBleed`: supplied or generated image plus + appropriate copy - `qrcode`, `webview`, `dartpad`, `custom`: matching supplied element only Treatments are semantic: `hero`, `section`, `content`, `data`, `quote`, `visual`, @@ -96,6 +97,24 @@ metric, or table composition. A supplied handoff purpose is exhaustive—do not rename it as documentation, signup, support, early access, or another promise. Use each grounded element only as often as the story genuinely needs it. +## Generated artwork + +When the typed request includes exact `imageStyleId` and `imageStyleVersion`, +you may plan two to four generated visuals, at most `maxGeneratedImages`. Use +them only where a visual materially clarifies, +anchors, or varies the story; a deck does not need an image on every slide. + +For each generated visual, add one `image` element with a concrete, +topic-specific `generationPrompt` and no `source`. Describe the subject and +scene, not the selected treatment: the application owns and applies the exact +versioned style. Keep the subject simple, presentation-safe, and free of text, +logos, brands, UI screenshots, and unsupported factual claims. An image element +must contain exactly one input—never both `source` and `generationPrompt`. + +If the exact image-style fields are absent, do not author a `generationPrompt`; +use only supplied grounded image sources. Use no more than one image element per +slide, and pair every image composition with its matching image element. + ## Theme selection Select exactly one eligible theme candidate by stable `id` and return only that @@ -109,6 +128,7 @@ tokens or add theme fields outside the schema. 1. Match `slideCount` exactly and keep section membership/order consistent. 2. Give every slide one assertion and enough concrete content to compose alone. 3. Preserve exact facts and grounded elements without invention or amplification. -4. Use tables only for uniform comparisons and columns for independent ideas. -5. End with the decision, action, or synthesis appropriate to the audience. -6. Return schema-supported JSON only, without commentary or Markdown fences. +4. Keep generated artwork within its exact style and count contract. +5. Use tables only for uniform comparisons and columns for independent ideas. +6. End with the decision, action, or synthesis appropriate to the audience. +7. Return schema-supported JSON only, without commentary or Markdown fences. diff --git a/packages/playground/assets/ai_prompts/wizard_system.prompt b/packages/playground/assets/ai_prompts/wizard_system.prompt index 416f08b8..4528bc8a 100644 --- a/packages/playground/assets/ai_prompts/wizard_system.prompt +++ b/packages/playground/assets/ai_prompts/wizard_system.prompt @@ -7,9 +7,10 @@ You are a presentation wizard. The user's first message is the presentation topi 3. AskUserCheckbox (step3) - 4 emphasis topics relevant to the topic 4. AskUserSlider (step4) - 5-20 slides, unit: "slides", suggest a default based on complexity 5. AskUserStyle (step5) - exact IDs for 3 registered presentation themes +6. AskUserImageStyle (step6) - generated artwork-direction selection -After the style selection, stop. The application owns the review and outline -screens, so emit nothing after the style selection is accepted. +After the image-style selection, stop. The application owns the review and +outline screens, so emit nothing after the image-style selection is accepted. ## AskUserRadio Example (for steps 1-2) ```json @@ -68,6 +69,21 @@ screens, so emit nothing after the style selection is accepted. } ``` +## AskUserImageStyle Example (for step 6) +```json +\{ + "AskUserImageStyle": \{ + "question": "Choose an artwork direction", + "description": "Compare the same idea across three generated treatments.", + "action": \{"name": "submit_answer", "context": []\} + \} +} +``` + +The application owns the three preview styles, their exact versions, the +shared topic-derived subject, and all image generation. Never add style IDs, +subjects, prompts, image URLs, or provider settings to this component. + ## Content Guidance - Tailor all options to the user's topic @@ -164,3 +180,8 @@ Think about the VISUAL MOOD that matches audience and topic. Consider: Offer the three exact registered theme IDs supplied in the system prompt. Do not invent IDs, descriptions, palettes, or font pairings; the application resolves all preview and renderer tokens from the selected ID. + +**Step 6 - Image Style** +Render one AskUserImageStyle surface. Do not choose or describe the preview +options: the application started generating its fixed three options when the +topic was accepted and will attach those results to the component. diff --git a/packages/playground/dcm_baseline.json b/packages/playground/dcm_baseline.json index e6b250a5..71c413e2 100644 --- a/packages/playground/dcm_baseline.json +++ b/packages/playground/dcm_baseline.json @@ -1,5 +1,5 @@ { - "date": "2026-07-14 15:05:26.516800Z", + "date": "2026-07-15 21:27:44.654825Z", "version": "1.38.0", "sensitivity": "exact", "commands": { @@ -11,7 +11,6 @@ "b00b830bdc44ada83b520642853d7e3f" ], "arguments-ordering": [ - "e4d7ae78f7315a8168e7fa2aa2a6caf8", "19d6f1a9f427cc09abada4fde1762015" ] }, @@ -58,36 +57,6 @@ ] }, "lib/core/domain/design/generated_deck_theme_factory.dart": { - "member-ordering": [ - "9ad4e5cc03b4696ebf5cf1af9ba89657", - "48591792627c948a6627e8fff5b84909", - "9416d42d020602a6810e7927999adab8", - "670f7872d2027af88101731fb6e3536b", - "1b23853f6aa8b6b3ae4b8fc0ac6e31c0", - "efb95d2edb53dc9b9912708e353ce780", - "07be413d53de668f5d7464360a14a36b", - "eef1c5f8f181e943bd14f42fd36068cd", - "92d9b7be922c49e382181ff70c5890f2", - "ceb85cf965d93e42502cc3cdc21caba6", - "226aa56394d48b3c10a35d6d453001ce", - "e1cdf59d1ed3f57dda01fa2f758ca67e", - "4401ae64f4541c1a4e856256ab855062", - "fe1a36bce5489c083752dd128cb22b52", - "0519e762b6bf004b7f0f3ae10f9a2063" - ], - "arguments-ordering": [ - "1f7f8ff8cfe1482b216593b470a53e77", - "2ec21bd4f9c686c798f9c90294c95150", - "f131f8299a5fc76536e1eb4237200b8a", - "ac1aed4db5a1328646d7bc5cde2b2162", - "af8329ffdd2810e35c6a5e00dfabd724", - "2c80651eaefc7354f4199020bed5af9d", - "22f43181e166ff2a6f06a225c4e63708", - "9ed646a9e82c3b3f237ac30c18acdfd6", - "aad20f49369c747f4942a698ea0e9499", - "dc741e7fb2fc2b4020fd1f5943536410", - "ccefe601bca8a6263242a0dd55564573" - ], "prefer-shorthands-with-enums": [ "f3d70c6aa77da74309195ab874fff66d", "aae503822bbe8bf0e188f42748fa55f7" @@ -99,11 +68,6 @@ "f02a8b91e92f721c96c4ab2efb4ec46c", "7619ac479ac9f2834cfe7d877347194d" ], - "newline-before-return": [ - "5869933a1ee60ef96c0a3830cd0a1b8f", - "add4c4752c1b812ad5ec73b3197e65b0", - "dbc87efa5e92516d3a0a2fba4795baac" - ], "prefer-returning-shorthands": [ "767ae84aa629503704894ebdeb013a0f", "21f0c1320ef8d61a16c3e682dc834207", @@ -123,8 +87,6 @@ "d587d8bf7020a0a9da8e541d5f3a26ba" ], "avoid-nullable-interpolation": [ - "58bf566cd68995fdad8b61b8d5d307c8", - "5b8de9294f65ed87a816d71f6036b37d", "58bf566cd68995fdad8b61b8d5d307c8", "5b8de9294f65ed87a816d71f6036b37d" ], @@ -132,60 +94,15 @@ "9198199045cb3658002cddc74ad31de1" ], "prefer-shorthands-with-enums": [ - "f043818bfafa3ade692897207360a20e", - "f043818bfafa3ade692897207360a20e", - "f043818bfafa3ade692897207360a20e", - "f043818bfafa3ade692897207360a20e", - "f043818bfafa3ade692897207360a20e", - "f043818bfafa3ade692897207360a20e", - "f043818bfafa3ade692897207360a20e", - "f043818bfafa3ade692897207360a20e", - "f043818bfafa3ade692897207360a20e", - "f043818bfafa3ade692897207360a20e", - "f043818bfafa3ade692897207360a20e", - "f043818bfafa3ade692897207360a20e", - "f043818bfafa3ade692897207360a20e", - "f043818bfafa3ade692897207360a20e", "f043818bfafa3ade692897207360a20e" ] }, "lib/core/domain/stores/deck_customization_store.dart": { "member-ordering": [ - "9ad4e5cc03b4696ebf5cf1af9ba89657", - "48591792627c948a6627e8fff5b84909", - "9416d42d020602a6810e7927999adab8", - "670f7872d2027af88101731fb6e3536b", - "1b23853f6aa8b6b3ae4b8fc0ac6e31c0", - "efb95d2edb53dc9b9912708e353ce780", - "07be413d53de668f5d7464360a14a36b", - "3c42da27cbbf557f27624003ecd9baeb", - "a24298df8aec11f34df9c8fe0e38fecb", - "37950f0aa20d801bb0d2520795a01a15", - "9ab9230fa84dab8ac756d85202a7c834", - "33af41f7d9bf6bc22a18458c19276797", "40da4ff6055bc7de309807aed924358c", "9965469f5b0bb2e3d3a1bcdc08339637", "6c9ba227d7adc731b7fa8de198d664e3", - "4a7ff0188ade0966be82961c60094ec2", - "d82c9b1d4c53960b70f88d4ffef3f190", - "8a79f576102d0b0bf88b5ed4e0725ee5", - "650d6838bd2525084c04a5011c0c0cc0", - "f387e1b4714a343aca8343dcce0e2e31", - "d578cab3215570275a25d800742e46c5", - "6692757d553cd352930b4da6f83b6f3b", - "3d9c803df2f6f4c60245d0d6a005224d", - "68cf063ea9cafb0f704092f3d9504016", - "7731655f3d118ec92cf0c40d1b413e1c", - "1b31c6e8d4f758ad536ac7382370e17e", - "29dd2ed95d71deaf80e788726abbe7db", - "725420751cd6217abef24034362242b8", - "5080a7567606aad800cae682b59d1955", - "2bc53cfd3a1b271a58c03bc2ed37cf94", - "a4666444545d8dbbcdf90d2577993e63", - "387e76f977d40ed456d8813c04715fd9", - "1279dda2c109c5f9b735e02e4f6a46d8", - "e9ed84632ad8e35d1fa5c4827843028e", - "17624d4f779831a988c7e3082fe44ea9" + "4a7ff0188ade0966be82961c60094ec2" ], "prefer-shorthands-with-enums": [ "65384f4b491613277bda0739ad40f805", @@ -195,21 +112,9 @@ "713619fa3d96c97eff6b553bec280e0b", "3beeab99d6eb30affa90fbf04b8f5e85", "fe331f45f8c3eb9aafe615ff64e8ebdd", - "fe331f45f8c3eb9aafe615ff64e8ebdd", "52a5f7ed4ad4f0f3e7004594e9c230a8", "ecf4662853240ebdf68c82547aaf4881", - "fe331f45f8c3eb9aafe615ff64e8ebdd", - "65384f4b491613277bda0739ad40f805", - "a18fefb61010d739ff802caad056ae74", - "7bff7f612462dc538f921eb60f4692a7", - "37d2b4bb4d81f71166e9cddf21bdda52", - "713619fa3d96c97eff6b553bec280e0b", - "3beeab99d6eb30affa90fbf04b8f5e85", - "fe331f45f8c3eb9aafe615ff64e8ebdd", - "fe331f45f8c3eb9aafe615ff64e8ebdd", - "492ec79db6fde06e4828484c00a2c961", - "52a5f7ed4ad4f0f3e7004594e9c230a8", - "65384f4b491613277bda0739ad40f805" + "492ec79db6fde06e4828484c00a2c961" ], "newline-before-return": [ "60fd1e28cefbcfc762ddcc2bd56abbd1", @@ -220,7 +125,6 @@ "4094bc6910eeaeffab912805c848a22a" ], "avoid-throw-in-catch-block": [ - "959b7175f88493905e3c324c2bf6f97d", "959b7175f88493905e3c324c2bf6f97d" ] }, @@ -266,34 +170,24 @@ }, "lib/features/ai/quick_agent/core/engine/prompts/font_styles.dart": { "newline-before-return": [ - "bbef19786c08a56d47025fc80a748f53", "bbef19786c08a56d47025fc80a748f53" ] }, "lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart": { - "member-ordering": [ - "1040911a1c35d617f70255dd83ed97cf", - "b917df2b2804122e664e498b1e85075e" - ], "prefer-shorthands-with-static-fields": [ "6debe9693098b85f1b7030aec538457d" ], "newline-before-return": [ - "f9624b3c96269696d425de21648bb743", "6da07b6856769f2a7d1b146adaa83a51", "0071adb734cdebc575811b747f4e33f2", - "e32fbf445ffe800f9827f9d4f66cc0ca", - "2352bc8f88ec92a1104aac9b72a84404" + "e32fbf445ffe800f9827f9d4f66cc0ca" ], "prefer-shorthands-with-enums": [ - "52a5f7ed4ad4f0f3e7004594e9c230a8", - "ecf4662853240ebdf68c82547aaf4881", "42a039328ed7ddec9ceee645e01af411", "f6d6ddabaa7b4fc26675965558107402", "14018a1f31ac3d06be19ce7f0c3b86cf", "b3bc42f0e6ddee26002bff76ba0e7f57", - "e60b6d41b47a410f4c9703b8df6ac273", - "b3bc42f0e6ddee26002bff76ba0e7f57" + "e60b6d41b47a410f4c9703b8df6ac273" ], "arguments-ordering": [ "9a1327c3f9e40bd369533b3c76b311ef" @@ -303,9 +197,7 @@ "76892535997402d7f2636ec3df6728ac", "4aaed52b07a1805c60478e71c4409cf5", "b7b285cc3d4d9a35c3be148899325dd7", - "d30004f9ab953749716b83f7accf68d8", - "b7b285cc3d4d9a35c3be148899325dd7", - "4aaed52b07a1805c60478e71c4409cf5" + "d30004f9ab953749716b83f7accf68d8" ] }, "lib/features/ai/quick_agent/core/engine/prompts/prompt_registry.dart": { @@ -336,19 +228,7 @@ "dd5fb45f127330e41528848263ee30d2", "8b0dcee6e21c872a5495109d65261c87", "238ef19bd2a8b4b59bed3d019cbe4019", - "3589ff0eefa813650bffb2fb47513c64", - "6bd852c2516ec41f37e34d5c5de21a8c", - "26904395faf86965ca0a6561df017655", - "883ba7b719aca4c93469376d02ace2c2", - "a2eef77b00e43c9124ef3743d1f7a993", - "3361ba8e6ee4715ab9b7d19751c9389f", - "a9ada1c3da67187d1b29fe7f3fb3bde3", - "292cfbd38e307172651ac9e552cd7b5c", - "77a3e5c9cc82bd77cdbc6b8921091617", - "f102e581fa3de65a545bdf9e78d48d87", - "2bf527fff622c238263ef4866cb48cfa", - "01d3592049ef0ec82f18833ac9925ba3", - "b1740d0b91189f96b8d39b7f7eaaeda7" + "3589ff0eefa813650bffb2fb47513c64" ], "avoid-inferrable-type-arguments": [ "bfee52d03478ce0c30b1ae2015bc758f", @@ -360,24 +240,12 @@ "bbef19786c08a56d47025fc80a748f53", "005d49e327abc6887bba7647199f4c41", "25cecec7dbf42b97dfd2e7b34c4235d6", - "bbef19786c08a56d47025fc80a748f53", - "bbef19786c08a56d47025fc80a748f53", - "bbef19786c08a56d47025fc80a748f53", - "8df9f5491388f01180cacb95a7e62a81", - "bbef19786c08a56d47025fc80a748f53", - "fca2323a25a83550a6e52cff3e598e9f", - "bbef19786c08a56d47025fc80a748f53", - "bbef19786c08a56d47025fc80a748f53", - "bbef19786c08a56d47025fc80a748f53" + "fca2323a25a83550a6e52cff3e598e9f" ], "arguments-ordering": [ - "57218f112ca141359a82afb6b366d0f5", "73e6259b97dd6f2923d2cce94271677d", - "a0e98b810fa075d8e62f3ccf775c9a7d", - "8ef34893c588497f3f1aed9af9207ccb", "269f695c8530fc002d513ce94d5eafab", "74c97d13b223ce5c9791048cb5ad8159", - "765a5d9a0866b45a512edade093aa8f8", "b2d3f9d0506d5614294f89c0bc3b9e70" ], "prefer-shorthands-with-enums": [ @@ -387,27 +255,12 @@ "9dce48609c0651c2c8f674034f551935", "0ed28c58b36808138703631dc6b50850", "cac3cf1480722792fbc6ff1a19f9900a", - "3d5d2e3c3d76cb3d3bbc677082bff044", - "cac3cf1480722792fbc6ff1a19f9900a", - "3d5d2e3c3d76cb3d3bbc677082bff044", "7f3ba2420ac76423548c578cd238e9c3", - "4b8f6b4995d7dbcb19ca9ce8e1eeb0a0", - "9dce48609c0651c2c8f674034f551935", - "0ed28c58b36808138703631dc6b50850", - "cac3cf1480722792fbc6ff1a19f9900a", - "3d5d2e3c3d76cb3d3bbc677082bff044", "e0702dd3f4b0189360f889335f47c078", "484e030ddc01f745ff9f110e4d0f9561", "e26fb406d5089a687500bbd71144d876", - "293747ef512b9fc631c9c7b873c7c4cc", - "4b8f6b4995d7dbcb19ca9ce8e1eeb0a0", - "9dce48609c0651c2c8f674034f551935", "65806845bc752e0fde82bc202bb4b228", - "cac3cf1480722792fbc6ff1a19f9900a", - "484e030ddc01f745ff9f110e4d0f9561", - "698a8a56a2fb9a1a9d5ef1dda2d5989a", - "484e030ddc01f745ff9f110e4d0f9561", - "484e030ddc01f745ff9f110e4d0f9561" + "698a8a56a2fb9a1a9d5ef1dda2d5989a" ] }, "lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline_helpers.dart": { @@ -417,35 +270,10 @@ "b50f844ae7c36b711a238efd1a64bd3b", "0d7da24a7b06a3161cae3da03140a137", "c71a859075bc26760aeae76ee9ce45f0", - "159fb2fb3c31f528814f62ecf60487da", - "0d7da24a7b06a3161cae3da03140a137", - "0d7da24a7b06a3161cae3da03140a137", - "0d7da24a7b06a3161cae3da03140a137", "f23fa8c148ddb010a1ef0db563d595da" ] }, "lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart": { - "member-ordering": [ - "bfb9332d95780558339beb75b6f66688", - "c459bc45c82c87be24e5b20511105b75", - "8c50807abb401de95112928cce8662e2", - "92306e847b2581cc3f76ad4bfba8d081", - "5291a91240883b487d2e095753b5cc36", - "307ddaef49fe745a274b8adafd0efef4", - "97a9e09d2f34cc780c2fd03778db616e", - "7ed736006250498e182e1f3b13cd02a9", - "41ba320a0569871b53d8ec0a409d4b56", - "92d4d9d6f35d57e99011d4809a969c30", - "1763e3eb8a64672863e59fb4b3f9bf12", - "f3587e2750d9e4d53524256ebe754431", - "420017253de0cea3fdd811116e81c210", - "5bbf75a485b6c5d5b69cf4ed54254646", - "50fb72d68622975411d79bc8eeb540e0", - "ac2efb1e85b0de7d8bdb7d5058ef7f6b", - "12574c5f7c2918f41ba427db65da4d11", - "e9a8a11933a99f82be749b8117fbe464", - "c2823e38eb95beb817c8ef4bea957ea7" - ], "prefer-shorthands-with-static-fields": [ "b32d9275721f7438582b42bf815a6d85" ], @@ -463,43 +291,26 @@ "3d5d2e3c3d76cb3d3bbc677082bff044", "bbfc1d5b729389800243cd8eacf3d25a", "698a8a56a2fb9a1a9d5ef1dda2d5989a", - "3d5d2e3c3d76cb3d3bbc677082bff044", - "e0702dd3f4b0189360f889335f47c078", "42160826cb5be0b284033d14b883c0e5", "e26fb406d5089a687500bbd71144d876", - "698a8a56a2fb9a1a9d5ef1dda2d5989a", - "42160826cb5be0b284033d14b883c0e5", "12a1033fd7ad23c01c1dc20306c807f1", "cac3cf1480722792fbc6ff1a19f9900a", - "315b6f26ad24c48e1410f258de8e0282", - "cac3cf1480722792fbc6ff1a19f9900a", - "315b6f26ad24c48e1410f258de8e0282", - "cac3cf1480722792fbc6ff1a19f9900a", - "315b6f26ad24c48e1410f258de8e0282", - "cac3cf1480722792fbc6ff1a19f9900a", "315b6f26ad24c48e1410f258de8e0282" ], "newline-before-return": [ "bbef19786c08a56d47025fc80a748f53", "cd51033dd4c0836b4ea5f02b4d3d99be", - "bbef19786c08a56d47025fc80a748f53", - "025d8ff6548c2a15a43ab53d1978c089", - "8367772ab39d5f8a626ad9c9817f6cf2", "c35c2b61e3238860c4e12067cea6a0ff", "803717c439377cbe4e05faa2bf7c2c74", "33f2b90b54c24956fa3b09b93311afa3", - "bbef19786c08a56d47025fc80a748f53", "47448feb6d03d4f5af61645f9dd94f02" ], "avoid-inferrable-type-arguments": [ - "3e96796766444d3b71d6f8a066d11fcd", - "91fb37915e0b137471ffed358a98df47", "bfee52d03478ce0c30b1ae2015bc758f" ] }, "lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart": { "newline-before-return": [ - "094ceb69155587ee47b1d557dfe685c7", "005d49e327abc6887bba7647199f4c41", "bbef19786c08a56d47025fc80a748f53", "db352cb5be7ecf757794710f9eb35d95", @@ -507,10 +318,7 @@ "f7a9dac86a9f03d1896a924b0cab0e45" ], "arguments-ordering": [ - "d310c55ef71a6bca11dd5bba6fb84278", "38f69b0378c1c534cf8d899fe8a4f20b", - "97c4481238baf85ed42e00d1e158be64", - "29a69c8e8a0fc115b9050f06a7f20d56", "b2d3f9d0506d5614294f89c0bc3b9e70" ], "avoid-inferrable-type-arguments": [ @@ -526,55 +334,34 @@ }, "lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart": { "prefer-shorthands-with-enums": [ - "c9dc47c9fe3fd49df082e47b2aaf7fd2", "c9dc47c9fe3fd49df082e47b2aaf7fd2", "ebcb05238a3f0eb7b46ced43963886f5", "14395f65ca8b5e131243ac407d25ecc9", - "ebcb05238a3f0eb7b46ced43963886f5", - "dee56c42d4dca9ebeb1655e201026985", - "7a8a4b700dbf2a6df8ad0540cef59537", - "e6ae8f3621ced35e0decab78369cfc76", "0f2ce7e8187e96b33f7471985307de00", "91973e0159d0ff9daee66bdd0d85a604", "0d07e265c118cb0e4630e61680355d12", - "91973e0159d0ff9daee66bdd0d85a604", "42a039328ed7ddec9ceee645e01af411", "ea4beca5477206d91a543c6f0116ba02", "b3bc42f0e6ddee26002bff76ba0e7f57", - "ea4beca5477206d91a543c6f0116ba02", "83d5930a437df131a38cce05ba985231", - "ea4beca5477206d91a543c6f0116ba02", "f6d6ddabaa7b4fc26675965558107402", - "ea4beca5477206d91a543c6f0116ba02", "9692ae73752b055424e232978227c894", "0f3941d1f44eb842dcd8e21d62013f14", "58dfc4fe116e9efe1e0ae5e54c92fd67", "7200d1a0cff6a30bef330ef76c904bab", - "14395f65ca8b5e131243ac407d25ecc9", - "14395f65ca8b5e131243ac407d25ecc9", - "14395f65ca8b5e131243ac407d25ecc9", - "14395f65ca8b5e131243ac407d25ecc9", - "14395f65ca8b5e131243ac407d25ecc9", - "14395f65ca8b5e131243ac407d25ecc9", - "14395f65ca8b5e131243ac407d25ecc9", - "14395f65ca8b5e131243ac407d25ecc9", "14018a1f31ac3d06be19ce7f0c3b86cf" ], "newline-before-return": [ "f78c5ea403f52a491171efc3c7a631f9", "505b97969baa28c3f607a38ee02f4f2d", - "505b97969baa28c3f607a38ee02f4f2d", "2e913be2539392ddf88e8ee8b320b20e" ] }, "lib/features/ai/quick_agent/core/engine/services/design_quality_metrics.dart": { "newline-before-return": [ - "122428c769833165a82a579de6b440d4", "882da0c35bc3a6bb638ea2521b141495", "aecc7a118b1650fe049e3deafabb2974", - "07fea4ab534099ab6201c8035f98fe5e", - "010268b2b3a75a2e9a1897eed68c2fe6", - "618cab4d89b093875feab49b2cca4e07" + "07fea4ab534099ab6201c8035f98fe5e" ], "no-equal-switch-expression-cases": [ "6ea2ef7311b482724a9b7b0bc0dd85c6" @@ -605,54 +392,31 @@ "ecd68d0a2f017878e0cec32b5f4c58a8", "0d7da24a7b06a3161cae3da03140a137", "f78c5ea403f52a491171efc3c7a631f9", - "f78c5ea403f52a491171efc3c7a631f9", "afd514b82d145f35d8fcdd98a574b0fc", "ceb01d9ed98374201d362585ceac9d39", - "afd514b82d145f35d8fcdd98a574b0fc", "e6978464917fa08dc31df29c37084eee", - "47941be0d0a25d6e9915e503b0b57e66", "ade9f8d86ad545ce61de1b6171c2c8fd", "939b375474fe15b4af21be027ea4bda2", - "afd514b82d145f35d8fcdd98a574b0fc", - "d0fb1625c2453fc80769deb4b7780c87", "d0fb1625c2453fc80769deb4b7780c87", - "ed0e87f5bbeef8fee8cce371743ea722", - "afd514b82d145f35d8fcdd98a574b0fc" + "ed0e87f5bbeef8fee8cce371743ea722" ], "prefer-shorthands-with-enums": [ "e60b6d41b47a410f4c9703b8df6ac273", "65806845bc752e0fde82bc202bb4b228", "a3029833c8b39817fbfc5a42c915a73c", "9a0db6cdce80c25fc29d077f1518fc4f", - "9a0db6cdce80c25fc29d077f1518fc4f", "760cc2150cc2203dc6af6b06dbca2170", "8192aeb34a7f329dc584289e878049ba", - "8192aeb34a7f329dc584289e878049ba", "7f3ba2420ac76423548c578cd238e9c3", "4b8f6b4995d7dbcb19ca9ce8e1eeb0a0", "f483c5c9333ad4e60c8b313467dedb4e", - "65806845bc752e0fde82bc202bb4b228", "14018a1f31ac3d06be19ce7f0c3b86cf", "91973e0159d0ff9daee66bdd0d85a604", "0d07e265c118cb0e4630e61680355d12", - "91973e0159d0ff9daee66bdd0d85a604", "42a039328ed7ddec9ceee645e01af411", "ea4beca5477206d91a543c6f0116ba02", "b3bc42f0e6ddee26002bff76ba0e7f57", - "ea4beca5477206d91a543c6f0116ba02", "f6d6ddabaa7b4fc26675965558107402", - "ea4beca5477206d91a543c6f0116ba02", - "e60b6d41b47a410f4c9703b8df6ac273", - "0d07e265c118cb0e4630e61680355d12", - "91973e0159d0ff9daee66bdd0d85a604", - "42a039328ed7ddec9ceee645e01af411", - "ea4beca5477206d91a543c6f0116ba02", - "b3bc42f0e6ddee26002bff76ba0e7f57", - "ea4beca5477206d91a543c6f0116ba02", - "f6d6ddabaa7b4fc26675965558107402", - "ea4beca5477206d91a543c6f0116ba02", - "536d0fddb78b7eec7bbfa72836c7747f", - "58dfc4fe116e9efe1e0ae5e54c92fd67", "536d0fddb78b7eec7bbfa72836c7747f", "58dfc4fe116e9efe1e0ae5e54c92fd67" ] @@ -683,16 +447,9 @@ ] }, "lib/features/ai/quick_agent/core/engine/services/generation_progress.dart": { - "member-ordering": [ - "021b52cc78fedd40747b4e9beded97a4", - "b7446d8b8d558b054007c6960e8a99d5", - "7f2aa349764eb1c6b2595080eecdd426", - "d08f4c598505c608639c720f6fe34d13" - ], "prefer-shorthands-with-enums": [ "e26fb406d5089a687500bbd71144d876", "bbfc1d5b729389800243cd8eacf3d25a", - "e26fb406d5089a687500bbd71144d876", "12a1033fd7ad23c01c1dc20306c807f1", "648d2b4434bcb866e1ce79539d37a784", "9d8cd4c8f6b2f7f01a366c97c50b2311" @@ -707,24 +464,7 @@ "93ac312546071a4932c9cb72821b311a", "084e7cc24e6c0fcbc62e9ae65640fc95", "4336cc761053aa2c68fa4e5d43908703", - "3636853fb8f8a6a159b0a1d92bc1397d", - "37b8b481254586ecabf1409cb29ad2b7", - "59896d1bd10774a554f9176e57e79b01", - "13fc839703ffc6f7d1767a645baa11b9", - "e5dcf8684482230d3ffecf7dd87050c8", - "50ca591007dffe24ca949bef3c522887", - "66e8e909e965be201f174ea354bc209a", - "4505d7887b416191173668490afe1e9e", - "c977d4111626ecf73ab432b8deab1b17", - "c53cbb53caebbc68cf959044aa9375e5", - "484a23d5d0d2a6df50f626b462b5bcfb", - "0243c6c5fde3edeb483686d7ca2af9c3", - "a0f1268cf6a9afd47eea602b07514cd8", - "9c32ae933b3728d071e892cafdbaf122", - "17295ed91f8b5f7e1838e6e78d4374df", - "15de6afc7474bb7fcabd98da254dcdbd", - "f8018ad751f2d8f94b204dbcffefbbf6", - "579000f49fcaa9db96c09b9bb8a7e6b2" + "3636853fb8f8a6a159b0a1d92bc1397d" ], "prefer-null-aware-elements": [ "ea7844bc16bdebeeb3afaf5610b72770" @@ -736,20 +476,16 @@ "64d0333cf654a2dd73c21d1a4e625170" ], "arguments-ordering": [ - "4e60f0557c7da89bee4ab65d328a44b4", "81a9098eace5b6a77d05ab5893094186" ], "prefer-shorthands-with-enums": [ "357de952d8a6483178f38cbaceca5563", "3d5d2e3c3d76cb3d3bbc677082bff044", - "484e030ddc01f745ff9f110e4d0f9561", - "3d5d2e3c3d76cb3d3bbc677082bff044", "484e030ddc01f745ff9f110e4d0f9561" ], "newline-before-return": [ "2730b3df559d43dc48d96baf458458fc", "4d3b26779d4203337a5da5512d574319", - "2e913be2539392ddf88e8ee8b320b20e", "2e913be2539392ddf88e8ee8b320b20e" ] }, @@ -787,13 +523,8 @@ "newline-before-return": [ "817b84d1b6be6eadf62fe88f44e14d20", "bbef19786c08a56d47025fc80a748f53", - "bbef19786c08a56d47025fc80a748f53", "6b1497ab86f62bac6c8f5cb6a4c27aa8", - "bbef19786c08a56d47025fc80a748f53", "86202c1401f9e55d676bb2996de7c09f", - "bbef19786c08a56d47025fc80a748f53", - "bbef19786c08a56d47025fc80a748f53", - "bbef19786c08a56d47025fc80a748f53", "2da5d80d7bc17c57701f9f6b6212d298" ], "arguments-ordering": [ @@ -821,8 +552,6 @@ "2c38d0c7ee16b998dd0b59f56f3add0c" ], "prefer-shorthands-with-static-fields": [ - "b32d9275721f7438582b42bf815a6d85", - "b32d9275721f7438582b42bf815a6d85", "b32d9275721f7438582b42bf815a6d85" ], "newline-before-return": [ @@ -834,39 +563,26 @@ "1b5e7b2454b1233e4bba6f24f4ea8a70", "566bac91bf51eff670cec6d89acb2e53", "e7e34e71382cec74649982dcafd1c485", - "1761907fc2f0b385e459e1d15b76f4bd", "c53b1733ef9aff677372a41546805237", "880c0cec95355c41819e7af80fe52bcb", - "880c0cec95355c41819e7af80fe52bcb", "848002e079a6d5e101ca300ffc499f07", "02068285decc93571ae033d1d87eefbb", "81b94c6f16ecfe6242f9c2bfaee6766f", "d7fddf33ffb8a9945554150e523cf4ac", "a21dadc60a9424e510dc3ba9e4ee7f96", "8b7e31ba85a9b7b82771b6e16b6eb87b", - "d7fddf33ffb8a9945554150e523cf4ac", "3762046baf08479b60aafb78f6608051", - "c0f9174ebd08f2a6075151b560fa583b", - "9f6561ebf93c6d58554dc13e33095aa2", - "e7e34e71382cec74649982dcafd1c485", "9f6561ebf93c6d58554dc13e33095aa2", "ff213c70e20583d4a8461e8a7fdc6769" ] }, "lib/features/ai/quick_agent/domain/commands/generate_deck_command.dart": { "member-ordering": [ - "93ac312546071a4932c9cb72821b311a", - "0ea62933d196c387856ef1a10cb4ed2a", - "8a0c32ad468260fc4fec223c4ae6fa54", - "562305e717db0bc575c3b3164c69a6a5", - "fe3c76af1b2fb8a5b18010b6fea6c7f8", - "7c5d3f311125d65ce50413677b0848a0", - "e1cab01410358f7490cc39fa0cc9abda" + "93ac312546071a4932c9cb72821b311a" ], "prefer-shorthands-with-enums": [ "9d8cd4c8f6b2f7f01a366c97c50b2311", - "bbfc1d5b729389800243cd8eacf3d25a", - "9d8cd4c8f6b2f7f01a366c97c50b2311" + "bbfc1d5b729389800243cd8eacf3d25a" ], "newline-before-return": [ "92bc3a2631bf7f0106dc346803864748" @@ -876,9 +592,6 @@ "prefer-shorthands-with-enums": [ "9d8cd4c8f6b2f7f01a366c97c50b2311" ], - "newline-before-return": [ - "6709faced1c59e613e270d62adfdfdb4" - ], "prefer-shorthands-with-constructors": [ "25d70aceeb5c316696c392bd922e2a3d", "7619ac479ac9f2834cfe7d877347194d", @@ -914,85 +627,29 @@ "f5cdc7a986cf2cda9c809339d8f575fc" ] }, - "lib/features/ai/wizard/chat/chat_message.dart": { - "member-ordering": [ - "63b0312b8498059c5c4d70303cf9305b", - "63b0312b8498059c5c4d70303cf9305b", - "63b0312b8498059c5c4d70303cf9305b", - "e34856fe666cdbf4e6908a5e57c35be9", - "75bcff17819a72de4ea0dc6f20103360" - ] - }, - "lib/features/ai/wizard/chat/view/widgets/chat_bubble.dart": { - "member-ordering": [ - "46cc7c3073328e3b7eb97fb5df56f6c1", - "3dd6caecaf0591596acb8e3974aee0c2" - ], - "prefer-shorthands-with-constructors": [ - "e87e1e04cd379d61fd8ee02dc86a8de6", - "e87e1e04cd379d61fd8ee02dc86a8de6" - ], - "prefer-shorthands-with-enums": [ - "08a14347d022dfdc11735818fd900919", - "1ca9077e7fb63c5f9668bc361ec2bcfc", - "9c35600ca1cbb8d7f48b8e9b77857741", - "ddf95cbd75caa38bea8f6cc36f2eb4c2" - ], - "arguments-ordering": [ - "84f684e82e0246b40a7d752a6bb45447" - ] - }, "lib/features/ai/wizard/chat/view/widgets/chat_genui_panels.dart": { - "arguments-ordering": [ - "f1068a141959cac88ac9aa909094cf5c", - "d25f62b6ecbb0ba92011eea72fc2c5ca", - "5ef4bc3769fd14e1ba646d3bcd03e421" - ], - "prefer-returning-shorthands": [ - "a548f40fbf24cdd7512edadafd6763e7" - ], "prefer-shorthands-with-enums": [ - "09121fdc5a685f7523093cd7cbd10592", - "09121fdc5a685f7523093cd7cbd10592", - "45da23b7c939e966a0ac561b5884cfe7", - "82b00b0ba6459d9f8ba85adcfb54b412" + "09121fdc5a685f7523093cd7cbd10592" ], "prefer-shorthands-with-constructors": [ - "25d70aceeb5c316696c392bd922e2a3d", - "7619ac479ac9f2834cfe7d877347194d", - "f02a8b91e92f721c96c4ab2efb4ec46c" + "25d70aceeb5c316696c392bd922e2a3d" ], "move-variable-closer-to-its-usage": [ "bf516925bb37a8544c8ee19a24e15c05", - "e780ee052657fd85be0373cf0de55bc8", "897a779351421523cadbafccdce22efe" ] }, "lib/features/ai/wizard/chat/view/widgets/chat_input.dart": { - "member-ordering": [ - "0e1194069150afe787323a9ea5a4b537" - ], "prefer-shorthands-with-static-fields": [ "86747cb1f8d1d1b6c1ba5fcd39c97fa9" ], - "arguments-ordering": [ - "32b4837af9dfe271919eb0586596a5f0" - ], "prefer-shorthands-with-enums": [ "073d46a91eac968762dd2d96bfea9403" ] }, "lib/features/ai/wizard/chat/view/widgets/empty_state.dart": { - "arguments-ordering": [ - "ba38a75e760fa742565bbbf567ef5ec1", - "c1e42b95c6a8fee989b0efefee99722a", - "078bfb735bed6a8a8276ce8be677f86a" - ], "prefer-shorthands-with-constructors": [ "f02a8b91e92f721c96c4ab2efb4ec46c" - ], - "prefer-shorthands-with-enums": [ - "e5df4f9378d3812a4ffb549a6ba06738" ] }, "lib/features/ai/wizard/core/ai/catalog/ask_user_checkbox.dart": { @@ -1011,36 +668,21 @@ }, "lib/features/ai/wizard/core/ai/catalog/ask_user_question_cards.dart": { "prefer-returning-shorthands": [ - "767ae84aa629503704894ebdeb013a0f", "767ae84aa629503704894ebdeb013a0f" ], "member-ordering": [ - "c0b5933772ce51548e6a3752b52db33e", "1638a404b5fbf796e158d136dabb035e", - "ee949bb3f26491a48d1c7c8c46e8c1f5", - "4e630725a18afb8b9eca0b9a14eb0263", - "fca00657909e37f3deb317155a7b1d5e", - "0ba5a93af0980ce790c245be74677737", - "adccfd76cb6fa3c94398a3d213a1ce65" + "4e630725a18afb8b9eca0b9a14eb0263" ], "prefer-shorthands-with-enums": [ "45da23b7c939e966a0ac561b5884cfe7", "09121fdc5a685f7523093cd7cbd10592", - "45da23b7c939e966a0ac561b5884cfe7", - "e5a9547b011274b87d41f70afa9257bd", "e5a9547b011274b87d41f70afa9257bd", "4b5e6559a090af31173473cecc19dca7", - "e5df4f9378d3812a4ffb549a6ba06738", - "09121fdc5a685f7523093cd7cbd10592" + "e5df4f9378d3812a4ffb549a6ba06738" ], "arguments-ordering": [ - "8ef7dbab916df52a368ed52223df8832", - "edf041c2d0746d6c9fd7636662d6471d", - "7b6a33e673f3878a8023bda9b5e41dbd", - "a87d61f5ae6d976fe10b873c70679319", - "9fb4896ff29d758f68674dc23ad394d9", - "7866ca92e57455add0b1d4845ebe7304", - "8f73cd2ada134880c79dc02d6be37ee4" + "a87d61f5ae6d976fe10b873c70679319" ], "avoid-flexible-outside-flex": [ "63f6baf1d88963b8c8210751c8530e94" @@ -1056,11 +698,9 @@ }, "lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart": { "member-ordering": [ - "dfa89e34a41102ff8c03740e79d05c9b", - "a8a9fdacb3553ef0f390438218ede1ec" + "dfa89e34a41102ff8c03740e79d05c9b" ], "newline-before-return": [ - "6ca441258dee8e009654647f3b18b68d", "b93e99b59297b149853fc54ca8b965f1" ], "arguments-ordering": [ @@ -1069,30 +709,16 @@ }, "lib/features/ai/wizard/core/ai/catalog/ask_user_slider.dart": { "member-ordering": [ - "c96664e19effa7e7bdb3ce6bc7962d89", - "771ae4667587f37d18482a763b8d2aa1", - "c08410ed416e910329e969f08a1f7cfe", - "63168b23fa1791429b06c3d8e4f7c117", - "bf7dbb0f05d78cd9a53f0314fe98f0fc" + "c96664e19effa7e7bdb3ce6bc7962d89" ], "newline-before-return": [ - "505b97969baa28c3f607a38ee02f4f2d", - "40e43344e157582eff1d17f4399ca40f" + "505b97969baa28c3f607a38ee02f4f2d" ], "arguments-ordering": [ - "b9b84c561cea0ac8dcb058355a089a2d", - "cbbbf594ca530ee01d9e408777c466e8", - "7313a877c16fed0383ee29bc61a3745a" - ], - "prefer-shorthands-with-enums": [ - "d96f0ba5d14e073d9febca47eb88bf24" + "b9b84c561cea0ac8dcb058355a089a2d" ] }, "lib/features/ai/wizard/core/ai/catalog/ask_user_style.dart": { - "member-ordering": [ - "c2cda96dc64dde670da5f012a956f113", - "74d7cb180dd5b721c3a109a9e2e6753e" - ], "newline-before-return": [ "d8cfe155f6465ad0394e452a1407953e" ], @@ -1100,30 +726,6 @@ "cc78ff5a946aab2404afeb82ae5cd9ef" ] }, - "lib/features/ai/wizard/core/ai/catalog/ask_user_text.dart": { - "member-ordering": [ - "2e0b689bf50d2dff11cc5551e3912df0", - "805b1b6d58c5ff3f3bcc9b9a62183284", - "ae90abdfb0acde94d004bcce1970b295" - ], - "newline-before-return": [ - "401a69a7273354f2e163844347997876" - ], - "arguments-ordering": [ - "449c0c6198638327e42c1264329c330c", - "b8e36f4d62ab6a4da9a15613ff0dcb91", - "ceb0874df2f329e47fb4465997c1db18" - ], - "prefer-shorthands-with-static-fields": [ - "3822ca846f4fe90d5a104da064b7d9fa" - ], - "avoid-empty-setstate": [ - "f3edb0bd9219cb00a7d9ee2aa2b831bd" - ], - "no-empty-block": [ - "99914b932bd37a50b983c5e7c90ae93b" - ] - }, "lib/features/ai/wizard/core/ai/catalog/component_schema.dart": { "prefer-iterable-of": [ "712fe1a31d915efe9c554b504c82975e" @@ -1132,57 +734,6 @@ "9b71f8224875d86396b1a510742682b9" ] }, - "lib/features/ai/wizard/core/ai/catalog/summary_card.dart": { - "newline-before-return": [ - "3c46563e1e0cd9579abfcd3b7879ac75", - "bbef19786c08a56d47025fc80a748f53", - "bbef19786c08a56d47025fc80a748f53", - "bbef19786c08a56d47025fc80a748f53", - "1265da10052e3159efe6337f1d94d808" - ], - "prefer-shorthands-with-enums": [ - "8cb6d6c1c59290c8f6bcef74cc63199f", - "8e419166ff6af5c9a21ec612acbe5b9f", - "b43e16f7eb270cc5abe2a1f86fca74ba" - ] - }, - "lib/features/ai/wizard/core/ai/catalog/summary_card_view.dart": { - "avoid-dynamic": [ - "b72f3bd391ba731a35708bfd8cd8a68f" - ], - "newline-before-return": [ - "79b41d9794649affbf62bb719e838127", - "79b41d9794649affbf62bb719e838127", - "694de16e1ee451b02cb7672b2ca1b5c8" - ], - "member-ordering": [ - "6fd3bc62e6c790fcb05e5f36d557a986", - "119dc50299bb803842595eed2c1940c7", - "bce74364514a8de47ddea46e86e4e0e3", - "1cb7d6a97bac26c656ca7851fcdb8549", - "149af29b6859828c3d9b2afd2e80dd26", - "c7309f02d7a0fe3d11f0fb41759b469c" - ], - "prefer-shorthands-with-constructors": [ - "e87e1e04cd379d61fd8ee02dc86a8de6" - ], - "prefer-shorthands-with-enums": [ - "45da23b7c939e966a0ac561b5884cfe7", - "45da23b7c939e966a0ac561b5884cfe7" - ], - "arguments-ordering": [ - "6779e53e250f7c811495424e63c1ff63" - ], - "prefer-returning-shorthands": [ - "767ae84aa629503704894ebdeb013a0f" - ], - "move-variable-closer-to-its-usage": [ - "d967b24e73a186833ff02f0ff8bd1657", - "cf18e3967849120aa9c6346524ba2c07", - "74ac8602f028dfb26f4f1ba0a6f8917b", - "6b604e1b520587315730f032671ed71d" - ] - }, "lib/features/ai/wizard/core/ai/catalog/typed_catalog_item.dart": { "arguments-ordering": [ "754121f3aea159058a85b9847dbff8c6" @@ -1201,59 +752,8 @@ "bbef19786c08a56d47025fc80a748f53" ] }, - "lib/features/ai/wizard/core/ai/prompts/image_style_prompts.dart": { - "newline-before-return": [ - "691e538a256533401966cf20427df3d4", - "bbef19786c08a56d47025fc80a748f53", - "ee6336c31f00a661659d93543f999079" - ] - }, - "lib/features/ai/wizard/core/ai/schemas/wizard_context_keys.dart": { - "member-ordering": [ - "73fb0fd7a80e739f7a1088859a8d38ff" - ], - "newline-before-return": [ - "3df9a68a6b584e934b0caac182bcbe0d" - ] - }, - "lib/features/ai/wizard/core/ai/services/ai_conversation_profile.dart": { - "member-ordering": [ - "bb233f42e25fd11f76b91ca8158016a7", - "7ee0723ebc4cc8ce1777d1ed94e3b2f1", - "4d486864641d4b995918ddd8607926a8", - "8eeeaf9edc109141eb2603935b63cac8" - ] - }, "lib/features/ai/wizard/core/ai/services/ai_conversation_viewmodel.dart": { - "member-ordering": [ - "3b37b34c6f7955e78d4b7b698cbe3cab", - "556d95735b500722cca4159fcbab5800", - "e8982e68f4754e7cc46519e33da7d7de", - "fd36d558c167db5fe4bda0d1524c615a", - "38efecb86232b05030845d9c54d39549", - "1acc8dc6bdd56338900e200437ee61b4", - "71f0ce4976d7a3892fff7995f864b6b1", - "5e7bb3093f8657ff28c99aff609c0e3e", - "44d60ad12ae58eefcf031919d27bcd85", - "a664d08066a3873a7ba1b24748afede4", - "ca66ab451eea2c970b145ce4a2c70ccf", - "bcf9a39cd669a1c61409aa87200d779e", - "f04c235f3081306c651e7314653dc219", - "b290730973c7b2dd1062661f81de2f4c", - "c2d138d17eb91b7f63419ea9a3925551", - "0173a3be08e3450b5262b43f6ed617b2", - "3e639ad6f7a48861042c9de2f29a45ac", - "fb5e1d11f94d98832a3d9890598a6e36", - "fb3361b3fbf270eb23aa21927ced40d9", - "706a18ce023e9299a409de3ba5c59aed", - "c555a404bd8dd9e323e2e7a155d13d63" - ], - "arguments-ordering": [ - "a3aff7443548ce429ca08f122f5ebe8f" - ], "avoid-inferrable-type-arguments": [ - "a4ce78f10642531236c337ef3ce2df5b", - "a4ce78f10642531236c337ef3ce2df5b", "a4ce78f10642531236c337ef3ce2df5b" ], "newline-before-return": [ @@ -1277,31 +777,7 @@ "9cc1855cd4a858a8b5771aafdff1587d", "c7a33b5758d04f29022a188cbc5e4392", "f445e42d922f724219e80a90272e8a7f", - "bc7fbdc2ed5eb8cb7951d3d4c4253336", - "cf3c6a8b76bb1f23cf984390acaa4061", - "2dd740e1b6b4584b87046cf9dae66b39", - "03c55bc1fdc2070aec054176420b672f", - "6f728271125f02f2ffccc0843ca05fa6", - "2e4d8c871f37677886d8761fe50bf928", - "40b5171cba901df3ba97d8df02783999", - "958613bfe632d10d2120e15be795ea36", - "3bbcc0e8ef1c9367e37b74363c0ca903", - "871408fd9c7d5169ad2658b8467d195c", - "2321d4df92e1c709b8f58594f6f84567", - "f6c2079cd24ceb71e0ab1af888e06a55", - "a9a38464c1c779b429bbf2669e88e8f4", - "35bb6bd1f6b83e1f1080b29c8be76bd2", - "238d041b79e7729bf420d22de3c31c8e", - "bf91ac48f248ea5095888bd0860aa1d7", - "9e11e6526bb42352021fbed50d583a69", - "5b523eb6f390016a2550c35a3b382fa5", - "eec4865bd248609a0cb961178df24429", - "1848f7e6b068f163129b56cb5afb3b68", - "06d3a7aa46007e7e5162d8fc84c424f0", - "18e71386ebcfd245efc279db26492bce", - "17c5edc186baf366d87eb5a3ea3c7300", - "1f4eb37ea19542195af509790c2429b0", - "a1590a599e768fcd3c1b4cfd2fa9994b" + "bc7fbdc2ed5eb8cb7951d3d4c4253336" ], "avoid-unnecessary-local-late": [ "f2c67381db28fa11c59fe7a6df0f2587" @@ -1310,12 +786,10 @@ "e3710941dc477f56d513d66a33f5b719", "1b529c0e1845e89a192291da90153e44", "1f3e64776bce11118c0d85c0487eb7cf", - "1f3e64776bce11118c0d85c0487eb7cf", "716db0189ac75ca240515de87fd5d869", "2752b224a537dadddcd8cea66edcc011", "f74e8ad5e90a36e15ccce02e2d9e95b2", "bbef19786c08a56d47025fc80a748f53", - "bbef19786c08a56d47025fc80a748f53", "2e956a5a13c19dde92a7d4337f577c69" ], "arguments-ordering": [ @@ -1345,7 +819,6 @@ "505b97969baa28c3f607a38ee02f4f2d" ], "newline-before-return": [ - "505b97969baa28c3f607a38ee02f4f2d", "505b97969baa28c3f607a38ee02f4f2d", "4548aef834a931b20266c20fed02dae2" ] @@ -1360,18 +833,6 @@ "99914b932bd37a50b983c5e7c90ae93b" ] }, - "lib/features/ai/wizard/core/ai/wizard_context.dart": { - "member-ordering": [ - "2be2d45acceb3c77381520dccb93b4ae" - ], - "newline-before-return": [ - "50ae119e2ea02fc585a5fa1961d855aa", - "ed94e543d1a13dbb675bcf12cfac5a63", - "52d1c6caac1c32aed2842dd6e99fb8cd", - "c7fdaf84e4be1c6d7c92b59a9552125e", - "bbef19786c08a56d47025fc80a748f53" - ] - }, "lib/features/ai/wizard/core/ui/components/sd_buttons.dart": { "arguments-ordering": [ "d43e02732d7412646ec0130a13355095", @@ -1388,7 +849,6 @@ }, "lib/features/ai/wizard/core/ui/components/sd_custom.dart": { "prefer-shorthands-with-constructors": [ - "e87e1e04cd379d61fd8ee02dc86a8de6", "e87e1e04cd379d61fd8ee02dc86a8de6" ], "newline-before-return": [ @@ -1407,7 +867,6 @@ }, "lib/features/ai/wizard/core/ui/components/sd_feedback.dart": { "prefer-shorthands-with-constructors": [ - "e87e1e04cd379d61fd8ee02dc86a8de6", "e87e1e04cd379d61fd8ee02dc86a8de6" ], "prefer-shorthands-with-enums": [ @@ -1448,10 +907,6 @@ }, "lib/features/ai/wizard/core/ui/components/sd_typography.dart": { "newline-before-return": [ - "7703cf205c169cdd606a8632a6817025", - "7703cf205c169cdd606a8632a6817025", - "7703cf205c169cdd606a8632a6817025", - "7703cf205c169cdd606a8632a6817025", "7703cf205c169cdd606a8632a6817025" ] }, @@ -1465,11 +920,6 @@ "801979137471c1e5fb1750d69c099bcf" ] }, - "lib/features/ai/wizard/core/ui/widgets/wizard_loading_state.dart": { - "member-ordering": [ - "8b7e1fe6048ba625d48c997e51b62c3b" - ] - }, "lib/features/ai/wizard/core/utils/color_utils.dart": { "newline-before-return": [ "ac84b6615d6391fc8cb6a100c0b2aad0", @@ -1484,9 +934,6 @@ ], "newline-before-return": [ "115f6149095a9822ccec9815455f25b4" - ], - "avoid-inferrable-type-arguments": [ - "f7399366cd6336ecbea702c3289d9432" ] }, "lib/features/ai/wizard/presentation/view/loading.dart": { @@ -1500,9 +947,6 @@ "3bd46a7affec9cc13b02cfd56c43b73b" ], "prefer-shorthands-with-enums": [ - "27fb40e9766fcfcd2cc70a5ef9308f4d", - "27fb40e9766fcfcd2cc70a5ef9308f4d", - "27fb40e9766fcfcd2cc70a5ef9308f4d", "27fb40e9766fcfcd2cc70a5ef9308f4d" ] }, @@ -1512,35 +956,20 @@ "e696d9c4d2adf40459d446470223c647" ], "arguments-ordering": [ - "98fcb55044336a2bb210a60fb323b5cc", - "b4d805e3057aca097846da31845c03fd", "ec227b2057a7d9c7dbd595a224e39f4a", "fd2e76e04f1f1c219fd378177267d89d" ], "prefer-shorthands-with-constructors": [ - "25d70aceeb5c316696c392bd922e2a3d", "25d70aceeb5c316696c392bd922e2a3d" ], "prefer-shorthands-with-enums": [ "09121fdc5a685f7523093cd7cbd10592", - "ca84476bcef57522ee2a8ab81af423b9", "ca84476bcef57522ee2a8ab81af423b9" ], "prefer-shorthands-with-static-fields": [ "86747cb1f8d1d1b6c1ba5fcd39c97fa9" ] }, - "lib/features/ai/wizard/presentation/wizard_view.dart": { - "avoid-inferrable-type-arguments": [ - "ed839eb7bf2c1c2a04ad2c5a05315409" - ], - "avoid-undisposed-instances": [ - "d0a656787ae67e991b84fb5fee67c5c4" - ], - "member-ordering": [ - "dfeb0b6ca24e37768a08be01bed57c1a" - ] - }, "lib/features/editor/data/mac_os_deck_file_repository.dart": { "member-ordering": [ "61583f66c352086f625389bc4ec09021", @@ -1574,10 +1003,6 @@ "7f0cab5db84d38ae4cf80b20c16985dd", "aa7a7d5299fc46c3295977ab5b977f88", "505b97969baa28c3f607a38ee02f4f2d", - "505b97969baa28c3f607a38ee02f4f2d", - "aa7a7d5299fc46c3295977ab5b977f88", - "7f0cab5db84d38ae4cf80b20c16985dd", - "7f0cab5db84d38ae4cf80b20c16985dd", "cca1137add70ca0139c19f0870da0d08", "a3197ed059a2770f9613ee8d3f1853a0", "c0c1fb2d771c5e23460368028f9c3ac5", @@ -1593,7 +1018,6 @@ "b3e1e9dcd03f9d3d459fd80e8cf33395" ], "avoid-throw-in-catch-block": [ - "959b7175f88493905e3c324c2bf6f97d", "959b7175f88493905e3c324c2bf6f97d" ], "prefer-null-aware-elements": [ @@ -1612,13 +1036,8 @@ "6b96878b5a62afdb18f1f10afe089726", "8a471518a3589e64e269981b1ad4f3a5", "7d63454bc73388949594a19405262cfb", - "8a471518a3589e64e269981b1ad4f3a5", "ddb043095f98ae8064bc32218e627e79", "be8c26dcc636a143b78001de86957301", - "a6ae710537c5749c910b8cc49a716d7a", - "be8c26dcc636a143b78001de86957301", - "a6ae710537c5749c910b8cc49a716d7a", - "be8c26dcc636a143b78001de86957301", "a6ae710537c5749c910b8cc49a716d7a" ] }, @@ -1642,17 +1061,9 @@ ], "prefer-shorthands-with-enums": [ "aa071c1154e0c2a1a966ae5e9ba4cdcb", - "aa071c1154e0c2a1a966ae5e9ba4cdcb", - "d0f1730bd6a052818f96347b2602dff7", - "d0f1730bd6a052818f96347b2602dff7", - "aa071c1154e0c2a1a966ae5e9ba4cdcb", - "aa071c1154e0c2a1a966ae5e9ba4cdcb", - "aa071c1154e0c2a1a966ae5e9ba4cdcb", - "d0f1730bd6a052818f96347b2602dff7", - "aa071c1154e0c2a1a966ae5e9ba4cdcb" + "d0f1730bd6a052818f96347b2602dff7" ], "avoid-explicit-pattern-field-name": [ - "2063c1608d6e0baf80249c42e2be5804", "2063c1608d6e0baf80249c42e2be5804" ], "avoid-unnecessary-type-assertions": [ @@ -1662,25 +1073,15 @@ "cf7d3bedb9b566e7bf05fbe1d4f9b527", "aa7a7d5299fc46c3295977ab5b977f88", "505b97969baa28c3f607a38ee02f4f2d", - "505b97969baa28c3f607a38ee02f4f2d", "13a19489a554a2f1393d0b243b42300c", "2e913be2539392ddf88e8ee8b320b20e", - "d0fb1625c2453fc80769deb4b7780c87", - "505b97969baa28c3f607a38ee02f4f2d" + "d0fb1625c2453fc80769deb4b7780c87" ], "no-empty-block": [ "99914b932bd37a50b983c5e7c90ae93b" ] }, "lib/features/editor/presentation/pages/editor_bootstrap.dart": { - "member-ordering": [ - "2fda142e702c104ac2d1dba8eb5e4008", - "bdbda3ee4f18a73d8572c33ce50905de", - "3a6a12a61c0830f5df785c0463d82faf", - "3de5cc500a0e533c4c72bb6017f4c5e3", - "0adab035fab662c40897d6ea1b491a26", - "3157750d0ebdab544976f3fadf332632" - ], "newline-before-return": [ "46e4c2b3161e333714f1c17e4f194bbd" ], @@ -1700,8 +1101,6 @@ "9772b903b9ec7a48c0b1f7c812bc7819", "bfd922d88a4d97c4357586a293d1996d", "5f7efaf080b60420d599580ae6dc96b5", - "bb9ba2fa6d63f9448496a5e277869998", - "bb9ba2fa6d63f9448496a5e277869998", "e696d9c4d2adf40459d446470223c647" ], "prefer-spacing": [ @@ -1778,7 +1177,6 @@ "fe331f45f8c3eb9aafe615ff64e8ebdd" ], "avoid-inferrable-type-arguments": [ - "3c3f6530e00e9f4ea603911863a90a6c", "3c3f6530e00e9f4ea603911863a90a6c", "a4f5c9920c2235bcf03341de5d2e2d58", "01b4d30c4566f2bce2f348c8ea97828c" @@ -1806,7 +1204,6 @@ }, "lib/features/editor/presentation/widgets/editor_header.dart": { "prefer-shorthands-with-static-fields": [ - "c52645537c6ab5d7f975731fba0f51fb", "c52645537c6ab5d7f975731fba0f51fb" ], "arguments-ordering": [ @@ -1928,9 +1325,6 @@ "avoid-unnecessary-futures": [ "696e8b56222a77c1a1038bd671b480d1" ], - "arguments-ordering": [ - "4029e549212eec27f33ccd065c1e4d83" - ], "dispose-fields": [ "cc513854023c24bc8ab87b8a428b8bf4" ], @@ -1940,4 +1334,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/playground/lib/app/providers.dart b/packages/playground/lib/app/providers.dart index 7f44471c..6e739bed 100644 --- a/packages/playground/lib/app/providers.dart +++ b/packages/playground/lib/app/providers.dart @@ -49,13 +49,13 @@ class AppProviders extends StatelessWidget { dispose: (_, repository) => repository.dispose(), ), Provider( - lazy: false, create: (ctx) => DeckController( deckLoader: ctx.read(), options: DeckOptions(debug: _debugDeckLayout), assetCacheStore: ctx.read(), ), dispose: (_, controller) => controller.dispose(), + lazy: false, ), ChangeNotifierProvider( lazy: false, diff --git a/packages/playground/lib/core/domain/design/generated_deck_theme_factory.dart b/packages/playground/lib/core/domain/design/generated_deck_theme_factory.dart index 8d5f1c54..010abf91 100644 --- a/packages/playground/lib/core/domain/design/generated_deck_theme_factory.dart +++ b/packages/playground/lib/core/domain/design/generated_deck_theme_factory.dart @@ -142,7 +142,6 @@ final class GeneratedDeckThemeFactory { }; return DeckOptions( - debug: debug, baseStyle: baseStyle, styles: styles, parts: SlideParts( @@ -150,6 +149,7 @@ final class GeneratedDeckThemeFactory { footer: null, background: Box(style: BoxStyler().color(palette.background)), ), + debug: debug, ); } } diff --git a/packages/playground/lib/core/domain/design/presentation_image_style_catalog.dart b/packages/playground/lib/core/domain/design/presentation_image_style_catalog.dart new file mode 100644 index 00000000..d7178c8a --- /dev/null +++ b/packages/playground/lib/core/domain/design/presentation_image_style_catalog.dart @@ -0,0 +1,221 @@ +/// Stable preview trio generated as soon as the Wizard accepts a topic. +const featuredPresentationImageStyleIds = [ + 'minimalist', + 'watercolor', + 'gradient', +]; + +/// One described, versioned image treatment available to deck generation. +final class PresentationImageStyleDescriptor { + final String id; + + final int version; + final String title; + final String description; + final Set selectionTags; + final Set mediaTags; + final Set compatibilityTags; + final String treatment; + const PresentationImageStyleDescriptor({ + required this.id, + required this.version, + required this.title, + required this.description, + required this.selectionTags, + required this.mediaTags, + required this.compatibilityTags, + required this.treatment, + }); + + /// Compact selection metadata safe to include in model prompts. + Map toModelCandidate() { + final tags = { + ...selectionTags, + ...mediaTags, + ...compatibilityTags, + }.toList()..sort(); + + return { + 'id': id, + 'version': version, + 'title': title, + 'description': description, + 'tags': tags, + }; + } + + /// Combines a concrete subject with this locally owned treatment. + String buildPrompt(String subject) { + final normalized = subject.trim(); + if (normalized.isEmpty) { + throw ArgumentError.value(subject, 'subject', 'Must not be empty.'); + } + final capitalized = + '${normalized[0].toUpperCase()}${normalized.substring(1)}'; + + return '$capitalized, $treatment'; + } +} + +/// Injectable registry and exact-version resolver for image treatments. +final class PresentationImageStyleCatalog { + final List styles; + + final Map _byVersion = {}; + + final Map _currentById = {}; + PresentationImageStyleCatalog( + Iterable styles, + ) : styles = List.unmodifiable(styles) { + if (this.styles.isEmpty) { + throw ArgumentError('Presentation image style catalog cannot be empty.'); + } + for (final style in this.styles) { + _validateDescriptor(style); + final versionKey = _versionKey(style.id, style.version); + if (_byVersion.containsKey(versionKey)) { + throw ArgumentError( + 'Duplicate presentation image style reference "$versionKey".', + ); + } + _byVersion[versionKey] = style; + final current = _currentById[style.id]; + if (current == null || current.version < style.version) { + _currentById[style.id] = style; + } + } + } + factory PresentationImageStyleCatalog.withDefaults() => + defaultPresentationImageStyleCatalog; + + /// Latest descriptor for every ID, preserving declared catalog order. + List get currentStyles => .unmodifiable( + styles.where((style) => identical(current(style.id), style)), + ); + + PresentationImageStyleDescriptor? current(String id) => + _currentById[id.trim()]; + + /// Resolves one exact image-style ID and version. + PresentationImageStyleDescriptor resolve({ + required String id, + required int version, + }) { + final descriptor = _byVersion[_versionKey(id, version)]; + if (descriptor != null) return descriptor; + + final currentVersion = current(id)?.version; + final detail = currentVersion == null + ? 'The image style ID is unknown.' + : 'Current catalog version is $currentVersion.'; + throw ArgumentError( + 'Unknown or stale presentation image style "$id@$version". $detail', + ); + } +} + +void _validateDescriptor(PresentationImageStyleDescriptor style) { + if (!RegExp(r'^[a-z0-9]+(?:-[a-z0-9]+)*$').hasMatch(style.id)) { + throw ArgumentError('Image style ID "${style.id}" must use kebab-case.'); + } + if (style.version < 1) { + throw ArgumentError( + 'Image style "${style.id}" must have a positive version.', + ); + } + if (style.title.trim().isEmpty || + style.description.trim().isEmpty || + style.treatment.trim().isEmpty) { + throw ArgumentError( + 'Image style "${style.id}" requires a title, description, and treatment.', + ); + } + if (style.selectionTags.isEmpty || style.mediaTags.isEmpty) { + throw ArgumentError( + 'Image style "${style.id}" requires selection and media tags.', + ); + } +} + +String _versionKey(String id, int version) => '${id.trim()}@$version'; + +final defaultPresentationImageStyleCatalog = PresentationImageStyleCatalog( + const [ + PresentationImageStyleDescriptor( + id: 'watercolor', + version: 1, + title: 'Watercolor', + description: 'Soft, expressive artwork with organic painted texture', + selectionTags: {'soft', 'artistic', 'organic', 'dreamy'}, + mediaTags: {'illustration', 'painted', 'textured'}, + compatibilityTags: {'editorial', 'playful', 'storytelling'}, + treatment: + 'rendered in a soft watercolor painting style with flowing organic ' + 'shapes, gentle color bleeding, muted pastel tones, and subtle paper ' + 'texture', + ), + PresentationImageStyleDescriptor( + id: 'minimalist', + version: 1, + title: 'Minimalist', + description: 'Clean, modern artwork with precise geometric simplicity', + selectionTags: {'clean', 'modern', 'professional', 'restrained'}, + mediaTags: {'illustration', 'geometric', 'negative-space'}, + compatibilityTags: {'minimal', 'technical', 'editorial'}, + treatment: + 'rendered in a clean minimalist style with simple geometric shapes, ' + 'generous negative space, a limited color palette, precise edges, ' + 'and balanced asymmetry', + ), + PresentationImageStyleDescriptor( + id: 'gradient', + version: 1, + title: 'Gradient', + description: 'Dynamic contemporary artwork with rich color transitions', + selectionTags: {'dynamic', 'contemporary', 'vibrant'}, + mediaTags: {'abstract', 'gradient', 'mesh'}, + compatibilityTags: {'bold', 'technical', 'product'}, + treatment: + 'rendered with smooth flowing gradients and rich color transitions, ' + 'a vibrant harmonious palette, and subtle mesh effects that create ' + 'depth and dimension', + ), + PresentationImageStyleDescriptor( + id: 'retro', + version: 1, + title: 'Retro', + description: 'Playful artwork with a warm 1960s and 1970s print feel', + selectionTags: {'nostalgic', 'playful', 'vintage'}, + mediaTags: {'illustration', 'print', 'halftone'}, + compatibilityTags: {'retro', 'bold', 'storytelling'}, + treatment: + 'rendered in a retro print illustration style with bold outlines, ' + 'halftone dots, warm muted colors, and a lightly faded finish', + ), + PresentationImageStyleDescriptor( + id: 'geometric', + version: 1, + title: 'Geometric', + description: 'Structured technical artwork using angular forms and grids', + selectionTags: {'technical', 'structured', 'bold'}, + mediaTags: {'abstract', 'geometric', 'grid'}, + compatibilityTags: {'technical', 'data', 'bold'}, + treatment: + 'rendered as a geometric abstract composition with clean angular ' + 'shapes, precise lines, a structured grid, bold contrast, and ' + 'mathematical balance', + ), + PresentationImageStyleDescriptor( + id: 'flat-design', + version: 1, + title: 'Flat Design', + description: 'Friendly approachable artwork using simple vector forms', + selectionTags: {'friendly', 'approachable', 'corporate'}, + mediaTags: {'illustration', 'vector', 'flat'}, + compatibilityTags: {'playful', 'learning', 'product'}, + treatment: + 'rendered as a flat vector illustration with solid colors, clean ' + 'shapes, simple forms, no gradients, and an approachable palette', + ), + ], +); diff --git a/packages/playground/lib/core/domain/generated_image_asset.dart b/packages/playground/lib/core/domain/generated_image_asset.dart new file mode 100644 index 00000000..51651170 --- /dev/null +++ b/packages/playground/lib/core/domain/generated_image_asset.dart @@ -0,0 +1,31 @@ +import 'dart:typed_data'; + +/// Aspect ratios supported by Wizard image generation. +enum GeneratedImageAspectRatio { + landscape16x9('16:9'), + slide3x4('3:4'); + + const GeneratedImageAspectRatio(this.apiValue); + + final String apiValue; +} + +/// Ready bytes or a typed failure for one generated slide image. +final class GeneratedImageAsset { + final String assetKey; + + final Uint8List? bytes; + + final String? error; + GeneratedImageAsset.success({ + required this.assetKey, + required List bytes, + }) : bytes = Uint8List.fromList(bytes), + error = null; + + const GeneratedImageAsset.failure({ + required this.assetKey, + required String error, + }) : bytes = null, + error = error; +} diff --git a/packages/playground/lib/features/ai/image_generation/image_generator.dart b/packages/playground/lib/features/ai/image_generation/image_generator.dart new file mode 100644 index 00000000..1c20d27c --- /dev/null +++ b/packages/playground/lib/features/ai/image_generation/image_generator.dart @@ -0,0 +1,174 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dartantic_ai/dartantic_ai.dart'; + +import '../../../core/domain/generated_image_asset.dart'; +import '../quick_agent/core/engine/services/error_classifier.dart'; + +const geminiImagePreviewModel = 'gemini-3.1-flash-lite-image'; +const geminiImageGenerationModel = 'gemini-3.1-flash-image'; + +/// Input for one image-generation request. +final class ImageGenerationRequest { + final String prompt; + final GeneratedImageAspectRatio aspectRatio; + + const ImageGenerationRequest({ + required this.prompt, + required this.aspectRatio, + }); +} + +/// Explicit outcome of one image-generation request. +sealed class ImageGenerationResult { + const ImageGenerationResult(); +} + +final class ImageGenerationSuccess extends ImageGenerationResult { + final Uint8List bytes; + + ImageGenerationSuccess(List bytes) : bytes = Uint8List.fromList(bytes); +} + +final class ImageGenerationFailure extends ImageGenerationResult { + final String message; + + const ImageGenerationFailure(this.message); +} + +abstract interface class ImageGenerator { + Future generate(ImageGenerationRequest request); +} + +/// Converts unexpected provider exceptions into the typed failure boundary. +Future generateImageSafely( + ImageGenerator generator, + ImageGenerationRequest request, +) async { + try { + return await generator.generate(request); + } catch (error) { + return ImageGenerationFailure( + const ErrorClassifier().getUserMessage(error), + ); + } +} + +/// Used when media generation is unavailable in the current environment. +final class UnavailableImageGenerator implements ImageGenerator { + final String message; + + const UnavailableImageGenerator([ + this.message = 'Image generation is unavailable.', + ]); + + @override + Future generate(ImageGenerationRequest request) async { + return ImageGenerationFailure(message); + } +} + +typedef DartanticMediaModelFactory = + MediaGenerationModel Function({ + required String apiKey, + required String modelName, + required GoogleMediaGenerationModelOptions options, + }); + +/// Generates PNG images through Dartantic's Google media provider. +final class DartanticImageGenerator implements ImageGenerator { + final String apiKey; + final String modelName; + final Duration timeout; + final DartanticMediaModelFactory _modelFactory; + + DartanticImageGenerator({ + required this.apiKey, + this.modelName = geminiImagePreviewModel, + this.timeout = const Duration(seconds: 8), + DartanticMediaModelFactory? modelFactory, + }) : _modelFactory = modelFactory ?? _createGoogleModel; + + static MediaGenerationModel + _createGoogleModel({ + required String apiKey, + required String modelName, + required GoogleMediaGenerationModelOptions options, + }) { + return GoogleProvider(apiKey: apiKey).createMediaModel( + name: modelName, + options: options, + mimeTypes: const ['image/png'], + ); + } + + @override + Future generate(ImageGenerationRequest request) async { + final options = GoogleMediaGenerationModelOptions( + responseMimeType: 'image/png', + aspectRatio: request.aspectRatio.apiValue, + responseModalities: const ['IMAGE'], + ); + MediaGenerationModel? model; + + try { + model = _modelFactory( + apiKey: apiKey, + modelName: modelName, + options: options, + ); + await for (final chunk + in model + .generateMediaStream( + request.prompt, + mimeTypes: const ['image/png'], + options: options, + ) + .timeout(timeout)) { + final image = chunk.assets.whereType().firstOrNull; + if (image == null || image.bytes.isEmpty) continue; + + return ImageGenerationSuccess(image.bytes); + } + + return const ImageGenerationFailure( + 'The provider returned no image. Try again.', + ); + } on TimeoutException { + return const ImageGenerationFailure( + 'Image preview took too long. Try again.', + ); + } catch (error) { + return ImageGenerationFailure( + const ErrorClassifier().getUserMessage(error), + ); + } finally { + try { + model?.dispose(); + } catch (_) { + // Cleanup must not replace a typed provider result with an exception. + } + } + } +} + +/// Adds presentation-safe constraints to a style and subject. +String buildPresentationImagePrompt( + String stylePrompt, { + String? backgroundColor, +}) { + final background = backgroundColor == null || backgroundColor.isEmpty + ? '' + : '\nUse $backgroundColor as the dominant background color. Keep the ' + 'subject clearly distinguishable from it.'; + + return ''' +$stylePrompt + +Create a polished presentation illustration with one clear focal subject and +generous negative space. Do not include readable text, logos, branding, +borders, presentation chrome, watermarks, or photorealistic faces.$background +''' + .trim(); +} diff --git a/packages/playground/lib/features/ai/image_generation/image_style_preview_coordinator.dart b/packages/playground/lib/features/ai/image_generation/image_style_preview_coordinator.dart new file mode 100644 index 00000000..e47a1218 --- /dev/null +++ b/packages/playground/lib/features/ai/image_generation/image_style_preview_coordinator.dart @@ -0,0 +1,167 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../../core/domain/design/presentation_image_style_catalog.dart'; +import 'image_generator.dart'; + +enum ImageStylePreviewStatus { loading, ready, failed } + +/// Current preview state for one exact versioned style. +final class ImageStylePreview { + final PresentationImageStyleDescriptor style; + final ImageStylePreviewStatus status; + final Uint8List? bytes; + final String? error; + + const ImageStylePreview({ + required this.style, + required this.status, + this.bytes, + this.error, + }); + + ImageStylePreview copyWith({ + PresentationImageStyleDescriptor? style, + ImageStylePreviewStatus? status, + Uint8List? bytes, + String? error, + bool clearBytes = false, + bool clearError = false, + }) { + return ImageStylePreview( + style: style ?? this.style, + status: status ?? this.status, + bytes: clearBytes ? null : (bytes ?? this.bytes), + error: clearError ? null : (error ?? this.error), + ); + } +} + +/// Owns the Wizard preview plan and begins it immediately after topic entry. +/// +/// Provider calls cannot currently be aborted, so reset/restart invalidates an +/// epoch and ignores all late results. Repeating the same topic is idempotent. +final class ImageStylePreviewCoordinator extends ChangeNotifier { + final ImageGenerator _generator; + final List _styles; + + String? _topic; + List _previews = const []; + var _epoch = 0; + var _disposed = false; + + ImageStylePreviewCoordinator({ + required ImageGenerator generator, + required PresentationImageStyleCatalog catalog, + Iterable styleIds = featuredPresentationImageStyleIds, + }) : _generator = generator, + _styles = .unmodifiable( + styleIds.map((id) { + final style = catalog.current(id); + if (style == null) { + throw ArgumentError('Unknown preview image style "$id".'); + } + + return style; + }), + ) { + if (_styles.length != 3 || + _styles.map((style) => style.id).toSet().length != 3) { + throw ArgumentError('Image preview plan requires three unique styles.'); + } + } + + Future _generate( + PresentationImageStyleDescriptor style, + int epoch, + ) async { + final topic = _topic; + if (topic == null) return; + final prompt = buildPresentationImagePrompt(style.buildPrompt(topic)); + final result = await generateImageSafely( + _generator, + ImageGenerationRequest(prompt: prompt, aspectRatio: .landscape16x9), + ); + if (_disposed || epoch != _epoch) return; + + final index = _previews.indexWhere( + (preview) => preview.style.id == style.id, + ); + if (index < 0) return; + _replace(index, switch (result) { + ImageGenerationSuccess(:final bytes) => _previews[index].copyWith( + status: .ready, + bytes: bytes, + clearError: true, + ), + ImageGenerationFailure(:final message) => _previews[index].copyWith( + status: .failed, + error: message, + clearBytes: true, + ), + }); + notifyListeners(); + } + + void _replace(int index, ImageStylePreview preview) { + _previews = [..._previews]..[index] = preview; + } + + String? get topic => _topic; + + List get previews => .unmodifiable(_previews); + + void prefetch(String rawTopic) { + final topic = rawTopic.trim().replaceAll(RegExp(r'\s+'), ' '); + if (topic.isEmpty || _disposed) return; + if (_topic == topic && _previews.isNotEmpty) return; + + final epoch = ++_epoch; + _topic = topic; + _previews = [ + for (final style in _styles) + ImageStylePreview(style: style, status: .loading), + ]; + notifyListeners(); + + for (final style in _styles) { + unawaited(_generate(style, epoch)); + } + } + + void retry(String styleId) { + if (_disposed || _topic == null) return; + final index = _previews.indexWhere( + (preview) => preview.style.id == styleId, + ); + if (index < 0 || _previews[index].status != .failed) return; + + _replace( + index, + _previews[index].copyWith( + status: .loading, + clearBytes: true, + clearError: true, + ), + ); + notifyListeners(); + unawaited(_generate(_previews[index].style, _epoch)); + } + + void reset() { + if (_disposed) return; + _epoch++; + _topic = null; + _previews = const []; + notifyListeners(); + } + + @override + void dispose() { + if (_disposed) return; + _disposed = true; + _epoch++; + super.dispose(); + } +} diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart index ee500e5d..43411e04 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart @@ -50,46 +50,15 @@ abstract interface class GenerationPromptProvider { /// Flutter-asset backed prompt provider used by the production app. final class AssetGenerationPromptProvider implements GenerationPromptProvider { + final PromptRegistry _promptRegistry; + + final AssetCompositionExampleLibrary _exampleLibrary; AssetGenerationPromptProvider({ PromptRegistry? promptRegistry, AssetCompositionExampleLibrary? exampleLibrary, }) : _promptRegistry = promptRegistry ?? PromptRegistry.instance, _exampleLibrary = exampleLibrary ?? AssetCompositionExampleLibrary(); - final PromptRegistry _promptRegistry; - final AssetCompositionExampleLibrary _exampleLibrary; - - @override - Future load() => - Future.wait([_promptRegistry.load(), _exampleLibrary.load()]); - - @override - String buildOutlinePrompt({ - required List themeCandidates, - List validationIssues = const [], - Map? invalidPlan, - }) { - if (validationIssues.isNotEmpty) { - return _buildOutlineRepairPrompt( - themeCandidates: themeCandidates, - validationIssues: validationIssues, - invalidPlan: invalidPlan, - ); - } - - return ''' -${_promptRegistry.render('outline_system')} - -## Eligible presentation themes - -Choose exactly one `theme.id` from the compact candidates below. Return only -that ID inside the theme object. Never return a version, palette, font family, -brand override, or runtime styling token; the application owns those values. - -${const JsonEncoder.withIndent(' ').convert(themeCandidates.map((theme) => theme.toModelCandidate()).toList())} -'''; - } - String _buildOutlineRepairPrompt({ required List themeCandidates, required List validationIssues, @@ -99,6 +68,7 @@ ${const JsonEncoder.withIndent(' ').convert(themeCandidates.map((theme) => them final literalRepairChecklist = _outlineLiteralRepairChecklist( validationIssues, ); + return ''' You repair one SuperDeck deck plan. Return one complete replacement deck-plan JSON object matching the response schema—no commentary or Markdown fence. @@ -113,7 +83,8 @@ Hard contract: - preserve the requested slide count, slide order, unique keys, section membership, and exact supported composition/treatment values - keep audience-facing claims grounded in the original request -- keep each planned element's exact type, source, purpose, and cardinality +- keep each planned element's exact type, source or generationPrompt, purpose, + and cardinality - choose one eligible theme ID; never author palette, font, or runtime tokens - return the complete plan, including every valid unchanged section and slide @@ -136,6 +107,37 @@ complete corrected deck-plan object. '''; } + @override + Future load() => + Future.wait([_promptRegistry.load(), _exampleLibrary.load()]); + + @override + String buildOutlinePrompt({ + required List themeCandidates, + List validationIssues = const [], + Map? invalidPlan, + }) { + if (validationIssues.isNotEmpty) { + return _buildOutlineRepairPrompt( + themeCandidates: themeCandidates, + validationIssues: validationIssues, + invalidPlan: invalidPlan, + ); + } + + return ''' +${_promptRegistry.render('outline_system')} + +## Eligible presentation themes + +Choose exactly one `theme.id` from the compact candidates below. Return only +that ID inside the theme object. Never return a version, palette, font family, +brand override, or runtime styling token; the application owns those values. + +${const JsonEncoder.withIndent(' ').convert(themeCandidates.map((theme) => theme.toModelCandidate()).toList())} +'''; + } + @override String buildSlidePrompt({ required DeckPlanType plan, @@ -377,15 +379,15 @@ String buildSingleSlideRepairPrompt({ ); final requiresElementContext = validationIssues.any( (issue) => - issue.code == GenerationValidationCode.elementGrounding || - issue.code == GenerationValidationCode.widgetArguments || + issue.code == .elementGrounding || + issue.code == .widgetArguments || issue.code == GenerationValidationCode.handoffPurpose, ); final requiresNumericContext = validationIssues.any( (issue) => issue.code == GenerationValidationCode.numericGrounding || issue.code == GenerationValidationCode.numericMeaning || - issue.code == GenerationValidationCode.metricIntent, + issue.code == .metricIntent, ); final hasCommentIssue = validationIssues.any( (issue) => issue.location == GenerationValidationLocation.speakerComments, diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/outline_schema.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/outline_schema.dart index 61bd2fac..6d7179a2 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/outline_schema.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/outline_schema.dart @@ -90,6 +90,9 @@ final deckPlanElementSchema = Ack.object({ 'source': Ack.string().optional().describe( 'User-supplied asset path, URL, text, or gist identifier when available', ), + 'generationPrompt': Ack.string().optional().describe( + 'Concrete visual subject to generate when an image style is configured', + ), 'widgetName': Ack.string().optional().describe( 'Registered widget name when type is custom', ), diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/outline_schema.g.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/outline_schema.g.dart index 887c6328..66a1c714 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/outline_schema.g.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/outline_schema.g.dart @@ -60,6 +60,8 @@ extension type DeckPlanElementType(Map _data) String? get source => _data['source'] as String?; + String? get generationPrompt => _data['generationPrompt'] as String?; + String? get widgetName => _data['widgetName'] as String?; } diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generation_request.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generation_request.dart index fad4fc71..eeb873ef 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generation_request.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generation_request.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import '../../../../../../core/domain/design/presentation_image_style_catalog.dart'; import 'source_grounding.dart'; /// A user-supplied element whose source is safe for generation to reference. @@ -48,8 +49,9 @@ final class DeckGenerationRequest { final List colors; final String? headlineFont; final String? bodyFont; - final String? imageStyleName; - final String? imageStyleDescription; + final String? imageStyleId; + final int? imageStyleVersion; + final int maxGeneratedImages; final List groundedElements; const DeckGenerationRequest({ @@ -64,10 +66,16 @@ final class DeckGenerationRequest { this.colors = const [], this.headlineFont, this.bodyFont, - this.imageStyleName, - this.imageStyleDescription, + this.imageStyleId, + this.imageStyleVersion, + this.maxGeneratedImages = 4, this.groundedElements = const [], - }) : assert(slideCount > 0 && slideCount <= 50); + }) : assert(slideCount > 0 && slideCount <= 50), + assert(maxGeneratedImages >= 0 && maxGeneratedImages <= 4), + assert( + (imageStyleId == null) == (imageStyleVersion == null), + 'Image style ID and version must be supplied together.', + ); factory DeckGenerationRequest.fromMap(Map map) => DeckGenerationRequest( @@ -82,8 +90,9 @@ final class DeckGenerationRequest { colors: _stringList(map['colors']), headlineFont: map['headlineFont'] as String?, bodyFont: map['bodyFont'] as String?, - imageStyleName: map['imageStyleName'] as String?, - imageStyleDescription: map['imageStyleDescription'] as String?, + imageStyleId: map['imageStyleId'] as String?, + imageStyleVersion: map['imageStyleVersion'] as int?, + maxGeneratedImages: map['maxGeneratedImages'] as int? ?? 4, groundedElements: switch (map['groundedElements']) { final List values => [ for (final value in values) @@ -110,8 +119,9 @@ final class DeckGenerationRequest { if (colors.isNotEmpty) 'colors': colors, 'headlineFont': ?headlineFont, 'bodyFont': ?bodyFont, - 'imageStyleName': ?imageStyleName, - 'imageStyleDescription': ?imageStyleDescription, + 'imageStyleId': ?imageStyleId, + 'imageStyleVersion': ?imageStyleVersion, + if (imageStyleId != null) 'maxGeneratedImages': maxGeneratedImages, if (groundedElements.isNotEmpty) 'groundedElements': groundedElements .map((element) => element.toMap()) @@ -120,6 +130,22 @@ final class DeckGenerationRequest { /// JSON data supplied as the model's user content. String toModelInput() => const JsonEncoder.withIndent(' ').convert(toMap()); + + /// Resolves the selected exact version before any provider call. + PresentationImageStyleDescriptor? resolveImageStyle( + PresentationImageStyleCatalog catalog, + ) { + final id = imageStyleId; + final version = imageStyleVersion; + if (id == null && version == null) return null; + if (id == null || version == null) { + throw ArgumentError( + 'Image style ID and version must be supplied together.', + ); + } + + return catalog.resolve(id: id, version: version); + } } List _stringList(Object? value) => switch (value) { diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_images.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_images.dart new file mode 100644 index 00000000..e1781b78 --- /dev/null +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_images.dart @@ -0,0 +1,250 @@ +part of 'deck_generator_service.dart'; + +typedef ImageGenerationProgressCallback = + void Function(int completed, int total); + +/// Rewritten composition plan and ordered image outcomes for one run. +final class DeckImageGenerationResult { + final DeckPlanType plan; + + final List assets; + const DeckImageGenerationResult({required this.plan, required this.assets}); +} + +final class _PlannedImage { + final int slideIndex; + + final int elementIndex; + final String subject; + final String assetKey; + final GeneratedImageAspectRatio aspectRatio; + const _PlannedImage({ + required this.slideIndex, + required this.elementIndex, + required this.subject, + required this.assetKey, + required this.aspectRatio, + }); +} + +Future _runImagePhase( + DeckGeneratorService owner, { + required DeckPlanType plan, + required DeckGenerationRequest request, + required GenerationProgressCallback? onProgress, + required GenerationTraceEmitter trace, + required bool Function() isCancelled, +}) async { + final style = request.resolveImageStyle(owner.imageStyleCatalog); + if (style == null) { + return DeckImageGenerationResult(plan: plan, assets: const []); + } + + debugLog.section('Phase 2: Generate Artwork'); + trace.emit(kind: .phaseStarted, phase: .image); + final resolvedTheme = resolveDeckThemeReference( + plan.theme, + themeCatalog: owner.themeCatalog, + typographyCatalog: owner.typographyCatalog, + ); + final timer = Stopwatch()..start(); + final result = await generateImagesForPlan( + plan: plan, + imageStyle: style, + generator: owner.imageGenerator ?? const UnavailableImageGenerator(), + runId: owner._assetRunIdFactory(), + backgroundColor: resolvedTheme.palette.background, + onProgress: (completed, total) => onProgress?.call( + GenerationProgress( + .generatingImages, + completedItems: completed, + totalItems: total, + ), + ), + isCancelled: isCancelled, + ); + timer.stop(); + if (!isCancelled()) { + final successful = result.assets + .where((asset) => asset.bytes != null) + .length; + debugLog.log( + 'DECK_GEN', + 'Artwork COMPLETE in ' + '${timer.elapsedMilliseconds}ms - ' + '$successful of ${result.assets.length} ready', + ); + trace.emit(kind: .phaseDone, phase: .image); + } + + return result; +} + +/// Generates planned artwork with bounded concurrency and returns a plan that +/// references only successful assets. Failed visuals fall back to text layouts. +Future generateImagesForPlan({ + required DeckPlanType plan, + required PresentationImageStyleDescriptor imageStyle, + required ImageGenerator generator, + required String runId, + String? backgroundColor, + ImageGenerationProgressCallback? onProgress, + bool Function()? isCancelled, + int maxConcurrency = 4, +}) async { + if (maxConcurrency < 1) { + throw ArgumentError.value( + maxConcurrency, + 'maxConcurrency', + 'Must be positive.', + ); + } + final planned = _plannedImages(plan, runId); + if (planned.isEmpty) { + onProgress?.call(0, 0); + + return DeckImageGenerationResult(plan: plan, assets: const []); + } + + var nextIndex = 0; + var completed = 0; + final results = List.filled(planned.length, null); + onProgress?.call(0, planned.length); + + Future worker() async { + while (!(isCancelled?.call() ?? false)) { + final workIndex = nextIndex++; + if (workIndex >= planned.length) return; + + final image = planned[workIndex]; + final result = await generateImageSafely( + generator, + ImageGenerationRequest( + prompt: buildPresentationImagePrompt( + imageStyle.buildPrompt(image.subject), + backgroundColor: backgroundColor, + ), + aspectRatio: image.aspectRatio, + ), + ); + results[workIndex] = switch (result) { + ImageGenerationSuccess(:final bytes) => GeneratedImageAsset.success( + assetKey: image.assetKey, + bytes: bytes, + ), + ImageGenerationFailure(:final message) => GeneratedImageAsset.failure( + assetKey: image.assetKey, + error: message, + ), + }; + completed++; + onProgress?.call(completed, planned.length); + } + } + + final workerCount = planned.length < maxConcurrency + ? planned.length + : maxConcurrency; + await Future.wait(List.generate(workerCount, (_) => worker())); + final assets = results.nonNulls.toList(growable: false); + if (isCancelled?.call() ?? false) { + return DeckImageGenerationResult(plan: plan, assets: assets); + } + + return DeckImageGenerationResult( + plan: _rewriteGeneratedImageSources(plan, planned, assets), + assets: assets, + ); +} + +List<_PlannedImage> _plannedImages(DeckPlanType plan, String runId) { + final planned = <_PlannedImage>[]; + for (final (slideIndex, slide) in plan.slides.indexed) { + for (final (elementIndex, element) + in (slide.elements ?? const []).indexed) { + final subject = element.generationPrompt?.trim(); + if (element.type != 'image' || subject == null || subject.isEmpty) { + continue; + } + planned.add( + _PlannedImage( + slideIndex: slideIndex, + elementIndex: elementIndex, + subject: subject, + assetKey: buildGeneratedAssetKey( + runId: runId, + slideIndex: slideIndex, + slideKey: slide.key, + ), + aspectRatio: slide.composition == 'imageFullBleed' + ? .landscape16x9 + : .slide3x4, + ), + ); + } + } + + return planned; +} + +DeckPlanType _rewriteGeneratedImageSources( + DeckPlanType plan, + List<_PlannedImage> planned, + List assets, +) { + final rewritten = jsonDecode(jsonEncode(plan)) as Map; + final slides = rewritten['slides']! as List; + + for (final (index, image) in planned.indexed.toList().reversed) { + final slide = slides[image.slideIndex] as Map; + final elements = slide['elements']! as List; + final asset = assets[index]; + if (asset.bytes case final bytes? when bytes.isNotEmpty) { + final element = elements[image.elementIndex] as Map; + element['source'] = image.assetKey; + element.remove('generationPrompt'); + continue; + } + + elements.removeAt(image.elementIndex); + if (slide['composition'] case final String composition + when composition == 'imageLeft' || + composition == 'imageRight' || + composition == 'imageFullBleed') { + slide['composition'] = 'content'; + slide['treatment'] = 'content'; + } + } + + return DeckPlanType.parse(rewritten); +} + +String buildGeneratedAssetKey({ + required String runId, + required int slideIndex, + required String slideKey, +}) { + final safeRun = _safeAssetSlug(runId, fallback: 'run', maxLength: 24); + final safeSlide = _safeAssetSlug(slideKey, fallback: 'slide', maxLength: 42); + final number = (slideIndex + 1).toString().padLeft(2, '0'); + + return 'wizard-$safeRun-slide-$number-$safeSlide.png'; +} + +String _safeAssetSlug( + String value, { + required String fallback, + required int maxLength, +}) { + var slug = value + .trim() + .toLowerCase() + .replaceAll(RegExp(r'[^a-z0-9]+'), '-') + .replaceAll(RegExp(r'^-+|-+$'), ''); + if (slug.isEmpty) slug = fallback; + if (slug.length > maxLength) { + slug = slug.substring(0, maxLength).replaceFirst(RegExp(r'-+$'), ''); + } + + return slug; +} diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart index 84434dc2..a0b09518 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart @@ -51,6 +51,9 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { : outlineRepairModelName; final modelRequest = google_ai.GenerateContentRequest( model: selectedModel, + systemInstruction: google_ai.Content( + parts: [google_ai.Part(text: systemPrompt)], + ), contents: [ google_ai.Content( role: 'user', @@ -62,9 +65,6 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { responseSchema: adaptResult.schema, thinkingConfig: google_ai.ThinkingConfig(thinkingBudget: 0), ), - systemInstruction: google_ai.Content( - parts: [google_ai.Part(text: systemPrompt)], - ), ); debugLog.log( @@ -114,6 +114,7 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { validationIssues = validateDeckPlanIssues( plan, typographyCatalog: typographyCatalog, + imageStyleCatalog: imageStyleCatalog, themeCatalog: themeCatalog, request: request, ); @@ -127,6 +128,7 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { validationIssues = validateDeckPlanIssues( repairedPlan, typographyCatalog: typographyCatalog, + imageStyleCatalog: imageStyleCatalog, themeCatalog: themeCatalog, request: request, ); @@ -204,6 +206,7 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { isCancelled, ); } + return _composeSlidesSequentially( executor, prompt, @@ -254,6 +257,7 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { sectionCount: plan.sections.length, ), ); + return result; }(), ]); @@ -264,6 +268,7 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { phase: GenerationTracePhase.slide, slideCount: plan.slides.length, ); + return _SlideCompositionResult( slides: List.unmodifiable([ for (final result in sectionResults) ...result.slides, @@ -338,6 +343,9 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { final modelRequest = google_ai.GenerateContentRequest( model: modelName, + systemInstruction: google_ai.Content( + parts: [google_ai.Part(text: systemPrompt)], + ), contents: [ google_ai.Content( role: 'user', @@ -349,9 +357,6 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { responseSchema: adaptResult.schema, thinkingConfig: google_ai.ThinkingConfig(thinkingBudget: 0), ), - systemInstruction: google_ai.Content( - parts: [google_ai.Part(text: systemPrompt)], - ), ); final google_ai.GenerateContentResponse response; @@ -373,12 +378,13 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { rethrow; } catch (error, stack) { final category = const ErrorClassifier().classify(error); - if (category != ErrorCategory.network) rethrow; + if (category != .network) rethrow; debugLog.error( 'DECK_GEN', 'Section ${section.key} transport failed; preserving other sections.', stack, ); + return _failedSection( plan: plan, slides: plannedSlides, @@ -481,6 +487,7 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { issues: issues, ); } + return _SlideCompositionResult( slides: const [], failures: List.unmodifiable([ @@ -527,8 +534,8 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { var issues = validateGeneratedSlideIssues( expectedKey: planSlide.key, rawSlide: normalized, - planSlide: planSlide, elementCatalog: elementCatalog, + planSlide: planSlide, request: request, ); final commentSafeSlide = removeInvalidOptionalSpeakerComments( @@ -540,8 +547,8 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { issues = validateGeneratedSlideIssues( expectedKey: planSlide.key, rawSlide: normalized, - planSlide: planSlide, elementCatalog: elementCatalog, + planSlide: planSlide, request: request, ); } @@ -563,6 +570,7 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { ], ); } + return (canonical: canonical, issues: issues); } @@ -667,7 +675,7 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { rethrow; } catch (error, stack) { final category = const ErrorClassifier().classify(error); - if (category != ErrorCategory.network) rethrow; + if (category != .network) rethrow; validationIssues = [ GenerationValidationIssue( code: GenerationValidationCode.invalidResponse, @@ -795,6 +803,7 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { phase: GenerationTracePhase.slide, slideCount: plan.slides.length, ); + return _SlideCompositionResult( slides: List.unmodifiable(slides), failures: List.unmodifiable(failures), @@ -854,6 +863,9 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { final request = google_ai.GenerateContentRequest( model: modelName, + systemInstruction: google_ai.Content( + parts: [google_ai.Part(text: systemPrompt)], + ), contents: [ google_ai.Content( role: 'user', @@ -865,9 +877,6 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { responseSchema: adaptResult.schema, thinkingConfig: google_ai.ThinkingConfig(thinkingBudget: 0), ), - systemInstruction: google_ai.Content( - parts: [google_ai.Part(text: systemPrompt)], - ), ); debugLog.log('DECK_GEN', 'Composing slide $slideIndex/$slideCount...'); diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart index a6035c7e..49230d7b 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart @@ -5,8 +5,11 @@ import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; import 'package:google_cloud_ai_generativelanguage_v1beta/generativelanguage.dart' as google_ai; import 'package:superdeck_core/superdeck_core.dart'; +import '../../../../../../core/domain/design/presentation_image_style_catalog.dart'; import '../../../../../../core/domain/design/presentation_theme_catalog.dart'; import '../../../../../../core/domain/design/presentation_typography_catalog.dart'; +import '../../../../../../core/domain/generated_image_asset.dart'; +import '../../../../image_generation/image_generator.dart'; import '../prompts/generation_prompt_provider.dart'; import '../schemas/outline_schema.dart'; import 'deck_generation_request.dart'; @@ -28,11 +31,17 @@ import '../../constants/gemini_models.dart'; import '../../debug_logger.dart'; part 'deck_generator_pipeline.dart'; +part 'deck_generator_images.dart'; part 'deck_plan_repair.dart'; part 'deck_generator_workflow.dart'; /// One slide slot that could not be composed into a valid canonical slide. final class SlideGenerationFailure { + final int slideIndex; + + final String slideKey; + final List issues; + final bool retryable; const SlideGenerationFailure({ required this.slideIndex, required this.slideKey, @@ -40,11 +49,6 @@ final class SlideGenerationFailure { this.retryable = true, }); - final int slideIndex; - final String slideKey; - final List issues; - final bool retryable; - String get message => issues.messages.join(' '); Map toJson() => { @@ -73,26 +77,36 @@ class DeckGenerationResult { /// Ordered slide slots that remain unresolved and can be retried. final List slideFailures; + /// Ordered final-artwork outcomes produced for this generation run. + /// + /// Failed outcomes are retained for diagnostics, while the validated plan + /// references only assets whose bytes are available. + final List generatedImages; + const DeckGenerationResult._({ required this.success, this.message, this.error, List? slides, List? slideFailures, + List? generatedImages, this.theme, this.plan, }) : slides = slides ?? const [], - slideFailures = slideFailures ?? const []; + slideFailures = slideFailures ?? const [], + generatedImages = generatedImages ?? const []; DeckGenerationResult.success({ required List slides, required DeckPlanType plan, required ResolvedPresentationTheme theme, + List generatedImages = const [], }) : this._( success: true, message: 'Successfully generated presentation with ${slides.length} slides.', slides: slides, + generatedImages: generatedImages, theme: theme, plan: plan, ); @@ -102,6 +116,7 @@ class DeckGenerationResult { required List slideFailures, required DeckPlanType plan, required ResolvedPresentationTheme theme, + List generatedImages = const [], }) : this._( success: false, error: @@ -110,6 +125,7 @@ class DeckGenerationResult { '(${failure.message})').join(', ')}.', slides: slides, slideFailures: slideFailures, + generatedImages: generatedImages, theme: theme, plan: plan, ); @@ -120,18 +136,32 @@ class DeckGenerationResult { /// Number of slides in the result. int get slideCount => slides.length; + int get generatedImageCount => generatedImages + .where((asset) => asset.bytes != null && asset.bytes!.isNotEmpty) + .length; + + int get failedImageCount => generatedImages.length - generatedImageCount; + + bool get hasImageFailures => failedImageCount > 0; + bool get isPartial => plan != null && slideFailures.isNotEmpty; } final class _SlideCompositionResult { - const _SlideCompositionResult({required this.slides, required this.failures}); - final List> slides; + final List failures; + const _SlideCompositionResult({required this.slides, required this.failures}); } /// Result of generating and validating the deck plan before slide composition. final class DeckPlanningResult { + final bool success; + + final DeckPlanType? plan; + + final String? error; + const DeckPlanningResult._({required this.success, this.plan, this.error}); const DeckPlanningResult.success(DeckPlanType plan) @@ -139,10 +169,6 @@ final class DeckPlanningResult { const DeckPlanningResult.failure(String error) : this._(success: false, error: error); - - final bool success; - final DeckPlanType? plan; - final String? error; } /// Service that generates SuperDeck presentations using Google Generative AI. @@ -155,6 +181,12 @@ class DeckGeneratorService { final PresentationThemeCatalog themeCatalog; + final PresentationImageStyleCatalog imageStyleCatalog; + + /// Optional final-artwork provider. Missing providers degrade visual slides + /// to valid content layouts instead of failing the whole deck. + final ImageGenerator? imageGenerator; + final String apiKey; /// Model used to compose and repair each slide. @@ -211,6 +243,8 @@ class DeckGeneratorService { final GenerationPromptProvider _promptProvider; + final String Function() _assetRunIdFactory; + DeckGeneratorService({ required this.apiKey, this.modelName = GeminiModelNames.gemini31FlashLite, @@ -230,6 +264,9 @@ class DeckGeneratorService { GenerationElementCatalog? elementCatalog, PresentationTypographyCatalog? typographyCatalog, PresentationThemeCatalog? themeCatalog, + PresentationImageStyleCatalog? imageStyleCatalog, + this.imageGenerator, + String Function()? assetRunIdFactory, }) : assert(sectionBatchThreshold > 0), assert(maxOutlineValidationAttempts > 0), assert(maxOutlineSlideValidationAttempts > 0), @@ -242,9 +279,67 @@ class DeckGeneratorService { typographyCatalog = typographyCatalog ?? PresentationTypographyCatalog.withDefaults(), themeCatalog = themeCatalog ?? PresentationThemeCatalog.withDefaults(), + imageStyleCatalog = + imageStyleCatalog ?? PresentationImageStyleCatalog.withDefaults(), _modelClientFactory = modelClientFactory ?? GoogleGenerationModelClient.fromApiKey, - _promptProvider = promptProvider ?? AssetGenerationPromptProvider(); + _promptProvider = promptProvider ?? AssetGenerationPromptProvider(), + _assetRunIdFactory = assetRunIdFactory ?? _newAssetRunId; + + String? _validateRequest(DeckGenerationRequest request) { + if (request.userIntent.trim().isEmpty) { + return 'Describe the presentation to create.'; + } + if (request.slideCount < 1 || request.slideCount > 50) { + return 'Slide count must be between 1 and 50.'; + } + if (request.maxGeneratedImages < 0 || request.maxGeneratedImages > 4) { + return 'Generated image count must be between 0 and 4.'; + } + try { + request.resolveImageStyle(imageStyleCatalog); + } catch (error) { + return 'Image style selection is invalid: ${_argumentMessage(error)}'; + } + + return null; + } + + List _themeCandidates( + DeckGenerationRequest request, + ) { + return themeCandidatesForRequest( + request: request, + themeCatalog: themeCatalog, + typographyCatalog: typographyCatalog, + ); + } + + GenerationModelCallExecutor _createExecutor({ + required GenerationModelClient client, + required GenerationTraceEmitter trace, + required DeckGenerationRequest request, + required bool Function() isCancelled, + }) { + final repairBudget = + maxRepairRequests ?? _defaultRepairBudget(request.slideCount); + debugLog.log( + 'DECK_GEN', + 'Run budgets: repairs=$repairBudget, requests=$maxModelRequests, ' + 'timeout=${runTimeout.inSeconds}s', + ); + + return GenerationModelCallExecutor( + client: client, + retryPolicy: retryPolicy, + trace: trace, + requestTimeout: requestTimeout, + isCancelled: isCancelled, + maxModelRequests: maxModelRequests, + maxRepairRequests: repairBudget, + runTimeout: runTimeout, + ); + } /// Generates and validates the shared deck plan without composing slides. Future plan( @@ -298,6 +393,7 @@ class DeckGeneratorService { 'Failed to generate presentation outline. Please try again.', ); } + return DeckPlanningResult.success(outline); } on GenerationCancelledException { return const DeckPlanningResult.failure('Generation cancelled.'); @@ -308,6 +404,7 @@ class DeckGeneratorService { 'Planning stopped after ${totalMs}ms: ${error.message}', stack, ); + return DeckPlanningResult.failure(error.message); } catch (error, stack) { final totalMs = DateTime.now().difference(pipelineStart).inMilliseconds; @@ -316,6 +413,7 @@ class DeckGeneratorService { 'Planning FAILED after ${totalMs}ms: $error', stack, ); + return DeckPlanningResult.failure( const ErrorClassifier().getUserMessage(error), ); @@ -338,6 +436,7 @@ class DeckGeneratorService { final planIssues = validateDeckPlanIssues( approvedPlan, typographyCatalog: typographyCatalog, + imageStyleCatalog: imageStyleCatalog, themeCatalog: themeCatalog, request: request, ).blockingIssues; @@ -363,12 +462,23 @@ class DeckGeneratorService { isCancelled: generationCancelled, ); await _promptProvider.load(); + final images = await _runImagePhase( + this, + plan: approvedPlan, + request: request, + onProgress: onProgress, + trace: trace, + isCancelled: generationCancelled, + ); + if (generationCancelled()) { + return DeckGenerationResult.failure('Generation cancelled.'); + } final composition = await _runSlideCompositionPhase( this, executor: executor, prompt: modelInput, request: request, - outline: approvedPlan, + outline: images.plan, onProgress: onProgress, trace: trace, isCancelled: isCancelled, @@ -381,10 +491,12 @@ class DeckGeneratorService { 'Failed while composing presentation slides. Please try again.', ); } + return _finalizeDeck( this, composition: composition, - plan: approvedPlan, + plan: images.plan, + generatedImages: images.assets, pipelineStart: pipelineStart, onProgress: onProgress, isCancelled: isCancelled, @@ -407,6 +519,7 @@ class DeckGeneratorService { 'Composition FAILED after ${totalMs}ms: $error', stack, ); + return DeckGenerationResult.failure( const ErrorClassifier().getUserMessage(error), ); @@ -444,8 +557,13 @@ class DeckGeneratorService { final planIssues = validateDeckPlanIssues( plan, typographyCatalog: typographyCatalog, + imageStyleCatalog: imageStyleCatalog, themeCatalog: themeCatalog, request: request, + knownGeneratedAssetKeys: partialResult.generatedImages + .where((asset) => asset.bytes != null) + .map((asset) => asset.assetKey) + .toSet(), ).blockingIssues; if (planIssues.isNotEmpty) { return DeckGenerationResult.failure( @@ -469,9 +587,7 @@ class DeckGeneratorService { isCancelled: generationCancelled, ); await _promptProvider.load(); - onProgress?.call( - const GenerationProgress(GenerationPhase.composingSlides), - ); + onProgress?.call(const GenerationProgress(.composingSlides)); final existingSlidesByKey = { for (final slide in partialResult.slides) slide.key: Map.of(slide.toMap()), @@ -512,10 +628,12 @@ class DeckGeneratorService { ]), failures: List.unmodifiable(remainingFailures), ); + return _finalizeDeck( this, composition: merged, plan: plan, + generatedImages: partialResult.generatedImages, pipelineStart: pipelineStart, onProgress: onProgress, isCancelled: generationCancelled, @@ -538,6 +656,7 @@ class DeckGeneratorService { 'Targeted retry FAILED after ${totalMs}ms: $error', stack, ); + return DeckGenerationResult.failure( const ErrorClassifier().getUserMessage(error), ); @@ -551,9 +670,10 @@ class DeckGeneratorService { /// Contractual fields such as slide count and typography remain typed so they /// can be validated independently from the user's prose. /// - /// Uses a two-phase pipeline: + /// Uses a three-phase pipeline: /// 1. Plan the shared narrative and visual system. - /// 2. Compose and validate the slides from that plan, grouping larger decks + /// 2. Generate the bounded set of planned artwork concurrently. + /// 3. Compose and validate the slides from that plan, grouping larger decks /// by narrative section for lower latency and stronger continuity. /// /// Progress updates are reported via [onProgress] if provided. @@ -612,12 +732,23 @@ class DeckGeneratorService { ); } + final images = await _runImagePhase( + this, + plan: outline, + request: request, + onProgress: onProgress, + trace: trace, + isCancelled: generationCancelled, + ); + if (generationCancelled()) { + return cancelledResult(); + } final composition = await _runSlideCompositionPhase( this, executor: executor, prompt: modelInput, request: request, - outline: outline, + outline: images.plan, onProgress: onProgress, trace: trace, isCancelled: isCancelled, @@ -635,7 +766,8 @@ class DeckGeneratorService { return _finalizeDeck( this, composition: composition, - plan: outline, + plan: images.plan, + generatedImages: images.assets, pipelineStart: pipelineStart, onProgress: onProgress, isCancelled: isCancelled, @@ -664,49 +796,13 @@ class DeckGeneratorService { client?.close(); } } +} - String? _validateRequest(DeckGenerationRequest request) { - if (request.userIntent.trim().isEmpty) { - return 'Describe the presentation to create.'; - } - if (request.slideCount < 1 || request.slideCount > 50) { - return 'Slide count must be between 1 and 50.'; - } - return null; - } +var _assetRunSequence = 0; - List _themeCandidates( - DeckGenerationRequest request, - ) { - return themeCandidatesForRequest( - request: request, - themeCatalog: themeCatalog, - typographyCatalog: typographyCatalog, - ); - } +String _newAssetRunId() { + final timestamp = DateTime.now().microsecondsSinceEpoch.toRadixString(36); + final sequence = (_assetRunSequence++).toRadixString(36); - GenerationModelCallExecutor _createExecutor({ - required GenerationModelClient client, - required GenerationTraceEmitter trace, - required DeckGenerationRequest request, - required bool Function() isCancelled, - }) { - final repairBudget = - maxRepairRequests ?? _defaultRepairBudget(request.slideCount); - debugLog.log( - 'DECK_GEN', - 'Run budgets: repairs=$repairBudget, requests=$maxModelRequests, ' - 'timeout=${runTimeout.inSeconds}s', - ); - return GenerationModelCallExecutor( - client: client, - retryPolicy: retryPolicy, - trace: trace, - requestTimeout: requestTimeout, - isCancelled: isCancelled, - maxModelRequests: maxModelRequests, - maxRepairRequests: repairBudget, - runTimeout: runTimeout, - ); - } + return '$timestamp-$sequence'; } diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_workflow.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_workflow.dart index a3348860..3f455571 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_workflow.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_workflow.dart @@ -13,6 +13,7 @@ void _logPipelineConfig(DeckGeneratorService owner, {required String prompt}) { int _defaultRepairBudget(int slideCount) { final proportionalBudget = (slideCount * 15) ~/ 100; + return proportionalBudget < 3 ? 3 : proportionalBudget; } @@ -72,7 +73,7 @@ Future<_SlideCompositionResult?> _runSlideCompositionPhase( required GenerationTraceEmitter trace, required bool Function()? isCancelled, }) async { - debugLog.section('Phase 2: Compose Slides'); + debugLog.section('Phase 3: Compose Slides'); trace.emit( kind: GenerationTraceKind.phaseStarted, phase: GenerationTracePhase.composition, @@ -93,7 +94,7 @@ Future<_SlideCompositionResult?> _runSlideCompositionPhase( if (composition == null) { debugLog.error( 'DECK_GEN', - 'Phase 2 FAILED after ${deckMs}ms - no deck JSON returned', + 'Phase 3 FAILED after ${deckMs}ms - no deck JSON returned', ); return null; } @@ -101,13 +102,14 @@ Future<_SlideCompositionResult?> _runSlideCompositionPhase( final deckSlides = composition.slides.length; debugLog.log( 'DECK_GEN', - 'Phase 2 COMPLETE in ${deckMs}ms - $deckSlides accepted, ' + 'Phase 3 COMPLETE in ${deckMs}ms - $deckSlides accepted, ' '${composition.failures.length} failed', ); trace.emit( kind: GenerationTraceKind.phaseDone, phase: GenerationTracePhase.composition, ); + return composition; } @@ -115,6 +117,7 @@ DeckGenerationResult _finalizeDeck( DeckGeneratorService owner, { required _SlideCompositionResult composition, required DeckPlanType plan, + List generatedImages = const [], required DateTime pipelineStart, required GenerationProgressCallback? onProgress, required bool Function()? isCancelled, @@ -145,6 +148,7 @@ DeckGenerationResult _finalizeDeck( phase: GenerationTracePhase.finalize, validationErrors: [failureSummary], ); + return DeckGenerationResult.failure(failureSummary); } @@ -200,6 +204,7 @@ DeckGenerationResult _finalizeDeck( slideFailures: composition.failures, plan: plan, theme: theme, + generatedImages: generatedImages, ); } @@ -207,6 +212,7 @@ DeckGenerationResult _finalizeDeck( slides: parsedSlides, plan: plan, theme: theme, + generatedImages: generatedImages, ); } diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart index a20cf5d6..05bbe697 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart @@ -5,6 +5,7 @@ const _maxTargetedOutlineSlidesPerPass = 2; bool _onlySlideScopedPlanIssues(List issues) { final blocking = issues.blockingIssues; final affectedSlideKeys = {for (final issue in blocking) ?issue.slideKey}; + return blocking.isNotEmpty && affectedSlideKeys.length <= _maxTargetedOutlineSlidesPerPass && blocking.every( @@ -24,6 +25,7 @@ extension _DeckPlanRepair on DeckGeneratorService { validateDeckPlanIssues( repairedPlan, typographyCatalog: typographyCatalog, + imageStyleCatalog: imageStyleCatalog, themeCatalog: themeCatalog, request: request, ), @@ -91,6 +93,7 @@ extension _DeckPlanRepair on DeckGeneratorService { final candidateIssues = validateDeckPlanIssues( candidatePlan, typographyCatalog: typographyCatalog, + imageStyleCatalog: imageStyleCatalog, themeCatalog: themeCatalog, request: request, ); @@ -129,6 +132,9 @@ extension _DeckPlanRepair on DeckGeneratorService { ); final modelRequest = google_ai.GenerateContentRequest( model: outlineRepairModelName, + systemInstruction: google_ai.Content( + parts: [google_ai.Part(text: systemPrompt)], + ), contents: [ google_ai.Content( role: 'user', @@ -140,9 +146,6 @@ extension _DeckPlanRepair on DeckGeneratorService { responseSchema: adapted.schema, thinkingConfig: google_ai.ThinkingConfig(thinkingBudget: 0), ), - systemInstruction: google_ai.Content( - parts: [google_ai.Part(text: systemPrompt)], - ), ); debugLog.log( diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart index 2bfb494b..284737a6 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import '../../../../../../core/domain/design/presentation_image_style_catalog.dart'; import '../../../../../../core/domain/design/presentation_theme_catalog.dart'; import '../../../../../../core/domain/design/presentation_typography_catalog.dart'; import '../schemas/outline_schema.dart'; @@ -14,14 +15,18 @@ List validateDeckPlan( DeckPlanType plan, { int? expectedSlideCount, PresentationTypographyCatalog? typographyCatalog, + PresentationImageStyleCatalog? imageStyleCatalog, PresentationThemeCatalog? themeCatalog, DeckGenerationRequest? request, + Set knownGeneratedAssetKeys = const {}, }) => validateDeckPlanIssues( plan, expectedSlideCount: expectedSlideCount, typographyCatalog: typographyCatalog, + imageStyleCatalog: imageStyleCatalog, themeCatalog: themeCatalog, request: request, + knownGeneratedAssetKeys: knownGeneratedAssetKeys, ).messages; /// Returns typed semantic issues for pipeline decisions and diagnostics. @@ -29,14 +34,18 @@ List validateDeckPlanIssues( DeckPlanType plan, { int? expectedSlideCount, PresentationTypographyCatalog? typographyCatalog, + PresentationImageStyleCatalog? imageStyleCatalog, PresentationThemeCatalog? themeCatalog, DeckGenerationRequest? request, + Set knownGeneratedAssetKeys = const {}, }) { final issues = GenerationValidationCollector(); final usedKeys = {}; final catalog = typographyCatalog ?? PresentationTypographyCatalog.withDefaults(); final themes = themeCatalog ?? PresentationThemeCatalog.withDefaults(); + final imageStyles = + imageStyleCatalog ?? PresentationImageStyleCatalog.withDefaults(); final exactSlideCount = request?.slideCount ?? expectedSlideCount; if (exactSlideCount != null && plan.slides.length != exactSlideCount) { @@ -96,6 +105,16 @@ List validateDeckPlanIssues( _validateElementGrounding( plan, request, + knownGeneratedAssetKeys, + issues.scoped( + code: GenerationValidationCode.elementGrounding, + category: GenerationValidationCategory.grounding, + ), + ); + _validateGeneratedImageIntent( + plan, + request, + imageStyles, issues.scoped( code: GenerationValidationCode.elementGrounding, category: GenerationValidationCategory.grounding, @@ -163,6 +182,103 @@ List validateDeckPlanIssues( return issues.issues.uniqueIssues; } +void _validateGeneratedImageIntent( + DeckPlanType plan, + DeckGenerationRequest? request, + PresentationImageStyleCatalog imageStyleCatalog, + GenerationValidationCollector errors, +) { + var generatedImageCount = 0; + final hasImageStyle = _hasResolvedImageStyle( + request, + imageStyleCatalog, + errors, + ); + + for (final slide in plan.slides) { + final slideErrors = errors.scoped( + location: GenerationValidationLocation.planSlide, + slideKey: slide.key, + ); + final elements = slide.elements ?? const []; + final imageCount = elements + .where((element) => element.type == 'image') + .length; + if (imageCount > 1) { + slideErrors.add( + 'Slide "${slide.key}" plans $imageCount image elements; ' + 'image compositions support one.', + ); + } + + for (final element in elements) { + final source = element.source?.trim(); + final prompt = element.generationPrompt?.trim(); + final hasSource = source != null && source.isNotEmpty; + final hasPrompt = prompt != null && prompt.isNotEmpty; + + if (element.type != 'image') { + if (hasPrompt) { + slideErrors.add( + 'Slide "${slide.key}" uses generationPrompt on non-image ' + 'element type "${element.type}".', + ); + } + continue; + } + + if (hasSource == hasPrompt) { + slideErrors.add( + 'Slide "${slide.key}" image element must provide exactly one of ' + 'source or generationPrompt.', + ); + continue; + } + if (!hasPrompt) continue; + + generatedImageCount++; + if (!hasImageStyle) { + slideErrors.add( + 'Slide "${slide.key}" requests a generated image without an exact ' + 'image-style reference.', + ); + } + } + } + + final budget = request?.maxGeneratedImages ?? 0; + if (generatedImageCount > budget) { + errors.add( + 'Deck plan requests $generatedImageCount generated images; the configured ' + 'maximum is $budget.', + ); + } +} + +bool _hasResolvedImageStyle( + DeckGenerationRequest? request, + PresentationImageStyleCatalog imageStyleCatalog, + GenerationValidationCollector errors, +) { + if (request == null || + (request.imageStyleId == null && request.imageStyleVersion == null)) { + return false; + } + try { + request.resolveImageStyle(imageStyleCatalog); + return true; + } on ArgumentError catch (error) { + errors.add( + error + .toString() + .replaceFirst('Invalid argument(s): ', '') + .replaceFirst('Invalid argument: ', ''), + ); + + return false; + } +} + void _validateMetricIntent( DeckPlanType plan, DeckGenerationRequest? request, @@ -243,7 +359,7 @@ void _validateCommitmentGrounding( errors .scoped( severity: hasBlockingCommitmentClaim(narrativeUnsupported) - ? GenerationValidationSeverity.blocking + ? .blocking : GenerationValidationSeverity.diagnostic, ) .add( @@ -260,7 +376,7 @@ void _validateCommitmentGrounding( if (unsupported.isNotEmpty) { final slideErrors = errors.scoped( severity: hasBlockingCommitmentClaim(unsupported) - ? GenerationValidationSeverity.blocking + ? .blocking : GenerationValidationSeverity.diagnostic, location: GenerationValidationLocation.planSlide, slideKey: slide.key, @@ -470,6 +586,7 @@ void _validateTheme( void _validateElementGrounding( DeckPlanType plan, DeckGenerationRequest? request, + Set knownGeneratedAssetKeys, GenerationValidationCollector errors, ) { for (final slide in plan.slides) { @@ -508,6 +625,7 @@ void _validateElementGrounding( for (final element in slide.elements ?? const []) { final source = element.source; if (source == null || source.trim().isEmpty) continue; + if (knownGeneratedAssetKeys.contains(source)) continue; if (!structuredSources.contains(source) && !request.userIntent.contains(source)) { slideErrors.add( diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart index 2a70354e..ba9adf68 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart @@ -58,6 +58,7 @@ Map _normalizeBlockForPlan( if (planSlide.composition == 'title') { normalized = _flattenTitleListMarkers(normalized); } + return normalized; } @@ -277,7 +278,7 @@ List _validatePlanFulfillment( ); final compositionGuidance = errors.scoped( category: GenerationValidationCategory.quality, - severity: GenerationValidationSeverity.diagnostic, + severity: .diagnostic, ); if (slide.sections.length > 2) { compositionGuidance.add( @@ -359,7 +360,7 @@ List _validatePlanFulfillment( .scoped( code: GenerationValidationCode.numericGrounding, category: GenerationValidationCategory.factual, - severity: GenerationValidationSeverity.diagnostic, + severity: .diagnostic, ) .addAll( _validateNumericClaimGrounding( @@ -372,7 +373,7 @@ List _validatePlanFulfillment( .scoped( code: GenerationValidationCode.numericMeaning, category: GenerationValidationCategory.factual, - severity: GenerationValidationSeverity.diagnostic, + severity: .diagnostic, ) .addAll( _validateNumericClaimContext( @@ -413,7 +414,7 @@ List _validatePlanFulfillment( .scoped( code: GenerationValidationCode.numericGrounding, category: GenerationValidationCategory.factual, - severity: GenerationValidationSeverity.diagnostic, + severity: .diagnostic, ) .addAll( _validateNumericClaimGrounding( @@ -426,7 +427,7 @@ List _validatePlanFulfillment( .scoped( code: GenerationValidationCode.numericMeaning, category: GenerationValidationCategory.factual, - severity: GenerationValidationSeverity.diagnostic, + severity: .diagnostic, ) .addAll( _validateNumericClaimContext( @@ -452,7 +453,7 @@ List _validatePlanFulfillment( .scoped( code: GenerationValidationCode.contentDensity, category: GenerationValidationCategory.quality, - severity: GenerationValidationSeverity.diagnostic, + severity: .diagnostic, ) .addAll( _validateDisplayHeadings( @@ -477,8 +478,8 @@ List _validatePlanFulfillment( visibleCharacters: visibleCharacters, characterLimit: characterLimit, ) - ? GenerationValidationSeverity.blocking - : GenerationValidationSeverity.diagnostic, + ? .blocking + : .diagnostic, ) .add( 'Slide exceeds the ${planSlide.density} content budget of ' @@ -645,6 +646,7 @@ _validateCommitmentGrounding( ); } final verb = label == 'Speaker comments' ? 'introduce' : 'introduces'; + return ( messages: [ '$label $verb unsupported commitment claim(s): ' diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_model_call_executor.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_model_call_executor.dart index 16f11497..d62a6ff1 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_model_call_executor.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_model_call_executor.dart @@ -36,12 +36,12 @@ final class GenerationModelCallExecutor { timeout: runTimeout, ); - bool get hasRepairCapacity => _budget.hasRepairCapacity; - void _throwIfCancelled() { if (_isCancelled()) throw const GenerationCancelledException(); } + bool get hasRepairCapacity => _budget.hasRepairCapacity; + Future execute({ required google_ai.GenerateContentRequest request, required GenerationTracePhase phase, @@ -114,11 +114,6 @@ final class GenerationRunBudget { required this.timeout, }) : _stopwatch = Stopwatch()..start(); - bool get hasRepairCapacity { - _throwIfTimedOut(); - return _repairRequests < maxRepairRequests; - } - void _throwIfTimedOut() { if (_stopwatch.elapsed >= timeout) { throw GenerationBudgetExceededException( @@ -127,6 +122,12 @@ final class GenerationRunBudget { } } + bool get hasRepairCapacity { + _throwIfTimedOut(); + + return _repairRequests < maxRepairRequests; + } + void beginSemanticRequest({required bool isRepair}) { _throwIfTimedOut(); if (!isRepair) return; diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_progress.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_progress.dart index 718e4e76..cafa6c2e 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_progress.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_progress.dart @@ -6,7 +6,10 @@ enum GenerationPhase { /// Phase 1: Generating presentation outline (structure + layout hints). generatingOutline, - /// Phase 2: Composing and validating slides from the deck plan. + /// Phase 2: Generating the bounded set of planned slide artwork. + generatingImages, + + /// Phase 3: Composing and validating slides from the deck plan. composingSlides, /// Writing final JSON and cleaning up. @@ -18,23 +21,34 @@ enum GenerationPhase { /// Detailed progress for a generation run. final class GenerationProgress { + final GenerationPhase phase; + + final int? slideIndex; + final int? slideCount; + final int? sectionIndex; + final int? sectionCount; + final int? completedItems; + final int? totalItems; + final bool isRepairing; const GenerationProgress( this.phase, { this.slideIndex, this.slideCount, this.sectionIndex, this.sectionCount, + this.completedItems, + this.totalItems, this.isRepairing = false, }); - final GenerationPhase phase; - final int? slideIndex; - final int? slideCount; - final int? sectionIndex; - final int? sectionCount; - final bool isRepairing; - String get label { + final completed = completedItems; + final total = totalItems; + if (phase == .generatingImages && completed != null && total != null) { + if (total == 0) return 'Preparing slide artwork…'; + + return 'Creating slide artwork $completed of $total…'; + } final currentSection = sectionIndex; final totalSections = sectionCount; if (phase == GenerationPhase.composingSlides && @@ -60,6 +74,7 @@ typedef GenerationProgressCallback = void Function(GenerationProgress progress); extension GenerationPhaseLabel on GenerationPhase { String get label => switch (this) { GenerationPhase.generatingOutline => 'Planning the outline…', + .generatingImages => 'Creating slide artwork…', GenerationPhase.composingSlides => 'Composing the slides…', GenerationPhase.finalizing => 'Finalizing…', GenerationPhase.generatingThumbnails => 'Rendering thumbnails…', diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_quality_report.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_quality_report.dart index db887bc4..c3f5d9e8 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_quality_report.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_quality_report.dart @@ -90,6 +90,7 @@ final class GenerationQualityReport { PresentationThemeCatalog? themeCatalog, PresentationTypographyCatalog? typographyCatalog, Duration captureElapsed = Duration.zero, + Set knownGeneratedAssetKeys = const {}, }) { final issues = []; final themes = themeCatalog ?? PresentationThemeCatalog.withDefaults(); @@ -137,6 +138,7 @@ final class GenerationQualityReport { typographyCatalog: typography, themeCatalog: themes, request: request, + knownGeneratedAssetKeys: knownGeneratedAssetKeys, )) { issues.add( GenerationQualityIssue( @@ -192,17 +194,17 @@ final class GenerationQualityReport { issues.add( GenerationQualityIssue( rule: 'slide.content_density', + message: + 'Slide "${slide.key}" has $characters visible characters; ' + '$density allows at most $maximum.', slideKey: slide.key, severity: isHardContentDensityOverage( visibleCharacters: characters, characterLimit: maximum, ) - ? GenerationValidationSeverity.blocking - : GenerationValidationSeverity.diagnostic, - message: - 'Slide "${slide.key}" has $characters visible characters; ' - '$density allows at most $maximum.', + ? .blocking + : .diagnostic, ), ); } @@ -345,9 +347,7 @@ final class GenerationQualityReport { ); } - bool get passed => issues.every( - (issue) => issue.severity != GenerationValidationSeverity.blocking, - ); + bool get passed => issues.every((issue) => issue.severity != .blocking); Map toJson() => { 'passed': passed, diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_trace.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_trace.dart index d006c677..a85e9b11 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_trace.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_trace.dart @@ -1,7 +1,7 @@ import 'generation_validation_issue.dart'; /// Logical stages represented in generation trace artifacts. -enum GenerationTracePhase { outline, composition, slide, finalize } +enum GenerationTracePhase { outline, image, composition, slide, finalize } /// Observable event types emitted by the generation pipeline. enum GenerationTraceKind { diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/source_grounding.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/source_grounding.dart index d22c78c8..4c7eba58 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/source_grounding.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/source_grounding.dart @@ -545,6 +545,7 @@ String unsupportedNumericClaimRepairGuidance(Set claims) { 'says "no change", preserve those exact qualitative words; ' 'otherwise remove the claim.' : ''; + return 'Remove them or label the containing copy as a projection, estimate, ' 'assumption, calculation, scenario, or planned target.' '$qualitativeZeroGuidance'; @@ -762,6 +763,7 @@ bool _matchesAnyAnchorGroup( ) => anchorGroups.any((required) { final matched = required.intersection(copyWords).length; if (matched == required.length) return true; + return required.length >= 3 && matched >= required.length - 1; }); @@ -843,6 +845,7 @@ Set findUnsupportedCommitmentPhrases({ bool _containsStandalonePhrase(String value, String phrase) { final escaped = RegExp.escape(phrase); + return RegExp( '(?:^|[^a-z0-9])$escaped(?=\$|[^a-z0-9])', caseSensitive: false, @@ -914,6 +917,7 @@ String _normalizeFactWord(String value) { if (word.length > 4 && word.endsWith('s') && !word.endsWith('ss')) { word = word.substring(0, word.length - 1); } + return switch (word) { 'adoption' || 'migration' || 'rollout' || 'transition' => 'adoption', 'canvas' || 'place' || 'surface' => 'workspace', diff --git a/packages/playground/lib/features/ai/quick_agent/core/env_config.dart b/packages/playground/lib/features/ai/quick_agent/core/env_config.dart index ed9af127..a845b27e 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/env_config.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/env_config.dart @@ -3,9 +3,18 @@ /// API keys are injected at build time with `--dart-define` or /// `--dart-define-from-file`. abstract final class EnvConfig { + /// Final artwork is on for local/debug demos and off by default in release. + /// Release builds can opt in explicitly after smoke validation. + static const wizardImageGenerationEnabled = bool.fromEnvironment( + 'SUPERDECK_WIZARD_IMAGE_GENERATION', + defaultValue: !_isReleaseMode, + ); + /// API key from --dart-define (compile-time injection). static const _dartDefineKey = String.fromEnvironment('GOOGLE_AI_API_KEY'); + static const _isReleaseMode = bool.fromEnvironment('dart.vm.product'); + static String get geminiApiKey { // Prefer build-time injection (--dart-define) if (_dartDefineKey.isNotEmpty) { diff --git a/packages/playground/lib/features/ai/quick_agent/domain/commands/generate_deck_command.dart b/packages/playground/lib/features/ai/quick_agent/domain/commands/generate_deck_command.dart index 39cc9fd7..0f5b400f 100644 --- a/packages/playground/lib/features/ai/quick_agent/domain/commands/generate_deck_command.dart +++ b/packages/playground/lib/features/ai/quick_agent/domain/commands/generate_deck_command.dart @@ -1,5 +1,6 @@ import '../../../../../core/command.dart'; import '../../../../../core/data/data_sources/memory_deck_loader.dart'; +import '../../../../../core/data/data_sources/memory_asset_cache_store.dart'; import '../../../../../core/result.dart'; import '../../../../editor/domain/stores/deck_document_store.dart'; import '../../../../../core/domain/stores/deck_customization_store.dart'; @@ -29,25 +30,33 @@ class GenerationException implements Exception { /// model. On success it serializes the slides to Markdown and replaces the /// shared [DeckDocumentStore]. class GenerateDeckCommand extends Command1 { + final GeneratedDeckResultApplier _resultApplier; + + final DeckGeneratorService? _service; + GenerationProgress _progress = const GenerationProgress(GenerationPhase.idle); + + String? _completionNotice; + bool _cancelled = false; + bool _disposed = false; GenerateDeckCommand({ required DeckDocumentStore documentStore, required DeckCustomizationStore customizationStore, MemoryDeckLoader? deckLoader, + MemoryAssetCacheStore? assetCacheStore, DeckGeneratorService? service, - }) : _documentStore = documentStore, - _customizationStore = customizationStore, - _deckLoader = deckLoader, + }) : _resultApplier = GeneratedDeckResultApplier( + documentStore: documentStore, + deckLoader: deckLoader, + assetCacheStore: assetCacheStore, + customizationStore: customizationStore, + ), _service = service; - final DeckDocumentStore _documentStore; - final DeckCustomizationStore _customizationStore; - final MemoryDeckLoader? _deckLoader; - final DeckGeneratorService? _service; - - GenerationProgress _progress = const GenerationProgress(GenerationPhase.idle); - String? _completionNotice; - bool _cancelled = false; - bool _disposed = false; + void _onProgress(GenerationProgress progress) { + if (_cancelled) return; + _progress = progress; + notifyListeners(); + } /// The pipeline stage currently running (outline → deck). GenerationPhase get phase => _progress.phase; @@ -96,14 +105,14 @@ class GenerateDeckCommand extends Command1 { return const Result.error(GenerationException('Generation cancelled.')); } - applyGeneratedDeckResult( - result: result, - documentStore: _documentStore, - deckLoader: _deckLoader, - customizationStore: _customizationStore, - ); + await _resultApplier.apply(result); if (result.isPartial) { _completionNotice = result.error; + } else if (result.hasImageFailures) { + _completionNotice = + 'Created ${result.generatedImageCount} of ' + '${result.generatedImages.length} planned artworks; ' + '${result.failedImageCount} used a text-first fallback.'; } debugLog.log( 'GENERATE_DECK', @@ -127,12 +136,6 @@ class GenerateDeckCommand extends Command1 { } } - void _onProgress(GenerationProgress progress) { - if (_cancelled) return; - _progress = progress; - notifyListeners(); - } - @override void clearResult() { _completionNotice = null; diff --git a/packages/playground/lib/features/ai/quick_agent/domain/generated_deck_result_applier.dart b/packages/playground/lib/features/ai/quick_agent/domain/generated_deck_result_applier.dart index 459a1ea0..ed087636 100644 --- a/packages/playground/lib/features/ai/quick_agent/domain/generated_deck_result_applier.dart +++ b/packages/playground/lib/features/ai/quick_agent/domain/generated_deck_result_applier.dart @@ -1,4 +1,5 @@ import 'package:superdeck_builder/superdeck_builder.dart'; +import 'package:superdeck_core/superdeck_core.dart'; import '../../../../core/data/data_sources/memory_deck_loader.dart'; import '../../../../core/domain/stores/deck_customization_store.dart'; @@ -6,17 +7,67 @@ import '../../../editor/domain/stores/deck_document_store.dart'; import '../core/engine/services/deck_generator_service.dart'; import 'generated_deck_style_mapper.dart'; +/// Applies generated decks for one host and evicts artwork from the deck it +/// replaces only after the replacement has been published successfully. +final class GeneratedDeckResultApplier { + final DeckDocumentStore _documentStore; + + final MemoryDeckLoader? _deckLoader; + final AssetCacheStore? _assetCacheStore; + final DeckCustomizationStore _customizationStore; + Set _appliedAssetKeys = const {}; + GeneratedDeckResultApplier({ + required DeckDocumentStore documentStore, + MemoryDeckLoader? deckLoader, + AssetCacheStore? assetCacheStore, + required DeckCustomizationStore customizationStore, + }) : _documentStore = documentStore, + _deckLoader = deckLoader, + _assetCacheStore = assetCacheStore, + _customizationStore = customizationStore; + + Future apply(DeckGenerationResult result) async { + final nextAssetKeys = {}; + for (final asset in result.generatedImages) { + final bytes = asset.bytes; + if (bytes == null || bytes.isEmpty) continue; + final cache = _assetCacheStore; + if (cache == null) { + throw StateError( + 'Generated artwork cannot be loaded without an asset cache.', + ); + } + await cache.write(asset.assetKey, bytes); + nextAssetKeys.add(asset.assetKey); + } + + final markdown = const SlideSerializer().serialize(result.slides); + _documentStore.replaceMarkdown(markdown); + _deckLoader?.updateMarkdown(markdown); + if (result.theme case final theme?) { + _customizationStore.applyGeneratedStyle(theme.toGeneratedDeckStyle()); + } + + final cache = _assetCacheStore; + if (cache != null) { + for (final assetKey in _appliedAssetKeys.difference(nextAssetKeys)) { + await cache.delete(assetKey); + } + } + _appliedAssetKeys = Set.unmodifiable(nextAssetKeys); + } +} + /// Loads one accepted generation result into the shared document and renderer. -void applyGeneratedDeckResult({ +Future applyGeneratedDeckResult({ required DeckGenerationResult result, required DeckDocumentStore documentStore, MemoryDeckLoader? deckLoader, + AssetCacheStore? assetCacheStore, required DeckCustomizationStore customizationStore, -}) { - final markdown = const SlideSerializer().serialize(result.slides); - documentStore.replaceMarkdown(markdown); - deckLoader?.updateMarkdown(markdown); - if (result.theme case final theme?) { - customizationStore.applyGeneratedStyle(theme.toGeneratedDeckStyle()); - } -} +}) => GeneratedDeckResultApplier( + documentStore: documentStore, + deckLoader: deckLoader, + assetCacheStore: assetCacheStore, + customizationStore: customizationStore, +).apply(result); diff --git a/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart b/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart index fd569097..da082a76 100644 --- a/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart +++ b/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart @@ -82,6 +82,7 @@ class _GenerationLabPageState extends State { Widget build(BuildContext context) { if (!kDebugMode) return const SizedBox.shrink(); final result = _result; + return Scaffold( appBar: AppBar(title: const Text('AI generation lab')), body: ListView( diff --git a/packages/playground/lib/features/ai/wizard/chat/chat_conversation_profile.dart b/packages/playground/lib/features/ai/wizard/chat/chat_conversation_profile.dart index 4d14c1f9..b2362aa2 100644 --- a/packages/playground/lib/features/ai/wizard/chat/chat_conversation_profile.dart +++ b/packages/playground/lib/features/ai/wizard/chat/chat_conversation_profile.dart @@ -1,17 +1,22 @@ +import '../../../../core/domain/design/presentation_image_style_catalog.dart'; import '../../../../core/domain/design/presentation_theme_catalog.dart'; import '../core/ai/catalog/catalog.dart'; import '../core/ai/services/ai_conversation_profile.dart'; AiConversationProfile chatConversationProfile({ + PresentationImageStyleCatalog? imageStyleCatalog, PresentationThemeCatalog? themeCatalog, }) { + final imageStyles = + imageStyleCatalog ?? PresentationImageStyleCatalog.withDefaults(); final themes = themeCatalog ?? PresentationThemeCatalog.withDefaults(); return AiConversationProfile( - catalog: chatCatalogFor(themes), + catalog: chatCatalogFor(themes, imageStyleCatalog: imageStyles), promptName: 'wizard_system', promptLoadErrorMessage: 'Unable to load conversation prompts. Please restart the app.', + imageStyleCatalog: imageStyles, themeCatalog: themes, ); } diff --git a/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_genui_panels.dart b/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_genui_panels.dart index 543bdd87..55c365b5 100644 --- a/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_genui_panels.dart +++ b/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_genui_panels.dart @@ -76,8 +76,8 @@ class AiSurfacesPanel extends StatelessWidget { switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, child: AnimatedOpacity( - duration: SdTokens.motionFast, opacity: thinking ? 0.5 : 1.0, + duration: SdTokens.motionFast, child: flex( key: ValueKey(ids.last), children: ids.map((surfaceId) { @@ -115,9 +115,9 @@ class AiSurfacesPanel extends StatelessWidget { } return SizedBox( - width: double.infinity, + width: .infinity, child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: .stretch, children: [ Expanded( child: Center( diff --git a/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_input.dart b/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_input.dart index 83e1d599..3c577ae2 100644 --- a/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_input.dart +++ b/packages/playground/lib/features/ai/wizard/chat/view/widgets/chat_input.dart @@ -7,21 +7,24 @@ import 'package:remix/remix.dart'; /// Provides consistent behavior: disabled while thinking, unified hint text, /// and proper TextInputAction.send for enter-to-submit. class ChatInput extends StatelessWidget { - final TextEditingController controller; - final FocusNode? focusNode; - final bool enabled; - final ValueChanged onSubmitted; - final String hintText; - const ChatInput({ super.key, required this.controller, this.focusNode, + this.autofocus = false, required this.enabled, required this.onSubmitted, this.hintText = 'Type a message...', }); + final TextEditingController controller; + final FocusNode? focusNode; + final bool autofocus; + final bool enabled; + final ValueChanged onSubmitted; + + final String hintText; + @override Widget build(BuildContext context) { final trailingStyle = TextStyler() @@ -30,19 +33,20 @@ class ChatInput extends StatelessWidget { .fontWeight(FontWeight.w600); return HeroTextField( + enabled: enabled, + autofocus: autofocus, + controller: controller, + focusNode: focusNode, hintText: hintText, trailing: trailingStyle('Press Enter'), textInputAction: TextInputAction.send, - controller: controller, - focusNode: focusNode, - enabled: enabled, + onSubmitted: enabled ? onSubmitted : null, semanticLabel: 'Chat message input', style: RemixTextFieldStyle() .padding(.horizontal(16).vertical(14)) .backgroundColor($surfaceSecondary()) .border(.color($border())) - .borderRadiusAll(const Radius.circular(14)), - onSubmitted: enabled ? onSubmitted : null, + .borderRadiusAll(const .circular(14)), ); } } diff --git a/packages/playground/lib/features/ai/wizard/chat/view/widgets/empty_state.dart b/packages/playground/lib/features/ai/wizard/chat/view/widgets/empty_state.dart index 2d21bbff..81ebd5d2 100644 --- a/packages/playground/lib/features/ai/wizard/chat/view/widgets/empty_state.dart +++ b/packages/playground/lib/features/ai/wizard/chat/view/widgets/empty_state.dart @@ -1,25 +1,44 @@ import 'package:flutter/material.dart'; + import '../../../core/ui/ui.dart'; /// Initial state displayed before conversation starts. class EmptyState extends StatelessWidget { - const EmptyState({super.key, required this.input}); + const EmptyState({super.key, required this.input, this.errorMessage}); final Widget input; + final String? errorMessage; @override Widget build(BuildContext context) { return Center( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48), + padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 620), child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ const SdHeadline('What is the presentation about?'), const SizedBox(height: 20), + if (errorMessage case final message?) ...[ + Container( + padding: const .all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer, + borderRadius: .circular(12), + ), + width: .infinity, + child: Text( + message, + style: TextStyle( + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + ), + const SizedBox(height: 16), + ], input, ], ), diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_image_style.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_image_style.dart new file mode 100644 index 00000000..e7aa9ca9 --- /dev/null +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_image_style.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; + +import 'package:ack/ack.dart'; +import 'package:ack_annotations/ack_annotations.dart'; +import 'package:ack_json_schema_builder/ack_json_schema_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:genui/genui.dart'; +import 'package:provider/provider.dart'; + +import '../../../../../../core/domain/design/presentation_image_style_catalog.dart'; +import '../../../../image_generation/image_style_preview_coordinator.dart'; +import '../schemas/genui_action_schema.dart'; +import '../schemas/wizard_context_keys.dart'; +import '../../ui/ui.dart'; +import 'ask_user_question_cards.dart'; +import 'catalog_question_step.dart'; +import 'component_schema.dart'; +import 'typed_catalog_item.dart'; +import 'user_action_dispatch.dart'; + +part 'ask_user_image_style.g.dart'; + +/// The model owns only the question copy. The application owns the exact +/// preview subject, styles, generation, and versioned selection payload. +@AckType(name: 'AskUserImageStyle') +final _askUserImageStyleSchema = Ack.object({ + 'question': Ack.string().describe('The question to display to the user'), + 'description': Ack.string().optional().describe( + 'Additional context or instructions', + ), + 'action': actionSchema, +}).describe('An application-owned generated image-style selection step.'); + +CatalogItem askUserImageStyleFor( + PresentationImageStyleCatalog imageStyleCatalog, +) { + for (final id in featuredPresentationImageStyleIds) { + if (imageStyleCatalog.current(id) == null) { + throw ArgumentError('Missing featured image style "$id".'); + } + } + + return typedCatalogItem( + name: 'AskUserImageStyle', + dataSchema: componentSchema(_askUserImageStyleSchema.toJsonSchemaBuilder()), + exampleData: [ + () => const JsonEncoder.withIndent(' ').convert([ + { + 'id': 'root', + 'component': 'AskUserImageStyle', + 'question': 'Choose an artwork direction', + 'description': + 'Compare the same idea across three generated treatments.', + 'action': {'name': 'submit_answer', 'context': []}, + }, + ]), + ], + parse: AskUserImageStyleType.parse, + widgetBuilder: (context, data) => + _AskUserImageStyleContent(data: data, itemContext: context), + ); +} + +final askUserImageStyle = askUserImageStyleFor( + PresentationImageStyleCatalog.withDefaults(), +); + +class _AskUserImageStyleContent extends StatefulWidget { + const _AskUserImageStyleContent({ + required this.data, + required this.itemContext, + }); + + final AskUserImageStyleType data; + final CatalogItemContext itemContext; + + @override + State<_AskUserImageStyleContent> createState() => + _AskUserImageStyleContentState(); +} + +class _AskUserImageStyleContentState extends State<_AskUserImageStyleContent> { + PresentationImageStyleDescriptor? _selectedStyle; + + bool get _canSubmit => _selectedStyle != null; + + void _submit() => submitCatalogActionIfValid( + canSubmit: _canSubmit, + itemContext: widget.itemContext, + action: widget.data.action, + contextBuilder: () => buildImageStyleSelectionContext(_selectedStyle!), + ); + + @override + Widget build(BuildContext context) { + final coordinator = context.watch(); + final previews = coordinator.previews; + + return CatalogQuestionStep( + question: widget.data.question, + body: previews.isEmpty + ? const SdBody('Preparing artwork directions…') + : IntrinsicHeight( + child: Row( + crossAxisAlignment: .stretch, + spacing: 16, + children: [ + for (final preview in previews) + Expanded( + child: ImageStyleOptionCard( + key: ValueKey('wizard-image-style-${preview.style.id}'), + style: preview.style, + imageBytes: preview.bytes, + isLoading: preview.status == .loading, + hasFailed: preview.status == .failed, + selected: _selectedStyle?.id == preview.style.id, + onTap: () { + setState(() => _selectedStyle = preview.style); + }, + onRetry: preview.status == .failed + ? () => coordinator.retry(preview.style.id) + : null, + ), + ), + ], + ), + ), + onSubmit: _submit, + description: widget.data.description, + canSubmit: _canSubmit, + ); + } +} + +/// Maps a catalog descriptor into the exact ID/version Wizard contract. +Map buildImageStyleSelectionContext( + PresentationImageStyleDescriptor style, +) { + return { + WizardContextKeys.imageStyleId: style.id, + WizardContextKeys.imageStyleVersion: style.version, + WizardContextKeys.title: style.title, + }; +} diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_image_style.g.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_image_style.g.dart new file mode 100644 index 00000000..77b7fdaa --- /dev/null +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_image_style.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// dart format width=80 + +// ************************************************************************** +// AckSchemaGenerator +// ************************************************************************** + +part of 'ask_user_image_style.dart'; + +/// Extension type for AskUserImageStyle +extension type AskUserImageStyleType(Map _data) + implements Map { + static AskUserImageStyleType parse(Object? data) { + return _askUserImageStyleSchema.parseAs( + data, + (validated) => AskUserImageStyleType(validated as Map), + ); + } + + static SchemaResult safeParse(Object? data) { + return _askUserImageStyleSchema.safeParseAs( + data, + (validated) => AskUserImageStyleType(validated as Map), + ); + } + + String get question => _data['question'] as String; + + String? get description => _data['description'] as String?; + + ActionType get action => ActionType(_data['action'] as Map); +} diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_question_cards.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_question_cards.dart index 839115e7..9e7586e4 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_question_cards.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_question_cards.dart @@ -6,8 +6,8 @@ import 'package:hero_ui/hero_ui.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:remix/remix.dart'; +import '../../../../../../core/domain/design/presentation_image_style_catalog.dart'; import '../../../../../../core/domain/design/presentation_theme_catalog.dart'; -import '../prompts/image_style_prompts.dart'; import '../../ui/ui.dart'; import '../../utils/color_utils.dart'; import '../../utils/font_utils.dart'; @@ -15,9 +15,8 @@ import '../../utils/font_utils.dart'; // ─────────────────────────────────── STYLING UTILITIES ─────────────────────────────────── /// Returns body text style for selected state. -TextStyler? selectedBodyStyle(bool selected) => selected - ? TextStyler().color($foreground()).fontWeight(FontWeight.w600) - : null; +TextStyler? selectedBodyStyle(bool selected) => + selected ? TextStyler().color($foreground()).fontWeight(.w600) : null; /// Returns caption text style for selected state. TextStyler? selectedCaptionStyle(bool selected) => @@ -57,8 +56,8 @@ class RadioOptionCard extends StatelessWidget { : $surfaceTertiary.resolve(context); return Semantics( - inMutuallyExclusiveGroup: true, selected: selected, + inMutuallyExclusiveGroup: true, label: 'Option: $title${description != null ? ', $description' : ''}', child: Pressable( onPress: onTap, @@ -68,13 +67,13 @@ class RadioOptionCard extends StatelessWidget { child: content( children: [ Container( - width: 38, - height: 38, + alignment: Alignment.center, decoration: BoxDecoration( color: iconBackground, - borderRadius: BorderRadius.circular(10), + borderRadius: .circular(10), ), - alignment: Alignment.center, + width: 38, + height: 38, child: Icon(icon, size: 20, color: iconColor), ), SdBody(title, style: selectedBodyStyle(selected)), @@ -190,50 +189,50 @@ class _ThemeSamplePreview extends StatelessWidget { final accent = hexToColor(palette.accent); return Container( - width: double.infinity, - height: 148, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: background, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: surface, width: 2), + border: .all(color: surface, width: 2), + borderRadius: .circular(12), ), + width: .infinity, + height: 148, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - width: 38, - height: 4, decoration: BoxDecoration( color: accent, - borderRadius: BorderRadius.circular(999), + borderRadius: .circular(999), ), + width: 38, + height: 4, ), const Spacer(), Text( 'Build what’s next', - maxLines: 2, - overflow: TextOverflow.ellipsis, style: TextStyle( color: heading, - fontFamily: headlineFamily, fontSize: 22, - fontWeight: FontWeight.w700, + fontWeight: .w700, height: 1.05, + fontFamily: headlineFamily, ), + overflow: .ellipsis, + maxLines: 2, ), const SizedBox(height: 8), Text( 'A clear story, beautifully presented.', - maxLines: 2, - overflow: TextOverflow.ellipsis, style: TextStyle( color: body, - fontFamily: bodyFamily, fontSize: 12, - fontWeight: FontWeight.w400, + fontWeight: .w400, height: 1.25, + fontFamily: bodyFamily, ), + overflow: .ellipsis, + maxLines: 2, ), ], ), @@ -243,7 +242,7 @@ class _ThemeSamplePreview extends StatelessWidget { /// Image style option card with generated preview image. class ImageStyleOptionCard extends StatelessWidget { - final ImageStyle style; + final PresentationImageStyleDescriptor style; final Uint8List? imageBytes; final bool isLoading; final bool hasFailed; @@ -294,25 +293,19 @@ class ImageStyleOptionCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, spacing: 8, children: [ - Icon( - Icons.broken_image_outlined, - color: $muted.resolve(context), - size: 32, - ), + Icon(LucideIcons.imageOff, size: 32, color: $muted.resolve(context)), GestureDetector( onTap: onRetry, child: Row( mainAxisSize: MainAxisSize.min, spacing: 4, children: [ - Icon(Icons.refresh, size: 16, color: $accent.resolve(context)), - Text( - 'Retry', - style: $paragraphSmall - .mix() - .resolve(context) - .copyWith(color: $accent.resolve(context)), + Icon( + LucideIcons.refreshCw, + size: 16, + color: $accent.resolve(context), ), + SdCaption('Retry', style: TextStyler().color($accent())), ], ), ), @@ -324,11 +317,7 @@ class ImageStyleOptionCard extends StatelessWidget { Widget _buildPlaceholder(BuildContext context) { return Box( style: BoxStyler().color($surfaceSecondary()).alignment(Alignment.center), - child: Icon( - Icons.image_outlined, - color: $muted.resolve(context), - size: 40, - ), + child: Icon(LucideIcons.image, size: 40, color: $muted.resolve(context)), ); } @@ -362,7 +351,17 @@ class ImageStyleOptionCard extends StatelessWidget { ), Padding( padding: const EdgeInsets.all(12), - child: SdBody(style.title, style: selectedBodyStyle(selected)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + SdBody(style.title, style: selectedBodyStyle(selected)), + SdCaption( + style.description, + style: selectedCaptionStyle(selected), + ), + ], + ), ), ], ), diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart index 9371b9c8..8c61c689 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart @@ -94,6 +94,7 @@ class _AskUserRadioContentState extends State<_AskUserRadioContent> { Map _buildActionContext() { if (_selectedIndex == null) return {}; final option = widget.data.options[_selectedIndex!]; + return { 'selectedOption': option.title, 'selectedDescription': option.description, @@ -107,17 +108,6 @@ class _AskUserRadioContentState extends State<_AskUserRadioContent> { contextBuilder: _buildActionContext, ); - @override - Widget build(BuildContext context) { - return CatalogQuestionStep( - question: widget.data.question, - description: widget.data.description, - body: _buildOptions(), - canSubmit: _canSubmit, - onSubmit: _submitAction, - ); - } - Widget _buildOptions() { final options = widget.data.options; @@ -161,4 +151,15 @@ class _AskUserRadioContentState extends State<_AskUserRadioContent> { }, ); } + + @override + Widget build(BuildContext context) { + return CatalogQuestionStep( + question: widget.data.question, + description: widget.data.description, + body: _buildOptions(), + canSubmit: _canSubmit, + onSubmit: _submitAction, + ); + } } diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_slider.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_slider.dart index 3a470886..5955123d 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_slider.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_slider.dart @@ -87,6 +87,7 @@ class DeckLengthSelector extends StatelessWidget { final values = unit.toLowerCase().contains('slide') ? [min, 8, 10, 12, 15, 20, max] : [min, ((min + max) / 2).round(), max]; + return values.where((item) => item >= min && item <= max).toSet().toList() ..sort(); } @@ -97,23 +98,23 @@ class DeckLengthSelector extends StatelessWidget { final mutedColor = $muted.resolve(context); final identity = Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, spacing: 12, children: [ Container( - width: 44, - height: 44, + alignment: Alignment.center, decoration: BoxDecoration( color: $accentSoft.resolve(context), - borderRadius: BorderRadius.circular(12), + borderRadius: .circular(12), ), - alignment: Alignment.center, + width: 44, + height: 44, child: Icon(LucideIcons.presentation, size: 22, color: iconColor), ), const Flexible( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, + crossAxisAlignment: .start, children: [ SdBody('Deck length'), SdCaption('Choose a pace that fits your story'), @@ -124,30 +125,30 @@ class DeckLengthSelector extends StatelessWidget { ); final counter = Container( + padding: const .symmetric(vertical: 4, horizontal: 4), decoration: BoxDecoration( color: $background.resolve(context), - border: Border.all(color: $border.resolve(context)), - borderRadius: BorderRadius.circular(14), + border: .all(color: $border.resolve(context)), + borderRadius: .circular(14), ), - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ IconButton( - tooltip: 'One fewer slide', onPressed: value > min ? () => onChanged(value - 1) : null, + tooltip: 'One fewer slide', icon: const Icon(LucideIcons.minus, size: 18), ), - SizedBox( - width: 76, + ConstrainedBox( + constraints: const BoxConstraints.tightFor(width: 76), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisSize: .min, children: [ Text( '$value', style: Theme.of(context).textTheme.titleLarge?.copyWith( color: $foreground.resolve(context), - fontWeight: FontWeight.w700, + fontWeight: .w700, height: 1, ), ), @@ -162,8 +163,8 @@ class DeckLengthSelector extends StatelessWidget { ), ), IconButton( - tooltip: 'One more slide', onPressed: value < max ? () => onChanged(value + 1) : null, + tooltip: 'One more slide', icon: const Icon(LucideIcons.plus, size: 18), ), ], @@ -172,20 +173,21 @@ class DeckLengthSelector extends StatelessWidget { return SdPanel( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, spacing: 18, children: [ LayoutBuilder( builder: (context, constraints) { if (constraints.maxWidth < 520) { return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, spacing: 16, children: [identity, counter], ); } + return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: .spaceBetween, spacing: 16, children: [ Expanded(child: identity), @@ -226,35 +228,35 @@ class _DeckLengthPreset extends StatelessWidget { @override Widget build(BuildContext context) { return Semantics( - button: true, selected: selected, + button: true, label: '$value slides', child: Material( color: Colors.transparent, child: InkWell( - borderRadius: BorderRadius.circular(10), onTap: onTap, + borderRadius: .circular(10), child: AnimatedContainer( - duration: SdTokens.motionFast, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + padding: const .symmetric(vertical: 9, horizontal: 14), decoration: BoxDecoration( color: selected ? $accentSoft.resolve(context) : $background.resolve(context), - borderRadius: BorderRadius.circular(10), - border: Border.all( + border: .all( color: selected ? $accent.resolve(context) : $border.resolve(context), ), + borderRadius: .circular(10), ), + duration: SdTokens.motionFast, child: Text( '$value', style: Theme.of(context).textTheme.labelMedium?.copyWith( color: selected ? $accent.resolve(context) : $muted.resolve(context), - fontWeight: FontWeight.w600, + fontWeight: .w600, ), ), ), @@ -277,10 +279,6 @@ class _AskUserSliderContent extends StatefulWidget { class _AskUserSliderContentState extends State<_AskUserSliderContent> { int _sliderValue = 0; - int _clampToRange(int value, {required int min, required int max}) { - return value.clamp(min, max).toInt(); - } - @override void initState() { super.initState(); @@ -290,6 +288,35 @@ class _AskUserSliderContentState extends State<_AskUserSliderContent> { _sliderValue = _clampToRange(defaultVal, min: minVal, max: maxVal); } + int _clampToRange(int value, {required int min, required int max}) { + return value.clamp(min, max).toInt(); + } + + Map _buildActionContext() { + return {'value': _sliderValue}; + } + + void _submitAction() => submitCatalogActionIfValid( + canSubmit: true, + itemContext: widget.itemContext, + action: widget.data.action, + contextBuilder: _buildActionContext, + ); + + Widget _buildSlider() { + final minValue = widget.data.minValue; + final maxValue = widget.data.maxValue; + final unit = widget.data.unit ?? ''; + + return DeckLengthSelector( + value: _sliderValue, + min: minValue, + max: maxValue, + onChanged: (value) => setState(() => _sliderValue = value), + unit: unit, + ); + } + @override void didUpdateWidget(covariant _AskUserSliderContent oldWidget) { super.didUpdateWidget(oldWidget); @@ -315,17 +342,6 @@ class _AskUserSliderContentState extends State<_AskUserSliderContent> { } } - Map _buildActionContext() { - return {'value': _sliderValue}; - } - - void _submitAction() => submitCatalogActionIfValid( - canSubmit: true, - itemContext: widget.itemContext, - action: widget.data.action, - contextBuilder: _buildActionContext, - ); - @override Widget build(BuildContext context) { return CatalogQuestionStep( @@ -335,18 +351,4 @@ class _AskUserSliderContentState extends State<_AskUserSliderContent> { onSubmit: _submitAction, ); } - - Widget _buildSlider() { - final minValue = widget.data.minValue; - final maxValue = widget.data.maxValue; - final unit = widget.data.unit ?? ''; - - return DeckLengthSelector( - value: _sliderValue, - min: minValue, - max: maxValue, - unit: unit, - onChanged: (value) => setState(() => _sliderValue = value), - ); - } } diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_style.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_style.dart index 83828434..4bcd8ffc 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_style.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_style.dart @@ -152,7 +152,7 @@ class _AskUserStyleContentState extends State<_AskUserStyleContent> { return IntrinsicHeight( child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: .stretch, spacing: 16, children: [ for (final (index, theme) in options.indexed) diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/catalog.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/catalog.dart index dc53408a..85e45677 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/catalog.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/catalog.dart @@ -1,13 +1,16 @@ import 'package:genui/genui.dart'; import '../../../../../../core/domain/design/presentation_theme_catalog.dart'; +import '../../../../../../core/domain/design/presentation_image_style_catalog.dart'; import 'ask_user_checkbox.dart'; +import 'ask_user_image_style.dart'; import 'ask_user_radio.dart'; import 'ask_user_slider.dart'; import 'ask_user_style.dart'; export 'ask_user_checkbox.dart'; +export 'ask_user_image_style.dart'; export 'ask_user_radio.dart'; export 'ask_user_slider.dart'; export 'ask_user_style.dart'; @@ -19,15 +22,21 @@ export 'ask_user_style.dart'; /// - [askUserCheckbox] - Checkbox multiple selection /// - [askUserSlider] - Slider numeric input /// - [askUserStyle] - Catalog-backed presentation theme selection -/// -/// Image-style selection is intentionally omitted for v1 (no image generation). -Catalog chatCatalogFor(PresentationThemeCatalog themeCatalog) { +/// - [askUserImageStyle] - Application-owned generated artwork directions +Catalog chatCatalogFor( + PresentationThemeCatalog themeCatalog, { + required PresentationImageStyleCatalog imageStyleCatalog, +}) { return Catalog([ askUserRadio, askUserCheckbox, askUserSlider, askUserStyleFor(themeCatalog), + askUserImageStyleFor(imageStyleCatalog), ], catalogId: 'com.superdeck.ai.chat'); } -final chatCatalog = chatCatalogFor(PresentationThemeCatalog.withDefaults()); +final chatCatalog = chatCatalogFor( + PresentationThemeCatalog.withDefaults(), + imageStyleCatalog: PresentationImageStyleCatalog.withDefaults(), +); diff --git a/packages/playground/lib/features/ai/wizard/core/ai/prompts/image_style_prompts.dart b/packages/playground/lib/features/ai/wizard/core/ai/prompts/image_style_prompts.dart deleted file mode 100644 index 19d3af79..00000000 --- a/packages/playground/lib/features/ai/wizard/core/ai/prompts/image_style_prompts.dart +++ /dev/null @@ -1,131 +0,0 @@ -/// Predefined image styles for presentation generation. -/// -/// AI selects from enum values, widget loads full metadata. -/// Each style shows the same subject in a different artistic treatment. -library; - -/// Available image styles for presentation backgrounds. -/// -/// Enhanced enum where: -/// - Enum value name = ID used in schema (e.g., `watercolor`, `flatDesign`) -/// - `title`: Display name for UI -/// - `description`: Short description for AI selection -/// - `treatment`: Full prompt treatment for image generation -enum ImageStyle { - watercolor( - title: 'Watercolor', - description: 'Soft, artistic, dreamy - organic flowing shapes', - treatment: - 'rendered in soft watercolor painting style with flowing ' - 'organic shapes, gentle color bleeding between hues, and dreamy ' - 'atmospheric quality. Muted pastel tones with subtle paper texture.', - ), - - minimalist( - title: 'Minimalist', - description: 'Clean, modern, professional - geometric simplicity', - treatment: - 'rendered in clean minimalist style with simple geometric ' - 'shapes, generous negative space, and limited color palette. ' - 'Modern and elegant with precise edges and balanced asymmetry.', - ), - - gradient( - title: 'Gradient', - description: 'Dynamic, contemporary, vibrant - smooth color transitions', - treatment: - 'rendered with smooth flowing gradients and rich color ' - 'transitions blending seamlessly. Vibrant yet harmonious palette ' - 'with subtle mesh effects creating depth and dimension.', - ), - - retro( - title: 'Retro', - description: 'Nostalgic, playful, vintage - 60s/70s print aesthetic', - treatment: - 'rendered in retro vintage illustration style with bold ' - 'outlines, limited color palette reminiscent of 1960s-70s print. ' - 'Halftone dots, warm muted colors, and slightly faded aesthetic.', - ), - - geometric( - title: 'Geometric', - description: 'Technical, structured, bold - angular shapes and grids', - treatment: - 'rendered as geometric abstract composition with clean ' - 'angular shapes, precise lines, and structured grid-based layout. ' - 'Bold primary colors with strong contrast and mathematical balance.', - ), - - flatDesign( - title: 'Flat Design', - description: 'Friendly, approachable, corporate - simple vector style', - treatment: - 'rendered in flat design illustration style with solid ' - 'colors, no gradients or shadows, clean vector-like shapes. ' - 'Simple, friendly, and approachable with bright cheerful palette.', - ); - - const ImageStyle({ - required this.title, - required this.description, - required this.treatment, - }); - - /// Display name shown to users (e.g., "Flat Design"). - final String title; - - /// Short description for AI selection guidance. - final String description; - - /// Full art style treatment for image generation prompt. - final String treatment; - - /// ID is the enum value name (e.g., "watercolor", "flatDesign"). - String get id => name; - - /// Builds a complete image generation prompt. - /// - /// Combines subject with style treatment: - /// ```dart - /// ImageStyle.watercolor.buildPrompt('runner crossing finish line'); - /// // → "Runner crossing finish line, rendered in soft watercolor..." - /// ``` - String buildPrompt(String subject) { - final capitalized = subject.isNotEmpty - ? '${subject[0].toUpperCase()}${subject.substring(1)}' - : subject; - return '$capitalized, $treatment'; - } - - /// Finds a style by ID (enum name), returns null if not found. - static ImageStyle? fromId(String id) { - for (final style in values) { - if (style.name == id) return style; - } - return null; - } - - /// Cached options string for schema descriptions. - static final _optionsDescription = values - .map((s) => '${s.name} (${s.description})') - .join(', '); - - /// Schema description with all styles and their descriptions. - /// - /// [count] specifies how many styles to select: - /// - `count: 1` (default): "Image style. Choose one from: ..." - /// - `count: 3`: "Select 3 styles from: ..." - /// - /// Throws [AssertionError] if count is less than 1 or greater than - /// the number of available styles. - static String schemaDescription({int count = 1}) { - assert( - count >= 1 && count <= values.length, - 'count must be between 1 and ${values.length}, got $count', - ); - return count == 1 - ? 'Image style. Choose one from: $_optionsDescription.' - : 'Select $count styles from: $_optionsDescription.'; - } -} diff --git a/packages/playground/lib/features/ai/wizard/core/ai/schemas/user_action_payload.dart b/packages/playground/lib/features/ai/wizard/core/ai/schemas/user_action_payload.dart index 10191e3a..525e6c28 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/schemas/user_action_payload.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/schemas/user_action_payload.dart @@ -2,10 +2,10 @@ import 'dart:convert'; /// Typed parser for user actions emitted by GenUI surfaces. final class UserActionPayload { - const UserActionPayload({required this.actionName, required this.context}); - final String actionName; + final Map context; + const UserActionPayload({required this.actionName, required this.context}); /// Returns `null` when the payload is malformed or lacks an action name. static UserActionPayload? tryParse(String jsonString) { @@ -20,11 +20,10 @@ final class UserActionPayload { if (name is! String) return null; final context = userAction['context']; + return UserActionPayload( actionName: name, - context: context is Map - ? context - : {}, + context: context is Map ? context : {}, ); } on FormatException { return null; diff --git a/packages/playground/lib/features/ai/wizard/core/ai/schemas/wizard_context_keys.dart b/packages/playground/lib/features/ai/wizard/core/ai/schemas/wizard_context_keys.dart index 4bc3736a..468b9d40 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/schemas/wizard_context_keys.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/schemas/wizard_context_keys.dart @@ -47,14 +47,11 @@ abstract final class WizardContextKeys { // Image style keys - /// Image style identifier (enum name). + /// Stable image-style catalog ID. static const imageStyleId = 'imageStyleId'; - /// Image style display name. - static const imageStyleName = 'imageStyleName'; - - /// Image style description for generation guidance. - static const imageStyleDescription = 'imageStyleDescription'; + /// Exact version paired with [imageStyleId]. + static const imageStyleVersion = 'imageStyleVersion'; // Common keys used across components diff --git a/packages/playground/lib/features/ai/wizard/core/ai/services/ai_conversation_profile.dart b/packages/playground/lib/features/ai/wizard/core/ai/services/ai_conversation_profile.dart index 4866e99a..5d207a5c 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/services/ai_conversation_profile.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/services/ai_conversation_profile.dart @@ -2,11 +2,13 @@ import 'package:dartantic_ai/dartantic_ai.dart' as dartantic; import 'package:genui/genui.dart' as genui; import '../../../../../../core/domain/design/presentation_theme_catalog.dart'; +import '../../../../../../core/domain/design/presentation_image_style_catalog.dart'; final class AiConversationProfile { final genui.Catalog catalog; final String promptName; final String promptLoadErrorMessage; + final PresentationImageStyleCatalog imageStyleCatalog; final PresentationThemeCatalog themeCatalog; final List tools; @@ -14,6 +16,7 @@ final class AiConversationProfile { required this.catalog, required this.promptName, required this.promptLoadErrorMessage, + required this.imageStyleCatalog, required this.themeCatalog, Iterable tools = const [], }) : tools = List.unmodifiable(tools); diff --git a/packages/playground/lib/features/ai/wizard/core/ai/services/ai_conversation_viewmodel.dart b/packages/playground/lib/features/ai/wizard/core/ai/services/ai_conversation_viewmodel.dart index ae25dbea..e9812e57 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/services/ai_conversation_viewmodel.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/services/ai_conversation_viewmodel.dart @@ -6,7 +6,9 @@ import 'package:genui/genui.dart' as genui; import 'package:signals/signals_flutter.dart'; import '../../../../quick_agent/core/constants/gemini_models.dart'; +import '../../../../image_generation/image_style_preview_coordinator.dart'; import '../../../../../../core/domain/design/presentation_theme_catalog.dart'; +import '../../../../../../core/domain/design/presentation_image_style_catalog.dart'; import '../schemas/user_action_payload.dart'; import '../wizard_session_state.dart'; import '../../debug_logger.dart'; @@ -28,20 +30,20 @@ MissingSurfaceAction decideMissingSurfaceAction({ required int recoveryAttempts, }) { if (hasError || !hasController || requestProducedSurface) { - return MissingSurfaceAction.none; + return .none; } - return recoveryAttempts == 0 - ? MissingSurfaceAction.recover - : MissingSurfaceAction.showError; + + return recoveryAttempts == 0 ? .recover : .showError; } @visibleForTesting String? expectedWizardComponentType(WizardStep step) => switch (step) { - WizardStep.audience || WizardStep.approach => 'AskUserRadio', - WizardStep.emphasis => 'AskUserCheckbox', - WizardStep.slideCount => 'AskUserSlider', - WizardStep.theme => 'AskUserStyle', - WizardStep.topic || WizardStep.review => null, + .audience || .approach => 'AskUserRadio', + .emphasis => 'AskUserCheckbox', + .slideCount => 'AskUserSlider', + .theme => 'AskUserStyle', + .imageStyle => 'AskUserImageStyle', + .topic || .review => null, }; @visibleForTesting @@ -50,47 +52,35 @@ bool isExpectedWizardSurface( WizardStep step, ) { final expectedType = expectedWizardComponentType(step); + return expectedType != null && definition.components['root']?.type == expectedType; } final class AiConversationViewModel extends ChangeNotifier implements Disposable { - AiConversationViewModel({ - required AiConversationProfile profile, - @visibleForTesting SuperdeckTransportFactory? transportFactory, - @visibleForTesting String? apiKey, - SuperdeckAgentClientFactory agentClientFactory = - DartanticSuperdeckAgentClient.new, - }) : themeCatalog = profile.themeCatalog { - _session = GenUiConversationSession( - profile: profile, - apiKey: apiKey, - transportFactory: transportFactory, - agentClientFactory: agentClientFactory, - handlers: ConversationSessionHandlers( - onRequestStarted: _handleRequestStarted, - onRequestFinished: _handleRequestFinished, - onUiSubmit: _handleUiSubmit, - onSurfaceUpdate: _handleSurfaceUpdate, - onTextResponse: _handleTextResponse, - onError: _handleTransportError, - ), - ); - } + final PresentationImageStyleCatalog imageStyleCatalog; + + final PresentationThemeCatalog themeCatalog; + final bool imageStyleEnabled; + + final surfaceIds = Signal>([]); + late final Computed isThinking = computed(() { + return _isProcessing.value; + }); + late final Computed hasConversationStarted = computed( + () => _controller.value != null, + ); /// The Wizard favors the current Flash Lite model for short, structured /// surface turns. There is intentionally no in-wizard model picker. static const _modelName = GeminiModelNames.gemini31FlashLite; static const _missingSurfaceError = 'I couldn\'t prepare the next step. Add a detail below to try again.'; - - final PresentationThemeCatalog themeCatalog; - - final surfaceIds = Signal>([]); final _controller = Signal(null); final _isProcessing = Signal(false); + final ImageStylePreviewCoordinator? _imageStylePreviews; late final GenUiConversationSession _session; DateTime? _lastRequestTime; String? _errorMessage; @@ -98,116 +88,41 @@ final class AiConversationViewModel extends ChangeNotifier var _surfaceRecoveryAttempts = 0; var _requestProducedSurface = false; var _disposed = false; - var _wizardState = WizardSessionState.initial(); - - genui.SurfaceController? get controller => _controller.value; - - String? get errorMessage => _errorMessage; - - WizardSessionState get wizardState => _wizardState; - - late final Computed isThinking = computed(() { - return _isProcessing.value; - }); - - late final Computed hasConversationStarted = computed( - () => _controller.value != null, - ); - - Future ensureConversationStarted() async { - if (_disposed) return false; - - final result = await _session.ensureStarted(modelName: _modelName); - if (result.started) { - _controller.value = _session.controller; - _notifyView(); - return true; - } - - final message = result.message; - if (message != null && !_disposed) { - _errorMessage = message; - _notifyView(); - } - return false; - } - - Future sendMessage(String raw) async { - final message = raw.trim(); - if (message.isEmpty || _disposed || _isProcessing.value) return; - - _isProcessing.value = true; - _notifyView(); - debugLog.userAction('SEND_MESSAGE', {'message': message}); - debugLog.section('New Message'); - - final isFirstTurn = !hasConversationStarted.value; - WizardSessionState? topicState; - if (isFirstTurn) { - topicState = _wizardState.startTopic(message); - if (topicState == null) { - _isProcessing.value = false; - _notifyView(); - return; - } - debugLog.log('CONV', 'Building new conversation'); - final ok = await ensureConversationStarted(); - if (!ok) { - _isProcessing.value = false; - _notifyView(); - return; - } - _wizardState = topicState; - } + WizardSessionState _wizardState; - if (!_session.hasActiveSession || _wizardState.step == WizardStep.review) { - _isProcessing.value = false; - _notifyView(); - return; - } + static const _errorClassifier = ErrorClassifier(); - _surfaceRecoveryAttempts = 0; - await _enqueueRequest( - genui.ChatMessage.user( - buildWizardTurnPrompt( - userInput: isFirstTurn - ? 'Presentation topic: $message' - : 'User request for the current step: $message', - state: _wizardState, - ), + AiConversationViewModel({ + required AiConversationProfile profile, + @visibleForTesting SuperdeckTransportFactory? transportFactory, + @visibleForTesting String? apiKey, + ImageStylePreviewCoordinator? imageStylePreviews, + this.imageStyleEnabled = true, + SuperdeckAgentClientFactory agentClientFactory = + DartanticSuperdeckAgentClient.new, + }) : imageStyleCatalog = profile.imageStyleCatalog, + themeCatalog = profile.themeCatalog, + _imageStylePreviews = imageStylePreviews, + _wizardState = WizardSessionState.initial( + imageStyleEnabled: imageStyleEnabled, + ) { + _session = GenUiConversationSession( + profile: profile, + handlers: ConversationSessionHandlers( + onRequestStarted: _handleRequestStarted, + onRequestFinished: _handleRequestFinished, + onUiSubmit: _handleUiSubmit, + onSurfaceUpdate: _handleSurfaceUpdate, + onTextResponse: _handleTextResponse, + onError: _handleTransportError, ), + apiKey: apiKey, + transportFactory: transportFactory, + agentClientFactory: agentClientFactory, ); } - void restartConversation() { - debugLog.section('Conversation Restarted'); - _session.restart(); - _controller.value = null; - _isProcessing.value = false; - _errorMessage = null; - _surfaceRecoveryReason = null; - _surfaceRecoveryAttempts = 0; - _requestProducedSurface = false; - _wizardState = WizardSessionState.initial(); - surfaceIds.value = []; - _notifyView(); - } - - @override - void dispose() { - if (_disposed) return; - _disposed = true; - _session.dispose(); - - surfaceIds.dispose(); - _controller.dispose(); - _isProcessing.dispose(); - isThinking.dispose(); - hasConversationStarted.dispose(); - super.dispose(); - } - Future _enqueueRequest(genui.ChatMessage message) { return _session.sendRequest(message); } @@ -233,7 +148,7 @@ final class AiConversationViewModel extends ChangeNotifier requestProducedSurface: _requestProducedSurface, recoveryAttempts: _surfaceRecoveryAttempts, )) { - case MissingSurfaceAction.recover: + case .recover: _surfaceRecoveryAttempts++; final recoveryPrompt = _buildSurfaceRecoveryPrompt(); _surfaceRecoveryReason = null; @@ -243,11 +158,12 @@ final class AiConversationViewModel extends ChangeNotifier 'requesting one recovery turn', ); unawaited(_enqueueRequest(genui.ChatMessage.user(recoveryPrompt))); + return; - case MissingSurfaceAction.showError: + case .showError: _errorMessage = _missingSurfaceError; break; - case MissingSurfaceAction.none: + case .none: break; } @@ -258,12 +174,14 @@ final class AiConversationViewModel extends ChangeNotifier void _handleUiSubmit(genui.ChatMessage message) { if (_isProcessing.value) { debugLog.log('USER', 'Ignored a duplicate Wizard interaction.'); + return; } _surfaceRecoveryAttempts = 0; final interactionParts = message.parts.uiInteractionParts.toList(); if (interactionParts.length != 1) { _rejectUiAction('Expected exactly one Wizard interaction.'); + return; } @@ -271,6 +189,7 @@ final class AiConversationViewModel extends ChangeNotifier final parsed = UserActionPayload.tryParse(rawJson); if (parsed == null || parsed.actionName != 'submit_answer') { _rejectUiAction('Received an invalid Wizard action.'); + return; } @@ -280,14 +199,16 @@ final class AiConversationViewModel extends ChangeNotifier _rejectUiAction( 'That selection does not match the current ${previousStep.name} step.', ); + return; } _wizardState = nextState; debugLog.userAction('UI_ACTION', parsed.context); - if (nextState.step == WizardStep.review) { + if (nextState.step == .review) { _notifyView(); + return; } @@ -393,6 +314,7 @@ final class AiConversationViewModel extends ChangeNotifier final reason = _surfaceRecoveryReason ?? 'The previous response did not include a renderable Wizard surface.'; + return buildWizardTurnPrompt( userInput: '$reason Return the registered component for the next unanswered ' @@ -426,8 +348,6 @@ final class AiConversationViewModel extends ChangeNotifier debugLog.log('TIMING', '$event at +${elapsed.inMilliseconds}ms'); } - static const _errorClassifier = ErrorClassifier(); - String _getErrorMessage(Object error) => _errorClassifier.getUserMessage(error); @@ -435,6 +355,113 @@ final class AiConversationViewModel extends ChangeNotifier final str = error.toString(); return str.replaceAll(RegExp(r'[A-Za-z0-9_-]{20,}'), '[REDACTED]'); } + + genui.SurfaceController? get controller => _controller.value; + + String? get errorMessage => _errorMessage; + + WizardSessionState get wizardState => _wizardState; + + Future ensureConversationStarted() async { + if (_disposed) return false; + + final result = await _session.ensureStarted(modelName: _modelName); + if (result.started) { + _controller.value = _session.controller; + _notifyView(); + return true; + } + + final message = result.message; + if (message != null && !_disposed) { + _errorMessage = message; + _notifyView(); + } + return false; + } + + Future sendMessage(String raw) async { + final message = raw.trim(); + if (message.isEmpty || _disposed || _isProcessing.value) return; + + _isProcessing.value = true; + _notifyView(); + + debugLog.userAction('SEND_MESSAGE', {'message': message}); + debugLog.section('New Message'); + + final isFirstTurn = !hasConversationStarted.value; + WizardSessionState? topicState; + if (isFirstTurn) { + topicState = _wizardState.startTopic(message); + if (topicState == null) { + _isProcessing.value = false; + _notifyView(); + + return; + } + _imageStylePreviews?.prefetch(message); + debugLog.log('CONV', 'Building new conversation'); + final ok = await ensureConversationStarted(); + if (!ok) { + _isProcessing.value = false; + _notifyView(); + + return; + } + _wizardState = topicState; + } + + if (!_session.hasActiveSession || _wizardState.step == .review) { + _isProcessing.value = false; + _notifyView(); + + return; + } + + _surfaceRecoveryAttempts = 0; + await _enqueueRequest( + genui.ChatMessage.user( + buildWizardTurnPrompt( + userInput: isFirstTurn + ? 'Presentation topic: $message' + : 'User request for the current step: $message', + state: _wizardState, + ), + ), + ); + } + + void restartConversation() { + debugLog.section('Conversation Restarted'); + _session.restart(); + _controller.value = null; + _isProcessing.value = false; + _errorMessage = null; + _surfaceRecoveryReason = null; + _surfaceRecoveryAttempts = 0; + _requestProducedSurface = false; + _wizardState = WizardSessionState.initial( + imageStyleEnabled: imageStyleEnabled, + ); + _imageStylePreviews?.reset(); + surfaceIds.value = []; + _notifyView(); + } + + @override + void dispose() { + if (_disposed) return; + _disposed = true; + _session.dispose(); + + surfaceIds.dispose(); + _controller.dispose(); + _isProcessing.dispose(); + isThinking.dispose(); + hasConversationStarted.dispose(); + super.dispose(); + } } @visibleForTesting diff --git a/packages/playground/lib/features/ai/wizard/core/ai/services/genui_conversation_session.dart b/packages/playground/lib/features/ai/wizard/core/ai/services/genui_conversation_session.dart index d34d05a2..31b42a06 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/services/genui_conversation_session.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/services/genui_conversation_session.dart @@ -145,56 +145,6 @@ final class GenUiConversationSession { ).systemPromptJoined(); } - @visibleForTesting - String buildSystemPromptForTesting(String systemInstruction) => - _buildSystemPrompt(systemInstruction); - - genui.SurfaceController? get controller => _controller; - - bool get hasActiveSession { - return !_disposed && _controller != null && _transport != null; - } - - Future ensureStarted({required String modelName}) { - if (_disposed) { - return Future.value(const ConversationStartResult.cancelled()); - } - if (_controller != null) { - return Future.value(const ConversationStartResult.started()); - } - - final starting = _startingConversation; - if (starting != null) return starting; - - late final Future future; - future = _start(modelName: modelName).whenComplete(() { - if (identical(_startingConversation, future)) { - _startingConversation = null; - } - }); - _startingConversation = future; - return future; - } - - Future sendRequest(genui.ChatMessage message) { - final epoch = _sessionEpoch; - final queued = _requestQueue - .catchError(_handleRequestQueueError) - .then((_) => _sendRequest(message, sessionEpoch: epoch)); - _requestQueue = queued; - return queued; - } - - void restart() { - _disposeSession(); - } - - void dispose() { - if (_disposed) return; - _disposed = true; - _disposeSession(); - } - Future _start({required String modelName}) async { final epoch = _sessionEpoch; final apiKey = _readApiKey(); @@ -391,6 +341,56 @@ final class GenUiConversationSession { final str = error.toString(); return str.replaceAll(RegExp(r'[A-Za-z0-9_-]{20,}'), '[REDACTED]'); } + + genui.SurfaceController? get controller => _controller; + + bool get hasActiveSession { + return !_disposed && _controller != null && _transport != null; + } + + @visibleForTesting + String buildSystemPromptForTesting(String systemInstruction) => + _buildSystemPrompt(systemInstruction); + + Future ensureStarted({required String modelName}) { + if (_disposed) { + return Future.value(const ConversationStartResult.cancelled()); + } + if (_controller != null) { + return Future.value(const ConversationStartResult.started()); + } + + final starting = _startingConversation; + if (starting != null) return starting; + + late final Future future; + future = _start(modelName: modelName).whenComplete(() { + if (identical(_startingConversation, future)) { + _startingConversation = null; + } + }); + _startingConversation = future; + return future; + } + + Future sendRequest(genui.ChatMessage message) { + final epoch = _sessionEpoch; + final queued = _requestQueue + .catchError(_handleRequestQueueError) + .then((_) => _sendRequest(message, sessionEpoch: epoch)); + _requestQueue = queued; + return queued; + } + + void restart() { + _disposeSession(); + } + + void dispose() { + if (_disposed) return; + _disposed = true; + _disposeSession(); + } } /// Compact, catalog-derived Wizard guidance. Renderer recipes deliberately stay diff --git a/packages/playground/lib/features/ai/wizard/core/ai/services/prompt_builder.dart b/packages/playground/lib/features/ai/wizard/core/ai/services/prompt_builder.dart index 43553acd..6d037e2d 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/services/prompt_builder.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/services/prompt_builder.dart @@ -1,3 +1,4 @@ +import '../../../../../../core/domain/design/presentation_image_style_catalog.dart'; import '../../../../quick_agent/core/engine/services/deck_generation_request.dart'; import '../wizard_context.dart'; @@ -5,8 +6,11 @@ import '../wizard_context.dart'; /// /// Extracts exact user selections from the Wizard workflow without /// flattening contractual fields into layout instructions. -DeckGenerationRequest buildPromptFromWizardContext(WizardContext context) { - return DeckGenerationRequest( +DeckGenerationRequest buildPromptFromWizardContext( + WizardContext context, { + PresentationImageStyleCatalog? imageStyleCatalog, +}) { + final request = DeckGenerationRequest( userIntent: _sanitize(context.topic).isEmpty ? 'Create a coherent presentation.' : _sanitize(context.topic), @@ -30,9 +34,14 @@ DeckGenerationRequest buildPromptFromWizardContext(WizardContext context) { const [], headlineFont: _sanitizedOrNull(context.headlineFont), bodyFont: _sanitizedOrNull(context.bodyFont), - imageStyleName: _sanitizedOrNull(context.imageStyleName), - imageStyleDescription: _sanitizedOrNull(context.imageStyleDescription), + imageStyleId: _sanitizedOrNull(context.imageStyleId), + imageStyleVersion: context.imageStyleVersion, ); + request.resolveImageStyle( + imageStyleCatalog ?? PresentationImageStyleCatalog.withDefaults(), + ); + + return request; } /// Maximum length for user-provided text fields. diff --git a/packages/playground/lib/features/ai/wizard/core/ai/wizard_context.dart b/packages/playground/lib/features/ai/wizard/core/ai/wizard_context.dart index 12a1185a..221e298c 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/wizard_context.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/wizard_context.dart @@ -17,8 +17,7 @@ class WizardContext { final String? headlineFont; final String? bodyFont; final String? imageStyleId; - final String? imageStyleName; - final String? imageStyleDescription; + final int? imageStyleVersion; const WizardContext({ this.topic, @@ -33,8 +32,7 @@ class WizardContext { this.headlineFont, this.bodyFont, this.imageStyleId, - this.imageStyleName, - this.imageStyleDescription, + this.imageStyleVersion, }); WizardContext copyWith({ @@ -50,8 +48,7 @@ class WizardContext { String? headlineFont, String? bodyFont, String? imageStyleId, - String? imageStyleName, - String? imageStyleDescription, + int? imageStyleVersion, }) { return WizardContext( topic: topic ?? this.topic, @@ -66,9 +63,7 @@ class WizardContext { headlineFont: headlineFont ?? this.headlineFont, bodyFont: bodyFont ?? this.bodyFont, imageStyleId: imageStyleId ?? this.imageStyleId, - imageStyleName: imageStyleName ?? this.imageStyleName, - imageStyleDescription: - imageStyleDescription ?? this.imageStyleDescription, + imageStyleVersion: imageStyleVersion ?? this.imageStyleVersion, ); } @@ -92,11 +87,7 @@ class WizardContext { setIfNonNull(WizardContextKeys.headlineFont, headlineFont); setIfNonNull(WizardContextKeys.bodyFont, bodyFont); setIfNonNull(WizardContextKeys.imageStyleId, imageStyleId); - setIfNonNull(WizardContextKeys.imageStyleName, imageStyleName); - setIfNonNull( - WizardContextKeys.imageStyleDescription, - imageStyleDescription, - ); + setIfNonNull(WizardContextKeys.imageStyleVersion, imageStyleVersion); return map; } diff --git a/packages/playground/lib/features/ai/wizard/core/ai/wizard_session_state.dart b/packages/playground/lib/features/ai/wizard/core/ai/wizard_session_state.dart index f59cdc37..4d021650 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/wizard_session_state.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/wizard_session_state.dart @@ -7,6 +7,7 @@ enum WizardStep { emphasis, slideCount, theme, + imageStyle, review, } @@ -15,96 +16,132 @@ enum WizardStep { /// GenUI chooses how each question is presented, while this state owns the /// accepted values and the only valid order in which the workflow can advance. final class WizardSessionState { - const WizardSessionState._({required this.step, required this.context}); + final WizardStep step; + + final WizardContext context; - factory WizardSessionState.initial() { - return const WizardSessionState._( - step: WizardStep.topic, - context: WizardContext(), + final bool imageStyleEnabled; + const WizardSessionState._({ + required this.step, + required this.context, + required this.imageStyleEnabled, + }); + + factory WizardSessionState.initial({bool imageStyleEnabled = true}) { + return WizardSessionState._( + step: .topic, + context: const WizardContext(), + imageStyleEnabled: imageStyleEnabled, ); } - final WizardStep step; - final WizardContext context; + WizardSessionState? _advanceSingleChoice( + Map actionContext, { + required WizardStep nextStep, + required WizardContext Function(String value) update, + }) { + final value = _nonEmptyString(actionContext['selectedOption']); + if (value == null) return null; + + return _next(step: nextStep, context: update(value)); + } + + WizardSessionState _next({ + required WizardStep step, + required WizardContext context, + }) => ._(step: step, context: context, imageStyleEnabled: imageStyleEnabled); bool get isReviewReady { - return step == WizardStep.review && + return step == .review && context.topic != null && context.audience != null && context.approach != null && context.emphasis?.isNotEmpty == true && context.slideCount != null && - context.themeId != null; + context.themeId != null && + (!imageStyleEnabled || + (context.imageStyleId != null && + context.imageStyleVersion != null)); } WizardSessionState? startTopic(String rawTopic) { - if (step != WizardStep.topic) return null; + if (step != .topic) return null; final topic = _nonEmptyString(rawTopic); if (topic == null) return null; - return WizardSessionState._( - step: WizardStep.audience, + return _next( + step: .audience, context: context.copyWith(topic: topic), ); } WizardSessionState? advance(Map actionContext) { switch (step) { - case WizardStep.topic: - case WizardStep.review: + case .topic: + case .review: return null; - case WizardStep.audience: + case .audience: return _advanceSingleChoice( actionContext, - nextStep: WizardStep.approach, + nextStep: .approach, update: (value) => context.copyWith(audience: value), ); - case WizardStep.approach: + case .approach: return _advanceSingleChoice( actionContext, - nextStep: WizardStep.emphasis, + nextStep: .emphasis, update: (value) => context.copyWith(approach: value), ); - case WizardStep.emphasis: + case .emphasis: final emphasis = _stringList(actionContext['selectedOptions']); if (emphasis == null || emphasis.length > 3) return null; - return WizardSessionState._( - step: WizardStep.slideCount, + + return _next( + step: .slideCount, context: context.copyWith(emphasis: emphasis), ); - case WizardStep.slideCount: + case .slideCount: final slideCount = _integer(actionContext['value']); if (slideCount == null || slideCount < 5 || slideCount > 20) { return null; } - return WizardSessionState._( - step: WizardStep.theme, + + return _next( + step: .theme, context: context.copyWith(slideCount: slideCount), ); - case WizardStep.theme: + case .theme: final themeId = _nonEmptyString(actionContext['themeId']); if (themeId == null) return null; - return WizardSessionState._( - step: WizardStep.review, + + return _next( + step: imageStyleEnabled ? .imageStyle : .review, context: context.copyWith(themeId: themeId), ); - } - } + case .imageStyle: + final imageStyleId = _nonEmptyString(actionContext['imageStyleId']); + final imageStyleVersion = _integer(actionContext['imageStyleVersion']); + if (imageStyleId == null || + imageStyleVersion == null || + imageStyleVersion < 1) { + return null; + } - WizardSessionState? _advanceSingleChoice( - Map actionContext, { - required WizardStep nextStep, - required WizardContext Function(String value) update, - }) { - final value = _nonEmptyString(actionContext['selectedOption']); - if (value == null) return null; - return WizardSessionState._(step: nextStep, context: update(value)); + return _next( + step: .review, + context: context.copyWith( + imageStyleId: imageStyleId, + imageStyleVersion: imageStyleVersion, + ), + ); + } } } String? _nonEmptyString(Object? value) { if (value is! String) return null; final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; } @@ -112,11 +149,13 @@ List? _stringList(Object? value) { if (value is! List) return null; final items = value.map(_nonEmptyString).whereType().toList(); if (items.isEmpty || items.length != value.length) return null; + return List.unmodifiable(items); } int? _integer(Object? value) { if (value is int) return value; if (value is num && value == value.roundToDouble()) return value.toInt(); + return null; } diff --git a/packages/playground/lib/features/ai/wizard/core/ui/widgets/catalog_next_button.dart b/packages/playground/lib/features/ai/wizard/core/ui/widgets/catalog_next_button.dart index b5d6df0a..326e9483 100644 --- a/packages/playground/lib/features/ai/wizard/core/ui/widgets/catalog_next_button.dart +++ b/packages/playground/lib/features/ai/wizard/core/ui/widgets/catalog_next_button.dart @@ -16,8 +16,8 @@ class CatalogNextButton extends StatelessWidget { padding: const EdgeInsets.only(top: 16), child: SdButton( label: 'Continue', - icon: LucideIcons.arrowRight, onPressed: onPressed, + icon: LucideIcons.arrowRight, semanticLabel: 'Next step', ), ), diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_controller.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_controller.dart index aec7b626..660ef013 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_controller.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_controller.dart @@ -20,55 +20,40 @@ enum WizardGenerationStage { enum WizardGenerationPhase { planning, composition } -typedef ApplyWizardDeckResult = void Function(DeckGenerationResult result); +typedef ApplyWizardDeckResult = + FutureOr Function(DeckGenerationResult result); /// Owns the deterministic plan → review → compose lifecycle for the Wizard. final class WizardGenerationController extends ChangeNotifier { - WizardGenerationController({ - required DeckGeneratorService service, - required ApplyWizardDeckResult applyResult, - }) : _service = service, - _applyResult = applyResult; + /// Booth-demo performance target used by live smoke tests and reporting. + /// + /// This is deliberately not a runtime deadline. The generation service owns + /// request and run safety timeouts, while the Wizard keeps showing progress + /// when a healthy provider call takes longer than the target. + static const generationBudget = Duration(seconds: 30); final DeckGeneratorService _service; final ApplyWizardDeckResult _applyResult; - static const generationBudget = Duration(seconds: 30); + WizardGenerationStage _stage = .setup; - WizardGenerationStage _stage = WizardGenerationStage.setup; WizardGenerationPhase? _failedPhase; DeckGenerationRequest? _request; DeckPlanType? _plan; DeckGenerationResult? _result; String? _errorMessage; - GenerationProgress _progress = const GenerationProgress(GenerationPhase.idle); - Duration _elapsed = Duration.zero; - Duration _budgetElapsed = Duration.zero; + GenerationProgress _progress = const GenerationProgress(.idle); + Duration _elapsed = .zero; DateTime? _stageStartedAt; var _cancelled = false; var _disposed = false; var _operationEpoch = 0; var _planRevision = 0; - - WizardGenerationStage get stage => _stage; - WizardGenerationPhase? get failedPhase => _failedPhase; - DeckPlanType? get plan => _plan; - int get planRevision => _planRevision; - DeckGenerationResult? get result => _result; - String? get errorMessage => _errorMessage; - GenerationProgress get progress => _progress; - Duration get elapsed => - _elapsed + - (_stageStartedAt == null - ? Duration.zero - : DateTime.now().difference(_stageStartedAt!)); - bool get isBusy => - _stage == WizardGenerationStage.planning || - _stage == WizardGenerationStage.composing; - - Future createOutline(DeckGenerationRequest request) async { - await _createOutline(request, preserveCurrentPlan: false); - } + WizardGenerationController({ + required DeckGeneratorService service, + required ApplyWizardDeckResult applyResult, + }) : _service = service, + _applyResult = applyResult; Future _createOutline( DeckGenerationRequest request, { @@ -81,62 +66,124 @@ final class WizardGenerationController extends ChangeNotifier { if (!preserveCurrentPlan) { _plan = null; _planRevision = 0; - _elapsed = Duration.zero; + _elapsed = .zero; } - _budgetElapsed = Duration.zero; _result = null; _errorMessage = null; _failedPhase = null; _cancelled = false; - _beginStage(WizardGenerationStage.planning); - _progress = const GenerationProgress(GenerationPhase.generatingOutline); + _beginStage(.planning); + _progress = const GenerationProgress(.generatingOutline); _notify(); final DeckPlanningResult planning; try { - planning = await _service - .plan( - request, - onProgress: (progress) => _setProgress(operation, progress), - isCancelled: () => _isOperationCancelled(operation), - ) - .timeout(_remainingBudget); - } on TimeoutException { - _finishTiming(); - if (!_isCurrentOperation(operation)) return; - _cancelled = true; - _fail( - WizardGenerationPhase.planning, - 'Outline creation exceeded the 30-second demo budget. Try again.', + planning = await _service.plan( + request, + onProgress: (progress) => _setProgress(operation, progress), + isCancelled: () => _isOperationCancelled(operation), ); - return; } catch (error) { _finishTiming(); if (!_isCurrentOperation(operation)) return; - _fail( - WizardGenerationPhase.planning, - 'Could not create the outline: $error', - ); + _fail(.planning, 'Could not create the outline: $error'); + return; } _finishTiming(); if (!_isCurrentOperation(operation) || _disposed) return; if (_cancelled) return; if (!planning.success || planning.plan == null) { - _fail( - WizardGenerationPhase.planning, - planning.error ?? 'Could not create the outline.', - ); + _fail(.planning, planning.error ?? 'Could not create the outline.'); + return; } _plan = planning.plan; _planRevision++; - _stage = WizardGenerationStage.outlineReview; - _progress = const GenerationProgress(GenerationPhase.idle); + _stage = .outlineReview; + _progress = const GenerationProgress(.idle); + _notify(); + } + + void _beginStage(WizardGenerationStage stage) { + _stage = stage; + _stageStartedAt = DateTime.now(); + } + + void _finishTiming() { + final startedAt = _stageStartedAt; + if (startedAt != null) { + final duration = DateTime.now().difference(startedAt); + _elapsed += duration; + } + _stageStartedAt = null; + } + + bool _isCurrentOperation(int operation) => operation == _operationEpoch; + bool _isOperationCancelled(int operation) => + _cancelled || !_isCurrentOperation(operation); + void _setProgress(int operation, GenerationProgress progress) { + if (_isOperationCancelled(operation) || _disposed) return; + _progress = progress; + _notify(); + } + + void _fail(WizardGenerationPhase phase, String message) { + _failedPhase = phase; + _errorMessage = message; + _stage = .failed; + _progress = const GenerationProgress(.idle); + _notify(); + } + + Future _completeComposition(DeckGenerationResult generated) async { + try { + await _applyResult(generated); + } catch (error) { + _fail( + .composition, + 'Slides were generated but could not be loaded: $error', + ); + + return; + } + _result = generated; + _stage = .completed; + _progress = const GenerationProgress(.idle); _notify(); } + void _notify() { + if (!_disposed) notifyListeners(); + } + + WizardGenerationStage get stage => _stage; + + WizardGenerationPhase? get failedPhase => _failedPhase; + + DeckPlanType? get plan => _plan; + + int get planRevision => _planRevision; + + DeckGenerationResult? get result => _result; + + String? get errorMessage => _errorMessage; + + GenerationProgress get progress => _progress; + + Duration get elapsed => + _elapsed + + (_stageStartedAt == null + ? Duration.zero + : DateTime.now().difference(_stageStartedAt!)); + + bool get isBusy => _stage == .planning || _stage == .composing; + + Future createOutline(DeckGenerationRequest request) async { + await _createOutline(request, preserveCurrentPlan: false); + } + Future regenerateOutline() async { final currentRequest = _request; if (currentRequest == null) return; @@ -149,7 +196,7 @@ final class WizardGenerationController extends ChangeNotifier { required String assertion, }) { final currentPlan = _plan; - if (_stage != WizardGenerationStage.outlineReview || currentPlan == null) { + if (_stage != .outlineReview || currentPlan == null) { return false; } final nextTitle = title.trim(); @@ -170,6 +217,7 @@ final class WizardGenerationController extends ChangeNotifier { final blockingIssues = validateDeckPlanIssues( candidate, typographyCatalog: _service.typographyCatalog, + imageStyleCatalog: _service.imageStyleCatalog, themeCatalog: _service.themeCatalog, request: _request, ).blockingIssues; @@ -177,13 +225,14 @@ final class WizardGenerationController extends ChangeNotifier { _plan = candidate; _notify(); + return true; } Future generateSlides() async { final currentRequest = _request; final currentPlan = _plan; - if (_stage != WizardGenerationStage.outlineReview || + if (_stage != .outlineReview || currentRequest == null || currentPlan == null || _disposed) { @@ -195,56 +244,41 @@ final class WizardGenerationController extends ChangeNotifier { _errorMessage = null; _failedPhase = null; _cancelled = false; - _beginStage(WizardGenerationStage.composing); - _progress = const GenerationProgress(GenerationPhase.composingSlides); + _beginStage(.composing); + _progress = const GenerationProgress(.composingSlides); _notify(); final DeckGenerationResult generated; try { - generated = await _service - .generateFromPlan( - currentRequest, - currentPlan, - onProgress: (progress) => _setProgress(operation, progress), - isCancelled: () => _isOperationCancelled(operation), - ) - .timeout(_remainingBudget); - } on TimeoutException { - _finishTiming(); - if (!_isCurrentOperation(operation)) return; - _cancelled = true; - _fail( - WizardGenerationPhase.composition, - 'Slide generation exceeded the 30-second demo budget. Try again.', + generated = await _service.generateFromPlan( + currentRequest, + currentPlan, + onProgress: (progress) => _setProgress(operation, progress), + isCancelled: () => _isOperationCancelled(operation), ); - return; } catch (error) { _finishTiming(); if (!_isCurrentOperation(operation)) return; - _fail( - WizardGenerationPhase.composition, - 'Could not compose the slides: $error', - ); + _fail(.composition, 'Could not compose the slides: $error'); + return; } _finishTiming(); if (!_isCurrentOperation(operation) || _disposed || _cancelled) return; if ((!generated.success && !generated.isPartial) || generated.slides.isEmpty) { - _fail( - WizardGenerationPhase.composition, - generated.error ?? 'Could not compose the slides.', - ); + _fail(.composition, generated.error ?? 'Could not compose the slides.'); + return; } - _completeComposition(generated); + await _completeComposition(generated); } Future retryFailedSlides() async { final currentRequest = _request; final currentResult = _result; - if (_stage != WizardGenerationStage.completed || + if (_stage != .completed || currentRequest == null || currentResult == null || !currentResult.isPartial || @@ -256,37 +290,23 @@ final class WizardGenerationController extends ChangeNotifier { _errorMessage = null; _failedPhase = null; _cancelled = false; - _budgetElapsed = Duration.zero; - _beginStage(WizardGenerationStage.composing); - _progress = const GenerationProgress(GenerationPhase.composingSlides); + _beginStage(.composing); + _progress = const GenerationProgress(.composingSlides); _notify(); final DeckGenerationResult generated; try { - generated = await _service - .retryFailedSlides( - currentRequest, - currentResult, - onProgress: (progress) => _setProgress(operation, progress), - isCancelled: () => _isOperationCancelled(operation), - ) - .timeout(_remainingBudget); - } on TimeoutException { - _finishTiming(); - if (!_isCurrentOperation(operation)) return; - _cancelled = true; - _fail( - WizardGenerationPhase.composition, - 'Slide retry exceeded the 30-second demo budget. Try again.', + generated = await _service.retryFailedSlides( + currentRequest, + currentResult, + onProgress: (progress) => _setProgress(operation, progress), + isCancelled: () => _isOperationCancelled(operation), ); - return; } catch (error) { _finishTiming(); if (!_isCurrentOperation(operation)) return; - _fail( - WizardGenerationPhase.composition, - 'Could not retry the unresolved slides: $error', - ); + _fail(.composition, 'Could not retry the unresolved slides: $error'); + return; } _finishTiming(); @@ -294,48 +314,46 @@ final class WizardGenerationController extends ChangeNotifier { if ((!generated.success && !generated.isPartial) || generated.slides.isEmpty) { _fail( - WizardGenerationPhase.composition, + .composition, generated.error ?? 'Could not retry the unresolved slides.', ); + return; } - _completeComposition(generated); + await _completeComposition(generated); } Future retry() async { switch (_failedPhase) { - case WizardGenerationPhase.planning: + case .planning: await regenerateOutline(); - case WizardGenerationPhase.composition: + case .composition: if (_result?.isPartial == true) { - _stage = WizardGenerationStage.completed; + _stage = .completed; await retryFailedSlides(); } else { returnToOutline(); await generateSlides(); } case null: - return; } } void returnToOutline() { - if (_plan == null || _stage != WizardGenerationStage.failed) { + if (_plan == null || _stage != .failed) { return; } _errorMessage = null; _failedPhase = null; - _budgetElapsed = Duration.zero; - _stage = WizardGenerationStage.outlineReview; - _progress = const GenerationProgress(GenerationPhase.idle); + _stage = .outlineReview; + _progress = const GenerationProgress(.idle); _notify(); } void editOutline() { - if (_plan == null || _stage != WizardGenerationStage.completed) return; - _budgetElapsed = Duration.zero; - _stage = WizardGenerationStage.outlineReview; + if (_plan == null || _stage != .completed) return; + _stage = .outlineReview; _notify(); } @@ -348,11 +366,10 @@ final class WizardGenerationController extends ChangeNotifier { _result = null; _errorMessage = null; _failedPhase = null; - _progress = const GenerationProgress(GenerationPhase.idle); - _elapsed = Duration.zero; - _budgetElapsed = Duration.zero; + _progress = const GenerationProgress(.idle); + _elapsed = .zero; _stageStartedAt = null; - _stage = WizardGenerationStage.setup; + _stage = .setup; _notify(); } @@ -362,75 +379,14 @@ final class WizardGenerationController extends ChangeNotifier { _operationEpoch++; _finishTiming(); _stage = _result?.isPartial == true - ? WizardGenerationStage.completed + ? .completed : _plan == null - ? WizardGenerationStage.setup - : WizardGenerationStage.outlineReview; - _progress = const GenerationProgress(GenerationPhase.idle); - _notify(); - } - - void _beginStage(WizardGenerationStage stage) { - _stage = stage; - _stageStartedAt = DateTime.now(); - } - - void _finishTiming() { - final startedAt = _stageStartedAt; - if (startedAt != null) { - final duration = DateTime.now().difference(startedAt); - _elapsed += duration; - _budgetElapsed += duration; - } - _stageStartedAt = null; - } - - Duration get _remainingBudget { - final remaining = generationBudget - _budgetElapsed; - return remaining > Duration.zero - ? remaining - : const Duration(microseconds: 1); - } - - bool _isCurrentOperation(int operation) => operation == _operationEpoch; - - bool _isOperationCancelled(int operation) => - _cancelled || !_isCurrentOperation(operation); - - void _setProgress(int operation, GenerationProgress progress) { - if (_isOperationCancelled(operation) || _disposed) return; - _progress = progress; - _notify(); - } - - void _fail(WizardGenerationPhase phase, String message) { - _failedPhase = phase; - _errorMessage = message; - _stage = WizardGenerationStage.failed; - _progress = const GenerationProgress(GenerationPhase.idle); - _notify(); - } - - void _completeComposition(DeckGenerationResult generated) { - try { - _applyResult(generated); - } catch (error) { - _fail( - WizardGenerationPhase.composition, - 'Slides were generated but could not be loaded: $error', - ); - return; - } - _result = generated; - _stage = WizardGenerationStage.completed; - _progress = const GenerationProgress(GenerationPhase.idle); + ? .setup + : .outlineReview; + _progress = const GenerationProgress(.idle); _notify(); } - void _notify() { - if (!_disposed) notifyListeners(); - } - @override void dispose() { _cancelled = true; diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart index f6faf3da..5856bd4a 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart @@ -13,11 +13,13 @@ class WizardGenerationStatus extends StatelessWidget { const WizardGenerationStatus({ super.key, required this.kind, - this.progress = const GenerationProgress(GenerationPhase.idle), + this.progress = const GenerationProgress(.idle), this.errorMessage, this.noticeMessage, this.slideCount, this.failedSlideCount = 0, + this.artworkCount = 0, + this.failedArtworkCount = 0, this.elapsed, this.planAvailable = false, this.onCancel, @@ -36,6 +38,8 @@ class WizardGenerationStatus extends StatelessWidget { final String? noticeMessage; final int? slideCount; final int failedSlideCount; + final int artworkCount; + final int failedArtworkCount; final Duration? elapsed; final bool planAvailable; final VoidCallback? onCancel; @@ -49,32 +53,32 @@ class WizardGenerationStatus extends StatelessWidget { @override Widget build(BuildContext context) { - final highlightedMessage = kind == WizardGenerationStatusKind.failed - ? errorMessage - : noticeMessage; - final highlightedColor = kind == WizardGenerationStatusKind.failed + final highlightedMessage = kind == .failed ? errorMessage : noticeMessage; + final highlightedColor = kind == .failed ? $danger.resolve(context) : $warning.resolve(context); final (:icon, :title, :description) = switch (kind) { - WizardGenerationStatusKind.running => ( + .running => ( icon: LucideIcons.sparkles, - title: progress.phase == GenerationPhase.generatingOutline + title: progress.phase == .generatingOutline ? 'Creating your outline' : 'Building your presentation', - description: progress.phase == GenerationPhase.generatingOutline + description: progress.phase == .generatingOutline ? 'Shaping a clear story before you review and approve it.' : 'Composing the approved story and checking each slide.', ), - WizardGenerationStatusKind.completed => ( + .completed => ( icon: LucideIcons.circleCheck, title: failedSlideCount > 0 ? 'Your presentation is almost ready' : 'Your presentation is ready', description: failedSlideCount > 0 ? 'Keep the finished slides and retry only the ones that need attention.' + : failedArtworkCount > 0 + ? 'The deck is ready, with a text-first fallback where artwork was unavailable.' : 'Open the deck now, or return to the outline for changes.', ), - WizardGenerationStatusKind.failed => ( + .failed => ( icon: LucideIcons.triangleAlert, title: planAvailable ? 'We couldn\'t finish the deck' @@ -87,50 +91,48 @@ class WizardGenerationStatus extends StatelessWidget { return Semantics( liveRegion: true, - label: kind == WizardGenerationStatusKind.running - ? progress.label - : title, + label: kind == .running ? progress.label : title, child: Container( key: ValueKey(kind), - padding: const EdgeInsets.all(28), + padding: const .all(28), decoration: BoxDecoration( color: $surfaceSecondary.resolve(context), - border: Border.all(color: $border.resolve(context)), - borderRadius: BorderRadius.circular(20), + border: .all(color: $border.resolve(context)), + borderRadius: .circular(20), ), child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: .min, + crossAxisAlignment: .start, spacing: 24, children: [ Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, spacing: 16, children: [ _StatusIcon(icon: icon, running: kind == .running), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, spacing: 6, children: [SdHeadline(title), SdBody(description)], ), ), ], ), - if (kind == WizardGenerationStatusKind.running) ...[ + if (kind == .running) ...[ ClipRRect( - borderRadius: BorderRadius.circular(999), + borderRadius: .circular(999), child: LinearProgressIndicator( value: _progressValue(progress), - minHeight: 5, - color: $accent.resolve(context), backgroundColor: $border.resolve(context), + color: $accent.resolve(context), + minHeight: 5, semanticsLabel: progress.label, ), ), _GenerationStages(progress: progress), Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: .spaceBetween, spacing: 16, children: [ const SdCaption('Usually 20–30 seconds'), @@ -142,43 +144,43 @@ class WizardGenerationStatus extends StatelessWidget { ], ), ], - if (kind == WizardGenerationStatusKind.completed && - slideCount != null && - elapsed != null) + if (kind == .completed && slideCount != null && elapsed != null) SdTitle( '$slideCount ${slideCount == 1 ? 'slide' : 'slides'} • ' + '${artworkCount > 0 ? '$artworkCount ' + '${artworkCount == 1 ? 'artwork' : 'artworks'} • ' : ''}' '${elapsed!.inSeconds}s', ), if (highlightedMessage != null) Container( - width: double.infinity, - padding: const EdgeInsets.all(14), + padding: const .all(14), decoration: BoxDecoration( color: highlightedColor.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(12), + borderRadius: .circular(12), ), + width: .infinity, child: SdCaption( highlightedMessage, style: TextStyler().color(highlightedColor), ), ), - if (kind == WizardGenerationStatusKind.failed) + if (kind == .failed) Wrap( - alignment: WrapAlignment.end, + alignment: .end, spacing: 10, runSpacing: 10, children: [ TextButton(onPressed: onBack, child: Text(backLabel)), SdButton( label: 'Retry', - icon: LucideIcons.refreshCw, onPressed: onRetry, + icon: LucideIcons.refreshCw, ), ], ), - if (kind == WizardGenerationStatusKind.completed) + if (kind == .completed) Wrap( - alignment: WrapAlignment.end, + alignment: .end, spacing: 10, runSpacing: 10, children: [ @@ -201,8 +203,8 @@ class WizardGenerationStatus extends StatelessWidget { ), SdButton( label: 'Present deck', - icon: LucideIcons.play, onPressed: onPresent, + icon: LucideIcons.play, ), ], ), @@ -215,27 +217,37 @@ class WizardGenerationStatus extends StatelessWidget { double _progressValue(GenerationProgress progress) { return switch (progress.phase) { - GenerationPhase.idle => 0.05, - GenerationPhase.generatingOutline => 0.15, - GenerationPhase.composingSlides => _compositionProgress(progress), - GenerationPhase.finalizing => 0.94, - GenerationPhase.generatingThumbnails => 0.98, + .idle => 0.05, + .generatingOutline => 0.15, + .generatingImages => _imageProgress(progress), + .composingSlides => _compositionProgress(progress), + .finalizing => 0.94, + .generatingThumbnails => 0.98, }; } +double _imageProgress(GenerationProgress progress) { + final completed = progress.completedItems; + final total = progress.totalItems; + if (completed == null || total == null || total < 1) return 0.22; + + return (0.2 + 0.14 * (completed / total)).clamp(0.2, 0.34); +} + double _compositionProgress(GenerationProgress progress) { final slideIndex = progress.slideIndex; final slideCount = progress.slideCount; if (slideIndex != null && slideCount != null && slideCount > 0) { - return (0.2 + 0.7 * (slideIndex / slideCount)).clamp(0.2, 0.9); + return (0.35 + 0.55 * (slideIndex / slideCount)).clamp(0.35, 0.9); } final sectionIndex = progress.sectionIndex; final sectionCount = progress.sectionCount; if (sectionIndex != null && sectionCount != null && sectionCount > 0) { - return (0.2 + 0.7 * (sectionIndex / sectionCount)).clamp(0.2, 0.9); + return (0.35 + 0.55 * (sectionIndex / sectionCount)).clamp(0.35, 0.9); } - return 0.25; + + return 0.38; } class _StatusIcon extends StatelessWidget { @@ -247,15 +259,15 @@ class _StatusIcon extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - width: 48, - height: 48, + alignment: Alignment.center, decoration: BoxDecoration( color: $accentSoft.resolve(context), - borderRadius: BorderRadius.circular(14), + borderRadius: .circular(14), ), - alignment: Alignment.center, + width: 48, + height: 48, child: running - ? const SdSpinner(size: SdSpinnerSize.size3) + ? const SdSpinner(size: .size3) : Icon(icon, size: 23, color: $accent.resolve(context)), ); } @@ -267,24 +279,27 @@ class _GenerationStages extends StatelessWidget { final GenerationProgress progress; int get _activeIndex => switch (progress.phase) { - GenerationPhase.generatingOutline || GenerationPhase.idle => 0, - GenerationPhase.composingSlides => 1, - GenerationPhase.finalizing || GenerationPhase.generatingThumbnails => 2, + .generatingOutline || .idle => 0, + .generatingImages => 1, + .composingSlides => 2, + .finalizing || .generatingThumbnails => 3, }; @override Widget build(BuildContext context) { - const stages = ['Shape the story', 'Compose the slides', 'Polish the deck']; + const stages = [ + 'Shape the story', + 'Create the artwork', + 'Compose the slides', + 'Polish the deck', + ]; final activeIndex = _activeIndex; return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, spacing: 12, children: [ - SdTitle( - progress.label, - style: TextStyler().fontWeight(FontWeight.w600), - ), + SdTitle(progress.label, style: TextStyler().fontWeight(.w600)), for (final (index, label) in stages.indexed) _GenerationStage( label: label, @@ -327,9 +342,7 @@ class _GenerationStage extends StatelessWidget { ), SdCaption( label, - style: TextStyler() - .color(color) - .fontWeight(active ? FontWeight.w600 : FontWeight.w400), + style: TextStyler().color(color).fontWeight(active ? .w600 : .w400), ), ], ); diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_outline_review.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_outline_review.dart index 5908ae01..9063f1ae 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_outline_review.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_outline_review.dart @@ -34,6 +34,16 @@ class WizardOutlineReview extends StatefulWidget { class _WizardOutlineReviewState extends State { final _invalidSlideKeys = {}; + void _setSlideValidity(String key, {required bool isValid}) { + setState(() { + if (isValid) { + _invalidSlideKeys.remove(key); + } else { + _invalidSlideKeys.add(key); + } + }); + } + @override void didUpdateWidget(covariant WizardOutlineReview oldWidget) { super.didUpdateWidget(oldWidget); @@ -44,40 +54,30 @@ class _WizardOutlineReviewState extends State { _invalidSlideKeys.removeWhere((key) => !currentKeys.contains(key)); } - void _setSlideValidity(String key, bool isValid) { - setState(() { - if (isValid) { - _invalidSlideKeys.remove(key); - } else { - _invalidSlideKeys.add(key); - } - }); - } - @override Widget build(BuildContext context) { return Center( child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: const .symmetric(vertical: 8), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 900), child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: .stretch, spacing: 18, children: [ SdPanel( child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, spacing: 14, children: [ Container( - width: 44, - height: 44, + alignment: Alignment.center, decoration: BoxDecoration( color: $accentSoft.resolve(context), - borderRadius: BorderRadius.circular(12), + borderRadius: .circular(12), ), - alignment: Alignment.center, + width: 44, + height: 44, child: Icon( LucideIcons.listTree, size: 22, @@ -86,7 +86,7 @@ class _WizardOutlineReviewState extends State { ), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, spacing: 6, children: [ const SdHeadline('Review the story'), @@ -114,7 +114,7 @@ class _WizardOutlineReviewState extends State { planRevision: widget.planRevision, ), Wrap( - alignment: WrapAlignment.end, + alignment: .end, spacing: 10, runSpacing: 10, children: [ @@ -129,10 +129,10 @@ class _WizardOutlineReviewState extends State { ), SdButton( label: 'Approve & build', - icon: LucideIcons.sparkles, onPressed: _invalidSlideKeys.isEmpty ? widget.onApprove : null, + icon: LucideIcons.sparkles, ), ], ), @@ -156,19 +156,20 @@ class _OutlineSection extends StatelessWidget { final DeckPlanSectionType section; final List<({int index, DeckPlanSlideType slide})> slides; final UpdateOutlineSlide onSlideChanged; - final void Function(String key, bool isValid) onSlideValidityChanged; + final void Function(String key, {required bool isValid}) + onSlideValidityChanged; final int planRevision; @override Widget build(BuildContext context) { return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: .stretch, spacing: 10, children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), + padding: const .symmetric(horizontal: 4), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, spacing: 4, children: [SdTitle(section.title), SdCaption(section.purpose)], ), @@ -180,7 +181,7 @@ class _OutlineSection extends StatelessWidget { slide: item.slide, onChanged: onSlideChanged, onValidityChanged: (isValid) => - onSlideValidityChanged(item.slide.key, isValid), + onSlideValidityChanged(item.slide.key, isValid: isValid), planRevision: planRevision, ), ], @@ -220,6 +221,26 @@ class _OutlineSlideEditorState extends State<_OutlineSlideEditor> { _assertionController = TextEditingController(text: widget.slide.assertion); } + void _submitEdit(String _) { + final title = _titleController.text.trim(); + final assertion = _assertionController.text.trim(); + if (title.isEmpty || assertion.isEmpty) { + setState(() { + _validationMessage = 'Add both a slide title and core message.'; + }); + widget.onValidityChanged(false); + + return; + } + final accepted = widget.onChanged(widget.index, title, assertion); + setState(() { + _validationMessage = accepted + ? null + : 'This edit conflicts with the approved deck constraints.'; + }); + widget.onValidityChanged(accepted); + } + @override void didUpdateWidget(covariant _OutlineSlideEditor oldWidget) { super.didUpdateWidget(oldWidget); @@ -227,6 +248,7 @@ class _OutlineSlideEditorState extends State<_OutlineSlideEditor> { _titleController.text = widget.slide.title; _assertionController.text = widget.slide.assertion; _validationMessage = null; + return; } if (oldWidget.slide.title != widget.slide.title && @@ -246,25 +268,6 @@ class _OutlineSlideEditorState extends State<_OutlineSlideEditor> { super.dispose(); } - void _submitEdit(String _) { - final title = _titleController.text.trim(); - final assertion = _assertionController.text.trim(); - if (title.isEmpty || assertion.isEmpty) { - setState(() { - _validationMessage = 'Add both a slide title and core message.'; - }); - widget.onValidityChanged(false); - return; - } - final accepted = widget.onChanged(widget.index, title, assertion); - setState(() { - _validationMessage = accepted - ? null - : 'This edit conflicts with the approved deck constraints.'; - }); - widget.onValidityChanged(accepted); - } - @override Widget build(BuildContext context) { final slide = widget.slide; @@ -272,41 +275,41 @@ class _OutlineSlideEditorState extends State<_OutlineSlideEditor> { return SdPanel( child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, spacing: 14, children: [ Container( - width: 58, - padding: const EdgeInsets.symmetric(vertical: 8), + alignment: Alignment.center, + padding: const .symmetric(vertical: 8), decoration: BoxDecoration( color: $surfaceTertiary.resolve(context), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: borderColor), + border: .all(color: borderColor), + borderRadius: .circular(10), ), - alignment: Alignment.center, + width: 58, child: SdCaption('Slide ${widget.index + 1}'), ), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, spacing: 12, children: [ SdTextField( key: ValueKey('outline-title-${slide.key}'), controller: _titleController, label: 'Slide title', - semanticLabel: 'Slide ${widget.index + 1} title', - textInputAction: TextInputAction.next, onChanged: _submitEdit, + textInputAction: .next, + semanticLabel: 'Slide ${widget.index + 1} title', ), SdTextField( key: ValueKey('outline-assertion-${slide.key}'), controller: _assertionController, label: 'Core message', - semanticLabel: 'Slide ${widget.index + 1} core message', - minLines: 1, - maxLines: 2, onChanged: _submitEdit, + maxLines: 2, + minLines: 1, + semanticLabel: 'Slide ${widget.index + 1} core message', ), if (_validationMessage case final message?) Text( @@ -323,6 +326,11 @@ class _OutlineSlideEditorState extends State<_OutlineSlideEditor> { _PlanChip(label: slide.narrativeRole), _PlanChip(label: slide.composition), _PlanChip(label: slide.treatment), + if ((slide.elements ?? const []).any( + (element) => + element.generationPrompt?.trim().isNotEmpty == true, + )) + const _PlanChip(label: 'artwork planned'), ], ), ], @@ -342,10 +350,10 @@ class _PlanChip extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + padding: const .symmetric(vertical: 5, horizontal: 9), decoration: BoxDecoration( color: $surfaceTertiary.resolve(context), - borderRadius: BorderRadius.circular(999), + borderRadius: .circular(999), ), child: SdCaption(label), ); diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart index 1aaa06ed..380a3f36 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_page.dart @@ -6,12 +6,15 @@ import 'package:go_router/go_router.dart'; import 'package:hero_ui/hero_ui.dart'; import 'package:provider/provider.dart'; +import '../../../../core/domain/design/presentation_image_style_catalog.dart'; import '../../../../core/domain/stores/deck_customization_store.dart'; -import '../../../../core/data/data_sources/memory_deck_loader.dart'; +import '../../../../core/data/data_sources/memory_asset_cache_store.dart'; import '../../../editor/domain/stores/deck_document_store.dart'; import '../../quick_agent/core/engine/services/deck_generator_service.dart'; import '../../quick_agent/core/env_config.dart'; import '../../quick_agent/domain/generated_deck_result_applier.dart'; +import '../../image_generation/image_generator.dart'; +import '../../image_generation/image_style_preview_coordinator.dart'; import '../chat/chat_conversation_profile.dart'; import '../core/ai/services/ai_conversation_viewmodel.dart'; import '../core/viewmodel_scope.dart'; @@ -26,7 +29,13 @@ import 'wizard_view.dart'; /// deck-storage folder. The production integration can provide its own document /// destination when the Wizard is embedded outside the playground. class WizardPage extends StatelessWidget { - const WizardPage({this.isConfigured, this.generationService, super.key}); + const WizardPage({ + this.isConfigured, + this.generationService, + this.imageGenerator, + this.imageGenerationEnabled, + super.key, + }); /// Overrides environment detection in tests and previews. final bool? isConfigured; @@ -34,6 +43,12 @@ class WizardPage extends StatelessWidget { /// Overrides the live Gemini-backed service in tests and previews. final DeckGeneratorService? generationService; + /// Overrides the live Flash Lite image provider in tests and previews. + final ImageGenerator? imageGenerator; + + /// Overrides the debug-on, release-opt-in image rollout policy. + final bool? imageGenerationEnabled; + @override Widget build(BuildContext context) { final hasApiKey = isConfigured ?? EnvConfig.hasGeminiApiKey; @@ -41,36 +56,77 @@ class WizardPage extends StatelessWidget { if (!hasApiKey) { return const _MissingApiKeyView(); } + final imagesEnabled = + imageGenerationEnabled ?? EnvConfig.wizardImageGenerationEnabled; + final imageStyles = PresentationImageStyleCatalog.withDefaults(); + final previewImageGenerator = !imagesEnabled + ? const UnavailableImageGenerator() + : (imageGenerator ?? + (EnvConfig.hasGeminiApiKey + ? DartanticImageGenerator(apiKey: EnvConfig.geminiApiKey) + : const UnavailableImageGenerator())); + final finalImageGenerator = !imagesEnabled + ? null + : (imageGenerator ?? + (EnvConfig.hasGeminiApiKey + ? DartanticImageGenerator( + apiKey: EnvConfig.geminiApiKey, + modelName: geminiImageGenerationModel, + ) + : const UnavailableImageGenerator())); return MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => DeckDocumentStore(markdown: '')), - ChangeNotifierProvider( - create: (context) => WizardGenerationController( - service: - generationService ?? - DeckGeneratorService(apiKey: EnvConfig.geminiApiKey), - applyResult: (result) => applyGeneratedDeckResult( - result: result, + ChangeNotifierProvider( + create: (_) => ImageStylePreviewCoordinator( + generator: previewImageGenerator, + catalog: imageStyles, + ), + ), + ChangeNotifierProvider( + create: (context) { + final resultApplier = GeneratedDeckResultApplier( documentStore: Provider.of( context, listen: false, ), - deckLoader: Provider.of(context, listen: false), + deckLoader: Provider.of(context, listen: false), + assetCacheStore: Provider.of( + context, + listen: false, + ), customizationStore: Provider.of( context, listen: false, ), - ), - ), + ); + + return WizardGenerationController( + service: + generationService ?? + DeckGeneratorService( + apiKey: EnvConfig.geminiApiKey, + imageGenerator: finalImageGenerator, + ), + applyResult: resultApplier.apply, + ); + }, ), ], - child: ViewModelScope( - create: () { - // ViewModelScope owns and disposes the created conversation model. - return AiConversationViewModel(profile: chatConversationProfile()); - }, - child: const _WizardExperience(), + child: Builder( + builder: (context) => ViewModelScope( + create: () { + // ViewModelScope owns and disposes the conversation model. The + // Wizard provider owns and disposes the preview coordinator. + return AiConversationViewModel( + profile: chatConversationProfile(imageStyleCatalog: imageStyles), + imageStylePreviews: imagesEnabled ? context.read() : null, + imageStyleEnabled: imagesEnabled, + ); + }, + child: const _WizardExperience(), + ), ), ); } @@ -83,16 +139,15 @@ class _WizardExperience extends StatelessWidget { Widget build(BuildContext context) { final controller = context.watch(); final overlay = switch (controller.stage) { - WizardGenerationStage.setup => null, - WizardGenerationStage.planning || - WizardGenerationStage.composing => _CenteredScrollable( + .setup => null, + .planning || .composing => _CenteredScrollable( child: WizardGenerationStatus( - kind: WizardGenerationStatusKind.running, + kind: .running, progress: controller.progress, onCancel: controller.cancel, ), ), - WizardGenerationStage.outlineReview => WizardOutlineReview( + .outlineReview => WizardOutlineReview( plan: controller.plan!, planRevision: controller.planRevision, onSlideChanged: (index, title, assertion) => @@ -105,31 +160,33 @@ class _WizardExperience extends StatelessWidget { unawaited(controller.generateSlides()); }, ), - WizardGenerationStage.failed => _CenteredScrollable( + .failed => _CenteredScrollable( child: WizardGenerationStatus( - kind: WizardGenerationStatusKind.failed, + kind: .failed, errorMessage: controller.errorMessage, planAvailable: controller.plan != null, onRetry: () { unawaited(controller.retry()); }, - backLabel: controller.plan != null ? 'Edit outline' : 'Back to setup', onBack: controller.plan != null ? controller.returnToOutline : controller.reset, + backLabel: controller.plan != null ? 'Edit outline' : 'Back to setup', ), ), - WizardGenerationStage.completed => _CenteredScrollable( + .completed => _CenteredScrollable( child: WizardGenerationStatus( - kind: WizardGenerationStatusKind.completed, - noticeMessage: _partialResultNotice(controller.result), + kind: .completed, + noticeMessage: _completionNotice(controller.result), slideCount: controller.result?.slides.length, failedSlideCount: controller.result?.slideFailures.length ?? 0, + artworkCount: controller.result?.generatedImageCount ?? 0, + failedArtworkCount: controller.result?.failedImageCount ?? 0, elapsed: controller.elapsed, + onPresent: () => context.push('/present/0'), onRetryFailed: () { unawaited(controller.retryFailedSlides()); }, - onPresent: () => context.push('/present/0'), onEditOutline: controller.editOutline, onStartOver: () { controller.reset(); @@ -142,7 +199,6 @@ class _WizardExperience extends StatelessWidget { }; return Scaffold( - backgroundColor: $background.resolve(context), body: SafeArea( child: Stack( children: [ @@ -154,11 +210,10 @@ class _WizardExperience extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(32), child: Stack( - fit: StackFit.expand, + fit: .expand, children: [ Offstage( - offstage: - controller.stage != WizardGenerationStage.setup, + offstage: controller.stage != .setup, child: const WizardView(), ), ?overlay, @@ -173,23 +228,39 @@ class _WizardExperience extends StatelessWidget { top: 8, right: 8, child: IconButton( - tooltip: 'Generation lab', onPressed: () => context.push('/debug/generation'), + tooltip: 'Generation lab', icon: const Icon(Icons.science_outlined), ), ), ], ), ), + backgroundColor: $background.resolve(context), ); } } -String? _partialResultNotice(DeckGenerationResult? result) { - if (result == null || !result.isPartial) return null; - final failed = result.slideFailures.length; - return '${result.slides.length} slides are ready. ' - '$failed ${failed == 1 ? 'slide needs' : 'slides need'} another pass.'; +String? _completionNotice(DeckGenerationResult? result) { + if (result == null) return null; + final messages = []; + if (result.isPartial) { + final failed = result.slideFailures.length; + messages.add( + '${result.slides.length} slides are ready. ' + '$failed ${failed == 1 ? 'slide needs' : 'slides need'} another pass.', + ); + } + if (result.hasImageFailures) { + final failed = result.failedImageCount; + messages.add( + 'Created ${result.generatedImageCount} of ' + '${result.generatedImages.length} planned artworks. ' + '$failed ${failed == 1 ? 'image uses' : 'images use'} a text-first fallback.', + ); + } + + return messages.isEmpty ? null : messages.join(' '); } class _CenteredScrollable extends StatelessWidget { diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_selection_review.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_selection_review.dart index 1f0ccd25..6eaf4e3e 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_selection_review.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_selection_review.dart @@ -3,6 +3,7 @@ import 'package:hero_ui/hero_ui.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../../../core/domain/design/presentation_theme_catalog.dart'; +import '../../../../core/domain/design/presentation_image_style_catalog.dart'; import '../core/ai/wizard_context.dart'; import '../core/ui/ui.dart'; import '../core/utils/color_utils.dart'; @@ -12,15 +13,19 @@ class WizardSelectionReview extends StatelessWidget { const WizardSelectionReview({ super.key, required this.wizardContext, + required this.imageStyleCatalog, required this.themeCatalog, required this.onCreateOutline, this.onStartOver, + this.showArtwork = true, }); final WizardContext wizardContext; + final PresentationImageStyleCatalog imageStyleCatalog; final PresentationThemeCatalog themeCatalog; final VoidCallback onCreateOutline; final VoidCallback? onStartOver; + final bool showArtwork; @override Widget build(BuildContext context) { @@ -28,6 +33,7 @@ class WizardSelectionReview extends StatelessWidget { ? null : themeCatalog.current(wizardContext.themeId!); final emphasis = wizardContext.emphasis?.join(', ') ?? ''; + final imageStyle = _resolveImageStyle(wizardContext, imageStyleCatalog); return Center( child: SingleChildScrollView( @@ -35,21 +41,21 @@ class WizardSelectionReview extends StatelessWidget { constraints: const BoxConstraints(maxWidth: 760), child: SdPanel( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, spacing: 20, children: [ Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, spacing: 12, children: [ Container( - width: 42, - height: 42, + alignment: Alignment.center, decoration: BoxDecoration( color: $accentSoft.resolve(context), - borderRadius: BorderRadius.circular(12), + borderRadius: .circular(12), ), - alignment: Alignment.center, + width: 42, + height: 42, child: Icon( LucideIcons.layers, size: 21, @@ -58,7 +64,7 @@ class WizardSelectionReview extends StatelessWidget { ), const Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, spacing: 4, children: [ SdHeadline('Review your deck setup'), @@ -82,9 +88,11 @@ class WizardSelectionReview extends StatelessWidget { ), ], theme: theme, + imageStyle: imageStyle, + showArtwork: showArtwork, ), Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: .end, spacing: 12, children: [ if (onStartOver != null) @@ -94,8 +102,8 @@ class WizardSelectionReview extends StatelessWidget { ), SdButton( label: 'Create outline', - icon: LucideIcons.sparkles, onPressed: onCreateOutline, + icon: LucideIcons.sparkles, ), ], ), @@ -109,17 +117,24 @@ class WizardSelectionReview extends StatelessWidget { } final class _ReviewItem { - const _ReviewItem(this.label, this.value); - final String label; + final String value; + const _ReviewItem(this.label, this.value); } class _ReviewItems extends StatelessWidget { - const _ReviewItems({required this.items, required this.theme}); + const _ReviewItems({ + required this.items, + required this.theme, + required this.imageStyle, + required this.showArtwork, + }); final List<_ReviewItem> items; final PresentationThemeDescriptor? theme; + final PresentationImageStyleDescriptor? imageStyle; + final bool showArtwork; @override Widget build(BuildContext context) { @@ -136,11 +151,41 @@ class _ReviewItems extends StatelessWidget { ? const SdBody('Theme unavailable') : _ThemeSummary(theme: theme!), ), + if (showArtwork) ...[ + Divider(height: 1, color: $separator.resolve(context)), + _ReviewRow( + label: 'Artwork', + child: imageStyle == null + ? const SdBody('Artwork direction unavailable') + : Column( + crossAxisAlignment: .start, + spacing: 4, + children: [ + SdBody(imageStyle!.title), + SdCaption(imageStyle!.description), + ], + ), + ), + ], ], ); } } +PresentationImageStyleDescriptor? _resolveImageStyle( + WizardContext context, + PresentationImageStyleCatalog catalog, +) { + final id = context.imageStyleId; + final version = context.imageStyleVersion; + if (id == null || version == null) return null; + try { + return catalog.resolve(id: id, version: version); + } on ArgumentError { + return null; + } +} + class _ReviewRow extends StatelessWidget { const _ReviewRow({required this.label, required this.child}); @@ -150,19 +195,19 @@ class _ReviewRow extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.symmetric(vertical: 14), + padding: const .symmetric(vertical: 14), child: LayoutBuilder( builder: (context, constraints) { if (constraints.maxWidth < 480) { return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, spacing: 8, children: [SdCaption(label), child], ); } return Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ SizedBox(width: 120, child: SdCaption(label)), Expanded(child: child), @@ -182,8 +227,9 @@ class _ThemeSummary extends StatelessWidget { @override Widget build(BuildContext context) { final recipe = theme.recipe; + return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, spacing: 6, children: [ SdBody(theme.title), diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_view.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_view.dart index 3fb12b9c..725591fd 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_view.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_view.dart @@ -42,13 +42,6 @@ class _WizardBodyState extends State<_WizardBody> { _focusNode = FocusNode(); } - @override - void dispose() { - _controller.dispose(); - _focusNode.dispose(); - super.dispose(); - } - void _submit(String value) { if (value.trim().isEmpty) return; unawaited( @@ -57,6 +50,13 @@ class _WizardBodyState extends State<_WizardBody> { _controller.clear(); } + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final viewModel = ViewModelScope.of(context); @@ -71,11 +71,12 @@ class _WizardBodyState extends State<_WizardBody> { if (viewModel.wizardState.isReviewReady) { return WizardSelectionReview( wizardContext: viewModel.wizardState.context, + imageStyleCatalog: viewModel.imageStyleCatalog, themeCatalog: viewModel.themeCatalog, - onStartOver: viewModel.restartConversation, onCreateOutline: () { final request = buildPromptFromWizardContext( viewModel.wizardState.context, + imageStyleCatalog: viewModel.imageStyleCatalog, ); unawaited( prov.Provider.of( @@ -84,17 +85,20 @@ class _WizardBodyState extends State<_WizardBody> { ).createOutline(request), ); }, + onStartOver: viewModel.restartConversation, + showArtwork: viewModel.imageStyleEnabled, ); } final input = ChatInput( controller: _controller, focusNode: _focusNode, + autofocus: !started, enabled: !isThinking, + onSubmitted: _submit, hintText: !started ? 'Describe your presentation topic…' : 'Add a detail or ask for a change…', - onSubmitted: _submit, ); // Before the first message: show the empty state with the topic prompt. @@ -104,7 +108,10 @@ class _WizardBodyState extends State<_WizardBody> { builder: (context, constraints) => SingleChildScrollView( child: ConstrainedBox( constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: EmptyState(input: input), + child: EmptyState( + input: input, + errorMessage: viewModel.errorMessage, + ), ), ), ); diff --git a/packages/playground/lib/features/editor/presentation/pages/editor_bootstrap.dart b/packages/playground/lib/features/editor/presentation/pages/editor_bootstrap.dart index 7bdb9d72..87028f90 100644 --- a/packages/playground/lib/features/editor/presentation/pages/editor_bootstrap.dart +++ b/packages/playground/lib/features/editor/presentation/pages/editor_bootstrap.dart @@ -31,20 +31,12 @@ class EditorBootstrap extends StatefulWidget { class _EditorBootstrapState extends State { late final DeckFileRepository _repository; - late Future> _initialDeck; late final AppLifecycleListener _lifecycleListener; + late Future> _initialDeck; DeckDocumentStore? _documentStore; DeckFileSession? _fileSession; - Future _handleExitRequested() async { - final session = _fileSession; - if (session == null) return AppExitResponse.exit; - return await session.flushPendingSave() - ? AppExitResponse.exit - : AppExitResponse.cancel; - } - @override void initState() { super.initState(); @@ -55,51 +47,12 @@ class _EditorBootstrapState extends State { ); } - @override - void dispose() { - _lifecycleListener.dispose(); + Future _handleExitRequested() async { final session = _fileSession; - if (session != null) { - session.dispose(); - } else { - // A successful load retains a bookmark. If the widget goes away before - // the route scope mounts, release that unclaimed reference once ready. - unawaited(_releaseUnclaimedInitialDeck()); - } - _documentStore?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return FutureBuilder>( - future: _initialDeck, - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return _BootstrapMessage( - child: SizedBox( - width: 28, - height: 28, - child: CircularProgressIndicator( - strokeWidth: 2.5, - color: $accent.resolve(context), - ), - ), - ); - } - if (snapshot.hasError) { - return _buildFailure(snapshot.error!); - } - - final result = snapshot.requireData; - switch (result) { - case Failure(:final error): - return _buildFailure(error); - case Ok(:final value): - return _buildEditor(value); - } - }, - ); + if (session == null) return AppExitResponse.exit; + return await session.flushPendingSave() + ? AppExitResponse.exit + : AppExitResponse.cancel; } Widget _buildEditor(DeckFileSnapshot snapshot) { @@ -130,6 +83,8 @@ class _EditorBootstrapState extends State { create: (ctx) => GenerateDeckCommand( documentStore: ctx.read(), customizationStore: ctx.read(), + deckLoader: ctx.read(), + assetCacheStore: ctx.read(), ), dispose: (_, command) => command.dispose(), ), @@ -179,6 +134,53 @@ class _EditorBootstrapState extends State { // Bootstrap errors do not retain a usable bookmark. } } + + @override + void dispose() { + _lifecycleListener.dispose(); + final session = _fileSession; + if (session != null) { + session.dispose(); + } else { + // A successful load retains a bookmark. If the widget goes away before + // the route scope mounts, release that unclaimed reference once ready. + unawaited(_releaseUnclaimedInitialDeck()); + } + _documentStore?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: _initialDeck, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return _BootstrapMessage( + child: SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: $accent.resolve(context), + ), + ), + ); + } + if (snapshot.hasError) { + return _buildFailure(snapshot.error!); + } + + final result = snapshot.requireData; + switch (result) { + case Failure(:final error): + return _buildFailure(error); + case Ok(:final value): + return _buildEditor(value); + } + }, + ); + } } /// Full-screen centered slot used for the loading spinner and error message. diff --git a/packages/playground/lib/main.dart b/packages/playground/lib/main.dart index 205f35d9..55b14a4c 100644 --- a/packages/playground/lib/main.dart +++ b/packages/playground/lib/main.dart @@ -29,10 +29,10 @@ class _SuperdeckAppState extends State { @override Widget build(BuildContext context) { return MaterialApp.router( - title: 'Superdeck', - debugShowCheckedModeBanner: false, routerConfig: _router, builder: (context, child) => _Theme(child: AppProviders(child: child!)), + title: 'Superdeck', + debugShowCheckedModeBanner: false, ); } } diff --git a/packages/playground/test/features/ai/image_generation/image_style_preview_coordinator_test.dart b/packages/playground/test/features/ai/image_generation/image_style_preview_coordinator_test.dart new file mode 100644 index 00000000..9cba861b --- /dev/null +++ b/packages/playground/test/features/ai/image_generation/image_style_preview_coordinator_test.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:playground/core/domain/design/presentation_image_style_catalog.dart'; +import 'package:playground/core/domain/generated_image_asset.dart'; +import 'package:playground/features/ai/image_generation/image_generator.dart'; +import 'package:playground/features/ai/image_generation/image_style_preview_coordinator.dart'; + +void main() { + test('starts exactly three concurrent previews once per topic', () { + final generator = _ControlledImageGenerator(); + final coordinator = ImageStylePreviewCoordinator( + generator: generator, + catalog: PresentationImageStyleCatalog.withDefaults(), + ); + addTearDown(coordinator.dispose); + + coordinator.prefetch('Urban gardens'); + + expect(generator.requests, hasLength(3)); + expect(coordinator.topic, 'Urban gardens'); + expect(coordinator.previews, hasLength(3)); + expect( + coordinator.previews.map((preview) => preview.style.id), + featuredPresentationImageStyleIds, + ); + expect( + coordinator.previews.map((preview) => preview.status), + everyElement(ImageStylePreviewStatus.loading), + ); + expect( + generator.requests.map((request) => request.aspectRatio), + everyElement(GeneratedImageAspectRatio.landscape16x9), + ); + + coordinator.prefetch(' Urban gardens '); + + expect(generator.requests, hasLength(3)); + }); + + test('ignores stale results after a new topic starts', () async { + final generator = _ControlledImageGenerator(); + final coordinator = ImageStylePreviewCoordinator( + generator: generator, + catalog: PresentationImageStyleCatalog.withDefaults(), + ); + addTearDown(coordinator.dispose); + + coordinator.prefetch('Urban gardens'); + coordinator.prefetch('Ocean restoration'); + expect(generator.requests, hasLength(6)); + + generator.results[0].complete(ImageGenerationSuccess([1, 2, 3])); + await _flushAsyncWork(); + + expect(coordinator.topic, 'Ocean restoration'); + expect( + coordinator.previews, + everyElement( + isA().having( + (preview) => preview.status, + 'status', + ImageStylePreviewStatus.loading, + ), + ), + ); + }); + + test('keeps failures selectable and retries only the failed style', () async { + final generator = _ControlledImageGenerator(); + final coordinator = ImageStylePreviewCoordinator( + generator: generator, + catalog: PresentationImageStyleCatalog.withDefaults(), + ); + addTearDown(coordinator.dispose); + + coordinator.prefetch('Urban gardens'); + generator.results.first.complete( + const ImageGenerationFailure('Preview unavailable.'), + ); + await _flushAsyncWork(); + + final failed = coordinator.previews.first; + expect(failed.status, ImageStylePreviewStatus.failed); + expect(failed.error, 'Preview unavailable.'); + + coordinator.retry(failed.style.id); + + expect(generator.requests, hasLength(4)); + expect(coordinator.previews.first.status, ImageStylePreviewStatus.loading); + }); + + test('reset invalidates in-flight previews', () async { + final generator = _ControlledImageGenerator(); + final coordinator = ImageStylePreviewCoordinator( + generator: generator, + catalog: PresentationImageStyleCatalog.withDefaults(), + ); + addTearDown(coordinator.dispose); + + coordinator.prefetch('Urban gardens'); + coordinator.reset(); + generator.results.first.complete(ImageGenerationSuccess([1, 2, 3])); + await _flushAsyncWork(); + + expect(coordinator.topic, isNull); + expect(coordinator.previews, isEmpty); + }); +} + +Future _flushAsyncWork() => Future.delayed(Duration.zero); + +final class _ControlledImageGenerator implements ImageGenerator { + final requests = []; + final results = >[]; + + @override + Future generate(ImageGenerationRequest request) { + requests.add(request); + final result = Completer(); + results.add(result); + return result.future; + } +} diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider_test.dart index 1820cc09..659a9193 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider_test.dart @@ -33,6 +33,10 @@ void main() { expect(prompt, isNot(contains('Playfair Display'))); expect(prompt, isNot(contains('Open Sans'))); expect(prompt, isNot(contains('accentContrast'))); + expect(prompt, contains('exact `imageStyleId` and `imageStyleVersion`')); + expect(prompt, contains('at most `maxGeneratedImages`')); + expect(prompt, contains('never both `source` and `generationPrompt`')); + expect(prompt, contains('two to four generated visuals')); }, ); diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/schemas/outline_schema_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/schemas/outline_schema_test.dart index 0803e4df..d26a1157 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/schemas/outline_schema_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/schemas/outline_schema_test.dart @@ -341,6 +341,90 @@ void main() { ); }); + test('accepts generated image intent with an exact style reference', () { + final data = _hierarchicalPlan(); + final slides = data['slides']! as List>; + slides[1] + ..['composition'] = 'imageRight' + ..['treatment'] = 'visual' + ..['elements'] = [ + { + 'type': 'image', + 'purpose': 'Make the operating tension tangible', + 'generationPrompt': + 'an operations team tracing one critical signal through noise', + }, + ]; + final plan = DeckPlanType.parse(data); + + final errors = validateDeckPlan( + plan, + request: const DeckGenerationRequest( + userIntent: 'Explain reliable delivery.', + slideCount: 10, + imageStyleId: 'minimalist', + imageStyleVersion: 1, + ), + ); + + expect(plan.slides[1].elements!.single.generationPrompt, isNotEmpty); + expect( + errors.where( + (error) => + error.contains('source or generationPrompt') || + error.contains('without an exact image-style reference') || + error.contains('generated images'), + ), + isEmpty, + ); + }); + + test('requires an exact style and enforces the generated-image budget', () { + final data = _hierarchicalPlan(); + final slides = data['slides']! as List>; + for (var index = 0; index < 5; index++) { + slides[index] + ..['composition'] = 'imageRight' + ..['treatment'] = 'visual' + ..['elements'] = [ + { + 'type': 'image', + 'purpose': 'Reinforce slide ${index + 1}', + 'generationPrompt': 'a concrete visual subject for slide $index', + }, + ]; + } + final plan = DeckPlanType.parse(data); + + final withoutStyle = validateDeckPlan( + plan, + request: const DeckGenerationRequest( + userIntent: 'Explain reliable delivery.', + slideCount: 10, + ), + ); + final overBudget = validateDeckPlan( + plan, + request: const DeckGenerationRequest( + userIntent: 'Explain reliable delivery.', + slideCount: 10, + imageStyleId: 'watercolor', + imageStyleVersion: 1, + ), + ); + + expect( + withoutStyle, + contains(contains('without an exact image-style reference')), + ); + expect( + overBudget, + contains( + 'Deck plan requests 5 generated images; the configured maximum is 4.', + ), + ); + }); + test('rejects an invented visible domain but allows a supplied one', () { final data = _hierarchicalPlan(); final slides = data['slides']! as List>; diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generation_request_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generation_request_test.dart index 08f227a6..3b14036f 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generation_request_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generation_request_test.dart @@ -19,8 +19,8 @@ void main() { colors: ['#101828', '#F9FAFB', '#D0D5DD'], headlineFont: 'Fraunces', bodyFont: 'Inter', - imageStyleName: 'Documentary', - imageStyleDescription: 'Natural light and real teams', + imageStyleId: 'minimalist', + imageStyleVersion: 1, groundedElements: [ GroundedGenerationElement( type: 'image', @@ -38,6 +38,8 @@ void main() { expect(payload['density'], 'spacious'); expect(payload['headlineFont'], 'Fraunces'); expect(payload['bodyFont'], 'Inter'); + expect(payload['imageStyleId'], 'minimalist'); + expect(payload['imageStyleVersion'], 1); expect(payload['colors'], ['#101828', '#F9FAFB', '#D0D5DD']); expect( payload['groundedElements'], @@ -60,8 +62,8 @@ void main() { colors: ['#101828', '#F9FAFB', '#D0D5DD'], headlineFont: 'Playfair Display', bodyFont: 'Inter', - imageStyleName: 'Editorial photography', - imageStyleDescription: 'Candid, high contrast, human', + imageStyleId: 'watercolor', + imageStyleVersion: 1, ); final request = buildPromptFromWizardContext(context); @@ -71,6 +73,8 @@ void main() { expect(request.themeId, 'bold-product'); expect(request.headlineFont, 'Playfair Display'); expect(request.bodyFont, 'Inter'); + expect(request.imageStyleId, 'watercolor'); + expect(request.imageStyleVersion, 1); expect(input, isNot(contains('Do not use widget blocks'))); expect(input, isNot(contains('two sections (title + body) for most'))); }); diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_images_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_images_test.dart new file mode 100644 index 00000000..6434acc9 --- /dev/null +++ b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_images_test.dart @@ -0,0 +1,163 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:playground/core/domain/design/presentation_image_style_catalog.dart'; +import 'package:playground/features/ai/image_generation/image_generator.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/schemas/outline_schema.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/deck_generator_service.dart'; + +void main() { + test( + 'generates concurrently and removes failed visual dependencies', + () async { + final generator = _TrackingImageGenerator(); + final progress = <(int, int)>[]; + + final result = await generateImagesForPlan( + plan: _plan(), + imageStyle: PresentationImageStyleCatalog.withDefaults().resolve( + id: 'watercolor', + version: 1, + ), + generator: generator, + runId: 'run-42', + onProgress: (completed, total) => progress.add((completed, total)), + ); + + expect(generator.requests, hasLength(3)); + expect(generator.maximumActive, 3); + expect(result.assets, hasLength(3)); + expect(result.assets.map((asset) => asset.assetKey), [ + 'wizard-run-42-slide-01-opening-wave.png', + 'wizard-run-42-slide-03-reef-risk.png', + 'wizard-run-42-slide-04-future-action.png', + ]); + expect(result.assets[1].error, contains('safety')); + expect(progress.first, (0, 3)); + expect(progress.last, (3, 3)); + + final successfulElement = result.plan.slides.first.elements!.single; + expect(successfulElement.source, result.assets.first.assetKey); + expect(successfulElement.generationPrompt, isNull); + + final failedSlide = result.plan.slides[2]; + expect(failedSlide.elements, isEmpty); + expect(failedSlide.composition, 'content'); + expect(failedSlide.treatment, 'content'); + }, + ); + + test('run IDs isolate asset keys across successive demos', () async { + final style = PresentationImageStyleCatalog.withDefaults().resolve( + id: 'minimalist', + version: 1, + ); + final first = await generateImagesForPlan( + plan: _plan(), + imageStyle: style, + generator: _SuccessfulImageGenerator(), + runId: 'first', + ); + final second = await generateImagesForPlan( + plan: _plan(), + imageStyle: style, + generator: _SuccessfulImageGenerator(), + runId: 'second', + ); + + expect( + first.assets + .map((asset) => asset.assetKey) + .toSet() + .intersection(second.assets.map((asset) => asset.assetKey).toSet()), + isEmpty, + ); + }); +} + +final class _TrackingImageGenerator implements ImageGenerator { + int active = 0; + int maximumActive = 0; + final requests = []; + + @override + Future generate(ImageGenerationRequest request) async { + requests.add(request); + active++; + if (active > maximumActive) maximumActive = active; + await Future.delayed(const Duration(milliseconds: 5)); + active--; + if (request.prompt.toLowerCase().contains('blocked subject')) { + return const ImageGenerationFailure('Blocked by safety filters.'); + } + return ImageGenerationSuccess(Uint8List.fromList([1, requests.length])); + } +} + +final class _SuccessfulImageGenerator implements ImageGenerator { + @override + Future generate(ImageGenerationRequest request) async { + return ImageGenerationSuccess([1, 2, 3]); + } +} + +DeckPlanType _plan() => DeckPlanType.parse({ + 'topic': 'Ocean systems', + 'story': 'Move from ocean risk to practical restoration.', + 'theme': {'id': 'technical-paper', 'version': 1, 'density': 'balanced'}, + 'sections': [ + { + 'key': 'main', + 'title': 'Ocean systems', + 'purpose': 'Explain the system and action.', + 'transition': 'Move from risk to restoration.', + 'slideKeys': ['opening-wave', 'data-table', 'reef-risk', 'future-action'], + }, + ], + 'slides': [ + _slide( + key: 'opening-wave', + composition: 'imageFullBleed', + subject: 'a curling ocean wave', + ), + _slide(key: 'data-table', composition: 'table'), + _slide( + key: 'reef-risk', + composition: 'imageLeft', + subject: 'blocked subject near a coral reef', + ), + _slide( + key: 'future-action', + composition: 'imageRight', + subject: 'coastal restoration volunteers', + ), + ], +}); + +Map _slide({ + required String key, + required String composition, + String? subject, +}) => { + 'key': key, + 'title': 'Test title', + 'purpose': 'Advance the ocean story.', + 'sectionKey': 'main', + 'assertion': 'Ocean systems reward coordinated action.', + 'contentUnits': ['One concrete supporting point'], + 'narrativeRole': 'insight', + 'contentBrief': 'Explain one useful idea.', + 'continuity': 'Connect the surrounding ideas.', + 'composition': composition, + 'treatment': composition.startsWith('image') ? 'visual' : 'data', + 'density': 'balanced', + 'elements': subject == null + ? [] + : [ + { + 'type': 'image', + 'purpose': 'Reinforce the slide assertion', + 'generationPrompt': subject, + }, + ], +}; diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart index 7cb7b65e..113d38e9 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:google_cloud_ai_generativelanguage_v1beta/generativelanguage.dart' as google_ai; import 'package:playground/core/domain/design/presentation_theme_catalog.dart'; +import 'package:playground/features/ai/image_generation/image_generator.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/deck_generation_request.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/deck_generator_service.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/generation_model_client.dart'; @@ -81,6 +82,132 @@ void main() { expect(compositionClient.isClosed, isTrue); }); + test('generates planned artwork before composing its image block', () async { + final client = _FakeGenerationModelClient([ + _jsonResponse({ + 'topic': 'Visual story', + 'story': 'One strong visual anchors one clear idea.', + 'theme': _testThemeSelection, + 'slides': [ + _planSlide( + key: 'visual', + composition: 'imageFullBleed', + elements: const [ + { + 'type': 'image', + 'purpose': 'Make the central idea tangible', + 'generationPrompt': 'a luminous city garden at sunrise', + }, + ], + ), + ], + }), + _jsonResponse({ + 'key': 'visual', + 'options': {'style': 'visual'}, + 'sections': [ + { + 'type': 'section', + 'blocks': [ + { + 'type': 'widget', + 'name': 'image', + 'args': {'fit': 'cover'}, + }, + ], + }, + ], + }), + ]); + final imageGenerator = _RecordingImageGenerator(); + final service = DeckGeneratorService( + apiKey: 'test-key', + imageGenerator: imageGenerator, + assetRunIdFactory: () => 'test-run', + modelClientFactory: (_) => client, + ); + + final result = await service.generate( + const DeckGenerationRequest( + userIntent: 'Create one visual slide.', + slideCount: 1, + imageStyleId: 'minimalist', + imageStyleVersion: 1, + ), + ); + + expect(result.success, isTrue); + expect(result.generatedImages, hasLength(1)); + expect(result.generatedImages.single.bytes, isNotEmpty); + expect(result.generatedImageCount, 1); + expect(result.failedImageCount, 0); + expect(result.hasImageFailures, isFalse); + expect( + result.plan!.slides.single.elements!.single.source, + 'wizard-test-run-slide-01-visual.png', + ); + final widget = result.slides.single.sections.single.blocks.single; + expect(widget, isA()); + expect( + (widget as WidgetBlock).args['src'], + 'wizard-test-run-slide-01-visual.png', + ); + }); + + test('keeps the deck valid when planned artwork fails', () async { + final client = _FakeGenerationModelClient([ + _jsonResponse({ + 'topic': 'Resilient visual story', + 'story': 'The core message survives an unavailable visual.', + 'theme': _testThemeSelection, + 'slides': [ + _planSlide( + key: 'fallback', + composition: 'imageRight', + elements: const [ + { + 'type': 'image', + 'purpose': 'Support the central idea', + 'generationPrompt': 'a thriving rooftop garden at sunrise', + }, + ], + ), + ], + }), + _jsonResponse( + _generatedSlide( + key: 'fallback', + title: 'The story still works', + style: 'content', + ), + ), + ]); + final service = DeckGeneratorService( + apiKey: 'test-key', + imageGenerator: const UnavailableImageGenerator('provider unavailable'), + assetRunIdFactory: () => 'failed-run', + modelClientFactory: (_) => client, + ); + + final result = await service.generate( + const DeckGenerationRequest( + userIntent: 'Create one resilient visual slide.', + slideCount: 1, + imageStyleId: 'minimalist', + imageStyleVersion: 1, + ), + ); + + expect(result.success, isTrue); + expect(result.generatedImages.single.error, 'provider unavailable'); + expect(result.generatedImageCount, 0); + expect(result.failedImageCount, 1); + expect(result.hasImageFailures, isTrue); + expect(result.plan!.slides.single.elements, isEmpty); + expect(result.plan!.slides.single.composition, 'content'); + expect(result.slides, hasLength(1)); + }); + test('rejects an outline that violates the typed request count', () async { final client = _FakeGenerationModelClient([ _jsonResponse({ @@ -1537,6 +1664,16 @@ final class _FakeGenerationModelClient implements GenerationModelClient { } } +final class _RecordingImageGenerator implements ImageGenerator { + final requests = []; + + @override + Future generate(ImageGenerationRequest request) async { + requests.add(request); + return ImageGenerationSuccess([1, 2, 3]); + } +} + final class _ConcurrentSectionModelClient implements GenerationModelClient { _ConcurrentSectionModelClient({this.omitProblem = false}); diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/services/generation_progress_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/services/generation_progress_test.dart index 200dd727..9f6f5b34 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/services/generation_progress_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/services/generation_progress_test.dart @@ -11,6 +11,7 @@ void main() { test('maps each phase to its human-readable label', () { expect(GenerationPhase.generatingOutline.label, 'Planning the outline…'); + expect(GenerationPhase.generatingImages.label, 'Creating slide artwork…'); expect(GenerationPhase.composingSlides.label, 'Composing the slides…'); expect(GenerationPhase.finalizing.label, 'Finalizing…'); expect( @@ -19,6 +20,17 @@ void main() { ); expect(GenerationPhase.idle.label, 'Working…'); }); + + test('reports bounded generated-artwork progress', () { + expect( + const GenerationProgress( + GenerationPhase.generatingImages, + completedItems: 2, + totalItems: 3, + ).label, + 'Creating slide artwork 2 of 3…', + ); + }); }); test('describes slide composition and repair progress', () { diff --git a/packages/playground/test/features/ai/quick_agent/domain/generated_deck_result_applier_test.dart b/packages/playground/test/features/ai/quick_agent/domain/generated_deck_result_applier_test.dart new file mode 100644 index 00000000..8c633015 --- /dev/null +++ b/packages/playground/test/features/ai/quick_agent/domain/generated_deck_result_applier_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:playground/core/data/data_sources/memory_asset_cache_store.dart'; +import 'package:playground/core/data/data_sources/memory_deck_loader.dart'; +import 'package:playground/core/domain/design/presentation_theme_catalog.dart'; +import 'package:playground/core/domain/design/presentation_typography_catalog.dart'; +import 'package:playground/core/domain/generated_image_asset.dart'; +import 'package:playground/core/domain/stores/deck_customization_store.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/schemas/outline_schema.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/deck_generator_service.dart'; +import 'package:playground/features/ai/quick_agent/domain/generated_deck_result_applier.dart'; +import 'package:playground/features/editor/domain/stores/deck_document_store.dart'; +import 'package:superdeck/superdeck.dart'; +import 'package:superdeck_core/superdeck_core.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(() => GoogleFonts.config.allowRuntimeFetching = false); + + test( + 'caches generated artwork before publishing the rendered deck', + () async { + const assetKey = 'wizard-test-slide-01-opening.png'; + final cache = MemoryAssetCacheStore(); + final loader = MemoryDeckLoader(); + final documentStore = DeckDocumentStore(markdown: ''); + final deckController = DeckController( + deckLoader: loader, + options: DeckOptions(), + assetCacheStore: cache, + ); + final customizationStore = DeckCustomizationStore(deckController); + addTearDown(customizationStore.dispose); + addTearDown(deckController.dispose); + addTearDown(loader.dispose); + addTearDown(documentStore.dispose); + + final published = loader.load().first.then( + (_) => cache.resolve(assetKey), + ); + await applyGeneratedDeckResult( + result: DeckGenerationResult.success( + slides: [_generatedSlide(assetKey)], + plan: _plan(assetKey), + theme: _resolvedTheme(), + generatedImages: [ + GeneratedImageAsset.success(assetKey: assetKey, bytes: [1, 2, 3]), + ], + ), + documentStore: documentStore, + deckLoader: loader, + assetCacheStore: cache, + customizationStore: customizationStore, + ); + + expect(await published, isNotNull); + expect(documentStore.markdown, contains(assetKey)); + }, + ); + + test('evicts artwork from the deck replaced by the current result', () async { + const oldAssetKey = 'wizard-old-slide-01-opening.png'; + const nextAssetKey = 'wizard-next-slide-01-opening.png'; + final cache = MemoryAssetCacheStore(); + final loader = MemoryDeckLoader(); + final documentStore = DeckDocumentStore(markdown: ''); + final deckController = DeckController( + deckLoader: loader, + options: DeckOptions(), + assetCacheStore: cache, + ); + final customizationStore = DeckCustomizationStore(deckController); + final applier = GeneratedDeckResultApplier( + documentStore: documentStore, + deckLoader: loader, + assetCacheStore: cache, + customizationStore: customizationStore, + ); + addTearDown(customizationStore.dispose); + addTearDown(deckController.dispose); + addTearDown(loader.dispose); + addTearDown(documentStore.dispose); + + await applier.apply(_result(oldAssetKey)); + expect(await cache.resolve(oldAssetKey), isNotNull); + + await applier.apply(_result(nextAssetKey)); + expect(await cache.resolve(oldAssetKey), isNull); + expect(await cache.resolve(nextAssetKey), isNotNull); + }); +} + +DeckGenerationResult _result(String assetKey) => DeckGenerationResult.success( + slides: [_generatedSlide(assetKey)], + plan: _plan(assetKey), + theme: _resolvedTheme(), + generatedImages: [ + GeneratedImageAsset.success(assetKey: assetKey, bytes: [1, 2, 3]), + ], +); + +Slide _generatedSlide(String assetKey) => Slide.parse({ + 'key': 'opening', + 'options': {'title': 'Opening', 'style': 'visual'}, + 'sections': [ + { + 'type': 'section', + 'blocks': [ + { + 'type': 'widget', + 'name': 'image', + 'args': {'src': assetKey, 'fit': 'cover'}, + }, + ], + }, + ], +}); + +DeckPlanType _plan(String assetKey) => DeckPlanType.parse({ + 'topic': 'Generated artwork', + 'story': 'One image supports one clear point.', + 'theme': {'id': 'technical-paper', 'version': 1, 'density': 'balanced'}, + 'sections': [ + { + 'key': 'main', + 'title': 'Main', + 'purpose': 'Introduce the idea.', + 'transition': 'Close clearly.', + 'slideKeys': ['opening'], + }, + ], + 'slides': [ + { + 'key': 'opening', + 'title': 'Opening', + 'purpose': 'Introduce the idea.', + 'sectionKey': 'main', + 'assertion': 'The visual makes the idea tangible.', + 'contentUnits': ['One focused supporting statement.'], + 'narrativeRole': 'opening', + 'contentBrief': 'Open with one clear idea.', + 'continuity': 'Lead into the story.', + 'composition': 'imageFullBleed', + 'treatment': 'visual', + 'density': 'balanced', + 'elements': [ + {'type': 'image', 'purpose': 'Anchor the story.', 'source': assetKey}, + ], + }, + ], +}); + +ResolvedPresentationTheme _resolvedTheme() => + PresentationThemeCatalog.withDefaults().resolve( + id: 'technical-paper', + version: 1, + density: 'balanced', + typographyCatalog: PresentationTypographyCatalog.withDefaults(), + ); diff --git a/packages/playground/test/features/ai/wizard/core/ai/catalog/ask_user_image_style_test.dart b/packages/playground/test/features/ai/wizard/core/ai/catalog/ask_user_image_style_test.dart new file mode 100644 index 00000000..ab3a27e6 --- /dev/null +++ b/packages/playground/test/features/ai/wizard/core/ai/catalog/ask_user_image_style_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hero_ui/hero_ui.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:playground/core/domain/design/presentation_image_style_catalog.dart'; +import 'package:playground/features/ai/wizard/chat/chat_conversation_profile.dart'; +import 'package:playground/features/ai/wizard/core/ai/catalog/ask_user_image_style.dart'; +import 'package:playground/features/ai/wizard/core/ai/catalog/ask_user_question_cards.dart'; +import 'package:playground/features/ai/wizard/core/ai/schemas/wizard_context_keys.dart'; + +void main() { + final imageStyles = PresentationImageStyleCatalog.withDefaults(); + + test('the Wizard catalog registers one app-owned image-style surface', () { + final profile = chatConversationProfile(imageStyleCatalog: imageStyles); + + expect( + profile.catalog.items.map((item) => item.name), + contains('AskUserImageStyle'), + ); + expect(profile.imageStyleCatalog, same(imageStyles)); + }); + + test('selection context records the exact catalog ID and version', () { + final context = buildImageStyleSelectionContext( + imageStyles.current('minimalist')!, + ); + + expect(context[WizardContextKeys.imageStyleId], 'minimalist'); + expect(context[WizardContextKeys.imageStyleVersion], 1); + expect(context[WizardContextKeys.title], 'Minimalist'); + }); + + testWidgets('a failed preview remains selectable and retryable', ( + tester, + ) async { + var selected = false; + var retried = false; + await tester.pumpWidget( + MaterialApp( + home: HeroTheme( + data: HeroThemeData.light(), + child: Scaffold( + body: ImageStyleOptionCard( + style: imageStyles.current('minimalist')!, + hasFailed: true, + selected: false, + onTap: () => selected = true, + onRetry: () => retried = true, + ), + ), + ), + ), + ); + + expect(find.byIcon(LucideIcons.imageOff), findsOneWidget); + expect(find.text('Retry'), findsOneWidget); + expect( + find.text(imageStyles.current('minimalist')!.description), + findsOneWidget, + ); + + await tester.tap(find.text('Retry')); + expect(retried, isTrue); + + await tester.tap(find.text('Minimalist')); + expect(selected, isTrue); + }); +} diff --git a/packages/playground/test/features/ai/wizard/core/ai/catalog/presentation_theme_selection_test.dart b/packages/playground/test/features/ai/wizard/core/ai/catalog/presentation_theme_selection_test.dart index 2469f2d1..a0acd2eb 100644 --- a/packages/playground/test/features/ai/wizard/core/ai/catalog/presentation_theme_selection_test.dart +++ b/packages/playground/test/features/ai/wizard/core/ai/catalog/presentation_theme_selection_test.dart @@ -92,6 +92,7 @@ void main() { 'AskUserCheckbox', 'AskUserSlider', 'AskUserStyle', + 'AskUserImageStyle', }); final examples = items['AskUserStyle']!.exampleData.map( (build) => build(), diff --git a/packages/playground/test/features/ai/wizard/core/ai/services/ai_conversation_viewmodel_test.dart b/packages/playground/test/features/ai/wizard/core/ai/services/ai_conversation_viewmodel_test.dart index 69fc5576..265f5f50 100644 --- a/packages/playground/test/features/ai/wizard/core/ai/services/ai_conversation_viewmodel_test.dart +++ b/packages/playground/test/features/ai/wizard/core/ai/services/ai_conversation_viewmodel_test.dart @@ -4,6 +4,9 @@ import 'package:dartantic_ai/dartantic_ai.dart' as dartantic; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart' as genui; +import 'package:playground/core/domain/design/presentation_image_style_catalog.dart'; +import 'package:playground/features/ai/image_generation/image_generator.dart'; +import 'package:playground/features/ai/image_generation/image_style_preview_coordinator.dart'; import 'package:playground/features/ai/wizard/chat/chat_conversation_profile.dart'; import 'package:playground/features/ai/wizard/core/ai/wizard_session_state.dart'; import 'package:playground/features/ai/wizard/core/ai/services/ai_conversation_viewmodel.dart'; @@ -68,6 +71,10 @@ void main() { isFalse, ); expect(expectedWizardComponentType(WizardStep.theme), 'AskUserStyle'); + expect( + expectedWizardComponentType(WizardStep.imageStyle), + 'AskUserImageStyle', + ); }); test('turn prompt pins canonical selections and the expected next step', () { @@ -107,6 +114,54 @@ void main() { await first; }); + test('prefetches image styles as soon as the topic is accepted', () async { + final client = _DelayedAgentClient(); + final generator = _RecordingImageGenerator(); + final previews = ImageStylePreviewCoordinator( + generator: generator, + catalog: PresentationImageStyleCatalog.withDefaults(), + ); + final viewModel = AiConversationViewModel( + profile: chatConversationProfile(), + apiKey: 'test-key', + imageStylePreviews: previews, + agentClientFactory: + ({required apiKey, required modelName, required tools}) => client, + ); + addTearDown(viewModel.dispose); + addTearDown(previews.dispose); + + final send = viewModel.sendMessage('Urban gardens'); + await client.started.future.timeout(const Duration(seconds: 2)); + + expect(previews.topic, 'Urban gardens'); + expect(generator.requests, hasLength(3)); + + client.release.complete(); + await send; + }); + + test('restarting the conversation clears prefetched images', () async { + final generator = _RecordingImageGenerator(); + final previews = ImageStylePreviewCoordinator( + generator: generator, + catalog: PresentationImageStyleCatalog.withDefaults(), + ); + final viewModel = AiConversationViewModel( + profile: chatConversationProfile(), + apiKey: 'test-key', + imageStylePreviews: previews, + ); + addTearDown(viewModel.dispose); + addTearDown(previews.dispose); + + previews.prefetch('Urban gardens'); + viewModel.restartConversation(); + + expect(previews.topic, isNull); + expect(previews.previews, isEmpty); + }); + test('Wizard system prompt permits its single-surface replacement flow', () { final session = GenUiConversationSession( profile: chatConversationProfile(), @@ -144,6 +199,8 @@ void main() { expect(prompt, contains('updateComponents')); expect(prompt, contains('surface messages only')); + expect(prompt, contains('AskUserImageStyle (step6)')); + expect(prompt, contains('application owns the three preview styles')); expect(prompt, isNot(contains('provideFinalOutput'))); expect(prompt, isNot(contains('deleteSurface'))); expect(prompt, isNot(contains('beginRendering'))); @@ -169,3 +226,13 @@ final class _DelayedAgentClient implements SuperdeckAgentClient { @override void dispose() {} } + +final class _RecordingImageGenerator implements ImageGenerator { + final requests = []; + + @override + Future generate(ImageGenerationRequest request) async { + requests.add(request); + return const ImageGenerationFailure('Not needed by this test.'); + } +} diff --git a/packages/playground/test/features/ai/wizard/core/ai/wizard_session_state_test.dart b/packages/playground/test/features/ai/wizard/core/ai/wizard_session_state_test.dart index a8389bdf..409fa0ba 100644 --- a/packages/playground/test/features/ai/wizard/core/ai/wizard_session_state_test.dart +++ b/packages/playground/test/features/ai/wizard/core/ai/wizard_session_state_test.dart @@ -35,6 +35,13 @@ void main() { state = state.advance({'value': 10})!; state = state.advance({'themeId': 'civic-blueprint'})!; + expect(state.step.name, 'imageStyle'); + expect(state.isReviewReady, isFalse); + state = state.advance({ + 'imageStyleId': 'minimalist', + 'imageStyleVersion': 1, + })!; + expect(state.step, WizardStep.review); expect(state.isReviewReady, isTrue); expect(state.context.topic, 'Urban gardens'); @@ -43,6 +50,8 @@ void main() { expect(state.context.emphasis, ['Zoning', 'Community funding']); expect(state.context.slideCount, 10); expect(state.context.themeId, 'civic-blueprint'); + expect(state.context.toMap()['imageStyleId'], 'minimalist'); + expect(state.context.toMap()['imageStyleVersion'], 1); expect(state.advance({'selectedOption': 'Unexpected'}), isNull); }); @@ -60,4 +69,21 @@ void main() { expect(state.advance({'value': 4}), isNull); expect(state.advance({'value': 21}), isNull); }); + + test('can roll out without image generation in release builds', () { + var state = WizardSessionState.initial( + imageStyleEnabled: false, + ).startTopic('Urban gardens')!; + state = state.advance({'selectedOption': 'City planners'})!; + state = state.advance({'selectedOption': 'Policy blueprint'})!; + state = state.advance({ + 'selectedOptions': ['Zoning'], + })!; + state = state.advance({'value': 10})!; + state = state.advance({'themeId': 'civic-blueprint'})!; + + expect(state.step, WizardStep.review); + expect(state.isReviewReady, isTrue); + expect(state.context.imageStyleId, isNull); + }); } diff --git a/packages/playground/test/features/ai/wizard/presentation/wizard_generation_controller_test.dart b/packages/playground/test/features/ai/wizard/presentation/wizard_generation_controller_test.dart index a97cafb2..fae7fbeb 100644 --- a/packages/playground/test/features/ai/wizard/presentation/wizard_generation_controller_test.dart +++ b/packages/playground/test/features/ai/wizard/presentation/wizard_generation_controller_test.dart @@ -149,6 +149,39 @@ void main() { expect(controller.stage, WizardGenerationStage.setup); }); + testWidgets('keeps composing after the 30-second performance target', ( + tester, + ) async { + const request = DeckGenerationRequest( + userIntent: 'Urban gardens', + slideCount: 1, + themeId: 'technical-paper', + ); + final pending = Completer(); + final service = _FakeWizardGenerationService(_plan(request)) + ..pendingComposition = pending; + final controller = WizardGenerationController( + service: service, + applyResult: (_) {}, + ); + addTearDown(controller.dispose); + + await controller.createOutline(request); + final composition = controller.generateSlides(); + await tester.pump(); + await tester.pump( + WizardGenerationController.generationBudget + const Duration(seconds: 1), + ); + + expect(controller.stage, WizardGenerationStage.composing); + expect(controller.errorMessage, isNull); + + pending.complete(_successfulResult(service, controller.plan!)); + await composition; + + expect(controller.stage, WizardGenerationStage.completed); + }); + test('keeps the edited outline when regeneration fails', () async { const request = DeckGenerationRequest( userIntent: 'Urban gardens', @@ -236,6 +269,7 @@ final class _FakeWizardGenerationService extends DeckGeneratorService { final bool partialComposition; DeckPlanType? approvedPlan; Completer? pendingPlanning; + Completer? pendingComposition; String? planningError; var retryCalls = 0; @@ -262,6 +296,7 @@ final class _FakeWizardGenerationService extends DeckGeneratorService { isCancelled, }) async { this.approvedPlan = approvedPlan; + if (pendingComposition case final pending?) return pending.future; if (compositionError case final error?) { return DeckGenerationResult.failure(error); } @@ -291,15 +326,7 @@ final class _FakeWizardGenerationService extends DeckGeneratorService { ), ); } - return DeckGenerationResult.success( - slides: [_generatedSlide('opening')], - plan: approvedPlan, - theme: resolveDeckThemeReference( - approvedPlan.theme, - themeCatalog: themeCatalog, - typographyCatalog: typographyCatalog, - ), - ); + return _successfulResult(this, approvedPlan); } @override @@ -319,6 +346,19 @@ final class _FakeWizardGenerationService extends DeckGeneratorService { } } +DeckGenerationResult _successfulResult( + DeckGeneratorService service, + DeckPlanType plan, +) => DeckGenerationResult.success( + slides: [_generatedSlide('opening')], + plan: plan, + theme: resolveDeckThemeReference( + plan.theme, + themeCatalog: service.themeCatalog, + typographyCatalog: service.typographyCatalog, + ), +); + Slide _generatedSlide(String key) => Slide.parse({ 'key': key, 'options': {'title': key, 'style': 'content'}, diff --git a/packages/playground/test/features/ai/wizard/presentation/wizard_selection_review_test.dart b/packages/playground/test/features/ai/wizard/presentation/wizard_selection_review_test.dart index d94e490d..3e9c1bfd 100644 --- a/packages/playground/test/features/ai/wizard/presentation/wizard_selection_review_test.dart +++ b/packages/playground/test/features/ai/wizard/presentation/wizard_selection_review_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hero_ui/hero_ui.dart'; import 'package:playground/core/domain/design/presentation_theme_catalog.dart'; +import 'package:playground/core/domain/design/presentation_image_style_catalog.dart'; import 'package:playground/features/ai/wizard/core/ai/wizard_context.dart'; import 'package:playground/features/ai/wizard/presentation/wizard_selection_review.dart'; @@ -15,7 +16,9 @@ void main() { ) async { var createOutlineCalls = 0; final themeCatalog = PresentationThemeCatalog.withDefaults(); + final imageStyleCatalog = PresentationImageStyleCatalog.withDefaults(); final theme = themeCatalog.currentThemes.first; + final imageStyle = imageStyleCatalog.currentStyles.first; await tester.pumpWidget( MaterialApp( @@ -30,8 +33,11 @@ void main() { emphasis: const ['Zoning', 'Community funding'], slideCount: 10, themeId: theme.id, + imageStyleId: imageStyle.id, + imageStyleVersion: imageStyle.version, ), themeCatalog: themeCatalog, + imageStyleCatalog: imageStyleCatalog, onCreateOutline: () => createOutlineCalls++, ), ), @@ -47,7 +53,9 @@ void main() { expect(find.text('Zoning, Community funding'), findsOneWidget); expect(find.text('10 slides'), findsOneWidget); expect(find.text(theme.title), findsOneWidget); + expect(find.text(imageStyle.title), findsOneWidget); + await tester.ensureVisible(find.text('Create outline')); await tester.tap(find.text('Create outline')); expect(createOutlineCalls, 1); }); diff --git a/packages/playground/test/features/ai/wizard/wizard_polish_test.dart b/packages/playground/test/features/ai/wizard/wizard_polish_test.dart index 92044539..eee04e17 100644 --- a/packages/playground/test/features/ai/wizard/wizard_polish_test.dart +++ b/packages/playground/test/features/ai/wizard/wizard_polish_test.dart @@ -4,7 +4,9 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:hero_ui/hero_ui.dart'; import 'package:playground/core/domain/design/presentation_theme_catalog.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/generation_progress.dart'; +import 'package:playground/features/ai/wizard/chat/view/widgets/chat_input.dart'; import 'package:playground/features/ai/wizard/chat/view/widgets/chat_genui_panels.dart'; +import 'package:playground/features/ai/wizard/chat/view/widgets/empty_state.dart'; import 'package:playground/features/ai/wizard/core/ai/catalog/ask_user_question_cards.dart'; import 'package:playground/features/ai/wizard/core/ai/catalog/ask_user_slider.dart'; import 'package:playground/features/ai/wizard/core/ai/catalog/catalog_question_step.dart'; @@ -48,6 +50,48 @@ void main() { expect(find.byType(Icon), findsOneWidget); }); + testWidgets('opening topic input is ready for keyboard entry', ( + tester, + ) async { + final controller = TextEditingController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + host( + ChatInput( + controller: controller, + autofocus: true, + enabled: true, + onSubmitted: (_) {}, + ), + ), + ); + await tester.pump(); + + expect( + tester.widget(find.byType(EditableText)).focusNode.hasFocus, + isTrue, + ); + }); + + testWidgets('first-turn failures remain visible beside the topic input', ( + tester, + ) async { + await tester.pumpWidget( + host( + const EmptyState( + errorMessage: 'The first Wizard step could not be prepared.', + input: SizedBox(height: 48), + ), + ), + ); + + expect( + find.text('The first Wizard step could not be prepared.'), + findsOneWidget, + ); + }); + testWidgets('theme options combine palette and typography in one preview', ( tester, ) async { @@ -164,6 +208,7 @@ void main() { expect(find.text('Building your presentation'), findsOneWidget); expect(find.text('Composed section 2 of 4…'), findsOneWidget); expect(find.text('Shape the story'), findsOneWidget); + expect(find.text('Create the artwork'), findsOneWidget); expect(find.text('Compose the slides'), findsOneWidget); expect(find.text('Polish the deck'), findsOneWidget); expect(find.text('Usually 20–30 seconds'), findsOneWidget); @@ -230,6 +275,42 @@ void main() { expect(find.text('Start over'), findsOneWidget); }); + testWidgets('artwork fallback stays visible on successful completion', ( + tester, + ) async { + await tester.pumpWidget( + host( + WizardGenerationStatus( + kind: WizardGenerationStatusKind.completed, + noticeMessage: + 'Created 2 of 3 planned artworks. 1 image uses a text-first fallback.', + slideCount: 10, + artworkCount: 2, + failedArtworkCount: 1, + elapsed: const Duration(seconds: 27), + onPresent: () {}, + onEditOutline: () {}, + onStartOver: () {}, + ), + ), + ); + + expect(find.text('Your presentation is ready'), findsOneWidget); + expect( + find.text( + 'The deck is ready, with a text-first fallback where artwork was unavailable.', + ), + findsOneWidget, + ); + expect(find.text('10 slides • 2 artworks • 27s'), findsOneWidget); + expect( + find.text( + 'Created 2 of 3 planned artworks. 1 image uses a text-first fallback.', + ), + findsOneWidget, + ); + }); + testWidgets('deck length uses a counter and useful presets', (tester) async { var selectedValue = 10; diff --git a/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart b/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart index 54123394..e1566c45 100644 --- a/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart +++ b/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart @@ -16,6 +16,7 @@ import 'package:playground/core/data/data_sources/memory_asset_cache_store.dart' import 'package:playground/core/data/data_sources/memory_deck_loader.dart'; import 'package:playground/core/domain/design/presentation_theme_catalog.dart'; import 'package:playground/core/domain/design/presentation_typography_catalog.dart'; +import 'package:playground/core/domain/generated_image_asset.dart'; import 'package:playground/core/domain/stores/deck_customization_store.dart'; import 'package:playground/features/ai/quick_agent/core/engine/schemas/outline_schema.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/deck_generation_request.dart'; @@ -30,6 +31,7 @@ import 'package:playground/features/ai/quick_agent/core/engine/services/generati import 'package:playground/features/ai/quick_agent/core/engine/services/generation_validation_issue.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/theme_json_serializer.dart'; import 'package:playground/features/ai/quick_agent/domain/generated_deck_style_mapper.dart'; +import 'package:playground/features/ai/image_generation/image_generator.dart'; import 'package:playground/features/ai/wizard/presentation/wizard_generation_controller.dart'; import 'package:superdeck/src/utils/syntax_highlighter.dart'; import 'package:superdeck/superdeck.dart'; @@ -378,6 +380,7 @@ void main() { for (final rawSlide in deckJson['slides']! as List) Map.from(rawSlide as Map), ], + generatedImages: await _readGeneratedImages(output), ); }))!; final auditErrors = [ @@ -387,6 +390,9 @@ void main() { ...validateDeckPlanIssues( artifact.plan, request: artifact.request, + knownGeneratedAssetKeys: { + for (final asset in artifact.generatedImages) asset.assetKey, + }, ).blockingIssues.map((issue) => issue.message), for (final (index, rawSlide) in artifact.rawSlides.indexed) ...validateGeneratedSlideIssues( @@ -412,6 +418,7 @@ void main() { markdown: artifact.markdown, theme: artifact.theme, expectedSlideCount: artifact.slideCount, + generatedImages: artifact.generatedImages, ); // ignore: avoid_print print('Writing contact sheet from ${capture.pngs.length} captures'); @@ -433,6 +440,7 @@ void main() { late DeckPlanType plan; late DeckGenerationRequest request; late List slides; + List generatedImages = const []; var traces = []; var expectedSlideCount = 0; var generationReady = false; @@ -443,7 +451,13 @@ void main() { final brief = await File( 'test_live/ai_generation/fixtures/$fixture.txt', ).readAsString(); - final service = DeckGeneratorService(apiKey: _apiKey); + final service = DeckGeneratorService( + apiKey: _apiKey, + imageGenerator: DartanticImageGenerator( + apiKey: _apiKey, + modelName: geminiImageGenerationModel, + ), + ); request = _requestForFixture(fixture, brief); final planningStopwatch = Stopwatch()..start(); final planning = await service.plan(request, onTrace: traces.add); @@ -457,12 +471,24 @@ void main() { onTrace: traces.add, ); compositionStopwatch.stop(); + output = await _createRunDirectory(fixture); + await File(p.join(output.path, 'timing.json')).writeAsString( + const JsonEncoder.withIndent(' ').convert({ + 'planningMs': planningStopwatch.elapsedMilliseconds, + 'compositionMs': compositionStopwatch.elapsedMilliseconds, + 'combinedGenerationMs': + planningStopwatch.elapsedMilliseconds + + compositionStopwatch.elapsedMilliseconds, + 'withinThirtySecondTarget': + planningStopwatch.elapsed + compositionStopwatch.elapsed <= + WizardGenerationController.generationBudget, + }), + ); expect( planningStopwatch.elapsed + compositionStopwatch.elapsed, lessThanOrEqualTo(WizardGenerationController.generationBudget), reason: 'Split generation exceeded the 30-second booth budget.', ); - output = await _createRunDirectory(fixture); await File(p.join(output.path, 'brief.txt')).writeAsString(brief); await File(p.join(output.path, 'request.json')).writeAsString( @@ -473,18 +499,6 @@ void main() { ).writeAsString( const JsonEncoder.withIndent(' ').convert(approvedPlan), ); - await File(p.join(output.path, 'timing.json')).writeAsString( - const JsonEncoder.withIndent(' ').convert({ - 'planningMs': planningStopwatch.elapsedMilliseconds, - 'compositionMs': compositionStopwatch.elapsedMilliseconds, - 'combinedGenerationMs': - planningStopwatch.elapsedMilliseconds + - compositionStopwatch.elapsedMilliseconds, - 'withinThirtySecondTarget': - planningStopwatch.elapsed + compositionStopwatch.elapsed <= - WizardGenerationController.generationBudget, - }), - ); await File(p.join(output.path, 'trace.json')).writeAsString( const JsonEncoder.withIndent( ' ', @@ -496,6 +510,15 @@ void main() { expect(result.slides, isNotEmpty); plan = result.plan!; slides = result.slides; + generatedImages = result.generatedImages; + await _writeGeneratedImages(output, generatedImages); + if (request.imageStyleId != null) { + expect( + generatedImages.where((asset) => asset.bytes != null), + isNotEmpty, + reason: 'Image-enabled smoke produced no usable artwork.', + ); + } final deckJson = { 'theme': serializeDeckThemeReference(result.plan!.theme), 'slides': result.slides.map((slide) => slide.toMap()).toList(), @@ -536,6 +559,7 @@ void main() { markdown: markdown, theme: theme, expectedSlideCount: expectedSlideCount, + generatedImages: generatedImages, ); await tester.runAsync(() => _writeContactSheet(output, capture.pngs)); final report = GenerationQualityReport.evaluate( @@ -547,6 +571,10 @@ void main() { capturedSlideCount: capture.pngs.length, resolvedFontFamilies: capture.resolvedFontFamilies, captureElapsed: capture.elapsed, + knownGeneratedAssetKeys: { + for (final asset in generatedImages) + if (asset.bytes != null) asset.assetKey, + }, ); await tester.runAsync( () => @@ -678,6 +706,9 @@ DeckGenerationRequest _requestForFixture(String fixture, String brief) { designDirection: 'Editorial, dark, restrained, and cinematic', headlineFont: 'Playfair Display', bodyFont: 'Inter', + imageStyleId: 'minimalist', + imageStyleVersion: 1, + maxGeneratedImages: 3, ), 'decision_data_15' => DeckGenerationRequest( userIntent: brief, @@ -799,6 +830,53 @@ Future _writeTraceArtifacts( ); } +Future _writeGeneratedImages( + Directory output, + List assets, +) async { + if (assets.isEmpty) return; + final directory = await Directory( + p.join(output.path, 'generated_images'), + ).create(); + for (final asset in assets) { + final bytes = asset.bytes; + if (bytes == null || bytes.isEmpty) continue; + await File(p.join(directory.path, asset.assetKey)).writeAsBytes(bytes); + } + await File(p.join(directory.path, 'outcomes.json')).writeAsString( + const JsonEncoder.withIndent(' ').convert([ + for (final asset in assets) + { + 'assetKey': asset.assetKey, + 'success': asset.bytes != null, + if (asset.error != null) 'error': asset.error, + }, + ]), + ); +} + +Future> _readGeneratedImages(Directory output) async { + final directory = Directory(p.join(output.path, 'generated_images')); + final outcomesFile = File(p.join(directory.path, 'outcomes.json')); + if (!await outcomesFile.exists()) return const []; + + final outcomes = jsonDecode(await outcomesFile.readAsString()) as List; + final assets = []; + for (final outcome in outcomes.whereType()) { + final assetKey = outcome['assetKey']; + if (assetKey is! String || outcome['success'] != true) continue; + final file = File(p.join(directory.path, assetKey)); + if (!await file.exists()) continue; + assets.add( + GeneratedImageAsset.success( + assetKey: assetKey, + bytes: await file.readAsBytes(), + ), + ); + } + return assets; +} + List _parseSectionPlanKeys(String prompt) { const startMarker = '## Ordered slide plans\n'; const endMarker = '\n\n## Boundary context'; @@ -882,6 +960,7 @@ Future<_CaptureResult> _captureSlides({ required String markdown, required ResolvedPresentationTheme theme, required int expectedSlideCount, + List generatedImages = const [], }) async { final startedAt = DateTime.now(); // ignore: avoid_print @@ -905,10 +984,17 @@ Future<_CaptureResult> _captureSlides({ // ignore: avoid_print print('Creating in-memory deck'); final loader = MemoryDeckLoader(); + final assetCache = MemoryAssetCacheStore(); + for (final asset in generatedImages) { + final bytes = asset.bytes; + if (bytes != null && bytes.isNotEmpty) { + await assetCache.write(asset.assetKey, bytes); + } + } final controller = DeckController( deckLoader: loader, options: DeckOptions(), - assetCacheStore: MemoryAssetCacheStore(), + assetCacheStore: assetCache, ); final customization = DeckCustomizationStore(controller); addTearDown(customization.dispose); diff --git a/packages/superdeck/lib/src/builtins/image_widget.dart b/packages/superdeck/lib/src/builtins/image_widget.dart index ea54efef..7be0660d 100644 --- a/packages/superdeck/lib/src/builtins/image_widget.dart +++ b/packages/superdeck/lib/src/builtins/image_widget.dart @@ -1,3 +1,5 @@ +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; import 'package:mix/mix.dart'; import 'package:superdeck_core/superdeck_core.dart'; @@ -134,9 +136,11 @@ class ImageDto { /// - `scale` (optional): Paint scale within the image frame - default: 1 class ImageWidget extends StatelessWidget { final ImageDto _data; + final ui.Image? _decodedImage; - ImageWidget(Map args, {super.key}) - : _data = ImageDto.parse(args); + ImageWidget(Map args, {super.key, ui.Image? decodedImage}) + : _data = ImageDto.parse(args), + _decodedImage = decodedImage; @override Widget build(BuildContext context) { @@ -157,19 +161,25 @@ class ImageWidget extends StatelessWidget { final assetCacheStore = InheritedData.maybeOf( context, )?.assetCacheStore; - final Widget image = (assetCacheStore != null && isBareAssetKey(_data.src)) - ? ResolvedAssetImage( - assetKey: _data.src.path, - store: assetCacheStore, - fallback: _data.src, - targetSize: data.size, - styleSpec: styleSpec, - ) - : CachedImage( - uri: _data.src, - targetSize: data.size, - styleSpec: styleSpec, - ); + final Widget image = switch (_decodedImage) { + final decodedImage? => _DecodedImage( + image: decodedImage, + styleSpec: styleSpec, + ), + null when assetCacheStore != null && isBareAssetKey(_data.src) => + ResolvedAssetImage( + assetKey: _data.src.path, + store: assetCacheStore, + fallback: _data.src, + targetSize: data.size, + styleSpec: styleSpec, + ), + null => CachedImage( + uri: _data.src, + targetSize: data.size, + styleSpec: styleSpec, + ), + }; final hasExplicitSize = _data.width != null || _data.height != null; @@ -198,3 +208,29 @@ class ImageWidget extends StatelessWidget { ); } } + +class _DecodedImage extends StatelessWidget { + const _DecodedImage({required this.image, required this.styleSpec}); + + final ui.Image image; + final StyleSpec styleSpec; + + @override + Widget build(BuildContext context) { + final spec = styleSpec.spec; + return RawImage( + image: image, + width: spec.width, + height: spec.height, + color: spec.color, + colorBlendMode: spec.colorBlendMode, + fit: spec.fit, + alignment: spec.alignment ?? Alignment.center, + repeat: spec.repeat ?? ImageRepeat.noRepeat, + centerSlice: spec.centerSlice, + matchTextDirection: spec.matchTextDirection ?? false, + filterQuality: spec.filterQuality ?? FilterQuality.medium, + isAntiAlias: spec.isAntiAlias ?? false, + ); + } +} diff --git a/packages/superdeck/lib/src/capture/slide_capture_service.dart b/packages/superdeck/lib/src/capture/slide_capture_service.dart index 5656c76c..ca146608 100644 --- a/packages/superdeck/lib/src/capture/slide_capture_service.dart +++ b/packages/superdeck/lib/src/capture/slide_capture_service.dart @@ -4,15 +4,20 @@ import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' show MaterialApp, Scaffold, Theme; -import 'package:flutter/widgets.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; import 'package:mix/mix.dart'; -import '../ui/tokens/colors.dart'; -import '../ui/widgets/provider.dart'; +import 'package:superdeck_core/superdeck_core.dart'; +import '../builtins/image_widget.dart'; +import '../builtins/widgets.dart'; +import '../deck/slide_configuration.dart'; +import '../markdown/builders/image_element_builder.dart' show isBareAssetKey; import '../rendering/slides/slide_view.dart'; +import '../ui/tokens/colors.dart'; +import '../ui/widgets/cache_image_widget.dart'; +import '../ui/widgets/provider.dart'; import '../utils/constants.dart'; -import '../deck/slide_configuration.dart'; import 'render_config.dart'; import 'slide_capture_readiness.dart'; @@ -40,6 +45,7 @@ class SlideCaptureService { static const _kRenderSettleDelay = Duration(milliseconds: 32); static const _kMaxRenderPasses = 30; static const _kRequiredStablePasses = 2; + static const _kImageDecodeTimeout = Duration(seconds: 1); Future capture({ SlideCaptureQuality quality = SlideCaptureQuality.thumbnail, @@ -57,7 +63,7 @@ class SlideCaptureService { _generationQueue.add(queueKey); - final staticRenderingSlide = slide.copyWith( + var staticRenderingSlide = slide.copyWith( debug: includeDebugLayout, isStaticRendering: true, ); @@ -67,21 +73,54 @@ class SlideCaptureService { throw Exception('BuildContext is no longer mounted'); } + final imageConfiguration = createLocalImageConfiguration(context); + final decodedImages = await _decodeBuiltInImages( + staticRenderingSlide, + imageConfiguration, + ); + if (!context.mounted) { + for (final image in decodedImages.values) { + image.dispose(); + } + throw Exception('BuildContext is no longer mounted'); + } + if (decodedImages.isNotEmpty) { + final originalImageFactory = staticRenderingSlide.widgets['image']!; + staticRenderingSlide = staticRenderingSlide.copyWith( + widgets: { + ...staticRenderingSlide.widgets, + 'image': (args) { + final src = (args['src'] as String?)?.trim(); + final decodedImage = decodedImages[src]; + return decodedImage == null + ? originalImageFactory(args) + : ImageWidget(args, decodedImage: decodedImage); + }, + }, + ); + } + final config = RenderConfig( pixelRatio: quality.pixelRatio, context: context, targetSize: kResolution, ); - final image = await _fromWidgetToImage( - InheritedData( - data: staticRenderingSlide, - child: SlideView(staticRenderingSlide), - ), - config, - ); + try { + final image = await _fromWidgetToImage( + InheritedData( + data: staticRenderingSlide, + child: SlideView(staticRenderingSlide), + ), + config, + ); - return _imageToUint8List(image); + return _imageToUint8List(image); + } finally { + for (final image in decodedImages.values) { + image.dispose(); + } + } } catch (e, stackTrace) { log('Error generating image: $e', stackTrace: stackTrace); rethrow; @@ -90,6 +129,74 @@ class SlideCaptureService { } } + Future> _decodeBuiltInImages( + SlideConfiguration slide, + ImageConfiguration imageConfiguration, + ) async { + if (slide.widgets['image'] != builtInWidgets['image']) return const {}; + + final sources = {}; + for (final section in slide.sections) { + for (final block in section.blocks) { + if (block is! WidgetBlock || block.name != 'image') continue; + final source = block.args['src']; + if (source is String && source.trim().isNotEmpty) { + sources.add(source.trim()); + } + } + } + if (sources.isEmpty) return const {}; + + final entries = await Future.wait( + sources.map((source) async { + try { + final data = ImageDto.parse({'src': source}); + final uri = isBareAssetKey(data.src) + ? await slide.assetCacheStore?.resolve(data.src.path) + : data.src; + if (uri == null) return null; + + final image = await _decodeImage( + getImageProvider(uri), + imageConfiguration, + ); + return image == null ? null : MapEntry(source, image); + } catch (_) { + return null; + } + }), + ); + + return Map.fromEntries(entries.whereType>()); + } + + Future _decodeImage( + ImageProvider provider, + ImageConfiguration imageConfiguration, + ) async { + final stream = provider.resolve(imageConfiguration); + final completer = Completer(); + final listener = ImageStreamListener( + (info, _) { + if (!completer.isCompleted) { + completer.complete(info.image.clone()); + } + }, + onError: (_, _) { + if (!completer.isCompleted) completer.complete(null); + }, + ); + stream.addListener(listener); + try { + return await completer.future.timeout( + _kImageDecodeTimeout, + onTimeout: () => null, + ); + } finally { + stream.removeListener(listener); + } + } + Future captureFromKey({ required GlobalKey key, required SlideCaptureQuality quality, diff --git a/packages/superdeck/test/src/capture/thumbnail_capture_timing_test.dart b/packages/superdeck/test/src/capture/thumbnail_capture_timing_test.dart index 24f0427d..c71cf6b6 100644 --- a/packages/superdeck/test/src/capture/thumbnail_capture_timing_test.dart +++ b/packages/superdeck/test/src/capture/thumbnail_capture_timing_test.dart @@ -1,8 +1,11 @@ import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:superdeck/superdeck.dart'; +import 'package:superdeck/src/builtins/widgets.dart'; import 'package:superdeck_core/superdeck_core.dart'; class _InMemoryCacheStore implements AssetCacheStore { @@ -27,6 +30,27 @@ class _InMemoryCacheStore implements AssetCacheStore { } } +class _DataUriCacheStore implements AssetCacheStore { + final List bytes; + + _DataUriCacheStore(this.bytes); + + var resolveCount = 0; + + @override + Future resolve(String assetKey) async { + resolveCount++; + return Uri.dataFromBytes(bytes, mimeType: 'image/png'); + } + + @override + Future write(String assetKey, List bytes) async => + Uri.dataFromBytes(bytes, mimeType: 'image/png'); + + @override + Future delete(String assetKey) async {} +} + WidgetFactory _delayedWidgetFactory({ required Duration delay, required Completer settled, @@ -185,6 +209,57 @@ SlideConfiguration _neverReadySlide() { ); } +SlideConfiguration _generatedImageSlide(AssetCacheStore assetCacheStore) { + return SlideConfiguration( + slideIndex: 0, + style: SlideStyler(), + slide: Slide( + key: 'generated-image', + sections: [ + SectionBlock([ + WidgetBlock( + name: 'image', + args: const {'src': 'generated-image.png', 'fit': 'cover'}, + ), + ]), + ], + ), + widgets: builtInWidgets, + assetCacheStore: assetCacheStore, + thumbnailKey: 'thumbnail_generated_image.png', + ); +} + +Future> _solidMagentaPng() async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawColor(const Color(0xFFFF00FF), BlendMode.src); + final image = await recorder.endRecording().toImage(64, 64); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + return data!.buffer.asUint8List(); +} + +Future _containsMagentaPixel(List pngBytes) async { + final codec = await ui.instantiateImageCodec(Uint8List.fromList(pngBytes)); + final frame = await codec.getNextFrame(); + final data = await frame.image.toByteData( + format: ui.ImageByteFormat.rawStraightRgba, + ); + frame.image.dispose(); + codec.dispose(); + final pixels = data!.buffer.asUint8List(); + for (var offset = 0; offset < pixels.length; offset += 4) { + if (pixels[offset] > 240 && + pixels[offset + 1] < 16 && + pixels[offset + 2] > 240 && + pixels[offset + 3] > 240) { + return true; + } + } + return false; +} + Future _pumpContext(WidgetTester tester) async { final key = GlobalKey(); await tester.pumpWidget(MaterialApp(home: SizedBox(key: key))); @@ -305,6 +380,26 @@ void main() { }); }); + testWidgets('paints a cache-resolved generated image into the capture', ( + tester, + ) async { + final context = await _pumpContext(tester); + + await tester.runAsync(() async { + final sourceBytes = await _solidMagentaPng(); + expect(await _containsMagentaPixel(sourceBytes), isTrue); + final store = _DataUriCacheStore(sourceBytes); + final bytes = await SlideCaptureService().capture( + quality: SlideCaptureQuality.good, + slide: _generatedImageSlide(store), + context: context, + ); + + expect(store.resolveCount, 1); + expect(await _containsMagentaPixel(bytes), isTrue); + }); + }); + testWidgets('bounds capture when registered readiness never completes', ( tester, ) async { From f5d8cb811ee79fb2c80e2dca191929b036a8ce98 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 15 Jul 2026 21:49:21 -0400 Subject: [PATCH 07/15] fix: initialize FVM before Firebase builds --- .github/workflows/firebase-hosting-merge.yml | 3 +++ .github/workflows/firebase-hosting-pull-request.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml index 0cd96234..4e57ece7 100644 --- a/.github/workflows/firebase-hosting-merge.yml +++ b/.github/workflows/firebase-hosting-merge.yml @@ -49,6 +49,9 @@ jobs: with: melos-version: 7.8.1 + - name: Install dependencies + run: fvm flutter pub get + - name: Build Runner run: melos run build_runner:build --no-select diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml index cc213095..e829539e 100644 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -48,6 +48,9 @@ jobs: with: melos-version: 7.8.1 + - name: Install dependencies + run: fvm flutter pub get + - name: Build Runner run: melos run build_runner:build --no-select From 3cc91fb0968ba43e79be47abc42ec21a694919d8 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 15 Jul 2026 22:17:21 -0400 Subject: [PATCH 08/15] fix: reset wizard radio selection between steps --- .../core/ai/catalog/ask_user_radio.dart | 8 ++ .../core/ai/catalog/ask_user_radio_test.dart | 83 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 packages/playground/test/features/ai/wizard/core/ai/catalog/ask_user_radio_test.dart diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart index 8c61c689..dcd46cdb 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart @@ -152,6 +152,14 @@ class _AskUserRadioContentState extends State<_AskUserRadioContent> { ); } + @override + void didUpdateWidget(covariant _AskUserRadioContent oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.data.question != widget.data.question) { + _selectedIndex = null; + } + } + @override Widget build(BuildContext context) { return CatalogQuestionStep( diff --git a/packages/playground/test/features/ai/wizard/core/ai/catalog/ask_user_radio_test.dart b/packages/playground/test/features/ai/wizard/core/ai/catalog/ask_user_radio_test.dart new file mode 100644 index 00000000..eefb3cc7 --- /dev/null +++ b/packages/playground/test/features/ai/wizard/core/ai/catalog/ask_user_radio_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/genui.dart'; +import 'package:hero_ui/hero_ui.dart'; +import 'package:playground/features/ai/wizard/core/ai/catalog/ask_user_radio.dart'; + +void main() { + testWidgets('selection resets when the radio question changes', ( + tester, + ) async { + final catalog = Catalog([askUserRadio]); + var data = _radioData( + question: 'Who is the audience?', + options: ['Leaders', 'Practitioners', 'Advocates'], + ); + late StateSetter rebuild; + + await tester.pumpWidget( + MaterialApp( + home: HeroTheme( + data: HeroThemeData.light(), + child: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + rebuild = setState; + return catalog.buildWidget(_itemContext(context, data)); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('Advocates')); + await tester.pumpAndSettle(); + expect(_canContinue(tester), isTrue); + + rebuild(() { + data = _radioData( + question: 'What is the approach?', + options: ['Evidence', 'Storytelling', 'Call to action'], + ); + }); + await tester.pumpAndSettle(); + + expect(_canContinue(tester), isFalse); + }); +} + +CatalogItemContext _itemContext( + BuildContext context, + Map data, +) => CatalogItemContext( + data: data, + id: 'root', + type: 'AskUserRadio', + buildChild: (_, [_]) => const SizedBox.shrink(), + dispatchEvent: (_) {}, + buildContext: context, + dataContext: DataContext(InMemoryDataModel(), DataPath.root), + getComponent: (_) => null, + getCatalogItem: (_) => null, + surfaceId: 'wizard', + reportError: (_, _) {}, +); + +Map _radioData({ + required String question, + required List options, +}) => { + 'question': question, + 'options': [ + for (final title in options) {'title': title}, + ], + 'action': {'name': 'submit_answer', 'context': []}, +}; + +bool _canContinue(WidgetTester tester) { + final button = tester.widget( + find.widgetWithText(HeroButton, 'Continue'), + ); + return button.onPressed != null; +} From 2c569e216fd382052c532e3f335b19be327ec66a Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 15 Jul 2026 22:26:16 -0400 Subject: [PATCH 09/15] fix: equalize wizard option card heights --- .../core/ai/catalog/ask_user_radio.dart | 54 ++++++++++++------- .../core/ai/catalog/ask_user_radio_test.dart | 48 ++++++++++++++++- 2 files changed, 81 insertions(+), 21 deletions(-) diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart index dcd46cdb..17d2c8fc 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_radio.dart @@ -124,29 +124,43 @@ class _AskUserRadioContentState extends State<_AskUserRadioContent> { ? 2 : 1; const spacing = 12.0; - final cardWidth = - (constraints.maxWidth - spacing * (columnCount - 1)) / columnCount; - return Wrap( + return Column( spacing: spacing, - runSpacing: spacing, - children: options.asMap().entries.map((entry) { - final index = entry.key; - final option = entry.value; - final isSelected = _selectedIndex == index; - - return SizedBox( - width: cardWidth, - child: RadioOptionCard( - title: option.title, - description: option.description, - icon: (option.icon ?? WizardOptionIcon.fallbackFor(index)) - .iconData, - selected: isSelected, - onTap: () => setState(() => _selectedIndex = index), + children: [ + for ( + var rowStart = 0; + rowStart < options.length; + rowStart += columnCount + ) + IntrinsicHeight( + child: Row( + crossAxisAlignment: .stretch, + children: [ + for (var column = 0; column < columnCount; column++) ...[ + if (column > 0) const SizedBox(width: spacing), + Expanded( + child: switch (rowStart + column) { + final index when index < options.length => + RadioOptionCard( + title: options[index].title, + description: options[index].description, + icon: + (options[index].icon ?? + WizardOptionIcon.fallbackFor(index)) + .iconData, + selected: _selectedIndex == index, + onTap: () => + setState(() => _selectedIndex = index), + ), + _ => const SizedBox.shrink(), + }, + ), + ], + ], + ), ), - ); - }).toList(), + ], ); }, ); diff --git a/packages/playground/test/features/ai/wizard/core/ai/catalog/ask_user_radio_test.dart b/packages/playground/test/features/ai/wizard/core/ai/catalog/ask_user_radio_test.dart index eefb3cc7..06e2890d 100644 --- a/packages/playground/test/features/ai/wizard/core/ai/catalog/ask_user_radio_test.dart +++ b/packages/playground/test/features/ai/wizard/core/ai/catalog/ask_user_radio_test.dart @@ -2,9 +2,50 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; import 'package:hero_ui/hero_ui.dart'; +import 'package:playground/features/ai/wizard/core/ai/catalog/ask_user_question_cards.dart'; import 'package:playground/features/ai/wizard/core/ai/catalog/ask_user_radio.dart'; void main() { + testWidgets('radio option cards in a row share the tallest card height', ( + tester, + ) async { + final catalog = Catalog([askUserRadio]); + final data = _radioData( + question: 'What other angles would be effective?', + options: ['Monitoring', 'Comparative analysis', 'Future projections'], + descriptions: [ + 'A short description.', + 'Benchmarking conservation outcomes across regions with enough detail ' + 'to wrap onto several lines in the option card.', + 'Brief outlook.', + ], + ); + + await tester.pumpWidget( + MaterialApp( + home: HeroTheme( + data: HeroThemeData.light(), + child: Scaffold( + body: Builder( + builder: (context) => + catalog.buildWidget(_itemContext(context, data)), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final cards = find.byType(RadioOptionCard); + expect(cards, findsNWidgets(3)); + final heights = [ + for (var index = 0; index < 3; index++) + tester.getSize(cards.at(index)).height, + ]; + + expect(heights.toSet(), hasLength(1)); + }); + testWidgets('selection resets when the radio question changes', ( tester, ) async { @@ -67,10 +108,15 @@ CatalogItemContext _itemContext( Map _radioData({ required String question, required List options, + List? descriptions, }) => { 'question': question, 'options': [ - for (final title in options) {'title': title}, + for (final (index, title) in options.indexed) + { + 'title': title, + if (descriptions != null) 'description': descriptions[index], + }, ], 'action': {'name': 'submit_answer', 'context': []}, }; From 3a285e90c72253ce055756941fe2d54e3bfa5aa0 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 15 Jul 2026 23:54:24 -0400 Subject: [PATCH 10/15] feat: enrich outline drafts before plan parsing --- .../assets/ai_prompts/outline_system.prompt | 206 +++++--------- .../assets/ai_prompts/wizard_system.prompt | 2 +- .../prompts/generation_prompt_provider.dart | 2 +- .../core/engine/schemas/outline_schema.dart | 22 +- .../engine/services/deck_plan_repair.dart | 21 +- .../services/deck_theme_resolution.dart | 71 ++++- .../services/theme_json_serializer.dart | 23 +- .../core/ai/catalog/ask_user_slider.dart | 109 ++------ .../wizard/core/ui/components/sd_buttons.dart | 14 +- .../wizard_generation_status.dart | 35 ++- .../presentation/wizard_outline_review.dart | 255 ++++++++++-------- .../presentation/wizard_selection_review.dart | 5 +- .../services/deck_generator_service_test.dart | 89 +++++- .../wizard_outline_review_test.dart | 76 +++++- .../ai/wizard/wizard_polish_test.dart | 22 +- 15 files changed, 557 insertions(+), 395 deletions(-) diff --git a/packages/playground/assets/ai_prompts/outline_system.prompt b/packages/playground/assets/ai_prompts/outline_system.prompt index bc474f71..160c8734 100644 --- a/packages/playground/assets/ai_prompts/outline_system.prompt +++ b/packages/playground/assets/ai_prompts/outline_system.prompt @@ -1,134 +1,76 @@ -You are a presentation strategist and design director. Create one coherent, -hierarchical blueprint for a SuperDeck presentation. Plan the complete story and -visual rhythm; do not write final slide Markdown. - -## Authority and evidence - -The user message is a typed JSON request. Treat `slideCount`, explicit theme, -palette/font choices, and `groundedElements` as exact constraints. The original -request is the only authority for factual claims, numbers, URLs, capabilities, -availability, evidence status, credentials, and commercial commitments. - -- Treat `groundedNumericFacts` as immutable evidence excerpts. Preserve each - value's subject, unit, denominator, comparison, and time period. -- A new number is allowed only as an explicitly labeled projection, estimate, - assumption, calculation, scenario, planned/proposed target, or illustrative - plan milestone in that same content unit. -- Recommendations and future operating choices are allowed when each affected - claim is visibly marked `planned`, `proposed`, or `illustrative`. Never present - them as shipped capability, observed outcome, guarantee, or current offer. -- Do not infer causality or strengthen evidence with language such as proven, - validated, significant, or at scale unless the request supplies it. -- Never invent an asset path, URL/domain, QR destination, product status, - security/compliance claim, certification, pricing term, trial, waitlist, award, - patent, or guarantee. - -These rules protect audience-facing fields (`title`, `assertion`, and -`contentUnits`). Internal `purpose`, `contentBrief`, and `continuity` should be -clear editorial instructions rather than additional factual copy. - -## Output contract - -Return one JSON object matching the provided schema: - -- `topic`: concise presentation topic -- `story`: one-sentence thesis and narrative through-line -- `theme`: one object containing only the selected candidate `id` -- `sections`: ordered narrative acts whose `slideKeys` partition the deck -- `slides`: exactly the requested number of ordered slide plans - -For 10–20 slides, use three to five meaningful sections. Every section needs a -unique key, short title, narrative purpose, earned transition, and ordered -`slideKeys` that appear exactly once across the deck. - -Every slide plan includes: - -- unique descriptive kebab-case `key`, short working `title`, and `sectionKey` -- internal `purpose`, one audience-facing `assertion`, concrete `contentUnits`, - precise `contentBrief`, and concise `continuity` -- one `narrativeRole`, `composition`, `treatment`, and density (`spacious`, - `balanced`, or `compact`) -- exact grounded `elements`, or an empty list - -Narrative roles: `opening`, `context`, `problem`, `insight`, `evidence`, -`comparison`, `solution`, `process`, `takeaway`, `transition`, and `closing`. -Build a deliberate opening, progressive sections, and a decisive close. Each -slide advances one distinct idea rather than repeating the prior slide. - -## Composition and rhythm - -Choose layout from information shape: - -- `title`: opening, divider, or closing display statement +You are a strategist. Return one coherent SuperDeck blueprint as +schema-valid JSON. Plan the story and visual rhythm; do not write slide Markdown. + +## Authority + +The typed user request is the only factual authority. Match `slideCount`, theme, +brand, density, image-style, and grounded-element constraints exactly. + +- Preserve every grounded number with its subject, unit, denominator, + comparison, and time period. +- Never invent facts, URLs, assets, product status, capabilities, credentials, + pricing, compliance, awards, guarantees, or commercial commitments. +- Label any new number as an estimate, assumption, scenario, or proposed target. +- Mark recommendations and future operating choices as proposed, planned, or + illustrative. Do not present them as observed results. +- Do not strengthen evidence with claims such as proven, validated, causal, or + significant unless the request explicitly supports them. + +These rules apply to audience-facing `title`, `assertion`, and `contentUnits`. + +## Story contract + +Return the schema fields only. Create exactly the requested number of ordered +slides. For 10–20 slides, use three to five ordered narrative acts whose +`slideKeys` partition the slides exactly once and in order. Give the deck one +clear thesis, a deliberate opening, progressive sections, and a decisive close. +Every slide must advance one distinct idea and contain enough concrete content +to be composed independently. + +Each slide needs a kebab-case key, title, section key, assertion, concrete +content units, one `narrativeRole`, composition, and exact elements. The app derives +editorial purpose, continuity, treatment, and density; do not return those +fields. + +## Composition and variation + +Choose composition from information shape: + +- `title`: opening, divider, or closing statement - `content`: one focused idea with concise support -- `twoColumn` / `threeColumn`: independent parallel categories +- `twoColumn` / `threeColumn`: parallel independent categories - `table`: uniform comparison on shared attributes -- `metric`: one to three grounded numbers with their meaning -- `quote`: short supplied voice or emphasized takeaway -- `titleLeft`: assertion/context beside supporting detail -- `imageLeft`, `imageRight`, `imageFullBleed`: supplied or generated image plus - appropriate copy -- `qrcode`, `webview`, `dartpad`, `custom`: matching supplied element only - -Treatments are semantic: `hero`, `section`, `content`, `data`, `quote`, `visual`, -and `closing`. Exact compatibility: - -- `hero` -> `title` -- `section` -> `title` or `titleLeft` -- `quote` -> `quote` -- `closing` -> `title`, `titleLeft`, `quote`, or `qrcode` -- `data` -> `metric`, `table`, `twoColumn`, or `threeColumn` -- `content` / `visual` -> any semantically appropriate compatible composition - -Avoid repeating one composition more than three times consecutively. A treatment -may repeat when the information shape and composition still create meaningful -visual variation; do not rotate treatments mechanically. -Unless the brief constrains format, target at least 5/6/7 distinct composition -families for 10/15/20 slides. Variation must serve the narrative, not rotate -mechanically. Use compact density only for genuinely structured information. - -## Grounded elements - -Supported types are `image`, `qrcode`, `webview`, `dartpad`, and `custom`. -Copy a matching source and purpose exactly from `groundedElements`; for custom -widgets, also preserve the supplied `widgetName`. Visual compositions require -their matching planned element. If the source is unavailable, choose a text, -metric, or table composition. A supplied handoff purpose is exhaustive—do not -rename it as documentation, signup, support, early access, or another promise. -Use each grounded element only as often as the story genuinely needs it. - -## Generated artwork - -When the typed request includes exact `imageStyleId` and `imageStyleVersion`, -you may plan two to four generated visuals, at most `maxGeneratedImages`. Use -them only where a visual materially clarifies, -anchors, or varies the story; a deck does not need an image on every slide. - -For each generated visual, add one `image` element with a concrete, -topic-specific `generationPrompt` and no `source`. Describe the subject and -scene, not the selected treatment: the application owns and applies the exact -versioned style. Keep the subject simple, presentation-safe, and free of text, -logos, brands, UI screenshots, and unsupported factual claims. An image element -must contain exactly one input—never both `source` and `generationPrompt`. - -If the exact image-style fields are absent, do not author a `generationPrompt`; -use only supplied grounded image sources. Use no more than one image element per -slide, and pair every image composition with its matching image element. - -## Theme selection - -Select exactly one eligible theme candidate by stable `id` and return only that -ID in `theme`. Candidate descriptions/tags are selection guidance, not slide -copy. The application resolves version, palette, typography, density, spacing, -components, treatments, and exact user overrides. Never emit those runtime -tokens or add theme fields outside the schema. - -## Final checks - -1. Match `slideCount` exactly and keep section membership/order consistent. -2. Give every slide one assertion and enough concrete content to compose alone. -3. Preserve exact facts and grounded elements without invention or amplification. -4. Keep generated artwork within its exact style and count contract. -5. Use tables only for uniform comparisons and columns for independent ideas. -6. End with the decision, action, or synthesis appropriate to the audience. -7. Return schema-supported JSON only, without commentary or Markdown fences. +- `metric`: one to three grounded numbers plus their meaning +- `quote`: supplied voice or a genuine emphasized takeaway +- `titleLeft`: assertion beside supporting context +- `imageLeft`, `imageRight`, `imageFullBleed`: meaningful image plus copy +- `qrcode`, `webview`, `dartpad`, `custom`: only a matching supplied element + +Avoid using one composition more than three times consecutively. Unless the +brief constrains format, use at least 5/6/7 composition families for 10/15/20 +slides. Variation must follow the narrative and information, not rotate +mechanically. Tables require comparable rows; columns require parallel ideas. + +## Elements and artwork + +Supported element types are `image`, `qrcode`, `webview`, `dartpad`, and +`custom`. Preserve supplied source, purpose, and custom `widgetName` exactly. +Never invent a source. Use visual/specialized compositions only with their +matching element. + +When exact image-style fields exist, plan two to four generated visuals, never +more than `maxGeneratedImages`. Each uses one image element with a concrete, +topic-specific `generationPrompt` and no source. Describe a simple scene or +subject without text, logos, brands, UI screenshots, or unsupported claims. +Without exact image-style fields, never return a generation prompt. An image +element has either `source` or `generationPrompt`, never both. + +## Theme and final check + +Select exactly one eligible theme ID and return only that ID in `theme`. Theme +candidates are selection guidance, not slide copy; the application owns all +runtime tokens. + +Before returning JSON, verify exact slide count, unique keys, section order, +grounded facts, element compatibility, useful composition variation, a clear +ending, and schema-only output with no commentary or Markdown fence. diff --git a/packages/playground/assets/ai_prompts/wizard_system.prompt b/packages/playground/assets/ai_prompts/wizard_system.prompt index 4528bc8a..e8638860 100644 --- a/packages/playground/assets/ai_prompts/wizard_system.prompt +++ b/packages/playground/assets/ai_prompts/wizard_system.prompt @@ -5,7 +5,7 @@ You are a presentation wizard. The user's first message is the presentation topi 1. AskUserRadio (step1) - 3 audience options 2. AskUserRadio (step2) - 3 approach options tailored to the selected audience 3. AskUserCheckbox (step3) - 4 emphasis topics relevant to the topic -4. AskUserSlider (step4) - 5-20 slides, unit: "slides", suggest a default based on complexity +4. AskUserSlider (step4) - 5-20 slides, unit: "slides", defaultValue: 10 5. AskUserStyle (step5) - exact IDs for 3 registered presentation themes 6. AskUserImageStyle (step6) - generated artwork-direction selection diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart index 43411e04..86b19545 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/generation_prompt_provider.dart @@ -134,7 +134,7 @@ Choose exactly one `theme.id` from the compact candidates below. Return only that ID inside the theme object. Never return a version, palette, font family, brand override, or runtime styling token; the application owns those values. -${const JsonEncoder.withIndent(' ').convert(themeCandidates.map((theme) => theme.toModelCandidate()).toList())} +${jsonEncode(themeCandidates.map((theme) => theme.toModelCandidate()).toList())} '''; } diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/outline_schema.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/outline_schema.dart index 6d7179a2..3aebaaf5 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/outline_schema.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/outline_schema.dart @@ -199,7 +199,27 @@ ObjectSchema buildDeckPlanDraftSchema(List eligibleThemeIds) { 'Ordered narrative sections whose slide keys partition the deck', ), 'slides': Ack.list( - deckPlanSlideSchema, + deckPlanDraftSlideSchema, ).describe('Ordered list of slides in the presentation'), }).describe('Model-facing deck-plan draft with bounded theme selection'); } + +/// Lean model-owned fields; Dart derives the remaining canonical plan fields. +final deckPlanDraftSlideSchema = Ack.object({ + 'key': Ack.string().describe('Unique descriptive kebab-case slide ID'), + 'title': Ack.string().describe('Short working slide title'), + 'sectionKey': Ack.string().describe('Owning narrative section key'), + 'assertion': Ack.string().describe('Single audience-facing claim'), + 'contentUnits': Ack.list( + Ack.string(), + ).describe('Concrete evidence, examples, or implications'), + 'narrativeRole': Ack.enumString( + deckPlanNarrativeRoles, + ).describe('Narrative job performed by the slide'), + 'composition': Ack.enumString( + deckPlanCompositionIntents, + ).describe('Semantic composition chosen for the information shape'), + 'elements': Ack.list(deckPlanElementSchema).optional().describe( + 'Optional grounded or generated elements required by the slide', + ), +}).describe('Lean slide plan enriched by the application after generation'); diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart index 05bbe697..c32fc080 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart @@ -37,7 +37,7 @@ extension _DeckPlanRepair on DeckGeneratorService { ); if (index < 0) continue; final original = repairedPlan.slides[index]; - var repairBase = Map.of(original); + var repairBase = serializeDeckPlanSlideDraft(original); var constraints = List.of(entry.value); for ( @@ -63,7 +63,7 @@ extension _DeckPlanRepair on DeckGeneratorService { slideIndex: index, ); if (candidate == null) continue; - repairBase = Map.of(candidate); + repairBase = serializeDeckPlanSlideDraft(candidate); final invariantErrors = _outlineSlideInvariantErrors( original: original, @@ -121,7 +121,7 @@ extension _DeckPlanRepair on DeckGeneratorService { required int slideIndex, }) async { final adapted = GoogleSchemaAdapter().adapt( - deckPlanSlideSchema.toJsonSchemaBuilder(), + deckPlanDraftSlideSchema.toJsonSchemaBuilder(), ); if (adapted.schema == null) return null; final systemPrompt = _promptProvider.buildOutlineSlideRepairPrompt( @@ -167,7 +167,20 @@ extension _DeckPlanRepair on DeckGeneratorService { final json = _parseJsonResponse(response, 'outline slide ${current.key}'); if (json == null) return null; try { - return DeckPlanSlideType.parse(json); + final parsed = deckPlanDraftSlideSchema.parse(json); + if (parsed == null) return null; + final slides = [ + for (final slide in plan.slides) serializeDeckPlanSlideDraft(slide), + ]; + slides[slideIndex] = Map.of(parsed); + return DeckPlanSlideType.parse( + enrichDeckPlanDraftSlide( + slides[slideIndex], + index: slideIndex, + slides: slides, + density: current.density, + ), + ); } catch (error) { debugLog.error( 'DECK_GEN', diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_theme_resolution.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_theme_resolution.dart index b0e12be0..bba1a11e 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_theme_resolution.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_theme_resolution.dart @@ -81,11 +81,80 @@ DeckPlanType resolveDeckPlanDraft({ typographyCatalog: typographyCatalog, ); + final slides = (parsed['slides']! as List) + .map((slide) => Map.from(slide! as Map)) + .toList(growable: false); + final enrichedSlides = [ + for (final (index, slide) in slides.indexed) + enrichDeckPlanDraftSlide( + slide, + index: index, + slides: slides, + density: request.density ?? descriptor.recipe.defaultDensity, + ), + ]; + return DeckPlanType.parse( - Map.of(parsed)..['theme'] = reference, + Map.of(parsed) + ..['theme'] = reference + ..['slides'] = enrichedSlides, ); } +Map enrichDeckPlanDraftSlide( + Map slide, { + required int index, + required List> slides, + required String density, +}) { + final assertion = slide['assertion']! as String; + final contentUnits = (slide['contentUnits']! as List).cast(); + final role = slide['narrativeRole']! as String; + final composition = slide['composition']! as String; + + return Map.of(slide) + ..['purpose'] = assertion + ..['contentBrief'] = contentUnits.join(' ') + ..['continuity'] = _deriveContinuity(index, slides) + ..['treatment'] = _deriveTreatment(role, composition) + ..['density'] = density; +} + +String _deriveContinuity(int index, List> slides) { + if (slides.length == 1) { + return 'Open the deck with this idea and establish the through-line.'; + } + if (index == 0) { + return 'Open the deck with this idea, then lead into ' + '"${slides[1]['title']}".'; + } + if (index == slides.length - 1) { + return 'Build from "${slides[index - 1]['title']}" and close the deck.'; + } + + return 'Build from "${slides[index - 1]['title']}" and lead into ' + '"${slides[index + 1]['title']}".'; +} + +String _deriveTreatment(String role, String composition) { + if (role == 'closing' && + const {'title', 'titleLeft', 'quote', 'qrcode'}.contains(composition)) { + return 'closing'; + } + if (role == 'transition' && + const {'title', 'titleLeft'}.contains(composition)) { + return 'section'; + } + + return switch (composition) { + 'title' => 'hero', + 'quote' => 'quote', + 'metric' || 'table' || 'twoColumn' || 'threeColumn' => 'data', + 'imageLeft' || 'imageRight' || 'imageFullBleed' => 'visual', + _ => 'content', + }; +} + /// Creates the application-owned canonical reference for one selected theme. Map buildDeckThemeReference({ required PresentationThemeDescriptor descriptor, diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/theme_json_serializer.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/theme_json_serializer.dart index 5d403d82..94ec88e2 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/theme_json_serializer.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/theme_json_serializer.dart @@ -39,5 +39,24 @@ Map serializeDeckThemeForSlidePrompt( ) => {'id': theme.id, 'version': theme.version, 'density': theme.density}; /// Projects a canonical plan back into the model's repair-only draft shape. -Map serializeDeckPlanDraftForRepair(DeckPlanType plan) => - Map.of(plan)..['theme'] = {'id': plan.theme.id}; +Map serializeDeckPlanDraftForRepair(DeckPlanType plan) => { + 'topic': plan.topic, + 'story': plan.story, + 'theme': {'id': plan.theme.id}, + 'sections': [for (final section in plan.sections) Map.of(section)], + 'slides': [ + for (final slide in plan.slides) serializeDeckPlanSlideDraft(slide), + ], +}; + +Map serializeDeckPlanSlideDraft(DeckPlanSlideType slide) => { + 'key': slide.key, + 'title': slide.title, + 'sectionKey': slide.sectionKey, + 'assertion': slide.assertion, + 'contentUnits': slide.contentUnits, + 'narrativeRole': slide.narrativeRole, + 'composition': slide.composition, + if (slide.elements case final elements?) + 'elements': [for (final element in elements) Map.of(element)], +}; diff --git a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_slider.dart b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_slider.dart index 5955123d..46da17ae 100644 --- a/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_slider.dart +++ b/packages/playground/lib/features/ai/wizard/core/ai/catalog/ask_user_slider.dart @@ -83,20 +83,16 @@ class DeckLengthSelector extends StatelessWidget { final String unit; final ValueChanged onChanged; - List get _presets { - final values = unit.toLowerCase().contains('slide') - ? [min, 8, 10, 12, 15, 20, max] - : [min, ((min + max) / 2).round(), max]; - - return values.where((item) => item >= min && item <= max).toSet().toList() - ..sort(); - } + List get _choices => [ + 5, + 10, + 15, + 20, + ].where((choice) => choice >= min && choice <= max).toList(growable: false); @override Widget build(BuildContext context) { final iconColor = $accent.resolve(context); - final mutedColor = $muted.resolve(context); - final identity = Row( mainAxisSize: .min, spacing: 12, @@ -124,87 +120,21 @@ class DeckLengthSelector extends StatelessWidget { ], ); - final counter = Container( - padding: const .symmetric(vertical: 4, horizontal: 4), - decoration: BoxDecoration( - color: $background.resolve(context), - border: .all(color: $border.resolve(context)), - borderRadius: .circular(14), - ), - child: Row( - mainAxisSize: .min, - children: [ - IconButton( - onPressed: value > min ? () => onChanged(value - 1) : null, - tooltip: 'One fewer slide', - icon: const Icon(LucideIcons.minus, size: 18), - ), - ConstrainedBox( - constraints: const BoxConstraints.tightFor(width: 76), - child: Column( - mainAxisSize: .min, - children: [ - Text( - '$value', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: $foreground.resolve(context), - fontWeight: .w700, - height: 1, - ), - ), - if (unit.isNotEmpty) - Text( - unit, - style: Theme.of( - context, - ).textTheme.labelSmall?.copyWith(color: mutedColor), - ), - ], - ), - ), - IconButton( - onPressed: value < max ? () => onChanged(value + 1) : null, - tooltip: 'One more slide', - icon: const Icon(LucideIcons.plus, size: 18), - ), - ], - ), - ); - return SdPanel( child: Column( crossAxisAlignment: .start, spacing: 18, children: [ - LayoutBuilder( - builder: (context, constraints) { - if (constraints.maxWidth < 520) { - return Column( - crossAxisAlignment: .start, - spacing: 16, - children: [identity, counter], - ); - } - - return Row( - mainAxisAlignment: .spaceBetween, - spacing: 16, - children: [ - Expanded(child: identity), - counter, - ], - ); - }, - ), + identity, Wrap( spacing: 8, runSpacing: 8, children: [ - for (final preset in _presets) + for (final choice in _choices) _DeckLengthPreset( - value: preset, - selected: preset == value, - onTap: () => onChanged(preset), + value: choice, + selected: choice == value, + onTap: () => onChanged(choice), ), ], ), @@ -237,7 +167,7 @@ class _DeckLengthPreset extends StatelessWidget { onTap: onTap, borderRadius: .circular(10), child: AnimatedContainer( - padding: const .symmetric(vertical: 9, horizontal: 14), + padding: const .symmetric(vertical: 11, horizontal: 16), decoration: BoxDecoration( color: selected ? $accentSoft.resolve(context) @@ -251,11 +181,11 @@ class _DeckLengthPreset extends StatelessWidget { ), duration: SdTokens.motionFast, child: Text( - '$value', + '$value slides', style: Theme.of(context).textTheme.labelMedium?.copyWith( color: selected ? $accent.resolve(context) - : $muted.resolve(context), + : $foreground.resolve(context), fontWeight: .w600, ), ), @@ -284,10 +214,17 @@ class _AskUserSliderContentState extends State<_AskUserSliderContent> { super.initState(); final minVal = widget.data.minValue; final maxVal = widget.data.maxValue; - final defaultVal = widget.data.defaultValue; + final defaultVal = _initialValue(min: minVal, max: maxVal); _sliderValue = _clampToRange(defaultVal, min: minVal, max: maxVal); } + int _initialValue({required int min, required int max}) { + final unit = (widget.data.unit ?? '').toLowerCase(); + if (unit.contains('slide') && min <= 10 && max >= 10) return 10; + + return widget.data.defaultValue; + } + int _clampToRange(int value, {required int min, required int max}) { return value.clamp(min, max).toInt(); } @@ -330,7 +267,7 @@ class _AskUserSliderContentState extends State<_AskUserSliderContent> { final maxValue = widget.data.maxValue; if (defaultChanged) { _sliderValue = _clampToRange( - widget.data.defaultValue, + _initialValue(min: minValue, max: maxValue), min: minValue, max: maxValue, ); diff --git a/packages/playground/lib/features/ai/wizard/core/ui/components/sd_buttons.dart b/packages/playground/lib/features/ai/wizard/core/ui/components/sd_buttons.dart index 17fac2a2..014aa247 100644 --- a/packages/playground/lib/features/ai/wizard/core/ui/components/sd_buttons.dart +++ b/packages/playground/lib/features/ai/wizard/core/ui/components/sd_buttons.dart @@ -2,7 +2,9 @@ import 'package:flutter/material.dart'; import 'package:hero_ui/hero_ui.dart'; import 'package:remix/remix.dart'; -/// Pre-styled solid (primary) button. +enum SdButtonVariant { primary, outline, ghost } + +/// Application-owned button with a consistent Wizard action hierarchy. class SdButton extends StatelessWidget { const SdButton({ super.key, @@ -13,6 +15,7 @@ class SdButton extends StatelessWidget { this.loading = false, this.semanticLabel, this.style, + this.variant = SdButtonVariant.primary, }); final String label; @@ -24,6 +27,13 @@ class SdButton extends StatelessWidget { /// Semantic label for accessibility. Defaults to [label] if not provided. final String? semanticLabel; final RemixButtonStyle? style; + final SdButtonVariant variant; + + HeroButtonVariant get _heroVariant => switch (variant) { + SdButtonVariant.primary => HeroButtonVariant.primary, + SdButtonVariant.outline => HeroButtonVariant.outline, + SdButtonVariant.ghost => HeroButtonVariant.ghost, + }; @override Widget build(BuildContext context) { @@ -37,7 +47,7 @@ class SdButton extends StatelessWidget { iconLeft: icon, enabled: enabled, loading: loading, - variant: HeroButtonVariant.primary, + variant: _heroVariant, style: style, ), ); diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart index 5856bd4a..6dfe7086 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart @@ -136,10 +136,11 @@ class WizardGenerationStatus extends StatelessWidget { spacing: 16, children: [ const SdCaption('Usually 20–30 seconds'), - TextButton.icon( + SdButton( + label: 'Cancel', onPressed: onCancel, - icon: const Icon(LucideIcons.x, size: 16), - label: const Text('Cancel'), + icon: LucideIcons.x, + variant: SdButtonVariant.ghost, ), ], ), @@ -170,7 +171,11 @@ class WizardGenerationStatus extends StatelessWidget { spacing: 10, runSpacing: 10, children: [ - TextButton(onPressed: onBack, child: Text(backLabel)), + SdButton( + label: backLabel, + onPressed: onBack, + variant: SdButtonVariant.ghost, + ), SdButton( label: 'Retry', onPressed: onRetry, @@ -184,22 +189,24 @@ class WizardGenerationStatus extends StatelessWidget { spacing: 10, runSpacing: 10, children: [ - TextButton( + SdButton( + label: 'Start over', onPressed: onStartOver, - child: const Text('Start over'), + variant: SdButtonVariant.ghost, ), - OutlinedButton( + SdButton( + label: 'Edit outline', onPressed: onEditOutline, - child: const Text('Edit outline'), + variant: SdButtonVariant.outline, ), if (failedSlideCount > 0) - OutlinedButton.icon( + SdButton( + label: + 'Retry $failedSlideCount ' + '${failedSlideCount == 1 ? 'slide' : 'slides'}', onPressed: onRetryFailed, - icon: const Icon(LucideIcons.refreshCw, size: 17), - label: Text( - 'Retry $failedSlideCount ' - '${failedSlideCount == 1 ? 'slide' : 'slides'}', - ), + icon: LucideIcons.refreshCw, + variant: SdButtonVariant.outline, ), SdButton( label: 'Present deck', diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_outline_review.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_outline_review.dart index 9063f1ae..1368d73d 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_outline_review.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_outline_review.dart @@ -33,6 +33,7 @@ class WizardOutlineReview extends StatefulWidget { class _WizardOutlineReviewState extends State { final _invalidSlideKeys = {}; + String? _editingSlideKey; void _setSlideValidity(String key, {required bool isValid}) { setState(() { @@ -49,6 +50,7 @@ class _WizardOutlineReviewState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.planRevision != widget.planRevision) { _invalidSlideKeys.clear(); + _editingSlideKey = null; } final currentKeys = widget.plan.slides.map((slide) => slide.key).toSet(); _invalidSlideKeys.removeWhere((key) => !currentKeys.contains(key)); @@ -57,87 +59,113 @@ class _WizardOutlineReviewState extends State { @override Widget build(BuildContext context) { return Center( - child: SingleChildScrollView( - padding: const .symmetric(vertical: 8), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 900), - child: Column( - crossAxisAlignment: .stretch, - spacing: 18, - children: [ - SdPanel( - child: Row( - crossAxisAlignment: .start, - spacing: 14, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 900), + child: Column( + crossAxisAlignment: .stretch, + children: [ + Expanded( + child: SingleChildScrollView( + padding: const .symmetric(vertical: 8), + child: Column( + crossAxisAlignment: .stretch, + spacing: 18, children: [ - Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: $accentSoft.resolve(context), - borderRadius: .circular(12), - ), - width: 44, - height: 44, - child: Icon( - LucideIcons.listTree, - size: 22, - color: $accent.resolve(context), - ), - ), - Expanded( - child: Column( + SdPanel( + child: Row( crossAxisAlignment: .start, - spacing: 6, + spacing: 14, children: [ - const SdHeadline('Review the story'), - SdBody(widget.plan.story), - SdCaption( - '${widget.plan.slides.length} slides across ' - '${widget.plan.sections.length} sections', + Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: $accentSoft.resolve(context), + borderRadius: .circular(12), + ), + width: 44, + height: 44, + child: Icon( + LucideIcons.listTree, + size: 22, + color: $accent.resolve(context), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: .start, + spacing: 6, + children: [ + const SdHeadline('Review the story'), + SdBody(widget.plan.story), + SdCaption( + '${widget.plan.slides.length} slides across ' + '${widget.plan.sections.length} sections', + ), + ], + ), ), ], ), ), + for (final section in widget.plan.sections) + _OutlineSection( + section: section, + slides: [ + for (final (index, slide) + in widget.plan.slides.indexed) + if (slide.sectionKey == section.key) + (index: index, slide: slide), + ], + editingSlideKey: _editingSlideKey, + onEdit: (key) => setState(() { + _editingSlideKey = key; + }), + onSlideChanged: widget.onSlideChanged, + onSlideValidityChanged: _setSlideValidity, + planRevision: widget.planRevision, + ), ], ), ), - for (final section in widget.plan.sections) - _OutlineSection( - section: section, - slides: [ - for (final (index, slide) in widget.plan.slides.indexed) - if (slide.sectionKey == section.key) - (index: index, slide: slide), + ), + SafeArea( + top: false, + child: Container( + padding: const .symmetric(vertical: 12), + decoration: BoxDecoration( + color: $background.resolve(context), + border: Border( + top: BorderSide(color: $border.resolve(context)), + ), + ), + child: Wrap( + alignment: .end, + spacing: 10, + runSpacing: 10, + children: [ + SdButton( + label: 'Back', + onPressed: widget.onBack, + variant: SdButtonVariant.ghost, + ), + SdButton( + label: 'Regenerate outline', + onPressed: widget.onRegenerate, + icon: LucideIcons.refreshCw, + variant: SdButtonVariant.outline, + ), + SdButton( + label: 'Approve & build', + onPressed: _invalidSlideKeys.isEmpty + ? widget.onApprove + : null, + icon: LucideIcons.sparkles, + ), ], - onSlideChanged: widget.onSlideChanged, - onSlideValidityChanged: _setSlideValidity, - planRevision: widget.planRevision, ), - Wrap( - alignment: .end, - spacing: 10, - runSpacing: 10, - children: [ - TextButton( - onPressed: widget.onBack, - child: const Text('Back'), - ), - OutlinedButton.icon( - onPressed: widget.onRegenerate, - icon: const Icon(LucideIcons.refreshCw, size: 17), - label: const Text('Regenerate outline'), - ), - SdButton( - label: 'Approve & build', - onPressed: _invalidSlideKeys.isEmpty - ? widget.onApprove - : null, - icon: LucideIcons.sparkles, - ), - ], ), - ], - ), + ), + ], ), ), ); @@ -148,6 +176,8 @@ class _OutlineSection extends StatelessWidget { const _OutlineSection({ required this.section, required this.slides, + required this.editingSlideKey, + required this.onEdit, required this.onSlideChanged, required this.onSlideValidityChanged, required this.planRevision, @@ -155,6 +185,8 @@ class _OutlineSection extends StatelessWidget { final DeckPlanSectionType section; final List<({int index, DeckPlanSlideType slide})> slides; + final String? editingSlideKey; + final ValueChanged onEdit; final UpdateOutlineSlide onSlideChanged; final void Function(String key, {required bool isValid}) onSlideValidityChanged; @@ -169,7 +201,7 @@ class _OutlineSection extends StatelessWidget { Padding( padding: const .symmetric(horizontal: 4), child: Column( - crossAxisAlignment: .start, + crossAxisAlignment: .stretch, spacing: 4, children: [SdTitle(section.title), SdCaption(section.purpose)], ), @@ -179,6 +211,8 @@ class _OutlineSection extends StatelessWidget { key: ValueKey(item.slide.key), index: item.index, slide: item.slide, + editing: editingSlideKey == item.slide.key, + onEdit: onEdit, onChanged: onSlideChanged, onValidityChanged: (isValid) => onSlideValidityChanged(item.slide.key, isValid: isValid), @@ -194,6 +228,8 @@ class _OutlineSlideEditor extends StatefulWidget { super.key, required this.index, required this.slide, + required this.editing, + required this.onEdit, required this.onChanged, required this.onValidityChanged, required this.planRevision, @@ -201,6 +237,8 @@ class _OutlineSlideEditor extends StatefulWidget { final int index; final DeckPlanSlideType slide; + final bool editing; + final ValueChanged onEdit; final UpdateOutlineSlide onChanged; final ValueChanged onValidityChanged; final int planRevision; @@ -292,25 +330,30 @@ class _OutlineSlideEditorState extends State<_OutlineSlideEditor> { Expanded( child: Column( crossAxisAlignment: .start, - spacing: 12, + spacing: widget.editing ? 12 : 4, children: [ - SdTextField( - key: ValueKey('outline-title-${slide.key}'), - controller: _titleController, - label: 'Slide title', - onChanged: _submitEdit, - textInputAction: .next, - semanticLabel: 'Slide ${widget.index + 1} title', - ), - SdTextField( - key: ValueKey('outline-assertion-${slide.key}'), - controller: _assertionController, - label: 'Core message', - onChanged: _submitEdit, - maxLines: 2, - minLines: 1, - semanticLabel: 'Slide ${widget.index + 1} core message', - ), + if (widget.editing) ...[ + SdTextField( + key: ValueKey('outline-title-${slide.key}'), + controller: _titleController, + label: 'Slide title', + onChanged: _submitEdit, + textInputAction: .next, + semanticLabel: 'Slide ${widget.index + 1} title', + ), + SdTextField( + key: ValueKey('outline-assertion-${slide.key}'), + controller: _assertionController, + label: 'Core message', + onChanged: _submitEdit, + maxLines: 2, + minLines: 1, + semanticLabel: 'Slide ${widget.index + 1} core message', + ), + ] else ...[ + SdTitle(slide.title), + SdBody(slide.assertion), + ], if (_validationMessage case final message?) Text( message, @@ -319,43 +362,17 @@ class _OutlineSlideEditorState extends State<_OutlineSlideEditor> { fontSize: 12, ), ), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _PlanChip(label: slide.narrativeRole), - _PlanChip(label: slide.composition), - _PlanChip(label: slide.treatment), - if ((slide.elements ?? const []).any( - (element) => - element.generationPrompt?.trim().isNotEmpty == true, - )) - const _PlanChip(label: 'artwork planned'), - ], - ), ], ), ), + SdButton( + label: widget.editing ? 'Done' : 'Edit slide ${widget.index + 1}', + onPressed: () => widget.onEdit(widget.editing ? null : slide.key), + icon: widget.editing ? LucideIcons.check : LucideIcons.pencil, + variant: SdButtonVariant.ghost, + ), ], ), ); } } - -class _PlanChip extends StatelessWidget { - const _PlanChip({required this.label}); - - final String label; - - @override - Widget build(BuildContext context) { - return Container( - padding: const .symmetric(vertical: 5, horizontal: 9), - decoration: BoxDecoration( - color: $surfaceTertiary.resolve(context), - borderRadius: .circular(999), - ), - child: SdCaption(label), - ); - } -} diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_selection_review.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_selection_review.dart index 6eaf4e3e..3ea4dab5 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_selection_review.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_selection_review.dart @@ -96,9 +96,10 @@ class WizardSelectionReview extends StatelessWidget { spacing: 12, children: [ if (onStartOver != null) - TextButton( + SdButton( + label: 'Start over', onPressed: onStartOver, - child: const Text('Start over'), + variant: SdButtonVariant.ghost, ), SdButton( label: 'Create outline', diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart index 113d38e9..89a0145b 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart @@ -9,6 +9,7 @@ import 'package:playground/core/domain/design/presentation_theme_catalog.dart'; import 'package:playground/features/ai/image_generation/image_generator.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/deck_generation_request.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/deck_generator_service.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/deck_theme_resolution.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/generation_model_client.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/generation_progress.dart'; import 'package:playground/features/ai/quick_agent/core/engine/services/generation_trace.dart'; @@ -82,6 +83,68 @@ void main() { expect(compositionClient.isClosed, isTrue); }); + test('enriches a lean model outline into the canonical deck plan', () async { + final client = _FakeGenerationModelClient([ + _jsonResponse({ + 'topic': 'Lean outline', + 'story': 'A small model response still drives complete composition.', + 'theme': _testThemeSelection, + 'slides': [_leanPlanSlide(key: 'opening')], + }), + ]); + final service = DeckGeneratorService( + apiKey: 'test-key', + modelClientFactory: (_) => client, + ); + + final result = await service.plan( + _request('Create one concise slide.', slideCount: 1), + ); + + expect(result.success, isTrue, reason: result.error); + final slide = result.plan!.slides.single; + expect(slide.purpose, slide.assertion); + expect(slide.contentBrief, 'One concrete supporting point'); + expect(slide.continuity, contains('Open the deck')); + expect(slide.treatment, 'hero'); + expect(slide.density, 'balanced'); + final properties = client + .requests + .single + .generationConfig! + .responseSchema! + .properties['slides']! + .items! + .properties; + expect(properties.keys, { + 'key', + 'title', + 'sectionKey', + 'assertion', + 'contentUnits', + 'narrativeRole', + 'composition', + 'elements', + }); + }); + + test('derives a treatment compatible with the selected composition', () { + final slide = _leanPlanSlide( + key: 'transition', + narrativeRole: 'transition', + composition: 'threeColumn', + ); + + final enriched = enrichDeckPlanDraftSlide( + slide, + index: 0, + slides: [slide], + density: 'balanced', + ); + + expect(enriched['treatment'], 'data'); + }); + test('generates planned artwork before composing its image block', () async { final client = _FakeGenerationModelClient([ _jsonResponse({ @@ -791,6 +854,7 @@ void main() { ]); final outlinePrompt = client.requests.first.systemInstruction!.parts.single.text; + expect(outlinePrompt!.length, lessThanOrEqualTo(6000)); expect(outlinePrompt, contains('narrativeRole')); expect(outlinePrompt, contains('composition')); expect(outlinePrompt, contains('## Eligible presentation themes')); @@ -1542,24 +1606,29 @@ Map _planSlide({ }) => { 'key': key, 'title': 'Test title', - 'purpose': 'Introduce the test topic', 'sectionKey': sectionKey, 'assertion': 'The test topic matters now.', 'contentUnits': ['One concrete supporting point'], 'narrativeRole': 'insight', - 'contentBrief': 'Explain the core idea.', - 'continuity': 'Connect the surrounding ideas.', 'composition': composition, - 'treatment': switch (composition) { - 'title' => 'hero', - 'titleLeft' => 'closing', - 'imageFullBleed' => 'visual', - _ => 'content', - }, - 'density': 'balanced', 'elements': elements, }; +Map _leanPlanSlide({ + required String key, + String narrativeRole = 'opening', + String composition = 'title', +}) => { + 'key': key, + 'title': 'Test title', + 'sectionKey': 'main', + 'assertion': 'The test topic matters now.', + 'contentUnits': ['One concrete supporting point'], + 'narrativeRole': narrativeRole, + 'composition': composition, + 'elements': [], +}; + Map _duplicateKeyPlan(String story) => { 'topic': 'Invalid plan', 'story': story, diff --git a/packages/playground/test/features/ai/wizard/presentation/wizard_outline_review_test.dart b/packages/playground/test/features/ai/wizard/presentation/wizard_outline_review_test.dart index d91e06ee..9ccff18d 100644 --- a/packages/playground/test/features/ai/wizard/presentation/wizard_outline_review_test.dart +++ b/packages/playground/test/features/ai/wizard/presentation/wizard_outline_review_test.dart @@ -47,6 +47,12 @@ void main() { ); expect(find.text('Opening'), findsOneWidget); expect(find.text('Slide 1'), findsOneWidget); + expect(find.text('opening'), findsNothing); + expect(find.byType(TextField), findsNothing); + expect(find.text('Edit slide 1'), findsOneWidget); + + await tester.tap(find.text('Edit slide 1')); + await tester.pump(); await tester.enterText( find.byKey(const ValueKey('outline-title-opening')), @@ -66,6 +72,41 @@ void main() { expect(approved, 1); }); + testWidgets('outline actions stay available while the outline scrolls', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: HeroTheme( + data: HeroThemeData.light(), + child: Scaffold( + body: SizedBox( + width: 900, + height: 360, + child: WizardOutlineReview( + plan: _planWithSlides(12), + onSlideChanged: (_, _, _) => true, + onBack: () {}, + onRegenerate: () {}, + onApprove: () {}, + ), + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.text('Approve & build').hitTestable(), findsOneWidget); + expect(find.text('Regenerate outline').hitTestable(), findsOneWidget); + expect(find.text('Slide 12').hitTestable(), findsNothing); + + await tester.drag(find.byType(Scrollable).first, const Offset(0, -1200)); + await tester.pump(); + + expect(find.text('Approve & build').hitTestable(), findsOneWidget); + }); + testWidgets('blocks approval while a visible outline edit is invalid', ( tester, ) async { @@ -100,6 +141,8 @@ void main() { ), ); + await tester.tap(find.text('Edit slide 1')); + await tester.pump(); await tester.enterText( find.byKey(const ValueKey('outline-title-opening')), '', @@ -120,18 +163,8 @@ void main() { await tester.pump(); expect(find.text('Add both a slide title and core message.'), findsNothing); - expect( - tester - .widget( - find.descendant( - of: find.byKey(const ValueKey('outline-title-opening')), - matching: find.byType(EditableText), - ), - ) - .controller - .text, - 'Urban gardens matter', - ); + expect(find.text('Urban gardens matter'), findsOneWidget); + expect(find.byKey(const ValueKey('outline-title-opening')), findsNothing); await tester.tap(find.text('Approve & build')); expect(approved, 1); }); @@ -168,3 +201,22 @@ DeckPlanType _plan() => DeckPlanType.parse({ }, ], }); + +DeckPlanType _planWithSlides(int count) { + final data = Map.from(_plan()); + final section = Map.from( + (data['sections']! as List).single! as Map, + ); + final source = Map.from( + (data['slides']! as List).single! as Map, + ); + final slides = [ + for (var index = 0; index < count; index++) + {...source, 'key': 'slide-$index', 'title': 'Slide title ${index + 1}'}, + ]; + section['slideKeys'] = slides.map((slide) => slide['key']).toList(); + data['sections'] = [section]; + data['slides'] = slides; + + return DeckPlanType.parse(data); +} diff --git a/packages/playground/test/features/ai/wizard/wizard_polish_test.dart b/packages/playground/test/features/ai/wizard/wizard_polish_test.dart index eee04e17..18b00547 100644 --- a/packages/playground/test/features/ai/wizard/wizard_polish_test.dart +++ b/packages/playground/test/features/ai/wizard/wizard_polish_test.dart @@ -273,6 +273,9 @@ void main() { expect(find.text('Retry 1 slide'), findsOneWidget); expect(find.text('Edit outline'), findsOneWidget); expect(find.text('Start over'), findsOneWidget); + expect(find.byType(TextButton), findsNothing); + expect(find.byType(OutlinedButton), findsNothing); + expect(find.byType(HeroButton), findsNWidgets(4)); }); testWidgets('artwork fallback stays visible on successful completion', ( @@ -311,7 +314,9 @@ void main() { ); }); - testWidgets('deck length uses a counter and useful presets', (tester) async { + testWidgets('deck length offers four plain slide-count choices', ( + tester, + ) async { var selectedValue = 10; await tester.pumpWidget( @@ -328,13 +333,14 @@ void main() { ); expect(find.text('Deck length'), findsOneWidget); - expect(find.text('10'), findsNWidgets(2)); - - await tester.tap(find.byTooltip('One more slide')); - await tester.pump(); - expect(find.text('11'), findsOneWidget); - - await tester.tap(find.text('20')); + expect(find.text('5 slides'), findsOneWidget); + expect(find.text('10 slides'), findsOneWidget); + expect(find.text('15 slides'), findsOneWidget); + expect(find.text('20 slides'), findsOneWidget); + expect(find.text('Custom'), findsNothing); + expect(find.byTooltip('One more slide'), findsNothing); + + await tester.tap(find.text('20 slides')); await tester.pump(); expect(selectedValue, 20); }); From b6749c316837b4b21aa91c6467ac91911509b6a9 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 16 Jul 2026 00:03:08 -0400 Subject: [PATCH 11/15] feat: split generation lab into planning and build stages --- .../pages/generation_lab_page.dart | 686 +++++++++++++++--- 1 file changed, 584 insertions(+), 102 deletions(-) diff --git a/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart b/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart index da082a76..fc1462df 100644 --- a/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart +++ b/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart @@ -6,66 +6,172 @@ import 'package:provider/provider.dart'; import 'package:superdeck/superdeck.dart'; import 'package:superdeck_builder/superdeck_builder.dart'; +import '../../../../../core/data/data_sources/memory_asset_cache_store.dart'; import '../../../../../core/data/data_sources/memory_deck_loader.dart'; +import '../../../../../core/domain/design/presentation_image_style_catalog.dart'; +import '../../../../../core/domain/design/presentation_theme_catalog.dart'; import '../../../../../core/domain/stores/deck_customization_store.dart'; -import '../../core/engine/services/deck_generator_service.dart'; +import '../../../image_generation/image_generator.dart'; +import '../../core/engine/schemas/outline_schema.dart'; import '../../core/engine/services/deck_generation_request.dart'; +import '../../core/engine/services/deck_generator_service.dart'; import '../../core/engine/services/generation_progress.dart'; import '../../core/engine/services/generation_trace.dart'; import '../../core/env_config.dart'; import '../../domain/generated_deck_style_mapper.dart'; +/// Debug-only harness for iterating on planning, artwork, and composition +/// without repeating the conversational Wizard intake. class GenerationLabPage extends StatefulWidget { - const GenerationLabPage({super.key}); + const GenerationLabPage({ + this.generationService, + this.isConfigured, + super.key, + }); + + final DeckGeneratorService? generationService; + final bool? isConfigured; @override State createState() => _GenerationLabPageState(); } +enum _GenerationLabStage { planning, composing } + class _GenerationLabPageState extends State { - final _traces = []; + final _planningTraces = []; + final _compositionTraces = []; + + late final DeckGeneratorService _service; + _GenerationPreset _preset = _presets.first; GenerationProgress _progress = const GenerationProgress(GenerationPhase.idle); + DeckPlanType? _plan; DeckGenerationResult? _result; + Duration? _planningDuration; + Duration? _compositionDuration; String? _error; - bool _running = false; + _GenerationLabStage? _runningStage; bool _cancelled = false; - Future _run(_GenerationPreset preset) async { + bool get _isConfigured => widget.isConfigured ?? EnvConfig.hasGeminiApiKey; + + @override + void initState() { + super.initState(); + _service = + widget.generationService ?? + DeckGeneratorService( + apiKey: EnvConfig.geminiApiKey, + imageGenerator: DartanticImageGenerator( + apiKey: EnvConfig.geminiApiKey, + modelName: geminiImageGenerationModel, + ), + ); + } + + void _selectPreset(_GenerationPreset preset) { + if (_runningStage != null || identical(_preset, preset)) return; + setState(() { + _preset = preset; + _plan = null; + _result = null; + _planningDuration = null; + _compositionDuration = null; + _planningTraces.clear(); + _compositionTraces.clear(); + _error = null; + _progress = const GenerationProgress(GenerationPhase.idle); + }); + } + + Future _generateStoryBeats() async { + if (_runningStage != null || !_isConfigured) return; setState(() { _cancelled = false; - _running = true; + _runningStage = .planning; _error = null; + _plan = null; _result = null; - _traces.clear(); + _planningDuration = null; + _compositionDuration = null; + _planningTraces.clear(); + _compositionTraces.clear(); + _progress = const GenerationProgress(GenerationPhase.generatingOutline); }); - final service = DeckGeneratorService(apiKey: EnvConfig.geminiApiKey); - final result = await service.generate( - preset.request, + + final timer = Stopwatch()..start(); + final planning = await _service.plan( + _preset.request, onProgress: (progress) { if (mounted) setState(() => _progress = progress); }, onTrace: (event) { - if (!_cancelled) _traces.add(event); + if (!_cancelled) _planningTraces.add(event); }, isCancelled: () => _cancelled || !mounted, ); - if (!mounted) return; + timer.stop(); + if (!mounted || _cancelled) return; + setState(() { + _runningStage = null; + _planningDuration = timer.elapsed; + _progress = const GenerationProgress(GenerationPhase.idle); + _plan = planning.plan; + _error = planning.error; + }); + } + + Future _buildSlides() async { + final plan = _plan; + if (_runningStage != null || !_isConfigured || plan == null) return; + setState(() { + _cancelled = false; + _runningStage = .composing; + _error = null; + _result = null; + _compositionDuration = null; + _compositionTraces.clear(); + _progress = const GenerationProgress(GenerationPhase.generatingImages); + }); + + final timer = Stopwatch()..start(); + final result = await _service.generateFromPlan( + _preset.request, + plan, + onProgress: (progress) { + if (mounted) setState(() => _progress = progress); + }, + onTrace: (event) { + if (!_cancelled) _compositionTraces.add(event); + }, + isCancelled: () => _cancelled || !mounted, + ); + timer.stop(); + if (!mounted || _cancelled) return; if (result.slides.isNotEmpty && result.theme != null) { - _applyResult(result); + await _applyResult(result); await Future.delayed(const Duration(milliseconds: 50)); } if (!mounted) return; setState(() { - _running = false; + _runningStage = null; + _compositionDuration = timer.elapsed; + _progress = const GenerationProgress(GenerationPhase.idle); _result = result; _error = result.error; }); } - void _applyResult(DeckGenerationResult result) { - final theme = result.theme!; + Future _applyResult(DeckGenerationResult result) async { + final cache = context.read(); + for (final asset in result.generatedImages) { + final bytes = asset.bytes; + if (bytes != null && bytes.isNotEmpty) { + await cache.write(asset.assetKey, bytes); + } + } context.read().applyGeneratedStyle( - theme.toGeneratedDeckStyle(), + result.theme!.toGeneratedDeckStyle(), ); context.read().updateMarkdown( const SlideSerializer().serialize(result.slides), @@ -81,52 +187,114 @@ class _GenerationLabPageState extends State { @override Widget build(BuildContext context) { if (!kDebugMode) return const SizedBox.shrink(); + final plan = _plan; final result = _result; + final running = _runningStage != null; return Scaffold( appBar: AppBar(title: const Text('AI generation lab')), body: ListView( padding: const EdgeInsets.all(24), children: [ + Text( + 'Test a completed Wizard selection', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 6), + const Text( + 'Generate the story beats once, review them, then rebuild the slides ' + 'and artwork without repeating the intake flow.', + ), + const SizedBox(height: 20), Wrap( - spacing: 12, - runSpacing: 12, + spacing: 10, + runSpacing: 10, children: [ for (final preset in _presets) - FilledButton.tonal( - onPressed: _running || !EnvConfig.hasGeminiApiKey - ? null - : () => _run(preset), - child: Text(preset.label), + ChoiceChip( + selected: identical(_preset, preset), + onSelected: running ? null : (_) => _selectPreset(preset), + label: Text(preset.label), ), ], ), const SizedBox(height: 16), - if (!EnvConfig.hasGeminiApiKey) - const Text('GOOGLE_AI_API_KEY is not configured.'), - if (_running) - LinearProgressIndicator(semanticsLabel: _progress.label), - if (_running) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(_progress.label), + _SelectionSummary(preset: _preset), + const SizedBox(height: 16), + if (!_isConfigured) + const Text( + 'GOOGLE_AI_API_KEY is not configured.', + style: TextStyle(color: Colors.red), ), - if (_error case final error?) + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + FilledButton.icon( + onPressed: running || !_isConfigured + ? null + : _generateStoryBeats, + icon: const Icon(Icons.auto_stories_outlined), + label: Text( + plan == null + ? 'Generate story beats' + : 'Regenerate story beats', + ), + ), + OutlinedButton.icon( + onPressed: running || plan == null ? null : _buildSlides, + icon: const Icon(Icons.slideshow_outlined), + label: Text(result == null ? 'Build slides' : 'Rebuild slides'), + ), + if (running) + TextButton.icon( + onPressed: () => setState(() => _cancelled = true), + icon: const Icon(Icons.close), + label: const Text('Cancel'), + ), + ], + ), + if (running) ...[ + const SizedBox(height: 16), + LinearProgressIndicator(semanticsLabel: _progress.label), + const SizedBox(height: 8), + Text(_progress.label), + ], + if (_error case final error?) ...[ + const SizedBox(height: 16), Text(error, style: const TextStyle(color: Colors.red)), + ], + if (_planningDuration != null || _compositionDuration != null) ...[ + const SizedBox(height: 20), + _TimingSummary( + planning: _planningDuration, + composition: _compositionDuration, + result: result, + planningTraces: _planningTraces, + compositionTraces: _compositionTraces, + ), + ], + if (plan != null) ...[ + const SizedBox(height: 28), + _StoryBeatReview(plan: plan), + ], if (result != null && result.slideFailures.isNotEmpty) ...[ - const SizedBox(height: 12), + const SizedBox(height: 20), Text( 'Unresolved slides', style: Theme.of(context).textTheme.titleMedium, ), for (final failure in result.slideFailures) Text( - '${failure.slideIndex}. ${failure.slideKey}: ' - '${failure.message}', + '${failure.slideIndex}. ${failure.slideKey}: ${failure.message}', ), ], + if (result != null && result.generatedImages.isNotEmpty) ...[ + const SizedBox(height: 28), + _GeneratedArtwork(result: result), + ], if (result != null && result.slides.isNotEmpty) ...[ - const SizedBox(height: 24), + const SizedBox(height: 28), Text( result.isPartial ? 'Accepted slides' : 'Generated slides', style: Theme.of(context).textTheme.titleLarge, @@ -136,111 +304,425 @@ class _GenerationLabPageState extends State { configurations: context.read().slides.value, ), ], - if (_traces.isNotEmpty) ...[ + if (_planningTraces.isNotEmpty) ...[ const SizedBox(height: 24), - ExpansionTile( - title: Text('Trace (${_traces.length} events)'), - children: [ - SelectableText( - const JsonEncoder.withIndent( - ' ', - ).convert(_traces.map((event) => event.toJson()).toList()), - ), - ], - ), + _TraceView(label: 'Story trace', traces: _planningTraces), ], + if (_compositionTraces.isNotEmpty) + _TraceView(label: 'Build trace', traces: _compositionTraces), ], ), ); } } -class _SlideGallery extends StatelessWidget { - const _SlideGallery({required this.configurations}); +class _SelectionSummary extends StatelessWidget { + const _SelectionSummary({required this.preset}); - final List configurations; + final _GenerationPreset preset; @override Widget build(BuildContext context) { - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 520, - childAspectRatio: 16 / 9, - crossAxisSpacing: 16, - mainAxisSpacing: 16, + final request = preset.request; + final themes = PresentationThemeCatalog.withDefaults(); + final theme = request.themeId == null + ? null + : themes.current(request.themeId!); + final imageStyles = PresentationImageStyleCatalog.withDefaults(); + final imageStyle = request.imageStyleId == null + ? null + : imageStyles.current(request.imageStyleId!); + final palette = theme?.recipe.palette; + + return Card( + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(preset.topic, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 8), + Text(request.userIntent), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _SelectionPill('${request.slideCount} slides'), + if (request.audience case final audience?) + _SelectionPill(audience), + if (request.approach case final approach?) + _SelectionPill(approach), + if (theme != null) _SelectionPill(theme.title), + if (imageStyle != null) + _SelectionPill('${imageStyle.title} artwork'), + for (final emphasis in request.emphasis) + _SelectionPill(emphasis), + ], + ), + if (theme != null) ...[ + const SizedBox(height: 16), + Row( + children: [ + for (final color in [ + palette!.background, + palette.surface, + palette.surfaceAlt, + palette.heading, + palette.accent, + ]) + Padding( + padding: const EdgeInsets.only(right: 6), + child: _ColorSwatch(color), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '${request.headlineFont ?? theme.recipe.headlineFamily} + ' + '${request.bodyFont ?? theme.recipe.bodyFamily}', + ), + ), + ], + ), + ], + ], + ), ), - itemCount: configurations.length, - itemBuilder: (context, index) => ClipRRect( - borderRadius: BorderRadius.circular(10), - child: SlideRenderView(configurations[index]), + ); + } +} + +class _SelectionPill extends StatelessWidget { + const _SelectionPill(this.label); + + final String label; + + @override + Widget build(BuildContext context) => DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Text(label, style: Theme.of(context).textTheme.labelMedium), + ), + ); +} + +class _ColorSwatch extends StatelessWidget { + const _ColorSwatch(this.hex); + + final String hex; + + @override + Widget build(BuildContext context) => Tooltip( + message: hex, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: _parseColor(hex), + shape: BoxShape.circle, + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), ), + ), + ); +} + +class _StoryBeatReview extends StatelessWidget { + const _StoryBeatReview({required this.plan}); + + final DeckPlanType plan; + + @override + Widget build(BuildContext context) { + final slidesByKey = {for (final slide in plan.slides) slide.key: slide}; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Story beats', style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 4), + Text(plan.story), + const SizedBox(height: 14), + for (final (sectionIndex, section) in plan.sections.indexed) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Act ${sectionIndex + 1} · ${section.title}', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text(section.purpose), + const SizedBox(height: 12), + for (final (beatIndex, key) in section.slideKeys.indexed) + if (slidesByKey[key] case final slide?) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 34, + child: Text('${beatIndex + 1}.'), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + slide.title, + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + Text(slide.assertion), + Text( + '${slide.narrativeRole} · ${slide.composition}', + style: Theme.of( + context, + ).textTheme.bodySmall, + ), + ], + ), + ), + ], + ), + ), + Text( + 'Transition: ${section.transition}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + ], ); } } +class _TimingSummary extends StatelessWidget { + const _TimingSummary({ + required this.planning, + required this.composition, + required this.result, + required this.planningTraces, + required this.compositionTraces, + }); + + final Duration? planning; + final Duration? composition; + final DeckGenerationResult? result; + final List planningTraces; + final List compositionTraces; + + @override + Widget build(BuildContext context) { + final models = { + for (final trace in [...planningTraces, ...compositionTraces]) + if (trace.kind == .request && trace.model != null) trace.model!, + }; + + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (planning != null) + _SelectionPill('Story ${_formatDuration(planning!)}'), + if (composition != null) + _SelectionPill('Build ${_formatDuration(composition!)}'), + if (planning != null && composition != null) + _SelectionPill('Total ${_formatDuration(planning! + composition!)}'), + if (result != null) + _SelectionPill( + '${result!.generatedImageCount}/${result!.generatedImages.length} images', + ), + for (final model in models) + _SelectionPill(model.replaceFirst('models/', '')), + ], + ); + } +} + +class _GeneratedArtwork extends StatelessWidget { + const _GeneratedArtwork({required this.result}); + + final DeckGenerationResult result; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Generated artwork', style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + for (final asset in result.generatedImages) + SizedBox( + width: 240, + child: Card( + clipBehavior: Clip.antiAlias, + child: asset.bytes == null + ? Padding( + padding: const EdgeInsets.all(16), + child: Text(asset.error ?? 'Image generation failed.'), + ) + : Image.memory( + asset.bytes!, + height: 160, + fit: BoxFit.cover, + ), + ), + ), + ], + ), + ], + ); +} + +class _TraceView extends StatelessWidget { + const _TraceView({required this.label, required this.traces}); + + final String label; + final List traces; + + @override + Widget build(BuildContext context) => ExpansionTile( + title: Text('$label (${traces.length} events)'), + children: [ + SelectableText( + const JsonEncoder.withIndent( + ' ', + ).convert(traces.map((event) => event.toJson()).toList()), + ), + ], + ); +} + +class _SlideGallery extends StatelessWidget { + const _SlideGallery({required this.configurations}); + + final List configurations; + + @override + Widget build(BuildContext context) => GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 520, + childAspectRatio: 16 / 9, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: configurations.length, + itemBuilder: (context, index) => ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SlideRenderView(configurations[index]), + ), + ); +} + final class _GenerationPreset { + const _GenerationPreset({ + required this.label, + required this.topic, + required this.request, + }); + final String label; + final String topic; final DeckGenerationRequest request; - - const _GenerationPreset(this.label, this.request); } const _presets = [ _GenerationPreset( - 'Narrative', - DeckGenerationRequest( + label: 'SuperDeck booth story', + topic: 'Build a polished deck from one idea', + request: DeckGenerationRequest( + userIntent: + 'Show how SuperDeck turns a rough presentation idea into a polished, ' + 'coherent deck. Build a confident live-demo story from the blank-page ' + 'problem through guided choices, story planning, generated visuals, ' + 'and a presentation-ready result.', + slideCount: 10, + audience: 'Conference booth visitors and product builders', + approach: 'Fast visual product story with a clear reveal', + emphasis: [ + 'GenUI-guided choices', + 'story beats before slide writing', + 'visible generated artwork and final quality', + ], + themeId: 'bold-product', + designDirection: 'Bold, modern, energetic, and product-forward', + headlineFont: 'Montserrat', + bodyFont: 'DM Sans', + imageStyleId: 'gradient', + imageStyleVersion: 1, + maxGeneratedImages: 3, + ), + ), + _GenerationPreset( + label: 'Editorial strategy', + topic: 'Move from incidents to reliability engineering', + request: DeckGenerationRequest( userIntent: 'Move from reactive incident response to reliability engineering. ' 'Build a clear story from operational pain to a practical first 30 days.', slideCount: 10, - audience: 'Engineering leaders', + audience: 'Senior product and engineering leaders', approach: 'Confident editorial narrative', + emphasis: [ + 'one clear assertion per slide', + 'purposeful pacing across three acts', + 'credible operational evidence', + ], themeId: 'editorial-midnight', - designDirection: 'Dark navy editorial', + designDirection: 'Dark, restrained, and cinematic', + headlineFont: 'Playfair Display', + bodyFont: 'Inter', + imageStyleId: 'minimalist', + imageStyleVersion: 1, + maxGeneratedImages: 3, ), ), _GenerationPreset( - 'Comparison table', - DeckGenerationRequest( + label: 'Decision deck', + topic: 'Choose an analytics delivery strategy', + request: DeckGenerationRequest( userIntent: 'Compare build, buy, and partner approaches for analytics. Include ' - 'one concise comparison table and a recommendation.', + 'a concise comparison table and a decision-ready recommendation.', slideCount: 15, audience: 'Product and engineering decision makers', approach: 'Evidence-led decision deck', - themeId: 'technical-paper', - designDirection: 'Light warm technical', - ), - ), - _GenerationPreset( - 'Visual elements', - DeckGenerationRequest( - userIntent: 'Create a developer-platform launch briefing.', - slideCount: 20, - audience: 'Developers and technical leaders', - approach: 'Bold product launch story', - themeId: 'bold-product', - designDirection: 'Bold dark product', - groundedElements: [ - GroundedGenerationElement( - type: 'image', - source: - 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3', - purpose: 'Ground the platform story in a developer workspace', - ), - GroundedGenerationElement( - type: 'qrcode', - source: 'https://example.com/developer-platform', - purpose: 'Let the audience open the developer platform', - ), - GroundedGenerationElement( - type: 'webview', - source: 'https://example.com', - purpose: 'Demonstrate the live product surface', - ), + emphasis: [ + 'readable tables and metrics', + 'clear trade-offs', + 'decision-ready recommendation', ], + themeId: 'technical-paper', + designDirection: 'Light, precise, and information-rich', + headlineFont: 'Space Grotesk', + bodyFont: 'Open Sans', + imageStyleId: 'minimalist', + imageStyleVersion: 1, + maxGeneratedImages: 2, ), ), ]; + +Color _parseColor(String hex) { + final value = hex.replaceFirst('#', ''); + return Color(int.parse('FF$value', radix: 16)); +} + +String _formatDuration(Duration duration) => + '${(duration.inMilliseconds / 1000).toStringAsFixed(2)}s'; From 39f6f931f32288e029abd9db897d10d327256dab Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 16 Jul 2026 02:00:52 -0400 Subject: [PATCH 12/15] fix(playground): polish generated deck workflow --- .../assets/ai_prompts/outline_system.prompt | 37 +++-- .../design/generated_deck_theme_factory.dart | 3 + .../ai/image_generation/image_generator.dart | 47 +++++-- .../prompts/composition_example_library.dart | 1 - .../core/engine/schemas/outline_schema.dart | 79 +++++++++-- .../services/deck_generator_pipeline.dart | 49 +++++++ .../services/deck_generator_service.dart | 11 ++ .../engine/services/deck_plan_validator.dart | 8 +- .../services/deck_theme_resolution.dart | 2 +- .../services/design_quality_metrics.dart | 1 - .../services/generated_slide_validator.dart | 17 +-- .../services/generation_element_catalog.dart | 8 -- .../services/generation_quality_report.dart | 2 +- .../pages/generation_lab_page.dart | 10 +- .../core/deck_customization_store_test.dart | 4 + .../image_generator_test.dart | 70 ++++++++++ .../composition_example_library_test.dart | 7 - .../engine/schemas/outline_schema_test.dart | 55 +------- .../schemas/theme_plan_contract_test.dart | 89 ++++++++++-- .../services/deck_generator_service_test.dart | 36 ++++- .../generated_slide_validator_test.dart | 86 ++++-------- .../generation_lab_page_test.dart | 132 ++++++++++++++++++ .../ai_generation_smoke_test.dart | 44 +++--- .../fixtures/superdeck_demo_10.txt | 4 + .../fixtures/visual_product_20.txt | 2 +- 25 files changed, 566 insertions(+), 238 deletions(-) create mode 100644 packages/playground/test/features/ai/image_generation/image_generator_test.dart create mode 100644 packages/playground/test/features/ai/quick_agent/presentation/generation_lab_page_test.dart create mode 100644 packages/playground/test_live/ai_generation/fixtures/superdeck_demo_10.txt diff --git a/packages/playground/assets/ai_prompts/outline_system.prompt b/packages/playground/assets/ai_prompts/outline_system.prompt index 160c8734..540604ab 100644 --- a/packages/playground/assets/ai_prompts/outline_system.prompt +++ b/packages/playground/assets/ai_prompts/outline_system.prompt @@ -6,18 +6,18 @@ schema-valid JSON. Plan the story and visual rhythm; do not write slide Markdown The typed user request is the only factual authority. Match `slideCount`, theme, brand, density, image-style, and grounded-element constraints exactly. -- Preserve every grounded number with its subject, unit, denominator, - comparison, and time period. +- Preserve grounded numbers with their subject, unit, denominator, comparison, + and time period. - Never invent facts, URLs, assets, product status, capabilities, credentials, pricing, compliance, awards, guarantees, or commercial commitments. +- Do not turn qualitative language into quantities or numeric idioms such as + zero friction, one click, 24/7, 100%, or numbered timelines. - Label any new number as an estimate, assumption, scenario, or proposed target. - Mark recommendations and future operating choices as proposed, planned, or illustrative. Do not present them as observed results. - Do not strengthen evidence with claims such as proven, validated, causal, or significant unless the request explicitly supports them. -These rules apply to audience-facing `title`, `assertion`, and `contentUnits`. - ## Story contract Return the schema fields only. Create exactly the requested number of ordered @@ -28,9 +28,8 @@ Every slide must advance one distinct idea and contain enough concrete content to be composed independently. Each slide needs a kebab-case key, title, section key, assertion, concrete -content units, one `narrativeRole`, composition, and exact elements. The app derives -editorial purpose, continuity, treatment, and density; do not return those -fields. +content units, one `narrativeRole`, composition, and exact elements. The app +derives editorial purpose, continuity, treatment, and density. ## Composition and variation @@ -44,7 +43,7 @@ Choose composition from information shape: - `quote`: supplied voice or a genuine emphasized takeaway - `titleLeft`: assertion beside supporting context - `imageLeft`, `imageRight`, `imageFullBleed`: meaningful image plus copy -- `qrcode`, `webview`, `dartpad`, `custom`: only a matching supplied element +- `webview`, `dartpad`, `custom`: only a matching supplied element Avoid using one composition more than three times consecutively. Unless the brief constrains format, use at least 5/6/7 composition families for 10/15/20 @@ -53,17 +52,18 @@ mechanically. Tables require comparable rows; columns require parallel ideas. ## Elements and artwork -Supported element types are `image`, `qrcode`, `webview`, `dartpad`, and -`custom`. Preserve supplied source, purpose, and custom `widgetName` exactly. +Supported element types are `image`, `webview`, `dartpad`, and `custom`. +Preserve supplied source, purpose, and custom `widgetName` exactly. Never invent a source. Use visual/specialized compositions only with their matching element. -When exact image-style fields exist, plan two to four generated visuals, never -more than `maxGeneratedImages`. Each uses one image element with a concrete, -topic-specific `generationPrompt` and no source. Describe a simple scene or -subject without text, logos, brands, UI screenshots, or unsupported claims. -Without exact image-style fields, never return a generation prompt. An image -element has either `source` or `generationPrompt`, never both. +When exact `imageStyleId` and `imageStyleVersion` fields exist, plan +two to four generated visuals, at most `maxGeneratedImages`. Each uses one image +element with a concrete, topic-specific `generationPrompt` and no source. +Describe a simple scene or subject without text, logos, brands, UI screenshots, +or unsupported claims. Without exact image-style fields, never return a +generation prompt. An image element may use `source` or `generationPrompt`, but +never both `source` and `generationPrompt`. ## Theme and final check @@ -71,6 +71,5 @@ Select exactly one eligible theme ID and return only that ID in `theme`. Theme candidates are selection guidance, not slide copy; the application owns all runtime tokens. -Before returning JSON, verify exact slide count, unique keys, section order, -grounded facts, element compatibility, useful composition variation, a clear -ending, and schema-only output with no commentary or Markdown fence. +Before returning JSON, verify slide count, unique keys, section order, grounded +facts, element compatibility, variation, a clear ending, and schema-only output. diff --git a/packages/playground/lib/core/domain/design/generated_deck_theme_factory.dart b/packages/playground/lib/core/domain/design/generated_deck_theme_factory.dart index 010abf91..dfb17049 100644 --- a/packages/playground/lib/core/domain/design/generated_deck_theme_factory.dart +++ b/packages/playground/lib/core/domain/design/generated_deck_theme_factory.dart @@ -212,6 +212,9 @@ BlockStyler _blockContainer( padding: EdgeInsetsGeometryMix.all( style == .none ? 0 : 20 * runtime.spacingScale, ), + margin: EdgeInsetsGeometryMix.all( + style == .none ? 0 : 12 * runtime.spacingScale, + ), decoration: _blockDecoration(style, palette, runtime), clipBehavior: .antiAlias, variants: [ diff --git a/packages/playground/lib/features/ai/image_generation/image_generator.dart b/packages/playground/lib/features/ai/image_generation/image_generator.dart index 1c20d27c..87708218 100644 --- a/packages/playground/lib/features/ai/image_generation/image_generator.dart +++ b/packages/playground/lib/features/ai/image_generation/image_generator.dart @@ -118,19 +118,15 @@ final class DartanticImageGenerator implements ImageGenerator { modelName: modelName, options: options, ); - await for (final chunk - in model - .generateMediaStream( - request.prompt, - mimeTypes: const ['image/png'], - options: options, - ) - .timeout(timeout)) { - final image = chunk.assets.whereType().firstOrNull; - if (image == null || image.bytes.isEmpty) continue; - - return ImageGenerationSuccess(image.bytes); - } + final image = await _firstImageWithin( + model.generateMediaStream( + request.prompt, + mimeTypes: const ['image/png'], + options: options, + ), + timeout, + ); + if (image != null) return ImageGenerationSuccess(image.bytes); return const ImageGenerationFailure( 'The provider returned no image. Try again.', @@ -153,6 +149,31 @@ final class DartanticImageGenerator implements ImageGenerator { } } +/// Waits for the first usable image under one wall-clock deadline. +/// +/// `Stream.timeout` measures inactivity between chunks. Media providers can +/// emit progress chunks indefinitely, so a shrinking deadline is required to +/// keep the complete image request inside [timeout]. +Future _firstImageWithin( + Stream stream, + Duration timeout, +) async { + final timer = Stopwatch()..start(); + final iterator = StreamIterator(stream); + try { + while (timer.elapsed < timeout) { + final remaining = timeout - timer.elapsed; + final hasNext = await iterator.moveNext().timeout(remaining); + if (!hasNext) return null; + final image = iterator.current.assets.whereType().firstOrNull; + if (image != null && image.bytes.isNotEmpty) return image; + } + throw TimeoutException('Image generation exceeded its deadline.'); + } finally { + await iterator.cancel(); + } +} + /// Adds presentation-safe constraints to a style and subject. String buildPresentationImagePrompt( String stylePrompt, { diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/composition_example_library.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/composition_example_library.dart index eb358950..ffc49771 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/composition_example_library.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/prompts/composition_example_library.dart @@ -85,7 +85,6 @@ const _templateForComposition = { 'imageRight': 'imageSplit', 'imageFullBleed': 'imageFullBleed', 'metric': 'metric', - 'qrcode': 'qrcode', 'webview': 'webview', 'dartpad': 'dartpad', 'custom': 'custom', diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/outline_schema.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/outline_schema.dart index 3aebaaf5..d13caddc 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/outline_schema.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/schemas/outline_schema.dart @@ -43,19 +43,12 @@ const deckPlanCompositionIntents = [ 'imageRight', 'imageFullBleed', 'metric', - 'qrcode', 'webview', 'dartpad', 'custom', ]; -const deckPlanElementTypes = [ - 'image', - 'qrcode', - 'webview', - 'dartpad', - 'custom', -]; +const deckPlanElementTypes = ['image', 'webview', 'dartpad', 'custom']; const deckPlanTreatments = [ 'hero', @@ -180,10 +173,19 @@ final deckPlanSchema = /// /// The model can select only one eligible ID. Catalog version, density, full /// recipe, and user brand overrides are attached by Dart after parsing. -ObjectSchema buildDeckPlanDraftSchema(List eligibleThemeIds) { +ObjectSchema buildDeckPlanDraftSchema( + List eligibleThemeIds, { + List allowedCompositionIntents = deckPlanCompositionIntents, + List allowedElementTypes = deckPlanElementTypes, + bool allowElementSources = true, + bool requireImageGenerationPrompt = false, +}) { if (eligibleThemeIds.isEmpty) { throw ArgumentError('At least one eligible theme ID is required.'); } + if (allowedCompositionIntents.isEmpty) { + throw ArgumentError('At least one composition intent is required.'); + } return Ack.object({ 'topic': Ack.string().describe('Main topic of the presentation'), @@ -199,11 +201,68 @@ ObjectSchema buildDeckPlanDraftSchema(List eligibleThemeIds) { 'Ordered narrative sections whose slide keys partition the deck', ), 'slides': Ack.list( - deckPlanDraftSlideSchema, + buildDeckPlanDraftSlideSchema( + allowedCompositionIntents: allowedCompositionIntents, + allowedElementTypes: allowedElementTypes, + allowElementSources: allowElementSources, + requireImageGenerationPrompt: requireImageGenerationPrompt, + ), ).describe('Ordered list of slides in the presentation'), }).describe('Model-facing deck-plan draft with bounded theme selection'); } +/// Builds the model-facing slide plan from capabilities present in the request. +ObjectSchema buildDeckPlanDraftSlideSchema({ + List allowedCompositionIntents = deckPlanCompositionIntents, + List allowedElementTypes = deckPlanElementTypes, + bool allowElementSources = true, + bool requireImageGenerationPrompt = false, +}) { + final elementSchema = allowedElementTypes.isEmpty + ? null + : Ack.object({ + 'type': Ack.enumString( + allowedElementTypes, + ).describe('Generation-capable element needed by the slide'), + 'purpose': Ack.string().describe( + 'Why this element belongs on the slide', + ), + if (allowElementSources) + 'source': Ack.string().optional().describe( + 'Exact user-supplied source when available', + ), + 'generationPrompt': requireImageGenerationPrompt + ? Ack.string().describe('Concrete visual subject to generate') + : Ack.string().optional().describe( + 'Concrete visual subject to generate for an image', + ), + if (allowedElementTypes.contains('custom')) + 'widgetName': Ack.string().optional().describe( + 'Registered widget name when type is custom', + ), + }); + + return Ack.object({ + 'key': Ack.string().describe('Unique descriptive kebab-case slide ID'), + 'title': Ack.string().describe('Short working slide title'), + 'sectionKey': Ack.string().describe('Owning narrative section key'), + 'assertion': Ack.string().describe('Single audience-facing claim'), + 'contentUnits': Ack.list( + Ack.string(), + ).describe('Concrete evidence, examples, or implications'), + 'narrativeRole': Ack.enumString( + deckPlanNarrativeRoles, + ).describe('Narrative job performed by the slide'), + 'composition': Ack.enumString( + allowedCompositionIntents, + ).describe('Semantic composition chosen for the information shape'), + if (elementSchema != null) + 'elements': Ack.list(elementSchema).optional().describe( + 'Optional grounded or generated elements required by the slide', + ), + }).describe('Lean slide plan enriched by the application after generation'); +} + /// Lean model-owned fields; Dart derives the remaining canonical plan fields. final deckPlanDraftSlideSchema = Ack.object({ 'key': Ack.string().describe('Unique descriptive kebab-case slide ID'), diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart index a0b09518..2661a5aa 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart @@ -15,8 +15,13 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { DeckGenerationRequest request, List themeCandidates, ) async { + final capabilities = _outlineDraftCapabilities(request); final draftSchema = buildDeckPlanDraftSchema( themeCandidates.map((candidate) => candidate.id).toList(growable: false), + allowedCompositionIntents: capabilities.compositions, + allowedElementTypes: capabilities.elementTypes, + allowElementSources: capabilities.allowElementSources, + requireImageGenerationPrompt: capabilities.requireImageGenerationPrompt, ); final adapter = GoogleSchemaAdapter(); final adaptResult = adapter.adapt(draftSchema.toJsonSchemaBuilder()); @@ -926,3 +931,47 @@ extension _DeckGeneratorPipeline on DeckGeneratorService { } } } + +({ + List compositions, + List elementTypes, + bool allowElementSources, + bool requireImageGenerationPrompt, +}) +_outlineDraftCapabilities(DeckGenerationRequest request) { + final groundedTypes = { + for (final element in request.groundedElements) element.type, + }..retainAll(deckPlanElementTypes); + final hasGeneratedImages = + request.imageStyleId != null && request.maxGeneratedImages > 0; + final elementTypes = {...groundedTypes, if (hasGeneratedImages) 'image'}; + final compositions = { + 'title', + 'content', + 'twoColumn', + 'threeColumn', + 'table', + 'quote', + 'titleLeft', + 'metric', + if (elementTypes.contains('image')) ...{ + 'imageLeft', + 'imageRight', + 'imageFullBleed', + }, + if (elementTypes.contains('webview')) 'webview', + if (elementTypes.contains('dartpad')) 'dartpad', + if (elementTypes.contains('custom')) 'custom', + }; + final hasGroundedImage = request.groundedElements.any( + (element) => element.type == 'image', + ); + + return ( + compositions: List.unmodifiable(compositions), + elementTypes: List.unmodifiable(elementTypes), + allowElementSources: request.groundedElements.isNotEmpty, + requireImageGenerationPrompt: + hasGeneratedImages && elementTypes.length == 1 && !hasGroundedImage, + ); +} diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart index 49230d7b..4592a516 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_service.dart @@ -296,6 +296,17 @@ class DeckGeneratorService { if (request.maxGeneratedImages < 0 || request.maxGeneratedImages > 4) { return 'Generated image count must be between 0 and 4.'; } + final unsupportedElementTypes = + request.groundedElements + .map((element) => element.type) + .where((type) => !deckPlanElementTypes.contains(type)) + .toSet() + .toList() + ..sort(); + if (unsupportedElementTypes.isNotEmpty) { + return 'Generation does not support element type(s): ' + '${unsupportedElementTypes.join(', ')}.'; + } try { request.resolveImageStyle(imageStyleCatalog); } catch (error) { diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart index 284737a6..e24ec7c0 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_validator.dart @@ -427,7 +427,7 @@ void _validateTreatmentIntent( 'hero' => const {'title'}, 'section' => const {'title', 'titleLeft'}, 'quote' => const {'quote'}, - 'closing' => const {'title', 'titleLeft', 'quote', 'qrcode'}, + 'closing' => const {'title', 'titleLeft', 'quote'}, 'data' => const {'metric', 'table', 'twoColumn', 'threeColumn'}, _ => null, }; @@ -592,7 +592,6 @@ void _validateElementGrounding( for (final slide in plan.slides) { final requiredType = switch (slide.composition) { 'imageLeft' || 'imageRight' || 'imageFullBleed' => 'image', - 'qrcode' => 'qrcode', 'webview' => 'webview', 'dartpad' => 'dartpad', 'custom' => 'custom', @@ -678,10 +677,7 @@ void _validateElementGrounding( } bool _isAudienceHandoffElement(String type) => - type == 'qrcode' || - type == 'webview' || - type == 'dartpad' || - type == 'custom'; + type == 'webview' || type == 'dartpad' || type == 'custom'; void _validateDesignRhythm( DeckPlanType plan, diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_theme_resolution.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_theme_resolution.dart index bba1a11e..6df68753 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_theme_resolution.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_theme_resolution.dart @@ -138,7 +138,7 @@ String _deriveContinuity(int index, List> slides) { String _deriveTreatment(String role, String composition) { if (role == 'closing' && - const {'title', 'titleLeft', 'quote', 'qrcode'}.contains(composition)) { + const {'title', 'titleLeft', 'quote'}.contains(composition)) { return 'closing'; } if (role == 'transition' && diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/design_quality_metrics.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/design_quality_metrics.dart index 39c939c1..cf5ad256 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/design_quality_metrics.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/design_quality_metrics.dart @@ -44,7 +44,6 @@ int visibleCharacterLimit(String density, {String? composition}) { 'content' => 560, 'imageLeft' || 'imageRight' => 380, 'imageFullBleed' => 120, - 'qrcode' => 320, 'webview' || 'dartpad' || 'custom' => 160, _ => densityLimit, }; diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart index ba9adf68..099e28d5 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart @@ -559,12 +559,6 @@ List _validatePlanFulfillment( 'Composition "$composition" requires substantive text beside the image.', ); } - case 'qrcode': - if (!_hasSubstantiveContent(contentBlocks)) { - compositionGuidance.add( - 'Composition "qrcode" requires a clear text handoff.', - ); - } default: if (!_hasSubstantiveContent(contentBlocks)) { compositionGuidance.add( @@ -598,10 +592,7 @@ List _validateHandoffPurpose( } bool _requiresVisibleHandoffPurpose(String type) => - type == 'qrcode' || - type == 'webview' || - type == 'dartpad' || - type == 'custom'; + type == 'webview' || type == 'dartpad' || type == 'custom'; List _validateNumericClaimContext( Iterable values, @@ -833,11 +824,5 @@ List _validateMarkdownTables(String markdown) { if (tableLines.any((line) => columnCount(line) != columns)) { errors.add('Markdown table rows must have a consistent column count.'); } - final cells = tableLines.expand( - (line) => line.split('|').map((cell) => cell.trim()), - ); - if (cells.any((cell) => cell.length > 60)) { - errors.add('Markdown table cells must contain at most 60 characters.'); - } return errors; } diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_element_catalog.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_element_catalog.dart index 6763b4bd..730ed6fe 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_element_catalog.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_element_catalog.dart @@ -109,14 +109,6 @@ final _builtIns = [ argumentSchema: ImageDto.schema, sourceArgument: 'src', ), - GenerationElementDescriptor( - name: 'qrcode', - description: 'A QR handoff for user-supplied text or URL.', - arguments: - 'text (required), size?, errorCorrection?, backgroundColor?, foregroundColor?', - argumentSchema: QrCodeDto.schema, - sourceArgument: 'text', - ), GenerationElementDescriptor( name: 'webview', description: 'A user-supplied absolute HTTP(S) page.', diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_quality_report.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_quality_report.dart index c3f5d9e8..c6181472 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_quality_report.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generation_quality_report.dart @@ -392,7 +392,7 @@ Iterable _generatedElementTypes(Slide slide) sync* { for (final block in section.blocks) { if (block case final WidgetBlock widget) { yield switch (widget.name) { - 'image' || 'qrcode' || 'webview' || 'dartpad' => widget.name, + 'image' || 'webview' || 'dartpad' => widget.name, _ => 'custom', }; } diff --git a/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart b/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart index fc1462df..02274bd7 100644 --- a/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart +++ b/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart @@ -164,18 +164,16 @@ class _GenerationLabPageState extends State { Future _applyResult(DeckGenerationResult result) async { final cache = context.read(); + final customization = context.read(); + final deckLoader = context.read(); for (final asset in result.generatedImages) { final bytes = asset.bytes; if (bytes != null && bytes.isNotEmpty) { await cache.write(asset.assetKey, bytes); } } - context.read().applyGeneratedStyle( - result.theme!.toGeneratedDeckStyle(), - ); - context.read().updateMarkdown( - const SlideSerializer().serialize(result.slides), - ); + customization.applyGeneratedStyle(result.theme!.toGeneratedDeckStyle()); + deckLoader.updateMarkdown(const SlideSerializer().serialize(result.slides)); } @override diff --git a/packages/playground/test/core/deck_customization_store_test.dart b/packages/playground/test/core/deck_customization_store_test.dart index 384a021a..d115cf4f 100644 --- a/packages/playground/test/core/deck_customization_store_test.dart +++ b/packages/playground/test/core/deck_customization_store_test.dart @@ -291,6 +291,9 @@ void main() { final dataBlock = data.blockContainer.spec.decoration! as BoxDecoration; expect(dataBlock.color, resolved.toGeneratedDeckStyle().surface); expect((dataBlock.border! as Border).top.width, 1.5); + final dataBlockMargin = data.blockContainer.spec.margin! as EdgeInsets; + expect(dataBlockMargin.left, closeTo(11.4, 0.01)); + expect(dataBlockMargin.top, closeTo(11.4, 0.01)); }); testWidgets('keeps generated hero slides chrome-free and unpadded', ( @@ -327,6 +330,7 @@ void main() { expect(options.parts.header, isNull); expect(options.parts.footer, isNull); expect(hero.blockContainer.spec.padding, EdgeInsets.zero); + expect(hero.blockContainer.spec.margin, EdgeInsets.zero); }); testWidgets( diff --git a/packages/playground/test/features/ai/image_generation/image_generator_test.dart b/packages/playground/test/features/ai/image_generation/image_generator_test.dart new file mode 100644 index 00000000..96362513 --- /dev/null +++ b/packages/playground/test/features/ai/image_generation/image_generator_test.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:dartantic_ai/dartantic_ai.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:playground/core/domain/generated_image_asset.dart'; +import 'package:playground/features/ai/image_generation/image_generator.dart'; + +void main() { + test('applies the timeout to total image-generation wall time', () async { + final model = _ProgressOnlyMediaModel(); + final generator = DartanticImageGenerator( + apiKey: 'test-key', + timeout: const Duration(milliseconds: 40), + modelFactory: ({required apiKey, required modelName, required options}) => + model, + ); + final timer = Stopwatch()..start(); + + final result = await generator.generate( + const ImageGenerationRequest( + prompt: 'A presentation-safe abstract illustration', + aspectRatio: GeneratedImageAspectRatio.landscape16x9, + ), + ); + timer.stop(); + + expect(result, isA()); + expect( + (result as ImageGenerationFailure).message, + 'Image preview took too long. Try again.', + ); + expect(timer.elapsed, lessThan(const Duration(milliseconds: 200))); + expect(model.disposed, isTrue); + expect(model.cancelled, isTrue); + }); +} + +final class _ProgressOnlyMediaModel + extends MediaGenerationModel { + _ProgressOnlyMediaModel() + : super( + name: 'progress-only', + defaultOptions: const GoogleMediaGenerationModelOptions(), + ); + + var disposed = false; + var cancelled = false; + + @override + Stream generateMediaStream( + String prompt, { + required List mimeTypes, + List history = const [], + List attachments = const [], + GoogleMediaGenerationModelOptions? options, + outputSchema, + }) async* { + try { + while (true) { + await Future.delayed(const Duration(milliseconds: 5)); + yield MediaGenerationResult(); + } + } finally { + cancelled = true; + } + } + + @override + void dispose() => disposed = true; +} diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/prompts/composition_example_library_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/prompts/composition_example_library_test.dart index 26898d0c..41be8f32 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/prompts/composition_example_library_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/prompts/composition_example_library_test.dart @@ -109,13 +109,6 @@ List> _elementsFor(String composition) => 'source': 'assets/example.png', }, ], - 'qrcode' => const [ - { - 'type': 'qrcode', - 'purpose': 'Continue to the supplied destination', - 'source': 'https://example.test/continue', - }, - ], 'webview' => const [ { 'type': 'webview', diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/schemas/outline_schema_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/schemas/outline_schema_test.dart index d26a1157..8044ac14 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/schemas/outline_schema_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/schemas/outline_schema_test.dart @@ -445,7 +445,7 @@ void main() { slideCount: 10, groundedElements: [ GroundedGenerationElement( - type: 'qrcode', + type: 'webview', source: 'https://signal-canvas.com/launch', purpose: 'Continue the experience.', ), @@ -711,7 +711,7 @@ void main() { validateDeckPlan( plan, request: const DeckGenerationRequest( - userIntent: 'Invite the audience to scan the supplied QR code.', + userIntent: 'Invite the audience to learn more.', slideCount: 10, ), ), @@ -993,57 +993,6 @@ void main() { }, ); - test('preserves grounded handoff identity in a qrcode plan', () { - final data = _hierarchicalPlan(); - final slides = data['slides']! as List>; - slides.last - ..['composition'] = 'qrcode' - ..['contentUnits'] = ['Scan to explore the live Signal Canvas experience'] - ..['elements'] = [ - { - 'type': 'qrcode', - 'source': 'https://superdeck-dev.web.app', - 'purpose': 'Let the audience open the live SuperDeck experience', - }, - ]; - const request = DeckGenerationRequest( - userIntent: 'End with the supplied QR code.', - slideCount: 10, - groundedElements: [ - GroundedGenerationElement( - type: 'qrcode', - source: 'https://superdeck-dev.web.app', - purpose: 'Let the audience open the live SuperDeck experience', - ), - ], - ); - - final rejected = validateDeckPlan( - DeckPlanType.parse(data), - request: request, - ); - expect( - rejected, - contains( - 'Slide "close" qrcode handoff omits grounded purpose term(s): ' - 'superdeck. Preserve the supplied destination or experience identity ' - 'in audience-facing copy.', - ), - ); - - slides.last['contentUnits'] = [ - 'Scan to open the live SuperDeck experience', - ]; - final preserved = validateDeckPlan( - DeckPlanType.parse(data), - request: request, - ); - expect( - preserved.where((candidate) => candidate.contains('handoff omits')), - isEmpty, - ); - }); - test('keeps internal narrative grounding non-blocking', () { final data = _hierarchicalPlan(); final sections = data['sections']! as List>; diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/schemas/theme_plan_contract_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/schemas/theme_plan_contract_test.dart index 2764df81..7cc76897 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/schemas/theme_plan_contract_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/schemas/theme_plan_contract_test.dart @@ -16,26 +16,90 @@ void main() { 'technical-paper', ]); - expect(schema.safeParse(_draftPlan('editorial-midnight')).isOk, isTrue); + final eligible = schema.safeParse(_draftPlan('editorial-midnight')); + expect( + eligible.isOk, + isTrue, + reason: eligible.isFail ? eligible.getError().toMap().toString() : null, + ); expect(schema.safeParse(_draftPlan('bold-product')).isOk, isFalse); }); + test('request-bounded draft schema excludes unavailable elements', () { + final schema = buildDeckPlanDraftSchema( + const ['bold-product'], + allowedCompositionIntents: const [ + 'title', + 'content', + 'imageLeft', + 'imageRight', + 'imageFullBleed', + ], + allowedElementTypes: const ['image'], + allowElementSources: false, + requireImageGenerationPrompt: true, + ); + final valid = _draftPlan('bold-product'); + final validSlide = + Map.from((valid['slides']! as List).single as Map) + ..['composition'] = 'imageRight' + ..['elements'] = [ + { + 'type': 'image', + 'purpose': 'Support the product reveal', + 'generationPrompt': 'Abstract presentation canvas transforming', + }, + ]; + valid['slides'] = [validSlide]; + final unavailable = _draftPlan('bold-product'); + final unavailableSlide = + Map.from( + (unavailable['slides']! as List).single as Map, + ) + ..['composition'] = 'qrcode' + ..['elements'] = [ + { + 'type': 'qrcode', + 'purpose': 'Open an invented destination', + 'source': 'https://example.com', + }, + ]; + unavailable['slides'] = [unavailableSlide]; + + final parsedValid = schema.safeParse(valid); + expect( + parsedValid.isOk, + isTrue, + reason: parsedValid.isFail + ? parsedValid.getError().toMap().toString() + : null, + ); + expect(schema.safeParse(unavailable).isOk, isFalse); + }); + test('canonical plan requires application-owned version and density', () { expect( deckPlanSchema.safeParse(_draftPlan('editorial-midnight')).isOk, isFalse, ); - expect( - deckPlanSchema.safeParse({ - ..._draftPlan('editorial-midnight'), - 'theme': { - 'id': 'editorial-midnight', - 'version': 1, + final canonical = _draftPlan('editorial-midnight'); + canonical['theme'] = { + 'id': 'editorial-midnight', + 'version': 1, + 'density': 'spacious', + }; + canonical['slides'] = [ + for (final rawSlide in canonical['slides']! as List) + { + ...Map.from(rawSlide as Map), + 'purpose': 'Frame the operating problem.', + 'contentBrief': 'Open with the core operating tension.', + 'continuity': 'Establish the premise for the deck.', + 'treatment': 'hero', 'density': 'spacious', }, - }).isOk, - isTrue, - ); + ]; + expect(deckPlanSchema.safeParse(canonical).isOk, isTrue); }); test('replays a retained versioned theme artifact exactly', () async { @@ -80,16 +144,11 @@ Map _draftPlan(String themeId) => { { 'key': 'opening', 'title': 'Reliability starts here', - 'purpose': 'Frame the operating problem.', 'sectionKey': 'main', 'assertion': 'A reliable system begins with shared priorities.', 'contentUnits': ['One concrete supporting point'], 'narrativeRole': 'opening', - 'contentBrief': 'Open with the core operating tension.', - 'continuity': 'Establish the premise for the deck.', 'composition': 'title', - 'treatment': 'hero', - 'density': 'spacious', 'elements': [], }, ], diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart index 89a0145b..1918320e 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_service_test.dart @@ -124,10 +124,38 @@ void main() { 'contentUnits', 'narrativeRole', 'composition', - 'elements', }); }); + test('rejects qrcode elements before calling a model', () async { + final client = _FakeGenerationModelClient(const []); + final service = DeckGeneratorService( + apiKey: 'test-key', + modelClientFactory: (_) => client, + ); + + final result = await service.plan( + const DeckGenerationRequest( + userIntent: 'Create a concise presentation.', + slideCount: 5, + groundedElements: [ + GroundedGenerationElement( + type: 'qrcode', + source: 'https://example.com', + purpose: 'Open the website', + ), + ], + ), + ); + + expect(result.success, isFalse); + expect( + result.error, + 'Generation does not support element type(s): qrcode.', + ); + expect(client.requests, isEmpty); + }); + test('derives a treatment compatible with the selected composition', () { final slide = _leanPlanSlide( key: 'transition', @@ -882,7 +910,11 @@ void main() { expect(widgetSchema.properties, contains('args')); expect( widgetSchema.properties['args']!.properties.keys, - containsAll(['src', 'text', 'url', 'id']), + containsAll(['src', 'url', 'id']), + ); + expect( + widgetSchema.properties['args']!.properties, + isNot(contains('text')), ); expect(widgetSchema.properties, isNot(contains('text'))); expect( diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/services/generated_slide_validator_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/services/generated_slide_validator_test.dart index f9f53213..76af71c8 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/services/generated_slide_validator_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/services/generated_slide_validator_test.dart @@ -118,6 +118,23 @@ void main() { ); }); + test('does not drop a valid table for a long cell', () { + final errors = validateGeneratedSlide( + expectedKey: 'test-slide', + rawSlide: _slideWithBlock({ + 'type': 'block', + 'content': + '| Approach | Result |\n' + '|---|---|\n' + '| Narrative planning | ' + 'A deliberately descriptive comparison that remains valid copy |', + }), + elementCatalog: GenerationElementCatalog.builtIn(), + ); + + expect(errors, isEmpty); + }); + test('rejects slide separators but allows them inside fenced code', () { final catalog = GenerationElementCatalog.builtIn(); final unsafeErrors = validateGeneratedSlide( @@ -1492,68 +1509,15 @@ void main() { }, ); - test( - 'preserves the supplied qrcode destination identity in visible copy', - () { - Map qrcodeSlide(String content) => { - 'key': 'test-slide', - 'options': {'title': 'Continue', 'style': 'closing'}, - 'sections': [ - { - 'type': 'section', - 'blocks': [ - {'type': 'block', 'content': content}, - { - 'type': 'widget', - 'name': 'qrcode', - 'args': {'text': 'https://superdeck-dev.web.app'}, - }, - ], - }, - ], - }; - - final plan = _planSlide( - composition: 'qrcode', - treatment: 'closing', - elements: const [ - { - 'type': 'qrcode', - 'source': 'https://superdeck-dev.web.app', - 'purpose': 'Let the audience open the live SuperDeck experience', - }, - ], - ); - final rejected = validateGeneratedSlide( - expectedKey: 'test-slide', - rawSlide: qrcodeSlide( - '## Continue\n\nScan to explore the live Signal Canvas experience.', - ), - planSlide: plan, - elementCatalog: GenerationElementCatalog.builtIn(), - ); - final preserved = validateGeneratedSlide( - expectedKey: 'test-slide', - rawSlide: qrcodeSlide( - '## Continue\n\nScan to open the live SuperDeck experience.', - ), - planSlide: plan, - elementCatalog: GenerationElementCatalog.builtIn(), - ); + test('does not expose qrcode to AI generation', () { + final catalog = GenerationElementCatalog.builtIn(); - expect( - rejected, - contains( - 'Visible qrcode handoff omits grounded purpose term(s): superdeck. ' - 'Preserve the supplied destination or experience identity.', - ), - ); - expect( - preserved.where((candidate) => candidate.contains('handoff omits')), - isEmpty, - ); - }, - ); + expect(catalog.names, isNot(contains('qrcode'))); + expect( + catalog.validate('qrcode', {'text': 'https://example.com'}), + contains('Widget "qrcode" is not registered for generation.'), + ); + }); } Map _slideWithBlock(Map block) => { diff --git a/packages/playground/test/features/ai/quick_agent/presentation/generation_lab_page_test.dart b/packages/playground/test/features/ai/quick_agent/presentation/generation_lab_page_test.dart new file mode 100644 index 00000000..7a8fd02a --- /dev/null +++ b/packages/playground/test/features/ai/quick_agent/presentation/generation_lab_page_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:playground/core/domain/design/presentation_theme_catalog.dart'; +import 'package:playground/core/domain/design/presentation_typography_catalog.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/schemas/outline_schema.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/deck_generation_request.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/deck_generator_service.dart'; +import 'package:playground/features/ai/quick_agent/core/engine/services/deck_theme_resolution.dart'; +import 'package:playground/features/ai/quick_agent/presentation/pages/generation_lab_page.dart'; + +void main() { + testWidgets('runs story planning separately from slide composition', ( + tester, + ) async { + const request = DeckGenerationRequest( + userIntent: 'SuperDeck product story', + slideCount: 10, + themeId: 'bold-product', + ); + final service = _FakeGenerationLabService(_plan(request)); + + await tester.pumpWidget( + MaterialApp( + home: GenerationLabPage(generationService: service, isConfigured: true), + ), + ); + + expect(find.text('SuperDeck booth story'), findsOneWidget); + expect(find.text('10 slides'), findsOneWidget); + expect(find.text('Bold Product'), findsOneWidget); + expect(find.text('Gradient artwork'), findsOneWidget); + expect(find.text('Montserrat + DM Sans'), findsOneWidget); + await tester.scrollUntilVisible(find.text('Build slides'), 160); + expect( + tester.widget(_buildSlidesButton()).onPressed, + isNull, + ); + + await tester.tap(find.text('Generate story beats')); + await tester.pumpAndSettle(); + + expect(service.planCalls, 1); + await tester.scrollUntilVisible(find.text('Story beats'), 200); + expect(find.text('Story beats'), findsOneWidget); + expect(find.text('Act 1 · The reveal'), findsOneWidget); + expect(find.text('The blank-page problem'), findsOneWidget); + expect(find.text('A rough idea needs a guided path.'), findsOneWidget); + expect( + tester.widget(_buildSlidesButton()).onPressed, + isNotNull, + ); + expect(service.compositionCalls, 0); + }); +} + +Finder _buildSlidesButton() => find.ancestor( + of: find.text('Build slides'), + matching: find.byWidgetPredicate((widget) => widget is OutlinedButton), +); + +DeckPlanType _plan(DeckGenerationRequest request) { + final themes = PresentationThemeCatalog.withDefaults(); + final typography = PresentationTypographyCatalog.withDefaults(); + final descriptor = themes.current(request.themeId!)!; + + return DeckPlanType.parse({ + 'topic': 'SuperDeck', + 'story': 'A rough idea becomes a presentation-ready story.', + 'theme': buildDeckThemeReference( + descriptor: descriptor, + request: request, + typographyCatalog: typography, + ), + 'sections': [ + { + 'key': 'reveal', + 'title': 'The reveal', + 'purpose': 'Introduce the transformation.', + 'transition': 'Move from friction to a guided workflow.', + 'slideKeys': ['blank-page'], + }, + ], + 'slides': [ + { + 'key': 'blank-page', + 'title': 'The blank-page problem', + 'purpose': 'Frame the starting friction.', + 'sectionKey': 'reveal', + 'assertion': 'A rough idea needs a guided path.', + 'contentUnits': ['People begin with intent, not slide structure.'], + 'narrativeRole': 'opening', + 'contentBrief': 'Show the cost of starting from an empty canvas.', + 'continuity': 'Open with friction before revealing the workflow.', + 'composition': 'title', + 'treatment': 'hero', + 'density': 'spacious', + 'elements': [], + }, + ], + }); +} + +final class _FakeGenerationLabService extends DeckGeneratorService { + _FakeGenerationLabService(this.planned) : super(apiKey: 'test-key'); + + final DeckPlanType planned; + var planCalls = 0; + var compositionCalls = 0; + + @override + Future plan( + DeckGenerationRequest request, { + onProgress, + onTrace, + isCancelled, + }) async { + planCalls++; + return DeckPlanningResult.success(planned); + } + + @override + Future generateFromPlan( + DeckGenerationRequest request, + DeckPlanType approvedPlan, { + onProgress, + onTrace, + isCancelled, + }) async { + compositionCalls++; + return DeckGenerationResult.failure('Not needed by this widget test.'); + } +} diff --git a/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart b/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart index e1566c45..c9e1acda 100644 --- a/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart +++ b/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart @@ -55,6 +55,7 @@ const _largeDeckFixtures = [ 'decision_data_15', 'visual_product_20', ]; +const _focusedFixtures = ['superdeck_demo_10']; final _themeCatalog = PresentationThemeCatalog.withDefaults(); final _typographyCatalog = PresentationTypographyCatalog.withDefaults(); @@ -484,12 +485,6 @@ void main() { WizardGenerationController.generationBudget, }), ); - expect( - planningStopwatch.elapsed + compositionStopwatch.elapsed, - lessThanOrEqualTo(WizardGenerationController.generationBudget), - reason: 'Split generation exceeded the 30-second booth budget.', - ); - await File(p.join(output.path, 'brief.txt')).writeAsString(brief); await File(p.join(output.path, 'request.json')).writeAsString( const JsonEncoder.withIndent(' ').convert(request.toMap()), @@ -506,6 +501,12 @@ void main() { ); await _writeTraceArtifacts(output, traces, plan: result.plan); + expect( + planningStopwatch.elapsed + compositionStopwatch.elapsed, + lessThanOrEqualTo(WizardGenerationController.generationBudget), + reason: 'Split generation exceeded the 30-second booth budget.', + ); + expect(result.success, isTrue, reason: result.error); expect(result.slides, isNotEmpty); plan = result.plan!; @@ -680,11 +681,6 @@ DeckGenerationRequest _requestForFixture(String fixture, String brief) { 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3', purpose: 'Ground the platform story in a developer workspace', ), - GroundedGenerationElement( - type: 'qrcode', - source: 'https://example.com/developer-platform', - purpose: 'Let the audience open the developer platform', - ), GroundedGenerationElement( type: 'webview', source: 'https://example.com', @@ -710,6 +706,24 @@ DeckGenerationRequest _requestForFixture(String fixture, String brief) { imageStyleVersion: 1, maxGeneratedImages: 3, ), + 'superdeck_demo_10' => DeckGenerationRequest( + userIntent: brief, + slideCount: 10, + audience: 'Conference booth visitors and product builders', + approach: 'Fast visual product story with a clear reveal', + themeId: 'bold-product', + emphasis: const [ + 'GenUI-guided choices', + 'story beats before slide writing', + 'visible generated artwork and final quality', + ], + designDirection: 'Bold, modern, energetic, and product-forward', + headlineFont: 'Montserrat', + bodyFont: 'DM Sans', + imageStyleId: 'gradient', + imageStyleVersion: 1, + maxGeneratedImages: 3, + ), 'decision_data_15' => DeckGenerationRequest( userIntent: brief, slideCount: 15, @@ -745,11 +759,6 @@ DeckGenerationRequest _requestForFixture(String fixture, String brief) { source: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71', purpose: 'Show a polished analytics product surface', ), - GroundedGenerationElement( - type: 'qrcode', - source: 'https://superdeck-dev.web.app', - purpose: 'Let the audience open the live SuperDeck experience', - ), ], ), _ => throw ArgumentError.value(fixture, 'fixture', 'Unknown fixture'), @@ -761,7 +770,8 @@ List _selectedFixtures() => switch (_selectedFixture) { 'large_deck_matrix' => _largeDeckFixtures, final fixture when _smokeFixtures.contains(fixture) || - _largeDeckFixtures.contains(fixture) => + _largeDeckFixtures.contains(fixture) || + _focusedFixtures.contains(fixture) => [fixture], _ => throw ArgumentError.value( _selectedFixture, diff --git a/packages/playground/test_live/ai_generation/fixtures/superdeck_demo_10.txt b/packages/playground/test_live/ai_generation/fixtures/superdeck_demo_10.txt new file mode 100644 index 00000000..9c4492d9 --- /dev/null +++ b/packages/playground/test_live/ai_generation/fixtures/superdeck_demo_10.txt @@ -0,0 +1,4 @@ +Show how SuperDeck turns a rough presentation idea into a polished, coherent +deck. Build a confident live-demo story from the blank-page problem through +guided choices, story planning, generated visuals, and a presentation-ready +result. diff --git a/packages/playground/test_live/ai_generation/fixtures/visual_product_20.txt b/packages/playground/test_live/ai_generation/fixtures/visual_product_20.txt index 5c3dc600..40652db3 100644 --- a/packages/playground/test_live/ai_generation/fixtures/visual_product_20.txt +++ b/packages/playground/test_live/ai_generation/fixtures/visual_product_20.txt @@ -11,6 +11,6 @@ to each customer's source-of-truth systems. Cover the evidence inbox, linked insights, decision trails, API/webhook extensibility, governance, onboarding, pricing shape, and roadmap without turning every slide into a feature list. Include the supplied analytics image once where it adds meaning and the supplied -QR code once on the final call to action. Use purposeful visual variety, concise +Use the supplied product image once. Use purposeful visual variety, concise copy, and a strong launch cadence. Do not invent other URLs, customer logos, or unsourced claims. From 664a0ede5286a5d671b818c354082b5e075d031d Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 16 Jul 2026 05:38:51 -0400 Subject: [PATCH 13/15] fix: tighten wizard generation state handling and cleanup --- .../services/deck_generator_pipeline.dart | 2 +- .../engine/services/deck_plan_repair.dart | 1 + .../services/deck_theme_resolution.dart | 32 +++++---- .../pages/generation_lab_page.dart | 67 ++++++++++--------- .../wizard/core/ui/components/sd_buttons.dart | 14 ++-- .../wizard_generation_controller.dart | 11 ++- .../wizard_generation_status.dart | 10 +-- .../presentation/wizard_outline_review.dart | 6 +- .../presentation/wizard_selection_review.dart | 2 +- .../wizard_generation_controller_test.dart | 33 +++++++++ 10 files changed, 112 insertions(+), 66 deletions(-) diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart index 2661a5aa..08f29e21 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_pipeline.dart @@ -945,7 +945,7 @@ _outlineDraftCapabilities(DeckGenerationRequest request) { final hasGeneratedImages = request.imageStyleId != null && request.maxGeneratedImages > 0; final elementTypes = {...groundedTypes, if (hasGeneratedImages) 'image'}; - final compositions = { + final compositions = { 'title', 'content', 'twoColumn', diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart index c32fc080..61bda031 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_plan_repair.dart @@ -173,6 +173,7 @@ extension _DeckPlanRepair on DeckGeneratorService { for (final slide in plan.slides) serializeDeckPlanSlideDraft(slide), ]; slides[slideIndex] = Map.of(parsed); + return DeckPlanSlideType.parse( enrichDeckPlanDraftSlide( slides[slideIndex], diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_theme_resolution.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_theme_resolution.dart index 6df68753..7550fc38 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_theme_resolution.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_theme_resolution.dart @@ -84,20 +84,19 @@ DeckPlanType resolveDeckPlanDraft({ final slides = (parsed['slides']! as List) .map((slide) => Map.from(slide! as Map)) .toList(growable: false); - final enrichedSlides = [ - for (final (index, slide) in slides.indexed) - enrichDeckPlanDraftSlide( - slide, - index: index, - slides: slides, - density: request.density ?? descriptor.recipe.defaultDensity, - ), - ]; return DeckPlanType.parse( Map.of(parsed) ..['theme'] = reference - ..['slides'] = enrichedSlides, + ..['slides'] = [ + for (final (index, slide) in slides.indexed) + enrichDeckPlanDraftSlide( + slide, + index: index, + slides: slides, + density: request.density ?? descriptor.recipe.defaultDensity, + ), + ], ); } @@ -125,15 +124,20 @@ String _deriveContinuity(int index, List> slides) { return 'Open the deck with this idea and establish the through-line.'; } if (index == 0) { + final nextTitle = slides[1]['title']! as String; + return 'Open the deck with this idea, then lead into ' - '"${slides[1]['title']}".'; + '"$nextTitle".'; } if (index == slides.length - 1) { - return 'Build from "${slides[index - 1]['title']}" and close the deck.'; + final previousTitle = slides[index - 1]['title']! as String; + + return 'Build from "$previousTitle" and close the deck.'; } + final previousTitle = slides[index - 1]['title']! as String; + final nextTitle = slides[index + 1]['title']! as String; - return 'Build from "${slides[index - 1]['title']}" and lead into ' - '"${slides[index + 1]['title']}".'; + return 'Build from "$previousTitle" and lead into "$nextTitle".'; } String _deriveTreatment(String role, String composition) { diff --git a/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart b/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart index 02274bd7..b6c1f6cb 100644 --- a/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart +++ b/packages/playground/lib/features/ai/quick_agent/presentation/pages/generation_lab_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -96,7 +97,7 @@ class _GenerationLabPageState extends State { _compositionDuration = null; _planningTraces.clear(); _compositionTraces.clear(); - _progress = const GenerationProgress(GenerationPhase.generatingOutline); + _progress = const GenerationProgress(.generatingOutline); }); final timer = Stopwatch()..start(); @@ -131,7 +132,7 @@ class _GenerationLabPageState extends State { _result = null; _compositionDuration = null; _compositionTraces.clear(); - _progress = const GenerationProgress(GenerationPhase.generatingImages); + _progress = const GenerationProgress(.generatingImages); }); final timer = Stopwatch()..start(); @@ -210,9 +211,9 @@ class _GenerationLabPageState extends State { children: [ for (final preset in _presets) ChoiceChip( - selected: identical(_preset, preset), - onSelected: running ? null : (_) => _selectPreset(preset), label: Text(preset.label), + onSelected: running ? null : (_) => _selectPreset(preset), + selected: identical(_preset, preset), ), ], ), @@ -231,7 +232,7 @@ class _GenerationLabPageState extends State { FilledButton.icon( onPressed: running || !_isConfigured ? null - : _generateStoryBeats, + : () => unawaited(_generateStoryBeats()), icon: const Icon(Icons.auto_stories_outlined), label: Text( plan == null @@ -240,7 +241,9 @@ class _GenerationLabPageState extends State { ), ), OutlinedButton.icon( - onPressed: running || plan == null ? null : _buildSlides, + onPressed: running || plan == null + ? null + : () => unawaited(_buildSlides()), icon: const Icon(Icons.slideshow_outlined), label: Text(result == null ? 'Build slides' : 'Rebuild slides'), ), @@ -336,7 +339,7 @@ class _SelectionSummary extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(18), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text(preset.topic, style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), @@ -402,7 +405,7 @@ class _SelectionPill extends StatelessWidget { borderRadius: BorderRadius.circular(999), ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + padding: const .symmetric(vertical: 6, horizontal: 10), child: Text(label, style: Theme.of(context).textTheme.labelMedium), ), ); @@ -417,13 +420,13 @@ class _ColorSwatch extends StatelessWidget { Widget build(BuildContext context) => Tooltip( message: hex, child: Container( - width: 24, - height: 24, decoration: BoxDecoration( color: _parseColor(hex), - shape: BoxShape.circle, - border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + border: .all(color: Theme.of(context).colorScheme.outlineVariant), + shape: .circle, ), + width: 24, + height: 24, ), ); } @@ -438,7 +441,7 @@ class _StoryBeatReview extends StatelessWidget { final slidesByKey = {for (final slide in plan.slides) slide.key: slide}; return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text('Story beats', style: Theme.of(context).textTheme.headlineSmall), const SizedBox(height: 4), @@ -449,7 +452,7 @@ class _StoryBeatReview extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(16), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text( 'Act ${sectionIndex + 1} · ${section.title}', @@ -463,7 +466,7 @@ class _StoryBeatReview extends StatelessWidget { Padding( padding: const EdgeInsets.only(bottom: 10), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ SizedBox( width: 34, @@ -471,13 +474,11 @@ class _StoryBeatReview extends StatelessWidget { ), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, children: [ Text( slide.title, - style: const TextStyle( - fontWeight: FontWeight.w600, - ), + style: const TextStyle(fontWeight: .w600), ), Text(slide.assertion), Text( @@ -555,19 +556,19 @@ class _GeneratedArtwork extends StatelessWidget { @override Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: .start, + spacing: 12, children: [ Text('Generated artwork', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 12), Wrap( spacing: 12, runSpacing: 12, children: [ - for (final asset in result.generatedImages) + for (final (index, asset) in result.generatedImages.indexed) SizedBox( width: 240, child: Card( - clipBehavior: Clip.antiAlias, + clipBehavior: .antiAlias, child: asset.bytes == null ? Padding( padding: const EdgeInsets.all(16), @@ -575,8 +576,9 @@ class _GeneratedArtwork extends StatelessWidget { ) : Image.memory( asset.bytes!, + semanticLabel: 'Generated artwork ${index + 1}', height: 160, - fit: BoxFit.cover, + fit: .cover, ), ), ), @@ -612,32 +614,32 @@ class _SlideGallery extends StatelessWidget { @override Widget build(BuildContext context) => GridView.builder( - shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 520, - childAspectRatio: 16 / 9, - crossAxisSpacing: 16, mainAxisSpacing: 16, + crossAxisSpacing: 16, + childAspectRatio: 16 / 9, ), - itemCount: configurations.length, itemBuilder: (context, index) => ClipRRect( borderRadius: BorderRadius.circular(10), child: SlideRenderView(configurations[index]), ), + itemCount: configurations.length, ); } final class _GenerationPreset { + final String label; + + final String topic; + final DeckGenerationRequest request; const _GenerationPreset({ required this.label, required this.topic, required this.request, }); - - final String label; - final String topic; - final DeckGenerationRequest request; } const _presets = [ @@ -719,6 +721,7 @@ const _presets = [ Color _parseColor(String hex) { final value = hex.replaceFirst('#', ''); + return Color(int.parse('FF$value', radix: 16)); } diff --git a/packages/playground/lib/features/ai/wizard/core/ui/components/sd_buttons.dart b/packages/playground/lib/features/ai/wizard/core/ui/components/sd_buttons.dart index 014aa247..fead25e5 100644 --- a/packages/playground/lib/features/ai/wizard/core/ui/components/sd_buttons.dart +++ b/packages/playground/lib/features/ai/wizard/core/ui/components/sd_buttons.dart @@ -30,24 +30,24 @@ class SdButton extends StatelessWidget { final SdButtonVariant variant; HeroButtonVariant get _heroVariant => switch (variant) { - SdButtonVariant.primary => HeroButtonVariant.primary, - SdButtonVariant.outline => HeroButtonVariant.outline, - SdButtonVariant.ghost => HeroButtonVariant.ghost, + .primary => HeroButtonVariant.primary, + .outline => .outline, + .ghost => .ghost, }; @override Widget build(BuildContext context) { return Semantics( + enabled: enabled && !loading, button: true, label: semanticLabel ?? label, - enabled: enabled && !loading, child: HeroButton( label: label, - onPressed: onPressed, + variant: _heroVariant, iconLeft: icon, - enabled: enabled, loading: loading, - variant: _heroVariant, + enabled: enabled, + onPressed: onPressed, style: style, ), ); diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_controller.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_controller.dart index 660ef013..007590a2 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_controller.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_controller.dart @@ -137,10 +137,14 @@ final class WizardGenerationController extends ChangeNotifier { _notify(); } - Future _completeComposition(DeckGenerationResult generated) async { + Future _completeComposition( + int operation, + DeckGenerationResult generated, + ) async { try { await _applyResult(generated); } catch (error) { + if (_isOperationCancelled(operation) || _disposed) return; _fail( .composition, 'Slides were generated but could not be loaded: $error', @@ -148,6 +152,7 @@ final class WizardGenerationController extends ChangeNotifier { return; } + if (_isOperationCancelled(operation) || _disposed) return; _result = generated; _stage = .completed; _progress = const GenerationProgress(.idle); @@ -272,7 +277,7 @@ final class WizardGenerationController extends ChangeNotifier { return; } - await _completeComposition(generated); + await _completeComposition(operation, generated); } Future retryFailedSlides() async { @@ -321,7 +326,7 @@ final class WizardGenerationController extends ChangeNotifier { return; } - await _completeComposition(generated); + await _completeComposition(operation, generated); } Future retry() async { diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart index 6dfe7086..b0a23b9a 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_generation_status.dart @@ -140,7 +140,7 @@ class WizardGenerationStatus extends StatelessWidget { label: 'Cancel', onPressed: onCancel, icon: LucideIcons.x, - variant: SdButtonVariant.ghost, + variant: .ghost, ), ], ), @@ -174,7 +174,7 @@ class WizardGenerationStatus extends StatelessWidget { SdButton( label: backLabel, onPressed: onBack, - variant: SdButtonVariant.ghost, + variant: .ghost, ), SdButton( label: 'Retry', @@ -192,12 +192,12 @@ class WizardGenerationStatus extends StatelessWidget { SdButton( label: 'Start over', onPressed: onStartOver, - variant: SdButtonVariant.ghost, + variant: .ghost, ), SdButton( label: 'Edit outline', onPressed: onEditOutline, - variant: SdButtonVariant.outline, + variant: .outline, ), if (failedSlideCount > 0) SdButton( @@ -206,7 +206,7 @@ class WizardGenerationStatus extends StatelessWidget { '${failedSlideCount == 1 ? 'slide' : 'slides'}', onPressed: onRetryFailed, icon: LucideIcons.refreshCw, - variant: SdButtonVariant.outline, + variant: .outline, ), SdButton( label: 'Present deck', diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_outline_review.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_outline_review.dart index 1368d73d..a5cfdea5 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_outline_review.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_outline_review.dart @@ -146,13 +146,13 @@ class _WizardOutlineReviewState extends State { SdButton( label: 'Back', onPressed: widget.onBack, - variant: SdButtonVariant.ghost, + variant: .ghost, ), SdButton( label: 'Regenerate outline', onPressed: widget.onRegenerate, icon: LucideIcons.refreshCw, - variant: SdButtonVariant.outline, + variant: .outline, ), SdButton( label: 'Approve & build', @@ -369,7 +369,7 @@ class _OutlineSlideEditorState extends State<_OutlineSlideEditor> { label: widget.editing ? 'Done' : 'Edit slide ${widget.index + 1}', onPressed: () => widget.onEdit(widget.editing ? null : slide.key), icon: widget.editing ? LucideIcons.check : LucideIcons.pencil, - variant: SdButtonVariant.ghost, + variant: .ghost, ), ], ), diff --git a/packages/playground/lib/features/ai/wizard/presentation/wizard_selection_review.dart b/packages/playground/lib/features/ai/wizard/presentation/wizard_selection_review.dart index 3ea4dab5..55a55d99 100644 --- a/packages/playground/lib/features/ai/wizard/presentation/wizard_selection_review.dart +++ b/packages/playground/lib/features/ai/wizard/presentation/wizard_selection_review.dart @@ -99,7 +99,7 @@ class WizardSelectionReview extends StatelessWidget { SdButton( label: 'Start over', onPressed: onStartOver, - variant: SdButtonVariant.ghost, + variant: .ghost, ), SdButton( label: 'Create outline', diff --git a/packages/playground/test/features/ai/wizard/presentation/wizard_generation_controller_test.dart b/packages/playground/test/features/ai/wizard/presentation/wizard_generation_controller_test.dart index fae7fbeb..070969f5 100644 --- a/packages/playground/test/features/ai/wizard/presentation/wizard_generation_controller_test.dart +++ b/packages/playground/test/features/ai/wizard/presentation/wizard_generation_controller_test.dart @@ -149,6 +149,39 @@ void main() { expect(controller.stage, WizardGenerationStage.setup); }); + test('ignores a late deck application after cancellation', () async { + const request = DeckGenerationRequest( + userIntent: 'Urban gardens', + slideCount: 1, + themeId: 'technical-paper', + ); + final service = _FakeWizardGenerationService(_plan(request)); + final applicationStarted = Completer(); + final pendingApplication = Completer(); + final controller = WizardGenerationController( + service: service, + applyResult: (_) { + applicationStarted.complete(); + + return pendingApplication.future; + }, + ); + addTearDown(controller.dispose); + + await controller.createOutline(request); + final composition = controller.generateSlides(); + await applicationStarted.future; + + controller.cancel(); + expect(controller.stage, WizardGenerationStage.outlineReview); + + pendingApplication.complete(); + await composition; + + expect(controller.stage, WizardGenerationStage.outlineReview); + expect(controller.result, isNull); + }); + testWidgets('keeps composing after the 30-second performance target', ( tester, ) async { From ab9f8781521c1e401a30301d17f4374e96d66d11 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 16 Jul 2026 09:30:00 -0400 Subject: [PATCH 14/15] fix(playground): improve generated slide visual polish --- .../slide_composition_examples.json | 4 +- .../assets/ai_prompts/outline_system.prompt | 56 +++++++------- .../assets/ai_prompts/section_system.prompt | 9 ++- .../ai/image_generation/image_generator.dart | 29 ++++++-- .../services/deck_generator_images.dart | 41 +++++++++- .../services/generated_slide_validator.dart | 52 ++++++++++++- .../image_generator_test.dart | 34 +++++++++ .../services/deck_generator_images_test.dart | 6 ++ .../generated_slide_validator_test.dart | 74 +++++++++++++++++++ 9 files changed, 260 insertions(+), 45 deletions(-) diff --git a/packages/playground/assets/ai_examples/slide_composition_examples.json b/packages/playground/assets/ai_examples/slide_composition_examples.json index 27097912..3ebfabd9 100644 --- a/packages/playground/assets/ai_examples/slide_composition_examples.json +++ b/packages/playground/assets/ai_examples/slide_composition_examples.json @@ -92,8 +92,8 @@ "options": {"title": "One connected system"}, "sections": [ {"type": "section", "flex": 1, "spacing": 32, "blocks": [ - {"type": "block", "flex": 2, "align": "centerLeft", "content": "## One connected system\n\nUse the supplied visual as evidence, then state its implication."}, - {"type": "widget", "name": "image", "args": {"src": "__SOURCE__", "fit": "cover"}, "flex": 3} + {"type": "block", "flex": 1, "align": "centerLeft", "content": "## One connected system\n\nUse the supplied visual as evidence, then state its implication."}, + {"type": "widget", "name": "image", "args": {"src": "__SOURCE__", "fit": "cover"}, "flex": 1} ]} ] }, diff --git a/packages/playground/assets/ai_prompts/outline_system.prompt b/packages/playground/assets/ai_prompts/outline_system.prompt index 540604ab..ac3c9aa3 100644 --- a/packages/playground/assets/ai_prompts/outline_system.prompt +++ b/packages/playground/assets/ai_prompts/outline_system.prompt @@ -3,33 +3,36 @@ schema-valid JSON. Plan the story and visual rhythm; do not write slide Markdown ## Authority -The typed user request is the only factual authority. Match `slideCount`, theme, -brand, density, image-style, and grounded-element constraints exactly. +The typed request is the only factual authority. Match its `slideCount`, theme, +brand, density, image style, and grounded elements exactly. -- Preserve grounded numbers with their subject, unit, denominator, comparison, - and time period. +- Preserve numbers with their subject, unit, denominator, comparison, and period. - Never invent facts, URLs, assets, product status, capabilities, credentials, pricing, compliance, awards, guarantees, or commercial commitments. - Do not turn qualitative language into quantities or numeric idioms such as zero friction, one click, 24/7, 100%, or numbered timelines. -- Label any new number as an estimate, assumption, scenario, or proposed target. -- Mark recommendations and future operating choices as proposed, planned, or - illustrative. Do not present them as observed results. +- Label new numbers as estimates, assumptions, scenarios, or proposed targets. +- Mark recommendations and future choices as proposed, planned, or illustrative. - Do not strengthen evidence with claims such as proven, validated, causal, or significant unless the request explicitly supports them. ## Story contract -Return the schema fields only. Create exactly the requested number of ordered -slides. For 10–20 slides, use three to five ordered narrative acts whose -`slideKeys` partition the slides exactly once and in order. Give the deck one -clear thesis, a deliberate opening, progressive sections, and a decisive close. -Every slide must advance one distinct idea and contain enough concrete content -to be composed independently. +Return schema fields only and exactly the requested ordered slides. For 10–20 +slides, use three to five ordered narrative acts whose `slideKeys` partition every slide +once and in order. Use one thesis, deliberate opening, progressive sections, +and a decisive close. Each slide advances one distinct, independently +composable idea. + +Use the first slide as a concise `title` opening with one assertion and at most +one short supporting content unit; do not summarize the whole deck there. Make +the final slide a dedicated `closing` narrative role using `title` or +`titleLeft`, with one memorable statement or invitation. Place comparisons, +tables, metrics, and detailed recaps before the final slide. Each slide needs a kebab-case key, title, section key, assertion, concrete content units, one `narrativeRole`, composition, and exact elements. The app -derives editorial purpose, continuity, treatment, and density. +derives purpose, continuity, treatment, and density. ## Composition and variation @@ -45,21 +48,19 @@ Choose composition from information shape: - `imageLeft`, `imageRight`, `imageFullBleed`: meaningful image plus copy - `webview`, `dartpad`, `custom`: only a matching supplied element -Avoid using one composition more than three times consecutively. Unless the -brief constrains format, use at least 5/6/7 composition families for 10/15/20 -slides. Variation must follow the narrative and information, not rotate -mechanically. Tables require comparable rows; columns require parallel ideas. +Do not use one composition over three times consecutively. Unless constrained, +use at least 5/6/7 families for 10/15/20 slides. Vary by narrative and +information, not rotation. Tables compare uniform rows; columns parallel ideas. ## Elements and artwork -Supported element types are `image`, `webview`, `dartpad`, and `custom`. -Preserve supplied source, purpose, and custom `widgetName` exactly. -Never invent a source. Use visual/specialized compositions only with their -matching element. +Elements: `image`, `webview`, `dartpad`, and `custom`. Preserve supplied source, +purpose, and custom `widgetName`; never invent a source. Use specialized +compositions only with their matching element. -When exact `imageStyleId` and `imageStyleVersion` fields exist, plan -two to four generated visuals, at most `maxGeneratedImages`. Each uses one image -element with a concrete, topic-specific `generationPrompt` and no source. +With exact `imageStyleId` and `imageStyleVersion`, +plan two to four generated visuals, at most `maxGeneratedImages`. Each uses one image element with a +concrete, topic-specific `generationPrompt` and no source. Describe a simple scene or subject without text, logos, brands, UI screenshots, or unsupported claims. Without exact image-style fields, never return a generation prompt. An image element may use `source` or `generationPrompt`, but @@ -67,9 +68,8 @@ never both `source` and `generationPrompt`. ## Theme and final check -Select exactly one eligible theme ID and return only that ID in `theme`. Theme -candidates are selection guidance, not slide copy; the application owns all -runtime tokens. +Return exactly one eligible theme ID in `theme`. Candidates guide selection, +not slide copy; the application owns runtime tokens. Before returning JSON, verify slide count, unique keys, section order, grounded facts, element compatibility, variation, a clear ending, and schema-only output. diff --git a/packages/playground/assets/ai_prompts/section_system.prompt b/packages/playground/assets/ai_prompts/section_system.prompt index 31fd0571..379fd20d 100644 --- a/packages/playground/assets/ai_prompts/section_system.prompt +++ b/packages/playground/assets/ai_prompts/section_system.prompt @@ -23,11 +23,14 @@ fields. - Use one or two sections per slide and 1–3 blocks per section. Prefer concise copy, clear hierarchy, and visible whitespace over filling the canvas. - Use the exact structural shape implied by each plan: `title` is one block - beginning with H1; `content` is one block beginning with H2; `twoColumn` is - one section with exactly two blocks beginning with H3; `threeColumn` is one - section with exactly three H3 blocks; `metric` begins with one concise H1. + beginning with H1 plus at most one short supporting sentence; `content` is + one block beginning with H2; `twoColumn` is one section with exactly two + blocks beginning with H3; `threeColumn` is one section with exactly three H3 + blocks; `metric` begins with one concise H1. - A table plan requires a real compact Markdown table with 2–4 columns and at most 6 body rows. A two/three-column plan requires that many dominant blocks. +- An `imageLeft` or `imageRight` plan uses one equal-width image/text section. + Keep its text to one short H2 plus one paragraph or at most three short bullets. - Use fullscreen only for genuine edge-to-edge visuals or embeds. ## Evidence diff --git a/packages/playground/lib/features/ai/image_generation/image_generator.dart b/packages/playground/lib/features/ai/image_generation/image_generator.dart index 87708218..fd1d7c50 100644 --- a/packages/playground/lib/features/ai/image_generation/image_generator.dart +++ b/packages/playground/lib/features/ai/image_generation/image_generator.dart @@ -8,6 +8,8 @@ import '../quick_agent/core/engine/services/error_classifier.dart'; const geminiImagePreviewModel = 'gemini-3.1-flash-lite-image'; const geminiImageGenerationModel = 'gemini-3.1-flash-image'; +const imagePreviewTimeout = Duration(seconds: 8); +const imageGenerationTimeout = Duration(seconds: 12); /// Input for one image-generation request. final class ImageGenerationRequest { @@ -86,9 +88,18 @@ final class DartanticImageGenerator implements ImageGenerator { DartanticImageGenerator({ required this.apiKey, this.modelName = geminiImagePreviewModel, - this.timeout = const Duration(seconds: 8), + Duration? timeout, DartanticMediaModelFactory? modelFactory, - }) : _modelFactory = modelFactory ?? _createGoogleModel; + }) : timeout = timeout ?? _defaultTimeout(modelName), + _modelFactory = modelFactory ?? _createGoogleModel; + + static Duration _defaultTimeout(String modelName) { + if (modelName == geminiImageGenerationModel) { + return imageGenerationTimeout; + } + + return imagePreviewTimeout; + } static MediaGenerationModel _createGoogleModel({ @@ -132,9 +143,11 @@ final class DartanticImageGenerator implements ImageGenerator { 'The provider returned no image. Try again.', ); } on TimeoutException { - return const ImageGenerationFailure( - 'Image preview took too long. Try again.', - ); + final operation = modelName == geminiImageGenerationModel + ? 'Image generation' + : 'Image preview'; + + return ImageGenerationFailure('$operation took too long. Try again.'); } catch (error) { return ImageGenerationFailure( const ErrorClassifier().getUserMessage(error), @@ -181,8 +194,10 @@ String buildPresentationImagePrompt( }) { final background = backgroundColor == null || backgroundColor.isEmpty ? '' - : '\nUse $backgroundColor as the dominant background color. Keep the ' - 'subject clearly distinguishable from it.'; + : '\nRender the entire background as a flat, untextured $backgroundColor ' + 'field so it blends into the slide. Do not add a paper rectangle, ' + 'frame, border, vignette, gradient, or shadow behind the subject. ' + 'Keep the subject clearly distinguishable from the background.'; return ''' $stylePrompt diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_images.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_images.dart index e1781b78..bb24bb3d 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_images.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/deck_generator_images.dart @@ -18,12 +18,14 @@ final class _PlannedImage { final String subject; final String assetKey; final GeneratedImageAspectRatio aspectRatio; + final String? backgroundColor; const _PlannedImage({ required this.slideIndex, required this.elementIndex, required this.subject, required this.assetKey, required this.aspectRatio, + required this.backgroundColor, }); } @@ -54,6 +56,11 @@ Future _runImagePhase( generator: owner.imageGenerator ?? const UnavailableImageGenerator(), runId: owner._assetRunIdFactory(), backgroundColor: resolvedTheme.palette.background, + backgroundColorsByTreatment: { + for (final treatment + in plan.slides.map((slide) => slide.treatment).toSet()) + treatment: _imageBackgroundForTreatment(resolvedTheme, treatment), + }, onProgress: (completed, total) => onProgress?.call( GenerationProgress( .generatingImages, @@ -88,6 +95,7 @@ Future generateImagesForPlan({ required ImageGenerator generator, required String runId, String? backgroundColor, + Map backgroundColorsByTreatment = const {}, ImageGenerationProgressCallback? onProgress, bool Function()? isCancelled, int maxConcurrency = 4, @@ -99,7 +107,12 @@ Future generateImagesForPlan({ 'Must be positive.', ); } - final planned = _plannedImages(plan, runId); + final planned = _plannedImages( + plan, + runId, + backgroundColor: backgroundColor, + backgroundColorsByTreatment: backgroundColorsByTreatment, + ); if (planned.isEmpty) { onProgress?.call(0, 0); @@ -122,7 +135,7 @@ Future generateImagesForPlan({ ImageGenerationRequest( prompt: buildPresentationImagePrompt( imageStyle.buildPrompt(image.subject), - backgroundColor: backgroundColor, + backgroundColor: image.backgroundColor, ), aspectRatio: image.aspectRatio, ), @@ -157,7 +170,12 @@ Future generateImagesForPlan({ ); } -List<_PlannedImage> _plannedImages(DeckPlanType plan, String runId) { +List<_PlannedImage> _plannedImages( + DeckPlanType plan, + String runId, { + required String? backgroundColor, + required Map backgroundColorsByTreatment, +}) { final planned = <_PlannedImage>[]; for (final (slideIndex, slide) in plan.slides.indexed) { for (final (elementIndex, element) @@ -179,6 +197,8 @@ List<_PlannedImage> _plannedImages(DeckPlanType plan, String runId) { aspectRatio: slide.composition == 'imageFullBleed' ? .landscape16x9 : .slide3x4, + backgroundColor: + backgroundColorsByTreatment[slide.treatment] ?? backgroundColor, ), ); } @@ -187,6 +207,21 @@ List<_PlannedImage> _plannedImages(DeckPlanType plan, String runId) { return planned; } +String _imageBackgroundForTreatment( + ResolvedPresentationTheme theme, + String treatment, +) { + final role = + theme.descriptor.recipe.runtime.treatments.byName[treatment]?.background; + + return switch (role) { + .surface => theme.palette.surface, + .surfaceAlt => theme.palette.surfaceAlt, + .accent => theme.palette.accent, + .background || null => theme.palette.background, + }; +} + DeckPlanType _rewriteGeneratedImageSources( DeckPlanType plan, List<_PlannedImage> planned, diff --git a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart index 099e28d5..43ecf792 100644 --- a/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart +++ b/packages/playground/lib/features/ai/quick_agent/core/engine/services/generated_slide_validator.dart @@ -45,7 +45,38 @@ Map normalizeGeneratedSlideForPlan({ else rawSection, ]; - return _normalizeImplicitVerticalAlignment(normalized, planSlide); + + return _normalizeImageSplitFlex( + _normalizeImplicitVerticalAlignment(normalized, planSlide), + planSlide, + ); +} + +Map _normalizeImageSplitFlex( + Map slide, + DeckPlanSlideType planSlide, +) { + if (planSlide.composition != 'imageLeft' && + planSlide.composition != 'imageRight') { + return slide; + } + final sections = slide['sections']; + if (sections is! List || sections.length != 1) return slide; + final section = sections.single; + if (section is! Map) return slide; + final blocks = section['blocks']; + if (blocks is! List || blocks.length != 2) return slide; + final hasImage = blocks.whereType().any( + (block) => block['type'] == WidgetBlock.key && block['name'] == 'image', + ); + final hasContent = blocks.whereType().any( + (block) => block['type'] == ContentBlock.key, + ); + if (!hasImage || !hasContent) return slide; + for (final block in blocks.whereType()) { + block['flex'] = 1; + } + return slide; } Map _normalizeBlockForPlan( @@ -126,7 +157,24 @@ Map _normalizeImplicitVerticalAlignment( ).hasMatch(titleBlock['content'] as String)) { return slide; } - titleBlock.putIfAbsent('align', () => 'bottomLeft'); + final titleContent = titleBlock['content'] as String; + final hasSupportingCopy = titleContent + .split('\n') + .any( + (line) => line.trim().isNotEmpty && !line.trimLeft().startsWith('#'), + ); + final titleFlex = titleSection['flex']; + final bodyFlex = bodySection['flex']; + if (hasSupportingCopy && + (titleFlex == null || titleFlex == 1) && + (bodyFlex == null || bodyFlex == 3)) { + titleSection['flex'] = 1; + bodySection['flex'] = 2; + } + titleBlock.putIfAbsent( + 'align', + () => hasSupportingCopy ? 'topLeft' : 'bottomLeft', + ); for (final bodyBlock in bodyBlocks.whereType()) { if (bodyBlock['type'] == ContentBlock.key) { bodyBlock.putIfAbsent('align', () => 'topLeft'); diff --git a/packages/playground/test/features/ai/image_generation/image_generator_test.dart b/packages/playground/test/features/ai/image_generation/image_generator_test.dart index 96362513..3dcd74da 100644 --- a/packages/playground/test/features/ai/image_generation/image_generator_test.dart +++ b/packages/playground/test/features/ai/image_generation/image_generator_test.dart @@ -6,6 +6,17 @@ import 'package:playground/core/domain/generated_image_asset.dart'; import 'package:playground/features/ai/image_generation/image_generator.dart'; void main() { + test('uses separate preview and final-generation deadlines', () { + final preview = DartanticImageGenerator(apiKey: 'test-key'); + final finalGeneration = DartanticImageGenerator( + apiKey: 'test-key', + modelName: geminiImageGenerationModel, + ); + + expect(preview.timeout, imagePreviewTimeout); + expect(finalGeneration.timeout, imageGenerationTimeout); + }); + test('applies the timeout to total image-generation wall time', () async { final model = _ProgressOnlyMediaModel(); final generator = DartanticImageGenerator( @@ -33,6 +44,29 @@ void main() { expect(model.disposed, isTrue); expect(model.cancelled, isTrue); }); + + test('labels final-generation timeouts accurately', () async { + final generator = DartanticImageGenerator( + apiKey: 'test-key', + modelName: geminiImageGenerationModel, + timeout: const Duration(milliseconds: 40), + modelFactory: ({required apiKey, required modelName, required options}) => + _ProgressOnlyMediaModel(), + ); + + final result = await generator.generate( + const ImageGenerationRequest( + prompt: 'A presentation-safe abstract illustration', + aspectRatio: GeneratedImageAspectRatio.landscape16x9, + ), + ); + + expect(result, isA()); + expect( + (result as ImageGenerationFailure).message, + 'Image generation took too long. Try again.', + ); + }); } final class _ProgressOnlyMediaModel diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_images_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_images_test.dart index 6434acc9..ff3518fa 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_images_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/services/deck_generator_images_test.dart @@ -21,11 +21,17 @@ void main() { ), generator: generator, runId: 'run-42', + backgroundColor: '#BASE00', + backgroundColorsByTreatment: const {'visual': '#VISUAL'}, onProgress: (completed, total) => progress.add((completed, total)), ); expect(generator.requests, hasLength(3)); expect(generator.maximumActive, 3); + expect( + generator.requests.map((request) => request.prompt), + everyElement(allOf(contains('#VISUAL'), isNot(contains('#BASE00')))), + ); expect(result.assets, hasLength(3)); expect(result.assets.map((asset) => asset.assetKey), [ 'wizard-run-42-slide-01-opening-wave.png', diff --git a/packages/playground/test/features/ai/quick_agent/core/engine/services/generated_slide_validator_test.dart b/packages/playground/test/features/ai/quick_agent/core/engine/services/generated_slide_validator_test.dart index 76af71c8..bfef3382 100644 --- a/packages/playground/test/features/ai/quick_agent/core/engine/services/generated_slide_validator_test.dart +++ b/packages/playground/test/features/ai/quick_agent/core/engine/services/generated_slide_validator_test.dart @@ -538,6 +538,80 @@ void main() { ); }); + test('top-aligns a title row that also contains supporting copy', () { + final raw = { + 'key': 'test-slide', + 'options': {'title': 'Grow With Us', 'style': 'data'}, + 'sections': [ + { + 'type': 'section', + 'blocks': [ + { + 'type': 'block', + 'content': + '## Grow With Us\n\n' + 'Sponsor support determines the next planting season.', + }, + ], + }, + { + 'type': 'section', + 'blocks': [ + {'type': 'block', 'content': '### Families\n\nJoin a workday.'}, + {'type': 'block', 'content': '### Staff\n\nNominate a site.'}, + {'type': 'block', 'content': '### Sponsors\n\nFund materials.'}, + ], + }, + ], + }; + + final normalized = normalizeGeneratedSlideForPlan( + rawSlide: raw, + planSlide: _planSlide(composition: 'threeColumn'), + ); + final sections = normalized['sections']! as List; + final titleBlock = + ((sections.first as Map)['blocks']! as List).single as Map; + + expect(titleBlock['align'], 'topLeft'); + expect((sections.first as Map)['flex'], 1); + expect((sections.last as Map)['flex'], 2); + }); + + test('balances image split columns to preserve text height', () { + final raw = { + 'key': 'test-slide', + 'options': {'title': 'Making Meadow Magic', 'style': 'visual'}, + 'sections': [ + { + 'type': 'section', + 'blocks': [ + { + 'type': 'widget', + 'name': 'image', + 'args': {'src': 'assets/meadow.png'}, + 'flex': 3, + }, + { + 'type': 'block', + 'content': '## Making Meadow Magic\n\n- One\n- Two\n- Three', + 'flex': 2, + }, + ], + }, + ], + }; + + final normalized = normalizeGeneratedSlideForPlan( + rawSlide: raw, + planSlide: _planSlide(composition: 'imageLeft', treatment: 'visual'), + ); + final blocks = + (((normalized['sections'] as List).single as Map)['blocks'] as List); + + expect(blocks.map((block) => (block as Map)['flex']), everyElement(1)); + }); + test('drops invalid optional comments while preserving visible content', () { final raw = _slideWithBlock({ 'type': 'block', From 559c78ce0738bbe2f04c11ae7ab09bd12bc5cf42 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 16 Jul 2026 09:30:44 -0400 Subject: [PATCH 15/15] test(playground): add multi-run generation review --- packages/playground/README.md | 23 +- .../ai_generation_smoke_test.dart | 496 ++++++++++++------ .../fixtures/playful_giraffes_10.txt | 43 ++ 3 files changed, 407 insertions(+), 155 deletions(-) create mode 100644 packages/playground/test_live/ai_generation/fixtures/playful_giraffes_10.txt diff --git a/packages/playground/README.md b/packages/playground/README.md index 142d35cb..6fc93ff8 100644 --- a/packages/playground/README.md +++ b/packages/playground/README.md @@ -67,7 +67,28 @@ fvm flutter test test_live/ai_generation/ai_generation_smoke_test.dart \ ``` Set `--dart-define=LIVE_FIXTURE=narrative`, `comparison_table`, or -`visual_elements` to run one fast fixture. Run the larger quality matrix with: +`visual_elements` to run one fast fixture. + +Use `--dart-define=LIVE_FIXTURE=superdeck_demo_10` for the booth product story +or `--dart-define=LIVE_FIXTURE=playful_giraffes_10` for the fictional Great +Giraffe Garden Party with exact story beats and three watercolor illustrations. + +Repeat a fixture to evaluate consistency across independent generations: + +```bash +fvm flutter test test_live/ai_generation/ai_generation_smoke_test.dart \ + --dart-define-from-file=../../.env \ + --dart-define=LIVE_FIXTURE=playful_giraffes_10 \ + --dart-define=LIVE_RUNS=3 \ + --reporter expanded +``` + +`LIVE_RUNS` accepts 1–10. Two or more completed runs also produce a consolidated +`review_*` artifact directory containing `review_board.png` and `manifest.json` +with each run's artifact path, timing, validation diagnostics, composition +distribution, and model-request counts. + +Run the larger quality matrix with: ```bash fvm flutter test test_live/ai_generation/ai_generation_smoke_test.dart \ diff --git a/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart b/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart index c9e1acda..fe6ca4a7 100644 --- a/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart +++ b/packages/playground/test_live/ai_generation/ai_generation_smoke_test.dart @@ -43,6 +43,7 @@ const _selectedFixture = String.fromEnvironment( 'LIVE_FIXTURE', defaultValue: 'all', ); +const _liveRunCount = int.fromEnvironment('LIVE_RUNS', defaultValue: 1); const _artifactPath = String.fromEnvironment('LIVE_ARTIFACT'); const _includeDebugLayout = bool.fromEnvironment('LIVE_DEBUG_LAYOUT'); const _renderThemeQualification = bool.fromEnvironment( @@ -55,9 +56,10 @@ const _largeDeckFixtures = [ 'decision_data_15', 'visual_product_20', ]; -const _focusedFixtures = ['superdeck_demo_10']; +const _focusedFixtures = ['superdeck_demo_10', 'playful_giraffes_10']; final _themeCatalog = PresentationThemeCatalog.withDefaults(); final _typographyCatalog = PresentationTypographyCatalog.withDefaults(); +final _loadedCaptureFontFamilies = {}; typedef _CaptureResult = ({ List pngs, @@ -66,6 +68,13 @@ typedef _CaptureResult = ({ Duration elapsed, }); +typedef _CompletedLiveRun = ({ + String fixture, + int runNumber, + Directory output, + File contactSheet, +}); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); // Initialize SuperDeck's fallback style while runtime fetching is disabled; @@ -433,167 +442,202 @@ void main() { return; } + if (_liveRunCount < 1 || _liveRunCount > 10) { + throw ArgumentError.value( + _liveRunCount, + 'LIVE_RUNS', + 'Use a value from 1 to 10.', + ); + } + + final completedRuns = <_CompletedLiveRun>[]; + tearDownAll(() async { + if (completedRuns.length < 2) return; + final review = await _writeMultiRunReview( + selectedFixture: _selectedFixture, + requestedRuns: _liveRunCount, + runs: completedRuns, + ); + // ignore: avoid_print + print('Multi-run review artifacts: ${review.path}'); + }); + for (final fixture in _selectedFixtures()) { - group('live generation: $fixture', () { - late Directory output; - late String markdown; - late ResolvedPresentationTheme theme; - late DeckPlanType plan; - late DeckGenerationRequest request; - late List slides; - List generatedImages = const []; - var traces = []; - var expectedSlideCount = 0; - var generationReady = false; + for (var runIndex = 0; runIndex < _liveRunCount; runIndex++) { + final runNumber = runIndex + 1; + group('live generation: $fixture [$runNumber/$_liveRunCount]', () { + late Directory output; + late String markdown; + late ResolvedPresentationTheme theme; + late DeckPlanType plan; + late DeckGenerationRequest request; + late List slides; + List generatedImages = const []; + var traces = []; + var expectedSlideCount = 0; + var generationReady = false; - test( - 'generates and writes model artifacts', - () async { - final brief = await File( - 'test_live/ai_generation/fixtures/$fixture.txt', - ).readAsString(); - final service = DeckGeneratorService( - apiKey: _apiKey, - imageGenerator: DartanticImageGenerator( + test( + 'generates and writes model artifacts', + () async { + final brief = await File( + 'test_live/ai_generation/fixtures/$fixture.txt', + ).readAsString(); + final service = DeckGeneratorService( apiKey: _apiKey, - modelName: geminiImageGenerationModel, - ), - ); - request = _requestForFixture(fixture, brief); - final planningStopwatch = Stopwatch()..start(); - final planning = await service.plan(request, onTrace: traces.add); - planningStopwatch.stop(); - expect(planning.success, isTrue, reason: planning.error); - final approvedPlan = planning.plan!; - final compositionStopwatch = Stopwatch()..start(); - final result = await service.generateFromPlan( - request, - approvedPlan, - onTrace: traces.add, - ); - compositionStopwatch.stop(); - output = await _createRunDirectory(fixture); - await File(p.join(output.path, 'timing.json')).writeAsString( - const JsonEncoder.withIndent(' ').convert({ - 'planningMs': planningStopwatch.elapsedMilliseconds, - 'compositionMs': compositionStopwatch.elapsedMilliseconds, - 'combinedGenerationMs': - planningStopwatch.elapsedMilliseconds + - compositionStopwatch.elapsedMilliseconds, - 'withinThirtySecondTarget': - planningStopwatch.elapsed + compositionStopwatch.elapsed <= - WizardGenerationController.generationBudget, - }), - ); - await File(p.join(output.path, 'brief.txt')).writeAsString(brief); - await File(p.join(output.path, 'request.json')).writeAsString( - const JsonEncoder.withIndent(' ').convert(request.toMap()), - ); - await File( - p.join(output.path, 'approved_outline.json'), - ).writeAsString( - const JsonEncoder.withIndent(' ').convert(approvedPlan), - ); - await File(p.join(output.path, 'trace.json')).writeAsString( - const JsonEncoder.withIndent( - ' ', - ).convert(traces.map((event) => event.toJson()).toList()), - ); - await _writeTraceArtifacts(output, traces, plan: result.plan); - - expect( - planningStopwatch.elapsed + compositionStopwatch.elapsed, - lessThanOrEqualTo(WizardGenerationController.generationBudget), - reason: 'Split generation exceeded the 30-second booth budget.', - ); + imageGenerator: DartanticImageGenerator( + apiKey: _apiKey, + modelName: geminiImageGenerationModel, + ), + ); + request = _requestForFixture(fixture, brief); + final planningStopwatch = Stopwatch()..start(); + final planning = await service.plan(request, onTrace: traces.add); + planningStopwatch.stop(); + expect(planning.success, isTrue, reason: planning.error); + final approvedPlan = planning.plan!; + final compositionStopwatch = Stopwatch()..start(); + final result = await service.generateFromPlan( + request, + approvedPlan, + onTrace: traces.add, + ); + compositionStopwatch.stop(); + output = await _createRunDirectory(fixture); + await File(p.join(output.path, 'timing.json')).writeAsString( + const JsonEncoder.withIndent(' ').convert({ + 'planningMs': planningStopwatch.elapsedMilliseconds, + 'compositionMs': compositionStopwatch.elapsedMilliseconds, + 'combinedGenerationMs': + planningStopwatch.elapsedMilliseconds + + compositionStopwatch.elapsedMilliseconds, + 'withinThirtySecondTarget': + planningStopwatch.elapsed + compositionStopwatch.elapsed <= + WizardGenerationController.generationBudget, + }), + ); + await File(p.join(output.path, 'brief.txt')).writeAsString(brief); + await File(p.join(output.path, 'request.json')).writeAsString( + const JsonEncoder.withIndent(' ').convert(request.toMap()), + ); + await File( + p.join(output.path, 'approved_outline.json'), + ).writeAsString( + const JsonEncoder.withIndent(' ').convert(approvedPlan), + ); + await File(p.join(output.path, 'trace.json')).writeAsString( + const JsonEncoder.withIndent( + ' ', + ).convert(traces.map((event) => event.toJson()).toList()), + ); + await _writeTraceArtifacts(output, traces, plan: result.plan); - expect(result.success, isTrue, reason: result.error); - expect(result.slides, isNotEmpty); - plan = result.plan!; - slides = result.slides; - generatedImages = result.generatedImages; - await _writeGeneratedImages(output, generatedImages); - if (request.imageStyleId != null) { expect( - generatedImages.where((asset) => asset.bytes != null), - isNotEmpty, - reason: 'Image-enabled smoke produced no usable artwork.', + planningStopwatch.elapsed + compositionStopwatch.elapsed, + lessThanOrEqualTo(WizardGenerationController.generationBudget), + reason: 'Split generation exceeded the 30-second booth budget.', ); - } - final deckJson = { - 'theme': serializeDeckThemeReference(result.plan!.theme), - 'slides': result.slides.map((slide) => slide.toMap()).toList(), - }; - await File( - p.join(output.path, 'deck.json'), - ).writeAsString(const JsonEncoder.withIndent(' ').convert(deckJson)); - markdown = const SlideSerializer().serialize(result.slides); - theme = result.theme!; - expectedSlideCount = result.slideCount; - await File(p.join(output.path, 'slides.md')).writeAsString(markdown); - await File(p.join(output.path, 'validation.json')).writeAsString( - const JsonEncoder.withIndent(' ').convert({ - 'success': result.success, - 'slideCount': result.slideCount, - 'validationEvents': traces - .where( - (event) => event.kind == GenerationTraceKind.validation, - ) - .map((event) => event.toJson()) - .toList(), - }), - ); - generationReady = true; - }, - skip: _apiKey.isEmpty, - timeout: const Timeout(Duration(minutes: 15)), - ); + expect(result.success, isTrue, reason: result.error); + expect(result.slides, isNotEmpty); + plan = result.plan!; + slides = result.slides; + generatedImages = result.generatedImages; + await _writeGeneratedImages(output, generatedImages); + if (request.imageStyleId != null) { + expect( + generatedImages.where((asset) => asset.bytes != null), + isNotEmpty, + reason: 'Image-enabled smoke produced no usable artwork.', + ); + } + final deckJson = { + 'theme': serializeDeckThemeReference(result.plan!.theme), + 'slides': result.slides.map((slide) => slide.toMap()).toList(), + }; + await File(p.join(output.path, 'deck.json')).writeAsString( + const JsonEncoder.withIndent(' ').convert(deckJson), + ); + markdown = const SlideSerializer().serialize(result.slides); + theme = result.theme!; + expectedSlideCount = result.slideCount; + await File( + p.join(output.path, 'slides.md'), + ).writeAsString(markdown); + await File(p.join(output.path, 'validation.json')).writeAsString( + const JsonEncoder.withIndent(' ').convert({ + 'success': result.success, + 'slideCount': result.slideCount, + 'validationEvents': traces + .where( + (event) => event.kind == GenerationTraceKind.validation, + ) + .map((event) => event.toJson()) + .toList(), + }), + ); - testWidgets( - 'captures PNGs and contact sheet', - (tester) async { - if (!generationReady) return; - final capture = await _captureSlides( - tester: tester, - output: output, - markdown: markdown, - theme: theme, - expectedSlideCount: expectedSlideCount, - generatedImages: generatedImages, - ); - await tester.runAsync(() => _writeContactSheet(output, capture.pngs)); - final report = GenerationQualityReport.evaluate( - request: request, - plan: plan, - slides: slides, - traces: traces, - replayedSlideCount: capture.replayedSlideCount, - capturedSlideCount: capture.pngs.length, - resolvedFontFamilies: capture.resolvedFontFamilies, - captureElapsed: capture.elapsed, - knownGeneratedAssetKeys: { - for (final asset in generatedImages) - if (asset.bytes != null) asset.assetKey, - }, - ); - await tester.runAsync( - () => - File(p.join(output.path, 'quality_report.json')).writeAsString( - const JsonEncoder.withIndent(' ').convert(report.toJson()), - ), - ); - expect(capture.pngs, hasLength(expectedSlideCount)); - expect( - report.passed, - isTrue, - reason: const JsonEncoder.withIndent(' ').convert(report.toJson()), - ); - }, - skip: _apiKey.isEmpty, - timeout: const Timeout(Duration(minutes: 5)), - ); - }); + generationReady = true; + }, + skip: _apiKey.isEmpty, + timeout: const Timeout(Duration(minutes: 15)), + ); + + testWidgets( + 'captures PNGs and contact sheet', + (tester) async { + if (!generationReady) return; + final capture = await _captureSlides( + tester: tester, + output: output, + markdown: markdown, + theme: theme, + expectedSlideCount: expectedSlideCount, + generatedImages: generatedImages, + ); + final contactSheet = (await tester.runAsync( + () => _writeContactSheet(output, capture.pngs), + ))!; + final report = GenerationQualityReport.evaluate( + request: request, + plan: plan, + slides: slides, + traces: traces, + replayedSlideCount: capture.replayedSlideCount, + capturedSlideCount: capture.pngs.length, + resolvedFontFamilies: capture.resolvedFontFamilies, + captureElapsed: capture.elapsed, + knownGeneratedAssetKeys: { + for (final asset in generatedImages) + if (asset.bytes != null) asset.assetKey, + }, + ); + await tester.runAsync( + () => File(p.join(output.path, 'quality_report.json')) + .writeAsString( + const JsonEncoder.withIndent(' ').convert(report.toJson()), + ), + ); + completedRuns.add(( + fixture: fixture, + runNumber: runNumber, + output: output, + contactSheet: contactSheet, + )); + expect(capture.pngs, hasLength(expectedSlideCount)); + expect( + report.passed, + isTrue, + reason: const JsonEncoder.withIndent( + ' ', + ).convert(report.toJson()), + ); + }, + skip: _apiKey.isEmpty, + timeout: const Timeout(Duration(minutes: 5)), + ); + }); + } } } @@ -724,6 +768,25 @@ DeckGenerationRequest _requestForFixture(String fixture, String brief) { imageStyleVersion: 1, maxGeneratedImages: 3, ), + 'playful_giraffes_10' => DeckGenerationRequest( + userIntent: brief, + slideCount: 10, + audience: 'Families, children, and playful demo visitors', + approach: + 'Whimsical storybook event recap with a clear beginning and end', + themeId: 'playful-learning', + emphasis: const [ + 'preserve every supplied story fact exactly', + 'make each giraffe scene expressive and easy to understand', + 'clearly distinguish the completed party from the proposed next party', + ], + designDirection: 'Sunny, whimsical, colorful, and storybook-like', + headlineFont: 'Lobster', + bodyFont: 'Open Sans', + imageStyleId: 'watercolor', + imageStyleVersion: 1, + maxGeneratedImages: 3, + ), 'decision_data_15' => DeckGenerationRequest( userIntent: brief, slideCount: 15, @@ -1150,6 +1213,7 @@ bool _isNetworkSource(String source) { Future> _loadCaptureFonts(ResolvedPresentationTheme theme) async { final families = {theme.headlineFamily, theme.bodyFamily}; + if (_loadedCaptureFontFamilies.containsAll(families)) return families; final fontStyles = []; for (final family in families) { final descriptor = _typographyCatalog.resolve(family); @@ -1174,6 +1238,7 @@ Future> _loadCaptureFonts(ResolvedPresentationTheme theme) async { await GoogleFonts.pendingFonts( fontStyles, ).timeout(const Duration(seconds: 30)); + _loadedCaptureFontFamilies.addAll(families); } on TimeoutException { throw StateError( 'Timed out resolving live capture fonts: ${families.join(', ')}.', @@ -1215,6 +1280,129 @@ Future _writeContactSheet(Directory output, List files) async { return file; } +Future _writeMultiRunReview({ + required String selectedFixture, + required int requestedRuns, + required List<_CompletedLiveRun> runs, +}) async { + final safeFixture = selectedFixture.replaceAll( + RegExp(r'[^a-zA-Z0-9_-]'), + '_', + ); + final output = await _createRunDirectory('review_$safeFixture'); + final entries = >[]; + final generationTimes = []; + var passedRuns = 0; + var diagnosticCount = 0; + + for (final run in runs) { + final timing = await _readJsonObject( + File(p.join(run.output.path, 'timing.json')), + ); + final quality = await _readJsonObject( + File(p.join(run.output.path, 'quality_report.json')), + ); + final generationMs = timing['combinedGenerationMs'] as int?; + if (generationMs != null) generationTimes.add(generationMs); + if (quality['passed'] == true) passedRuns++; + final issues = quality['issues']; + if (issues is List) diagnosticCount += issues.length; + + entries.add({ + 'fixture': run.fixture, + 'runNumber': run.runNumber, + 'artifactDirectory': p.basename(run.output.path), + 'contactSheet': p.basename(run.contactSheet.path), + 'timing': timing, + 'quality': { + 'passed': quality['passed'], + 'issues': quality['issues'], + 'counts': quality['counts'], + 'plan': quality['plan'], + 'distributions': quality['distributions'], + 'modelRequests': quality['modelRequests'], + }, + }); + } + + final board = await _writeReviewBoard(output, runs); + final averageGenerationMs = generationTimes.isEmpty + ? null + : generationTimes.reduce((a, b) => a + b) ~/ generationTimes.length; + await File(p.join(output.path, 'manifest.json')).writeAsString( + const JsonEncoder.withIndent(' ').convert({ + 'selectedFixture': selectedFixture, + 'requestedRunsPerFixture': requestedRuns, + 'completedRuns': runs.length, + 'reviewBoard': p.basename(board.path), + 'aggregate': { + 'passedRuns': passedRuns, + 'diagnosticIssueCount': diagnosticCount, + 'averageGenerationMs': averageGenerationMs, + 'minimumGenerationMs': generationTimes.isEmpty + ? null + : generationTimes.reduce((a, b) => a < b ? a : b), + 'maximumGenerationMs': generationTimes.isEmpty + ? null + : generationTimes.reduce((a, b) => a > b ? a : b), + 'allWithinThirtySecondTarget': entries.every( + (entry) => + ((entry['timing'] as Map)['withinThirtySecondTarget'] as bool?) ?? + false, + ), + }, + 'runs': entries, + }), + ); + return output; +} + +Future> _readJsonObject(File file) async { + final decoded = jsonDecode(await file.readAsString()); + if (decoded is! Map) { + throw StateError('Expected a JSON object in ${file.path}.'); + } + return Map.from(decoded); +} + +Future _writeReviewBoard( + Directory output, + List<_CompletedLiveRun> runs, +) async { + final sheets = []; + for (final run in runs) { + final decoded = image.decodePng(await run.contactSheet.readAsBytes()); + if (decoded != null) sheets.add(image.copyResize(decoded, width: 480)); + } + if (sheets.isEmpty) { + throw StateError('Cannot create a review board without contact sheets.'); + } + + final columns = sheets.length < 3 ? sheets.length : 3; + final rows = (sheets.length + columns - 1) ~/ columns; + final cellWidth = sheets.first.width; + final cellHeight = sheets + .map((sheet) => sheet.height) + .reduce((a, b) => a > b ? a : b); + final board = image.Image( + width: cellWidth * columns, + height: cellHeight * rows, + ); + image.fill(board, color: image.ColorRgb8(24, 24, 27)); + for (var index = 0; index < sheets.length; index++) { + image.compositeImage( + board, + sheets[index], + dstX: (index % columns) * cellWidth, + dstY: (index ~/ columns) * cellHeight, + ); + } + + final file = File(p.join(output.path, 'review_board.png')); + await file.writeAsBytes(image.encodePng(board)); + return file; +} + Future _expectThemeGolden(String themeId, File actualFile) async { final expectedFile = File( p.join('test_live', 'ai_generation', 'goldens', '$themeId.png'), diff --git a/packages/playground/test_live/ai_generation/fixtures/playful_giraffes_10.txt b/packages/playground/test_live/ai_generation/fixtures/playful_giraffes_10.txt new file mode 100644 index 00000000..3d4ca64d --- /dev/null +++ b/packages/playground/test_live/ai_generation/fixtures/playful_giraffes_10.txt @@ -0,0 +1,43 @@ +Create a ten-slide presentation called "The Great Giraffe Garden Party." This +is a fictional, playful storybook event recap for families, children, and demo +visitors. It should feel sunny, funny, and visually delightful while following +one clear story from the first idea through the final celebration. + +Treat the following as the complete story record. Five giraffe friends—Gigi, +Zuri, Tamu, Nia, and Pip—planned a sunset garden party at the fictional Sunbeam +Savanna Club. They had 3 hours to prepare 4 party stations: a welcome arch, a +music corner, a picnic table, and a lantern path. Their supplies included 12 +paper lanterns, 24 fruit cups, and 60 leaf-shaped invitations. Gigi and Zuri +built the flower-covered welcome arch and greeted guests. Tamu arranged the +music corner, Nia prepared the picnic table, and Pip organized the supplies and +lantern path. A gust of wind knocked over 3 blank, arrow-shaped wooden direction +signs. The signs had no words or lettering. The friends stabilized every sign +with pebble-filled baskets and bright ribbons. By sunset, all 4 stations were +ready, all 12 lanterns were hanging, and 48 guests arrived. The friends served +all 24 fruit cups. After the party, they used the remaining leaf-shaped +invitations as scrapbook pages and decorated them with saved ribbon pieces. + +These are fictional story details, not real wildlife observations. Cheerful +descriptive language is welcome, but do not add real-world giraffe facts, +locations, species claims, animal behavior claims, new characters, new +character roles, additional quantities, sponsors, prices, URLs, or outcomes. +Preserve every supplied character assignment. If a factual story detail is not +supplied, omit it rather than inventing it. + +Tell a simple three-act story: introduce the five friends and their party idea; +show the preparation, the windy setback, and the colorful solution; then reveal +the finished party and close with a proposed next event. The proposed next +event is a moonrise picnic with a stargazing corner. It is only a proposal and +may include picnic blankets and playful cardboard telescope props. It has no +date, guest count, budget, or confirmed supplies. Include one compact +before-and-after comparison. End with a warm closing statement that invites the +audience to imagine the next adventure; do not invent or format it as a quote. + +For generated artwork, use recognizable, friendly storybook giraffes with +consistent golden-yellow coats, warm brown patches, expressive faces, and +natural-looking giraffe proportions. Favor three distinct scenes: the five +friends decorating together, the windy sign mishap with ribbons fluttering, +and the finished lantern-lit garden party at sunset. Keep the mood joyful and +gentle. Any wooden signs must be completely blank, with no lettering or symbols. +Do not include readable text, logos, charts, numbers, borders, or presentation +chrome inside the artwork.