diff --git a/AGENTS.md b/AGENTS.md index 503a8b1..1a22740 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,7 @@ See `RunOptions.cs` for the source of truth; this is the current surface: - **Production UI browser policy (updated 2026-08-06):** Chromium-backed features remain disabled in `--ui`. `grok-web` text-to-image uses the browser-free imagine WebSocket. Grok-web **image edit and video are browser-free** when the exact current `GrokWebStatsigVerificationKey` + `GrokWebStatsigAnimationKey` pair is configured: `GrokWebStatsigSigner` signs direct app-chat HTTP for both payloads because each uses the same complete request identity, `POST /rest/app-chat/conversations/new`. The UI reports grok-web image-capable/video-capable only with a complete validated pair. Capture/refresh the public deployment pair via one explicit CLI operation, `--grok-web-capture-statsig --input-image `: Playwright invokes the real Edit control, intercepts and aborts before generation, and the C# signer must reproduce the captured header byte-for-byte before settings are written. Image edit was live-verified locally 2026-08-05 (direct .NET HTTP accepted; one edit returned a 1328x784 image in ~50s); browser-free video's first provider test is pending. Missing/malformed/stale material and provider rejection fail closed; never guess. `meta-web` remains absent from UI. Explicit CLI modes retain Playwright only as a fallback when signing material is absent. - `--ui [--ui-port N]` — **local web UI** (the project's first GUI; default port 5960, binds 127.0.0.1 only). Kestrel + minimal APIs hosted inside the console binary (`FrameworkReference Microsoft.AspNetCore.App`, no NuGet additions), serving a no-build static frontend from `MultiImageClient/Ui/wwwroot/` (source-tree copy preferred at runtime, so editing `app.js` + refreshing the browser works without a rebuild). Core gesture: paste an image from the clipboard (Ctrl+V anywhere, or drag-drop/browse), type a prompt, pick generators, watch results fill in live side-by-side. The complete current endpoint catalog is exposed: `gpt2`, `gpt1`, `gpt1-mini`, `ideogram` (V4, text-only), `ideogram-v3` (image-capable; split from V4 on 2026-07-28), `recraft`, `bfl`, `google`, `googlepro`, `grok-web`, `grok-api`, `grok-api-pro`, `meta-web`, `local-klein`, and `local-zimage`. Unconfigured targets remain visible but disabled with their exact availability problem; the two local ComfyUI targets (`local-klein`, `local-zimage`) are additionally gated behind `EnableLocalGenerators` in settings.json (default **false** = "not installed": disabled in every UI, skipped by showcase/batch runs, never installed automatically — the gate lives in `ProviderKeyValidator.DescribeKeyProblem`, which every surface consults); Enable all / Disable all / Toggle all act on every available target, and while an image is attached two more bulk buttons appear: "Enable image-capable" and "Disable text-only". **Attachment policy (user-specified, 2026-07-28): text-to-image-only endpoints stay selectable on image jobs and run from the prompt alone — the attached image is simply not sent to them.** The UI labels this behavior everywhere: every chooser chip carries a tiny picture icon when the target accepts an image (a slashed red one appears on text-only targets while an image is attached), and each such result cell gets a red "text-only" badge stating the attachment was not sent. `gpt2`, `grok-web`, and both `grok-api` tiers auto-route to edit generators when an image is attached (the image-capable set is defined once as `UiJobRunner.ImageCapableKeys`, exposed per-generator as `imageCapable` in `/api/config`, and consumed by the frontend — never hardcode it in `app.js`); `recraft` runs Recraft's `/images/imageToImage` (strength default 0.1 — that scale is VERY aggressive: 0.2+ already discards the source composition entirely, observed 2026-07-20; input contract is <10 MB / <16 MP / max dimension <4096 px / min 256 px, violations get rejected as `image_too_big`, so `RecraftGenerator.ConformImageToImageInput` deterministically downscales+re-encodes oversized inputs before upload and logs the transformation — undersized inputs are a hard error); `google`/`googlepro`/`bfl`/`ideogram-v3` use the pasted image as a style/reference guide (Ideogram V3 accepts style references; the `ideogram` V4 target is JSON-only and therefore text-only — the two are separate targets since 2026-07-28). Remaining targets run image-input jobs from the prompt alone per the attachment policy above. Recraft V4.1 has NO style/substyle/artistic_level/negative_prompt surface (all V2/V3-era concepts, rejected or ignored on V4.x; API-created custom styles are V3-only) — but it does honor `n` (1-6), `random_seed`, and `size` as either exact `WxH` or aspect `w:h` (omitting size lets Recraft auto-pick from the prompt); `RecraftGenerator` exposes all of these (`sizeOverride`/`imageCount`/`randomSeed`/`imageStrength` ctor params) and the UI maps shape→aspect + passes n. **Option-mapping policy (2026-07-20):** if a provider endpoint offers a knob, the generator + UI support it; if a selected target doesn't support a configured option (n, shape, detail, quality...), the job still runs and the option is silently ignored — the UI only hard-disables a target for capability gaps (currently: image-input on text-only targets). Per-target mapping lives in `UiShapeMapping` (`Implementation/UiJobs.cs`): gpt-image-2 exact WxH; gpt-image-1 3 canonical sizes + n≤10; grok-api AR + 1k/2k + n≤10; Recraft aspect + n≤6 (+seed in ctor); Ideogram V4 native 2K resolution by shape + num_images≤8 (+seed; detail is a no-op, v4 is 2K-native — and as of 2026-07-20 the v4 endpoint silently IGNORES num_images: HTTP 200, one image back, so GetCost stays per-call; V3 honors it), Ideogram V3 (own `ideogram-v3` target: style-reference path with an image, plain text-to-image without one, auto = square) AR enum + num_images≤8 (verified working); BFL explicit WxH (~1 MP standard / ~4 MP high+max, multiples of 32, no n on the API); Google AR + imageSize 1K/2K/4K (no n — Gemini returns what it returns). Multi-image URL responses (Recraft/Ideogram/grok-api with n>1) are downloaded in-generator and returned as `Base64ImageDatas` since `ImageManager`'s Url path only saves one file. grok-web has NO arbitrary n: `enable_side_by_side` false=1 image, true=4 (the web transport's only counts). Architecture: jobs are the unit — `POST /api/jobs` (multipart: prompt + generators + options + optional image) returns an id; `GET /api/events/poll?cursor=N` is a cursor-based poll over one global envelope log (`job-known` metadata announcement per job, then its `accepted`/`gen-start`/`gen-result`/`grid`/`job-done` events; cursor=0 replays the full history, out-of-range cursors resync from 0). **Transport rule: the UI holds ZERO persistent connections** — plain-HTTP localhost gets no HTTP/2, so every long-lived SSE/WebSocket permanently occupies one of the ~6 HTTP/1.1 sockets browsers allow per origin ACROSS ALL TABS; per-job EventSources and later even one-stream-per-window both starved all `` loads and made pages go blind (observed twice, 2026-07-27). The frontend polls every 1s (5s when the tab is hidden), and the logs panel likewise uses `GET /api/logs/poll?after=seq` instead of SSE. Result bytes are served from memory at `/api/jobs/{id}/images/{gen}/{n}`; `?thumb=1` returns a <=640px card preview (JPEG q80, PNG if the source has alpha, memory-cached per job) and cacheability is per IMAGE, not per job (fixed 2026-08-05): any path-backed response — durable original, disk thumb, input, video — gets `Cache-Control: private, max-age=31536000, immutable` because a durable path is recorded exactly once, when that gen/index's final bytes are saved; only memory-backed streaming partials stay `no-store` (their stable URLs advance from partials to finals). Keying on job.IsDone made users reviewing each other's fresh multi-generator jobs re-download every already-final multi-MB original per viewer open until the last generator landed. **Card-image rule (2026-07-28):** job cards and the input-library picker always display `?thumb=1` previews with an explicit `aspect-ratio` placeholder from the event's size, while anchors/viewer/video-source/set-active keep the plain full-resolution URL — a 200+ job history otherwise pulls gigabytes of originals through the browser's ~6-socket HTTP/1.1 pool (collapsed height-less cells defeat `loading="lazy"`, and same-origin tabs' API calls starve behind the image queue; observed 2026-07-28 as "images not loading" plus a second tab stuck without even `/api/config`). Each successful `gen-result` carries a `cost` field (the generator's `GetCost()` estimate — per-image ceiling x n, 0 on failure/free targets); the frontend shows it per result cell, per job, and in a session `#cost-summary` bar with a per-generator breakdown (collapsible to just the headline total via a show/hide toggle whose state persists in localStorage), all recomputed idempotently from cell `data-*` attributes so SSE replays/reconnects never double-count. Everything still saves to disk through the standard `ImageManager` pipeline + combined contact sheet — and when the job carried an input image, the combined sheet's FIRST cell is that input image, gold-framed and labeled `INPUT` in black with a per-generator statement of exactly what function it served ("edit source", "image-to-image source", "style/reference image", "video source", or "NOT sent (text-only target, prompt only)"; role text built by `UiJobRunner.BuildInputImageRoleText`, rendered by `ImageCombiner.CreateBatchLayoutImageSquareAsync`'s `inputImagePath`/`inputImageRole` params, which fail closed if a path arrives without a role, 2026-08-02); uploaded input images are archived under `saves//UiInputs/` — **with the saved extension + content type matching the actual bytes**: the upload is format-sniffed (ImageSharp `Image.Identify`), PNG/JPEG/WEBP saved verbatim under their true type, and any other decodable format (GIF/BMP/... from a web drag-drop) deterministically re-encoded to PNG before the job starts (logged pre-operation input conformance; undecodable uploads are a hard 400). This matters because providers sniff the bytes — Ideogram rejects any style reference that isn't PNG/JPEG/WEBP, and the old code force-labeled unknown formats `image/png` without converting (`IdeogramV3Generator` also hardcoded `image/png` on the multipart part; it now detects the real type from magic bytes and hard-errors on unsupported formats — fixed 2026-07-28 after live "image was not provided in one of supported formats" rejections). Implementation: `Workflows/UiWorkflow.cs` (server), `Implementation/UiJobs.cs` (job model + runner, modeled on `ReplWorkflow.ProcessOneAsync`). Jobs live on the server for the life of the process; every browser window/tab is a view — `GET /api/jobs` + the replayable SSE stream let a (re)loaded page hydrate all history, while composer state (draft prompt/attached image) stays per-window. Output geometry is intent-level, no freetext sizes: a **shape** picker (auto / square 1:1 / landscape 3:2 / portrait 2:3 / wide 16:9 / tall 9:16) plus a **detail** tier (standard ≈1K / high ≈2K / max ≈4K-ish), mapped per generator by `UiShapeMapping` in `Implementation/UiJobs.cs` — gpt-image-2 gets an exact WxH from a validated table (all multiples of 16, inside the pixel envelope; square max is 2880x2880 = exactly the 8 294 400-pixel cap), grok gets an AR string + 1k/2k resolution, and edits with shape=auto inherit the source image's shape. grok-web edit-with-image now works by riding the SAME imagine WebSocket as text-to-image (`wss://grok.com/ws/imagine/listen`): `GrokWebImagineEditGenerator` uploads the source via `/http/upload-file-v2/direct`, creates a media post, then runs browser-backed `POST /rest/app-chat/conversations/new` with `modelName: "imagine-image-edit"` and `mediaGenInput.imageToImage.inputAssets: [assetId]`. Playwright opens the post and clicks grok.com's real **Edit** control so `x-statsig-id` is attached (plain fetch / HttpClient still 403). The older `properties.image_uri` imagine-WebSocket path is intentionally unused for edits: the transport accepts the field but ignores the source and invents a new scene from the prompt alone (observed 2026-07-31). The upload response nests the asset id as `fileMetadata.fileMetadataId` (top-level `uploadId` is NOT an asset id — `GrokWebClient.TryExtractAssetId` handles this). With shape=auto the edit derives the nearest supported AR from the source file's pixel dimensions so it keeps the input's shape. - **UI grok-web details:** **prompt cap (2026-07-30):** the consumer imagine WebSocket hard-rejects prompts over **8192 chars** with "Prompt is too long (invalid_parameter)" (instant, pre-generation, free; verified empirically 8192 OK / 8193 rejected — the official grok-api 4096 limit does NOT apply to this transport). The constant is `GrokWebClient.MaxPromptChars`; `/api/config` surfaces it per generator as `maxPromptChars` and the composer shows a amber non-blocking `#prompt-limit-notice` whenever the prompt exceeds a selected target's cap. Submission stays allowed: `GrokWebClient.GenerateImageAsync` truncates over-limit prompts at the send stage (explicit user-required product behavior, logged, never splits a surrogate pair) — this covers both grok-web text-to-image and edit since both ride the same method; other selected targets still receive the full prompt. Separately: the official grok-api supports prompt-aware `aspect_ratio: "auto"` and the UI sends it. The consumer grok-web imagine WebSocket does not: live tests on 2026-07-20 showed both literal `"auto"` and omitted `aspect_ratio` returning 768x1152 (2:3) for strongly panoramic and square prompts, while explicit `16:9` correctly returned 1280x720. Therefore (2026-07-28) grok-web UI auto requests an explicit square `1:1` — a declared pre-call input default, chosen because Grok's native 2:3 is a poor universal shape and no prompt-aware auto exists on this transport; explicit shapes map as usual. Edit-with-image auto continues to derive/inherit the source dimensions. Side-by-side mode is on by default and asks for up to four images (`n` does not control grok-web; `--grok-web-no-side-by-side` asks for one). Every successful image tile from any provider has **Make Grok video** as a light opaque "grok video" chip floating over the tile's top-right corner, visible only while hovering that result (absolute-positioned: zero reserved space, instant snap, keyboard-focusable): a follow-up dialog with an optional motion prompt, required Normal/Funny/Custom/Spicy method, 1–15s duration (default 10s), and 480p/720p resolution (default 480p). It posts `/api/video-jobs`, reuses the selected persisted result bytes as the source upload, runs `GrokWebImagineVideoGenerator` with side-by-side off, streams job progress through the normal SSE machinery, saves the MP4 under `saves//Video/`, and displays it in the custom video player. Consumer method values sent to Grok are `normal`, `fun`, `custom`, and `extremely-spicy-or-crazy`. Live browser research on 2026-07-20 confirmed that empty-prompt image-to-video succeeds when the method suffix is present, and that video still uses `/rest/app-chat/conversations/new`; this endpoint returns 403 to standalone HTTP but succeeds from a real logged-in browser. Therefore only the video app-chat POST runs through a shared Playwright page (`GrokWebBrowserClient`); image/edit remain on the imagine WebSocket and upload/post/poll/download remain HTTP. One-time setup is `--playwright-install` unless `GrokWebBrowserExecutablePath` points at Chrome/Chromium. See [docs/grok-web-video-browser-transport.md](docs/grok-web-video-browser-transport.md). +- **grok-web served-model surfacing (2026-08-08):** the consumer imagine WebSocket pins no model — the client sends only `enable_pro` and grok.com picks the model server-side, so a provider release (xAI shipped **Imagine Image 2.0** as the consumer Quality Mode on 2026-08-07) changes what serves us with zero code change. Every WS response reports the serving model as `model_name` (+ `mode`); the only name observed before that release is `imagine-x-1` (`GrokWebClient.BaselineServedModelNames`). Any OTHER reported name is announced loudly in the logs once per process per distinct name (`GrokWebClient.AnnounceServedModel`), travels on `TaskProcessResult.ServedModelName`/`ServedModelMode`, and rides the persisted `gen-result` event as `servedModel`/`servedModelMode`/`servedModelIsNew`; the UI result cell shows the reported name beside the pixel size, as an accent "— new model!" pill when outside the baseline. Display/telemetry only — never a gate or fallback; results are used exactly as returned. Covers grok-web text-to-image only: the app-chat edit/video path reports no model identity. When a new name is confirmed stable, add it to the baseline so the announcement retires. - **UI Recraft variants (2026-08-04):** the chooser exposes `recraft` (V4.1 raster), `recraft-v41-utility`, `recraft-v41-pro`, `recraft-v41-vector`, `recraft-v3`, `recraft-v4`, and `recraft-v4-pro`. All accept image-to-image input and n up to 6; image jobs inherit source dimensions and reject explicit output AR overrides. Vector raw output is preserved as SVG while cards and contact sheets use raster previews. Published per-image estimates are V4.1/Utility $0.035, V4.1 Pro $0.21, V4.1 Vector $0.08, V3/V4 $0.04, and V4 Pro $0.25. - **UI describe endpoints (image → text, 2026-08-05):** the chooser has a second, separately-labeled **describe** section whose targets return TEXT descriptions of the attached image(s) instead of images: `describe-ideogram` (Ideogram `/describe`), `describe-openai` (gpt-4.1 vision), `describe-claude` (claude-sonnet-4-5), `describe-gemini` (gemini-2.5-pro), `describe-grok` (grok-4.3 via api.x.ai). Placement (user-specified 2026-08-05): the output-params row (AR/detail/quality/n/moderation) stays directly under the media chooser it applies to, and the describe section sits BELOW that row (those options don't apply to describe targets); the whole section is HIDDEN, not disabled, while no image is attached — nothing to describe means no chips at all. The local `describe-local-internvl`/`describe-local-qwen` targets were REMOVED same day: neither local server exists on the dev box or production, so they only produced instant failures (the CLI describer classes remain for batch workflows). Rules: (a) an attached image is REQUIRED — chips disable without one, `POST /api/jobs` hard-rejects, and each selected endpoint describes EVERY attached input separately (one text per input); (b) the composer prompt is the describe instruction when non-blank; a blank describe-ONLY job gets the standard instruction (`UiJobRunner.DefaultDescribeInstruction`, exposed via `/api/config` `describe.defaultInstruction`; the composer substitutes the same text at submit so the card shows exactly what was recorded) while a blank mixed job is still rejected; (c) Ideogram `/describe` takes no instruction — the prompt is NOT sent to it and its result label says so; (d) describe results ride the normal event pipeline as `gen-result` with `resultKind:"text"`, `texts:[{inputIndex,text,comments,raw}]`, `sentPrompt`, `images:[]` — persisted to events.jsonl like everything else, so live cards, reloads, and day-archive cards all render them identically; (e) describe cells are excluded from the combined contact sheet (a describe-only job builds no sheet at all); (f) blank text from any endpoint is a hard per-target failure, never an empty success. **JSON reply contract (2026-08-05, user-specified):** the instruction-capable targets (all but Ideogram) receive wire prompt = user instruction + `UiJobRunner.DescribeJsonReplyContract`, which forces a JSON object `{"description", "comments"}` — description is ONLY the clean description (models kept appending "Would you like me to..." chatter), comments absorbs all meta remarks and renders as a smaller separated block; the contract also asks (user requirement) for complete matter-of-fact coverage of every person's apparent age, sex, and appearance in support of the project's fairness/equality goal, and requires (user requirement) that resemblance to any real-world person be mentioned: "For everyone in the image who resembles a real-world person, you may mention the resemblance — and indeed must." `ParseDescribeJsonReply` is strict fail-closed (markdown fence stripped deterministically; non-JSON or blank description = hard target failure); OpenAI additionally gets native `text.format json_object` and Gemini `responseMimeType application/json` (Claude/Grok transports have no such knob — prompt only). **Gemini gotcha:** gemini-2.5-pro CANNOT disable thinking — `thinkingBudget: 0` is HTTP 400 "Budget 0 is invalid. This model only works in thinking mode." (looks like billing, is request shape); `GeminiVisionDescriber` sends a fixed 1024 budget and adds it to `maxOutputTokens` since thought tokens count against that cap. Frontend: each result renders as a describe block (full-width UNCLAMPED description — no per-result input thumbnail, since the job head right above already shows the input image (user decision 2026-08-05) — + optional "model comments" + copy button + "view with image" link + a collapsed **sent / returned** `
` exchange viewer showing the exact full wire prompt and the raw pre-parse reply — Ideogram's shows "nothing is sent"); the view link is the viewer-walk anchor (`data-viewer-image` with `data-result-kind="text"`, carrying the viewer-seen mark), and in the JS viewer the stage shows the SUBMITTED input image while a dedicated `#image-viewer-describe` panel above the prompt carries the description + comments (" — description of the submitted image" header; `c` compare stays armed but paints nothing since input IS the stage). Scheduling: describe targets ride their provider-account lanes (openai/xai-api/google/ideogram) plus a new `anthropic` lane. Cost estimates per call: Ideogram $0.01 (published), OpenAI ~$0.015, Claude/Grok ~$0.025, Gemini ~$0.025 (includes mandatory thinking tokens); the session bar counts them as "results". Implementations: `Describers/RemoteVisionDescribers.cs` (OpenAI/Claude/Gemini/Ideogram clients + `DescriberImageFormat.DetectMime` magic-byte MIME detection — Claude/Gemini/Grok data URIs must carry the true type, not hardcoded PNG), existing `GrokVisionDescriber`; catalog + dispatch in `UiJobs.cs` (`DescribeKeys`, `BuildDescriber`, `RunDescribeOneAsync`). - **UI Grok video sources and iteration:** every successful image tile, from any image provider, can be sent to grok-web video generation. Video aspect defaults to the source image, with explicit 1:1 / 3:2 / 2:3 / 16:9 / 9:16 overrides. Every successful video has **Redo Grok video**, which reuses the archived source image (not the MP4) and restores the prior prompt, method, aspect, duration, and resolution for editing, plus **Save video** for downloading the original MP4. Players share one browser-local volume and mute state across every video (initially 50% and unmuted), and start at exact 1:1 pixels. Video jobs do not build image contact sheets, and the frontend suppresses obsolete contact-sheet links when replaying older video-job events. diff --git a/MultiImageClient/GrokWebClient.cs b/MultiImageClient/GrokWebClient.cs index 9268601..84eb092 100644 --- a/MultiImageClient/GrokWebClient.cs +++ b/MultiImageClient/GrokWebClient.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; @@ -72,6 +73,42 @@ public sealed class GrokWebClient : IDisposable /// "Prompt is too long (invalid_parameter)". Note this differs from the /// official api.x.ai limit of 4096 — that does not apply here. public const int MaxPromptChars = 8192; + + /// Served-model surfacing (2026-08-08): this transport never pins a + /// model — it sends only enable_pro and grok.com picks the model + /// server-side, so a provider release (e.g. Imagine Image 2.0, shipped + /// 2026-08-07 as the new consumer Quality Mode) changes what serves us + /// with no code change here. Every response reports the serving model + /// as model_name; "imagine-x-1" is the only name this transport had + /// returned before that release. Any other reported name is announced + /// loudly once per process per distinct name so runs visibly say when + /// a new model has arrived. Display/telemetry only — never a gate, + /// never a fallback; generation results are used exactly as returned. + public static readonly IReadOnlyList BaselineServedModelNames = new[] { "imagine-x-1" }; + + public static bool IsBaselineServedModel(string? modelName) + => !string.IsNullOrWhiteSpace(modelName) + && BaselineServedModelNames.Contains(modelName.Trim(), StringComparer.OrdinalIgnoreCase); + + private static readonly ConcurrentDictionary AnnouncedServedModels = + new(StringComparer.OrdinalIgnoreCase); + + private static void AnnounceServedModel(string? modelName, string? mode) + { + if (string.IsNullOrWhiteSpace(modelName) || IsBaselineServedModel(modelName)) + { + return; + } + if (!AnnouncedServedModels.TryAdd(modelName.Trim(), 0)) + { + return; + } + Logger.Log( + $"==== GROK-WEB MODEL CHANGE: this generation was served by model '{modelName.Trim()}' (mode '{mode ?? "?"}'), " + + $"not the previously observed '{string.Join("', '", BaselineServedModelNames)}'. " + + "grok.com switched what serves this transport server-side " + + "(xAI released Imagine Image 2.0 as the consumer Quality Mode on 2026-08-07). ===="); + } private static readonly TimeSpan FirstImageEventTimeout = TimeSpan.FromSeconds(60); private static readonly TimeSpan ImageEventInactivityTimeout = TimeSpan.FromSeconds(60); @@ -523,6 +560,7 @@ public async Task GenerateImageAsync( }).ToList(), }, metadata: new { requestId, operation = "image-generation" }); + AnnounceServedModel(modelName, mode); return new GrokWebImageGenerationResult { Images = images, diff --git a/MultiImageClient/ImageGenerators/GrokWebImagineGenerator.cs b/MultiImageClient/ImageGenerators/GrokWebImagineGenerator.cs index bd1bf5b..9f6d55c 100644 --- a/MultiImageClient/ImageGenerators/GrokWebImagineGenerator.cs +++ b/MultiImageClient/ImageGenerators/GrokWebImagineGenerator.cs @@ -109,7 +109,11 @@ public async Task ProcessPromptAsync(IImageGenerator generato } _stats.GrokImageGenerationSuccessCount++; - Logger.Log($"\t<- Grok Web Imagine OK in {sw.ElapsedMilliseconds} ms; {result.Images.Count} image(s) model={result.ModelName ?? "?"} mode={result.Mode ?? "?"}"); + var newModelFlag = !string.IsNullOrWhiteSpace(result.ModelName) + && !GrokWebClient.IsBaselineServedModel(result.ModelName) + ? " ** NEW MODEL **" + : ""; + Logger.Log($"\t<- Grok Web Imagine OK in {sw.ElapsedMilliseconds} ms; {result.Images.Count} image(s) model={result.ModelName ?? "?"} mode={result.Mode ?? "?"}{newModelFlag}"); if (!string.IsNullOrWhiteSpace(result.CaptureDirectory)) { Logger.Log($"\t capture: {result.CaptureDirectory}"); @@ -134,6 +138,8 @@ public async Task ProcessPromptAsync(IImageGenerator generato ImageGenerator = _apiType, ImageGeneratorDescription = generator.GetGeneratorSpecPart(), CreateTotalMs = sw.ElapsedMilliseconds, + ServedModelName = result.ModelName, + ServedModelMode = result.Mode, }; } catch (Exception ex) diff --git a/MultiImageClient/Implementation/TaskProcessResult.cs b/MultiImageClient/Implementation/TaskProcessResult.cs index a351cd1..81a9ff6 100644 --- a/MultiImageClient/Implementation/TaskProcessResult.cs +++ b/MultiImageClient/Implementation/TaskProcessResult.cs @@ -29,6 +29,14 @@ public class TaskProcessResult public TextGeneratorApiType TextGenerator { get; set; } public long CreateTotalMs { get; set; } = 0; public long DownloadTotalMs { get; set; } = 0; + + // The model identity the provider REPORTED serving this result with, + // when the transport exposes one (currently grok-web's imagine + // WebSocket model_name/mode). Null elsewhere. Informational only: + // lets runs say at runtime which server-side model produced the + // images (e.g. after xAI's 2026-08-07 Imagine Image 2.0 rollout). + public string ServedModelName { get; set; } + public string ServedModelMode { get; set; } public string GeneratedMediaPath { get; set; } public string GeneratedMediaContentType { get; set; } public string GenerationAttemptId { get; set; } = ""; diff --git a/MultiImageClient/Implementation/UiJobs.cs b/MultiImageClient/Implementation/UiJobs.cs index b7b5030..101a265 100644 --- a/MultiImageClient/Implementation/UiJobs.cs +++ b/MultiImageClient/Implementation/UiJobs.cs @@ -2801,6 +2801,8 @@ or KeyRecraftV41Vector or KeyRecraftV3 or KeyRecraftV4 long createMs = 0, downloadMs = 0; string label = null; string firstError = null; + string servedModel = null; + string servedModelMode = null; for (var attempt = 0; attempt < want; attempt++) { @@ -2958,6 +2960,8 @@ or KeyRecraftV41Vector or KeyRecraftV3 or KeyRecraftV4 totalCost += costEstimate; createMs += result.CreateTotalMs; downloadMs += result.DownloadTotalMs; + servedModel ??= result.ServedModelName; + servedModelMode ??= result.ServedModelMode; if (label == null) { label = copy.RuntimeMeta.TryGetValue("label", out var l) && !string.IsNullOrEmpty(l) @@ -3045,6 +3049,15 @@ or KeyRecraftV41Vector or KeyRecraftV3 or KeyRecraftV4 mediaType, label = label ?? key, size = actualSize, + // Provider-REPORTED serving model, when the transport exposes + // one (grok-web model_name/mode). servedModelIsNew flags names + // outside the transport's known baseline so the card can say + // "you're on the new model" at runtime (e.g. after xAI's + // 2026-08-07 Imagine Image 2.0 rollout). Display only. + servedModel, + servedModelMode, + servedModelIsNew = servedModel != null + && !GrokWebClient.IsBaselineServedModel(servedModel), videoMode = key == KeyGrokWebVideo ? spec.VideoMode : null, videoDurationSeconds = key == KeyGrokWebVideo ? spec.VideoDurationSeconds diff --git a/MultiImageClient/Ui/wwwroot/app.js b/MultiImageClient/Ui/wwwroot/app.js index 1019eef..a0a0dc8 100644 --- a/MultiImageClient/Ui/wwwroot/app.js +++ b/MultiImageClient/Ui/wwwroot/app.js @@ -5224,6 +5224,7 @@ function addJobCard(id, prompt, gens, hasImage, createdAtUnixMs, inputCount, opt
+
@@ -5445,6 +5446,27 @@ function applyJobEvent(id, card, evt) { || (evt.label && (/ \u00b7 (\d+x\d+)$/.exec(evt.label) || [])[1]) || ""; cell.querySelector(".cell-size").textContent = sizeText; + // Provider-REPORTED serving model, when the transport exposes one + // (grok-web model_name). Highlighted when the server-side model isn't + // the known baseline — that's how "you're on the new model" surfaces + // at runtime without any client-side pinning. + const servedEl = cell.querySelector(".cell-served-model"); + if (servedEl) { + if (evt.servedModel) { + servedEl.textContent = evt.servedModelIsNew + ? `${evt.servedModel} — new model!` + : evt.servedModel; + servedEl.classList.toggle("new-model", !!evt.servedModelIsNew); + const modePart = evt.servedModelMode ? ` (mode ${evt.servedModelMode})` : ""; + servedEl.title = evt.servedModelIsNew + ? `The provider reported serving model "${evt.servedModel}"${modePart} — not the previously observed model on this transport.` + : `The provider reported serving model "${evt.servedModel}"${modePart}.`; + } else { + servedEl.textContent = ""; + servedEl.classList.remove("new-model"); + servedEl.removeAttribute("title"); + } + } if (evt.label) cell.querySelector(".cell-head").title = evt.label; cell.dataset.cost = String(evt.cost || 0); cell.dataset.imgCount = String(evt.images.filter(Boolean).length); diff --git a/MultiImageClient/Ui/wwwroot/style.css b/MultiImageClient/Ui/wwwroot/style.css index dd16f82..3dfa33b 100644 --- a/MultiImageClient/Ui/wwwroot/style.css +++ b/MultiImageClient/Ui/wwwroot/style.css @@ -1270,6 +1270,16 @@ body:not(.show-costs) #cost-summary { display: none !important; } } /* Actual returned pixel size; secondary via smaller size, not desaturation. */ .cell-size { font-size: 11px; font-weight: 600; color: var(--accent-dark); white-space: nowrap; } +/* Provider-reported serving model (currently grok-web model_name). The + new-model variant flags a name outside the transport's known baseline: + the server-side model changed under us (e.g. Imagine Image 2.0). */ +.cell-served-model { font-size: 10px; font-weight: 700; color: var(--accent-dark); white-space: nowrap; } +.cell-served-model.new-model { + padding: 1px 7px; + color: #fff; + background: var(--accent); + border-radius: 8px; +} .cell-cost { margin-left: auto; font-size: 12px; color: var(--accent-dark); font-weight: 700; white-space: nowrap; } .cell-time { font-size: 12px; color: var(--ok); font-weight: 600; } .cell-time.err { color: var(--err); }