Skip to content

Commit 1025fa2

Browse files
ernopcursoragent
andcommitted
grok-web edit: real browser app-chat transport (imagine-image-edit)
The imagine WebSocket accepts properties.image_uri but silently ignores the source image and invents a new scene from the prompt alone (verified 2026-07-31 on live jobs). Live grok.com edits use POST /rest/app-chat/conversations/new with modelName imagine-image-edit and mediaGenInput.imageToImage.inputAssets=[assetId], triggered by clicking the site's real Edit control so x-statsig-id is attached. - GrokWebBrowserClient: ImageEdit trigger (composer fill + Edit click, route-intercepted body), shared ReadAppChatResponseAsync - GrokWebClient: RunImageEditAsync; parse streamingImageGenerationResponse finals + relative generatedImageUrls; never harvest source imageReferences (users/_/... placeholders); image downloads validated as PNG/JPEG/WEBP/GIF instead of demanding MP4 magic - UiJobs/GrokWebWorkflow: attach the Playwright client for edits, not only video End-to-end verified via --grok-web-mode edit: output preserves the source image with the requested overlay. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 92f524e commit 1025fa2

7 files changed

Lines changed: 545 additions & 140 deletions

File tree

MultiImageClient/GrokWebBrowserClient.cs

Lines changed: 179 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,21 @@ public sealed class GrokWebBrowserResponse
2626
public required string Url { get; init; }
2727
}
2828

29+
// Which real grok.com control must initiate the integrity-signed app-chat
30+
// POST. A plain fetch inside Playwright still 403s; only the site's own
31+
// click path attaches a valid x-statsig-id.
32+
public enum GrokWebAppChatTrigger
33+
{
34+
None = 0,
35+
Video = 1,
36+
ImageEdit = 2,
37+
}
38+
2939
// grok.com's app-chat endpoint rejects standalone HTTP clients even when
30-
// they copy browser headers. Video generation therefore performs only that
31-
// POST inside a real logged-in Chromium page. Uploads, image generation,
32-
// media polling, downloads, and saving remain in GrokWebClient.
40+
// they copy browser headers. Video generation and image editing therefore
41+
// perform only that POST inside a real logged-in Chromium page. Uploads,
42+
// image generation (text-to-image WS), media polling, downloads, and
43+
// saving remain in GrokWebClient.
3344
public sealed class GrokWebBrowserClient : IAsyncDisposable, IDisposable
3445
{
3546
public const string ImagineUrl = "https://grok.com/imagine";
@@ -67,6 +78,7 @@ public static GrokWebBrowserClientOptions BuildOptions(
6778
public async Task<GrokWebBrowserResponse> PostAppChatAsync(
6879
object payload,
6980
string? triggerPostId,
81+
GrokWebAppChatTrigger trigger = GrokWebAppChatTrigger.None,
7082
CancellationToken cancellationToken = default)
7183
{
7284
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
@@ -81,11 +93,21 @@ public async Task<GrokWebBrowserResponse> PostAppChatAsync(
8193
await PrepareImaginePageAsync(triggerPostId, ct);
8294

8395
var payloadJson = JsonSerializer.Serialize(payload);
84-
if (!string.IsNullOrWhiteSpace(triggerPostId))
96+
if (trigger != GrokWebAppChatTrigger.None)
8597
{
86-
return await TriggerAppChatFromPageAsync(payloadJson, ct);
98+
if (string.IsNullOrWhiteSpace(triggerPostId))
99+
{
100+
throw new GrokWebException(
101+
$"Grok web browser {trigger} trigger requires a source post id.");
102+
}
103+
return trigger == GrokWebAppChatTrigger.ImageEdit
104+
? await TriggerImageEditAppChatFromPageAsync(payloadJson, ct)
105+
: await TriggerVideoAppChatFromPageAsync(payloadJson, ct);
87106
}
88107

108+
// Plain page fetch still 403s on app-chat (no x-statsig-id).
109+
// Callers that need a signed request must pass a Video/ImageEdit
110+
// trigger instead of relying on this path.
89111
var responseTask = _page!.EvaluateAsync<JsonElement>(
90112
"""
91113
async ({ payloadJson, timeoutMs }) => {
@@ -131,7 +153,7 @@ public async Task<GrokWebBrowserResponse> PostAppChatAsync(
131153
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
132154
{
133155
throw new GrokWebException(
134-
$"Grok web browser video request timed out after {_options.Timeout.TotalSeconds:0} seconds.");
156+
$"Grok web browser request timed out after {_options.Timeout.TotalSeconds:0} seconds.");
135157
}
136158
catch (PlaywrightException ex)
137159
{
@@ -146,7 +168,117 @@ public async Task<GrokWebBrowserResponse> PostAppChatAsync(
146168
}
147169
}
148170

149-
private async Task<GrokWebBrowserResponse> TriggerAppChatFromPageAsync(
171+
// Live protocol (2026-07-31): on an uploaded image post, fill the
172+
// composer and click aria-label=Edit. Grok sends modelName
173+
// imagine-image-edit with mediaGenInput.imageToImage.inputAssets.
174+
// Route interception replaces only the body; x-statsig-id stays.
175+
private async Task<GrokWebBrowserResponse> TriggerImageEditAppChatFromPageAsync(
176+
string payloadJson,
177+
CancellationToken ct)
178+
{
179+
const string endpointPattern = "**/rest/app-chat/conversations/new";
180+
var responseSource = new TaskCompletionSource<IResponse>(
181+
TaskCreationOptions.RunContinuationsAsynchronously);
182+
183+
void HandleResponse(object? sender, IResponse response)
184+
{
185+
if (response.Request.Method == "POST"
186+
&& response.Url.Contains(
187+
"/rest/app-chat/conversations/new",
188+
StringComparison.Ordinal))
189+
{
190+
responseSource.TrySetResult(response);
191+
}
192+
}
193+
194+
Func<IRoute, Task> routeHandler = async route =>
195+
{
196+
await route.ContinueAsync(new RouteContinueOptions
197+
{
198+
PostData = Encoding.UTF8.GetBytes(payloadJson),
199+
});
200+
};
201+
202+
_page!.Response += HandleResponse;
203+
await _page.RouteAsync(endpointPattern, routeHandler);
204+
try
205+
{
206+
var imageToggle = _page.Locator("button[aria-label=\"Image\" i]").Last;
207+
if (await imageToggle.CountAsync() > 0 && await imageToggle.IsVisibleAsync())
208+
{
209+
await imageToggle.ClickAsync(new LocatorClickOptions { Force = true });
210+
}
211+
212+
var composer = _page.Locator(
213+
"[contenteditable=\"true\"][aria-label*=\"Ask Grok\" i], "
214+
+ "[contenteditable=\"true\"]").Last;
215+
await composer.WaitForAsync(new LocatorWaitForOptions
216+
{
217+
State = WaitForSelectorState.Visible,
218+
Timeout = 30_000,
219+
});
220+
await composer.ClickAsync(new LocatorClickOptions { Force = true });
221+
// Harmless placeholder: the intercepted body carries the real prompt.
222+
await composer.FillAsync("edit this image");
223+
224+
var editButton = _page.Locator("button[aria-label=\"Edit\" i]").Last;
225+
var enabled = false;
226+
for (var i = 0; i < 40; i++)
227+
{
228+
ct.ThrowIfCancellationRequested();
229+
if (await editButton.CountAsync() > 0
230+
&& await editButton.IsVisibleAsync()
231+
&& !await editButton.IsDisabledAsync())
232+
{
233+
enabled = true;
234+
break;
235+
}
236+
await Task.Delay(250, ct);
237+
}
238+
if (!enabled)
239+
{
240+
throw new GrokWebException(
241+
"Grok web: the Edit control stayed disabled after filling the composer. "
242+
+ "The Imagine post page layout or account controls may have changed; "
243+
+ "retry with --grok-web-headed.");
244+
}
245+
246+
await editButton.ClickAsync(new LocatorClickOptions { Force = true });
247+
248+
IResponse response;
249+
try
250+
{
251+
response = await responseSource.Task.WaitAsync(TimeSpan.FromSeconds(30), ct);
252+
}
253+
catch (TimeoutException)
254+
{
255+
var screenshotPath = Path.Combine(
256+
Path.GetTempPath(),
257+
$"grok-web-edit-{DateTime.Now:yyyyMMdd-HHmmss}.png");
258+
await _page.ScreenshotAsync(new PageScreenshotOptions
259+
{
260+
Path = screenshotPath,
261+
FullPage = true,
262+
});
263+
Logger.Log($"Grok web edit trigger failed; screenshot: {screenshotPath}");
264+
throw new GrokWebException(
265+
"Grok web: the real Edit control did not start an app-chat request. "
266+
+ "The Imagine page layout or account controls may have changed; retry with --grok-web-headed.");
267+
}
268+
269+
// Edit results arrive in the streaming body as relative
270+
// generatedImageUrls / streamingImageGenerationResponse URLs.
271+
// Wait long enough to collect finals; do not rely on liked-post polling.
272+
return await ReadAppChatResponseAsync(response, ct, bodyTimeoutSeconds: 90);
273+
}
274+
finally
275+
{
276+
_page.Response -= HandleResponse;
277+
await _page.UnrouteAsync(endpointPattern, routeHandler);
278+
}
279+
}
280+
281+
private async Task<GrokWebBrowserResponse> TriggerVideoAppChatFromPageAsync(
150282
string payloadJson,
151283
CancellationToken ct)
152284
{
@@ -311,38 +443,7 @@ await _page.ScreenshotAsync(new PageScreenshotOptions
311443
"Grok web: the real Make Video control did not start an app-chat request. "
312444
+ "The Imagine page layout or account controls may have changed; retry with --grok-web-headed.");
313445
}
314-
string body;
315-
try
316-
{
317-
// The endpoint streams. On silent moderation failures Grok can
318-
// leave the body open indefinitely after returning HTTP 200.
319-
// The durable media-post poll is the source of truth, so do
320-
// not hold the whole video job for the 15-minute browser limit.
321-
var bodyTimeout = TimeSpan.FromSeconds(
322-
Math.Min(30, Math.Max(5, _options.Timeout.TotalSeconds)));
323-
body = await response.TextAsync().WaitAsync(bodyTimeout, ct);
324-
}
325-
catch (TimeoutException)
326-
{
327-
body = "";
328-
Logger.Log(
329-
"Grok web browser: app-chat returned headers but left its streaming body open; "
330-
+ "continuing with media-post polling.");
331-
}
332-
catch (PlaywrightException ex)
333-
{
334-
body = "";
335-
Logger.Log(
336-
$"Grok web browser: could not read the accepted app-chat stream ({ex.Message}); "
337-
+ "continuing with media-post polling.");
338-
}
339-
340-
return new GrokWebBrowserResponse
341-
{
342-
StatusCode = response.Status,
343-
Body = body,
344-
Url = response.Url,
345-
};
446+
return await ReadAppChatResponseAsync(response, ct, bodyTimeoutSeconds: 30);
346447
}
347448
finally
348449
{
@@ -351,6 +452,45 @@ await _page.ScreenshotAsync(new PageScreenshotOptions
351452
}
352453
}
353454

455+
private async Task<GrokWebBrowserResponse> ReadAppChatResponseAsync(
456+
IResponse response,
457+
CancellationToken ct,
458+
int bodyTimeoutSeconds)
459+
{
460+
string body;
461+
try
462+
{
463+
// The endpoint streams. On silent moderation failures Grok can
464+
// leave the body open indefinitely after returning HTTP 200.
465+
// Video can fall back to media-post polling; image edit needs the
466+
// streamed finals, so callers pass a longer bodyTimeoutSeconds.
467+
var bodyTimeout = TimeSpan.FromSeconds(
468+
Math.Min(bodyTimeoutSeconds, Math.Max(5, _options.Timeout.TotalSeconds)));
469+
body = await response.TextAsync().WaitAsync(bodyTimeout, ct);
470+
}
471+
catch (TimeoutException)
472+
{
473+
body = "";
474+
Logger.Log(
475+
"Grok web browser: app-chat returned headers but left its streaming body open; "
476+
+ "continuing with whatever partial body was already collected / media-post polling.");
477+
}
478+
catch (PlaywrightException ex)
479+
{
480+
body = "";
481+
Logger.Log(
482+
$"Grok web browser: could not read the accepted app-chat stream ({ex.Message}); "
483+
+ "continuing with media-post polling.");
484+
}
485+
486+
return new GrokWebBrowserResponse
487+
{
488+
StatusCode = response.Status,
489+
Body = body,
490+
Url = response.Url,
491+
};
492+
}
493+
354494
private async Task EnsureStartedAsync(CancellationToken ct)
355495
{
356496
if (_context != null)

0 commit comments

Comments
 (0)