From e54bb999e7efc5796ab39f13ad29307a74dfea68 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Tue, 28 Jul 2026 22:32:38 -0700 Subject: [PATCH] label Agy's model field for the agent it selects, and allow a per-render override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Image Gen settings field was labeled "Image model" while listing Gemini agent models, which read as the wrong catalog being loaded. It isn't: `agy models` enumerates the *agent* models the CLI's `--model` flag accepts, and that agent is what drives the built-in `generate_image` tool. Agy exposes no knob for the underlying image model — its `GenerateImageToolConfig` carries only `force_disable` and `output_directory`, and the tool args are prompt / image name / aspect ratio / reference paths — so there is no imagen-* option to offer, and passing one makes agy exit non-zero before it generates. The field is now "Default agent model" with that explanation instead of a label that promises a knob the CLI doesn't have. Adds the override half: a `cloudModel` request field, folded into the provider's own `model` by resolveCloudProviderConfig and stripped before it reaches persisted job params. Gated per provider by a `supportsModelOverride` spec flag (grok is false — fixed xAI backend, no model knob) with a mirrored `supportsCloudModelOverride` on the client. The Image Gen page renders an "Agent model" picker driven by the live probe; blank inherits the Settings default, and a pin the catalog no longer lists falls back to the default rather than failing the render. Also fixes the retry editor on a failed Agy job: it was bound to `params.modelId` (a local image-model registry id Agy never reads), so the model was uneditable. It now follows `params.model` for every backend that takes a CLI model, and renders a configured-default sentinel as an empty field so an untouched form doesn't submit `model: ''`. --- .changelog/NEXT.md | 1 + .../components/imageGen/ImageGenControls.jsx | 33 ++++++++ .../imageGen/ImageGenControls.test.jsx | 49 ++++++++++++ .../imageGen/ImageGenSettingsForm.jsx | 4 +- .../src/components/media/MediaJobsQueue.jsx | 36 ++++++--- .../components/media/MediaJobsQueue.test.jsx | 52 +++++++++++++ .../src/components/settings/ImageGenTab.jsx | 52 ++++++------- .../components/settings/ImageGenTab.test.jsx | 2 +- client/src/hooks/README.md | 1 + client/src/hooks/index.js | 2 + client/src/hooks/useAgyModels.js | 51 +++++++++++++ client/src/hooks/useAgyModels.test.jsx | 67 +++++++++++++++++ client/src/lib/imageGenBackends.js | 19 +++++ client/src/pages/ImageGen.jsx | 45 ++++++++++- server/routes/imageGen.js | 15 +++- server/routes/imageGen.test.js | 75 ++++++++++++++++++- .../services/imageGen/cloudProviderConfig.js | 43 +++++++++-- .../imageGen/cloudProviderConfig.test.js | 47 ++++++++++++ server/services/imageGen/index.js | 7 +- 19 files changed, 547 insertions(+), 54 deletions(-) create mode 100644 client/src/hooks/useAgyModels.js create mode 100644 client/src/hooks/useAgyModels.test.jsx diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md index 398a8b2b98..5285196798 100644 --- a/.changelog/NEXT.md +++ b/.changelog/NEXT.md @@ -17,6 +17,7 @@ - **Test Connection in Image Gen settings now tests the CLI whose tab you're on.** Pressing it from the Grok CLI or Agy CLI tab probed whichever backend was saved as the default instead — so an Agy test answered `codex — codex-cli 0.145.0`, which looked like Agy had somehow resolved to the Codex binary. The button now probes the provider for the open tab (Backend, Tokens, Expose, and Test still probe the saved default backend), and every result is shown only under the tab that asked for it — so a slow probe you left behind can't surface under the provider you switched to. - **[issue-3186] Agy CLI is now an opt-in image-generation backend.** Users can enable Antigravity in Image Gen settings, select an installed or custom model, and send text-to-image work through its `generate_image` tool with queue progress, cancellation, gallery metadata, cleaner options, and CoS tool registration. Each render runs in an isolated scratch directory and only a signature-verified directed image is imported; image-edit surfaces continue to use an edit-capable backend. +- **Agy's model field is now labeled for what it actually selects, and can be overridden per render.** The Image Gen settings field was labeled "Image model" while listing Gemini agent models, which read as the wrong list being loaded. It isn't: `agy models` is the catalog of *agent* models the CLI's `--model` flag accepts, and that agent is what drives the built-in `generate_image` tool. Agy exposes no knob for the underlying image model — its `generate_image` takes prompt, image name, aspect ratio, and reference paths and nothing else — so there is no `imagen-*` option to offer, and passing one makes agy exit before it generates. The field is now "Default agent model" with that explanation, and the Image Gen page adds an **Agent model** picker so a single queue item can run on a different agent model than the saved default without a settings round-trip. Leaving it blank inherits the Settings value; a pinned model that Google later drops from the catalog falls back to the default instead of failing the render. The retry editor on a failed Agy job now edits that same model field (it was bound to the local model-id field, which Agy never reads). ## Game - **[issue-3177] Added a Game studio for assembling managed-app asset bundles.** Create a Game workspace, bind reusable Sprite records and Music tracks, compile an immutable versioned manifest with SHA-256 asset references, and request persisted AI feedback with any configured provider, model, and supported effort. The new `/game/:id` workspace is reachable from Create, Cmd+K, and voice navigation. diff --git a/client/src/components/imageGen/ImageGenControls.jsx b/client/src/components/imageGen/ImageGenControls.jsx index 87924d4419..1ada9ebe18 100644 --- a/client/src/components/imageGen/ImageGenControls.jsx +++ b/client/src/components/imageGen/ImageGenControls.jsx @@ -50,11 +50,25 @@ export default function ImageGenControls({ modelStatus = null, onModelDownload, onModelDownloadCancel, + // Per-render cloud-CLI model override. `cloudModels` is the live catalog — + // an id outside it makes the CLI exit before generating, so the select only + // renders once the probe has returned something AND the host wired + // `onCloudModelChange`. Omitting either prop hides the knob entirely rather + // than shipping a control whose value the host would silently drop. No mode + // check here: the host owns which backends it supplies a catalog for (see + // supportsCloudModelOverride), so a second CLI backend is a caller change, + // not an edit to this shared component. + cloudModels = [], + cloudModel = '', + onCloudModelChange, + cloudModelLabel = 'Model', + cloudModelDefaultLabel = '', }) { const isLocal = mode === IMAGE_GEN_MODE.LOCAL; // Cloud-CLI backends (codex, grok) pick model/steps/seed internally — only // resolution + style fields apply, so the local-only knobs are hidden. const isCloudCli = isCloudCliMode(mode); + const showCloudModel = !!onCloudModelChange && cloudModels.length > 0; const currentModel = models.find((m) => m.id === modelId); const isFlux2 = currentModel?.runner === RUNNER_FAMILIES.FLUX2; @@ -89,6 +103,25 @@ export default function ImageGenControls({ )} + {/* The cloud CLI's own model, overridable per queue item. Blank = inherit + the Settings default, so clearing the select never pins an empty id — + the server treats a blank `cloudModel` as "use the saved value". */} + {showCloudModel && ( + + + + )} + {/* Preset dropdown + arbitrary width/height. The image route accepts ANY integer edge in [64, 3840] with total pixels ≤ 8.29M (imageEdgeSchema is `z.number().int().min(64).max(3840)` + refineImagePixelCap), so diff --git a/client/src/components/imageGen/ImageGenControls.test.jsx b/client/src/components/imageGen/ImageGenControls.test.jsx index 3368761f6d..7f07492ba0 100644 --- a/client/src/components/imageGen/ImageGenControls.test.jsx +++ b/client/src/components/imageGen/ImageGenControls.test.jsx @@ -81,3 +81,52 @@ describe('ImageGenControls — custom dimensions', () => { expect(screen.getByLabelText('Height')).toBeTruthy(); }); }); + +// The per-render cloud-CLI model picker. Gated on BOTH a non-empty catalog and +// an `onCloudModelChange` handler — rendering a select whose value the host +// would silently drop is worse than not offering the knob at all. +describe('ImageGenControls — per-render cloud model override', () => { + const agyProps = (overrides = {}) => baseProps({ + mode: IMAGE_GEN_MODE.AGY, + cloudModels: ['gemini-3.6-flash-high', 'gemini-3.1-pro-high'], + cloudModelLabel: 'Agent model', + onCloudModelChange: vi.fn(), + ...overrides, + }); + + it('hides the picker when the probe returned no models', () => { + render(); + expect(screen.queryByLabelText('Agent model')).toBeNull(); + }); + + it('hides the picker when the host wired no change handler', () => { + render(); + expect(screen.queryByLabelText('Agent model')).toBeNull(); + }); + + it('lists every probed model plus a blank "Settings default" option', () => { + render(); + const select = screen.getByLabelText('Agent model'); + expect([...select.options].map((o) => o.value)) + .toEqual(['', 'gemini-3.6-flash-high', 'gemini-3.1-pro-high']); + // Naming what blank resolves to is the point — "Settings default" alone + // leaves the user guessing which model a blank select actually runs. + expect(select.options[0].textContent).toBe('Settings default (gemini-3.5-flash-high)'); + }); + + it('emits the picked id, and emits blank when cleared back to the default', () => { + const onCloudModelChange = vi.fn(); + render(); + const select = screen.getByLabelText('Agent model'); + fireEvent.change(select, { target: { value: 'gemini-3.1-pro-high' } }); + expect(onCloudModelChange).toHaveBeenLastCalledWith('gemini-3.1-pro-high'); + fireEvent.change(select, { target: { value: '' } }); + expect(onCloudModelChange).toHaveBeenLastCalledWith(''); + }); + + it('hides the local-only model/steps/seed knobs on a cloud backend', () => { + render(); + expect(screen.getByLabelText('Agent model')).toBeTruthy(); + expect(screen.queryByLabelText('Model')).toBeNull(); + }); +}); diff --git a/client/src/components/imageGen/ImageGenSettingsForm.jsx b/client/src/components/imageGen/ImageGenSettingsForm.jsx index dbd1a9f7d6..46f0903ddc 100644 --- a/client/src/components/imageGen/ImageGenSettingsForm.jsx +++ b/client/src/components/imageGen/ImageGenSettingsForm.jsx @@ -90,7 +90,9 @@ export default function ImageGenSettingsForm({

) : isAgy ? (

- Agy uses the model selected in Settings and supports text-to-image only. Resolution and style fields apply. + Agy's generate_image picks the image model internally + and supports text-to-image only. The model in Settings is the agent that drives the + tool. Resolution and style fields apply.

) : null} diff --git a/client/src/components/media/MediaJobsQueue.jsx b/client/src/components/media/MediaJobsQueue.jsx index 93078b614c..82ee45c4d8 100644 --- a/client/src/components/media/MediaJobsQueue.jsx +++ b/client/src/components/media/MediaJobsQueue.jsx @@ -5,8 +5,8 @@ import ConfirmButtonPair from '../ui/ConfirmButtonPair'; import { FormField } from '../ui/FormField'; import { listMediaJobs, cancelMediaJob, cancelQueuedMediaJobs, deleteMediaJob, retryMediaJob, runMediaJobNow } from '../../services/apiMediaJobs.js'; import { listLoraTrainingCheckpoints } from '../../services/apiLoraTraining.js'; -import { isCloudCliMode, IMAGE_GEN_MODE, CODEX_IMAGEGEN_DEFAULT_EFFORT } from '../../lib/imageGenBackends'; -import { CODEX_EFFORT_LEVELS } from '../../utils/providers'; +import { isCloudCliMode, IMAGE_GEN_MODE, CODEX_IMAGEGEN_DEFAULT_EFFORT, supportsCloudModelOverride, modeLabel } from '../../lib/imageGenBackends'; +import { ANTIGRAVITY_CONFIGURED_DEFAULT, CODEX_EFFORT_LEVELS, isConfiguredDefaultModel } from '../../utils/providers'; import { lossSparklineGeometry } from '../../lib/lossSparkline'; import { useAutoRefetch } from '../../hooks/useAutoRefetch'; import { useConfirmDelete } from '../../hooks/useConfirmDelete'; @@ -57,7 +57,7 @@ function modelLabel(params) { } if (params.mode === IMAGE_GEN_MODE.AGY) { const model = (typeof params.model === 'string' ? params.model.trim() : ''); - return model && model !== 'antigravity-configured-default' + return model && model !== ANTIGRAVITY_CONFIGURED_DEFAULT ? `agy / ${model}` : 'agy / configured default'; } @@ -480,9 +480,22 @@ const EFFORT_DEFAULT_OPTION = 'default'; function EditRetryForm({ job, onSubmit, onCancel }) { const p = job.params || {}; const isCodex = p.mode === IMAGE_GEN_MODE.CODEX; + // Codex and Agy both run under a CLI model id carried on `params.model`; + // local jobs use `params.modelId` (a PortOS image-model registry id). Binding + // an Agy retry to `modelId` would edit a field its provider never reads AND + // leave the actual model unchangeable, so the field follows `params.model` + // for every backend that takes a CLI model — the same capability list the + // gen form's override picker is gated on. + const usesCliModel = supportsCloudModelOverride(p.mode); const [prompt, setPrompt] = useState(p.prompt || ''); const [negativePrompt, setNegativePrompt] = useState(p.negativePrompt || ''); - const [model, setModel] = useState(p.model || ''); + // A configured-default sentinel means "let the CLI's own config pick" — display + // it as an empty field so the "leave empty for default" placeholder is honest. + // The submit comparison below uses this same blanked value, not the raw + // `p.model`: otherwise an untouched sentinel field reads as an edit and + // submits `model: ''` on every retry. + const originalModel = isConfiguredDefaultModel(p.model) ? '' : (p.model || ''); + const [model, setModel] = useState(originalModel); const [modelId, setModelId] = useState(p.modelId || ''); const [width, setWidth] = useState(p.width ?? ''); const [height, setHeight] = useState(p.height ?? ''); @@ -500,8 +513,8 @@ function EditRetryForm({ job, onSubmit, onCancel }) { const numEq = (a, b) => (a === '' ? null : Number(a)) === (b ?? null); if (!trimEq(prompt, p.prompt) && prompt.trim()) overrides.prompt = prompt.trim(); if (!trimEq(negativePrompt, p.negativePrompt)) overrides.negativePrompt = negativePrompt.trim(); - if (isCodex && !trimEq(model, p.model)) overrides.model = model.trim(); - if (!isCodex && !trimEq(modelId, p.modelId)) overrides.modelId = modelId.trim(); + if (usesCliModel && !trimEq(model, originalModel)) overrides.model = model.trim(); + if (!usesCliModel && !trimEq(modelId, p.modelId)) overrides.modelId = modelId.trim(); if (!numEq(width, p.width) && width !== '') overrides.width = Number(width); if (!numEq(height, p.height) && height !== '') overrides.height = Number(height); if (!numEq(steps, p.steps) && steps !== '') overrides.steps = Number(steps); @@ -532,12 +545,15 @@ function EditRetryForm({ job, onSubmit, onCancel }) { />
- + {/* 'Codex model' / 'Agy model' come from the shared backend META rather + than a per-backend ternary ladder — a new CLI backend gets the right + label from its registry entry alone. */} + (isCodex ? setModel(e.target.value) : setModelId(e.target.value))} - placeholder={isCodex ? 'leave empty for default' : 'e.g. z-image-turbo-bf16'} + value={usesCliModel ? model : modelId} + onChange={(e) => (usesCliModel ? setModel(e.target.value) : setModelId(e.target.value))} + placeholder={usesCliModel ? 'leave empty for default' : 'e.g. z-image-turbo-bf16'} className="w-full px-2 py-1 bg-port-bg border border-port-border rounded text-white text-xs" maxLength={200} /> diff --git a/client/src/components/media/MediaJobsQueue.test.jsx b/client/src/components/media/MediaJobsQueue.test.jsx index d3e3525e3b..208ddcf091 100644 --- a/client/src/components/media/MediaJobsQueue.test.jsx +++ b/client/src/components/media/MediaJobsQueue.test.jsx @@ -168,6 +168,58 @@ describe('MediaJobsQueue — Codex reasoning-effort retry control', () => { }); }); +// An Agy job's model lives on `params.model` (the CLI's `--model` session +// model), NOT `params.modelId` (a local image-model registry id). The retry +// editor must follow the same field, or it edits something Agy never reads and +// leaves the actual model unchangeable. +describe('MediaJobsQueue — Agy retry model field', () => { + const failedAgyJob = { + id: 'agyfail000000dead', + kind: 'image', + status: 'failed', + error: 'boom', + queuedAt: '2026-06-19T10:00:00Z', + params: { prompt: 'a fox', mode: 'agy', model: 'gemini-3.5-flash-high' }, + }; + + it('pre-fills the Agy model from params.model and retries with the edited value', async () => { + const user = userEvent.setup(); + listMediaJobs.mockResolvedValue([failedAgyJob]); + render(); + await expandReel(user); + await user.click(await screen.findByLabelText('Edit and retry')); + + const input = await screen.findByLabelText('Agy model'); + expect(input.value).toBe('gemini-3.5-flash-high'); + await user.clear(input); + await user.type(input, 'gemini-3.1-pro-high'); + await user.click(screen.getByRole('button', { name: /Retry with changes/i })); + + expect(retryMediaJob).toHaveBeenCalledWith( + 'agyfail000000dead', { model: 'gemini-3.1-pro-high' }, { silent: true }, + ); + }); + + it('shows the configured-default sentinel as an empty field and sends no override', async () => { + const user = userEvent.setup(); + listMediaJobs.mockResolvedValue([{ + ...failedAgyJob, + id: 'agysentinel00dead', + params: { ...failedAgyJob.params, model: 'antigravity-configured-default' }, + }]); + render(); + await expandReel(user); + await user.click(await screen.findByLabelText('Edit and retry')); + + // The sentinel is "let agy choose", not a literal id the user should see or + // resubmit — an untouched field must leave the original params intact. + const input = await screen.findByLabelText('Agy model'); + expect(input.value).toBe(''); + await user.click(screen.getByRole('button', { name: /Retry with changes/i })); + expect(retryMediaJob).toHaveBeenCalledWith('agysentinel00dead', null, { silent: true }); + }); +}); + describe('MediaJobsQueue — training rows', () => { it('renders a training summary + engine/character label instead of a prompt', async () => { listMediaJobs.mockResolvedValue([trainingJob]); diff --git a/client/src/components/settings/ImageGenTab.jsx b/client/src/components/settings/ImageGenTab.jsx index c16d01e71f..4ee922d4c5 100644 --- a/client/src/components/settings/ImageGenTab.jsx +++ b/client/src/components/settings/ImageGenTab.jsx @@ -20,11 +20,12 @@ import { isLoopbackHost } from '../../lib/loopbackHost.js'; import { getSettings, updateSettings, getImageGenStatus, generateImage, registerTool, updateTool, getToolsList, - saveHfToken, clearHfToken, listAgyImageModels, + saveHfToken, clearHfToken, } from '../../services/api'; import { isCloudCliMode, IMAGE_GEN_MODE, CODEX_IMAGEGEN_DEFAULT_EFFORT, GROK_ASPECT_RATIOS } from '../../lib/imageGenBackends'; import { resolveCleanersFromConfig } from '../../lib/imageCleaners'; import { useMediaJobSse } from '../../hooks/useMediaJobSse'; +import { useAgyModels } from '../../hooks/useAgyModels'; import { useHfTokenStatus } from '../../hooks/useHfTokenStatus'; import { CODEX_EFFORT_LEVELS } from '../../utils/providers'; @@ -120,9 +121,6 @@ export function ImageGenTab() { const [agyEnabled, setAgyEnabled] = useState(false); const [agyPath, setAgyPath] = useState(''); const [agyModel, setAgyModel] = useState(''); - const [agyModels, setAgyModels] = useState([]); - const [agyModelsError, setAgyModelsError] = useState(null); - const [agyModelsLoading, setAgyModelsLoading] = useState(false); // Per-provider cleaner toggles. Both run after the PNG lands and before // the SSE complete event so subscribers see the cleaned bytes. SynthID // (the gpt-image / Imagen / Gemini pixel-level watermark) is unaffected @@ -286,25 +284,14 @@ export function ImageGenTab() { .finally(() => setLoading(false)); }, []); - const refreshAgyModels = useCallback(() => { - if (!agyEnabled) return; - setAgyModelsLoading(true); - setAgyModelsError(null); - listAgyImageModels({ silent: true }) - .then((result) => { - setAgyModels(Array.isArray(result?.models) ? result.models : []); - setAgyModelsError(result?.error || null); - }) - .catch((err) => { - setAgyModels([]); - setAgyModelsError(err.message || 'Failed to list Agy models'); - }) - .finally(() => setAgyModelsLoading(false)); - }, [agyEnabled]); - - useEffect(() => { - if (mediaTab === 'agy' && agyEnabled) refreshAgyModels(); - }, [mediaTab, agyEnabled, refreshAgyModels]); + // Probe only while the Agy tab is actually open — the list spawns `agy models` + // server-side, so an unopened tab must not pay for a child process. + const { + models: agyModels, + error: agyModelsError, + loading: agyModelsLoading, + refresh: refreshAgyModels, + } = useAgyModels(mediaTab === 'agy' && agyEnabled); // Probe the provider whose tab is open (undefined → the saved default // backend). Every result is tagged with the tab that asked for it and only @@ -920,7 +907,7 @@ export function ImageGenTab() {
- +
{ }); await renderTab(['/media/image?mediaTab=agy']); await waitFor(() => expect(listAgyImageModels).toHaveBeenCalledWith({ silent: true })); - const modelInput = screen.getByLabelText('Image model'); + const modelInput = screen.getByLabelText('Default agent model'); expect(modelInput.value).toBe('gemini-image'); fireEvent.change(modelInput, { target: { value: 'custom/image-v3' } }); fireEvent.click(screen.getByRole('button', { name: /^Save$/ })); diff --git a/client/src/hooks/README.md b/client/src/hooks/README.md index 7cdcadf45c..678f66f847 100644 --- a/client/src/hooks/README.md +++ b/client/src/hooks/README.md @@ -40,6 +40,7 @@ grep -i "what you want to do" client/src/hooks/README.md | `useSseJobSlot` | Generic single-slot SSE job: `pending`/`job` state, terminal-frame handling (`complete`/`error`/`canceled`), and `sse.closed`-without-terminal-frame recovery in one place. Returns `{ active, percent, stage, context, start, cancel }`; pass `startRequest`/`eventsUrl`/`cancelRequest` + per-feature toast copy. | Any "kick off a job → stream SSE → settle on terminal frame" hook. The reference-audio/YouTube/video-download/MIDI hooks all wrap this — don't re-roll the terminal-frame + lost-connection dance. | | `useInstallStream` | BYO-runtime install-log EventSource lifecycle: `stage`/`log`/`complete`/`error` frame dispatch, capped + optionally-debounced log accumulation, "connection lost" handling, ref-stashed `onComplete`, auto-scroll. | A streamed install/setup modal that shows live SSE log lines (FLUX.2, video runtimes). Don't re-roll the EventSource teardown/onComplete-ref dance. | | `useModelDownloadStatus` | Image/video model cache-status + SSE pre-download. | Surfacing "Available" vs "Download" badge inline in the gen form. | +| `useAgyModels` | Live `agy models` probe (the AGENT models `--model` accepts) with `models`/`error`/`loading`/`refresh`; opt-in via an `enabled` flag so it never spawns for a disabled backend. | Any Agy model picker — the Settings default or the per-render override. Don't hardcode a model list: agy exits non-zero on an id outside this catalog. | | `useSpritePendingRenders` | Sprite-workflow in-flight render map: queue rehydrate + 4s poll keyed by a media-job tag field, `'submitting'` double-render guard, 404-only drop, deferred `onChanged` sweeps. | A sprite workflow tracking `queued/running` media jobs by tag (reference renders, walk videos, phase-4 publish) — don't re-roll the rehydrate/poll dance. | | `useSpriteRecordCrud` | Rename/delete state machine for one sprite record: `view`/`rename`/`confirm` mode, name draft, busy/inline-error, silent PATCH/DELETE. | A surface that renames or deletes a sprite record (the Library catalog card, the detail header) — share this so both stay in lock-step. | | `useImageGenProgress` | Live diffusion progress for an image-gen call. | Showing per-call image-gen progress. | diff --git a/client/src/hooks/index.js b/client/src/hooks/index.js index 24ca22f141..392ff53fc7 100644 --- a/client/src/hooks/index.js +++ b/client/src/hooks/index.js @@ -41,6 +41,8 @@ export { default as useMoltworldWs } from './useMoltworldWs.js'; export { default as useMounted } from './useMounted.js'; export { default as usePendingListRows } from './usePendingListRows.js'; export { default as usePopoverPosition } from './usePopoverPosition.js'; +export { default as useAgyModels } from './useAgyModels.js'; +export * from './useAgyModels.js'; export { default as useLocalModels } from './useLocalModels.js'; export { default as useVisionModelIds } from './useVisionModelIds.js'; export { default as usePreviewRoute } from './usePreviewRoute.js'; diff --git a/client/src/hooks/useAgyModels.js b/client/src/hooks/useAgyModels.js new file mode 100644 index 0000000000..8699da2ccd --- /dev/null +++ b/client/src/hooks/useAgyModels.js @@ -0,0 +1,51 @@ +// Agy (Antigravity CLI) session-model list. +// +// `agy models` enumerates the AGENT models the CLI's `--model` flag accepts — +// the model that drives the session and calls the built-in `generate_image` +// tool. Agy exposes no knob for the underlying *image* model (its +// `generate_image` tool takes Prompt / ImageName / AspectRatio / ImagePaths and +// nothing else), so this list is the only model selection PortOS can make for +// an Agy render. Handing `--model` anything outside this list makes agy exit +// non-zero before it generates, which is why both the Settings default and the +// per-render override are driven off the live probe rather than a baked-in +// list that can drift when Google rotates the catalog. +// +// The probe spawns a child process server-side, so it is opt-in via `enabled` +// and never runs on mount for a backend the user hasn't turned on. + +import { useCallback, useEffect, useState } from 'react'; +import { listAgyImageModels } from '../services/api'; + +/** + * @param {boolean} enabled - only probe while the Agy backend is in play + * @returns {{ models: string[], error: string|null, loading: boolean, refresh: () => void }} + */ +export function useAgyModels(enabled) { + const [models, setModels] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const refresh = useCallback(() => { + if (!enabled) return; + setLoading(true); + setError(null); + // `silent: true` — the callers below render the error inline next to the + // field, so the shared request() helper must not also toast it. + listAgyImageModels({ silent: true }) + .then((result) => { + setModels(Array.isArray(result?.models) ? result.models : []); + setError(result?.error || null); + }) + .catch((err) => { + setModels([]); + setError(err.message || 'Failed to list Agy models'); + }) + .finally(() => setLoading(false)); + }, [enabled]); + + useEffect(() => { refresh(); }, [refresh]); + + return { models, error, loading, refresh }; +} + +export default useAgyModels; diff --git a/client/src/hooks/useAgyModels.test.jsx b/client/src/hooks/useAgyModels.test.jsx new file mode 100644 index 0000000000..1196e5e26a --- /dev/null +++ b/client/src/hooks/useAgyModels.test.jsx @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useAgyModels } from './useAgyModels'; +import { listAgyImageModels } from '../services/api'; + +vi.mock('../services/api', () => ({ listAgyImageModels: vi.fn() })); + +describe('useAgyModels', () => { + beforeEach(() => vi.clearAllMocks()); + + // The probe spawns `agy models` server-side. A backend the user hasn't + // selected must not pay for a child process, so `enabled` gates the call + // itself — not just what the hook renders with the result. + it('does not probe while disabled', () => { + const { result } = renderHook(() => useAgyModels(false)); + expect(listAgyImageModels).not.toHaveBeenCalled(); + expect(result.current.models).toEqual([]); + expect(result.current.loading).toBe(false); + }); + + it('probes silently once enabled and exposes the catalog', async () => { + listAgyImageModels.mockResolvedValue({ models: ['gemini-3.6-flash-high', 'gemini-3.1-pro-high'] }); + const { result } = renderHook(() => useAgyModels(true)); + + // `silent: true` — callers render the error inline next to the field, so the + // shared request() helper must not also toast it (one layer wins). + expect(listAgyImageModels).toHaveBeenCalledWith({ silent: true }); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.models).toEqual(['gemini-3.6-flash-high', 'gemini-3.1-pro-high']); + expect(result.current.error).toBeNull(); + }); + + // A reachable-but-list-failed probe carries `{ models: [], error }` in a 200 + // body rather than rejecting. Surfacing that error is what keeps "probe + // failed" from reading as "the catalog is empty". + it('surfaces an in-body error without throwing', async () => { + listAgyImageModels.mockResolvedValue({ models: [], error: 'agy models timed out' }); + const { result } = renderHook(() => useAgyModels(true)); + await waitFor(() => expect(result.current.error).toBe('agy models timed out')); + expect(result.current.models).toEqual([]); + }); + + it('surfaces a rejected request and clears any stale catalog', async () => { + listAgyImageModels.mockRejectedValue(new Error('Agy CLI not found')); + const { result } = renderHook(() => useAgyModels(true)); + await waitFor(() => expect(result.current.error).toBe('Agy CLI not found')); + expect(result.current.models).toEqual([]); + expect(result.current.loading).toBe(false); + }); + + // A non-array `models` (older peer, malformed body) must degrade to [] rather + // than reaching a `.map()` in the picker. + it('coerces a non-array models field to an empty list', async () => { + listAgyImageModels.mockResolvedValue({ models: 'gemini-3.6-flash-high' }); + const { result } = renderHook(() => useAgyModels(true)); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.models).toEqual([]); + }); + + it('re-probes on demand via refresh', async () => { + listAgyImageModels.mockResolvedValue({ models: ['gemini-3.5-flash-high'] }); + const { result } = renderHook(() => useAgyModels(true)); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { result.current.refresh(); }); + expect(listAgyImageModels).toHaveBeenCalledTimes(2); + }); +}); diff --git a/client/src/lib/imageGenBackends.js b/client/src/lib/imageGenBackends.js index bddd955e3d..2081dc5b9c 100644 --- a/client/src/lib/imageGenBackends.js +++ b/client/src/lib/imageGenBackends.js @@ -40,6 +40,25 @@ export const CLOUD_IMAGE_GEN_MODES = Object.freeze([ ]); export const isCloudCliMode = (mode) => CLOUD_IMAGE_GEN_MODES.includes(mode); +// Client mirror of the server's `supportsModelOverride` spec flag +// (imageGen/cloudProviderConfig.js) — cloud CLIs that accept a per-render +// `cloudModel` replacing the saved `settings.imageGen..model` for one +// queue item. Grok is absent because its image tools run on a fixed xAI backend +// with no model knob, so offering the control there would be a lie. +// Use `supportsCloudModelOverride` instead of hand-rolled +// `mode === CODEX || mode === AGY` disjunctions — the two must stay in lock-step +// with the server spec, and a new CLI backend should be one entry here. +export const MODEL_OVERRIDE_CAPABLE_MODES = Object.freeze([ + IMAGE_GEN_MODE.CODEX, + IMAGE_GEN_MODE.AGY, +]); +export const supportsCloudModelOverride = (mode) => MODEL_OVERRIDE_CAPABLE_MODES.includes(mode); + +// Human-facing name for a backend ('Local', 'Codex', 'Grok', 'Agy', 'External'). +// Shared so label ladders (`isCodex ? 'Codex model' : …`) don't re-type what META +// already holds and grow a branch per backend. +export const modeLabel = (mode) => META[mode]?.label || mode || ''; + // Backends that support image-to-image (init image / reference editing). The // external SD-API path does not. Single source of truth for i2i gating in the UI. export const I2I_CAPABLE_MODES = Object.freeze([IMAGE_GEN_MODE.LOCAL, IMAGE_GEN_MODE.CODEX, IMAGE_GEN_MODE.GROK]); diff --git a/client/src/pages/ImageGen.jsx b/client/src/pages/ImageGen.jsx index 2c91874698..104cb75ddb 100644 --- a/client/src/pages/ImageGen.jsx +++ b/client/src/pages/ImageGen.jsx @@ -37,7 +37,7 @@ import { AlertTriangle, X, Film, } from 'lucide-react'; import { composeStyledPrompt } from '../lib/composeStyledPrompt'; -import { isCloudCliMode, deriveAvailableBackends, IMAGE_GEN_MODE, isI2iCapableMode, pickI2iMode } from '../lib/imageGenBackends'; +import { isCloudCliMode, deriveAvailableBackends, IMAGE_GEN_MODE, isI2iCapableMode, pickI2iMode, modeLabel } from '../lib/imageGenBackends'; import { clampImageDimensions, clampImageEdge } from '../lib/imageGenResolutions'; import { DEFAULT_NEGATIVE_PROMPT } from '../lib/imageGenDefaults'; import { resolveCleanersFromConfig } from '../lib/imageCleaners'; @@ -47,6 +47,7 @@ import { useImageGenProgress } from '../hooks/useImageGenProgress'; import { useMediaJobSse } from '../hooks/useMediaJobSse'; import { useModelDownloadStatus } from '../hooks/useModelDownloadStatus'; import { useHfTokenStatus } from '../hooks/useHfTokenStatus'; +import { useAgyModels } from '../hooks/useAgyModels'; import { getImageGenStatus, generateImage, generateImageMultipart, listImageModels, listLorasFull, listImageGallery, cancelImageGen, deleteImage, setImageHidden, cleanGalleryImage, getActiveImageJob, getSettings, @@ -155,6 +156,10 @@ export default function ImageGen() { const [quantize, setQuantize] = useState('8'); const [seed, setSeed] = useState(''); const [selectedLoras, setSelectedLoras] = useState([]); + // Per-render override of the Agy session model. Empty = inherit the saved + // Settings default (which itself may be empty = agy's own configured model). + const [agyModel, setAgyModel] = useState(''); + const [savedAgyModel, setSavedAgyModel] = useState(''); // i2i (Flux, local mflux only). source='upload' carries `file`; source='gallery' // carries `name` (basename from URL param). Coupled lifetime — always replace @@ -221,10 +226,23 @@ export default function ImageGen() { const effectiveMode = selectedMode || status?.mode || IMAGE_GEN_MODE.EXTERNAL; const isLocalMode = effectiveMode === IMAGE_GEN_MODE.LOCAL; const isCloudMode = isCloudCliMode(effectiveMode); - const cloudModeLabel = effectiveMode === IMAGE_GEN_MODE.GROK - ? 'Grok' - : effectiveMode === IMAGE_GEN_MODE.AGY ? 'Agy' : 'Codex'; + const cloudModeLabel = modeLabel(effectiveMode); + const isAgyMode = effectiveMode === IMAGE_GEN_MODE.AGY; const isAsyncMode = isLocalMode || isCloudMode; + // Only probe `agy models` while Agy is the active backend — it spawns a + // child process server-side, so an unselected backend must not pay for it. + const agy = useAgyModels(isAgyMode); + // Only ever submit a pin the live catalog confirms. Two cases collapse to the + // same answer — "" (inherit the Settings default): + // - the pin was rotated out of the catalog: a `