@monoes/monobrowse 1.0.0 → 1.0.2
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/dist/package.json +71 -0
- package/dist/src/browser/action-builder/analyzer.d.ts +0 -1
- package/dist/src/browser/action-builder/analyzer.d.ts.map +1 -1
- package/dist/src/browser/action-builder/analyzer.js +43 -21
- package/dist/src/browser/action-builder/analyzer.js.map +1 -1
- package/dist/src/browser/adapters/index.d.ts.map +1 -1
- package/dist/src/browser/adapters/index.js +4 -0
- package/dist/src/browser/adapters/index.js.map +1 -1
- package/dist/src/browser/browser.js +2 -2
- package/dist/src/browser/browser.js.map +1 -1
- package/dist/src/browser/cdp.d.ts +6 -0
- package/dist/src/browser/cdp.d.ts.map +1 -1
- package/dist/src/browser/cdp.js +56 -1
- package/dist/src/browser/cdp.js.map +1 -1
- package/dist/src/browser/dashboard/api-types.d.ts +1 -1
- package/dist/src/browser/dashboard/server.d.ts +12 -1
- package/dist/src/browser/dashboard/server.d.ts.map +1 -1
- package/dist/src/browser/dashboard/server.js +23 -28
- package/dist/src/browser/dashboard/server.js.map +1 -1
- package/dist/src/browser/dashboard/ui.html +1811 -0
- package/dist/src/browser/playbook/browser-handlers.d.ts +3 -0
- package/dist/src/browser/playbook/browser-handlers.d.ts.map +1 -0
- package/dist/src/browser/playbook/browser-handlers.js +377 -0
- package/dist/src/browser/playbook/browser-handlers.js.map +1 -0
- package/dist/src/browser/playbook/builtin-handlers.d.ts +3 -0
- package/dist/src/browser/playbook/builtin-handlers.d.ts.map +1 -0
- package/dist/src/browser/playbook/builtin-handlers.js +364 -0
- package/dist/src/browser/playbook/builtin-handlers.js.map +1 -0
- package/dist/src/browser/playbook/index.d.ts +3 -0
- package/dist/src/browser/playbook/index.d.ts.map +1 -0
- package/dist/src/browser/playbook/index.js +3 -0
- package/dist/src/browser/playbook/index.js.map +1 -0
- package/dist/src/browser/playbook/store.d.ts +8 -0
- package/dist/src/browser/playbook/store.d.ts.map +1 -0
- package/dist/src/browser/playbook/store.js +87 -0
- package/dist/src/browser/playbook/store.js.map +1 -0
- package/dist/src/browser/screenshot.d.ts +2 -0
- package/dist/src/browser/screenshot.d.ts.map +1 -1
- package/dist/src/browser/screenshot.js +69 -2
- package/dist/src/browser/screenshot.js.map +1 -1
- package/dist/src/browser/types.d.ts.map +1 -1
- package/dist/src/browser/types.js +11 -0
- package/dist/src/browser/types.js.map +1 -1
- package/dist/src/cli/action.d.ts +3 -0
- package/dist/src/cli/action.d.ts.map +1 -0
- package/dist/src/cli/action.js +142 -0
- package/dist/src/cli/action.js.map +1 -0
- package/dist/src/cli/commands.d.ts +8 -0
- package/dist/src/cli/commands.d.ts.map +1 -0
- package/dist/src/cli/commands.js +3018 -0
- package/dist/src/cli/commands.js.map +1 -0
- package/dist/src/cli/output.d.ts +11 -0
- package/dist/src/cli/output.d.ts.map +1 -0
- package/dist/src/cli/output.js +33 -0
- package/dist/src/cli/output.js.map +1 -0
- package/dist/src/cli/platform.d.ts +7 -0
- package/dist/src/cli/platform.d.ts.map +1 -0
- package/dist/src/cli/platform.js +193 -0
- package/dist/src/cli/platform.js.map +1 -0
- package/dist/src/cli/playbook.d.ts +3 -0
- package/dist/src/cli/playbook.d.ts.map +1 -0
- package/dist/src/cli/playbook.js +265 -0
- package/dist/src/cli/playbook.js.map +1 -0
- package/dist/src/cli/types.d.ts +42 -0
- package/dist/src/cli/types.d.ts.map +1 -0
- package/dist/src/cli/types.js +2 -0
- package/dist/src/cli/types.js.map +1 -0
- package/dist/src/cli/workflow.d.ts +2 -0
- package/dist/src/cli/workflow.d.ts.map +1 -0
- package/dist/src/cli/workflow.js +3 -0
- package/dist/src/cli/workflow.js.map +1 -0
- package/dist/src/cli.d.ts +7 -0
- package/dist/src/cli.d.ts.map +1 -0
- package/dist/src/cli.js +187 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/index.d.ts +0 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +0 -1
- package/dist/src/index.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +21 -11
- package/src/browser/action-builder/analyzer.ts +41 -23
- package/src/browser/adapters/index.ts +4 -0
- package/src/browser/browser.ts +2 -2
- package/src/browser/cdp.ts +68 -1
- package/src/browser/dashboard/api-types.ts +1 -1
- package/src/browser/dashboard/server.ts +41 -30
- package/src/browser/dashboard/ui.html +1412 -45
- package/src/browser/screenshot.ts +78 -3
- package/src/browser/types.ts +11 -0
- package/src/cli/action.ts +149 -0
- package/src/cli/commands.ts +2892 -0
- package/src/cli/output.ts +37 -0
- package/src/cli/platform.ts +211 -0
- package/src/cli/types.ts +47 -0
- package/src/cli.ts +208 -0
- package/src/index.ts +0 -1
- package/src/browser/workflow/builtin-handlers.ts +0 -379
- package/src/browser/workflow/engine.ts +0 -202
- package/src/browser/workflow/expression.ts +0 -107
- package/src/browser/workflow/index.ts +0 -5
- package/src/browser/workflow/store.ts +0 -87
- package/src/browser/workflow/types.ts +0 -62
|
@@ -1,379 +0,0 @@
|
|
|
1
|
-
// Built-in node handlers registered for every `browse workflow run` invocation.
|
|
2
|
-
//
|
|
3
|
-
// action.http — fetch a URL (GET/POST/etc.) and put the response in item.data
|
|
4
|
-
// action.save_file — write item data or binaryBase64 to a file on disk
|
|
5
|
-
// action.log — console.log each item (useful for debugging workflows)
|
|
6
|
-
// action.gemini_image — generate image via Gemini web app (browser automation + session store)
|
|
7
|
-
// or Imagen REST API (GEMINI_API_KEY), or mock mode
|
|
8
|
-
import { writeFile, readFile, mkdir, chmod } from 'node:fs/promises';
|
|
9
|
-
import { existsSync } from 'node:fs';
|
|
10
|
-
import { dirname, join, resolve } from 'node:path';
|
|
11
|
-
import { homedir } from 'node:os';
|
|
12
|
-
|
|
13
|
-
/** Resolve a user-supplied path and assert it stays within the working directory. */
|
|
14
|
-
function safeResolvePath(rawPath: string): string {
|
|
15
|
-
const cwd = process.cwd();
|
|
16
|
-
const safePath = resolve(cwd, rawPath);
|
|
17
|
-
if (safePath !== cwd && !safePath.startsWith(cwd + '/')) {
|
|
18
|
-
throw new Error(`Path traversal blocked: "${rawPath}" resolves outside working directory`);
|
|
19
|
-
}
|
|
20
|
-
return safePath;
|
|
21
|
-
}
|
|
22
|
-
import type { NodeHandler } from './engine.js';
|
|
23
|
-
import type { Item } from './types.js';
|
|
24
|
-
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
// Session persistence (shared format with browse-platform.ts)
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
|
|
29
|
-
const SESSIONS_FILE = join(homedir(), '.monomind', 'sessions.json');
|
|
30
|
-
|
|
31
|
-
interface StoredSession {
|
|
32
|
-
id: string;
|
|
33
|
-
platform: string;
|
|
34
|
-
username: string;
|
|
35
|
-
cookies: string; // JSON-serialized CDP cookie array
|
|
36
|
-
userAgent: string;
|
|
37
|
-
createdAt: number;
|
|
38
|
-
lastUsedAt: number;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async function loadStoredSession(platform: string): Promise<StoredSession | null> {
|
|
42
|
-
if (!existsSync(SESSIONS_FILE)) return null;
|
|
43
|
-
try {
|
|
44
|
-
const sessions: StoredSession[] = JSON.parse(await readFile(SESSIONS_FILE, 'utf-8'));
|
|
45
|
-
return sessions.find(s => s.platform === platform) ?? null;
|
|
46
|
-
} catch { return null; }
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async function saveStoredSession(
|
|
50
|
-
platform: string,
|
|
51
|
-
cookies: object[],
|
|
52
|
-
userAgent: string,
|
|
53
|
-
username: string,
|
|
54
|
-
): Promise<void> {
|
|
55
|
-
let sessions: StoredSession[] = [];
|
|
56
|
-
if (existsSync(SESSIONS_FILE)) {
|
|
57
|
-
try { sessions = JSON.parse(await readFile(SESSIONS_FILE, 'utf-8')); } catch { /* start fresh */ }
|
|
58
|
-
}
|
|
59
|
-
const id = `${platform}:${username}`;
|
|
60
|
-
const now = Date.now();
|
|
61
|
-
const existing = sessions.findIndex(s => s.platform === platform);
|
|
62
|
-
const entry: StoredSession = {
|
|
63
|
-
id, platform, username, userAgent,
|
|
64
|
-
cookies: JSON.stringify(cookies),
|
|
65
|
-
createdAt: existing >= 0 ? sessions[existing].createdAt : now,
|
|
66
|
-
lastUsedAt: now,
|
|
67
|
-
};
|
|
68
|
-
if (existing >= 0) sessions[existing] = entry; else sessions.push(entry);
|
|
69
|
-
await mkdir(join(homedir(), '.monomind'), { recursive: true });
|
|
70
|
-
await writeFile(SESSIONS_FILE, JSON.stringify(sessions, null, 2));
|
|
71
|
-
await chmod(SESSIONS_FILE, 0o600);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
75
|
-
// Gemini browser automation
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
|
|
78
|
-
const GEMINI_IMAGE_CHECK = `
|
|
79
|
-
(() => {
|
|
80
|
-
const imgs = Array.from(document.querySelectorAll('img'));
|
|
81
|
-
return imgs.some(img =>
|
|
82
|
-
img.complete && img.naturalWidth > 200 && img.naturalHeight > 200 &&
|
|
83
|
-
img.src && !img.src.includes('icon') && !img.src.includes('avatar') &&
|
|
84
|
-
!img.src.includes('logo') && !img.src.includes('profile')
|
|
85
|
-
);
|
|
86
|
-
})()
|
|
87
|
-
`;
|
|
88
|
-
|
|
89
|
-
const GEMINI_IMAGE_SRC = `
|
|
90
|
-
(() => {
|
|
91
|
-
const imgs = Array.from(document.querySelectorAll('img'));
|
|
92
|
-
const c = imgs.filter(img =>
|
|
93
|
-
img.complete && img.naturalWidth > 200 && img.naturalHeight > 200 &&
|
|
94
|
-
img.src && !img.src.includes('icon') && !img.src.includes('avatar') &&
|
|
95
|
-
!img.src.includes('logo') && !img.src.includes('profile')
|
|
96
|
-
);
|
|
97
|
-
return c.length ? c[c.length - 1].src : '';
|
|
98
|
-
})()
|
|
99
|
-
`;
|
|
100
|
-
|
|
101
|
-
// Generate an image via the Gemini web app, with session management.
|
|
102
|
-
// Flow: connect → restore saved session → if not logged in, wait for user login → generate.
|
|
103
|
-
// Returns the saved file path, or null if Chrome is not available.
|
|
104
|
-
async function generateViaGeminiBrowser(
|
|
105
|
-
prompt: string,
|
|
106
|
-
outputPath: string,
|
|
107
|
-
cdpPort: number,
|
|
108
|
-
): Promise<string | null> {
|
|
109
|
-
let browser: typeof import('../index.js');
|
|
110
|
-
try { browser = await import('../index.js'); } catch { return null; }
|
|
111
|
-
|
|
112
|
-
let conn: Awaited<ReturnType<typeof browser.connectToTarget>>;
|
|
113
|
-
try { conn = await browser.connectToTarget(cdpPort); } catch { return null; }
|
|
114
|
-
|
|
115
|
-
const { client, sessionId } = conn;
|
|
116
|
-
const refs = new Map();
|
|
117
|
-
|
|
118
|
-
try {
|
|
119
|
-
// Restore stored Gemini session cookies before navigating
|
|
120
|
-
const stored = await loadStoredSession('gemini');
|
|
121
|
-
if (stored?.cookies) {
|
|
122
|
-
try {
|
|
123
|
-
const cookies = JSON.parse(stored.cookies) as object[];
|
|
124
|
-
if (cookies.length > 0) {
|
|
125
|
-
await browser.setCookies(client, sessionId, cookies as Parameters<typeof browser.setCookies>[2]);
|
|
126
|
-
}
|
|
127
|
-
} catch { /* ignore malformed cookies */ }
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Navigate to Gemini
|
|
131
|
-
await browser.openUrl(client, sessionId, 'https://gemini.google.com/app');
|
|
132
|
-
|
|
133
|
-
// Dismiss cookie consent if present
|
|
134
|
-
const refs2 = new Map();
|
|
135
|
-
const consentBtn = await browser.findByRole(client, sessionId, refs2, 'button', { name: 'Accept all' }).catch(() => null);
|
|
136
|
-
if (consentBtn) {
|
|
137
|
-
await browser.clickElement(client, sessionId, consentBtn);
|
|
138
|
-
await new Promise(r => setTimeout(r, 1500));
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// Check if logged in — .ql-editor only appears when authenticated
|
|
142
|
-
let inputRef = await browser.findBySelector(client, sessionId, refs, '.ql-editor').catch(() => null);
|
|
143
|
-
|
|
144
|
-
if (!inputRef) {
|
|
145
|
-
// Not logged in — ask the user to authenticate
|
|
146
|
-
console.log('');
|
|
147
|
-
console.log('┌─────────────────────────────────────────────────────────┐');
|
|
148
|
-
console.log('│ Gemini login required │');
|
|
149
|
-
console.log('│ │');
|
|
150
|
-
console.log('│ A browser window has been opened to Gemini. │');
|
|
151
|
-
console.log('│ Please sign in with your Google account. │');
|
|
152
|
-
console.log('│ │');
|
|
153
|
-
console.log('│ The workflow will resume automatically after login. │');
|
|
154
|
-
console.log('│ Waiting up to 5 minutes… │');
|
|
155
|
-
console.log('└─────────────────────────────────────────────────────────┘');
|
|
156
|
-
console.log('');
|
|
157
|
-
|
|
158
|
-
// Navigate to Google sign-in
|
|
159
|
-
await browser.openUrl(client, sessionId, 'https://accounts.google.com/signin/v2/identifier');
|
|
160
|
-
|
|
161
|
-
// Wait up to 5 min for the user to complete login and land back on Gemini
|
|
162
|
-
const loginDeadline = Date.now() + 5 * 60 * 1000;
|
|
163
|
-
let loggedIn = false;
|
|
164
|
-
while (Date.now() < loginDeadline) {
|
|
165
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
166
|
-
const url = await browser.getCurrentUrl(client, sessionId).catch(() => '');
|
|
167
|
-
if (url.includes('gemini.google.com')) {
|
|
168
|
-
inputRef = await browser.findBySelector(client, sessionId, refs, '.ql-editor').catch(() => null);
|
|
169
|
-
if (inputRef) { loggedIn = true; break; }
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
if (!loggedIn) {
|
|
174
|
-
console.log('[action.gemini_image] Login timeout. Run: monomind browse platform connect gemini');
|
|
175
|
-
return null;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
console.log('[action.gemini_image] Login detected! Saving session...');
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Save/refresh cookies after confirming login
|
|
182
|
-
const liveCookies = await browser.getCookies(client, sessionId).catch(() => []);
|
|
183
|
-
const userAgent = await browser.evaluateJs(client, sessionId, 'navigator.userAgent').catch(() => 'unknown') as string;
|
|
184
|
-
const username = await browser.evaluateJs(
|
|
185
|
-
client, sessionId,
|
|
186
|
-
"document.querySelector('.gb_A.gb_Sa')?.textContent?.trim() ?? 'gemini-user'"
|
|
187
|
-
).catch(() => 'gemini-user') as string;
|
|
188
|
-
await saveStoredSession('gemini', liveCookies, String(userAgent), String(username));
|
|
189
|
-
|
|
190
|
-
// Ensure we're on the app page with the input visible
|
|
191
|
-
if (!inputRef) {
|
|
192
|
-
await browser.openUrl(client, sessionId, 'https://gemini.google.com/app');
|
|
193
|
-
inputRef = await browser.findBySelector(client, sessionId, refs, '.ql-editor').catch(() => null);
|
|
194
|
-
}
|
|
195
|
-
if (!inputRef) return null;
|
|
196
|
-
|
|
197
|
-
// Fill and submit the prompt
|
|
198
|
-
await browser.fillElement(client, sessionId, inputRef, prompt);
|
|
199
|
-
await new Promise(r => setTimeout(r, 300));
|
|
200
|
-
await browser.pressKey(client, sessionId, 'Return');
|
|
201
|
-
console.log('[action.gemini_image] Prompt submitted to Gemini, waiting for image...');
|
|
202
|
-
|
|
203
|
-
// Poll up to 90s for generated image
|
|
204
|
-
const deadline = Date.now() + 90_000;
|
|
205
|
-
while (Date.now() < deadline) {
|
|
206
|
-
const found = await browser.evaluateJs(client, sessionId, GEMINI_IMAGE_CHECK).catch(() => false);
|
|
207
|
-
if (found) break;
|
|
208
|
-
await new Promise(r => setTimeout(r, 500));
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
await mkdir(dirname(outputPath), { recursive: true });
|
|
212
|
-
|
|
213
|
-
// Extract image URL and download
|
|
214
|
-
const imgSrc = (await browser.evaluateJs(client, sessionId, GEMINI_IMAGE_SRC).catch(() => '')) as string;
|
|
215
|
-
if (imgSrc && (imgSrc.startsWith('https://') || imgSrc.startsWith('http://'))) {
|
|
216
|
-
try {
|
|
217
|
-
const resp = await fetch(imgSrc);
|
|
218
|
-
if (resp.ok) {
|
|
219
|
-
await writeFile(outputPath, Buffer.from(await resp.arrayBuffer()));
|
|
220
|
-
console.log(`[action.gemini_image] Image saved → ${outputPath}`);
|
|
221
|
-
return outputPath;
|
|
222
|
-
}
|
|
223
|
-
} catch { /* fall through to screenshot */ }
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Fallback: screenshot the response
|
|
227
|
-
const screenshotPath = outputPath.replace(/\.(png|jpe?g|webp)$/i, '') + '-screenshot.png';
|
|
228
|
-
const ss = await browser.captureScreenshot(client, sessionId, { path: screenshotPath, fullPage: true }).catch(() => null);
|
|
229
|
-
if (ss) { console.log(`[action.gemini_image] Screenshot saved → ${ss.path}`); return ss.path; }
|
|
230
|
-
|
|
231
|
-
return null;
|
|
232
|
-
} finally {
|
|
233
|
-
client.close();
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
export function createBuiltinHandlers(): Map<string, NodeHandler> {
|
|
238
|
-
const handlers = new Map<string, NodeHandler>();
|
|
239
|
-
|
|
240
|
-
// action.http
|
|
241
|
-
// config: { url, method?, headers?, body?, responseField? }
|
|
242
|
-
// Puts { statusCode, body, json? } into item.data[responseField ?? 'response']
|
|
243
|
-
handlers.set('action.http', async (items, config) => {
|
|
244
|
-
const url = String(config['url'] ?? '');
|
|
245
|
-
const method = String(config['method'] ?? 'GET').toUpperCase();
|
|
246
|
-
const headers = (config['headers'] as Record<string, string>) ?? {};
|
|
247
|
-
const body = config['body'] !== undefined ? JSON.stringify(config['body']) : undefined;
|
|
248
|
-
const responseField = String(config['responseField'] ?? 'response');
|
|
249
|
-
|
|
250
|
-
const res = await fetch(url, {
|
|
251
|
-
method,
|
|
252
|
-
headers: { 'Content-Type': 'application/json', ...headers },
|
|
253
|
-
body: method !== 'GET' && method !== 'HEAD' ? body : undefined,
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
const text = await res.text();
|
|
257
|
-
let json: unknown;
|
|
258
|
-
try { json = JSON.parse(text); } catch { /* not JSON */ }
|
|
259
|
-
|
|
260
|
-
return items.map(item => ({
|
|
261
|
-
...item,
|
|
262
|
-
data: {
|
|
263
|
-
...item.data,
|
|
264
|
-
[responseField]: { statusCode: res.status, body: text, json },
|
|
265
|
-
},
|
|
266
|
-
}));
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
// action.save_file
|
|
270
|
-
// config: { path, content?, field?, encoding? }
|
|
271
|
-
// Writes item.data[field] (or binaryBase64 decoded) to disk
|
|
272
|
-
handlers.set('action.save_file', async (items, config) => {
|
|
273
|
-
const results: Item[] = [];
|
|
274
|
-
for (const item of items) {
|
|
275
|
-
const outPath = safeResolvePath(String(config['path'] ?? './output.txt'));
|
|
276
|
-
const field = config['field'] as string | undefined;
|
|
277
|
-
const encoding = String(config['encoding'] ?? 'utf8');
|
|
278
|
-
|
|
279
|
-
await mkdir(dirname(outPath), { recursive: true });
|
|
280
|
-
|
|
281
|
-
if (item.binaryBase64) {
|
|
282
|
-
await writeFile(outPath, Buffer.from(item.binaryBase64, 'base64'));
|
|
283
|
-
} else {
|
|
284
|
-
const content = field
|
|
285
|
-
? JSON.stringify(item.data[field] ?? '', null, 2)
|
|
286
|
-
: (config['content'] as string ?? JSON.stringify(item.data, null, 2));
|
|
287
|
-
await writeFile(outPath, content, encoding as BufferEncoding);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
results.push({ ...item, data: { ...item.data, savedPath: outPath } });
|
|
291
|
-
}
|
|
292
|
-
return results;
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
// action.log
|
|
296
|
-
// config: { label? }
|
|
297
|
-
handlers.set('action.log', async (items, config) => {
|
|
298
|
-
const label = String(config['label'] ?? 'action.log');
|
|
299
|
-
for (const item of items) {
|
|
300
|
-
console.log(`[${label}]`, JSON.stringify(item.data, null, 2));
|
|
301
|
-
}
|
|
302
|
-
return items;
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
// action.gemini_image
|
|
306
|
-
// config: { prompt, cdpPort?, outputPath?, apiKey?, model?, aspectRatio? }
|
|
307
|
-
// Priority: (1) Gemini web browser via CDP port 9222, (2) Imagen REST API, (3) mock
|
|
308
|
-
handlers.set('action.gemini_image', async (items, config) => {
|
|
309
|
-
const cdpPort = Number(config['cdpPort'] ?? process.env['GEMINI_CDP_PORT'] ?? 9222);
|
|
310
|
-
const apiKey = String(config['apiKey'] ?? process.env['GEMINI_API_KEY'] ?? process.env['GOOGLE_API_KEY'] ?? '');
|
|
311
|
-
const model = String(config['model'] ?? 'imagen-3.0-generate-001');
|
|
312
|
-
const aspectRatio = String(config['aspectRatio'] ?? '1:1');
|
|
313
|
-
const outputPath = config['outputPath'] as string | undefined;
|
|
314
|
-
|
|
315
|
-
const results: Item[] = [];
|
|
316
|
-
|
|
317
|
-
for (const item of items) {
|
|
318
|
-
const prompt = String(config['prompt'] ?? item.data['prompt'] ?? '');
|
|
319
|
-
const filePath = safeResolvePath(outputPath ?? `./output/gemini-image-${Date.now()}.png`);
|
|
320
|
-
|
|
321
|
-
// Priority 1: Browser automation via authenticated Chrome session
|
|
322
|
-
const browserPath = await generateViaGeminiBrowser(prompt, filePath, cdpPort);
|
|
323
|
-
if (browserPath) {
|
|
324
|
-
results.push({
|
|
325
|
-
...item,
|
|
326
|
-
data: { ...item.data, prompt, generatedImagePath: browserPath, source: 'gemini-browser' },
|
|
327
|
-
});
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Priority 2: Gemini Imagen REST API
|
|
332
|
-
if (apiKey) {
|
|
333
|
-
console.log(`[action.gemini_image] Browser unavailable — trying Imagen REST API`);
|
|
334
|
-
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:predict?key=${apiKey}`;
|
|
335
|
-
const res = await fetch(url, {
|
|
336
|
-
method: 'POST',
|
|
337
|
-
headers: { 'Content-Type': 'application/json' },
|
|
338
|
-
body: JSON.stringify({
|
|
339
|
-
instances: [{ prompt }],
|
|
340
|
-
parameters: { sampleCount: 1, aspectRatio },
|
|
341
|
-
}),
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
if (!res.ok) throw new Error(`Gemini Imagen API error ${res.status}: ${await res.text()}`);
|
|
345
|
-
|
|
346
|
-
const data = await res.json() as { predictions?: { bytesBase64Encoded?: string; mimeType?: string }[] };
|
|
347
|
-
const prediction = data.predictions?.[0];
|
|
348
|
-
if (!prediction?.bytesBase64Encoded) throw new Error('No image data in Gemini response');
|
|
349
|
-
|
|
350
|
-
await mkdir(dirname(filePath), { recursive: true });
|
|
351
|
-
await writeFile(filePath, Buffer.from(prediction.bytesBase64Encoded, 'base64'));
|
|
352
|
-
|
|
353
|
-
results.push({
|
|
354
|
-
...item,
|
|
355
|
-
data: { ...item.data, prompt, generatedImagePath: filePath, mimeType: prediction.mimeType ?? 'image/png', source: 'gemini-api' },
|
|
356
|
-
binaryBase64: prediction.bytesBase64Encoded,
|
|
357
|
-
});
|
|
358
|
-
continue;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Priority 3: Mock mode — no browser on port 9222 and no API key
|
|
362
|
-
console.log(`[action.gemini_image] No browser on port ${cdpPort} and no API key — mock mode.`);
|
|
363
|
-
console.log(` Prompt: "${prompt}"`);
|
|
364
|
-
results.push({
|
|
365
|
-
...item,
|
|
366
|
-
data: {
|
|
367
|
-
...item.data,
|
|
368
|
-
prompt,
|
|
369
|
-
mockMode: true,
|
|
370
|
-
note: `Set GEMINI_CDP_PORT env var (default: 9222) for browser mode, or GEMINI_API_KEY for REST API`,
|
|
371
|
-
},
|
|
372
|
-
});
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
return results;
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
return handlers;
|
|
379
|
-
}
|
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { resolveExpression, resolveConfig } from './expression.js';
|
|
3
|
-
import { getDashboard } from '../dashboard/server.js';
|
|
4
|
-
import type { WorkflowDef, NodeDef, Item, RunRecord, StepEvent, RunStatus } from './types.js';
|
|
5
|
-
|
|
6
|
-
export type NodeHandler = (items: Item[], config: Record<string, unknown>) => Promise<Item[]>;
|
|
7
|
-
|
|
8
|
-
export interface EngineOptions {
|
|
9
|
-
handlers?: Map<string, NodeHandler>;
|
|
10
|
-
onEvent?: (event: StepEvent) => void;
|
|
11
|
-
signal?: AbortSignal;
|
|
12
|
-
params?: Record<string, string>;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export async function runWorkflow(
|
|
16
|
-
def: WorkflowDef,
|
|
17
|
-
options: EngineOptions = {},
|
|
18
|
-
): Promise<RunRecord> {
|
|
19
|
-
const { handlers = new Map(), onEvent = () => {}, signal, params = {} } = options;
|
|
20
|
-
const runId = randomUUID();
|
|
21
|
-
const startedAt = Date.now();
|
|
22
|
-
|
|
23
|
-
// Build a unified abort controller that merges the caller's signal with dashboard stop-requests.
|
|
24
|
-
const controller = new AbortController();
|
|
25
|
-
if (signal) {
|
|
26
|
-
if (signal.aborted) {
|
|
27
|
-
controller.abort();
|
|
28
|
-
} else {
|
|
29
|
-
signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
const dashboard = getDashboard();
|
|
33
|
-
let stopPollTimer: ReturnType<typeof setInterval> | undefined;
|
|
34
|
-
if (dashboard) {
|
|
35
|
-
stopPollTimer = setInterval(() => {
|
|
36
|
-
if (dashboard.isStopRequested(runId)) controller.abort();
|
|
37
|
-
}, 500);
|
|
38
|
-
// Avoid keeping Node.js event loop alive if nothing else is running
|
|
39
|
-
stopPollTimer.unref?.();
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const emit = (partial: Omit<StepEvent, 'runId' | 'workflowId' | 'workflowName' | 'timestamp'>) =>
|
|
43
|
-
onEvent({ runId, workflowId: def.id, workflowName: def.name, timestamp: Date.now(), ...partial });
|
|
44
|
-
|
|
45
|
-
emit({ nodeId: '', nodeName: '', eventType: 'run_started' });
|
|
46
|
-
|
|
47
|
-
// Build adjacency structures
|
|
48
|
-
const nodeMap = new Map(def.nodes.map(n => [n.id, n]));
|
|
49
|
-
const inDegree = new Map<string, number>(def.nodes.map(n => [n.id, 0]));
|
|
50
|
-
const outEdges = new Map<string, { to: string; handle?: string }[]>();
|
|
51
|
-
const toEdges = new Map<string, { from: string; handle?: string }[]>();
|
|
52
|
-
|
|
53
|
-
for (const conn of def.connections) {
|
|
54
|
-
inDegree.set(conn.to, (inDegree.get(conn.to) ?? 0) + 1);
|
|
55
|
-
const edges = outEdges.get(conn.from) ?? [];
|
|
56
|
-
edges.push({ to: conn.to, handle: conn.handle });
|
|
57
|
-
outEdges.set(conn.from, edges);
|
|
58
|
-
const toList = toEdges.get(conn.to) ?? [];
|
|
59
|
-
toList.push({ from: conn.from, handle: conn.handle });
|
|
60
|
-
toEdges.set(conn.to, toList);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Kahn's topological sort
|
|
64
|
-
const queue: string[] = [];
|
|
65
|
-
const sorted: string[] = [];
|
|
66
|
-
for (const [id, deg] of inDegree) {
|
|
67
|
-
if (deg === 0) queue.push(id);
|
|
68
|
-
}
|
|
69
|
-
while (queue.length > 0) {
|
|
70
|
-
const id = queue.shift()!;
|
|
71
|
-
sorted.push(id);
|
|
72
|
-
for (const edge of outEdges.get(id) ?? []) {
|
|
73
|
-
const newDeg = (inDegree.get(edge.to) ?? 0) - 1;
|
|
74
|
-
inDegree.set(edge.to, newDeg);
|
|
75
|
-
if (newDeg === 0) queue.push(edge.to);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
if (sorted.length !== def.nodes.length) {
|
|
79
|
-
throw new Error('Workflow contains a cycle');
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Node output accumulator
|
|
83
|
-
const nodeOutputs = new Map<string, Item[]>();
|
|
84
|
-
let itemsProcessed = 0;
|
|
85
|
-
let runStatus: RunStatus = 'completed';
|
|
86
|
-
let runError: string | undefined;
|
|
87
|
-
|
|
88
|
-
try {
|
|
89
|
-
for (const nodeId of sorted) {
|
|
90
|
-
if (controller.signal.aborted) {
|
|
91
|
-
runStatus = 'stopped';
|
|
92
|
-
emit({ nodeId, nodeName: nodeId, eventType: 'run_stopped' });
|
|
93
|
-
break;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const node = nodeMap.get(nodeId)!;
|
|
97
|
-
const nodeName = node.name ?? node.id;
|
|
98
|
-
const t0 = Date.now();
|
|
99
|
-
|
|
100
|
-
// Collect inputs from predecessor outputs
|
|
101
|
-
const inputItems = collectInputs(nodeId, nodeOutputs, toEdges);
|
|
102
|
-
|
|
103
|
-
emit({ nodeId, nodeName, eventType: 'step_started', itemTotal: inputItems.length });
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
const outputs = await executeNode(node, inputItems, handlers, nodeOutputs, params, def.allowEnvAccess);
|
|
107
|
-
nodeOutputs.set(nodeId, outputs);
|
|
108
|
-
itemsProcessed += outputs.length;
|
|
109
|
-
emit({ nodeId, nodeName, eventType: 'step_completed', durationMs: Date.now() - t0, itemTotal: outputs.length });
|
|
110
|
-
} catch (err) {
|
|
111
|
-
const error = err instanceof Error ? err.message : String(err);
|
|
112
|
-
emit({ nodeId, nodeName, eventType: 'step_failed', error, durationMs: Date.now() - t0 });
|
|
113
|
-
if (node.onError === 'skip') {
|
|
114
|
-
nodeOutputs.set(nodeId, []);
|
|
115
|
-
} else {
|
|
116
|
-
runStatus = 'failed';
|
|
117
|
-
runError = error;
|
|
118
|
-
break;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
} finally {
|
|
123
|
-
if (stopPollTimer !== undefined) clearInterval(stopPollTimer);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const completedAt = Date.now();
|
|
127
|
-
const record: RunRecord = {
|
|
128
|
-
id: runId,
|
|
129
|
-
workflowId: def.id,
|
|
130
|
-
workflowName: def.name,
|
|
131
|
-
status: runStatus,
|
|
132
|
-
startedAt,
|
|
133
|
-
completedAt,
|
|
134
|
-
itemsProcessed,
|
|
135
|
-
itemsTotal: itemsProcessed,
|
|
136
|
-
error: runError,
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
emit({ nodeId: '', nodeName: '', eventType: runStatus === 'completed' ? 'run_completed' : 'run_stopped', error: runError });
|
|
140
|
-
return record;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function collectInputs(nodeId: string, nodeOutputs: Map<string, Item[]>, toEdges: Map<string, { from: string; handle?: string }[]>): Item[] {
|
|
144
|
-
const predecessors = toEdges.get(nodeId) ?? [];
|
|
145
|
-
if (predecessors.length === 0) return [{ data: {} }];
|
|
146
|
-
return predecessors.flatMap(({ from, handle }) => {
|
|
147
|
-
const items = nodeOutputs.get(from) ?? [];
|
|
148
|
-
if (handle === 'true') return items.filter(item => item.data['__ifResult'] === true);
|
|
149
|
-
if (handle === 'false') return items.filter(item => item.data['__ifResult'] === false);
|
|
150
|
-
return items;
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
async function executeNode(
|
|
155
|
-
node: NodeDef,
|
|
156
|
-
inputs: Item[],
|
|
157
|
-
handlers: Map<string, NodeHandler>,
|
|
158
|
-
nodeOutputs: Map<string, Item[]>,
|
|
159
|
-
params: Record<string, string>,
|
|
160
|
-
allowEnvAccess?: boolean,
|
|
161
|
-
): Promise<Item[]> {
|
|
162
|
-
const allOutputs: Record<string, Item[]> = Object.fromEntries(nodeOutputs);
|
|
163
|
-
const { type, config } = node;
|
|
164
|
-
|
|
165
|
-
if (type === 'trigger.manual') {
|
|
166
|
-
const items = config['items'];
|
|
167
|
-
if (Array.isArray(items)) return items as Item[];
|
|
168
|
-
return inputs;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
if (type === 'core.set') {
|
|
172
|
-
return inputs.map(item => {
|
|
173
|
-
const resolved = resolveConfig(config, item, allOutputs, params, allowEnvAccess);
|
|
174
|
-
return { ...item, data: { ...item.data, ...resolved } };
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
if (type === 'core.filter') {
|
|
179
|
-
const predicate = config['expression'] as string;
|
|
180
|
-
return inputs.filter(item => {
|
|
181
|
-
try {
|
|
182
|
-
return Boolean(resolveExpression(predicate, item, allOutputs, params, allowEnvAccess));
|
|
183
|
-
} catch {
|
|
184
|
-
return false;
|
|
185
|
-
}
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
if (type === 'core.if') {
|
|
190
|
-
const predicate = config['expression'] as string;
|
|
191
|
-
return inputs.map(item => {
|
|
192
|
-
const result = Boolean(resolveExpression(predicate, item, allOutputs, params, allowEnvAccess));
|
|
193
|
-
return { ...item, data: { ...item.data, __ifResult: result } };
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// action.* — delegate to registered handler
|
|
198
|
-
const handler = handlers.get(type);
|
|
199
|
-
if (!handler) throw new Error(`No handler registered for node type: ${type}`);
|
|
200
|
-
const resolvedConfig = resolveConfig(config, inputs[0] ?? { data: {} }, allOutputs, params, allowEnvAccess);
|
|
201
|
-
return handler(inputs, resolvedConfig);
|
|
202
|
-
}
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import type { Item } from './types.js';
|
|
2
|
-
|
|
3
|
-
const TEMPLATE_PATTERN = '\\{\\{([^}]+)\\}\\}';
|
|
4
|
-
|
|
5
|
-
/** Env var names matching this pattern are blocked from $env expressions to prevent secret exfiltration. */
|
|
6
|
-
const ENV_DENYLIST = /(_KEY|_TOKEN|_SECRET|_PASSWORD|_JWT|_API_KEY|_PRIVATE_KEY|_PASS|_PWD|_CERT|_PEM|_CREDENTIAL|_CREDENTIALS|_AUTH)$/i;
|
|
7
|
-
type ParsedTemplate = RegExpMatchArray[];
|
|
8
|
-
const cache = new Map<string, ParsedTemplate>();
|
|
9
|
-
|
|
10
|
-
function extractTemplates(template: string): ParsedTemplate {
|
|
11
|
-
if (cache.has(template)) return cache.get(template)!;
|
|
12
|
-
// LRU eviction: drop the oldest entry rather than clearing all at once (thundering-herd).
|
|
13
|
-
if (cache.size >= 500) {
|
|
14
|
-
const oldest = cache.keys().next().value;
|
|
15
|
-
if (oldest !== undefined) cache.delete(oldest);
|
|
16
|
-
}
|
|
17
|
-
const matches = [...template.matchAll(new RegExp(TEMPLATE_PATTERN, 'g'))];
|
|
18
|
-
cache.set(template, matches);
|
|
19
|
-
return matches;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function resolveExpression(
|
|
23
|
-
template: string,
|
|
24
|
-
item: Item,
|
|
25
|
-
nodeOutputs: Record<string, Item[]>,
|
|
26
|
-
params: Record<string, string>,
|
|
27
|
-
allowEnvAccess?: boolean,
|
|
28
|
-
): string {
|
|
29
|
-
const matches = extractTemplates(template);
|
|
30
|
-
if (matches.length === 0) return template;
|
|
31
|
-
|
|
32
|
-
let result = template;
|
|
33
|
-
for (const match of matches) {
|
|
34
|
-
const expr = match[1].trim();
|
|
35
|
-
const value = resolveToken(expr, item, nodeOutputs, params, allowEnvAccess);
|
|
36
|
-
result = result.split(match[0]).join(String(value));
|
|
37
|
-
}
|
|
38
|
-
return result;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function resolveToken(
|
|
42
|
-
expr: string,
|
|
43
|
-
item: Item,
|
|
44
|
-
nodeOutputs: Record<string, Item[]>,
|
|
45
|
-
params: Record<string, string>,
|
|
46
|
-
allowEnvAccess?: boolean,
|
|
47
|
-
): unknown {
|
|
48
|
-
if (expr.startsWith('$json.')) {
|
|
49
|
-
const key = expr.slice(6);
|
|
50
|
-
if (!(key in item.data)) throw new Error(`Unresolved: $json.${key} not found in item data`);
|
|
51
|
-
return item.data[key];
|
|
52
|
-
}
|
|
53
|
-
if (expr.startsWith('$env.')) {
|
|
54
|
-
const key = expr.slice(5);
|
|
55
|
-
if (!allowEnvAccess && ENV_DENYLIST.test(key)) {
|
|
56
|
-
throw new Error(
|
|
57
|
-
`Blocked: env var "${key}" matches secret pattern. Set allowEnvAccess: true in workflow config to override.`,
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
const val = process.env[key];
|
|
61
|
-
if (val === undefined) throw new Error(`Unresolved: $env.${key} not set`);
|
|
62
|
-
return val;
|
|
63
|
-
}
|
|
64
|
-
if (expr.startsWith('params.')) {
|
|
65
|
-
const key = expr.slice(7);
|
|
66
|
-
if (!(key in params)) throw new Error(`Unresolved: params.${key} not provided`);
|
|
67
|
-
return params[key];
|
|
68
|
-
}
|
|
69
|
-
if (expr.startsWith('$node.') || expr.startsWith('$node["')) {
|
|
70
|
-
let nodeId: string;
|
|
71
|
-
let field: string;
|
|
72
|
-
if (expr.startsWith('$node["')) {
|
|
73
|
-
// $node["NodeId"].field
|
|
74
|
-
const bracketEnd = expr.indexOf('"].');
|
|
75
|
-
if (bracketEnd === -1) throw new Error(`Unresolved: malformed $node bracket expression: ${expr}`);
|
|
76
|
-
nodeId = expr.slice(7, bracketEnd); // slice off '$node["'
|
|
77
|
-
field = expr.slice(bracketEnd + 3); // slice off '"].'
|
|
78
|
-
} else {
|
|
79
|
-
// $node.NodeId.field
|
|
80
|
-
const parts = expr.slice(6).split('.');
|
|
81
|
-
nodeId = parts[0];
|
|
82
|
-
field = parts.slice(1).join('.');
|
|
83
|
-
}
|
|
84
|
-
const items = nodeOutputs[nodeId];
|
|
85
|
-
if (!items || items.length === 0) throw new Error(`Unresolved: $node.${nodeId} has no output`);
|
|
86
|
-
const val = items[0].data[field];
|
|
87
|
-
if (val === undefined) throw new Error(`Unresolved: $node.${nodeId}.${field} not found`);
|
|
88
|
-
return val;
|
|
89
|
-
}
|
|
90
|
-
// Named ref (e.g. {{box}} from a find step) — action executor resolves these using its element handle map
|
|
91
|
-
return `{{${expr}}}`;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Note: only resolves top-level string values. Nested objects/arrays are passed through unchanged.
|
|
95
|
-
export function resolveConfig(
|
|
96
|
-
config: Record<string, unknown>,
|
|
97
|
-
item: Item,
|
|
98
|
-
nodeOutputs: Record<string, Item[]>,
|
|
99
|
-
params: Record<string, string>,
|
|
100
|
-
allowEnvAccess?: boolean,
|
|
101
|
-
): Record<string, unknown> {
|
|
102
|
-
const result: Record<string, unknown> = {};
|
|
103
|
-
for (const [k, v] of Object.entries(config)) {
|
|
104
|
-
result[k] = typeof v === 'string' ? resolveExpression(v, item, nodeOutputs, params, allowEnvAccess) : v;
|
|
105
|
-
}
|
|
106
|
-
return result;
|
|
107
|
-
}
|