From 6279cc93a914ffe377755bef81a71bd69c9c53dd Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Tue, 6 Oct 2026 18:44:53 +0300 Subject: [PATCH 1/3] version up --- package-lock.json | 4 ++-- package.json | 2 +- src/chrome/ARCHITECTURE.md | 2 +- src/chrome/manifest.json | 2 +- src/chrome/src/ui/settings.js | 2 +- src/firefox/ARCHITECTURE.md | 2 +- src/firefox/manifest.json | 2 +- src/firefox/src/ui/settings.js | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0ae1a11d87..872babec00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "webbrain", - "version": "39.1.1", + "version": "39.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "webbrain", - "version": "39.1.1", + "version": "39.1.2", "license": "GPL-3.0-or-later", "devDependencies": { "playwright": "^1.48.0", diff --git a/package.json b/package.json index e4429a2c14..1d330edb35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webbrain", - "version": "39.1.1", + "version": "39.1.2", "description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.", "private": true, "type": "module", diff --git a/src/chrome/ARCHITECTURE.md b/src/chrome/ARCHITECTURE.md index 9efb0d7180..1e244bbde5 100644 --- a/src/chrome/ARCHITECTURE.md +++ b/src/chrome/ARCHITECTURE.md @@ -1,6 +1,6 @@ # WebBrain Chrome/Edge Extension — Architecture -> Version 39.1.1 · Manifest V3 · Service Worker background +> Version 39.1.2 · Manifest V3 · Service Worker background ## High-Level Overview diff --git a/src/chrome/manifest.json b/src/chrome/manifest.json index 73f67c02d5..9fab79971e 100644 --- a/src/chrome/manifest.json +++ b/src/chrome/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "WebBrain", - "version": "39.1.1", + "version": "39.1.2", "description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.", "permissions": [ "sidePanel", diff --git a/src/chrome/src/ui/settings.js b/src/chrome/src/ui/settings.js index 2b5f4dcfae..d7a817aa20 100644 --- a/src/chrome/src/ui/settings.js +++ b/src/chrome/src/ui/settings.js @@ -89,7 +89,7 @@ const SUBSCRIPTION_GUIDE_PRODUCTS = Object.freeze({ // Version shown in the subtitle. Kept here so it only needs one update per // release; the subtitle string itself is translated. -const EXT_VERSION = '39.1.1'; +const EXT_VERSION = '39.1.2'; const providersContainer = document.getElementById('providers'); const displaySettings = document.getElementById('display-settings'); diff --git a/src/firefox/ARCHITECTURE.md b/src/firefox/ARCHITECTURE.md index c05dfeb7f2..8d04c6ff06 100644 --- a/src/firefox/ARCHITECTURE.md +++ b/src/firefox/ARCHITECTURE.md @@ -1,6 +1,6 @@ # WebBrain Firefox Extension — Architecture -> Version 39.1.1 · Manifest V2 · Background Page +> Version 39.1.2 · Manifest V2 · Background Page ## How Firefox Differs from Chrome diff --git a/src/firefox/manifest.json b/src/firefox/manifest.json index 1220588a90..e5b73a42b5 100644 --- a/src/firefox/manifest.json +++ b/src/firefox/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "WebBrain", - "version": "39.1.1", + "version": "39.1.2", "description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.", "permissions": [ "activeTab", diff --git a/src/firefox/src/ui/settings.js b/src/firefox/src/ui/settings.js index f6d5eda92c..45cdf11ef7 100644 --- a/src/firefox/src/ui/settings.js +++ b/src/firefox/src/ui/settings.js @@ -81,7 +81,7 @@ const SUBSCRIPTION_GUIDE_PRODUCTS = Object.freeze({ // Version shown in the subtitle. Kept here so it only needs one update per // release; the subtitle string itself is translated. -const EXT_VERSION = '39.1.1'; +const EXT_VERSION = '39.1.2'; const providersContainer = document.getElementById('providers'); const displaySettings = document.getElementById('display-settings'); From a2cf66a532c7a26dee6b70b99c4ecb448c8d6d3e Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Tue, 6 Oct 2026 19:11:37 +0300 Subject: [PATCH 2/3] Fix media service-worker imports and support OpenRouter videos --- package.json | 3 +- src/chrome/src/agent/fal-media.js | 12 +-- src/chrome/src/agent/generative-media.js | 47 ++++++++-- .../src/ui/locales/generative-media-copy.mjs | 4 +- src/firefox/src/agent/fal-media.js | 12 +-- src/firefox/src/agent/generative-media.js | 47 ++++++++-- .../src/ui/locales/generative-media-copy.mjs | 4 +- test/generative-media-extension.mjs | 61 +++++++++++++ test/generative-media-ui.mjs | 3 +- test/generative-media.mjs | 91 ++++++++++++++++++- 10 files changed, 233 insertions(+), 51 deletions(-) create mode 100644 test/generative-media-extension.mjs diff --git a/package.json b/package.json index 1d330edb35..fd8891003b 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,8 @@ "test:compass-quota": "node --test test/compass-quota.mjs", "test:compass-quota:ui": "node test/compass-quota-ui.mjs", "test:generative-media": "node --test test/generative-media.mjs", - "test:generative-media:ui": "node test/generative-media-ui.mjs" + "test:generative-media:ui": "node test/generative-media-ui.mjs", + "test:generative-media:extension": "node test/generative-media-extension.mjs" }, "devDependencies": { "playwright": "^1.48.0", diff --git a/src/chrome/src/agent/fal-media.js b/src/chrome/src/agent/fal-media.js index d90dc54427..e30efcfa4f 100644 --- a/src/chrome/src/agent/fal-media.js +++ b/src/chrome/src/agent/fal-media.js @@ -216,13 +216,5 @@ export async function runFalGeneration({ } } -// Legacy entry points retained for callers importing fal-media.js. -export async function generateImage(args, options = {}) { - const media = await import('./generative-media.js'); - return media.generateImage(args, options); -} - -export async function testImageGenProvider(fetchImpl = fetch) { - const media = await import('./generative-media.js'); - return media.testImageGenProvider(fetchImpl); -} +// Legacy entry points must use static exports: MV3 service workers forbid import(). +export { generateImage, testImageGenProvider } from './generative-media.js'; diff --git a/src/chrome/src/agent/generative-media.js b/src/chrome/src/agent/generative-media.js index f87a3104a8..ef0d80ec60 100644 --- a/src/chrome/src/agent/generative-media.js +++ b/src/chrome/src/agent/generative-media.js @@ -100,12 +100,21 @@ async function mediaResponse(response, label) { return url; } -export async function runMediaGeneration({ prompt, config, fetchImpl = fetch, signal, timeoutMs = GENERATION_TIMEOUT_MS, pollIntervalMs = 2000 }) { +async function openRouterMediaType(model, headers, request) { + for (const kind of ['images', 'videos']) { + const catalog = await jsonResponse(await request(`https://openrouter.ai/api/v1/${kind}/models`, { headers }), `OpenRouter ${kind} models`); + if (catalog.data?.some(item => item.id === model)) return kind; + } + throw new Error('The selected OpenRouter model is not in the image or video generation catalog.'); +} + +export async function runMediaGeneration({ prompt, config, fetchImpl = fetch, signal, timeoutMs = GENERATION_TIMEOUT_MS, pollIntervalMs }) { const provider = validateMediaConfig(config); const text = String(prompt || '').trim(); if (!text) throw new Error('prompt is required.'); if (provider === 'fal') return runFalGeneration({ prompt: text, config, fetchImpl, signal, timeoutMs }); const operation = operationSignal(signal, timeoutMs); + const pollMs = pollIntervalMs ?? (provider === 'openrouter' ? 30000 : 2000); const model = provider === 'comfyui' ? 'ComfyUI workflow' : normalizeMediaModel(config.model); const request = (url, init = {}) => { operation.signal.throwIfAborted(); @@ -115,11 +124,30 @@ export async function runMediaGeneration({ prompt, config, fetchImpl = fetch, si let completed = false; try { if (provider === 'openrouter') { - const res = await request('https://openrouter.ai/api/v1/images', { - method: 'POST', headers: { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' }, + const headers = { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' }; + const kind = await openRouterMediaType(model, headers, request); + const base = `https://openrouter.ai/api/v1/${kind}`; + const res = await request(base, { + method: 'POST', headers, body: JSON.stringify({ model, prompt: text }), }); - return { url: await mediaResponse(res, 'OpenRouter generation'), model }; + if (kind === 'images') return { url: await mediaResponse(res, 'OpenRouter generation'), model }; + const queued = await jsonResponse(res, 'OpenRouter video submit'); + if (!/^[A-Za-z0-9_-]+$/.test(queued.id || '')) throw new Error('OpenRouter returned an invalid video job ID.'); + // Construct authenticated paths ourselves; returned URLs cannot select a key recipient. + const jobUrl = `${base}/${queued.id}`; + let waitMs = pollDelay(res, pollMs); + while (true) { + await delay(waitMs, operation.signal); + const statusRes = await request(jobUrl, { headers }); + const status = await jsonResponse(statusRes, 'OpenRouter video status'); + waitMs = pollDelay(statusRes, pollMs); + if (status.status === 'completed') { + const result = await request(`${jobUrl}/content?index=0`, { headers }); + return { url: await mediaResponse(result, 'OpenRouter video download'), model }; + } + if (!['pending', 'in_progress'].includes(status.status)) throw new Error(`OpenRouter video generation ${status.status || 'returned an unknown status'}.`); + } } if (provider === 'comfyrouter') { const base = `https://api.comfy.org/v2/models/${model}/requests`; @@ -134,15 +162,15 @@ export async function runMediaGeneration({ prompt, config, fetchImpl = fetch, si // Build authenticated URLs ourselves; never send the key to returned URLs. const resultUrl = `${base}/${queued.request_id}`; cancelUrl = `${resultUrl}/cancel`; cancelInit = { method: 'PUT', headers }; - let waitMs = pollIntervalMs; + let waitMs = pollMs; while (true) { await delay(waitMs, operation.signal); const res = await request(`${resultUrl}/status`, { headers }); const status = await jsonResponse(res, 'Comfy Router status'); - waitMs = pollDelay(res, pollIntervalMs); + waitMs = pollDelay(res, pollMs); if (status.status === 'COMPLETED') { const result = await request(resultUrl, { headers }); - if (result.status === 202) { waitMs = pollDelay(result, pollIntervalMs); continue; } + if (result.status === 202) { waitMs = pollDelay(result, pollMs); continue; } const url = await mediaResponse(result, 'Comfy Router generation'); completed = true; return { url, model }; @@ -160,7 +188,7 @@ export async function runMediaGeneration({ prompt, config, fetchImpl = fetch, si // Delete this queued prompt only. /interrupt could stop someone else's running workflow. cancelUrl = `${base}/queue`; cancelInit = { method: 'POST', headers, body: JSON.stringify({ delete: [queued.prompt_id] }) }; while (true) { - await delay(pollIntervalMs, operation.signal); + await delay(pollMs, operation.signal); const history = await jsonResponse(await request(`${base}/history/${queued.prompt_id}`), 'ComfyUI history'); const item = history[queued.prompt_id]; if (!item) continue; @@ -217,8 +245,7 @@ export async function testImageGenProvider(fetchImpl = fetch) { if (!res.ok) throw new Error(`${MEDIA_PROVIDERS[provider].label} connection failed (HTTP ${res.status}).`); if (provider === 'openrouter') { await jsonResponse(res, 'OpenRouter key'); - const catalog = await jsonResponse(await fetchImpl('https://openrouter.ai/api/v1/images/models', { headers, signal: operation.signal, redirect: 'error', credentials: 'omit' }), 'OpenRouter image models'); - if (!catalog.data?.some(item => item.id === model)) throw new Error('The selected OpenRouter model is not in the image generation catalog.'); + await openRouterMediaType(model, headers, (url, init) => fetchImpl(url, { ...init, signal: operation.signal, redirect: 'error', credentials: 'omit' })); } else if (provider === 'comfyui') { const stats = await jsonResponse(res, 'ComfyUI'); if (!stats.system || !Array.isArray(stats.devices)) throw new Error('The endpoint did not return ComfyUI system information.'); diff --git a/src/chrome/src/ui/locales/generative-media-copy.mjs b/src/chrome/src/ui/locales/generative-media-copy.mjs index 19c67f9070..fdb81099bf 100644 --- a/src/chrome/src/ui/locales/generative-media-copy.mjs +++ b/src/chrome/src/ui/locales/generative-media-copy.mjs @@ -5,7 +5,7 @@ const english = { 'st.imagegen.parameters': 'Model input JSON (optional)', 'st.imagegen.setup': 'Setup guide ↗', 'st.imagegen.hint.fal': 'Enter your fal.ai key and model ID. Test Connection checks authentication without generating media.', - 'st.imagegen.hint.openrouter': 'Enter an OpenRouter key and an image generation model ID. Test Connection checks the key and image catalog without generating an image.', + 'st.imagegen.hint.openrouter': 'Enter an OpenRouter key and an image or video generation model ID. Test Connection checks the key and model catalog without generating media.', 'st.imagegen.hint.comfyrouter': 'Enter a Comfy API key and provider/model ID. For models with other prompt fields, paste their input JSON using {{prompt}} where the text belongs. Test Connection reads model metadata.', 'st.imagegen.hint.comfyui': 'Start ComfyUI locally, export the workflow in API format, and replace its positive prompt text with {{prompt}}. Include a Save Image or media output node. No API key is needed. Test Connection checks the local server; the workflow runs only when you request generation.', 'st.imagegen.output': 'Generated media', @@ -19,7 +19,7 @@ const turkish = { 'st.imagegen.parameters': 'Model girdisi JSON (isteğe bağlı)', 'st.imagegen.setup': 'Kurulum rehberi ↗', 'st.imagegen.hint.fal': 'fal.ai anahtarınızı ve model kimliğini girin. Bağlantı testi medya üretmeden kimlik doğrulamayı kontrol eder.', - 'st.imagegen.hint.openrouter': 'OpenRouter anahtarını ve görsel üreten bir model kimliğini girin. Bağlantı testi görsel üretmeden anahtarı ve görsel kataloğunu kontrol eder.', + 'st.imagegen.hint.openrouter': 'OpenRouter anahtarını ve görsel veya video üreten bir model kimliğini girin. Bağlantı testi medya üretmeden anahtarı ve model kataloğunu kontrol eder.', 'st.imagegen.hint.comfyrouter': 'Comfy API anahtarını ve sağlayıcı/model kimliğini girin. Farklı istem alanları kullanan modeller için girdi JSON dosyasını yapıştırıp metnin yerine {{prompt}} yazın. Bağlantı testi model bilgilerini okur.', 'st.imagegen.hint.comfyui': 'ComfyUI yerel sunucusunu başlatın, iş akışını API biçiminde dışa aktarın ve olumlu istem metnini {{prompt}} ile değiştirin. Save Image veya medya çıktı düğümü ekleyin. API anahtarı gerekmez. Bağlantı testi yerel sunucuyu kontrol eder; iş akışı yalnızca medya üretimi istediğinizde çalışır.', 'st.imagegen.output': 'Üretilen medya', diff --git a/src/firefox/src/agent/fal-media.js b/src/firefox/src/agent/fal-media.js index d90dc54427..e30efcfa4f 100644 --- a/src/firefox/src/agent/fal-media.js +++ b/src/firefox/src/agent/fal-media.js @@ -216,13 +216,5 @@ export async function runFalGeneration({ } } -// Legacy entry points retained for callers importing fal-media.js. -export async function generateImage(args, options = {}) { - const media = await import('./generative-media.js'); - return media.generateImage(args, options); -} - -export async function testImageGenProvider(fetchImpl = fetch) { - const media = await import('./generative-media.js'); - return media.testImageGenProvider(fetchImpl); -} +// Legacy entry points must use static exports: MV3 service workers forbid import(). +export { generateImage, testImageGenProvider } from './generative-media.js'; diff --git a/src/firefox/src/agent/generative-media.js b/src/firefox/src/agent/generative-media.js index f87a3104a8..ef0d80ec60 100644 --- a/src/firefox/src/agent/generative-media.js +++ b/src/firefox/src/agent/generative-media.js @@ -100,12 +100,21 @@ async function mediaResponse(response, label) { return url; } -export async function runMediaGeneration({ prompt, config, fetchImpl = fetch, signal, timeoutMs = GENERATION_TIMEOUT_MS, pollIntervalMs = 2000 }) { +async function openRouterMediaType(model, headers, request) { + for (const kind of ['images', 'videos']) { + const catalog = await jsonResponse(await request(`https://openrouter.ai/api/v1/${kind}/models`, { headers }), `OpenRouter ${kind} models`); + if (catalog.data?.some(item => item.id === model)) return kind; + } + throw new Error('The selected OpenRouter model is not in the image or video generation catalog.'); +} + +export async function runMediaGeneration({ prompt, config, fetchImpl = fetch, signal, timeoutMs = GENERATION_TIMEOUT_MS, pollIntervalMs }) { const provider = validateMediaConfig(config); const text = String(prompt || '').trim(); if (!text) throw new Error('prompt is required.'); if (provider === 'fal') return runFalGeneration({ prompt: text, config, fetchImpl, signal, timeoutMs }); const operation = operationSignal(signal, timeoutMs); + const pollMs = pollIntervalMs ?? (provider === 'openrouter' ? 30000 : 2000); const model = provider === 'comfyui' ? 'ComfyUI workflow' : normalizeMediaModel(config.model); const request = (url, init = {}) => { operation.signal.throwIfAborted(); @@ -115,11 +124,30 @@ export async function runMediaGeneration({ prompt, config, fetchImpl = fetch, si let completed = false; try { if (provider === 'openrouter') { - const res = await request('https://openrouter.ai/api/v1/images', { - method: 'POST', headers: { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' }, + const headers = { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' }; + const kind = await openRouterMediaType(model, headers, request); + const base = `https://openrouter.ai/api/v1/${kind}`; + const res = await request(base, { + method: 'POST', headers, body: JSON.stringify({ model, prompt: text }), }); - return { url: await mediaResponse(res, 'OpenRouter generation'), model }; + if (kind === 'images') return { url: await mediaResponse(res, 'OpenRouter generation'), model }; + const queued = await jsonResponse(res, 'OpenRouter video submit'); + if (!/^[A-Za-z0-9_-]+$/.test(queued.id || '')) throw new Error('OpenRouter returned an invalid video job ID.'); + // Construct authenticated paths ourselves; returned URLs cannot select a key recipient. + const jobUrl = `${base}/${queued.id}`; + let waitMs = pollDelay(res, pollMs); + while (true) { + await delay(waitMs, operation.signal); + const statusRes = await request(jobUrl, { headers }); + const status = await jsonResponse(statusRes, 'OpenRouter video status'); + waitMs = pollDelay(statusRes, pollMs); + if (status.status === 'completed') { + const result = await request(`${jobUrl}/content?index=0`, { headers }); + return { url: await mediaResponse(result, 'OpenRouter video download'), model }; + } + if (!['pending', 'in_progress'].includes(status.status)) throw new Error(`OpenRouter video generation ${status.status || 'returned an unknown status'}.`); + } } if (provider === 'comfyrouter') { const base = `https://api.comfy.org/v2/models/${model}/requests`; @@ -134,15 +162,15 @@ export async function runMediaGeneration({ prompt, config, fetchImpl = fetch, si // Build authenticated URLs ourselves; never send the key to returned URLs. const resultUrl = `${base}/${queued.request_id}`; cancelUrl = `${resultUrl}/cancel`; cancelInit = { method: 'PUT', headers }; - let waitMs = pollIntervalMs; + let waitMs = pollMs; while (true) { await delay(waitMs, operation.signal); const res = await request(`${resultUrl}/status`, { headers }); const status = await jsonResponse(res, 'Comfy Router status'); - waitMs = pollDelay(res, pollIntervalMs); + waitMs = pollDelay(res, pollMs); if (status.status === 'COMPLETED') { const result = await request(resultUrl, { headers }); - if (result.status === 202) { waitMs = pollDelay(result, pollIntervalMs); continue; } + if (result.status === 202) { waitMs = pollDelay(result, pollMs); continue; } const url = await mediaResponse(result, 'Comfy Router generation'); completed = true; return { url, model }; @@ -160,7 +188,7 @@ export async function runMediaGeneration({ prompt, config, fetchImpl = fetch, si // Delete this queued prompt only. /interrupt could stop someone else's running workflow. cancelUrl = `${base}/queue`; cancelInit = { method: 'POST', headers, body: JSON.stringify({ delete: [queued.prompt_id] }) }; while (true) { - await delay(pollIntervalMs, operation.signal); + await delay(pollMs, operation.signal); const history = await jsonResponse(await request(`${base}/history/${queued.prompt_id}`), 'ComfyUI history'); const item = history[queued.prompt_id]; if (!item) continue; @@ -217,8 +245,7 @@ export async function testImageGenProvider(fetchImpl = fetch) { if (!res.ok) throw new Error(`${MEDIA_PROVIDERS[provider].label} connection failed (HTTP ${res.status}).`); if (provider === 'openrouter') { await jsonResponse(res, 'OpenRouter key'); - const catalog = await jsonResponse(await fetchImpl('https://openrouter.ai/api/v1/images/models', { headers, signal: operation.signal, redirect: 'error', credentials: 'omit' }), 'OpenRouter image models'); - if (!catalog.data?.some(item => item.id === model)) throw new Error('The selected OpenRouter model is not in the image generation catalog.'); + await openRouterMediaType(model, headers, (url, init) => fetchImpl(url, { ...init, signal: operation.signal, redirect: 'error', credentials: 'omit' })); } else if (provider === 'comfyui') { const stats = await jsonResponse(res, 'ComfyUI'); if (!stats.system || !Array.isArray(stats.devices)) throw new Error('The endpoint did not return ComfyUI system information.'); diff --git a/src/firefox/src/ui/locales/generative-media-copy.mjs b/src/firefox/src/ui/locales/generative-media-copy.mjs index 19c67f9070..fdb81099bf 100644 --- a/src/firefox/src/ui/locales/generative-media-copy.mjs +++ b/src/firefox/src/ui/locales/generative-media-copy.mjs @@ -5,7 +5,7 @@ const english = { 'st.imagegen.parameters': 'Model input JSON (optional)', 'st.imagegen.setup': 'Setup guide ↗', 'st.imagegen.hint.fal': 'Enter your fal.ai key and model ID. Test Connection checks authentication without generating media.', - 'st.imagegen.hint.openrouter': 'Enter an OpenRouter key and an image generation model ID. Test Connection checks the key and image catalog without generating an image.', + 'st.imagegen.hint.openrouter': 'Enter an OpenRouter key and an image or video generation model ID. Test Connection checks the key and model catalog without generating media.', 'st.imagegen.hint.comfyrouter': 'Enter a Comfy API key and provider/model ID. For models with other prompt fields, paste their input JSON using {{prompt}} where the text belongs. Test Connection reads model metadata.', 'st.imagegen.hint.comfyui': 'Start ComfyUI locally, export the workflow in API format, and replace its positive prompt text with {{prompt}}. Include a Save Image or media output node. No API key is needed. Test Connection checks the local server; the workflow runs only when you request generation.', 'st.imagegen.output': 'Generated media', @@ -19,7 +19,7 @@ const turkish = { 'st.imagegen.parameters': 'Model girdisi JSON (isteğe bağlı)', 'st.imagegen.setup': 'Kurulum rehberi ↗', 'st.imagegen.hint.fal': 'fal.ai anahtarınızı ve model kimliğini girin. Bağlantı testi medya üretmeden kimlik doğrulamayı kontrol eder.', - 'st.imagegen.hint.openrouter': 'OpenRouter anahtarını ve görsel üreten bir model kimliğini girin. Bağlantı testi görsel üretmeden anahtarı ve görsel kataloğunu kontrol eder.', + 'st.imagegen.hint.openrouter': 'OpenRouter anahtarını ve görsel veya video üreten bir model kimliğini girin. Bağlantı testi medya üretmeden anahtarı ve model kataloğunu kontrol eder.', 'st.imagegen.hint.comfyrouter': 'Comfy API anahtarını ve sağlayıcı/model kimliğini girin. Farklı istem alanları kullanan modeller için girdi JSON dosyasını yapıştırıp metnin yerine {{prompt}} yazın. Bağlantı testi model bilgilerini okur.', 'st.imagegen.hint.comfyui': 'ComfyUI yerel sunucusunu başlatın, iş akışını API biçiminde dışa aktarın ve olumlu istem metnini {{prompt}} ile değiştirin. Save Image veya medya çıktı düğümü ekleyin. API anahtarı gerekmez. Bağlantı testi yerel sunucuyu kontrol eder; iş akışı yalnızca medya üretimi istediğinizde çalışır.', 'st.imagegen.output': 'Üretilen medya', diff --git a/test/generative-media-extension.mjs b/test/generative-media-extension.mjs new file mode 100644 index 0000000000..196a7c39dd --- /dev/null +++ b/test/generative-media-extension.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { chromium } from 'playwright'; + +// Exercise Settings -> the installed MV3 background, where dynamic import fails. +// Synthetic fetch responses keep the probe free and independent of credentials. +const profile = await mkdtemp(join(tmpdir(), 'wb-media-profile-')); +let context; +try { + const extension = resolve('src/chrome'); + context = await chromium.launchPersistentContext(profile, { + headless: true, channel: 'chromium', + args: [`--disable-extensions-except=${extension}`, `--load-extension=${extension}`], + }); + const worker = context.serviceWorkers()[0] || await context.waitForEvent('serviceworker'); + await worker.evaluate(() => { + globalThis.mediaProbeCalls = []; + globalThis.fetch = async (url, init) => { + if (init.method && init.method !== 'GET') throw new Error('Connection test must not generate media.'); + if (init.headers?.Authorization !== 'Bearer synthetic-or-key') throw new Error('Missing authentication.'); + mediaProbeCalls.push(url); + const body = url.endsWith('/key') ? { data: {} } + : url.endsWith('/images/models') ? { data: [{ id: 'image-model' }] } + : url.endsWith('/videos/models') ? { data: [{ id: 'minimax/hailuo-3-max' }] } : null; + if (!body) throw new Error(`Unexpected probe URL: ${url}`); + return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' } }); + }; + }); + const page = await context.newPage(); + const extensionId = new URL(worker.url()).host; + await page.goto(`chrome-extension://${extensionId}/src/ui/settings.html#multimodal`); + await page.locator('#image-gen-provider').selectOption('openrouter'); + await page.locator('#image-gen-api-key').fill('synthetic-or-key'); + for (const model of ['minimax/hailuo-3-max', 'image-model', 'text-only']) { + await page.locator('#image-gen-model').fill(model); + await page.locator('#btn-save-image-gen').click(); + await page.waitForFunction(model => document.querySelector('#test-image-gen').classList.contains('ok') + && document.querySelector('#image-gen-model').value === model, model); + await page.locator('#btn-test-image-gen').click(); + await page.waitForFunction(() => { + const result = document.querySelector('#test-image-gen'); + return result.classList.contains('ok') || result.classList.contains('fail'); + }); + const status = await page.locator('#test-image-gen').textContent(); + const ok = await page.locator('#test-image-gen').evaluate(el => el.classList.contains('ok')); + assert.equal(ok, model !== 'text-only', status); + assert.ok(!status.includes('import()'), status); + console.log(`${model}: ${status}`); + } + const calls = await worker.evaluate(() => mediaProbeCalls); + assert.deepEqual(calls, [ + 'https://openrouter.ai/api/v1/key', 'https://openrouter.ai/api/v1/images/models', 'https://openrouter.ai/api/v1/videos/models', + 'https://openrouter.ai/api/v1/key', 'https://openrouter.ai/api/v1/images/models', + 'https://openrouter.ai/api/v1/key', 'https://openrouter.ai/api/v1/images/models', 'https://openrouter.ai/api/v1/videos/models', + ]); +} finally { + await context?.close(); + await rm(profile, { recursive: true, force: true }); +} diff --git a/test/generative-media-ui.mjs b/test/generative-media-ui.mjs index 28daa9dd13..35221c7d2e 100644 --- a/test/generative-media-ui.mjs +++ b/test/generative-media-ui.mjs @@ -124,7 +124,8 @@ try { const dataUrl = canvas.toDataURL('image/png'); const result = await generateImage({ prompt: 'test image' }, { config: { provider: 'openrouter', apiKey: 'synthetic-key', model: 'image-model' }, - fetchImpl: async () => new Response(JSON.stringify({ data: [{ b64_json: dataUrl.split(',')[1], media_type: 'image/png' }] }), { headers: { 'Content-Type': 'application/json' } }), + fetchImpl: async url => new Response(JSON.stringify({ data: url.endsWith('/models') + ? [{ id: 'image-model' }] : [{ b64_json: dataUrl.split(',')[1], media_type: 'image/png' }] }), { headers: { 'Content-Type': 'application/json' } }), }); if (!result.success) throw new Error(result.error); const div = document.createElement('div'); div.id = 'media-test-preview'; document.querySelector('#image-gen-card').appendChild(div); diff --git a/test/generative-media.mjs b/test/generative-media.mjs index 4437d74d6e..eebc14cb63 100644 --- a/test/generative-media.mjs +++ b/test/generative-media.mjs @@ -7,6 +7,7 @@ const workflow = { '6': { class_type: 'CLIPTextEncode', inputs: { text: '{{promp for (const build of ['chrome', 'firefox']) { const media = await import(`../src/${build}/src/agent/generative-media.js`); + const legacyMedia = await import(`../src/${build}/src/agent/fal-media.js`); const config = await import(`../src/${build}/src/agent/media-config.js`); const gate = await import(`../src/${build}/src/agent/permission-gate.js`); const journal = await import(`../src/${build}/src/run-ui-journal.js`); @@ -58,10 +59,12 @@ for (const build of ['chrome', 'firefox']) { const calls = []; const result = await media.runMediaGeneration({ prompt: 'red apple', config: { provider: 'openrouter', apiKey: 'or-key', model: 'google/gemini-2.5-flash-image' }, fetchImpl: async (url, init) => { calls.push(url); assert.equal(init.headers.Authorization, 'Bearer or-key'); - assert.equal(init.redirect, 'error'); assert.deepEqual(JSON.parse(init.body), { model: 'google/gemini-2.5-flash-image', prompt: 'red apple' }); + assert.equal(init.redirect, 'error'); + if (url.endsWith('/images/models')) return json({ data: [{ id: 'google/gemini-2.5-flash-image' }] }); + assert.deepEqual(JSON.parse(init.body), { model: 'google/gemini-2.5-flash-image', prompt: 'red apple' }); return json({ data: [{ b64_json: 'YWJj', media_type: 'image/webp' }] }); } }); - assert.deepEqual(calls, ['https://openrouter.ai/api/v1/images']); + assert.deepEqual(calls, ['https://openrouter.ai/api/v1/images/models', 'https://openrouter.ai/api/v1/images']); assert.equal(result.url, 'data:image/webp;base64,YWJj'); assert.equal(media.extractMediaUrl({ data: [{ b64_json: 'YWJj', media_type: 'image/svg+xml' }] }), ''); assert.equal(media.extractMediaUrl({ candidates: [{ content: { parts: [{ inlineData: { mimeType: 'image/png', data: 'YWJj' } }] } }] }), 'data:image/png;base64,YWJj'); @@ -70,6 +73,83 @@ for (const build of ['chrome', 'firefox']) { assert.equal(media.safeMediaUrl('https://key@evil.example/x'), ''); }); + test(`${build}: legacy media entry points bind statically to the provider implementation`, () => { + assert.equal(legacyMedia.testImageGenProvider, media.testImageGenProvider); + assert.equal(legacyMedia.generateImage, media.generateImage); + }); + + test(`${build}: OpenRouter Hailuo video polls and downloads through authenticated trusted paths`, async () => { + const calls = []; + const responses = [ + json({ data: [] }), json({ data: [{ id: 'minimax/hailuo-3-max' }] }), + json({ id: 'job-1', status: 'pending', polling_url: 'https://evil.example/status' }, 202), + json({ status: 'pending' }), json({ status: 'in_progress' }), + json({ status: 'completed', unsigned_urls: ['https://evil.example/content'] }), + new Response('abc', { headers: { 'Content-Type': 'video/mp4' } }), + ]; + const result = await media.runMediaGeneration({ prompt: 'a moving red apple', config: { provider: 'openrouter', apiKey: 'or-key', model: 'minimax/hailuo-3-max' }, pollIntervalMs: 1, fetchImpl: async (url, init) => { + calls.push(url); + assert.equal(init.headers.Authorization, 'Bearer or-key'); + assert.equal(init.redirect, 'error'); assert.equal(init.credentials, 'omit'); + if (init.method === 'POST') assert.deepEqual(JSON.parse(init.body), { model: 'minimax/hailuo-3-max', prompt: 'a moving red apple' }); + return responses.shift(); + } }); + assert.deepEqual(result, { url: 'data:video/mp4;base64,YWJj', model: 'minimax/hailuo-3-max' }); + assert.deepEqual(calls, [ + 'https://openrouter.ai/api/v1/images/models', 'https://openrouter.ai/api/v1/videos/models', 'https://openrouter.ai/api/v1/videos', + ...Array(3).fill('https://openrouter.ai/api/v1/videos/job-1'), 'https://openrouter.ai/api/v1/videos/job-1/content?index=0', + ]); + }); + + test(`${build}: OpenRouter completed video downloads even without unsigned URLs`, async () => { + const responses = [json({ data: [] }), json({ data: [{ id: 'video-model' }] }), json({ id: 'job-1' }, 202), json({ status: 'completed' }), new Response('abc', { headers: { 'Content-Type': 'video/mp4' } })]; + const result = await media.runMediaGeneration({ prompt: 'apple', config: { provider: 'openrouter', apiKey: 'k', model: 'video-model' }, pollIntervalMs: 1, fetchImpl: async () => responses.shift() }); + assert.equal(result.url, 'data:video/mp4;base64,YWJj'); + }); + + test(`${build}: OpenRouter rejects invalid jobs and terminal failures without downloading media`, async () => { + for (const [submit, status, expected] of [ + [{ id: '../another-job' }, null, /invalid video job ID/], + [{ id: 'job-1' }, { status: 'failed', error: 'Content policy violation' }, /Content policy violation/], + [{ id: 'job-1' }, { status: 'failed' }, /failed/], + [{ id: 'job-1' }, { status: 'cancelled' }, /cancelled/], + [{ id: 'job-1' }, { status: 'expired' }, /expired/], + [{ id: 'job-1' }, {}, /unknown status/], + ]) { + const calls = []; + const responses = [json({ data: [] }), json({ data: [{ id: 'video-model' }] }), json(submit, 202), json(status)]; + await assert.rejects(media.runMediaGeneration({ prompt: 'apple', config: { provider: 'openrouter', apiKey: 'k', model: 'video-model' }, pollIntervalMs: 1, fetchImpl: async url => { calls.push(url); return responses.shift(); } }), expected); + assert.ok(!calls.some(url => url.includes('/content'))); + assert.equal(calls.length, status ? 4 : 3); + } + }); + + test(`${build}: OpenRouter rejects non-media models before paid submission`, async () => { + const calls = []; + await assert.rejects(media.runMediaGeneration({ prompt: 'apple', config: { provider: 'openrouter', apiKey: 'k', model: 'text-only' }, fetchImpl: async (url, init) => { + calls.push(url); assert.equal(init.method, undefined); return json({ data: [] }); + } }), /image or video generation catalog/); + assert.equal(calls.length, 2); + }); + + test(`${build}: OpenRouter video cancellation stops polling and aborts in-flight downloads`, async () => { + for (const phase of ['poll', 'download']) { + const controller = new AbortController(); + const calls = []; + const responses = [json({ data: [] }), json({ data: [{ id: 'video-model' }] }), json({ id: 'job-1' }, 202)]; + await assert.rejects(media.runMediaGeneration({ prompt: 'apple', config: { provider: 'openrouter', apiKey: 'k', model: 'video-model' }, signal: controller.signal, pollIntervalMs: 1, fetchImpl: async (url, init) => { + calls.push(url); + if (responses.length) return responses.shift(); + if (phase === 'download' && !url.includes('/content')) return json({ status: 'completed' }); + return new Promise((_resolve, reject) => { + init.signal.addEventListener('abort', () => reject(init.signal.reason), { once: true }); + controller.abort(new Error('Stopped video generation.')); + }); + } }), /Stopped video generation/); + assert.equal(calls.length, phase === 'poll' ? 4 : 5); + } + }); + test(`${build}: Comfy Router binds native inputs and polls trusted job paths`, async () => { const calls = []; const result = await media.runMediaGeneration({ prompt: 'an apple "quoted"', config: { provider: 'comfyrouter', apiKey: 'comfyui-key', model: 'bfl/flux-2-pro', parameters: JSON.stringify({ content: [{ text: '{{prompt}}', type: 'text' }], steps: 20 }) }, pollIntervalMs: 1, fetchImpl: async (url, init) => { @@ -207,17 +287,18 @@ for (const build of ['chrome', 'firefox']) { for (const [provider, values, responses, urls] of [ ['fal', { apiKey: 'k', model: 'fal-ai/flux/schnell' }, [json({})], ['https://api.fal.ai/v1/workflows?limit=1']], ['openrouter', { apiKey: 'k', model: 'image-model' }, [json({ data: {} }), json({ data: [{ id: 'image-model' }] })], ['https://openrouter.ai/api/v1/key', 'https://openrouter.ai/api/v1/images/models']], + ['openrouter', { apiKey: 'k', model: 'minimax/hailuo-3-max' }, [json({ data: {} }), json({ data: [] }), json({ data: [{ id: 'minimax/hailuo-3-max' }] })], ['https://openrouter.ai/api/v1/key', 'https://openrouter.ai/api/v1/images/models', 'https://openrouter.ai/api/v1/videos/models']], ['comfyrouter', { apiKey: 'k', model: 'bfl/flux-2-pro' }, [json({ id: 'bfl/flux-2-pro' })], ['https://api.comfy.org/v2/models/bfl/flux-2-pro']], ['comfyui', { workflow }, [json({ system: {}, devices: [] })], ['http://127.0.0.1:8188/system_stats']], ]) { selected = { provider, ...values }; const calls = []; - const result = await media.testImageGenProvider(async (url, init) => { assert.ok(!init.method || init.method === 'GET'); calls.push(url); return responses.shift(); }); + const result = await legacyMedia.testImageGenProvider(async (url, init) => { assert.ok(!init.method || init.method === 'GET'); calls.push(url); return responses.shift(); }); assert.equal(result.ok, true, result.error); assert.deepEqual(calls, urls); assert.equal((await media.testImageGenProvider(async () => json({}, 401))).ok, false); } selected = { provider: 'openrouter', apiKey: 'k', model: 'text-only' }; - const responses = [json({ data: {} }), json({ data: [{ id: 'image-model' }] })]; - assert.match((await media.testImageGenProvider(async () => responses.shift())).error, /image generation catalog/); + const responses = [json({ data: {} }), json({ data: [{ id: 'image-model' }] }), json({ data: [] })]; + assert.match((await legacyMedia.testImageGenProvider(async () => responses.shift())).error, /image or video generation catalog/); } finally { globalThis.chrome = originalChrome; globalThis.browser = originalBrowser; } }); From 89a95fec48966e0800245b1a60a6c7be6c8cd0a5 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Tue, 6 Oct 2026 19:13:27 +0300 Subject: [PATCH 3/3] version up --- package-lock.json | 4 ++-- package.json | 2 +- src/chrome/ARCHITECTURE.md | 2 +- src/chrome/manifest.json | 2 +- src/chrome/src/ui/settings.js | 2 +- src/firefox/ARCHITECTURE.md | 2 +- src/firefox/manifest.json | 2 +- src/firefox/src/ui/settings.js | 2 +- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 872babec00..fac4b216cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "webbrain", - "version": "39.1.2", + "version": "39.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "webbrain", - "version": "39.1.2", + "version": "39.1.3", "license": "GPL-3.0-or-later", "devDependencies": { "playwright": "^1.48.0", diff --git a/package.json b/package.json index fd8891003b..73b512f76b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webbrain", - "version": "39.1.2", + "version": "39.1.3", "description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.", "private": true, "type": "module", diff --git a/src/chrome/ARCHITECTURE.md b/src/chrome/ARCHITECTURE.md index 1e244bbde5..6011011ed4 100644 --- a/src/chrome/ARCHITECTURE.md +++ b/src/chrome/ARCHITECTURE.md @@ -1,6 +1,6 @@ # WebBrain Chrome/Edge Extension — Architecture -> Version 39.1.2 · Manifest V3 · Service Worker background +> Version 39.1.3 · Manifest V3 · Service Worker background ## High-Level Overview diff --git a/src/chrome/manifest.json b/src/chrome/manifest.json index 9fab79971e..a10e40bfc2 100644 --- a/src/chrome/manifest.json +++ b/src/chrome/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "WebBrain", - "version": "39.1.2", + "version": "39.1.3", "description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.", "permissions": [ "sidePanel", diff --git a/src/chrome/src/ui/settings.js b/src/chrome/src/ui/settings.js index d7a817aa20..e7d7c18ef5 100644 --- a/src/chrome/src/ui/settings.js +++ b/src/chrome/src/ui/settings.js @@ -89,7 +89,7 @@ const SUBSCRIPTION_GUIDE_PRODUCTS = Object.freeze({ // Version shown in the subtitle. Kept here so it only needs one update per // release; the subtitle string itself is translated. -const EXT_VERSION = '39.1.2'; +const EXT_VERSION = '39.1.3'; const providersContainer = document.getElementById('providers'); const displaySettings = document.getElementById('display-settings'); diff --git a/src/firefox/ARCHITECTURE.md b/src/firefox/ARCHITECTURE.md index 8d04c6ff06..9249f0d3aa 100644 --- a/src/firefox/ARCHITECTURE.md +++ b/src/firefox/ARCHITECTURE.md @@ -1,6 +1,6 @@ # WebBrain Firefox Extension — Architecture -> Version 39.1.2 · Manifest V2 · Background Page +> Version 39.1.3 · Manifest V2 · Background Page ## How Firefox Differs from Chrome diff --git a/src/firefox/manifest.json b/src/firefox/manifest.json index e5b73a42b5..14edfa6e88 100644 --- a/src/firefox/manifest.json +++ b/src/firefox/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "WebBrain", - "version": "39.1.2", + "version": "39.1.3", "description": "Open-source AI browser agent — chat with pages, automate tasks, multi-provider LLM support.", "permissions": [ "activeTab", diff --git a/src/firefox/src/ui/settings.js b/src/firefox/src/ui/settings.js index 45cdf11ef7..b91e979e03 100644 --- a/src/firefox/src/ui/settings.js +++ b/src/firefox/src/ui/settings.js @@ -81,7 +81,7 @@ const SUBSCRIPTION_GUIDE_PRODUCTS = Object.freeze({ // Version shown in the subtitle. Kept here so it only needs one update per // release; the subtitle string itself is translated. -const EXT_VERSION = '39.1.2'; +const EXT_VERSION = '39.1.3'; const providersContainer = document.getElementById('providers'); const displaySettings = document.getElementById('display-settings');