opencode-pollinations-plugin 5.9.0 → 6.0.0-beta.1
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 +5 -3
- package/dist/index.js +5 -0
- package/dist/server/generate-config.js +7 -0
- package/dist/server/proxy.js +81 -12
- 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 +117 -0
- package/dist/tools/power/remove_background.d.ts +2 -0
- package/dist/tools/power/remove_background.js +115 -0
- package/package.json +5 -3
- package/dist/debug_check.js +0 -36
- package/dist/provider.d.ts +0 -1
- package/dist/provider.js +0 -135
- package/dist/provider_v1.d.ts +0 -1
- package/dist/provider_v1.js +0 -135
- package/dist/test-require.js +0 -9
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 🌸 Pollinations AI Plugin for OpenCode (v5.
|
|
1
|
+
# 🌸 Pollinations AI Plugin for OpenCode (v5.9.0)
|
|
2
2
|
|
|
3
3
|
<div align="center">
|
|
4
4
|
<img src="https://avatars.githubusercontent.com/u/88394740?s=400&v=4" alt="Pollinations.ai Logo" width="200">
|
|
@@ -134,8 +134,9 @@ OpenCode uses NPM as its registry. To publish:
|
|
|
134
134
|
|
|
135
135
|
### 1. The Basics (Free Mode)
|
|
136
136
|
Just type in the chat. You are in **Manual Mode** by default.
|
|
137
|
-
- Model: `openai` (GPT-
|
|
138
|
-
- Model: `mistral` (Mistral
|
|
137
|
+
- Model: `openai-fast` (GPT-OSS 20b)
|
|
138
|
+
- Model: `mistral` (Mistral Small 3.1)
|
|
139
|
+
- ...
|
|
139
140
|
|
|
140
141
|
### 🔑 Configuration (API Key)
|
|
141
142
|
|
|
@@ -155,6 +156,7 @@ Just type in the chat. You are in **Manual Mode** by default.
|
|
|
155
156
|
|
|
156
157
|
## 🔗 Links
|
|
157
158
|
|
|
159
|
+
- **Sign up Pollinations Beta (more and best free tiers access and paids models)**: [pollinations.ai](https://enter.pollinations.ai)
|
|
158
160
|
- **Pollinations Website**: [pollinations.ai](https://pollinations.ai)
|
|
159
161
|
- **Discord Community**: [Join us!](https://discord.gg/pollinations-ai-885844321461485618)
|
|
160
162
|
- **OpenCode Ecosystem**: [opencode.ai](https://opencode.ai/docs/ecosystem#plugins)
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { handleChatCompletion } from './server/proxy.js';
|
|
|
6
6
|
import { createToastHooks, setGlobalClient } from './server/toast.js';
|
|
7
7
|
import { createStatusHooks } from './server/status.js';
|
|
8
8
|
import { createCommandHooks } from './server/commands.js';
|
|
9
|
+
import { createToolRegistry } from './tools/index.js';
|
|
9
10
|
import { createRequire } from 'module';
|
|
10
11
|
const require = createRequire(import.meta.url);
|
|
11
12
|
const LOG_FILE = '/tmp/opencode_pollinations_v4.log';
|
|
@@ -84,7 +85,11 @@ export const PollinationsPlugin = async (ctx) => {
|
|
|
84
85
|
setGlobalClient(ctx.client);
|
|
85
86
|
const toastHooks = createToastHooks(ctx.client);
|
|
86
87
|
const commandHooks = createCommandHooks();
|
|
88
|
+
// Build tool registry (conditional on API key presence)
|
|
89
|
+
const toolRegistry = createToolRegistry();
|
|
90
|
+
log(`[Tools] ${Object.keys(toolRegistry).length} tools registered`);
|
|
87
91
|
return {
|
|
92
|
+
tool: toolRegistry,
|
|
88
93
|
async config(config) {
|
|
89
94
|
log("[Hook] config() called");
|
|
90
95
|
// STARTUP only - No complex hot reload logic
|
|
@@ -214,6 +214,13 @@ function mapModel(raw, prefix, namePrefix) {
|
|
|
214
214
|
// Also keep variant just in case
|
|
215
215
|
modelObj.variants.bedrock_safe = { options: { maxTokens: 8000 } };
|
|
216
216
|
}
|
|
217
|
+
// BEDROCK/ENTERPRISE LIMITS (Chickytutor only)
|
|
218
|
+
if (rawId.includes('chickytutor')) {
|
|
219
|
+
modelObj.limit = {
|
|
220
|
+
output: 8192,
|
|
221
|
+
context: 128000
|
|
222
|
+
};
|
|
223
|
+
}
|
|
217
224
|
// NOMNOM FIX: User reported error if max_tokens is missing.
|
|
218
225
|
// Also it is a 'Gemini-scrape' model, so we treat it similar to Gemini but with strict limit.
|
|
219
226
|
if (rawId.includes('nomnom') || rawId.includes('scrape')) {
|
package/dist/server/proxy.js
CHANGED
|
@@ -78,6 +78,23 @@ function dereferenceSchema(schema, rootDefs) {
|
|
|
78
78
|
schema.description = (schema.description || "") + " [Ref Failed]";
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
+
// VERTEX FIX: 'const' not supported -> convert to 'enum'
|
|
82
|
+
if (schema.const !== undefined) {
|
|
83
|
+
schema.enum = [schema.const];
|
|
84
|
+
delete schema.const;
|
|
85
|
+
}
|
|
86
|
+
// VERTEX FIX: 'anyOf' must be exclusive (no other siblings)
|
|
87
|
+
if (schema.anyOf || schema.oneOf) {
|
|
88
|
+
// Vertex demands strict exclusivity.
|
|
89
|
+
// We keep 'definitions'/'$defs' if present at root (though unlikely here)
|
|
90
|
+
// But for a property node, we must strip EVERYTHING else.
|
|
91
|
+
const keys = Object.keys(schema);
|
|
92
|
+
keys.forEach(k => {
|
|
93
|
+
if (k !== 'anyOf' && k !== 'oneOf' && k !== 'definitions' && k !== '$defs') {
|
|
94
|
+
delete schema[k];
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
81
98
|
if (schema.properties) {
|
|
82
99
|
for (const key in schema.properties) {
|
|
83
100
|
schema.properties[key] = dereferenceSchema(schema.properties[key], rootDefs);
|
|
@@ -86,6 +103,15 @@ function dereferenceSchema(schema, rootDefs) {
|
|
|
86
103
|
if (schema.items) {
|
|
87
104
|
schema.items = dereferenceSchema(schema.items, rootDefs);
|
|
88
105
|
}
|
|
106
|
+
if (schema.anyOf) {
|
|
107
|
+
schema.anyOf = schema.anyOf.map((s) => dereferenceSchema(s, rootDefs));
|
|
108
|
+
}
|
|
109
|
+
if (schema.oneOf) {
|
|
110
|
+
schema.oneOf = schema.oneOf.map((s) => dereferenceSchema(s, rootDefs));
|
|
111
|
+
}
|
|
112
|
+
if (schema.allOf) {
|
|
113
|
+
schema.allOf = schema.allOf.map((s) => dereferenceSchema(s, rootDefs));
|
|
114
|
+
}
|
|
89
115
|
if (schema.optional !== undefined)
|
|
90
116
|
delete schema.optional;
|
|
91
117
|
if (schema.title)
|
|
@@ -107,6 +133,38 @@ function sanitizeToolsForVertex(tools) {
|
|
|
107
133
|
return tool;
|
|
108
134
|
});
|
|
109
135
|
}
|
|
136
|
+
function sanitizeToolsForBedrock(tools) {
|
|
137
|
+
return tools.map(tool => {
|
|
138
|
+
if (tool.function) {
|
|
139
|
+
if (!tool.function.description || tool.function.description.length === 0) {
|
|
140
|
+
tool.function.description = " "; // Force non-empty string
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return tool;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function sanitizeSchemaForKimi(schema) {
|
|
147
|
+
if (!schema || typeof schema !== 'object')
|
|
148
|
+
return schema;
|
|
149
|
+
// Kimi Fixes
|
|
150
|
+
if (schema.title)
|
|
151
|
+
delete schema.title;
|
|
152
|
+
// Fix empty objects "{}" which Kimi hates.
|
|
153
|
+
// If it's an empty object without type, assume string or object?
|
|
154
|
+
// Often happens with "additionalProperties: {}"
|
|
155
|
+
if (Object.keys(schema).length === 0) {
|
|
156
|
+
schema.type = "string"; // Fallback to safe type
|
|
157
|
+
schema.description = "Any value";
|
|
158
|
+
}
|
|
159
|
+
if (schema.properties) {
|
|
160
|
+
for (const key in schema.properties) {
|
|
161
|
+
schema.properties[key] = sanitizeSchemaForKimi(schema.properties[key]);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (schema.items)
|
|
165
|
+
sanitizeSchemaForKimi(schema.items);
|
|
166
|
+
return schema;
|
|
167
|
+
}
|
|
110
168
|
function truncateTools(tools, limit = 120) {
|
|
111
169
|
if (!tools || tools.length <= limit)
|
|
112
170
|
return tools;
|
|
@@ -114,12 +172,16 @@ function truncateTools(tools, limit = 120) {
|
|
|
114
172
|
}
|
|
115
173
|
const MAX_RETRIES = 3;
|
|
116
174
|
const RETRY_DELAY_MS = 1000;
|
|
175
|
+
const FETCH_TIMEOUT_MS = 600000; // 10 Minutes global timeout
|
|
117
176
|
function sleep(ms) {
|
|
118
177
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
119
178
|
}
|
|
120
179
|
async function fetchWithRetry(url, options, retries = MAX_RETRIES) {
|
|
121
180
|
try {
|
|
122
|
-
const
|
|
181
|
+
const controller = new AbortController();
|
|
182
|
+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
183
|
+
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
184
|
+
clearTimeout(timeoutId);
|
|
123
185
|
if (response.ok)
|
|
124
186
|
return response;
|
|
125
187
|
if (response.status === 404 || response.status === 401 || response.status === 400) {
|
|
@@ -380,17 +442,24 @@ export async function handleChatCompletion(req, res, bodyRaw) {
|
|
|
380
442
|
// LOGIC BLOCK: MODEL SPECIFIC ADAPTATIONS
|
|
381
443
|
// =========================================================
|
|
382
444
|
if (proxyBody.tools && Array.isArray(proxyBody.tools) && proxyBody.tools.length > 0) {
|
|
383
|
-
// B0. KIMI / MOONSHOT SURGICAL FIX
|
|
384
|
-
// Tools are ENABLED. We rely on penalties and strict stops to fight loops.
|
|
445
|
+
// B0. KIMI / MOONSHOT SURGICAL FIX
|
|
385
446
|
if (actualModel.includes("kimi") || actualModel.includes("moonshot")) {
|
|
386
|
-
log(`[Proxy] Kimi: Tools ENABLED. Applying penalties/stops.`);
|
|
447
|
+
log(`[Proxy] Kimi: Tools ENABLED. Applying penalties/stops/sanitization.`);
|
|
387
448
|
proxyBody.frequency_penalty = 1.1;
|
|
388
449
|
proxyBody.presence_penalty = 0.4;
|
|
389
450
|
proxyBody.stop = ["<|endoftext|>", "User:", "\nUser", "User :"];
|
|
451
|
+
// KIMI FIX: Remove 'title' from schema
|
|
452
|
+
proxyBody.tools = proxyBody.tools.map((t) => {
|
|
453
|
+
if (t.function && t.function.parameters) {
|
|
454
|
+
t.function.parameters = sanitizeSchemaForKimi(t.function.parameters);
|
|
455
|
+
}
|
|
456
|
+
return t;
|
|
457
|
+
});
|
|
390
458
|
}
|
|
391
|
-
// A. AZURE/OPENAI FIXES
|
|
392
|
-
if (actualModel.includes("gpt") || actualModel.includes("openai") || actualModel.includes("azure")) {
|
|
393
|
-
|
|
459
|
+
// A. AZURE/OPENAI FIXES + MIDJOURNEY + GROK
|
|
460
|
+
if (actualModel.includes("gpt") || actualModel.includes("openai") || actualModel.includes("azure") || actualModel.includes("midijourney") || actualModel.includes("grok")) {
|
|
461
|
+
const limit = (actualModel.includes("midijourney") || actualModel.includes("grok")) ? 128 : 120;
|
|
462
|
+
proxyBody.tools = truncateTools(proxyBody.tools, limit);
|
|
394
463
|
if (proxyBody.messages) {
|
|
395
464
|
proxyBody.messages.forEach((m) => {
|
|
396
465
|
if (m.tool_calls) {
|
|
@@ -405,6 +474,11 @@ export async function handleChatCompletion(req, res, bodyRaw) {
|
|
|
405
474
|
});
|
|
406
475
|
}
|
|
407
476
|
}
|
|
477
|
+
// BEDROCK FIX (Claude / Nova / ChickyTutor)
|
|
478
|
+
if (actualModel.includes("claude") || actualModel.includes("nova") || actualModel.includes("bedrock") || actualModel.includes("chickytutor")) {
|
|
479
|
+
log(`[Proxy] Bedrock: Sanitizing tools description.`);
|
|
480
|
+
proxyBody.tools = sanitizeToolsForBedrock(proxyBody.tools);
|
|
481
|
+
}
|
|
408
482
|
// B1. NOMNOM SPECIAL (Disable Grounding, KEEP Search Tool)
|
|
409
483
|
if (actualModel === "nomnom") {
|
|
410
484
|
proxyBody.tools_config = { google_search_retrieval: { disable: true } };
|
|
@@ -412,12 +486,7 @@ export async function handleChatCompletion(req, res, bodyRaw) {
|
|
|
412
486
|
proxyBody.tools = sanitizeToolsForVertex(proxyBody.tools || []);
|
|
413
487
|
log(`[Proxy] Nomnom Fix: Grounding Disabled, Search Tool KEPT.`);
|
|
414
488
|
}
|
|
415
|
-
// B2. GEMINI FREE / FAST (CRASH FIX: STRICT SANITIZATION)
|
|
416
|
-
// Restore Tools but REMOVE conflicting ones (Search)
|
|
417
|
-
// B. GEMINI UNIFIED FIX (Free, Fast, Pro, Enterprise, Legacy)
|
|
418
|
-
// Handles: "tools" vs "grounding" conflicts, and "infinite loops" via Stop Sequences.
|
|
419
489
|
// B. GEMINI UNIFIED FIX (Free, Fast, Pro, Enterprise, Legacy)
|
|
420
|
-
// Fixes "Multiple tools" error (Vertex) and "JSON body validation failed" (v5.3.5 regression)
|
|
421
490
|
else if (actualModel.includes("gemini")) {
|
|
422
491
|
let hasFunctions = false;
|
|
423
492
|
if (proxyBody.tools && Array.isArray(proxyBody.tools)) {
|
|
@@ -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
|
+
}
|