From 6c4edb48ceba8ee851cf8487cf3c668fad3d8e89 Mon Sep 17 00:00:00 2001 From: Infographic Architect Date: Wed, 8 Jul 2026 19:43:10 +0000 Subject: [PATCH] Expose agent loop UX --- CHANGELOG.md | 3 + README.md | 6 +- docs/architecture.md | 56 ++++- docs/assets/create-step.svg | 4 - docs/learnings.md | 14 +- skill/infographic-agent/README.md | 3 +- skill/infographic-agent/SKILL.md | 20 +- .../infographic-agent/portable_infographic.py | 27 ++- src/App.tsx | 1 + .../hooks/useInfographicFlow.test.ts | 108 +++++++++ src/components/AdminPanel.tsx | 11 +- src/components/ChatPanel.tsx | 22 +- src/components/StepCreate.tsx | 5 +- src/components/StepStudio.tsx | 7 +- src/components/ThoughtStream.tsx | 75 ++++++- src/hooks/useInfographicFlow.ts | 208 ++++++++++++++++-- src/services/geminiService.ts | 1 - src/types.ts | 26 ++- 18 files changed, 523 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67fe872..a193b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Web model and resolution controls locked**: the web app now always uses `gemini-3.5-flash` for research/planning and `gemini-3.1-flash-lite-image` for image generation/refinement, including stale localStorage migrations. Web resolution choices are limited to 0.5K, 1K, and 2K. +- **Portable skill quality model option**: the CLI skill keeps `gemini-3.1-flash-lite-image` as its default image model and adds a skill-only `--image-model gemini-3.1-flash-image` option for quality-focused runs. +- **Agent loop UX surfaced in Studio**: the generation and refinement flows now expose the bounded loop state (research, plan, render, review, refine), turn count, HITL review status, and stop rule instead of only showing a generic thinking/refining state. - **Bumped free trial turn limit**: Increased the default trial turn limit from 3 to 5 to give users more free generations before requiring their own Gemini API key. - **Portable skill rewritten to match the web pipeline (`infographic-agent` v3.0.0)**: the skill now generates infographics **directly with Gemini** using the same two agents as the web demo — a research orchestrator (`gemini-3.5-flash`) grounds the topic with Google Search and engineers a text-accurate prompt, then `gemini-3.1-flash-lite-image` renders the PNG. Replaces the previous HTML/CSS + headless-Chromium screenshot approach. - **No browser dependencies**: removed Playwright/Chromium from the skill entirely. `npx infographic-agent --install` now just runs `pip install google-genai pillow` (seconds, not a browser download). diff --git a/README.md b/README.md index 0914646..5de52da 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Upload a PDF, paste a URL, or just describe a topic — a two-agent Gemini pipel - **Multi-format input** — PDF, CSV/spreadsheets, images (PNG/JPEG/WebP/HEIC), plain text, or just a topic description - **6 infographic modes** — Data Story, Executive Summary, Classroom Explainer, Technical Deep-Dive, Quick Slide, and fully Custom -- **Configurable output** — 6 aspect ratios (square, portrait, landscape, and more) and resolution from 0.5K up to 4K +- **Configurable output** — 6 aspect ratios (square, portrait, landscape, and more) and resolution from 0.5K up to 2K - **Live "thought" stream** — watch the agent's reasoning render as streaming cards while it researches and designs - **Multi-turn refinement chat** — keep talking to the agent to tweak colors, layout, or content after the first draft - **Before/after slider** — compare each revision against the original at a glance @@ -107,6 +107,7 @@ The CLI options and flags are shared between the `npx infographic-agent` command | `--mode` | `-m` | Preset layout and style theme for the infographic. | `data-story`
Choices: `classroom`, `custom`, `data-story`, `executive-summary`, `quick-slide`, `technical-deep-dive` | | `--aspect` | `-a` | Aspect ratio of the generated infographic image. | `9:16`
Choices: `1:1`, `1:4`, `3:4`, `4:3`, `9:16`, `16:9` | | `--instructions` | `-i` | Custom layout, design, or style rules (e.g. brand hex colors, font preferences). | `""` | +| `--image-model` | *None* | Image model for the portable skill. The web app remains locked to `gemini-3.1-flash-lite-image`. | `gemini-3.1-flash-lite-image`
Choices: `gemini-3.1-flash-lite-image`, `gemini-3.1-flash-image` | | `--no-research` | *None* | Skip the research agent and generate directly from your text (faster, doesn't use Google Search). | *Flag* | | `--no-open` | *None* | Do not auto-open the generated infographic image in the default system viewer. | *Flag* | | `--yes` | `-y` | Non-interactive execution. Generates once and exits immediately without entering the refine loop. | *Flag* (best for CI or autonomous agents) | @@ -325,6 +326,5 @@ infographic-agent/ - **App keeps asking for an API key** — get a free one at [aistudio.google.com/apikey](https://aistudio.google.com/apikey) and either put it in `.env` as `VITE_GEMINI_API_KEY` (dev) or paste it into the settings panel (it's stored in your browser only). - **"File exceeds maximum size" / files silently skipped** — individual files are capped at 20MB, 50MB total per generation, up to 14 files; split large PDFs or compress images. -- **Generation feels slow** — the analysis agent may search the web or read large files before the image agent starts rendering; watch the thought stream, it's usually still working, not stuck. Higher resolutions (3K/4K) also take longer. +- **Generation feels slow** — the analysis agent may search the web or read large files before the image agent starts rendering; watch the thought stream, it's usually still working, not stuck. Use 0.5K or 1K for faster web-app iteration, then upgrade to 2K when the layout is approved. - **`npm run dev` prints `spawn xdg-open ENOENT`** — harmless in headless environments (SSH, containers, CI): the server is running fine, there's just no browser to auto-open. Visit `http://localhost:3456` yourself. - diff --git a/docs/architecture.md b/docs/architecture.md index 783139e..d2d041f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,6 +51,52 @@ The infographic generation process is structured in three primary stages to opti └───────────────────────────────┘ ``` +The web app exposes this as a bounded agent loop rather than a plain loading state: + +| Loop phase | Responsibility | Stop / handoff condition | +| --- | --- | --- | +| Intake | Collect files, pasted text, URLs, mode, aspect ratio, and quality settings. | User starts generation. | +| Research | Ground claims against uploaded content, Google Search, and URL context. | Enough attributable facts exist for a plan. | +| Plan | Finalize exact text strings, layout hierarchy, colors, and prompt. | Structured `PrepareResult` parses successfully. | +| Render | Send the engineered prompt and reference images to the image model. | A valid image response is returned. | +| Review | Hold the current artifact for human approval or feedback. | Stop unless the user requests a targeted edit. | +| Refine | Apply one focused edit using the current image and chat history. | Return to Review with a new revision. | + +This contract is represented in the app as `AgentLoopState` (`src/types.ts`) and updated by `useInfographicFlow.ts`. It keeps a stable `sessionId`, a monotonic `turn`, a user-visible `goal`, a `stopRule`, phase statuses, and a state backend label. The browser build currently uses `stateBackend: "browser-local"` because the app has no server component and users bring an AI Studio Gemini API key. Chat history, current image state, and generated revisions are preserved in React state and IndexedDB. + +### Enterprise Interactions API Adapter + +For Gemini Enterprise Agent Platform deployments, the same loop can be backed by the stateful Gemini Interactions API instead of browser-local chat history. That adapter should persist the returned `interaction.id` and pass it as `previousInteractionId` on the next HITL turn: + +```typescript +import { GoogleGenAI } from '@google/genai'; + +const ai = new GoogleGenAI({ + enterprise: { + project: 'your-project-id', + location: 'global', + }, +}); + +const turn1 = await ai.interactions.create({ + model: 'gemini-3.5-flash', + input: 'Research and plan an infographic from the supplied source package.', + store: true, + tools: [{ googleSearch: {} }, { urlContext: {} }], + systemInstruction: prepareSystemInstruction, +}); + +const turn2 = await ai.interactions.create({ + model: 'gemini-3.5-flash', + input: 'Apply the user review: make the title larger.', + previousInteractionId: turn1.id, + store: true, + systemInstruction: refineSystemInstruction, +}); +``` + +Interactions require the unified Google Gen AI SDK (`@google/genai >= 2.0.0` or `google-genai >= 2.0.0`). Legacy packages such as `@google/generative-ai`, `google-generativeai`, `@google-cloud/vertexai`, and `google-cloud-aiplatform` are unsupported for Interactions. Legacy models such as `gemini-2.0-*` and `gemini-1.5-*` are deprecated and unsupported for Interactions. Parameters like `tools`, `systemInstruction`, and generation settings are turn-scoped, so the adapter must send them with every interaction request. Keep the repo's model contract intact: `gemini-3.5-flash` for research/planning and `gemini-3.1-flash-lite-image` for web-app image rendering. + --- ## 2. Agent 1: Prepare (Content Analysis & Layout Planning) @@ -189,7 +235,7 @@ const response = await client.models.generateContent('gemini-3.1-flash-lite-imag }, imageConfig: { aspectRatio: '16:9', - resolution: '1024x576', // Configurable: 1K-4K + resolution: '1024x576', // Web app choices: 0.5K, 1K, or 2K }, thinkingConfig: { thinkingLevel: 'HIGH', // For debugging @@ -200,15 +246,15 @@ const response = await client.models.generateContent('gemini-3.1-flash-lite-imag ### Model Selection -- **Primary**: `gemini-3.1-flash-lite-image` (default, 3-5 seconds, good balance) -- **Alternative**: `gemini-3.1-flash-image` (higher quality, slower, 8-12 seconds) -- **Performance**: Flash Lite produces quality output 40% faster than Flash +- **Web app image model**: `gemini-3.1-flash-lite-image` only, for predictable latency and tool compatibility. +- **Portable skill option**: `gemini-3.1-flash-image` may be used by skill/CLI workflows that explicitly choose quality over latency. +- **Analysis model**: `gemini-3.5-flash` only, because search and URL-context tools belong in the research/planning phase. ### Key Capabilities 1. **Native Image Generation** - Renders PNG images at specified aspect ratios and resolutions - - Supports 1K (1024x576), 2K (2048x1152), and 4K (4096x2304) resolutions + - Web app exposes 0.5K, 1K, and 2K only; 0.5K maps to the model's supported 1K request size 2. **Layout Enforcement** - Follows step-by-step spatial instructions from Agent 1 diff --git a/docs/assets/create-step.svg b/docs/assets/create-step.svg index e0d1e62..ddf2258 100644 --- a/docs/assets/create-step.svg +++ b/docs/assets/create-step.svg @@ -119,10 +119,6 @@ 1K 2K - - 3K - - 4K diff --git a/docs/learnings.md b/docs/learnings.md index 2ab36fe..97538e0 100644 --- a/docs/learnings.md +++ b/docs/learnings.md @@ -23,11 +23,11 @@ The SDK types do not always support newer parameters. Use the `as any` cast patt ### Model Capabilities & Thinking Levels - **`gemini-3.5-flash`** (default orchestrator): Supports detailed reasoning. Configured with `thinkingConfig: { thinkingLevel: 'HIGH' }` for layout planning. -- **`gemini-3.1-flash-lite-image`** (default image generator/refiner): Optimized for faster image generation (3-5 seconds) with native image generation and refinement. This is the recommended model for most use cases due to its speed. Supports thinking config for streaming thoughts but **rejects `thinkingLevel: 'LOW'`** with a 400 error. It must be configured with `thinkingLevel: 'HIGH'` to return thought streams properly. +- **`gemini-3.1-flash-lite-image`** (required web-app image generator/refiner): Optimized for faster image generation (3-5 seconds) with native image generation and refinement. The web app keeps this model fixed for predictable latency and tool compatibility. Supports thinking config for streaming thoughts but **rejects `thinkingLevel: 'LOW'`** with a 400 error. It must be configured with `thinkingLevel: 'HIGH'` to return thought streams properly. - **Behavior**: Consistently generates valid PNG images at any aspect ratio - **Limitations**: Slightly lower fidelity on text rendering compared to Flash (but still acceptable for most infographics) - - **Recommendation**: Use for rapid iteration and user-facing workflows -- **`gemini-3.1-flash-image`** (alternative for quality-focused workflows): Provides enhanced quality for complex infographics with more accurate text rendering and visual fidelity. Use this when fast iteration is less important than output fidelity (8-12 seconds per generation). + - **Recommendation**: Use for rapid iteration and all web-app image workflows +- **`gemini-3.1-flash-image`** (portable skill quality option only): Provides enhanced quality for skill/CLI workflows where the caller explicitly chooses output fidelity over latency. Do not expose this model in the web app. - **Thinking Part Filtering**: When thinking is enabled, the model returns thoughts alongside the regular text parts. Always filter out the thought parts to parse the output JSON or text correctly: ```typescript const parts = response.candidates?.[0]?.content?.parts || []; @@ -83,6 +83,12 @@ The SDK types do not always support newer parameters. Use the `as any` cast patt - **Solution**: Adopt IndexedDB via `localforage` for persistent storage of infographic history. This bypasses the 5MB storage limit and supports large image blobs. - **Migration Safeguard**: Ensure a fallback schema merge when loading settings, preventing stale configurations from breaking the application when default models or settings are updated. +### Browser State vs Enterprise Interactions State +- **Context:** The app is a static, browser-only SPA where users supply AI Studio Gemini API keys, but the portable/enterprise agent story also needs durable multi-turn state and HITL iteration. +- **Learning:** Keep the web app's loop state explicit and local (`AgentLoopState`, chat history, current image, IndexedDB revisions). Do not call the Gemini Enterprise Interactions API directly from the browser path unless the product adds an Enterprise auth/runtime layer. +- **Evidence:** `src/hooks/useInfographicFlow.ts` updates loop phases and turn state locally; `docs/architecture.md` documents the `previousInteractionId` adapter for Enterprise Agent Platform. +- **Use next time:** Preserve the same intake -> research -> plan -> render -> review -> refine contract across app, CLI skill, and Enterprise adapters, but choose the state backend that matches the runtime. + --- ## 4. Design System & UX Decisions @@ -304,7 +310,7 @@ This ordering can reduce prompt processing cost by 10-20% on repeated requests. - **Thought streams**: Returns thoughts when `includeThoughts: true`, must filter in parsing - **Image output**: Requires `responseModalities: ['TEXT', 'IMAGE']` in config - **Aspect ratios**: Supports any aspect ratio (16:9, 1:1, 21:9, etc.) via `imageConfig` -- **Resolutions**: Supports 1K-4K; higher resolutions = longer generation (~2x slower at 4K) +- **Resolutions**: The web app exposes only 0.5K, 1K, and 2K. For `gemini-3.1-flash-lite-image`, 0.5K and higher web choices map to the model's supported 1K request size. - **Text fidelity**: Good but not perfect; prefer verbatim quotes from Agent 1 - **Generation time**: 3-5 seconds typical, up to 15 seconds on overload diff --git a/skill/infographic-agent/README.md b/skill/infographic-agent/README.md index f7fc7d6..a44038b 100644 --- a/skill/infographic-agent/README.md +++ b/skill/infographic-agent/README.md @@ -40,7 +40,7 @@ npx infographic-agent "Top 5 programming languages in 2026" The skill runs a fast, browser-less two-agent pipeline powered by Gemini: 1. **Research & Plan (`gemini-3.5-flash`)**: Grounds the topic with Google Search and engineers a precise, text-accurate prompt. -2. **Render (`gemini-3.1-flash-lite-image`)**: Renders the prompt directly into a high-quality, text-accurate infographic PNG. +2. **Render (`gemini-3.1-flash-lite-image` by default)**: Renders the prompt directly into a high-quality, text-accurate infographic PNG. The portable skill can opt into `gemini-3.1-flash-image` via `--image-model` for quality-focused runs. ## 🛠️ CLI Flags & Options @@ -53,6 +53,7 @@ All flags are fully compatible between the Python script and the `npx` execution | `--mode` | `-m` | Preset layout and style theme. | `data-story`
Choices: `classroom`, `custom`, `data-story`, `executive-summary`, `quick-slide`, `technical-deep-dive` | | `--aspect` | `-a` | Aspect ratio of the generated infographic image. | `9:16`
Choices: `1:1`, `1:4`, `3:4`, `4:3`, `9:16`, `16:9` | | `--instructions` | `-i` | Custom layout, design, or brand color rules. | `""` | +| `--image-model` | *None* | Image model for the portable skill. | `gemini-3.1-flash-lite-image`
Choices: `gemini-3.1-flash-lite-image`, `gemini-3.1-flash-image` | | `--no-research` | *None* | Skip the research agent and generate directly (faster, no web search). | *Flag* | | `--yes` | `-y` | Non-interactive execution (no refine loop, best for CI/agents). | *Flag* | | `--setup` | *None* | Launch the interactive key onboarding walkthrough, then exit. | *Flag* | diff --git a/skill/infographic-agent/SKILL.md b/skill/infographic-agent/SKILL.md index 95525e7..9b46192 100644 --- a/skill/infographic-agent/SKILL.md +++ b/skill/infographic-agent/SKILL.md @@ -21,7 +21,7 @@ You are an expert AI Infographic Designer and Coordinator. You generate high-qua This skill mirrors the repo's web demo as a two-agent pipeline, both powered by Gemini: 1. **Research orchestrator (`gemini-3.5-flash`):** reads the user's topic/content, optionally grounds it with Google Search, and engineers a precise, text-accurate image-generation prompt. -2. **Image generator (`gemini-3.1-flash-lite-image`):** renders that prompt directly into a polished infographic PNG. +2. **Image generator (`gemini-3.1-flash-lite-image` by default):** renders that prompt directly into a polished infographic PNG. The portable skill may use `gemini-3.1-flash-image` via `--image-model` when the caller explicitly chooses quality over latency. The web app remains locked to `gemini-3.1-flash-lite-image`. After the first draft, an interactive refine loop lets the user iterate ("make the header bolder", "use teal accents") — each turn re-invokes the image model with the previous image plus the edit, saving a new revision in seconds. @@ -30,6 +30,21 @@ The entire workflow lives in `portable_infographic.py`. There are **no browser d **Security posture:** the Gemini API key is user-provided. If not set via `GEMINI_API_KEY`, the CLI walks the user through getting a free key from Google AI Studio and stores it locally at `~/.config/infographic-agent/config.json` with `0600` permissions. Errors are scrubbed of anything that looks like a credential before printing. If this skill is invoked autonomously, treat `--output` as trusted input — the path is resolved but will write wherever the invoker points it. + +Run this skill as a bounded human-in-the-loop agent loop: + +1. **Intake:** collect topic/content, mode, aspect ratio, output path, and any brand/style constraints. +2. **Research:** use the research orchestrator unless `--no-research` is set; never invent data points. +3. **Plan:** produce the exact text strings, layout, palette, and image prompt before rendering. +4. **Render:** call the image model once for the current plan and save the artifact. +5. **Review:** stop for human review unless `--yes` was passed. +6. **Refine:** apply one focused edit per turn, preserving the previous image as state, then return to Review. + +Portable state is held locally by the CLI as the current image path plus turn history. Agents that support Gemini Enterprise Agent Platform can replace that local state with the Gemini Interactions API by storing each returned `interaction.id` and passing it as `previousInteractionId` on the next review/refine turn. + +Interactions API note: use the unified Google Gen AI SDK (`@google/genai >= 2.0.0` or `google-genai >= 2.0.0`). Legacy packages such as `@google/generative-ai`, `google-generativeai`, `@google-cloud/vertexai`, and `google-cloud-aiplatform` are unsupported for Interactions, and legacy models such as `gemini-2.0-*` and `gemini-1.5-*` are deprecated and unsupported. Turn-scoped settings such as tools, system instructions, and generation config must be sent on every interaction request. + + 1. **Identify Request:** Confirm the user wants to generate an infographic. 2. **Install Skill:** The easiest way to install this skill into any AI coding agent: @@ -77,6 +92,7 @@ python3 skill/infographic-agent/portable_infographic.py \ - `--mode` — `data-story` (default), `executive-summary`, `technical-deep-dive`, `classroom`, `quick-slide`, `custom` - `--aspect` — `1:1`, `9:16` (default), `16:9`, `3:4`, `4:3`, `1:4` - `--instructions` — extra styling/content guidance +- `--image-model` — `gemini-3.1-flash-lite-image` (default) or skill-only `gemini-3.1-flash-image` - `--no-research` — skip the research agent and generate directly from the text (faster, no web grounding) - `--yes` — non-interactive: generate once and exit (use this when running autonomously or in CI) - `--no-open` — do not auto-open the result in an image viewer @@ -87,7 +103,7 @@ When invoking this skill autonomously (no human at the terminal), always pass `- ### Alternative: orchestrate directly with subagents If you prefer to orchestrate without the script: 1. Ask a research/LLM subagent (e.g. `gemini-3.5-flash`) to produce a dense, text-accurate image-generation prompt from the user's content. -2. Send that prompt to an image model (`gemini-3.1-flash-lite-image`) with `responseModalities: ['TEXT', 'IMAGE']` and save the returned PNG. +2. Send that prompt to an image model (`gemini-3.1-flash-lite-image` by default; `gemini-3.1-flash-image` for skill-only quality runs) with `responseModalities: ['TEXT', 'IMAGE']` and save the returned PNG. 3. Provide the link to the user, and offer refinement turns by re-sending the previous image plus the edit instruction. diff --git a/skill/infographic-agent/portable_infographic.py b/skill/infographic-agent/portable_infographic.py index f7bb826..458abe7 100755 --- a/skill/infographic-agent/portable_infographic.py +++ b/skill/infographic-agent/portable_infographic.py @@ -52,11 +52,13 @@ sys.exit(1) # --------------------------------------------------------------------------- # -# Constants — keep these model IDs in lockstep with the web demo (src/types.ts) +# Constants — keep the default model IDs in lockstep with the web demo (src/types.ts) # --------------------------------------------------------------------------- # ORCHESTRATOR_MODEL = "gemini-3.5-flash" # research + prompt engineering IMAGE_MODEL = "gemini-3.1-flash-lite-image" # direct infographic rendering +QUALITY_IMAGE_MODEL = "gemini-3.1-flash-image" # skill-only quality option +SUPPORTED_IMAGE_MODELS = (IMAGE_MODEL, QUALITY_IMAGE_MODEL) AISTUDIO_KEY_URL = "https://aistudio.google.com/apikey" CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "infographic-agent" @@ -423,21 +425,22 @@ def _normalize_to_png(data: bytes, mime: str): return data, m -def generate_image(client, prompt: str, aspect: str): +def generate_image(client, prompt: str, aspect: str, image_model: str): config = types.GenerateContentConfig( response_modalities=["TEXT", "IMAGE"], image_config=types.ImageConfig(aspect_ratio=aspect, image_size="1K"), http_options=types.HttpOptions(timeout=180_000), ) - info("🎨 Generating the infographic (gemini-3.1-flash-lite-image)...") + info(f"🎨 Generating the infographic ({image_model})...") return _call_image_model( client, contents=[f"{prompt}\n\n{IMAGE_SYSTEM_PROMPT}"], config=config, + image_model=image_model, ) -def refine_image(client, image_bytes: bytes, mime: str, instruction: str, aspect: str): +def refine_image(client, image_bytes: bytes, mime: str, instruction: str, aspect: str, image_model: str): config = types.GenerateContentConfig( response_modalities=["TEXT", "IMAGE"], image_config=types.ImageConfig(aspect_ratio=aspect, image_size="1K"), @@ -452,16 +455,16 @@ def refine_image(client, image_bytes: bytes, mime: str, instruction: str, aspect ) ), ] - return _call_image_model(client, contents=contents, config=config) + return _call_image_model(client, contents=contents, config=config, image_model=image_model) -def _call_image_model(client, contents, config): +def _call_image_model(client, contents, config, image_model: str): """Call the image model with retries; return (png_bytes, mime_type).""" last_error = None for attempt in range(3): try: response = client.models.generate_content( - model=IMAGE_MODEL, contents=contents, config=config + model=image_model, contents=contents, config=config ) data, mime = _extract_image(response) return _normalize_to_png(data, mime) @@ -509,7 +512,7 @@ def open_output(path: str) -> None: # Interactive refine loop — fast, iterative preview # --------------------------------------------------------------------------- # -def refine_loop(client, image_bytes: bytes, mime: str, output_path: str, aspect: str, auto_open: bool) -> None: +def refine_loop(client, image_bytes: bytes, mime: str, output_path: str, aspect: str, auto_open: bool, image_model: str) -> None: info( "\n💬 Refine it, or press Enter to finish.\n" ' e.g. "make the header bolder", "use teal accents", "add source citations"' @@ -525,7 +528,7 @@ def refine_loop(client, image_bytes: bytes, mime: str, output_path: str, aspect: if not instruction or instruction.lower() in ("q", "quit", "exit", "done"): break try: - image_bytes, mime = refine_image(client, image_bytes, mime, instruction, aspect) + image_bytes, mime = refine_image(client, image_bytes, mime, instruction, aspect, image_model) except Exception as e: # noqa: BLE001 error(_scrub(str(e))) continue @@ -554,6 +557,8 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--aspect", "-a", default="9:16", choices=sorted(SUPPORTED_ASPECTS), help="Aspect ratio (default: 9:16)") parser.add_argument("--instructions", "-i", default="", help="Extra styling / content instructions") + parser.add_argument("--image-model", default=IMAGE_MODEL, choices=SUPPORTED_IMAGE_MODELS, + help="Image model for the portable skill (default: gemini-3.1-flash-lite-image)") parser.add_argument("--no-research", action="store_true", help="Skip the research agent and generate directly from your text") parser.add_argument("--no-open", action="store_true", help="Do not auto-open the result") @@ -592,7 +597,7 @@ def main() -> None: prompt = direct_prompt(content, args.mode, args.instructions) else: prompt = research_prompt(client, content, args.mode, args.aspect, args.instructions) - image_bytes, mime = generate_image(client, prompt, args.aspect) + image_bytes, mime = generate_image(client, prompt, args.aspect, args.image_model) except Exception as e: # noqa: BLE001 error(_friendly_api_error(e)) sys.exit(1) @@ -606,7 +611,7 @@ def main() -> None: interactive = not args.yes and sys.stdin.isatty() if interactive: - refine_loop(client, image_bytes, mime, path, args.aspect, auto_open) + refine_loop(client, image_bytes, mime, path, args.aspect, auto_open, args.image_model) info("\nDone. 🎉") diff --git a/src/App.tsx b/src/App.tsx index 2b643db..fc51760 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -138,6 +138,7 @@ export default function App() { thoughtBubbles={state.thoughtBubbles} chatMessages={state.chatMessages} refineThoughts={state.refineThoughts} + agentLoop={state.agentLoop} mode={state.config.mode} aspectRatio={state.config.aspectRatio} onOpenSettings={() => setShowAdmin(true)} diff --git a/src/__tests__/hooks/useInfographicFlow.test.ts b/src/__tests__/hooks/useInfographicFlow.test.ts index b74c31d..a1c00f3 100644 --- a/src/__tests__/hooks/useInfographicFlow.test.ts +++ b/src/__tests__/hooks/useInfographicFlow.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { useInfographicFlow } from '../../hooks/useInfographicFlow'; import { DEFAULT_INFOGRAPHIC_CONFIG, DEFAULT_ADMIN_CONFIG } from '../../types'; +import * as geminiService from '../../services/geminiService'; // Mock the services vi.mock('../../services/fileProcessor', () => ({ @@ -19,6 +20,8 @@ vi.mock('../../services/geminiService', () => ({ prepareInfographic: vi.fn(), generateInfographic: vi.fn(), generateFilename: vi.fn(), + refineInfographic: vi.fn(), + incrementTrialTurns: vi.fn(), })); vi.mock('../../services/downloadService', () => ({ @@ -47,6 +50,7 @@ describe('useInfographicFlow', () => { expect(result.current.state.generationPhase).toBe('idle'); expect(result.current.state.error).toBeNull(); expect(result.current.state.theme).toBe('light'); + expect(result.current.state.agentLoop.hitlStatus).toBe('not-started'); }); // Skipped: initialState is computed at module import, before the test can @@ -74,6 +78,30 @@ describe('useInfographicFlow', () => { expect(result.current.state.adminConfig.geminiApiKey).toBe('test-key-123'); }); + + it('should enforce supported models from stale stored admin config', async () => { + localStorage.setItem('infographic-admin-config', JSON.stringify({ + geminiApiKey: 'test-key-123', + orchestratorModel: 'gemini-3-flash-preview', + imageGenModel: 'gemini-3.1-flash-image', + thinkingLevel: 'HIGH', + imageResolution: '4K', + timeoutSeconds: 180, + })); + localStorage.setItem('infographic-user-config', JSON.stringify({ + ...DEFAULT_INFOGRAPHIC_CONFIG, + resolution: '4K', + })); + + vi.resetModules(); + const { useInfographicFlow: useFreshInfographicFlow } = await import('../../hooks/useInfographicFlow'); + const { result } = renderHook(() => useFreshInfographicFlow()); + + expect(result.current.state.adminConfig.orchestratorModel).toBe('gemini-3.5-flash'); + expect(result.current.state.adminConfig.imageGenModel).toBe('gemini-3.1-flash-lite-image'); + expect(result.current.state.adminConfig.imageResolution).toBe('0.5K'); + expect(result.current.state.config.resolution).toBe('0.5K'); + }); }); describe('file management', () => { @@ -277,6 +305,86 @@ describe('useInfographicFlow', () => { }); }); + describe('agent loop state', () => { + beforeEach(() => { + vi.mocked(geminiService.prepareInfographic).mockResolvedValue({ + analysis: { + title: 'Loop Test', + subtitle: 'Stateful generation', + sectionsCount: 1, + dataPointsCount: 1, + brandColors: ['#4285F4'], + sourceAttribution: 'Uploaded content', + }, + prompt: 'Generate a professional infographic image for loop testing.', + allTextStrings: ['Loop Test'], + }); + vi.mocked(geminiService.generateInfographic).mockResolvedValue({ + imageData: 'mock-image', + description: 'Generated draft', + prompt: 'Generate a professional infographic image for loop testing.', + }); + vi.mocked(geminiService.generateFilename).mockResolvedValue('loop-test'); + vi.mocked(geminiService.refineInfographic).mockResolvedValue({ + imageData: 'mock-image-v2', + description: 'Refined draft', + prompt: 'Make the title larger', + }); + }); + + it('moves a completed generation into human review', async () => { + const { result } = renderHook(() => useInfographicFlow()); + + await act(async () => { + await result.current.handleGenerate(); + }); + + await waitFor(() => { + expect(result.current.state.generationPhase).toBe('complete'); + }); + + expect(result.current.state.agentLoop.turn).toBe(1); + expect(result.current.state.agentLoop.hitlStatus).toBe('awaiting-input'); + expect(result.current.state.agentLoop.phases.find(phase => phase.id === 'review')?.status).toBe('active'); + expect(result.current.state.agentLoop.phases.find(phase => phase.id === 'render')?.status).toBe('complete'); + }); + + it('advances the loop turn for a focused refinement', async () => { + const { result } = renderHook(() => useInfographicFlow()); + + await act(async () => { + await result.current.handleGenerate(); + }); + + await waitFor(() => { + expect(result.current.state.currentResult?.imageData).toBe('mock-image'); + }); + + const sessionId = result.current.state.agentLoop.sessionId; + + await act(async () => { + await result.current.handleRefine('Make the title larger'); + }); + + await waitFor(() => { + expect(result.current.state.currentResult?.imageData).toBe('mock-image-v2'); + }); + + expect(result.current.state.agentLoop.sessionId).toBe(sessionId); + expect(result.current.state.agentLoop.turn).toBe(2); + expect(result.current.state.agentLoop.hitlStatus).toBe('awaiting-input'); + expect(result.current.state.agentLoop.phases.find(phase => phase.id === 'review')?.status).toBe('active'); + expect(geminiService.refineInfographic).toHaveBeenCalledWith( + 'mock-image', + 'Make the title larger', + expect.any(Object), + expect.any(Object), + expect.any(Function), + expect.any(Array) + ); + }); + }); + describe('multiple file operations', () => { it('should handle adding multiple files in sequence', async () => { const { result } = renderHook(() => useInfographicFlow()); diff --git a/src/components/AdminPanel.tsx b/src/components/AdminPanel.tsx index 80d24bc..d3fcb63 100644 --- a/src/components/AdminPanel.tsx +++ b/src/components/AdminPanel.tsx @@ -3,10 +3,6 @@ import type { AdminConfig, ImageResolution } from '../types'; import { DEFAULT_ADMIN_CONFIG } from '../types'; import { saveApiKey, clearApiKey, hasApiKey, getTrialTurnsCount } from '../services/geminiService'; -const isMasterView = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('view') === 'master'; -const isProdDeploy = import.meta.env.PROD || import.meta.env.VITE_PRODUCTION_DEPLOY === 'true'; -const allowQualityModel = isMasterView && !isProdDeploy; - interface AdminPanelProps { config: AdminConfig; onUpdate: (partial: Partial) => void; @@ -22,7 +18,7 @@ const sectionClasses = 'p-6 border-b border-gborder-light dark:border-gborder-da export default function AdminPanel({ config, onUpdate, onClose }: AdminPanelProps) { const thinkingLevels: AdminConfig['thinkingLevel'][] = ['LOW', 'HIGH']; - const resolutions: ImageResolution[] = ['0.5K', '1K', '2K', '4K']; + const resolutions: ImageResolution[] = ['0.5K', '1K', '2K']; const [keyDraft, setKeyDraft] = useState(''); const [showKey, setShowKey] = useState(false); const [keyIsSet, setKeyIsSet] = useState(() => hasApiKey(config)); @@ -135,12 +131,9 @@ export default function AdminPanel({ config, onUpdate, onClose }: AdminPanelProp value={config.imageGenModel} onChange={(e) => onUpdate({ imageGenModel: e.target.value })} className={inputClasses} - disabled={!allowQualityModel} + disabled > - {allowQualityModel && ( - - )} diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 0fa7ae1..f7bfcbe 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback, createElement, useMemo, type ReactNode } from 'react'; -import type { ChatMessage, InfographicMode, ThoughtBubble } from '../types'; +import type { ChatMessage, InfographicMode, ThoughtBubble, AgentLoopState } from '../types'; import { QUICK_ACTIONS, GENERAL_QUICK_ACTIONS } from '../types'; interface ChatPanelProps { @@ -8,6 +8,7 @@ interface ChatPanelProps { onSendMessage: (text: string) => void; isRefining: boolean; refineThoughts: ThoughtBubble[]; + agentLoop: AgentLoopState; } /** Parse inline markdown (bold, italic, code) into React nodes */ @@ -101,7 +102,7 @@ function MarkdownContent({ text }: { text: string }) { return createElement('div', { className: 'text-xs text-gtext-secondary dark:text-gtext-secondary-dark space-y-0.5' }, ...elements); } -export default function ChatPanel({ messages, mode, onSendMessage, isRefining, refineThoughts }: ChatPanelProps) { +export default function ChatPanel({ messages, mode, onSendMessage, isRefining, refineThoughts, agentLoop }: ChatPanelProps) { const [input, setInput] = useState(''); const scrollRef = useRef(null); @@ -151,10 +152,19 @@ export default function ChatPanel({ messages, mode, onSendMessage, isRefining, r
{/* Header */}
-

- chat - Refine -

+
+

+ chat + Review & Refine +

+ + {isRefining ? 'sync' : 'person_check'} + Turn {agentLoop.turn || 1} + +
+

+ {agentLoop.stopRule} +

{/* Messages */} diff --git a/src/components/StepCreate.tsx b/src/components/StepCreate.tsx index 81d243a..73e916b 100644 --- a/src/components/StepCreate.tsx +++ b/src/components/StepCreate.tsx @@ -25,8 +25,6 @@ const RESOLUTION_OPTIONS: { label: string; shortLabel: string; value: ImageResol { label: 'Default 0.5K', shortLabel: '0.5K', value: '0.5K' }, { label: 'Fast 1K', shortLabel: '1K', value: '1K' }, { label: 'Standard 2K', shortLabel: '2K', value: '2K' }, - { label: 'Ultra 3K', shortLabel: '3K', value: '3K' }, - { label: 'High 4K', shortLabel: '4K', value: '4K' }, ]; function getCategoryIcon(category: string): string { @@ -272,7 +270,8 @@ export default function StepCreate({ { icon: 'link', label: 'Visit URLs' }, { icon: 'search', label: 'Google Search' }, { icon: 'analytics', label: 'Analyze data' }, - { icon: 'auto_awesome', label: 'AI research' }, + { icon: 'route', label: 'Plan then render' }, + { icon: 'person_check', label: 'HITL refine' }, ].map((cap) => ( void; @@ -41,6 +42,7 @@ export default function StepStudio({ thoughtBubbles, chatMessages, refineThoughts, + agentLoop, mode, aspectRatio, onClearError, @@ -133,6 +135,7 @@ export default function StepStudio({ phase={phase} elapsed={elapsed} prepareResult={prepareResult} + agentLoop={agentLoop} />
{currentImageUrl ? ( @@ -174,7 +177,7 @@ export default function StepStudio({ )} {error && }
- +
{previousImage && previousImage !== currentResult.imageData && !isRefining ? (
diff --git a/src/components/ThoughtStream.tsx b/src/components/ThoughtStream.tsx index 8ac1adc..60b28a4 100644 --- a/src/components/ThoughtStream.tsx +++ b/src/components/ThoughtStream.tsx @@ -1,11 +1,12 @@ import { useEffect, useRef, useMemo, createElement, type ReactNode } from 'react'; -import type { ThoughtBubble, PrepareResult, GenerationPhase } from '../types'; +import type { ThoughtBubble, PrepareResult, GenerationPhase, AgentLoopState } from '../types'; interface ThoughtStreamProps { thoughts: ThoughtBubble[]; phase: GenerationPhase; elapsed: number; prepareResult: PrepareResult | null; + agentLoop: AgentLoopState; } function formatTime(s: number) { @@ -103,7 +104,73 @@ function MarkdownContent({ text }: { text: string }) { return createElement('div', { className: 'text-xs text-gtext-secondary dark:text-gtext-secondary-dark space-y-0.5' }, ...elements); } -export default function ThoughtStream({ thoughts, phase, elapsed, prepareResult }: ThoughtStreamProps) { +function getPhaseIcon(status: AgentLoopState['phases'][number]['status']) { + if (status === 'complete') return 'check_circle'; + if (status === 'active') return 'progress_activity'; + return 'radio_button_unchecked'; +} + +function getPhaseClass(status: AgentLoopState['phases'][number]['status']) { + if (status === 'complete') return 'text-gsuccess'; + if (status === 'active') return 'text-gblue-600 dark:text-gblue-300'; + return 'text-gtext-secondary/50 dark:text-gtext-secondary-dark/50'; +} + +function LoopStatus({ agentLoop }: { agentLoop: AgentLoopState }) { + const activePhase = agentLoop.phases.find(phase => phase.status === 'active'); + + return ( +
+
+
+
+ Turn {agentLoop.turn || 1} +
+
+ {agentLoop.stateBackend === 'browser-local' ? 'Browser-local state' : 'Interactions state'} +
+
+ + + {agentLoop.hitlStatus === 'awaiting-input' ? 'person_check' : 'sync'} + + {agentLoop.hitlStatus === 'awaiting-input' ? 'HITL review' : 'Running'} + +
+ + {activePhase && ( +

+ {activePhase.detail} +

+ )} + +
+ {agentLoop.phases.map((loopPhase) => ( +
+ + {getPhaseIcon(loopPhase.status)} + + {loopPhase.label} +
+ ))} +
+
+ ); +} + +export default function ThoughtStream({ thoughts, phase, elapsed, prepareResult, agentLoop }: ThoughtStreamProps) { const scrollRef = useRef(null); useEffect(() => { @@ -118,7 +185,7 @@ export default function ThoughtStream({ thoughts, phase, elapsed, prepareResult

psychology - AI Thinking + Agent Loop

timer @@ -126,6 +193,8 @@ export default function ThoughtStream({ thoughts, phase, elapsed, prepareResult
+ + {/* Thought bubbles scrollable area */}
= [ + { id: 'intake', label: 'Intake', detail: 'Collect source files, URLs, text, and generation preferences.' }, + { id: 'research', label: 'Research', detail: 'Ground claims with uploaded content, Google Search, and URL context when needed.' }, + { id: 'plan', label: 'Plan', detail: 'Finalize text, facts, layout hierarchy, palette, and image prompt.' }, + { id: 'render', label: 'Render', detail: 'Send the verified prompt and references to the image model.' }, + { id: 'review', label: 'Review', detail: 'Hold the current artifact for human approval or targeted feedback.' }, + { id: 'refine', label: 'Refine', detail: 'Apply one requested edit while preserving unchanged content.' }, +]; + +function createAgentLoopState({ + sessionId = `loop_${Date.now()}`, + turn = 0, + activePhase = 'intake', + goal = 'Create an infographic from user-provided context.', + stopRule = 'Stop after a rendered draft; continue only when the user asks for a refinement.', + hitlStatus, + interactionId, + previousInteractionId, +}: { + sessionId?: string; + turn?: number; + activePhase?: AgentLoopPhaseId; + goal?: string; + stopRule?: string; + hitlStatus?: AgentLoopState['hitlStatus']; + interactionId?: string; + previousInteractionId?: string; +} = {}): AgentLoopState { + const activeIndex = LOOP_PHASES.findIndex(phase => phase.id === activePhase); + const phases = LOOP_PHASES.map((phase, index) => { + let status: AgentLoopState['phases'][number]['status'] = 'pending'; + if (activePhase === 'refine') { + status = phase.id === 'refine' ? 'active' : 'complete'; + } else if (activeIndex >= 0) { + if (index < activeIndex) status = 'complete'; + if (index === activeIndex) status = 'active'; + } + return { ...phase, status }; + }); + + return { + sessionId, + turn, + goal, + stopRule, + stateBackend: 'browser-local', + hitlStatus: hitlStatus ?? (activePhase === 'review' ? 'awaiting-input' : 'running'), + interactionId, + previousInteractionId, + phases, + }; +} + +function buildLoopGoal(config: InfographicConfig, filesCount: number): string { + return `Create a ${config.mode} infographic from ${filesCount} source ${filesCount === 1 ? 'item' : 'items'}.`; +} + +function normalizeResolution(value: unknown): ImageResolution { + return SUPPORTED_RESOLUTIONS.includes(value as ImageResolution) + ? value as ImageResolution + : DEFAULT_INFOGRAPHIC_CONFIG.resolution; +} function loadHasVisited(): boolean { try { @@ -40,10 +104,6 @@ function safePersist(key: string, value: string): void { } } -const isMasterView = typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('view') === 'master'; -const isProdDeploy = import.meta.env.PROD || import.meta.env.VITE_PRODUCTION_DEPLOY === 'true'; -const allowQualityModel = isMasterView && !isProdDeploy; - function loadAdminConfig(): AdminConfig { try { const stored = localStorage.getItem(ADMIN_STORAGE_KEY); @@ -51,14 +111,9 @@ function loadAdminConfig(): AdminConfig { const parsed = JSON.parse(stored); // Enforce gemini-3.5-flash as the default and only supported analysis model parsed.orchestratorModel = 'gemini-3.5-flash'; - // Enforce gemini-3.1-flash-lite-image if quality model is not allowed - if (!allowQualityModel) { - parsed.imageGenModel = 'gemini-3.1-flash-lite-image'; - } else { - if (parsed.imageGenModel === 'gemini-3.1-flash-image-preview') { - parsed.imageGenModel = 'gemini-3.1-flash-image'; - } - } + // Enforce gemini-3.1-flash-lite-image as the only supported image model + parsed.imageGenModel = 'gemini-3.1-flash-lite-image'; + parsed.imageResolution = normalizeResolution(parsed.imageResolution); return { ...DEFAULT_ADMIN_CONFIG, ...parsed }; } } catch { /* ignore */ } @@ -76,7 +131,11 @@ function loadTheme(): 'light' | 'dark' { function loadUserConfig(): InfographicConfig { try { const stored = localStorage.getItem(CONFIG_STORAGE_KEY); - if (stored) return { ...DEFAULT_INFOGRAPHIC_CONFIG, ...JSON.parse(stored) }; + if (stored) { + const parsed = JSON.parse(stored); + parsed.resolution = normalizeResolution(parsed.resolution); + return { ...DEFAULT_INFOGRAPHIC_CONFIG, ...parsed }; + } } catch { /* ignore */ } return { ...DEFAULT_INFOGRAPHIC_CONFIG }; } @@ -105,6 +164,7 @@ const initialState: AppState = { chatMessages: [], refineThoughts: [], isProcessingFiles: false, + agentLoop: createAgentLoopState({ hitlStatus: 'not-started' }), }; export function useInfographicFlow() { @@ -203,7 +263,23 @@ export function useInfographicFlow() { }, []); const handleGenerate = useCallback(async () => { - setState(s => ({ ...s, step: 'studio', generationPhase: 'preparing', error: null, streamingText: '', thoughtBubbles: [], refineThoughts: [], chatMessages: [] })); + const sessionId = `loop_${Date.now()}`; + setState(s => ({ + ...s, + step: 'studio', + generationPhase: 'preparing', + error: null, + streamingText: '', + thoughtBubbles: [], + refineThoughts: [], + chatMessages: [], + agentLoop: createAgentLoopState({ + sessionId, + turn: 1, + activePhase: 'research', + goal: buildLoopGoal(state.config, state.files.length), + }), + })); try { localStorage.setItem(HAS_VISITED_KEY, 'true'); @@ -225,7 +301,18 @@ export function useInfographicFlow() { }; const prepResult = await prepareInfographic(state.files, state.config, state.adminConfig, onThought); - setState(s => ({ ...s, prepareResult: prepResult, streamingText: 'Preparation complete. Generating image...' })); + setState(s => ({ + ...s, + prepareResult: prepResult, + streamingText: 'Preparation complete. Generating image...', + agentLoop: createAgentLoopState({ + sessionId: s.agentLoop.sessionId, + turn: s.agentLoop.turn, + activePhase: 'render', + goal: s.agentLoop.goal, + stopRule: s.agentLoop.stopRule, + }), + })); // Phase 2: Generate image setState(s => ({ ...s, generationPhase: 'generating', streamingText: 'Generating infographic...' })); @@ -265,6 +352,14 @@ export function useInfographicFlow() { streamingText: '', history: [historyEntry, ...s.history].slice(0, 20), chatMessages: [firstMessage], + agentLoop: createAgentLoopState({ + sessionId: s.agentLoop.sessionId, + turn: s.agentLoop.turn, + activePhase: 'review', + goal: s.agentLoop.goal, + stopRule: 'Stop here unless the user requests a focused refinement.', + hitlStatus: 'awaiting-input', + }), })); } catch (err) { setState(s => ({ @@ -273,6 +368,14 @@ export function useInfographicFlow() { step: 'create', generationPhase: 'idle', streamingText: '', + agentLoop: createAgentLoopState({ + sessionId: s.agentLoop.sessionId, + turn: s.agentLoop.turn, + activePhase: 'intake', + goal: s.agentLoop.goal, + stopRule: 'Resolve the error or adjust source inputs before restarting.', + hitlStatus: 'awaiting-input', + }), })); } }, [state.files, state.config, state.adminConfig]); @@ -286,7 +389,20 @@ export function useInfographicFlow() { text: instruction, timestamp: Date.now(), }; - setState(s => ({ ...s, error: null, streamingText: 'Refining infographic...', chatMessages: [...s.chatMessages, userMsg], refineThoughts: [] })); + setState(s => ({ + ...s, + error: null, + streamingText: 'Refining infographic...', + chatMessages: [...s.chatMessages, userMsg], + refineThoughts: [], + agentLoop: createAgentLoopState({ + sessionId: s.agentLoop.sessionId, + turn: s.agentLoop.turn + 1, + activePhase: 'refine', + goal: `Apply focused edit: ${instruction.slice(0, 80)}`, + stopRule: 'Stop after this edit is rendered and reviewed.', + }), + })); try { const onRefineThought = (thought: string) => { @@ -368,12 +484,28 @@ export function useInfographicFlow() { history: [historyEntry, ...s.history].slice(0, 20), chatMessages: [...s.chatMessages, assistantMsg], refineThoughts: [], + agentLoop: createAgentLoopState({ + sessionId: s.agentLoop.sessionId, + turn: s.agentLoop.turn, + activePhase: 'review', + goal: s.agentLoop.goal, + stopRule: 'Stop here unless the user requests another focused refinement.', + hitlStatus: 'awaiting-input', + }), })); } catch (err) { setState(s => ({ ...s, error: (err as Error).message, streamingText: '', + agentLoop: createAgentLoopState({ + sessionId: s.agentLoop.sessionId, + turn: s.agentLoop.turn, + activePhase: 'review', + goal: s.agentLoop.goal, + stopRule: 'Resolve the failed refinement or request a narrower edit.', + hitlStatus: 'awaiting-input', + }), })); } }, [state.currentResult, state.adminConfig, state.config, state.chatMessages, state.prepareResult]); @@ -393,7 +525,20 @@ export function useInfographicFlow() { text: `Upgrade resolution to ${res}`, timestamp: Date.now(), }; - setState(s => ({ ...s, error: null, streamingText: `Upgrading to ${res} resolution...`, chatMessages: [...s.chatMessages, userMsg], refineThoughts: [] })); + setState(s => ({ + ...s, + error: null, + streamingText: `Upgrading to ${res} resolution...`, + chatMessages: [...s.chatMessages, userMsg], + refineThoughts: [], + agentLoop: createAgentLoopState({ + sessionId: s.agentLoop.sessionId, + turn: s.agentLoop.turn + 1, + activePhase: 'refine', + goal: `Upgrade the current infographic to ${res} resolution.`, + stopRule: 'Stop after the higher-resolution render is available for review.', + }), + })); try { const onRefineThought = (thought: string) => { @@ -473,12 +618,28 @@ export function useInfographicFlow() { history: [historyEntry, ...s.history].slice(0, 20), chatMessages: [...s.chatMessages, assistantMsg], refineThoughts: [], + agentLoop: createAgentLoopState({ + sessionId: s.agentLoop.sessionId, + turn: s.agentLoop.turn, + activePhase: 'review', + goal: s.agentLoop.goal, + stopRule: 'Stop here unless the user requests another focused refinement.', + hitlStatus: 'awaiting-input', + }), })); } catch (err) { setState(s => ({ ...s, error: (err as Error).message, streamingText: '', + agentLoop: createAgentLoopState({ + sessionId: s.agentLoop.sessionId, + turn: s.agentLoop.turn, + activePhase: 'review', + goal: s.agentLoop.goal, + stopRule: 'Resolve the failed resolution upgrade or request a lower resolution.', + hitlStatus: 'awaiting-input', + }), })); } }, [state.currentResult, state.adminConfig, state.config, updateAdminConfig, updateConfig, state.chatMessages, state.prepareResult]); @@ -499,9 +660,17 @@ export function useInfographicFlow() { setState(s => ({ ...s, currentResult: result, - config: entry.config, + config: { ...entry.config, resolution: normalizeResolution(entry.config.resolution) }, step: 'studio', generationPhase: 'complete', + agentLoop: createAgentLoopState({ + sessionId: `history_${entry.id}`, + turn: 1, + activePhase: 'review', + goal: `Review saved infographic: ${entry.title}`, + stopRule: 'Stop here unless the user requests a focused refinement.', + hitlStatus: 'awaiting-input', + }), })); }, []); @@ -575,6 +744,7 @@ Source: Global AI Index 2026 Report`; thoughtBubbles: [], chatMessages: [], refineThoughts: [], + agentLoop: createAgentLoopState({ hitlStatus: 'not-started' }), })); }, []); diff --git a/src/services/geminiService.ts b/src/services/geminiService.ts index e09b994..63292b5 100644 --- a/src/services/geminiService.ts +++ b/src/services/geminiService.ts @@ -505,7 +505,6 @@ function mapResolutionToApi(res: string, model: string): string { return '1K'; } if (res === '0.5K') return '1K'; // '512' is not supported by Gemini image generation models - if (res === '3K') return '2K'; // Fallback for unsupported 3K return res; } diff --git a/src/types.ts b/src/types.ts index c757527..0c08101 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,9 @@ // === Step Types === export type StepType = 'hero' | 'create' | 'studio'; export type GenerationPhase = 'idle' | 'preparing' | 'generating' | 'complete'; +export type AgentLoopPhaseId = 'intake' | 'research' | 'plan' | 'render' | 'review' | 'refine'; +export type AgentLoopPhaseStatus = 'pending' | 'active' | 'complete'; +export type AgentLoopStateBackend = 'browser-local' | 'enterprise-interactions-ready'; // === Infographic Mode Types === export type InfographicMode = @@ -11,7 +14,7 @@ export type InfographicMode = | 'quick-slide' | 'custom'; export type AspectRatio = '1:1' | '9:16' | '16:9' | '3:4' | '4:3' | '1:4'; -export type ImageResolution = '0.5K' | '1K' | '2K' | '3K' | '4K'; +export type ImageResolution = '0.5K' | '1K' | '2K'; // === File Types === export type FileCategory = 'document' | 'spreadsheet' | 'image' | 'text'; @@ -62,6 +65,26 @@ export interface ChatMessage { timestamp: number; } +// === Agent Loop State === +export interface AgentLoopPhase { + id: AgentLoopPhaseId; + label: string; + status: AgentLoopPhaseStatus; + detail: string; +} + +export interface AgentLoopState { + sessionId: string; + turn: number; + goal: string; + stopRule: string; + stateBackend: AgentLoopStateBackend; + hitlStatus: 'awaiting-input' | 'running' | 'not-started'; + interactionId?: string; + previousInteractionId?: string; + phases: AgentLoopPhase[]; +} + // === Configuration === export interface AdminConfig { geminiApiKey: string; @@ -118,6 +141,7 @@ export interface AppState { chatMessages: ChatMessage[]; refineThoughts: ThoughtBubble[]; isProcessingFiles: boolean; + agentLoop: AgentLoopState; } export interface HistoryEntry {