@mahe_pkm/buzl-html-editor 0.1.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/LICENSE +5 -0
- package/README.md +113 -0
- package/bin/buzl-editor.js +102 -0
- package/bin/buzl-site.js +77 -0
- package/dist/imageOptimizer.js +68 -0
- package/dist/imageService.js +242 -0
- package/dist/public/index.html +2005 -0
- package/dist/server.js +1045 -0
- package/dist/site-server.js +318 -0
- package/lib/cli.js +267 -0
- package/package.json +52 -0
- package/templates/config.example.json +6 -0
- package/templates/website_context.example.json +7 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Copyright (c) 2026 Buzl. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This package is proprietary software. Publication to a package registry does
|
|
4
|
+
not grant permission to copy, modify, redistribute, sublicense, or sell the
|
|
5
|
+
software. Use is limited to people and organizations authorized by Buzl.
|
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Buzl HTML Editor
|
|
2
|
+
|
|
3
|
+
A local visual editor and static-site server for ordinary multi-page HTML, CSS,
|
|
4
|
+
and JavaScript websites. It includes Live Edit, image uploads, AVIF image
|
|
5
|
+
optimization, AI-assisted text, and AI image generation.
|
|
6
|
+
|
|
7
|
+
The server is local-only by default. It edits the website folder from which it
|
|
8
|
+
is started; the editor's own files stay inside the npm package.
|
|
9
|
+
|
|
10
|
+
## Requirements
|
|
11
|
+
|
|
12
|
+
- Node.js 18 or newer
|
|
13
|
+
- Node.js 20 LTS or newer is recommended
|
|
14
|
+
- A static website containing one or more `.html` files
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
Run this inside the website root:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install --save-dev @mahe_pkm/buzl-html-editor
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Start the website and editor
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx buzl-editor
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Default addresses:
|
|
31
|
+
|
|
32
|
+
- Website: `http://localhost:4000/`
|
|
33
|
+
- Editor: `http://localhost:4000/admin/`
|
|
34
|
+
|
|
35
|
+
Use another port or website folder when needed:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npx buzl-editor --port 4500
|
|
39
|
+
npx buzl-editor --root "C:\Websites\client-site" --port 4500
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Prevent automatic browser opening:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx buzl-editor --no-open
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Website-only mode
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx buzl-site
|
|
52
|
+
npx buzl-site --port 3500
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Website-only mode blocks the editor, APIs, dependencies, configuration,
|
|
56
|
+
development files, and local secrets.
|
|
57
|
+
|
|
58
|
+
## Setup and diagnostics
|
|
59
|
+
|
|
60
|
+
Initialize optional configuration in the current website:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npx buzl-editor init
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
This creates `.buzl/config.json`, `.env.example`, and safe `.gitignore` rules
|
|
67
|
+
without replacing existing files.
|
|
68
|
+
|
|
69
|
+
Inspect a website before editing:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npx buzl-editor doctor
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Optional configuration
|
|
76
|
+
|
|
77
|
+
`.buzl/config.json`:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"editorPort": 4000,
|
|
82
|
+
"websitePort": 3000,
|
|
83
|
+
"host": "127.0.0.1",
|
|
84
|
+
"open": true
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Command-line options take priority over environment variables, configuration,
|
|
89
|
+
and defaults.
|
|
90
|
+
|
|
91
|
+
## AI configuration
|
|
92
|
+
|
|
93
|
+
Create `.env` in the client website root. Never commit this file.
|
|
94
|
+
|
|
95
|
+
```dotenv
|
|
96
|
+
ANTHROPIC_API_KEY=
|
|
97
|
+
OPENROUTER_API_KEY=
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The editor and image uploads work without the AI keys. Only the related AI
|
|
101
|
+
actions require them.
|
|
102
|
+
|
|
103
|
+
## Safety
|
|
104
|
+
|
|
105
|
+
- The default bind address is `127.0.0.1`.
|
|
106
|
+
- Non-local binding requires both `--host` and `--allow-network`.
|
|
107
|
+
- The editor can only load and save HTML files inside the selected website.
|
|
108
|
+
- Each save creates a recoverable copy under `.buzl/backups/`.
|
|
109
|
+
- Uploaded raster images are optimized to AVIF under `assets/images/`.
|
|
110
|
+
- `.env`, `.git`, `.buzl`, `node_modules`, tests, package metadata, and server
|
|
111
|
+
source are not publicly served.
|
|
112
|
+
|
|
113
|
+
Press `Ctrl+C` to stop either server.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const {
|
|
7
|
+
askForPort,
|
|
8
|
+
checkPort,
|
|
9
|
+
ensureSiteRoot,
|
|
10
|
+
initializeSite,
|
|
11
|
+
openBrowser,
|
|
12
|
+
parseArgs,
|
|
13
|
+
resolveRuntimeOptions,
|
|
14
|
+
runDoctor,
|
|
15
|
+
} = require('../lib/cli');
|
|
16
|
+
|
|
17
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
18
|
+
const DEFAULTS = { configPortKey: 'editorPort', host: '127.0.0.1', open: true, port: 4000 };
|
|
19
|
+
|
|
20
|
+
function printHelp() {
|
|
21
|
+
console.log(`
|
|
22
|
+
Buzl HTML Editor
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
buzl-editor [--root <folder>] [--port <number>] [--no-open]
|
|
26
|
+
buzl-editor init [--root <folder>]
|
|
27
|
+
buzl-editor doctor [--root <folder>] [--port <number>]
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
--root <folder> Website root (default: current folder)
|
|
31
|
+
--port, -p Local port (default: 4000)
|
|
32
|
+
--host <address> Bind address (default: 127.0.0.1)
|
|
33
|
+
--allow-network Required for a non-local bind address
|
|
34
|
+
--open Open the editor in the default browser
|
|
35
|
+
--no-open Do not open a browser
|
|
36
|
+
--version, -v Show package version
|
|
37
|
+
--help, -h Show this help
|
|
38
|
+
`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function main() {
|
|
42
|
+
try {
|
|
43
|
+
if (Number(process.versions.node.split('.')[0]) < 18) {
|
|
44
|
+
throw new Error('Node.js 18 or newer is required. Node.js 20 LTS is recommended.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const parsed = parseArgs(process.argv.slice(2), DEFAULTS);
|
|
48
|
+
if (parsed.command === 'help') return printHelp();
|
|
49
|
+
if (parsed.command === 'version') {
|
|
50
|
+
console.log(require('../package.json').version);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
ensureSiteRoot(parsed.root);
|
|
55
|
+
let options = resolveRuntimeOptions(parsed, DEFAULTS);
|
|
56
|
+
if (!options.portConfigured) {
|
|
57
|
+
options.port = await askForPort('editor', DEFAULTS.port);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const envPath = path.join(options.root, '.env');
|
|
61
|
+
process.env.BUZL_SITE_ROOT = options.root;
|
|
62
|
+
process.env.BUZL_ENV_FILE = envPath;
|
|
63
|
+
process.env.BUZL_CONTEXT_FILE = path.join(options.root, 'website_context.json');
|
|
64
|
+
process.env.HOST = options.host;
|
|
65
|
+
process.env.PORT = String(options.port);
|
|
66
|
+
require('dotenv').config({ path: envPath });
|
|
67
|
+
|
|
68
|
+
if (parsed.command === 'init') {
|
|
69
|
+
initializeSite(options.root);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (parsed.command === 'doctor') {
|
|
73
|
+
runDoctor(options.root, options.port, options.host);
|
|
74
|
+
await checkPort(options.port, options.host)
|
|
75
|
+
.then(() => console.log(`Port ${options.port}: available`))
|
|
76
|
+
.catch((error) => console.warn(`Port ${options.port}: ${error.message}`));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await checkPort(options.port, options.host);
|
|
81
|
+
const app = require(path.join(PACKAGE_ROOT, 'dist', 'server.js'));
|
|
82
|
+
const server = app.listen(options.port, options.host, () => {
|
|
83
|
+
const siteUrl = `http://localhost:${options.port}/`;
|
|
84
|
+
const editorUrl = `http://localhost:${options.port}/admin/`;
|
|
85
|
+
console.log('\nBuzl website and editor are running.');
|
|
86
|
+
console.log(`Website: ${siteUrl}`);
|
|
87
|
+
console.log(`Editor: ${editorUrl}`);
|
|
88
|
+
console.log(`Root: ${options.root}`);
|
|
89
|
+
console.log('Press Ctrl+C to stop.\n');
|
|
90
|
+
if (options.open) openBrowser(editorUrl);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const stop = () => server.close(() => process.exit(0));
|
|
94
|
+
process.once('SIGINT', stop);
|
|
95
|
+
process.once('SIGTERM', stop);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error(`\nUnable to start Buzl Editor: ${error.message}\n`);
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
main();
|
package/bin/buzl-site.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const {
|
|
7
|
+
askForPort,
|
|
8
|
+
checkPort,
|
|
9
|
+
ensureSiteRoot,
|
|
10
|
+
openBrowser,
|
|
11
|
+
parseArgs,
|
|
12
|
+
resolveRuntimeOptions,
|
|
13
|
+
runDoctor,
|
|
14
|
+
} = require('../lib/cli');
|
|
15
|
+
|
|
16
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
17
|
+
const DEFAULTS = { configPortKey: 'websitePort', host: '127.0.0.1', open: true, port: 3000 };
|
|
18
|
+
|
|
19
|
+
function printHelp() {
|
|
20
|
+
console.log(`
|
|
21
|
+
Buzl static website server
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
buzl-site [--root <folder>] [--port <number>] [--no-open]
|
|
25
|
+
buzl-site doctor [--root <folder>] [--port <number>]
|
|
26
|
+
|
|
27
|
+
The editor, APIs, package files and private configuration are unavailable in
|
|
28
|
+
website-only mode.
|
|
29
|
+
`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function main() {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = parseArgs(process.argv.slice(2), DEFAULTS);
|
|
35
|
+
if (parsed.command === 'help') return printHelp();
|
|
36
|
+
if (parsed.command === 'version') {
|
|
37
|
+
console.log(require('../package.json').version);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (parsed.command === 'init') {
|
|
41
|
+
throw new Error('Use "buzl-editor init" to initialize a website.');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
ensureSiteRoot(parsed.root);
|
|
45
|
+
let options = resolveRuntimeOptions(parsed, DEFAULTS);
|
|
46
|
+
if (!options.portConfigured) {
|
|
47
|
+
options.port = await askForPort('website', DEFAULTS.port);
|
|
48
|
+
}
|
|
49
|
+
if (parsed.command === 'doctor') {
|
|
50
|
+
runDoctor(options.root, options.port, options.host);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
await checkPort(options.port, options.host);
|
|
55
|
+
process.env.BUZL_SITE_ROOT = options.root;
|
|
56
|
+
const { createSiteServer } = require(path.join(PACKAGE_ROOT, 'dist', 'site-server.js'));
|
|
57
|
+
const server = createSiteServer({ siteRoot: options.root });
|
|
58
|
+
server.listen(options.port, options.host, () => {
|
|
59
|
+
const siteUrl = `http://localhost:${options.port}/`;
|
|
60
|
+
console.log('\nBuzl public website is running.');
|
|
61
|
+
console.log(`Website: ${siteUrl}`);
|
|
62
|
+
console.log(`Root: ${options.root}`);
|
|
63
|
+
console.log('The editor and APIs are disabled in this mode.');
|
|
64
|
+
console.log('Press Ctrl+C to stop.\n');
|
|
65
|
+
if (options.open) openBrowser(siteUrl);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const stop = () => server.close(() => process.exit(0));
|
|
69
|
+
process.once('SIGINT', stop);
|
|
70
|
+
process.once('SIGTERM', stop);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.error(`\nUnable to start Buzl website: ${error.message}\n`);
|
|
73
|
+
process.exitCode = 1;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
main();
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const sharp = require('sharp');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
|
|
4
|
+
const MAX_DIMENSION = 2048;
|
|
5
|
+
const MAX_SIZE_BYTES = 1 * 1024 * 1024; // 1 MB cap
|
|
6
|
+
const AVIF_QUALITY_LADDER = [70, 60, 50, 40, 30];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compresses an image (buffer or file path) strictly to AVIF.
|
|
10
|
+
* Loops through quality ladder to fit within MAX_SIZE_BYTES.
|
|
11
|
+
* If it doesn't fit, writes the lowest quality (30) AVIF.
|
|
12
|
+
*
|
|
13
|
+
* @param {Buffer|string} inputBufferOrPath - Input image buffer or absolute file path
|
|
14
|
+
* @param {string} outPathWithoutExt - Target output path without the .avif extension
|
|
15
|
+
* @returns {Promise<{format: 'avif', path: string, size: number}>}
|
|
16
|
+
*/
|
|
17
|
+
async function compressImage(inputBufferOrPath, outPathWithoutExt) {
|
|
18
|
+
// Read metadata
|
|
19
|
+
const pipeline = sharp(inputBufferOrPath, { failOn: 'none' });
|
|
20
|
+
const meta = await pipeline.metadata();
|
|
21
|
+
|
|
22
|
+
const originalWidth = meta.width;
|
|
23
|
+
const originalHeight = meta.height;
|
|
24
|
+
if (!originalWidth || !originalHeight) {
|
|
25
|
+
throw new Error('Could not read image dimensions');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const needsResize = Math.max(originalWidth, originalHeight) > MAX_DIMENSION;
|
|
29
|
+
const buildPipeline = () => {
|
|
30
|
+
let p = sharp(inputBufferOrPath, { failOn: 'none' });
|
|
31
|
+
if (needsResize) {
|
|
32
|
+
p = p.resize({
|
|
33
|
+
width: MAX_DIMENSION,
|
|
34
|
+
height: MAX_DIMENSION,
|
|
35
|
+
fit: 'inside',
|
|
36
|
+
withoutEnlargement: true
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return p;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const finalPath = outPathWithoutExt + '.avif';
|
|
43
|
+
let smallestBuffer = null;
|
|
44
|
+
|
|
45
|
+
// Try AVIF quality levels
|
|
46
|
+
for (const quality of AVIF_QUALITY_LADDER) {
|
|
47
|
+
const { data } = await buildPipeline()
|
|
48
|
+
.avif({ quality, effort: 4 })
|
|
49
|
+
.toBuffer({ resolveWithObject: true });
|
|
50
|
+
|
|
51
|
+
if (!smallestBuffer || data.length < smallestBuffer.length) {
|
|
52
|
+
smallestBuffer = data;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (data.length <= MAX_SIZE_BYTES) {
|
|
56
|
+
await fs.promises.writeFile(finalPath, data);
|
|
57
|
+
return { format: 'avif', path: finalPath, size: data.length };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// If nothing fit within 1MB budget, write the smallest AVIF buffer (quality 30)
|
|
62
|
+
await fs.promises.writeFile(finalPath, smallestBuffer);
|
|
63
|
+
return { format: 'avif', path: finalPath, size: smallestBuffer.length };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
compressImage
|
|
68
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* imageService.js
|
|
3
|
+
*
|
|
4
|
+
* Image generation service using OpenRouter APIs.
|
|
5
|
+
* JS port of tools/imageService.ts — simplified for the admin backend.
|
|
6
|
+
*
|
|
7
|
+
* Supports:
|
|
8
|
+
* - Image generation with model fallback
|
|
9
|
+
* - Request cancellation via AbortController
|
|
10
|
+
* - Saving generated images to disk
|
|
11
|
+
*
|
|
12
|
+
* Environment variables (.env):
|
|
13
|
+
* OPENROUTER_API_KEY
|
|
14
|
+
* OPENROUTER_BASE_URL (default: https://openrouter.ai/api/v1)
|
|
15
|
+
* OPENROUTER_IMAGE_MODEL (default: google/gemini-3.1-flash-image-preview)
|
|
16
|
+
* OPENROUTER_IMAGE_FALLBACK_MODELS (comma-separated)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// ─── OpenRouterClient ───────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
class OpenRouterClient {
|
|
22
|
+
constructor({ apiKey, baseUrl }) {
|
|
23
|
+
this.apiKey = apiKey;
|
|
24
|
+
this.baseUrl = baseUrl;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Generate an image from a text prompt.
|
|
29
|
+
* Returns base64 image data.
|
|
30
|
+
* @param {string} prompt
|
|
31
|
+
* @param {string} model
|
|
32
|
+
* @param {AbortSignal} [signal] - optional AbortSignal for cancellation
|
|
33
|
+
*/
|
|
34
|
+
async generateImage(prompt, model, signal) {
|
|
35
|
+
console.log(` 🖼️ ImageGen → model: ${model}`);
|
|
36
|
+
|
|
37
|
+
const data = await this._post('/chat/completions', {
|
|
38
|
+
model,
|
|
39
|
+
stream: false,
|
|
40
|
+
messages: [{ role: 'user', content: prompt }],
|
|
41
|
+
}, signal);
|
|
42
|
+
|
|
43
|
+
const choices = data?.choices;
|
|
44
|
+
if (!choices || choices.length === 0) {
|
|
45
|
+
throw new Error('No choices returned from image generation API');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const message = choices[0].message;
|
|
49
|
+
return this._extractBase64(message?.content, message?.images);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* POST to OpenRouter using native fetch (Node 18+).
|
|
54
|
+
* Handles large responses (image base64 payloads can be 200KB+) reliably.
|
|
55
|
+
*/
|
|
56
|
+
async _post(path, body, signal) {
|
|
57
|
+
// Ensure baseUrl ends with '/' so relative path appends correctly
|
|
58
|
+
const base = this.baseUrl.endsWith('/') ? this.baseUrl : this.baseUrl + '/';
|
|
59
|
+
const url = new URL(path.replace(/^\//, ''), base).toString();
|
|
60
|
+
|
|
61
|
+
const res = await fetch(url, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: {
|
|
64
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
65
|
+
'Content-Type': 'application/json',
|
|
66
|
+
},
|
|
67
|
+
body: JSON.stringify(body),
|
|
68
|
+
signal,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const raw = await res.text();
|
|
72
|
+
let json;
|
|
73
|
+
try {
|
|
74
|
+
json = JSON.parse(raw);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
// OpenRouter may return SSE streaming format even when stream:false is set.
|
|
77
|
+
// Detect and parse SSE "data: {...}" lines, merging them into one response.
|
|
78
|
+
if (raw.trimStart().startsWith('data:')) {
|
|
79
|
+
console.log(` ℹ️ Received SSE stream response, parsing chunks…`);
|
|
80
|
+
json = this._parseSSE(raw);
|
|
81
|
+
}
|
|
82
|
+
if (!json) {
|
|
83
|
+
console.error(` ❌ OpenRouter parse error: status=${res.status}, body length=${raw.length}, first 300 chars: ${raw.substring(0, 300)}`);
|
|
84
|
+
throw new Error(`Failed to parse OpenRouter response (status ${res.status}, ${raw.length} bytes)`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
const err = new Error(json?.error?.message || `HTTP ${res.status}`);
|
|
90
|
+
err.status = res.status;
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return json;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parse SSE (Server-Sent Events) stream into a merged chat completion object.
|
|
99
|
+
* OpenRouter may return SSE format unexpectedly for image generation requests.
|
|
100
|
+
*/
|
|
101
|
+
_parseSSE(raw) {
|
|
102
|
+
const lines = raw.split('\n');
|
|
103
|
+
let merged = null;
|
|
104
|
+
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
const trimmed = line.trim();
|
|
107
|
+
if (!trimmed.startsWith('data:')) continue;
|
|
108
|
+
const payload = trimmed.slice(5).trim();
|
|
109
|
+
if (payload === '[DONE]') break;
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const chunk = JSON.parse(payload);
|
|
113
|
+
if (!merged) {
|
|
114
|
+
// Use the first chunk as the base structure
|
|
115
|
+
merged = chunk;
|
|
116
|
+
// SSE chunks use "delta" instead of "message" in choices
|
|
117
|
+
if (merged.choices?.[0]?.delta && !merged.choices[0].message) {
|
|
118
|
+
merged.choices[0].message = { ...merged.choices[0].delta };
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
// Merge subsequent delta content into the message
|
|
122
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
123
|
+
if (delta && merged.choices?.[0]?.message) {
|
|
124
|
+
const msg = merged.choices[0].message;
|
|
125
|
+
if (delta.content) {
|
|
126
|
+
msg.content = (msg.content || '') + delta.content;
|
|
127
|
+
}
|
|
128
|
+
if (delta.images) {
|
|
129
|
+
msg.images = (msg.images || []).concat(delta.images);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch (parseErr) {
|
|
134
|
+
// Skip malformed chunks
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return merged;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Extract base64 data from API response.
|
|
144
|
+
* Checks both `images` array and `content` field.
|
|
145
|
+
*/
|
|
146
|
+
_extractBase64(content, images) {
|
|
147
|
+
// Check `images` array (OpenRouter Gemini models return data here)
|
|
148
|
+
if (Array.isArray(images)) {
|
|
149
|
+
for (const item of images) {
|
|
150
|
+
if (item.type === 'image_url' && item.image_url?.url) {
|
|
151
|
+
const url = item.image_url.url;
|
|
152
|
+
const match = url.match(/^data:image\/[^;]+;base64,(.+)$/s);
|
|
153
|
+
if (match) return match[1];
|
|
154
|
+
return url;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Content is array with image_url objects
|
|
160
|
+
if (Array.isArray(content)) {
|
|
161
|
+
for (const item of content) {
|
|
162
|
+
if (item.type === 'image_url' && item.image_url?.url) {
|
|
163
|
+
const url = item.image_url.url;
|
|
164
|
+
const match = url.match(/^data:image\/[^;]+;base64,(.+)$/s);
|
|
165
|
+
if (match) return match[1];
|
|
166
|
+
return url;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Content is a string data URI
|
|
172
|
+
if (typeof content === 'string') {
|
|
173
|
+
const match = content.match(/^data:image\/[^;]+;base64,(.+)$/s);
|
|
174
|
+
if (match) return match[1];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
throw new Error('Unable to extract image data from API response');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── ImageGenerator ─────────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
class ImageGenerator {
|
|
184
|
+
/**
|
|
185
|
+
* @param {OpenRouterClient} client
|
|
186
|
+
* @param {{ primaryModel: string, fallbackModels: string[] }} config
|
|
187
|
+
*/
|
|
188
|
+
constructor(client, config) {
|
|
189
|
+
this.client = client;
|
|
190
|
+
this.primaryModel = config.primaryModel;
|
|
191
|
+
this.fallbackModels = config.fallbackModels || [];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Generate an image. Tries primary model, then fallbacks.
|
|
196
|
+
* @param {string} prompt
|
|
197
|
+
* @param {AbortSignal} [signal]
|
|
198
|
+
* @returns {Promise<{ imageBase64: string, model: string }>}
|
|
199
|
+
*/
|
|
200
|
+
async generate(prompt, signal) {
|
|
201
|
+
const models = [this.primaryModel, ...this.fallbackModels];
|
|
202
|
+
|
|
203
|
+
for (const model of models) {
|
|
204
|
+
try {
|
|
205
|
+
console.log(` 🎨 Trying model: ${model}`);
|
|
206
|
+
const imageBase64 = await this.client.generateImage(prompt, model, signal);
|
|
207
|
+
return { imageBase64, model };
|
|
208
|
+
} catch (err) {
|
|
209
|
+
// Propagate cancellation immediately
|
|
210
|
+
if (err.name === 'AbortError' || err.message === 'Request cancelled') {
|
|
211
|
+
throw new Error('Request cancelled');
|
|
212
|
+
}
|
|
213
|
+
console.warn(` ⚠️ Model ${model} failed: ${err.message}`);
|
|
214
|
+
// Fall through to try next model
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
throw new Error(`All models failed: [${models.join(', ')}]`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Create an ImageGenerator from environment variables.
|
|
226
|
+
*/
|
|
227
|
+
function createImageGenerator() {
|
|
228
|
+
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
229
|
+
if (!apiKey) {
|
|
230
|
+
throw new Error('OPENROUTER_API_KEY is required. Set it in admin/.env');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const baseUrl = process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1';
|
|
234
|
+
const primaryModel = process.env.OPENROUTER_IMAGE_MODEL || 'google/gemini-3.1-flash-image-preview';
|
|
235
|
+
const fallbackModelsRaw = process.env.OPENROUTER_IMAGE_FALLBACK_MODELS || 'google/gemini-2.5-flash-image';
|
|
236
|
+
const fallbackModels = fallbackModelsRaw.split(',').map(m => m.trim()).filter(Boolean);
|
|
237
|
+
|
|
238
|
+
const client = new OpenRouterClient({ apiKey, baseUrl });
|
|
239
|
+
return new ImageGenerator(client, { primaryModel, fallbackModels });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = { OpenRouterClient, ImageGenerator, createImageGenerator };
|