opencode-pollinations-plugin 6.0.0-beta.19 ā 6.0.0-beta.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/README.md +7 -7
- package/dist/index.js +25 -116
- package/dist/server/commands.js +19 -9
- package/dist/server/generate-config.d.ts +3 -30
- package/dist/server/generate-config.js +164 -100
- package/dist/server/proxy.js +109 -65
- package/dist/tools/design/gen_diagram.d.ts +2 -0
- package/dist/tools/design/gen_diagram.js +97 -0
- package/dist/tools/design/gen_palette.d.ts +2 -0
- package/dist/tools/design/gen_palette.js +185 -0
- package/dist/tools/design/gen_qrcode.d.ts +2 -0
- package/dist/tools/design/gen_qrcode.js +60 -0
- package/dist/tools/index.d.ts +14 -0
- package/dist/tools/index.js +75 -0
- package/dist/tools/power/extract_frames.d.ts +2 -0
- package/dist/tools/power/extract_frames.js +215 -0
- package/dist/tools/power/file_to_url.d.ts +2 -0
- package/dist/tools/power/file_to_url.js +217 -0
- package/dist/tools/power/remove_background.d.ts +2 -0
- package/dist/tools/power/remove_background.js +115 -0
- package/package.json +6 -4
- package/dist/server/models-seed.d.ts +0 -18
- package/dist/server/models-seed.js +0 -55
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { tool } from '@opencode-ai/plugin/tool';
|
|
2
|
+
import * as https from 'https';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import * as os from 'os';
|
|
6
|
+
const SAVE_DIR = path.join(os.homedir(), 'Downloads', 'pollinations', 'diagrams');
|
|
7
|
+
const MERMAID_INK_BASE = 'https://mermaid.ink';
|
|
8
|
+
/**
|
|
9
|
+
* Encode Mermaid code for mermaid.ink API
|
|
10
|
+
* Uses base64 encoding of the diagram definition
|
|
11
|
+
*/
|
|
12
|
+
function encodeMermaid(code) {
|
|
13
|
+
return Buffer.from(code, 'utf-8').toString('base64url');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Fetch binary content from URL
|
|
17
|
+
*/
|
|
18
|
+
function fetchBinary(url) {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const req = https.get(url, { headers: { 'User-Agent': 'OpenCode-Pollinations-Plugin/6.0' } }, (res) => {
|
|
21
|
+
// Follow redirects
|
|
22
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
23
|
+
return fetchBinary(res.headers.location).then(resolve).catch(reject);
|
|
24
|
+
}
|
|
25
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
26
|
+
return reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
|
|
27
|
+
}
|
|
28
|
+
const chunks = [];
|
|
29
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
30
|
+
res.on('end', () => resolve(Buffer.concat(chunks)));
|
|
31
|
+
});
|
|
32
|
+
req.on('error', reject);
|
|
33
|
+
req.setTimeout(15000, () => {
|
|
34
|
+
req.destroy();
|
|
35
|
+
reject(new Error('Timeout fetching diagram'));
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export const genDiagramTool = tool({
|
|
40
|
+
description: `Render a Mermaid diagram to SVG or PNG image.
|
|
41
|
+
Uses mermaid.ink (free, no auth required). Supports all Mermaid syntax:
|
|
42
|
+
flowchart, sequenceDiagram, classDiagram, stateDiagram, erDiagram, gantt, pie, mindmap, timeline, etc.
|
|
43
|
+
The diagram code should be valid Mermaid syntax WITHOUT the \`\`\`mermaid fences.`,
|
|
44
|
+
args: {
|
|
45
|
+
code: tool.schema.string().describe('Mermaid diagram code (e.g. "graph LR; A-->B; B-->C")'),
|
|
46
|
+
format: tool.schema.enum(['svg', 'png']).optional().describe('Output format (default: svg)'),
|
|
47
|
+
theme: tool.schema.enum(['default', 'dark', 'forest', 'neutral']).optional().describe('Diagram theme (default: default)'),
|
|
48
|
+
filename: tool.schema.string().optional().describe('Custom filename (without extension). Auto-generated if omitted'),
|
|
49
|
+
},
|
|
50
|
+
async execute(args, context) {
|
|
51
|
+
const format = args.format || 'svg';
|
|
52
|
+
const theme = args.theme || 'default';
|
|
53
|
+
// Ensure save directory
|
|
54
|
+
if (!fs.existsSync(SAVE_DIR)) {
|
|
55
|
+
fs.mkdirSync(SAVE_DIR, { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
// Build mermaid.ink URL
|
|
58
|
+
// For themed rendering, we wrap with config
|
|
59
|
+
const themedCode = theme !== 'default'
|
|
60
|
+
? `%%{init: {'theme': '${theme}'}}%%\n${args.code}`
|
|
61
|
+
: args.code;
|
|
62
|
+
const encoded = encodeMermaid(themedCode);
|
|
63
|
+
const endpoint = format === 'svg' ? 'svg' : 'img';
|
|
64
|
+
const url = `${MERMAID_INK_BASE}/${endpoint}/${encoded}`;
|
|
65
|
+
// Generate filename
|
|
66
|
+
const safeName = args.filename
|
|
67
|
+
? args.filename.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
68
|
+
: `diagram_${Date.now()}`;
|
|
69
|
+
const filePath = path.join(SAVE_DIR, `${safeName}.${format}`);
|
|
70
|
+
try {
|
|
71
|
+
const data = await fetchBinary(url);
|
|
72
|
+
if (data.length < 50) {
|
|
73
|
+
return `ā Diagram Error: mermaid.ink returned empty/invalid response. Check your Mermaid syntax.`;
|
|
74
|
+
}
|
|
75
|
+
fs.writeFileSync(filePath, data);
|
|
76
|
+
const fileSizeKB = (data.length / 1024).toFixed(1);
|
|
77
|
+
// Extract diagram type from first line
|
|
78
|
+
const firstLine = args.code.trim().split('\n')[0].trim();
|
|
79
|
+
const diagramType = firstLine.replace(/[;\s{].*/g, '');
|
|
80
|
+
context.metadata({ title: `š Diagram: ${diagramType}` });
|
|
81
|
+
return [
|
|
82
|
+
`š Diagram Rendered`,
|
|
83
|
+
`āāāāāāāāāāāāāāāāāāā`,
|
|
84
|
+
`Type: ${diagramType}`,
|
|
85
|
+
`Theme: ${theme}`,
|
|
86
|
+
`Format: ${format.toUpperCase()}`,
|
|
87
|
+
`File: ${filePath}`,
|
|
88
|
+
`Weight: ${fileSizeKB} KB`,
|
|
89
|
+
`URL: ${url}`,
|
|
90
|
+
`Cost: Free (mermaid.ink)`,
|
|
91
|
+
].join('\n');
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
return `ā Diagram Error: ${err.message}\nš” Verify your Mermaid syntax at https://mermaid.live`;
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { tool } from '@opencode-ai/plugin/tool';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
const SAVE_DIR = path.join(os.homedir(), 'Downloads', 'pollinations', 'palettes');
|
|
6
|
+
function hexToHSL(hex) {
|
|
7
|
+
hex = hex.replace('#', '');
|
|
8
|
+
if (hex.length === 3)
|
|
9
|
+
hex = hex.split('').map(c => c + c).join('');
|
|
10
|
+
const r = parseInt(hex.substring(0, 2), 16) / 255;
|
|
11
|
+
const g = parseInt(hex.substring(2, 4), 16) / 255;
|
|
12
|
+
const b = parseInt(hex.substring(4, 6), 16) / 255;
|
|
13
|
+
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
|
14
|
+
let h = 0, s = 0;
|
|
15
|
+
const l = (max + min) / 2;
|
|
16
|
+
if (max !== min) {
|
|
17
|
+
const d = max - min;
|
|
18
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
19
|
+
switch (max) {
|
|
20
|
+
case r:
|
|
21
|
+
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
22
|
+
break;
|
|
23
|
+
case g:
|
|
24
|
+
h = ((b - r) / d + 2) / 6;
|
|
25
|
+
break;
|
|
26
|
+
case b:
|
|
27
|
+
h = ((r - g) / d + 4) / 6;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
|
|
32
|
+
}
|
|
33
|
+
function hslToHex(h, s, l) {
|
|
34
|
+
s /= 100;
|
|
35
|
+
l /= 100;
|
|
36
|
+
const a = s * Math.min(l, 1 - l);
|
|
37
|
+
const f = (n) => {
|
|
38
|
+
const k = (n + h / 30) % 12;
|
|
39
|
+
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
|
40
|
+
return Math.round(255 * color).toString(16).padStart(2, '0');
|
|
41
|
+
};
|
|
42
|
+
return `#${f(0)}${f(8)}${f(4)}`;
|
|
43
|
+
}
|
|
44
|
+
function generatePalette(baseHex, scheme, count) {
|
|
45
|
+
const base = hexToHSL(baseHex);
|
|
46
|
+
const colors = [];
|
|
47
|
+
switch (scheme) {
|
|
48
|
+
case 'complementary':
|
|
49
|
+
colors.push({ hex: baseHex, role: 'Base' });
|
|
50
|
+
colors.push({ hex: hslToHex((base.h + 180) % 360, base.s, base.l), role: 'Complement' });
|
|
51
|
+
// Fill shades
|
|
52
|
+
for (let i = 2; i < count; i++) {
|
|
53
|
+
const lShift = base.l + (i % 2 === 0 ? 15 : -15) * Math.ceil(i / 2);
|
|
54
|
+
colors.push({ hex: hslToHex(base.h, base.s, Math.max(10, Math.min(90, lShift))), role: `Shade ${i - 1}` });
|
|
55
|
+
}
|
|
56
|
+
break;
|
|
57
|
+
case 'analogous':
|
|
58
|
+
for (let i = 0; i < count; i++) {
|
|
59
|
+
const offset = (i - Math.floor(count / 2)) * 30;
|
|
60
|
+
colors.push({
|
|
61
|
+
hex: hslToHex((base.h + offset + 360) % 360, base.s, base.l),
|
|
62
|
+
role: offset === 0 ? 'Base' : `${offset > 0 ? '+' : ''}${offset}°`
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
case 'triadic':
|
|
67
|
+
colors.push({ hex: baseHex, role: 'Base' });
|
|
68
|
+
colors.push({ hex: hslToHex((base.h + 120) % 360, base.s, base.l), role: 'Triad +120°' });
|
|
69
|
+
colors.push({ hex: hslToHex((base.h + 240) % 360, base.s, base.l), role: 'Triad +240°' });
|
|
70
|
+
for (let i = 3; i < count; i++) {
|
|
71
|
+
const lShift = base.l + (i % 2 === 0 ? 12 : -12) * Math.ceil((i - 2) / 2);
|
|
72
|
+
colors.push({ hex: hslToHex((base.h + (i * 120)) % 360, base.s, Math.max(10, Math.min(90, lShift))), role: `Accent ${i - 2}` });
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
case 'split-complementary':
|
|
76
|
+
colors.push({ hex: baseHex, role: 'Base' });
|
|
77
|
+
colors.push({ hex: hslToHex((base.h + 150) % 360, base.s, base.l), role: 'Split +150°' });
|
|
78
|
+
colors.push({ hex: hslToHex((base.h + 210) % 360, base.s, base.l), role: 'Split +210°' });
|
|
79
|
+
for (let i = 3; i < count; i++) {
|
|
80
|
+
colors.push({ hex: hslToHex(base.h, base.s, Math.max(10, Math.min(90, base.l + (i * 10 - 30)))), role: `Tone ${i - 2}` });
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
case 'monochromatic':
|
|
84
|
+
default:
|
|
85
|
+
for (let i = 0; i < count; i++) {
|
|
86
|
+
const l = Math.round(15 + (i / (count - 1)) * 70); // 15% to 85%
|
|
87
|
+
colors.push({
|
|
88
|
+
hex: hslToHex(base.h, base.s, l),
|
|
89
|
+
role: l < base.l ? `Dark ${Math.abs(i - Math.floor(count / 2))}` : l === base.l ? 'Base' : `Light ${Math.abs(i - Math.floor(count / 2))}`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// Mark closest to base
|
|
93
|
+
let closestIdx = 0;
|
|
94
|
+
let closestDiff = Infinity;
|
|
95
|
+
colors.forEach((c, i) => {
|
|
96
|
+
const diff = Math.abs(hexToHSL(c.hex).l - base.l);
|
|
97
|
+
if (diff < closestDiff) {
|
|
98
|
+
closestDiff = diff;
|
|
99
|
+
closestIdx = i;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
colors[closestIdx].role = 'Base';
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
return colors.slice(0, count);
|
|
106
|
+
}
|
|
107
|
+
function generateSVG(colors) {
|
|
108
|
+
const swatchW = 120;
|
|
109
|
+
const swatchH = 80;
|
|
110
|
+
const gap = 8;
|
|
111
|
+
const totalW = colors.length * (swatchW + gap) - gap + 40;
|
|
112
|
+
const totalH = swatchH + 60;
|
|
113
|
+
const swatches = colors.map((c, i) => {
|
|
114
|
+
const x = 20 + i * (swatchW + gap);
|
|
115
|
+
const textColor = hexToHSL(c.hex).l > 50 ? '#1a1a1a' : '#ffffff';
|
|
116
|
+
return `
|
|
117
|
+
<rect x="${x}" y="20" width="${swatchW}" height="${swatchH}" rx="8" fill="${c.hex}" stroke="#333" stroke-width="1"/>
|
|
118
|
+
<text x="${x + swatchW / 2}" y="${swatchH / 2 + 15}" text-anchor="middle" fill="${textColor}" font-family="monospace" font-size="13" font-weight="bold">${c.hex.toUpperCase()}</text>
|
|
119
|
+
<text x="${x + swatchW / 2}" y="${swatchH + 38}" text-anchor="middle" fill="#666" font-family="sans-serif" font-size="11">${c.role}</text>`;
|
|
120
|
+
}).join('');
|
|
121
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="${totalH}" viewBox="0 0 ${totalW} ${totalH}">
|
|
122
|
+
<rect width="100%" height="100%" fill="#0d0d0d" rx="12"/>
|
|
123
|
+
${swatches}
|
|
124
|
+
</svg>`;
|
|
125
|
+
}
|
|
126
|
+
export const genPaletteTool = tool({
|
|
127
|
+
description: `Generate a harmonious color palette from a base hex color.
|
|
128
|
+
Outputs a visual SVG palette + JSON color codes. Works 100% offline.
|
|
129
|
+
Schemes: monochromatic, complementary, analogous, triadic, split-complementary.
|
|
130
|
+
Perfect for frontend design, branding, and UI theming.`,
|
|
131
|
+
args: {
|
|
132
|
+
color: tool.schema.string().describe('Base hex color (e.g. "#3B82F6" or "3B82F6")'),
|
|
133
|
+
scheme: tool.schema.enum(['monochromatic', 'complementary', 'analogous', 'triadic', 'split-complementary']).optional()
|
|
134
|
+
.describe('Color harmony scheme (default: analogous)'),
|
|
135
|
+
count: tool.schema.number().min(3).max(8).optional().describe('Number of colors (default: 5, max: 8)'),
|
|
136
|
+
filename: tool.schema.string().optional().describe('Custom filename (without extension). Auto-generated if omitted'),
|
|
137
|
+
},
|
|
138
|
+
async execute(args, context) {
|
|
139
|
+
const scheme = args.scheme || 'analogous';
|
|
140
|
+
const count = args.count || 5;
|
|
141
|
+
// Normalize hex
|
|
142
|
+
let hex = args.color.trim();
|
|
143
|
+
if (!hex.startsWith('#'))
|
|
144
|
+
hex = '#' + hex;
|
|
145
|
+
if (!/^#[0-9a-fA-F]{3,6}$/.test(hex)) {
|
|
146
|
+
return `ā Invalid hex color: "${args.color}". Use format: #3B82F6 or 3B82F6`;
|
|
147
|
+
}
|
|
148
|
+
if (hex.length === 4)
|
|
149
|
+
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
|
|
150
|
+
// Generate palette
|
|
151
|
+
const colors = generatePalette(hex, scheme, count);
|
|
152
|
+
// Ensure save directory
|
|
153
|
+
if (!fs.existsSync(SAVE_DIR)) {
|
|
154
|
+
fs.mkdirSync(SAVE_DIR, { recursive: true });
|
|
155
|
+
}
|
|
156
|
+
// Save SVG
|
|
157
|
+
const safeName = args.filename
|
|
158
|
+
? args.filename.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
159
|
+
: `palette_${hex.replace('#', '')}_${scheme}`;
|
|
160
|
+
const svgPath = path.join(SAVE_DIR, `${safeName}.svg`);
|
|
161
|
+
const svg = generateSVG(colors);
|
|
162
|
+
fs.writeFileSync(svgPath, svg);
|
|
163
|
+
// Build CSS custom properties snippet
|
|
164
|
+
const cssVars = colors.map((c, i) => ` --color-${i + 1}: ${c.hex};`).join('\n');
|
|
165
|
+
context.metadata({ title: `šØ Palette: ${scheme} from ${hex}` });
|
|
166
|
+
const colorTable = colors.map(c => ` ${c.hex.toUpperCase()} ${c.role}`).join('\n');
|
|
167
|
+
return [
|
|
168
|
+
`šØ Color Palette Generated`,
|
|
169
|
+
`āāāāāāāāāāāāāāāāāāāāāāāāā`,
|
|
170
|
+
`Base: ${hex.toUpperCase()}`,
|
|
171
|
+
`Scheme: ${scheme}`,
|
|
172
|
+
`Colors (${count}):`,
|
|
173
|
+
colorTable,
|
|
174
|
+
``,
|
|
175
|
+
`File: ${svgPath}`,
|
|
176
|
+
``,
|
|
177
|
+
`CSS Variables:`,
|
|
178
|
+
`:root {`,
|
|
179
|
+
cssVars,
|
|
180
|
+
`}`,
|
|
181
|
+
``,
|
|
182
|
+
`Cost: Free (local computation)`,
|
|
183
|
+
].join('\n');
|
|
184
|
+
},
|
|
185
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { tool } from '@opencode-ai/plugin/tool';
|
|
2
|
+
import * as QRCode from 'qrcode';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import * as os from 'os';
|
|
6
|
+
const SAVE_DIR = path.join(os.homedir(), 'Downloads', 'pollinations', 'qrcodes');
|
|
7
|
+
export const genQrcodeTool = tool({
|
|
8
|
+
description: `Generate a QR code image from text, URL, or WiFi credentials.
|
|
9
|
+
Outputs a PNG file saved locally. Works 100% offline, no API key needed.
|
|
10
|
+
Examples: URLs, plain text, WiFi (format: WIFI:T:WPA;S:NetworkName;P:Password;;)`,
|
|
11
|
+
args: {
|
|
12
|
+
content: tool.schema.string().describe('The text, URL, or WiFi string to encode into a QR code'),
|
|
13
|
+
size: tool.schema.number().min(128).max(2048).optional().describe('QR code size in pixels (default: 512)'),
|
|
14
|
+
filename: tool.schema.string().optional().describe('Custom filename (without extension). Auto-generated if omitted'),
|
|
15
|
+
},
|
|
16
|
+
async execute(args, context) {
|
|
17
|
+
const size = args.size || 512;
|
|
18
|
+
// Ensure save directory exists
|
|
19
|
+
if (!fs.existsSync(SAVE_DIR)) {
|
|
20
|
+
fs.mkdirSync(SAVE_DIR, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
// Generate filename
|
|
23
|
+
const safeName = args.filename
|
|
24
|
+
? args.filename.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
25
|
+
: `qr_${Date.now()}`;
|
|
26
|
+
const filePath = path.join(SAVE_DIR, `${safeName}.png`);
|
|
27
|
+
try {
|
|
28
|
+
// Generate QR code PNG
|
|
29
|
+
await QRCode.toFile(filePath, args.content, {
|
|
30
|
+
width: size,
|
|
31
|
+
margin: 2,
|
|
32
|
+
color: {
|
|
33
|
+
dark: '#000000',
|
|
34
|
+
light: '#ffffff',
|
|
35
|
+
},
|
|
36
|
+
errorCorrectionLevel: 'M',
|
|
37
|
+
});
|
|
38
|
+
// Get file size
|
|
39
|
+
const stats = fs.statSync(filePath);
|
|
40
|
+
const fileSizeKB = (stats.size / 1024).toFixed(1);
|
|
41
|
+
// Truncate content for display
|
|
42
|
+
const displayContent = args.content.length > 80
|
|
43
|
+
? args.content.substring(0, 77) + '...'
|
|
44
|
+
: args.content;
|
|
45
|
+
context.metadata({ title: `š² QR Code: ${displayContent}` });
|
|
46
|
+
return [
|
|
47
|
+
`š² QR Code Generated`,
|
|
48
|
+
`āāāāāāāāāāāāāāāāāāā`,
|
|
49
|
+
`Content: ${displayContent}`,
|
|
50
|
+
`Size: ${size}Ć${size}px`,
|
|
51
|
+
`File: ${filePath}`,
|
|
52
|
+
`Weight: ${fileSizeKB} KB`,
|
|
53
|
+
`Cost: Free (local generation)`,
|
|
54
|
+
].join('\n');
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
return `ā QR Code Error: ${err.message}`;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Registry ā Conditional Injection System
|
|
3
|
+
*
|
|
4
|
+
* Free Universe (no key): 7 tools always available
|
|
5
|
+
* Enter Universe (with key): +5 Pollinations tools
|
|
6
|
+
*
|
|
7
|
+
* Tools are injected ONCE at plugin init. Restart needed after /poll connect.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Build the tool registry based on user's access level
|
|
11
|
+
*
|
|
12
|
+
* @returns Record<string, Tool> to be spread into the plugin's tool: {} property
|
|
13
|
+
*/
|
|
14
|
+
export declare function createToolRegistry(): Record<string, any>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool Registry ā Conditional Injection System
|
|
3
|
+
*
|
|
4
|
+
* Free Universe (no key): 7 tools always available
|
|
5
|
+
* Enter Universe (with key): +5 Pollinations tools
|
|
6
|
+
*
|
|
7
|
+
* Tools are injected ONCE at plugin init. Restart needed after /poll connect.
|
|
8
|
+
*/
|
|
9
|
+
import { loadConfig } from '../server/config.js';
|
|
10
|
+
// === FREE TOOLS (Always available) ===
|
|
11
|
+
import { genQrcodeTool } from './design/gen_qrcode.js';
|
|
12
|
+
import { genDiagramTool } from './design/gen_diagram.js';
|
|
13
|
+
import { genPaletteTool } from './design/gen_palette.js';
|
|
14
|
+
import { fileToUrlTool } from './power/file_to_url.js';
|
|
15
|
+
import { removeBackgroundTool } from './power/remove_background.js';
|
|
16
|
+
import { extractFramesTool } from './power/extract_frames.js';
|
|
17
|
+
// === ENTER TOOLS (Require API key) ===
|
|
18
|
+
// Phase 4D: Pollinations tools ā TO BE IMPLEMENTED
|
|
19
|
+
// import { genImageTool } from './pollinations/gen_image.js';
|
|
20
|
+
// import { genVideoTool } from './pollinations/gen_video.js';
|
|
21
|
+
// import { genAudioTool } from './pollinations/gen_audio.js';
|
|
22
|
+
// import { genMusicTool } from './pollinations/gen_music.js';
|
|
23
|
+
// import { deepsearchTool } from './pollinations/deepsearch.js';
|
|
24
|
+
// import { searchCrawlScrapeTool } from './pollinations/search_crawl_scrape.js';
|
|
25
|
+
import * as fs from 'fs';
|
|
26
|
+
const LOG_FILE = '/tmp/opencode_pollinations_v4.log';
|
|
27
|
+
function log(msg) {
|
|
28
|
+
try {
|
|
29
|
+
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [Tools] ${msg}\n`);
|
|
30
|
+
}
|
|
31
|
+
catch { }
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Detect if a valid API key is present
|
|
35
|
+
*/
|
|
36
|
+
function hasValidKey() {
|
|
37
|
+
const config = loadConfig();
|
|
38
|
+
return !!(config.apiKey && config.apiKey.length > 5 && config.apiKey !== 'dummy');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build the tool registry based on user's access level
|
|
42
|
+
*
|
|
43
|
+
* @returns Record<string, Tool> to be spread into the plugin's tool: {} property
|
|
44
|
+
*/
|
|
45
|
+
export function createToolRegistry() {
|
|
46
|
+
const tools = {};
|
|
47
|
+
const keyPresent = hasValidKey();
|
|
48
|
+
const config = loadConfig();
|
|
49
|
+
// === FREE UNIVERSE: Always injected ===
|
|
50
|
+
// Design tools
|
|
51
|
+
tools['gen_qrcode'] = genQrcodeTool;
|
|
52
|
+
tools['gen_diagram'] = genDiagramTool;
|
|
53
|
+
tools['gen_palette'] = genPaletteTool;
|
|
54
|
+
// Power tools
|
|
55
|
+
tools['file_to_url'] = fileToUrlTool;
|
|
56
|
+
tools['remove_background'] = removeBackgroundTool;
|
|
57
|
+
tools['extract_frames'] = extractFramesTool;
|
|
58
|
+
// gen_image (free version) ā TODO Phase 4D
|
|
59
|
+
// tools['gen_image'] = genImageTool;
|
|
60
|
+
log(`Free tools injected: ${Object.keys(tools).length}`);
|
|
61
|
+
// === ENTER UNIVERSE: Only with valid API key ===
|
|
62
|
+
if (keyPresent) {
|
|
63
|
+
// Pollinations paid tools ā TODO Phase 4D
|
|
64
|
+
// tools['gen_video'] = genVideoTool;
|
|
65
|
+
// tools['gen_audio'] = genAudioTool;
|
|
66
|
+
// tools['gen_music'] = genMusicTool;
|
|
67
|
+
// tools['deepsearch'] = deepsearchTool;
|
|
68
|
+
// tools['search_crawl_scrape'] = searchCrawlScrapeTool;
|
|
69
|
+
log(`Enter tools injected (key detected). Total: ${Object.keys(tools).length}`);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
log(`Enter tools SKIPPED (no key). Total: ${Object.keys(tools).length}`);
|
|
73
|
+
}
|
|
74
|
+
return tools;
|
|
75
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { tool } from '@opencode-ai/plugin/tool';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import * as https from 'https';
|
|
6
|
+
import * as http from 'http';
|
|
7
|
+
const SAVE_DIR = path.join(os.homedir(), 'Downloads', 'pollinations', 'frames');
|
|
8
|
+
/**
|
|
9
|
+
* Download a video from URL to a temp file
|
|
10
|
+
*/
|
|
11
|
+
function downloadVideo(url) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const tempPath = path.join(os.tmpdir(), `video_${Date.now()}.mp4`);
|
|
14
|
+
const proto = url.startsWith('https') ? https : http;
|
|
15
|
+
const req = proto.get(url, { headers: { 'User-Agent': 'OpenCode-Pollinations-Plugin/6.0' } }, (res) => {
|
|
16
|
+
// Follow redirects
|
|
17
|
+
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
18
|
+
return downloadVideo(res.headers.location).then(resolve).catch(reject);
|
|
19
|
+
}
|
|
20
|
+
if (res.statusCode && res.statusCode >= 400) {
|
|
21
|
+
return reject(new Error(`HTTP ${res.statusCode}`));
|
|
22
|
+
}
|
|
23
|
+
const ws = fs.createWriteStream(tempPath);
|
|
24
|
+
res.pipe(ws);
|
|
25
|
+
ws.on('finish', () => {
|
|
26
|
+
ws.close();
|
|
27
|
+
resolve(tempPath);
|
|
28
|
+
});
|
|
29
|
+
ws.on('error', reject);
|
|
30
|
+
});
|
|
31
|
+
req.on('error', reject);
|
|
32
|
+
req.setTimeout(120000, () => {
|
|
33
|
+
req.destroy();
|
|
34
|
+
reject(new Error('Download timeout (120s)'));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Try to load @ffmpeg/ffmpeg dynamically ā it's an optional peer dependency
|
|
40
|
+
*/
|
|
41
|
+
async function loadFFmpeg() {
|
|
42
|
+
try {
|
|
43
|
+
// @ts-ignore ā optional peer dependency
|
|
44
|
+
const { FFmpeg } = await import('@ffmpeg/ffmpeg');
|
|
45
|
+
// @ts-ignore ā optional peer dependency
|
|
46
|
+
const { toBlobURL } = await import('@ffmpeg/util');
|
|
47
|
+
return { FFmpeg, toBlobURL };
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Check if system ffmpeg is available (fallback)
|
|
55
|
+
*/
|
|
56
|
+
function hasSystemFFmpeg() {
|
|
57
|
+
try {
|
|
58
|
+
const { execSync } = require('child_process');
|
|
59
|
+
execSync('ffmpeg -version', { stdio: 'ignore' });
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Extract frames using system ffmpeg
|
|
68
|
+
*/
|
|
69
|
+
function extractWithSystemFFmpeg(videoPath, outputDir, baseName, options) {
|
|
70
|
+
const { execSync } = require('child_process');
|
|
71
|
+
const outputPattern = path.join(outputDir, `${baseName}_%03d.png`);
|
|
72
|
+
const outputs = [];
|
|
73
|
+
let cmd = `ffmpeg -y -i "${videoPath}"`;
|
|
74
|
+
if (options.at_time) {
|
|
75
|
+
// Single frame at specific time
|
|
76
|
+
cmd += ` -ss ${options.at_time} -frames:v 1`;
|
|
77
|
+
const singleOutput = path.join(outputDir, `${baseName}_at_${options.at_time.replace(/:/g, '-')}.png`);
|
|
78
|
+
cmd += ` "${singleOutput}"`;
|
|
79
|
+
execSync(cmd, { stdio: 'ignore', timeout: 60000 });
|
|
80
|
+
if (fs.existsSync(singleOutput))
|
|
81
|
+
outputs.push(singleOutput);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// Range extraction
|
|
85
|
+
if (options.start)
|
|
86
|
+
cmd += ` -ss ${options.start}`;
|
|
87
|
+
if (options.end)
|
|
88
|
+
cmd += ` -to ${options.end}`;
|
|
89
|
+
const fps = options.fps || 1; // Default 1 fps
|
|
90
|
+
cmd += ` -vf "fps=${fps}" "${outputPattern}"`;
|
|
91
|
+
execSync(cmd, { stdio: 'ignore', timeout: 120000 });
|
|
92
|
+
// Collect generated files
|
|
93
|
+
const files = fs.readdirSync(outputDir);
|
|
94
|
+
files.filter(f => f.startsWith(baseName) && f.endsWith('.png'))
|
|
95
|
+
.sort()
|
|
96
|
+
.forEach(f => outputs.push(path.join(outputDir, f)));
|
|
97
|
+
}
|
|
98
|
+
return outputs;
|
|
99
|
+
}
|
|
100
|
+
function formatFileSize(bytes) {
|
|
101
|
+
if (bytes < 1024)
|
|
102
|
+
return `${bytes} B`;
|
|
103
|
+
if (bytes < 1024 * 1024)
|
|
104
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
105
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
106
|
+
}
|
|
107
|
+
export const extractFramesTool = tool({
|
|
108
|
+
description: `Extract image frames from a video file or URL.
|
|
109
|
+
Can extract a single frame at a specific timestamp, or multiple frames from a time range.
|
|
110
|
+
Requires either system ffmpeg installed or @ffmpeg/ffmpeg npm package.
|
|
111
|
+
Supports MP4, WebM, and other common video formats.
|
|
112
|
+
Free to use ā no API key needed.`,
|
|
113
|
+
args: {
|
|
114
|
+
source: tool.schema.string().describe('Video file path (absolute) or URL'),
|
|
115
|
+
at_time: tool.schema.string().optional().describe('Extract single frame at timestamp (e.g. "00:00:05" or "5")'),
|
|
116
|
+
start: tool.schema.string().optional().describe('Start time for range extraction (e.g. "00:00:02")'),
|
|
117
|
+
end: tool.schema.string().optional().describe('End time for range extraction (e.g. "00:00:10")'),
|
|
118
|
+
fps: tool.schema.number().min(0.1).max(30).optional().describe('Frames per second for range extraction (default: 1)'),
|
|
119
|
+
filename: tool.schema.string().optional().describe('Base filename prefix. Auto-generated if omitted'),
|
|
120
|
+
},
|
|
121
|
+
async execute(args, context) {
|
|
122
|
+
// Check ffmpeg availability
|
|
123
|
+
const systemFFmpeg = hasSystemFFmpeg();
|
|
124
|
+
if (!systemFFmpeg) {
|
|
125
|
+
// Try wasm ffmpeg
|
|
126
|
+
const ffmpegWasm = await loadFFmpeg();
|
|
127
|
+
if (!ffmpegWasm) {
|
|
128
|
+
return [
|
|
129
|
+
`ā FFmpeg not found!`,
|
|
130
|
+
``,
|
|
131
|
+
`This tool requires ffmpeg. Install one of:`,
|
|
132
|
+
` ⢠System: sudo apt install ffmpeg (or brew install ffmpeg)`,
|
|
133
|
+
` ⢠Node.js: npm install @ffmpeg/ffmpeg @ffmpeg/util @ffmpeg/core`,
|
|
134
|
+
].join('\n');
|
|
135
|
+
}
|
|
136
|
+
// TODO: Implement wasm extraction path (system ffmpeg is sufficient for MVP)
|
|
137
|
+
return `ā @ffmpeg/ffmpeg wasm path not yet implemented. Please install system ffmpeg: sudo apt install ffmpeg`;
|
|
138
|
+
}
|
|
139
|
+
// Ensure save directory
|
|
140
|
+
if (!fs.existsSync(SAVE_DIR)) {
|
|
141
|
+
fs.mkdirSync(SAVE_DIR, { recursive: true });
|
|
142
|
+
}
|
|
143
|
+
// Resolve source: URL ā download, path ā validate
|
|
144
|
+
let videoPath;
|
|
145
|
+
let isRemote = false;
|
|
146
|
+
if (args.source.startsWith('http://') || args.source.startsWith('https://')) {
|
|
147
|
+
isRemote = true;
|
|
148
|
+
context.metadata({ title: `š¬ Downloading video...` });
|
|
149
|
+
try {
|
|
150
|
+
videoPath = await downloadVideo(args.source);
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
return `ā Download error: ${err.message}`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
videoPath = args.source;
|
|
158
|
+
if (!fs.existsSync(videoPath)) {
|
|
159
|
+
return `ā File not found: ${videoPath}`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const baseName = args.filename
|
|
163
|
+
? args.filename.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
164
|
+
: `frame_${Date.now()}`;
|
|
165
|
+
try {
|
|
166
|
+
context.metadata({ title: `š¬ Extracting frames...` });
|
|
167
|
+
const extractedFiles = extractWithSystemFFmpeg(videoPath, SAVE_DIR, baseName, {
|
|
168
|
+
at_time: args.at_time,
|
|
169
|
+
start: args.start,
|
|
170
|
+
end: args.end,
|
|
171
|
+
fps: args.fps,
|
|
172
|
+
});
|
|
173
|
+
// Cleanup temp video if downloaded
|
|
174
|
+
if (isRemote && fs.existsSync(videoPath)) {
|
|
175
|
+
try {
|
|
176
|
+
fs.unlinkSync(videoPath);
|
|
177
|
+
}
|
|
178
|
+
catch { }
|
|
179
|
+
}
|
|
180
|
+
if (extractedFiles.length === 0) {
|
|
181
|
+
return `ā No frames extracted. Check your timestamps and video source.`;
|
|
182
|
+
}
|
|
183
|
+
// Calculate total size
|
|
184
|
+
const totalSize = extractedFiles.reduce((sum, f) => sum + fs.statSync(f).size, 0);
|
|
185
|
+
const fileList = extractedFiles.length <= 5
|
|
186
|
+
? extractedFiles.map(f => ` ${path.basename(f)}`).join('\n')
|
|
187
|
+
: [
|
|
188
|
+
...extractedFiles.slice(0, 3).map(f => ` ${path.basename(f)}`),
|
|
189
|
+
` ... and ${extractedFiles.length - 3} more`,
|
|
190
|
+
].join('\n');
|
|
191
|
+
return [
|
|
192
|
+
`š¬ Frames Extracted`,
|
|
193
|
+
`āāāāāāāāāāāāāāāāāāā`,
|
|
194
|
+
`Source: ${isRemote ? args.source : path.basename(videoPath)}`,
|
|
195
|
+
`Frames: ${extractedFiles.length}`,
|
|
196
|
+
`Directory: ${SAVE_DIR}`,
|
|
197
|
+
`Total size: ${formatFileSize(totalSize)}`,
|
|
198
|
+
`Files:`,
|
|
199
|
+
fileList,
|
|
200
|
+
``,
|
|
201
|
+
`Cost: Free (local ffmpeg)`,
|
|
202
|
+
].join('\n');
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
// Cleanup on error
|
|
206
|
+
if (isRemote && fs.existsSync(videoPath)) {
|
|
207
|
+
try {
|
|
208
|
+
fs.unlinkSync(videoPath);
|
|
209
|
+
}
|
|
210
|
+
catch { }
|
|
211
|
+
}
|
|
212
|
+
return `ā Frame extraction error: ${err.message}`;
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
});
|