wtagent 0.3.0 → 1.0.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.
- package/README.md +163 -9
- package/package.json +9 -8
- package/src/artifacts/artifact-store.js +154 -0
- package/src/audio/music-artifact.js +66 -0
- package/src/audio/native-music-receiver.js +91 -0
- package/src/browser/base-web-adapter.js +2418 -201
- package/src/browser/cdp-browser.js +223 -25
- package/src/browser/cdp-state.js +8 -5
- package/src/browser/chatgpt-dom.js +179 -0
- package/src/browser/chatgpt-web-adapter.js +831 -69
- package/src/browser/claude-web-adapter.js +13 -0
- package/src/browser/fake-web-model-adapter.js +152 -3
- package/src/browser/gemini-web-adapter.js +71 -20
- package/src/browser/glm-web-adapter.js +2 -2
- package/src/browser/grok-web-adapter.js +35 -3
- package/src/browser/rendered-text.js +11 -0
- package/src/cli/i18n.js +50 -20
- package/src/cli/main.js +124 -40
- package/src/cli/prompt-input.js +68 -0
- package/src/image/browser-image-driver.js +238 -0
- package/src/image/browser-image-session-pool.js +96 -0
- package/src/image/image-generation-service.js +101 -0
- package/src/image/image-provider-driver.js +22 -0
- package/src/image/native-image-receiver.js +66 -0
- package/src/image/provider-registry.js +23 -0
- package/src/image/providers/chatgpt-image-driver.js +211 -0
- package/src/image/providers/gemini-image-driver.js +100 -0
- package/src/image/providers/grok-image-driver.js +193 -0
- package/src/platform/command-launcher.js +1 -1
- package/src/platform/paths.js +19 -0
- package/src/platform/windows-diagnostics.js +9 -16
- package/src/policy/path-guard.js +5 -1
- package/src/policy/policy-engine.js +10 -5
- package/src/protocol/envelope.js +74 -0
- package/src/protocol/markers.js +16 -0
- package/src/protocol/prompt-builder.js +12 -9
- package/src/protocol/xml-protocol.js +36 -18
- package/src/runtime/agent-runtime.js +1383 -238
- package/src/session/agent-session.js +1047 -121
- package/src/tools/default-tools.js +1 -1
- package/src/tools/image-tools.js +92 -0
- package/src/tools/registry.js +13 -1
- package/docs/technical-design.md +0 -866
package/src/cli/prompt-input.js
CHANGED
|
@@ -849,6 +849,74 @@ export async function promptForText(config, options = {}) {
|
|
|
849
849
|
});
|
|
850
850
|
}
|
|
851
851
|
|
|
852
|
+
// A non-permission startup pause: Enter and expiry both continue. Keep this
|
|
853
|
+
// separate from text/approval prompts, which must never accept on a timer.
|
|
854
|
+
export async function promptForContinue({ message, countdown, timeoutMs = 5_000 }, {
|
|
855
|
+
inputStream = process.stdin,
|
|
856
|
+
outputStream = process.stdout,
|
|
857
|
+
} = {}) {
|
|
858
|
+
if (inputStream.readableEnded || inputStream.destroyed) return null;
|
|
859
|
+
return await new Promise((resolve, reject) => {
|
|
860
|
+
const previousRaw = Boolean(inputStream.isRaw);
|
|
861
|
+
const deadline = Date.now() + timeoutMs;
|
|
862
|
+
let timer;
|
|
863
|
+
let settled = false;
|
|
864
|
+
const render = (seconds) => {
|
|
865
|
+
const text = countdown(seconds);
|
|
866
|
+
if (outputStream.isTTY) {
|
|
867
|
+
// Keep the changing status on one physical terminal row.
|
|
868
|
+
let visible = "";
|
|
869
|
+
for (const ch of text) {
|
|
870
|
+
if (displayWidth(visible + ch) >= (outputStream.columns || 80)) break;
|
|
871
|
+
visible += ch;
|
|
872
|
+
}
|
|
873
|
+
outputStream.write(`\r\u001b[2K${visible}`);
|
|
874
|
+
} else {
|
|
875
|
+
outputStream.write(`${text}\n`);
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
const finish = (value, error) => {
|
|
879
|
+
if (settled) return;
|
|
880
|
+
settled = true;
|
|
881
|
+
clearInterval(timer);
|
|
882
|
+
inputStream.removeListener("data", onData);
|
|
883
|
+
inputStream.removeListener("end", onEnd);
|
|
884
|
+
inputStream.removeListener("close", onEnd);
|
|
885
|
+
inputStream.removeListener("error", onError);
|
|
886
|
+
inputStream.setRawMode?.(previousRaw);
|
|
887
|
+
inputStream.pause?.();
|
|
888
|
+
if (value) render(0);
|
|
889
|
+
if (outputStream.isTTY) outputStream.write("\n");
|
|
890
|
+
if (error) reject(error);
|
|
891
|
+
else resolve(value);
|
|
892
|
+
};
|
|
893
|
+
const onData = (chunk) => {
|
|
894
|
+
const text = String(chunk);
|
|
895
|
+
if (/[\u0003\u0004]/.test(text)) finish(null);
|
|
896
|
+
else if (/[\r\n]/.test(text)) finish(true);
|
|
897
|
+
};
|
|
898
|
+
const onEnd = () => finish(null);
|
|
899
|
+
const onError = (error) => finish(null, error);
|
|
900
|
+
inputStream.on("data", onData);
|
|
901
|
+
inputStream.once("end", onEnd);
|
|
902
|
+
inputStream.once("close", onEnd);
|
|
903
|
+
inputStream.once("error", onError);
|
|
904
|
+
try {
|
|
905
|
+
inputStream.setRawMode?.(true);
|
|
906
|
+
outputStream.write(`${message}\n`);
|
|
907
|
+
render(Math.ceil(timeoutMs / 1_000));
|
|
908
|
+
timer = setInterval(() => {
|
|
909
|
+
const seconds = Math.max(0, Math.ceil((deadline - Date.now()) / 1_000));
|
|
910
|
+
if (seconds === 0) finish(true);
|
|
911
|
+
else render(seconds);
|
|
912
|
+
}, 1_000);
|
|
913
|
+
inputStream.resume?.();
|
|
914
|
+
} catch (error) {
|
|
915
|
+
finish(null, error);
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
852
920
|
export async function promptForSelect(config, options = {}) {
|
|
853
921
|
return await promptWithCleanExit(config, {
|
|
854
922
|
...options,
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ImageProviderDriver } from "./image-provider-driver.js";
|
|
6
|
+
import { appendSystemReminder } from "../protocol/markers.js";
|
|
7
|
+
|
|
8
|
+
const IMAGE_OUTBOUND_CORRELATION_PREFIX =
|
|
9
|
+
" Opaque WTAgent transport correlation ID (do not repeat): ";
|
|
10
|
+
|
|
11
|
+
function parseRemoteFileId(value) {
|
|
12
|
+
try {
|
|
13
|
+
return new URL(value).searchParams.get("id");
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function extensionForContentType(contentType, fallback = ".png") {
|
|
20
|
+
const type = String(contentType ?? "").toLowerCase();
|
|
21
|
+
if (type.includes("jpeg") || type.includes("jpg")) return ".jpg";
|
|
22
|
+
if (type.includes("webp")) return ".webp";
|
|
23
|
+
if (type.includes("png")) return ".png";
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class BrowserImageDriver extends ImageProviderDriver {
|
|
28
|
+
async ensureChatPage() {
|
|
29
|
+
if (!this.adapter.page) {
|
|
30
|
+
throw new Error(`${this.provider} chat page is unavailable.`);
|
|
31
|
+
}
|
|
32
|
+
const kind = this.adapter.classifyConversationUrl?.(this.adapter.page.url());
|
|
33
|
+
if (!["fresh", "restorable", "provisional"].includes(kind)) {
|
|
34
|
+
throw new Error(`${this.provider} is not on a normal chat page.`);
|
|
35
|
+
}
|
|
36
|
+
const deadline = Date.now() + 30_000;
|
|
37
|
+
while (Date.now() < deadline) {
|
|
38
|
+
if (await this.composer()) return;
|
|
39
|
+
await this.adapter.page.waitForTimeout(500);
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`${this.provider} chat composer is unavailable.`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async composer() {
|
|
45
|
+
for (const locator of this.adapter.composerLocators()) {
|
|
46
|
+
if (await locator.isVisible().catch(() => false)) return locator;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
promptText(request) {
|
|
52
|
+
const lines = [
|
|
53
|
+
"Generate exactly one image now. Use your native image-generation capability and output the image itself. Do not write drawing code or respond with only a text description.",
|
|
54
|
+
`Image requirements:\n${request.prompt}`,
|
|
55
|
+
];
|
|
56
|
+
if (request.aspectRatio !== "auto") {
|
|
57
|
+
lines.push(`Aspect ratio: ${request.aspectRatio}.`);
|
|
58
|
+
}
|
|
59
|
+
return lines.join("\n\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Poll alongside image detection, without consuming the assistant boundary.
|
|
63
|
+
// The normal auxiliary-turn completion path still verifies/checkpoints it.
|
|
64
|
+
// A refusal, clarification, or other finished text is a provider result too;
|
|
65
|
+
// do not classify it using language-specific refusal keywords.
|
|
66
|
+
async completedTextResponse(state) {
|
|
67
|
+
const adapter = this.adapter;
|
|
68
|
+
if (typeof adapter.isNewAssistantIdentity !== "function") return null;
|
|
69
|
+
const reset = () => { state.candidate = null; return null; };
|
|
70
|
+
const messages = adapter.assistantMessages();
|
|
71
|
+
if (await messages.count() === 0) return reset();
|
|
72
|
+
const message = messages.last();
|
|
73
|
+
const identity = await adapter.messageIdentity(message);
|
|
74
|
+
const text = await adapter.assistantText(message);
|
|
75
|
+
if (!text.trim() || !adapter.isNewAssistantIdentity({ ...identity, text })) {
|
|
76
|
+
return reset();
|
|
77
|
+
}
|
|
78
|
+
if (await adapter.isAssistantGenerating(message)) return reset();
|
|
79
|
+
for (const locator of adapter.stopButtonLocators()) {
|
|
80
|
+
if (await locator.first().isVisible()) return reset();
|
|
81
|
+
}
|
|
82
|
+
// An image can mount before its pixels/URL are ready. Do not return a
|
|
83
|
+
// caption while that image is still loading or awaiting correlation.
|
|
84
|
+
const hasImage = await message.locator("img").evaluateAll((images) => images.some(
|
|
85
|
+
(image) => !image.complete || (image.naturalWidth >= 256 && image.naturalHeight >= 256),
|
|
86
|
+
));
|
|
87
|
+
if (hasImage) return reset();
|
|
88
|
+
const key = JSON.stringify([identity.id, identity.turn, text]);
|
|
89
|
+
if (state.candidate !== key) {
|
|
90
|
+
state.candidate = key;
|
|
91
|
+
state.since = Date.now();
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const stableMs = adapter.hasReliableCompletionSignal()
|
|
95
|
+
? 2_000
|
|
96
|
+
: Math.max(10_000, adapter.truncatedEnvelopeGraceMs());
|
|
97
|
+
if (Date.now() - state.since < stableMs) return null;
|
|
98
|
+
return {
|
|
99
|
+
type: "text",
|
|
100
|
+
text,
|
|
101
|
+
provenance: {
|
|
102
|
+
provider: this.provider,
|
|
103
|
+
assistantMessageId: identity.id ?? null,
|
|
104
|
+
assistantTurn: identity.turn ?? null,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async performTurn(request, execution, waitForCompletion) {
|
|
110
|
+
const prompt = this.promptText(request);
|
|
111
|
+
if (this.sharedConversation) {
|
|
112
|
+
if (typeof execution?.runAuxiliaryTurn !== "function") {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`${this.provider} image generation in the active chat requires Runtime auxiliary-turn support.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
return await execution.runAuxiliaryTurn({
|
|
118
|
+
text: prompt,
|
|
119
|
+
files: request.referenceImages,
|
|
120
|
+
timeoutMs: request.timeoutMs,
|
|
121
|
+
waitForCompletion,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const outboundId = randomUUID();
|
|
126
|
+
const message = appendSystemReminder(
|
|
127
|
+
prompt,
|
|
128
|
+
"This is an internal WTAgent provider operation. Perform only the requested operation; "
|
|
129
|
+
+ `do not emit the WTAgent XML protocol.${IMAGE_OUTBOUND_CORRELATION_PREFIX}${outboundId}.`,
|
|
130
|
+
);
|
|
131
|
+
let value;
|
|
132
|
+
try {
|
|
133
|
+
const sendResult = await this.adapter.sendMessage(message, {
|
|
134
|
+
files: request.referenceImages,
|
|
135
|
+
maxBytes: null,
|
|
136
|
+
outboundId,
|
|
137
|
+
allowAssistantContinuation: false,
|
|
138
|
+
requireAttachments: request.referenceImages.length > 0,
|
|
139
|
+
});
|
|
140
|
+
value = await waitForCompletion({
|
|
141
|
+
adapter: this.adapter,
|
|
142
|
+
page: this.adapter.page,
|
|
143
|
+
sendResult,
|
|
144
|
+
timeoutMs: request.timeoutMs,
|
|
145
|
+
});
|
|
146
|
+
await this.adapter.captureAuxiliaryTurnCompletion?.({
|
|
147
|
+
timeoutMs: Math.min(request.timeoutMs, 30_000),
|
|
148
|
+
});
|
|
149
|
+
return value;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
await value?.cleanup?.().catch(() => null);
|
|
152
|
+
if (this.adapter.getLastSendStatus?.() !== "not-submitted") {
|
|
153
|
+
error.completionUnknown = true;
|
|
154
|
+
}
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async saveDownload(page, button, timeoutMs) {
|
|
160
|
+
const [download] = await Promise.all([
|
|
161
|
+
page.waitForEvent("download", { timeout: timeoutMs }),
|
|
162
|
+
button.click(),
|
|
163
|
+
]);
|
|
164
|
+
const temporaryDir = await fs.mkdtemp(path.join(os.tmpdir(), "wtagent-image-"));
|
|
165
|
+
const suggestedFilename = path.basename(
|
|
166
|
+
download.suggestedFilename() || "generated-image",
|
|
167
|
+
);
|
|
168
|
+
const temporaryPath = path.join(
|
|
169
|
+
temporaryDir,
|
|
170
|
+
suggestedFilename,
|
|
171
|
+
);
|
|
172
|
+
try {
|
|
173
|
+
await download.saveAs(temporaryPath);
|
|
174
|
+
return {
|
|
175
|
+
temporaryDir,
|
|
176
|
+
temporaryPath,
|
|
177
|
+
suggestedFilename,
|
|
178
|
+
downloadUrl: download.url(),
|
|
179
|
+
};
|
|
180
|
+
} catch (error) {
|
|
181
|
+
await fs.rm(temporaryDir, { recursive: true, force: true }).catch(() => null);
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async saveFromUrl(page, url, timeoutMs) {
|
|
187
|
+
if (!url) {
|
|
188
|
+
throw new Error("No image URL was available to download.");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let body;
|
|
192
|
+
let contentType = "";
|
|
193
|
+
if (url.startsWith("blob:") || url.startsWith("data:")) {
|
|
194
|
+
const result = await page.evaluate(async (src) => {
|
|
195
|
+
const response = await fetch(src);
|
|
196
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
197
|
+
let binary = "";
|
|
198
|
+
const chunk = 0x8000;
|
|
199
|
+
for (let index = 0; index < bytes.length; index += chunk) {
|
|
200
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + chunk));
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
base64: btoa(binary),
|
|
204
|
+
contentType: response.headers.get("content-type") || "",
|
|
205
|
+
};
|
|
206
|
+
}, url);
|
|
207
|
+
body = Buffer.from(result.base64, "base64");
|
|
208
|
+
contentType = result.contentType;
|
|
209
|
+
} else {
|
|
210
|
+
const response = await page.request.get(url, { timeout: timeoutMs });
|
|
211
|
+
if (!response.ok()) {
|
|
212
|
+
throw new Error(`Image URL download failed with HTTP ${response.status()}.`);
|
|
213
|
+
}
|
|
214
|
+
body = Buffer.from(await response.body());
|
|
215
|
+
contentType = response.headers()["content-type"] ?? "";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const temporaryDir = await fs.mkdtemp(path.join(os.tmpdir(), "wtagent-image-"));
|
|
219
|
+
const suggestedFilename = `generated-image${extensionForContentType(contentType)}`;
|
|
220
|
+
const temporaryPath = path.join(temporaryDir, suggestedFilename);
|
|
221
|
+
try {
|
|
222
|
+
await fs.writeFile(temporaryPath, body);
|
|
223
|
+
return {
|
|
224
|
+
temporaryDir,
|
|
225
|
+
temporaryPath,
|
|
226
|
+
suggestedFilename,
|
|
227
|
+
downloadUrl: url,
|
|
228
|
+
};
|
|
229
|
+
} catch (error) {
|
|
230
|
+
await fs.rm(temporaryDir, { recursive: true, force: true }).catch(() => null);
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
remoteFileId(url) {
|
|
236
|
+
return parseRemoteFileId(url);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createWebAdapter, getProviderProfileDir, resolveProvider } from "../browser/provider-registry.js";
|
|
2
|
+
|
|
3
|
+
export class BrowserImageSessionPool {
|
|
4
|
+
constructor({
|
|
5
|
+
primaryAdapter,
|
|
6
|
+
primaryProvider,
|
|
7
|
+
appDataDir,
|
|
8
|
+
chromePath,
|
|
9
|
+
debug = false,
|
|
10
|
+
minimized = true,
|
|
11
|
+
adapterFactory = createWebAdapter,
|
|
12
|
+
}) {
|
|
13
|
+
this.primaryAdapter = primaryAdapter;
|
|
14
|
+
this.primaryProvider = primaryProvider;
|
|
15
|
+
this.appDataDir = appDataDir;
|
|
16
|
+
this.chromePath = chromePath;
|
|
17
|
+
this.debug = debug;
|
|
18
|
+
this.minimized = minimized;
|
|
19
|
+
this.adapterFactory = adapterFactory;
|
|
20
|
+
this.resources = new Map();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async adapterFor(providerId) {
|
|
24
|
+
if (providerId === this.primaryProvider.id) {
|
|
25
|
+
if (!this.primaryAdapter.context || !this.primaryAdapter.page) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`${this.primaryProvider.label} browser context is not ready for image generation.`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
// Image generation is an ordinary turn in the active conversation. Do
|
|
31
|
+
// not clone the adapter or create a second Page: doing so would split the
|
|
32
|
+
// provider's visible history from Runtime's message identities.
|
|
33
|
+
return {
|
|
34
|
+
adapter: this.primaryAdapter,
|
|
35
|
+
sharedConversation: true,
|
|
36
|
+
close: async () => {},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const cached = this.resources.get(providerId);
|
|
41
|
+
if (cached) return await cached;
|
|
42
|
+
const pending = this.#create(providerId);
|
|
43
|
+
this.resources.set(providerId, pending);
|
|
44
|
+
try {
|
|
45
|
+
return await pending;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
this.resources.delete(providerId);
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async #create(providerId) {
|
|
53
|
+
const provider = resolveProvider(providerId);
|
|
54
|
+
const adapter = this.adapterFactory({
|
|
55
|
+
provider,
|
|
56
|
+
profileDir: getProviderProfileDir(this.appDataDir, providerId),
|
|
57
|
+
chromePath: this.chromePath,
|
|
58
|
+
debug: this.debug,
|
|
59
|
+
minimized: this.minimized,
|
|
60
|
+
cancelOnEsc: false,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await adapter.launch();
|
|
64
|
+
let authState = await adapter.getAuthState();
|
|
65
|
+
if (authState !== "authenticated") {
|
|
66
|
+
// A freshly attached SPA can expose neither its login control nor its
|
|
67
|
+
// composer for a few seconds. Give it the same bounded authentication
|
|
68
|
+
// grace as the primary Runtime before declaring the profile logged out.
|
|
69
|
+
await adapter.waitForManualLogin({ timeoutMs: 8_000 }).catch(() => null);
|
|
70
|
+
authState = await adapter.getAuthState();
|
|
71
|
+
}
|
|
72
|
+
if (authState !== "authenticated") {
|
|
73
|
+
await adapter.close().catch(() => null);
|
|
74
|
+
throw new Error(
|
|
75
|
+
`${provider.label} is not logged in. Run "wtagent login --model ${providerId}" first.`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
await adapter.startConversation(null);
|
|
79
|
+
adapter.ownsWindow = true;
|
|
80
|
+
return {
|
|
81
|
+
adapter,
|
|
82
|
+
sharedConversation: false,
|
|
83
|
+
close: async () => adapter.close(),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async close() {
|
|
88
|
+
const resources = await Promise.allSettled([...this.resources.values()]);
|
|
89
|
+
this.resources.clear();
|
|
90
|
+
for (const result of resources) {
|
|
91
|
+
if (result.status === "fulfilled") {
|
|
92
|
+
await result.value.close().catch(() => null);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ArtifactStore } from "../artifacts/artifact-store.js";
|
|
2
|
+
|
|
3
|
+
export class ImageGenerationService {
|
|
4
|
+
constructor({ drivers, defaultProvider = null, driverResolver = null }) {
|
|
5
|
+
this.drivers = new Map(Object.entries(drivers ?? {}));
|
|
6
|
+
this.defaultProvider = defaultProvider;
|
|
7
|
+
this.driverResolver = driverResolver;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
providerIds() {
|
|
11
|
+
return [...this.drivers.keys()];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async resolveDriver(provider = "auto") {
|
|
15
|
+
const requested = provider === "auto"
|
|
16
|
+
? (this.defaultProvider ?? this.providerIds()[0])
|
|
17
|
+
: provider;
|
|
18
|
+
const driver = this.drivers.get(requested)
|
|
19
|
+
?? await this.driverResolver?.(requested);
|
|
20
|
+
if (driver && !this.drivers.has(requested)) {
|
|
21
|
+
this.drivers.set(requested, driver);
|
|
22
|
+
}
|
|
23
|
+
if (!driver) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Image provider "${requested}" is unavailable. Available providers: ${this.providerIds().join(", ") || "none"}.`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return driver;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async generate(request, context) {
|
|
32
|
+
const driver = await this.resolveDriver(request.provider);
|
|
33
|
+
const capabilities = driver.capabilities();
|
|
34
|
+
if (!capabilities.generate) {
|
|
35
|
+
throw new Error(`${driver.provider} does not support image generation.`);
|
|
36
|
+
}
|
|
37
|
+
if (request.referenceImages.length > 0 && !capabilities.referenceImages) {
|
|
38
|
+
throw new Error(`${driver.provider} does not support reference images.`);
|
|
39
|
+
}
|
|
40
|
+
let aspectRatio = request.aspectRatio;
|
|
41
|
+
let requestedAspectRatio = null;
|
|
42
|
+
if (
|
|
43
|
+
aspectRatio !== "auto"
|
|
44
|
+
&& capabilities.aspectRatios.length > 0
|
|
45
|
+
&& !capabilities.aspectRatios.includes(aspectRatio)
|
|
46
|
+
) {
|
|
47
|
+
requestedAspectRatio = aspectRatio;
|
|
48
|
+
aspectRatio = "auto";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await driver.adapter?.restoreWindow?.();
|
|
52
|
+
await driver.adapter?.page?.bringToFront?.().catch(() => null);
|
|
53
|
+
let produced;
|
|
54
|
+
try {
|
|
55
|
+
produced = await driver.generate(
|
|
56
|
+
{ ...request, aspectRatio },
|
|
57
|
+
{
|
|
58
|
+
runAuxiliaryTurn: context.runAuxiliaryTurn,
|
|
59
|
+
},
|
|
60
|
+
);
|
|
61
|
+
} finally {
|
|
62
|
+
if (driver.adapter?.ownsWindow) {
|
|
63
|
+
await driver.adapter.minimizeWindow?.();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// No artifact exists for a completed textual provider reply. Preserve it
|
|
67
|
+
// as a normal result so Runtime can acknowledge the completed browser turn.
|
|
68
|
+
if (produced.type === "text") return produced;
|
|
69
|
+
try {
|
|
70
|
+
const store = new ArtifactStore({
|
|
71
|
+
projectRoot: context.projectRoot,
|
|
72
|
+
allowOutside: context.allowOutside,
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
return await store.saveImage(
|
|
76
|
+
produced.sourcePath,
|
|
77
|
+
request.outputPath,
|
|
78
|
+
{
|
|
79
|
+
...produced.provenance,
|
|
80
|
+
...(requestedAspectRatio
|
|
81
|
+
? { aspectRatio, requestedAspectRatio }
|
|
82
|
+
: {}),
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
// The provider operation has already completed by the time a driver
|
|
87
|
+
// returns. A validation or local write failure must therefore use the
|
|
88
|
+
// same no-replay semantics as a failed download: blindly retrying the
|
|
89
|
+
// tool could spend quota and create another remote image.
|
|
90
|
+
error.completionUnknown = true;
|
|
91
|
+
error.meta = {
|
|
92
|
+
...(error.meta ?? {}),
|
|
93
|
+
provenance: produced.provenance,
|
|
94
|
+
};
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
await produced.cleanup?.().catch(() => null);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Provider contract for browser-backed image generation. Concrete drivers own
|
|
2
|
+
// website-specific navigation, completion detection, and artifact correlation.
|
|
3
|
+
export class ImageProviderDriver {
|
|
4
|
+
constructor({ provider, adapter, sharedConversation = false }) {
|
|
5
|
+
this.provider = provider;
|
|
6
|
+
this.adapter = adapter;
|
|
7
|
+
this.sharedConversation = sharedConversation;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
capabilities() {
|
|
11
|
+
return Object.freeze({
|
|
12
|
+
generate: true,
|
|
13
|
+
referenceImages: false,
|
|
14
|
+
aspectRatios: [],
|
|
15
|
+
imageCount: 1,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async generate(_request, _execution = {}) {
|
|
20
|
+
throw new Error(`${this.provider} does not implement image generation.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { ArtifactStore, inspectImageBuffer } from "../artifacts/artifact-store.js";
|
|
5
|
+
import { createImageDriver, listImageProviderIds } from "./provider-registry.js";
|
|
6
|
+
import { parseGrokGeneratedImageUrl } from "./providers/grok-image-driver.js";
|
|
7
|
+
|
|
8
|
+
// Receive the ordinary assistant turn. This class never sends a prompt or
|
|
9
|
+
// generates an image, and is deliberately absent from the local tool catalog.
|
|
10
|
+
export class NativeImageReceiver {
|
|
11
|
+
static forAdapter(provider, adapter) {
|
|
12
|
+
return listImageProviderIds().includes(provider)
|
|
13
|
+
? new NativeImageReceiver({ provider, adapter }) : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
constructor({ provider, adapter }) {
|
|
17
|
+
this.provider = provider;
|
|
18
|
+
this.adapter = adapter;
|
|
19
|
+
this.driver = createImageDriver({ provider, adapter });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async read(message) {
|
|
23
|
+
const snapshot = await message.locator("img").evaluateAll((elements) => ({
|
|
24
|
+
pending: elements.some((image) => !image.complete),
|
|
25
|
+
images: elements.filter((image) => image.isConnected && image.complete
|
|
26
|
+
&& image.naturalWidth >= 256 && image.naturalHeight >= 256)
|
|
27
|
+
.map((image) => ({
|
|
28
|
+
source: image.currentSrc || image.src,
|
|
29
|
+
width: image.naturalWidth,
|
|
30
|
+
height: image.naturalHeight,
|
|
31
|
+
})),
|
|
32
|
+
}));
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
snapshot.images = snapshot.images.filter((image) => {
|
|
35
|
+
if (!image.source || seen.has(image.source)) return false;
|
|
36
|
+
if (this.provider === "grok" && !parseGrokGeneratedImageUrl(image.source)) return false;
|
|
37
|
+
seen.add(image.source);
|
|
38
|
+
return true;
|
|
39
|
+
});
|
|
40
|
+
return snapshot;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async save(images, { projectRoot, handoffId, assistantMessageId }) {
|
|
44
|
+
const store = new ArtifactStore({ projectRoot });
|
|
45
|
+
// The handoff UUID names this turn, so retries reuse the same destination
|
|
46
|
+
// without overwriting artifacts from another turn or a user-selected path.
|
|
47
|
+
const directory = path.join("artifacts", "native-images",
|
|
48
|
+
createHash("sha256").update(handoffId).digest("hex").slice(0, 24));
|
|
49
|
+
const artifacts = [];
|
|
50
|
+
for (const [index, image] of images.entries()) {
|
|
51
|
+
const downloaded = await this.driver.saveFromUrl(this.adapter.page, image.source, 60_000);
|
|
52
|
+
try {
|
|
53
|
+
const inspected = inspectImageBuffer(await fs.readFile(downloaded.temporaryPath));
|
|
54
|
+
artifacts.push(await store.saveImage(downloaded.temporaryPath,
|
|
55
|
+
path.join(directory, `image-${index + 1}${inspected.extension}`), {
|
|
56
|
+
provider: this.provider,
|
|
57
|
+
assistantMessageId,
|
|
58
|
+
source: image.source,
|
|
59
|
+
}));
|
|
60
|
+
} finally {
|
|
61
|
+
await fs.rm(downloaded.temporaryDir, { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return artifacts;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ChatGPTImageDriver } from "./providers/chatgpt-image-driver.js";
|
|
2
|
+
import { GeminiImageDriver } from "./providers/gemini-image-driver.js";
|
|
3
|
+
import { GrokImageDriver } from "./providers/grok-image-driver.js";
|
|
4
|
+
|
|
5
|
+
export const IMAGE_PROVIDERS = Object.freeze({
|
|
6
|
+
chatgpt: Object.freeze({ id: "chatgpt", driver: ChatGPTImageDriver }),
|
|
7
|
+
gemini: Object.freeze({ id: "gemini", driver: GeminiImageDriver }),
|
|
8
|
+
grok: Object.freeze({ id: "grok", driver: GrokImageDriver }),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export function listImageProviderIds() {
|
|
12
|
+
return Object.keys(IMAGE_PROVIDERS);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createImageDriver({ provider, adapter, sharedConversation = false }) {
|
|
16
|
+
const record = IMAGE_PROVIDERS[provider];
|
|
17
|
+
if (!record) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`Provider "${provider}" has no image driver. Supported image providers: ${listImageProviderIds().join(", ")}.`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return new record.driver({ provider, adapter, sharedConversation });
|
|
23
|
+
}
|