ima2-gen 3.12.3 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +17 -3
  2. package/bin/commands/prompt-sub/build.js +5 -1
  3. package/config.js +14 -0
  4. package/docs/API.md +21 -0
  5. package/docs/CLI.md +7 -0
  6. package/docs/PROMPT_STUDIO.md +9 -0
  7. package/docs/migration/runtime-test-inventory.md +4 -1
  8. package/lib/configFileStore.js +44 -0
  9. package/lib/configKeys.js +4 -0
  10. package/lib/promptBuilder/client.js +106 -64
  11. package/lib/promptBuilder/constants.js +20 -2
  12. package/lib/promptBuilder/requestSchema.js +42 -7
  13. package/lib/promptBuilder/router.js +70 -0
  14. package/lib/promptBuilder/transport.js +5 -4
  15. package/package.json +2 -2
  16. package/routes/keys.js +6 -32
  17. package/routes/promptBuilder.js +68 -14
  18. package/skills/ima2/SKILL.md +40 -3
  19. package/skills/ima2-front/SKILL.md +1 -1
  20. package/skills/ima2-front/references/asset-requirements.md +16 -0
  21. package/ui/dist/.vite/manifest.json +44 -35
  22. package/ui/dist/assets/{AgentWorkspace-AJkVTaU5.js → AgentWorkspace-CBwn1WUC.js} +1 -1
  23. package/ui/dist/assets/AssetGenWorkspace-CleWSwPz.js +2 -0
  24. package/ui/dist/assets/AssetsWorkspace-DdjXb45L.js +1 -0
  25. package/ui/dist/assets/{CardNewsWorkspace-C_Eb2g7d.js → CardNewsWorkspace-BaNpBvJw.js} +1 -1
  26. package/ui/dist/assets/{GenerationRequestLogPanel-giH1e4hp.js → GenerationRequestLogPanel-Cd2ArsWr.js} +1 -1
  27. package/ui/dist/assets/HomeWorkspace-nKDhivwR.js +1 -0
  28. package/ui/dist/assets/KeyingPanel-DT8zsqpi.js +1 -0
  29. package/ui/dist/assets/{NodeCanvas-CF_NHudI.js → NodeCanvas-DcKs0w36.js} +1 -1
  30. package/ui/dist/assets/PromptBuilderPanel-e6BSRAdj.js +2 -0
  31. package/ui/dist/assets/{PromptImportDialog-B40X9UAa.js → PromptImportDialog-CLkjHz7L.js} +2 -2
  32. package/ui/dist/assets/{PromptImportDiscoverySection-BWoUDHj1.js → PromptImportDiscoverySection-DoQw4zcd.js} +1 -1
  33. package/ui/dist/assets/{PromptImportFolderSection-DBSEyJb-.js → PromptImportFolderSection-CcLiliAZ.js} +1 -1
  34. package/ui/dist/assets/{PromptLibraryPanel-DLywJBpt.js → PromptLibraryPanel-BjxzL7t5.js} +2 -2
  35. package/ui/dist/assets/SettingsWorkspace-Ctu_usKk.js +1 -0
  36. package/ui/dist/assets/SpriteRecipeWorkspace-DZjsabce.js +1 -0
  37. package/ui/dist/assets/index-0fpfY8vu.css +1 -0
  38. package/ui/dist/assets/index-DueH_AmZ.js +30 -0
  39. package/ui/dist/assets/index-ICf98ZcU.js +5 -0
  40. package/ui/dist/assets/{pptxgen.es-CSd_gCsx.js → pptxgen.es-C--aL3JM.js} +1 -1
  41. package/ui/dist/assets/promptBuilderStore-CkwnWMbR.js +1 -0
  42. package/ui/dist/assets/useAgentDialogFocus-DPB8JITN.js +1 -0
  43. package/ui/dist/index.html +2 -2
  44. package/ui/dist/assets/AssetGenWorkspace-Cguej69V.js +0 -2
  45. package/ui/dist/assets/AssetsWorkspace-DIQ9wBuD.js +0 -1
  46. package/ui/dist/assets/HomeWorkspace-DHmXGtqC.js +0 -1
  47. package/ui/dist/assets/PromptBuilderPanel-D7NJto-1.js +0 -2
  48. package/ui/dist/assets/SettingsWorkspace-BawWId1t.js +0 -1
  49. package/ui/dist/assets/SpriteRecipeWorkspace-Biy2yRAk.js +0 -1
  50. package/ui/dist/assets/VectorizePanel-DdlVvwV1.js +0 -1
  51. package/ui/dist/assets/index-D05Ong5g.js +0 -5
  52. package/ui/dist/assets/index-DGRIdnsL.css +0 -1
  53. package/ui/dist/assets/index-Sch70vBs.js +0 -30
  54. package/ui/dist/assets/useAgentDialogFocus-DcWwFfYd.js +0 -1
package/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  `ima2-gen` is a local-first visual generation runtime and studio for people and coding agents, with reproducible image and video workflows across multiple providers.
18
18
 
19
- Install globally and generate images and videos from eight core lanes: OpenAI OAuth/API, Grok OAuth/API, Antigravity CLI, Gemini API, AtlasCloud, and MiniMax. Runway and Higgsfield remain separate MCP-backed integrations. Iterate with history, references, node branches, multimode batches, and Canvas Mode cleanup — now with one-click GPT background transparency (server-verified real alpha), annotation hover feedback, and a full light/dark/system theme.
19
+ Install globally and generate images and videos through the core registry: OpenAI OAuth/API, Grok OAuth/API, Antigravity CLI, Gemini API, AtlasCloud, MiniMax, NovelAI, and registered ComfyUI workflows. Runway and Higgsfield remain separate MCP-backed integrations. Iterate with history, references, node branches, multimode batches, and Canvas Mode cleanup — now with one-click GPT background transparency (server-verified real alpha), annotation hover feedback, and a full light/dark/system theme.
20
20
 
21
21
  ![ima2-gen classic workspace in light mode with a transparent-background result on the canvas.](assets/screenshots/classic-generate-lightmode.png)
22
22
 
@@ -107,6 +107,9 @@ Ctrl+C now performs a clean shutdown — closing the database, stopping child pr
107
107
  - **Video generation**: create short videos from text, a single image, or multiple reference images via Grok video models. SSE streaming shows planning → submitted → progress % → done. Video frame copy buttons (First/Mid/Last) let you extract and copy keyframes from generated videos.
108
108
  - **Storyboard mode**: toggle storyboard mode in the composer to maintain character and scene continuity across sequential frames. Works with both image and video generation — image keyframes are composed for video production, and video clips inherit character/environment lock rules.
109
109
  - **Canvas Mode**: zoom, pan, annotate (with hover highlighting), erase, clean backgrounds, keep transparent previews, and export either alpha or matte-backed versions. A one-click **GPT transparency** button sends the current image through the i2i edit lane and reports honestly whether the result carries real pixel alpha — verified on the server, never trusted from provider metadata.
110
+ - **Raster-to-vector SVG**: trace flat raster art into real SVG paths with `ima2 vectorize`, from AssetGen/Assets, or from Canvas Export. Canvas labels its older self-contained wrapper as **SVG (embedded raster)** so it cannot be mistaken for a trace.
111
+ - **NovelAI dual prompt**: when NovelAI is selected, Classic, Home, and the mobile compose sheet show **Positive prompt** and **Undesired content** as peer panes; they stack below a 719px composer container. Other providers keep the normal single prompt.
112
+ - **Prompt Builder backend choice**: Settings > Providers can keep Builder routing on **Auto** or pin a supported text backend and model, and the **via <backend>** badge shows which backend actually answered.
110
113
  - **Light & dark themes**: a token-based light mode with tinted neutrals and AA contrast, switchable between light / dark / system in Settings, with no flash on load.
111
114
  - **Local gallery**: keep generated assets on your machine with session-aware history. By default the gallery shows the current session and an All Images toggle reveals the full history; the default scope is sticky across sessions. Each image records its generation time and reasoning effort in the result metadata, so they persist across reloads.
112
115
  - **Reference images**: drag, drop, paste, and attach up to 5 references (images) or up to 7 references (video); large images are compressed before upload.
@@ -172,9 +175,9 @@ Grok video generation defaults to canonical `grok-imagine-video-1.5`; `grok-imag
172
175
 
173
176
  ## Model Guidance
174
177
 
175
- The app defaults to **`gpt-5.6-luna`** for image generation and Prompt Builder planning. Older supported models remain explicit compatibility choices.
178
+ Image generation defaults to **`gpt-5.6-luna`** on the GPT lane. Prompt Builder backend selection is separate: **Auto** chooses the first ready supported text backend, while Settings can pin one explicitly. The `via <backend>` badge reports the backend that actually answered.
176
179
 
177
- - `gpt-5.6-luna` — current image and Prompt Builder default.
180
+ - `gpt-5.6-luna` — current default image model on the GPT lane and the default GPT Builder model when that backend is selected.
178
181
  - `gpt-5.6-terra` / `gpt-5.6-sol` — current GPT-5.6 alternatives when your account exposes them.
179
182
  - `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini` — supported compatibility choices.
180
183
 
@@ -187,6 +190,7 @@ The app also exposes quality (`low`, `medium`, `high`) and moderation (`auto`, `
187
190
  Use Classic when you want one strong result quickly.
188
191
 
189
192
  1. Write a prompt.
193
+ With NovelAI, use **Positive prompt** for desired content and **Undesired content** for what the image should avoid.
190
194
  2. Attach or paste references if needed.
191
195
  3. Pick model, quality, size, format, and moderation.
192
196
  4. Generate one image, or enable multimode to fan out several candidate slots from the same prompt.
@@ -214,6 +218,7 @@ Use Canvas Mode when a generated image is close but needs targeted cleanup befor
214
218
  - Use annotation, eraser, multiselect, grouping, undo/redo, and sticky notes while keeping the original gallery image available.
215
219
  - Pick background-cleanup seeds, preview the mask, and save the cleanup as a canvas version.
216
220
  - Detect transparent images and show a checkerboard preview; export with preserved alpha or with a chosen matte color.
221
+ - Choose **SVG (embedded raster)** for a self-contained canvas document, or **Trace to SVG (vector)** to flatten the composition and open the shared real-vector tracing panel.
217
222
  - Saved canvas versions stay hidden from Gallery and HistoryStrip, but Canvas Mode can reuse them and attach a canvas version as the next reference.
218
223
  - Hover any annotation with the select tool to see a live outline and a move cursor before you click.
219
224
  - Press the **GPT transparency** button in the canvas toolbar to remove the background through the OAuth i2i lane in one click. The server decodes the returned bytes and reports `alphaVerified` — the toast tells you whether real transparency actually landed, instead of assuming.
@@ -237,6 +242,7 @@ be treated as a stable public feature yet.
237
242
  ### Settings
238
243
 
239
244
  The settings workspace keeps account, model, appearance, and language controls away from the generation sidebar.
245
+ Prompt Builder backend lives under Providers: Auto tries GPT OAuth, Grok, OpenAI API, then Grok API and uses the first ready lane. An explicit choice stays pinned and returns a typed error when unavailable; the Builder surface displays the backend that actually answered.
240
246
  Appearance now includes a light / dark / system theme toggle; the light palette uses tinted neutrals with AA contrast and applies before first paint.
241
247
 
242
248
  ![Settings workspace with account navigation and generation model controls.](assets/screenshots/settings-workspace.png)
@@ -270,6 +276,8 @@ These require a running `ima2 serve`. The CLI covers every server route. The mos
270
276
  | `ima2 defaults reset image\|video` | Remove a persisted CLI generation target |
271
277
  | `ima2 gen <prompt> [--model <lane>/<model>]` | Generate from the CLI; requires an explicit target or saved image default |
272
278
  | `ima2 edit <file> --prompt <text>` | Edit an existing image |
279
+ | `ima2 vectorize <input.png> [-o output.svg]` | Trace PNG/JPEG/WebP into a real SVG locally; no server or provider required |
280
+ | `ima2 prompt build --message <text> [--backend <backend>] [--model <model>]` | Refine prompt intent through the configured or explicitly selected Prompt Builder backend; requires the local server |
273
281
  | `ima2 multimode <prompt>` | Multi-image SSE generation |
274
282
  | `ima2 video <prompt> [--model <lane>/<model>]` | Generate video through a Grok or MCP lane; requires an explicit target or saved video default |
275
283
  | `ima2 ls [--session <id>] [--favorites]` | List recent history |
@@ -284,6 +292,9 @@ The server advertises its actual port at `~/.ima2/server.json`. If `3333` is bus
284
292
  ```bash
285
293
  ima2 models --kind image
286
294
  ima2 gen "poster" --model oauth/gpt-5.6-luna --reasoning-effort high
295
+ ima2 gen "1girl, blue hair" --model nai/nai-diffusion-5-full --nai-negative-prompt "lowres, watermark"
296
+ ima2 vectorize logo.png -o logo.svg --json
297
+ ima2 prompt build --message "Make this prompt production-ready" --backend auto --model auto
287
298
  ima2 edit input.png --prompt "make it rainy" --web-search
288
299
  ima2 multimode "two cats playing" -n 2
289
300
  ima2 video "a cat playing piano" --model grok/grok-imagine-video-1.5 --duration 5 --resolution 720p
@@ -312,6 +323,8 @@ environment variables > ~/.ima2/config.json > built-in defaults
312
323
  | `IMA2_ADVERTISE_FILE` | `~/.ima2/server.json` | Runtime discovery file |
313
324
  | `IMA2_GENERATED_DIR` | `~/.ima2/generated` | Generated image directory |
314
325
  | `IMA2_IMAGE_MODEL_DEFAULT` | `gpt-5.6-luna` | Server fallback image model |
326
+ | `IMA2_PROMPT_BUILDER_BACKEND` | `auto` | Prompt Builder text backend (`auto`, `oauth`, `grok`, `api`, or `grok-api`); Settings persists the same value as `promptBuilder.backend` |
327
+ | `IMA2_PROMPT_BUILDER_MODEL` | `auto` with Auto backend | Backend-scoped Builder model; Settings persists the same value as `promptBuilder.model` |
315
328
  | `IMA2_REASONING_EFFORT` | `medium` | Default reasoning effort for the default (GPT OAuth) path; one of `none`, `low`, `medium`, `high`, `xhigh` |
316
329
  | `IMA2_NO_OAUTH_PROXY` | — | Set `1` to disable the auto-started OAuth proxy |
317
330
  | `IMA2_LOG_LEVEL` | `info` | Normal serve defaults to `info`; dev mode defaults to `debug`; supports `debug`, `info`, `warn`, `error`, or `silent` |
@@ -351,6 +364,7 @@ Use `ima2 serve --dev`, `npm run dev`, or `IMA2_LOG_LEVEL=debug ima2 serve` when
351
364
  ## API Reference
352
365
 
353
366
  The endpoint list moved to [docs/API.md](docs/API.md) so this README can stay focused on first-run use.
367
+ The relevant feature groups are `POST /api/assets/derived` with `kind=vector-svg`, NovelAI's `negativePrompt` generation field, `POST /api/prompt-builder/chat`, and `GET`/`PUT /api/prompt-builder/config`.
354
368
 
355
369
  Useful references:
356
370
 
@@ -12,7 +12,8 @@ const BUILD_HELP = `
12
12
  --message <text> User message (required unless --messages)
13
13
  --messages <file|@file|-> Multi-turn conversation as JSON array
14
14
  --ref <image> Image reference (repeatable)
15
- --model <model> Builder model (gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna)
15
+ --backend <backend> Backend override (auto, oauth, api, grok, grok-api)
16
+ --model <model> Model for the configured Prompt Builder backend
16
17
  --language <ko|en|both> Preferred output language hint
17
18
  --server <url> Override server URL
18
19
  --json Output raw JSON
@@ -27,6 +28,7 @@ const FLAGS = {
27
28
  message: { type: "string" },
28
29
  messages: { type: "string" },
29
30
  ref: { type: "string", repeatable: true },
31
+ backend: { type: "string" },
30
32
  model: { type: "string" },
31
33
  language: { type: "string" },
32
34
  server: { type: "string" },
@@ -65,6 +67,8 @@ export default async function buildSub(argv) {
65
67
  }
66
68
  const messages = await resolveMessages(args);
67
69
  const body = { messages };
70
+ if (args.backend)
71
+ body.backend = args.backend;
68
72
  if (args.model)
69
73
  body.model = args.model;
70
74
  let server;
package/config.js CHANGED
@@ -15,6 +15,7 @@ import { join, dirname } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { readFileSync, existsSync } from "node:fs";
17
17
  import { deriveSupportedImageModels, deriveUnsupportedImageModels } from "./lib/providers/derive.js";
18
+ import { DEFAULT_PROMPT_BUILDER_MODELS, PROMPT_BUILDER_BACKENDS, PROMPT_BUILDER_MODELS, } from "./lib/promptBuilder/constants.js";
18
19
  // 4.6 rewrites prompts in ways that read worse than 4.3 for this planner's job, which
19
20
  // is judged by the result rather than a benchmark. 4.6 stays selectable below.
20
21
  export const DEFAULT_GROK_PLANNER_MODEL = "grok-4.3";
@@ -80,6 +81,13 @@ function pickBool(envVal, fileVal, fallback) {
80
81
  const s = String(v).toLowerCase();
81
82
  return s === "1" || s === "true" || s === "yes";
82
83
  }
84
+ function promptBuilderBackend(raw) {
85
+ return PROMPT_BUILDER_BACKENDS.includes(raw)
86
+ ? raw
87
+ : "auto";
88
+ }
89
+ const selectedPromptBuilderBackend = promptBuilderBackend(pickStr(env.IMA2_PROMPT_BUILDER_BACKEND, fileCfg.promptBuilder?.backend, "auto"));
90
+ const selectedPromptBuilderModel = pickStr(env.IMA2_PROMPT_BUILDER_MODEL, fileCfg.promptBuilder?.model, DEFAULT_PROMPT_BUILDER_MODELS[selectedPromptBuilderBackend]);
83
91
  export function defaultLogLevelForEnv(runtimeEnv = env) {
84
92
  return runtimeEnv.IMA2_DEV === "1" ? "debug" : "info";
85
93
  }
@@ -135,6 +143,12 @@ export const config = {
135
143
  ? fileCfg.oauth.validModeration
136
144
  : ["auto", "low"]),
137
145
  },
146
+ promptBuilder: {
147
+ backend: selectedPromptBuilderBackend,
148
+ model: PROMPT_BUILDER_MODELS[selectedPromptBuilderBackend].includes(selectedPromptBuilderModel)
149
+ ? selectedPromptBuilderModel
150
+ : DEFAULT_PROMPT_BUILDER_MODELS[selectedPromptBuilderBackend],
151
+ },
138
152
  github: {
139
153
  token: pickStr(env.IMA2_GITHUB_TOKEN, fileCfg.github?.token, ""),
140
154
  },
package/docs/API.md CHANGED
@@ -810,6 +810,26 @@ Agent Mode is a conversational image workspace (web UI only — no CLI). All rou
810
810
  | `POST` | `/api/agent/queue/:itemId/cancel` | Cancel queued item |
811
811
  | `POST` | `/api/agent/queue/:itemId/retry` | Retry failed item |
812
812
 
813
+ ## Prompt Builder
814
+
815
+ | Method | Path | Body | Response |
816
+ |---|---|---|---|
817
+ | `POST` | `/api/prompt-builder/chat` | `{ messages, backend?, model?, context? }` | `{ provider, backend, requestedBackend, model, message, usage }` |
818
+ | `GET` | `/api/prompt-builder/config` | none | `{ backend, model, options: { backends, models, autoOrder }, locked }` |
819
+ | `PUT` | `/api/prompt-builder/config` | `{ backend, model? }` | Same config payload after atomic persistence |
820
+
821
+ The persisted keys are `promptBuilder.backend` and `promptBuilder.model`; the corresponding
822
+ environment locks are `IMA2_PROMPT_BUILDER_BACKEND` and `IMA2_PROMPT_BUILDER_MODEL`.
823
+ `backend=auto` tries `oauth -> grok -> api -> grok-api` and selects the first ready lane.
824
+ An explicit backend stays pinned and never falls back. Chat responses separate
825
+ `requestedBackend` from the answering `backend`, which the UI renders as `via <backend>`.
826
+
827
+ Invalid backend/model pairs return typed 400 errors. An unavailable explicit lane returns its
828
+ typed 401/503 error, no ready Auto lane returns 503 `PROMPT_BUILDER_NO_BACKEND_READY`, and
829
+ environment-locked config writes return 409 `PROMPT_BUILDER_CONFIG_ENV_LOCKED`. Auto selection
830
+ happens before sending upstream; an accepted or failed upstream request is not retried on another
831
+ backend.
832
+
813
833
  ## Endpoint → CLI Mapping
814
834
 
815
835
  Most server routes under `/api/*` have a CLI wrapper. The exception is **Agent Mode** (`/api/agent/*`), which is server + web-UI-only and has no `ima2` subcommand. The prompt builder HTTP route (`POST /api/prompt-builder/chat`) is wrapped by `ima2 prompt build`. Use this table to find the command that calls a given endpoint. (See README.md "Client" section for full flag lists.)
@@ -862,6 +882,7 @@ Most server routes under `/api/*` have a CLI wrapper. The exception is **Agent M
862
882
  | `GET /api/keys/status`, `PUT/DELETE /api/keys/:provider`, `PUT/DELETE /api/keys/vertex` | Web UI only (Settings > API Keys) |
863
883
  | `GET/POST/PATCH/DELETE /api/agent/*` (sessions, turns, queue) | — (Agent Mode; web UI only, no CLI) |
864
884
  | `POST /api/prompt-builder/chat` | `ima2 prompt build` |
885
+ | `GET/PUT /api/prompt-builder/config` | Web UI only (Settings > Providers > Prompt Builder backend / Builder model) |
865
886
 
866
887
  Notes:
867
888
  - `ima2 history favorite` and `ima2 annotate …` send `X-Ima2-Browser-Id: cli-<sha1prefix>` derived from the config dir, so CLI activity does not collide with browser sessions.
package/docs/CLI.md CHANGED
@@ -365,6 +365,13 @@ Windows DNS/fragmentation bypass tool such as SecretDNS is in use.
365
365
  | `ima2 prompt build --message <text> [--ref <file>] [--model <id>] [--json]` | Build a structured image prompt through `/api/prompt-builder/chat` |
366
366
  | `ima2 prompt build --messages <file\|@file\|-> [--json]` | Build from a message transcript file or stdin |
367
367
 
368
+ Prompt Builder uses the server's persisted `promptBuilder.backend` preference unless
369
+ `--backend <auto|oauth|api|grok|grok-api>` overrides it for one request. `auto`
370
+ chooses the first ready supported text backend (oauth, grok, api, grok-api); an explicit
371
+ value pins routing and fails with a typed error instead of falling back. The result
372
+ identifies `requestedBackend`, the answering `backend`, and `model`, so callers can
373
+ detect an Auto fallback.
374
+
368
375
  ## Card News (gated)
369
376
 
370
377
  Card News requires the server to be started with `IMA2_CARD_NEWS=1` (or `features.cardNews: true` in `~/.ima2/config.json`). When disabled, the CLI exits 2 with a clear message instead of producing a 404.
@@ -21,6 +21,15 @@ you want a reproducible way to report a workspace issue.
21
21
  | Gallery | Browses saved local images, All/Favorites tabs, and folders. | Favorite toggles should preserve the gallery viewport you were browsing. |
22
22
  | Prompt library | Imports saved prompt text into the composer intentionally. | Library insert/continue actions are explicit prompt imports; passive image selection is not. |
23
23
 
24
+ ### Provider-specific prompt surfaces
25
+
26
+ NovelAI shows Positive prompt and Undesired content as separate panes in Classic, Home,
27
+ and the mobile compose sheet (side by side when the composer is wider than 719px,
28
+ stacked otherwise). Switching away from NovelAI restores the single prompt without
29
+ deleting the saved undesired-content text. Prompt Builder routing is configured in
30
+ Settings > Providers; the Builder badge shows the backend that actually answered,
31
+ including an Auto selection or fallback.
32
+
24
33
  ## Multimode Prompting
25
34
 
26
35
  Multimode repeats one generation request shape across several candidate slots.
@@ -4,7 +4,7 @@ Generated by `npm run test:inventory` (script: `scripts/classify-tests.mjs`).
4
4
 
5
5
  _Tests considered "runtime-importing" if they import from `../lib/`, `../routes/`, `../bin/`, `../server`, or `../config`._
6
6
 
7
- Total: 412 (runtime: 191, contract: 221)
7
+ Total: 415 (runtime: 191, contract: 224)
8
8
 
9
9
  ## Runtime-importing tests
10
10
  - `tests/agent-image-reference-contract.test.ts`
@@ -251,6 +251,7 @@ Total: 412 (runtime: 191, contract: 221)
251
251
  - `tests/canvas-selection-contract.test.js`
252
252
  - `tests/canvas-style-contract.test.js`
253
253
  - `tests/canvas-svg-export-contract.test.ts`
254
+ - `tests/canvas-vectorize-entry-contract.test.ts`
254
255
  - `tests/canvas-version-api.test.js`
255
256
  - `tests/canvas-version-contract.test.js`
256
257
  - `tests/canvas-viewport-pan-contract.test.js`
@@ -337,6 +338,7 @@ Total: 412 (runtime: 191, contract: 221)
337
338
  - `tests/multimode-concurrent-store-contract.test.js`
338
339
  - `tests/multimode-ui-contract.test.js`
339
340
  - `tests/nai-cli-built-smoke.test.ts`
341
+ - `tests/nai-dual-prompt-contract.test.ts`
340
342
  - `tests/nai-ui-registration-contract.test.ts`
341
343
  - `tests/navrail-hover-label-contract.test.ts`
342
344
  - `tests/node-async-eventbus-contract.test.js`
@@ -397,6 +399,7 @@ Total: 412 (runtime: 191, contract: 221)
397
399
  - `tests/star-surface-controls-contract.test.ts`
398
400
  - `tests/storage-open-generated-dir.test.js`
399
401
  - `tests/structure-line-counts-contract.test.js`
402
+ - `tests/studio-surface-docs-contract.test.ts`
400
403
  - `tests/style-feature-removal-contract.test.js`
401
404
  - `tests/subprocess-deadline-contract.test.ts`
402
405
  - `tests/toast-stack-contract.test.js`
@@ -0,0 +1,44 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ let configMutationQueue = Promise.resolve();
5
+ function serializeConfigMutation(mutation) {
6
+ const result = configMutationQueue.then(mutation, mutation);
7
+ configMutationQueue = result.then(() => undefined, () => undefined);
8
+ return result;
9
+ }
10
+ async function writeConfigAtomic(cfgPath, data) {
11
+ const tmp = `${cfgPath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
12
+ try {
13
+ await mkdir(dirname(cfgPath), { recursive: true, mode: 0o700 });
14
+ await writeFile(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
15
+ await rename(tmp, cfgPath);
16
+ }
17
+ catch (error) {
18
+ throw error;
19
+ }
20
+ }
21
+ export async function updateConfigFileAtomic(cfgPath, mutate) {
22
+ try {
23
+ await serializeConfigMutation(async () => {
24
+ let existing = {};
25
+ try {
26
+ existing = JSON.parse(await readFile(cfgPath, "utf8"));
27
+ }
28
+ catch (error) {
29
+ const code = error.code;
30
+ if (code !== "ENOENT") {
31
+ throw Object.assign(new Error(`config file unreadable: ${cfgPath}`), {
32
+ code: "CONFIG_UNREADABLE",
33
+ cause: error,
34
+ });
35
+ }
36
+ }
37
+ mutate(existing);
38
+ await writeConfigAtomic(cfgPath, existing);
39
+ });
40
+ }
41
+ catch (error) {
42
+ throw error;
43
+ }
44
+ }
package/lib/configKeys.js CHANGED
@@ -1,5 +1,7 @@
1
1
  export const AUTH_CONFIG_KEYS = new Set(["provider", "apiKey"]);
2
2
  export const WRITABLE_CONFIG_KEYS = new Set([
3
+ "promptBuilder.backend",
4
+ "promptBuilder.model",
3
5
  "imageModels.default",
4
6
  "imageModels.reasoningEffort",
5
7
  "apiProvider.defaultImageModel",
@@ -37,6 +39,8 @@ export const WRITABLE_CONFIG_KEYS = new Set([
37
39
  "history.maxPageCap",
38
40
  ]);
39
41
  export const KEY_TO_ENV = {
42
+ "promptBuilder.backend": "IMA2_PROMPT_BUILDER_BACKEND",
43
+ "promptBuilder.model": "IMA2_PROMPT_BUILDER_MODEL",
40
44
  "imageModels.default": "IMA2_IMAGE_MODEL_DEFAULT",
41
45
  "imageModels.reasoningEffort": "IMA2_REASONING_EFFORT",
42
46
  "apiProvider.defaultImageModel": "IMA2_API_IMAGE_MODEL_DEFAULT",
@@ -1,76 +1,118 @@
1
1
  import { errInfo } from "../errInfo.js";
2
- import { logWarn } from "../logger.js";
3
- import { fetchOAuth, waitForOAuthReady } from "../oauthProxy/runtime.js";
2
+ import { logEvent, logWarn } from "../logger.js";
3
+ import { fetchOAuth } from "../oauthProxy/runtime.js";
4
+ import { requireRuntimeContext, } from "../runtimeContext.js";
5
+ import { DEFAULT_PROMPT_BUILDER_MODELS, PROMPT_BUILDER_AUTO_ORDER, } from "./constants.js";
4
6
  import { promptBuilderError } from "./errors.js";
5
- import { normalizeModel, normalizeMessages } from "./requestSchema.js";
7
+ import { lanesForModel, normalizeMessages, normalizePromptBuilderBackend, normalizeRequestModel, } from "./requestSchema.js";
8
+ import { resolvePromptBuilderTransport, selectPromptBuilderBackend, } from "./router.js";
6
9
  import { buildTransportPayload } from "./transport.js";
7
10
  import { parseUpstreamError, responseSummary, extractChatText, readResponsesResult, } from "./responseParser.js";
8
- export async function requestPromptBuilderChat(ctx, input) {
9
- const model = normalizeModel(input.model);
11
+ async function prepareRequest(ctx, input, lanes) {
12
+ const persistedBackend = normalizePromptBuilderBackend(ctx.config.promptBuilder.backend);
13
+ const requestedBackend = normalizePromptBuilderBackend(input.backend, persistedBackend);
14
+ const backendOverridden = requestedBackend !== persistedBackend;
15
+ const requestedModel = normalizeRequestModel(requestedBackend, input.model ?? (backendOverridden
16
+ ? DEFAULT_PROMPT_BUILDER_MODELS[requestedBackend]
17
+ : ctx.config.promptBuilder.model));
18
+ const allowedLanes = requestedBackend === "auto" && requestedModel !== "auto"
19
+ ? lanesForModel(requestedModel)
20
+ : PROMPT_BUILDER_AUTO_ORDER;
21
+ const selection = selectPromptBuilderBackend(requestedBackend, lanes, allowedLanes);
22
+ const model = requestedBackend === "auto"
23
+ ? (requestedModel === "auto" ? DEFAULT_PROMPT_BUILDER_MODELS[selection.backend] : requestedModel)
24
+ : requestedModel;
10
25
  const messages = normalizeMessages(input.messages);
11
- await waitForOAuthReady(ctx);
12
- const timeoutMs = ctx.config?.oauth?.generationTimeoutMs ?? 120_000;
26
+ if (selection.fallbackFrom)
27
+ logFallback(selection);
28
+ const payload = buildTransportPayload(selection.backend, model, messages, input.context);
29
+ const target = await resolvePromptBuilderTransport(ctx, selection.backend, payload.endpoint);
30
+ return { selection, model, payload, target };
31
+ }
32
+ function logFallback(selection) {
33
+ logEvent("prompt-builder", "backend_fallback", {
34
+ requestedBackend: selection.requestedBackend,
35
+ from: selection.fallbackFrom,
36
+ to: selection.backend,
37
+ reason: selection.fallbackReason,
38
+ });
39
+ }
40
+ function sendUpstream(prepared, signal) {
41
+ const init = {
42
+ method: "POST",
43
+ headers: prepared.target.headers,
44
+ signal,
45
+ body: JSON.stringify(prepared.payload.body),
46
+ };
47
+ return prepared.target.useOAuthFetch
48
+ ? fetchOAuth(prepared.target.url, init, { scope: "prompt-builder" })
49
+ : fetch(prepared.target.url, init);
50
+ }
51
+ async function throwUpstreamFailure(res, prepared) {
52
+ const text = await res.text();
53
+ const upstream = parseUpstreamError(text);
54
+ logWarn("prompt-builder", "upstream_failed", {
55
+ endpoint: prepared.payload.endpoint,
56
+ model: prepared.model,
57
+ status: res.status,
58
+ hasImageAttachments: prepared.payload.endpoint === "responses",
59
+ upstreamBodyChars: text.length,
60
+ upstreamCode: upstream.upstreamCode,
61
+ upstreamType: upstream.upstreamType,
62
+ upstreamParam: upstream.upstreamParam,
63
+ });
64
+ const err = promptBuilderError("Prompt builder upstream failed", "PROMPT_BUILDER_UPSTREAM_FAILED", 502);
65
+ Object.assign(err, {
66
+ upstreamStatus: res.status,
67
+ upstreamBodyChars: text.length,
68
+ upstreamEndpoint: prepared.payload.endpoint,
69
+ ...upstream,
70
+ });
71
+ throw err;
72
+ }
73
+ async function parseResponse(res, endpoint) {
74
+ if (endpoint === "responses") {
75
+ return readResponsesResult(res);
76
+ }
77
+ const body = (await res.json());
78
+ return {
79
+ content: extractChatText(body),
80
+ usage: body.usage ?? null,
81
+ summary: responseSummary(body),
82
+ };
83
+ }
84
+ function requireContent(parsed, prepared) {
85
+ const content = parsed.content.trim();
86
+ if (content)
87
+ return content;
88
+ logWarn("prompt-builder", "empty_response", {
89
+ endpoint: prepared.payload.endpoint,
90
+ model: prepared.model,
91
+ ...parsed.summary,
92
+ });
93
+ const err = promptBuilderError("Prompt builder returned an empty response", "PROMPT_BUILDER_EMPTY_RESPONSE", 502);
94
+ err.upstreamEndpoint = prepared.payload.endpoint;
95
+ Object.assign(err, parsed.summary);
96
+ throw err;
97
+ }
98
+ export async function requestPromptBuilderChat(ctxRaw, input, lanes) {
99
+ const ctx = requireRuntimeContext(ctxRaw);
100
+ const prepared = await prepareRequest(ctx, input, lanes);
13
101
  const controller = new AbortController();
14
- const timer = setTimeout(() => controller.abort(), timeoutMs);
102
+ const timer = setTimeout(() => controller.abort(), ctx.config.oauth.generationTimeoutMs);
15
103
  try {
16
- const { endpoint, body: payload } = buildTransportPayload(model, messages, input.context);
17
- const url = `${ctx.oauthUrl}${endpoint === "responses" ? "/v1/responses" : "/v1/chat/completions"}`;
18
- const res = await fetchOAuth(url, {
19
- method: "POST",
20
- headers: { "Content-Type": "application/json" },
21
- signal: controller.signal,
22
- body: JSON.stringify(payload),
23
- }, { scope: "prompt-builder" });
24
- if (!res.ok) {
25
- const text = await res.text();
26
- const upstream = parseUpstreamError(text);
27
- logWarn("prompt-builder", "upstream_failed", {
28
- endpoint,
29
- model,
30
- status: res.status,
31
- hasImageAttachments: endpoint === "responses",
32
- upstreamBodyChars: text.length,
33
- upstreamCode: upstream.upstreamCode,
34
- upstreamType: upstream.upstreamType,
35
- upstreamParam: upstream.upstreamParam,
36
- });
37
- const err = promptBuilderError("Prompt builder upstream failed", "PROMPT_BUILDER_UPSTREAM_FAILED", 502);
38
- err.upstreamStatus = res.status;
39
- err.upstreamBodyChars = text.length;
40
- err.upstreamEndpoint = endpoint;
41
- err.upstreamCode = upstream.upstreamCode;
42
- err.upstreamType = upstream.upstreamType;
43
- err.upstreamParam = upstream.upstreamParam;
44
- throw err;
45
- }
46
- const useResponses = endpoint === "responses";
47
- const responseBody = useResponses
48
- ? await readResponsesResult(res)
49
- : (await res.json());
50
- const content = (useResponses
51
- ? responseBody.content
52
- : extractChatText(responseBody)).trim();
53
- if (!content) {
54
- const summary = useResponses
55
- ? responseBody.summary
56
- : responseSummary(responseBody);
57
- logWarn("prompt-builder", "empty_response", {
58
- endpoint,
59
- model,
60
- ...summary,
61
- });
62
- const err = promptBuilderError("Prompt builder returned an empty response", "PROMPT_BUILDER_EMPTY_RESPONSE", 502);
63
- err.upstreamEndpoint = endpoint;
64
- Object.assign(err, summary);
65
- throw err;
66
- }
104
+ const res = await sendUpstream(prepared, controller.signal);
105
+ if (!res.ok)
106
+ await throwUpstreamFailure(res, prepared);
107
+ const parsed = await parseResponse(res, prepared.payload.endpoint);
108
+ const content = requireContent(parsed, prepared);
67
109
  return {
68
- provider: "oauth",
69
- model,
110
+ provider: prepared.selection.backend,
111
+ backend: prepared.selection.backend,
112
+ requestedBackend: prepared.selection.requestedBackend,
113
+ model: prepared.model,
70
114
  message: { role: "assistant", content },
71
- usage: useResponses
72
- ? responseBody.usage
73
- : (responseBody.usage ?? null),
115
+ usage: parsed.usage,
74
116
  };
75
117
  }
76
118
  catch (error) {
@@ -1,5 +1,23 @@
1
- export const VALID_PROMPT_BUILDER_MODELS = new Set(["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]);
2
- export const DEFAULT_PROMPT_BUILDER_MODEL = "gpt-5.6-luna";
1
+ export const PROMPT_BUILDER_BACKENDS = ["auto", "oauth", "grok", "api", "grok-api"];
2
+ const GPT_MODELS = ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini"];
3
+ const grokModels = ["grok-4.3", "grok-4.6", "grok-4.5"];
4
+ export const PROMPT_BUILDER_MODELS = {
5
+ auto: ["auto"],
6
+ oauth: GPT_MODELS,
7
+ grok: grokModels,
8
+ api: GPT_MODELS,
9
+ "grok-api": grokModels,
10
+ };
11
+ export const DEFAULT_PROMPT_BUILDER_MODELS = {
12
+ auto: "auto",
13
+ oauth: "gpt-5.6-luna",
14
+ grok: "grok-4.3",
15
+ api: "gpt-5.6-luna",
16
+ "grok-api": "grok-4.3",
17
+ };
18
+ export const PROMPT_BUILDER_AUTO_ORDER = [
19
+ "oauth", "grok", "api", "grok-api",
20
+ ];
3
21
  export const MAX_MESSAGES = 24;
4
22
  export const MAX_MESSAGE_CHARS = 16_000;
5
23
  export const MAX_ATTACHMENTS = 6;
@@ -1,13 +1,48 @@
1
- import { VALID_PROMPT_BUILDER_MODELS, DEFAULT_PROMPT_BUILDER_MODEL, MAX_MESSAGES, MAX_MESSAGE_CHARS, } from "./constants.js";
1
+ import { DEFAULT_PROMPT_BUILDER_MODELS, MAX_MESSAGES, MAX_MESSAGE_CHARS, PROMPT_BUILDER_AUTO_ORDER, PROMPT_BUILDER_BACKENDS, PROMPT_BUILDER_MODELS, } from "./constants.js";
2
2
  import { promptBuilderError } from "./errors.js";
3
3
  import { normalizeAttachments } from "./attachments.js";
4
- export function normalizeModel(raw) {
5
- if (typeof raw !== "string" || raw.trim().length === 0)
6
- return DEFAULT_PROMPT_BUILDER_MODEL;
7
- if (!VALID_PROMPT_BUILDER_MODELS.has(raw)) {
8
- throw promptBuilderError(`model must be one of: ${[...VALID_PROMPT_BUILDER_MODELS].join(", ")}`, "PROMPT_BUILDER_BAD_MODEL");
4
+ export function normalizePromptBuilderBackend(raw, fallback = "auto") {
5
+ if (raw === undefined || raw === null)
6
+ return fallback;
7
+ if (typeof raw === "string") {
8
+ const candidate = raw.trim();
9
+ if (!candidate)
10
+ return fallback;
11
+ if (PROMPT_BUILDER_BACKENDS.includes(candidate)) {
12
+ return candidate;
13
+ }
14
+ }
15
+ throw promptBuilderError(`backend must be one of: ${PROMPT_BUILDER_BACKENDS.join(", ")}`, "PROMPT_BUILDER_BAD_BACKEND");
16
+ }
17
+ export function normalizePromptBuilderModel(backend, raw) {
18
+ const candidate = typeof raw === "string" && raw.trim()
19
+ ? raw.trim()
20
+ : DEFAULT_PROMPT_BUILDER_MODELS[backend];
21
+ if (!PROMPT_BUILDER_MODELS[backend].includes(candidate)) {
22
+ throw promptBuilderError(`model for ${backend} must be one of: ${PROMPT_BUILDER_MODELS[backend].join(", ")}`, "PROMPT_BUILDER_BAD_MODEL");
23
+ }
24
+ return candidate;
25
+ }
26
+ export function normalizeRequestModel(backend, raw) {
27
+ const candidate = typeof raw === "string" && raw.trim() ? raw.trim() : "";
28
+ if (backend === "auto" && candidate && candidate !== "auto") {
29
+ if (!lanesForModel(candidate).length) {
30
+ throw promptBuilderError(`model ${candidate} is not in any Prompt Builder catalog`, "PROMPT_BUILDER_BAD_MODEL");
31
+ }
32
+ return candidate;
9
33
  }
10
- return raw;
34
+ return normalizePromptBuilderModel(backend, candidate || undefined);
35
+ }
36
+ export function lanesForModel(model) {
37
+ return PROMPT_BUILDER_AUTO_ORDER.filter((lane) => PROMPT_BUILDER_MODELS[lane].includes(model));
38
+ }
39
+ export function normalizePromptBuilderConfig(raw, current) {
40
+ const body = raw && typeof raw === "object" ? raw : {};
41
+ const backend = normalizePromptBuilderBackend(body.backend, current.backend);
42
+ const modelInput = body.model === undefined && backend !== current.backend
43
+ ? DEFAULT_PROMPT_BUILDER_MODELS[backend]
44
+ : body.model ?? current.model;
45
+ return { backend, model: normalizePromptBuilderModel(backend, modelInput) };
11
46
  }
12
47
  export function normalizeMessages(raw) {
13
48
  if (!Array.isArray(raw)) {