opc-agent 4.0.0 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +404 -80
  2. package/README.zh-CN.md +82 -0
  3. package/dist/cli/chat.d.ts +2 -0
  4. package/dist/cli/chat.js +134 -0
  5. package/dist/cli/setup.d.ts +4 -0
  6. package/dist/cli/setup.js +303 -0
  7. package/dist/cli.js +106 -6
  8. package/dist/hub/brain-seed.d.ts +14 -0
  9. package/dist/hub/brain-seed.js +77 -0
  10. package/dist/hub/client.d.ts +25 -0
  11. package/dist/hub/client.js +44 -0
  12. package/dist/index.d.ts +4 -2
  13. package/dist/index.js +12 -3
  14. package/dist/providers/index.d.ts +1 -1
  15. package/dist/providers/index.js +74 -1
  16. package/dist/scheduler/cron-engine.d.ts +41 -0
  17. package/dist/scheduler/cron-engine.js +200 -0
  18. package/dist/scheduler/index.d.ts +3 -0
  19. package/dist/scheduler/index.js +7 -0
  20. package/dist/skills/builtin/index.d.ts +6 -0
  21. package/dist/skills/builtin/index.js +402 -0
  22. package/dist/skills/marketplace.d.ts +30 -0
  23. package/dist/skills/marketplace.js +142 -0
  24. package/dist/skills/types.d.ts +34 -0
  25. package/dist/skills/types.js +16 -0
  26. package/dist/studio/server.d.ts +25 -0
  27. package/dist/studio/server.js +780 -0
  28. package/dist/studio/templates-data.d.ts +21 -0
  29. package/dist/studio/templates-data.js +148 -0
  30. package/dist/studio-ui/index.html +2502 -1073
  31. package/dist/tools/builtin/index.d.ts +1 -0
  32. package/dist/tools/builtin/index.js +7 -2
  33. package/dist/tools/builtin/web-search.d.ts +9 -0
  34. package/dist/tools/builtin/web-search.js +150 -0
  35. package/dist/tools/document-processor.d.ts +39 -0
  36. package/dist/tools/document-processor.js +188 -0
  37. package/dist/tools/image-generator.d.ts +42 -0
  38. package/dist/tools/image-generator.js +136 -0
  39. package/dist/tools/web-scraper.d.ts +20 -0
  40. package/dist/tools/web-scraper.js +148 -0
  41. package/dist/tools/web-search.d.ts +51 -0
  42. package/dist/tools/web-search.js +152 -0
  43. package/install.ps1 +154 -0
  44. package/install.sh +164 -0
  45. package/package.json +63 -52
  46. package/src/cli/chat.ts +99 -0
  47. package/src/cli/setup.ts +314 -0
  48. package/src/cli.ts +108 -6
  49. package/src/hub/brain-seed.ts +54 -0
  50. package/src/hub/client.ts +60 -0
  51. package/src/index.ts +4 -2
  52. package/src/providers/index.ts +80 -1
  53. package/src/scheduler/cron-engine.ts +191 -0
  54. package/src/scheduler/index.ts +2 -0
  55. package/src/skills/builtin/index.ts +408 -0
  56. package/src/skills/marketplace.ts +113 -0
  57. package/src/skills/types.ts +42 -0
  58. package/src/studio/server.ts +1591 -791
  59. package/src/studio/templates-data.ts +178 -0
  60. package/src/studio-ui/index.html +2502 -1073
  61. package/src/tools/builtin/index.ts +37 -35
  62. package/src/tools/builtin/web-search.ts +126 -0
  63. package/src/tools/document-processor.ts +213 -0
  64. package/src/tools/image-generator.ts +150 -0
  65. package/src/tools/web-scraper.ts +179 -0
  66. package/src/tools/web-search.ts +180 -0
  67. package/tests/cron-engine.test.ts +101 -0
  68. package/tests/document-processor.test.ts +69 -0
  69. package/tests/e2e-nocode.test.ts +442 -0
  70. package/tests/image-generator.test.ts +84 -0
  71. package/tests/settings-api.test.ts +148 -0
  72. package/tests/setup.test.ts +73 -0
  73. package/tests/studio.test.ts +402 -229
  74. package/tests/voice-interaction.test.ts +38 -0
  75. package/tests/web-search.test.ts +155 -0
@@ -1,35 +1,37 @@
1
- import type { MCPTool } from '../mcp';
2
- import { fileTool } from './file';
3
- import { webTool } from './web';
4
- import { shellTool } from './shell';
5
- import { datetimeTool } from './datetime';
6
- import { browserTools, BrowserManager, browserManager } from './browser';
7
- import { visionTools, visionAnalyzeTool, visionExtractTextTool, visionCompareTool } from './vision';
8
- import { rlTools } from './rl-tools';
9
- import { homeAssistantTools } from './home-assistant';
10
-
11
- export { fileTool, webTool, shellTool, datetimeTool, browserTools, BrowserManager, browserManager };
12
- export { visionTools, visionAnalyzeTool, visionExtractTextTool, visionCompareTool };
13
- export { rlTools } from './rl-tools';
14
- export { homeAssistantTools, configureHomeAssistant } from './home-assistant';
15
-
16
- const ALL_BUILTIN_TOOLS: MCPTool[] = [fileTool, webTool, shellTool, datetimeTool, ...browserTools, ...visionTools, ...rlTools, ...homeAssistantTools];
17
-
18
- const BUILTIN_MAP = new Map<string, MCPTool>(
19
- ALL_BUILTIN_TOOLS.map(t => [t.name, t])
20
- );
21
-
22
- /**
23
- * Get all built-in tools.
24
- */
25
- export function getBuiltinTools(): MCPTool[] {
26
- return [...ALL_BUILTIN_TOOLS];
27
- }
28
-
29
- /**
30
- * Get specific built-in tools by name. If no names given, returns all.
31
- */
32
- export function getBuiltinToolsByName(names?: string[]): MCPTool[] {
33
- if (!names || names.length === 0) return getBuiltinTools();
34
- return names.map(n => BUILTIN_MAP.get(n)).filter((t): t is MCPTool => !!t);
35
- }
1
+ import type { MCPTool } from '../mcp';
2
+ import { fileTool } from './file';
3
+ import { webTool } from './web';
4
+ import { shellTool } from './shell';
5
+ import { datetimeTool } from './datetime';
6
+ import { browserTools, BrowserManager, browserManager } from './browser';
7
+ import { visionTools, visionAnalyzeTool, visionExtractTextTool, visionCompareTool } from './vision';
8
+ import { rlTools } from './rl-tools';
9
+ import { homeAssistantTools } from './home-assistant';
10
+ import { webSearchTools, webSearchTool, webReadTool } from './web-search';
11
+
12
+ export { fileTool, webTool, shellTool, datetimeTool, browserTools, BrowserManager, browserManager };
13
+ export { visionTools, visionAnalyzeTool, visionExtractTextTool, visionCompareTool };
14
+ export { rlTools } from './rl-tools';
15
+ export { homeAssistantTools, configureHomeAssistant } from './home-assistant';
16
+ export { webSearchTools, webSearchTool, webReadTool } from './web-search';
17
+
18
+ const ALL_BUILTIN_TOOLS: MCPTool[] = [fileTool, webTool, shellTool, datetimeTool, ...browserTools, ...visionTools, ...rlTools, ...homeAssistantTools, ...webSearchTools];
19
+
20
+ const BUILTIN_MAP = new Map<string, MCPTool>(
21
+ ALL_BUILTIN_TOOLS.map(t => [t.name, t])
22
+ );
23
+
24
+ /**
25
+ * Get all built-in tools.
26
+ */
27
+ export function getBuiltinTools(): MCPTool[] {
28
+ return [...ALL_BUILTIN_TOOLS];
29
+ }
30
+
31
+ /**
32
+ * Get specific built-in tools by name. If no names given, returns all.
33
+ */
34
+ export function getBuiltinToolsByName(names?: string[]): MCPTool[] {
35
+ if (!names || names.length === 0) return getBuiltinTools();
36
+ return names.map(n => BUILTIN_MAP.get(n)).filter((t): t is MCPTool => !!t);
37
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Web Search & Read Built-in Tools - v0.10.0
3
+ * Registers web_search and web_read as agent-callable tools.
4
+ */
5
+
6
+ import type { MCPTool, MCPToolResult } from '../mcp';
7
+ import { webSearch, DEFAULT_SEARCH_CONFIG, type WebSearchConfig, type SearchEngine } from '../web-search';
8
+ import { scrapeUrl } from '../web-scraper';
9
+ import { existsSync, readFileSync } from 'fs';
10
+ import { join } from 'path';
11
+ import * as os from 'os';
12
+
13
+ function loadSearchConfig(): WebSearchConfig {
14
+ try {
15
+ const cfgPath = join(os.homedir(), '.opc', 'config.json');
16
+ if (existsSync(cfgPath)) {
17
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
18
+ if (cfg.webSearch) {
19
+ return { ...DEFAULT_SEARCH_CONFIG, ...cfg.webSearch };
20
+ }
21
+ }
22
+ } catch { /* ignore */ }
23
+ return DEFAULT_SEARCH_CONFIG;
24
+ }
25
+
26
+ export const webSearchTool: MCPTool = {
27
+ name: 'web_search',
28
+ description: 'Search the internet for information. Returns titles, URLs, and snippets from search results. Use when you need current information or facts you\'re unsure about.',
29
+ inputSchema: {
30
+ type: 'object',
31
+ properties: {
32
+ query: {
33
+ type: 'string',
34
+ description: 'Search query string',
35
+ },
36
+ maxResults: {
37
+ type: 'number',
38
+ description: 'Maximum number of results to return (default: 5)',
39
+ },
40
+ engine: {
41
+ type: 'string',
42
+ enum: ['duckduckgo', 'brave', 'searxng', 'google'],
43
+ description: 'Search engine to use (default: configured engine)',
44
+ },
45
+ },
46
+ required: ['query'],
47
+ },
48
+
49
+ async execute(input: Record<string, unknown>): Promise<MCPToolResult> {
50
+ const query = String(input.query ?? '');
51
+ if (!query.trim()) {
52
+ return { content: 'Error: empty search query', isError: true };
53
+ }
54
+
55
+ const config = loadSearchConfig();
56
+ if (!config.enabled) {
57
+ return { content: 'Web search is disabled in settings.', isError: true };
58
+ }
59
+
60
+ try {
61
+ const results = await webSearch(query, config, {
62
+ maxResults: (input.maxResults as number) || 5,
63
+ engine: input.engine as SearchEngine | undefined,
64
+ });
65
+
66
+ if (results.length === 0) {
67
+ return { content: `No results found for: ${query}` };
68
+ }
69
+
70
+ const formatted = results.map((r, i) =>
71
+ `${i + 1}. **${r.title}**\n ${r.url}\n ${r.snippet}`
72
+ ).join('\n\n');
73
+
74
+ return {
75
+ content: `Search results for "${query}":\n\n${formatted}`,
76
+ metadata: { resultCount: results.length, query },
77
+ };
78
+ } catch (err) {
79
+ return {
80
+ content: `Search error: ${err instanceof Error ? err.message : String(err)}`,
81
+ isError: true,
82
+ };
83
+ }
84
+ },
85
+ };
86
+
87
+ export const webReadTool: MCPTool = {
88
+ name: 'web_read',
89
+ description: 'Read and extract the main content from a web page URL. Returns clean markdown text. Use to get detailed information from a specific page.',
90
+ inputSchema: {
91
+ type: 'object',
92
+ properties: {
93
+ url: {
94
+ type: 'string',
95
+ description: 'URL of the web page to read',
96
+ },
97
+ maxLength: {
98
+ type: 'number',
99
+ description: 'Maximum content length in characters (default: 5000)',
100
+ },
101
+ },
102
+ required: ['url'],
103
+ },
104
+
105
+ async execute(input: Record<string, unknown>): Promise<MCPToolResult> {
106
+ const url = String(input.url ?? '');
107
+ if (!url.trim()) {
108
+ return { content: 'Error: empty URL', isError: true };
109
+ }
110
+
111
+ try {
112
+ const result = await scrapeUrl(url, (input.maxLength as number) || 5000);
113
+ return {
114
+ content: `# ${result.title}\n\nSource: ${result.url}\nWords: ${result.wordCount}\n\n---\n\n${result.content}`,
115
+ metadata: { title: result.title, url: result.url, wordCount: result.wordCount },
116
+ };
117
+ } catch (err) {
118
+ return {
119
+ content: `Scrape error: ${err instanceof Error ? err.message : String(err)}`,
120
+ isError: true,
121
+ };
122
+ }
123
+ },
124
+ };
125
+
126
+ export const webSearchTools: MCPTool[] = [webSearchTool, webReadTool];
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Document Processor - Parse and chunk documents for knowledge learning
3
+ * Supports: PDF, TXT, MD, DOCX, CSV, JSON
4
+ */
5
+
6
+ export interface DocumentChunk {
7
+ title: string;
8
+ content: string;
9
+ metadata: {
10
+ source: string;
11
+ format: string;
12
+ chunkIndex: number;
13
+ totalChunks?: number;
14
+ page?: number;
15
+ };
16
+ }
17
+
18
+ export interface ProcessedDocument {
19
+ id: string;
20
+ filename: string;
21
+ format: string;
22
+ size: number;
23
+ chunks: DocumentChunk[];
24
+ processedAt: string;
25
+ }
26
+
27
+ const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
28
+ const CHUNK_TARGET_CHARS = 2000; // ~500 tokens
29
+ const CHUNK_MAX_CHARS = 4000; // ~1000 tokens
30
+
31
+ export class DocumentProcessor {
32
+ /**
33
+ * Process a file buffer into chunks
34
+ */
35
+ async process(buffer: Buffer, filename: string): Promise<ProcessedDocument> {
36
+ if (buffer.length > MAX_FILE_SIZE) {
37
+ throw new Error(`File too large: ${(buffer.length / 1024 / 1024).toFixed(1)}MB (max 50MB)`);
38
+ }
39
+
40
+ const ext = filename.split('.').pop()?.toLowerCase() || '';
41
+ let rawText: string;
42
+
43
+ switch (ext) {
44
+ case 'pdf':
45
+ rawText = await this.parsePDF(buffer);
46
+ break;
47
+ case 'docx':
48
+ rawText = await this.parseDOCX(buffer);
49
+ break;
50
+ case 'csv':
51
+ rawText = this.parseCSV(buffer.toString('utf-8'));
52
+ break;
53
+ case 'json':
54
+ rawText = this.parseJSON(buffer.toString('utf-8'));
55
+ break;
56
+ case 'txt':
57
+ case 'md':
58
+ case 'markdown':
59
+ rawText = buffer.toString('utf-8');
60
+ break;
61
+ default:
62
+ // Try as plain text
63
+ rawText = buffer.toString('utf-8');
64
+ }
65
+
66
+ const chunks = this.chunkText(rawText, filename, ext);
67
+
68
+ return {
69
+ id: `doc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
70
+ filename,
71
+ format: ext,
72
+ size: buffer.length,
73
+ chunks,
74
+ processedAt: new Date().toISOString(),
75
+ };
76
+ }
77
+
78
+ private async parsePDF(buffer: Buffer): Promise<string> {
79
+ try {
80
+ const pdfParse = require('pdf-parse');
81
+ const data = await pdfParse(buffer);
82
+ return data.text || '';
83
+ } catch (e: any) {
84
+ throw new Error(`PDF parse failed: ${e.message}`);
85
+ }
86
+ }
87
+
88
+ private async parseDOCX(buffer: Buffer): Promise<string> {
89
+ try {
90
+ const mammoth = require('mammoth');
91
+ const result = await mammoth.extractRawText({ buffer });
92
+ return result.value || '';
93
+ } catch (e: any) {
94
+ throw new Error(`DOCX parse failed: ${e.message}`);
95
+ }
96
+ }
97
+
98
+ private parseCSV(text: string): string {
99
+ const lines = text.split('\n').filter(l => l.trim());
100
+ if (lines.length === 0) return '';
101
+
102
+ const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
103
+ const rows = lines.slice(1);
104
+
105
+ // Convert CSV to readable text
106
+ return rows.map((row, i) => {
107
+ const values = this.parseCSVLine(row);
108
+ const pairs = headers.map((h, j) => `${h}: ${values[j] || ''}`);
109
+ return `Record ${i + 1}:\n${pairs.join('\n')}`;
110
+ }).join('\n\n');
111
+ }
112
+
113
+ private parseCSVLine(line: string): string[] {
114
+ const result: string[] = [];
115
+ let current = '';
116
+ let inQuotes = false;
117
+ for (const ch of line) {
118
+ if (ch === '"') { inQuotes = !inQuotes; }
119
+ else if (ch === ',' && !inQuotes) { result.push(current.trim()); current = ''; }
120
+ else { current += ch; }
121
+ }
122
+ result.push(current.trim());
123
+ return result;
124
+ }
125
+
126
+ private parseJSON(text: string): string {
127
+ try {
128
+ const data = JSON.parse(text);
129
+ if (Array.isArray(data)) {
130
+ return data.map((item, i) => `Item ${i + 1}:\n${JSON.stringify(item, null, 2)}`).join('\n\n');
131
+ }
132
+ return JSON.stringify(data, null, 2);
133
+ } catch {
134
+ return text;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Smart chunking: split by headings/paragraphs, respecting size limits
140
+ */
141
+ private chunkText(text: string, filename: string, format: string): DocumentChunk[] {
142
+ if (!text.trim()) return [];
143
+
144
+ // Split by markdown headings or double newlines
145
+ const sections = text.split(/\n(?=#{1,3}\s)|(?:\n\s*\n)/).filter(s => s.trim());
146
+ const chunks: DocumentChunk[] = [];
147
+ let currentChunk = '';
148
+ let currentTitle = filename;
149
+
150
+ for (const section of sections) {
151
+ const headingMatch = section.match(/^(#{1,3})\s+(.+)/);
152
+ if (headingMatch) {
153
+ currentTitle = headingMatch[2].trim();
154
+ }
155
+
156
+ if (currentChunk.length + section.length > CHUNK_MAX_CHARS && currentChunk.length > 0) {
157
+ chunks.push({
158
+ title: currentTitle,
159
+ content: currentChunk.trim(),
160
+ metadata: { source: filename, format, chunkIndex: chunks.length },
161
+ });
162
+ currentChunk = '';
163
+ }
164
+
165
+ currentChunk += section + '\n\n';
166
+
167
+ if (currentChunk.length >= CHUNK_TARGET_CHARS) {
168
+ chunks.push({
169
+ title: currentTitle,
170
+ content: currentChunk.trim(),
171
+ metadata: { source: filename, format, chunkIndex: chunks.length },
172
+ });
173
+ currentChunk = '';
174
+ }
175
+ }
176
+
177
+ if (currentChunk.trim()) {
178
+ chunks.push({
179
+ title: currentTitle,
180
+ content: currentChunk.trim(),
181
+ metadata: { source: filename, format, chunkIndex: chunks.length },
182
+ });
183
+ }
184
+
185
+ // If we got no chunks from section splitting (e.g. dense text), force-split
186
+ if (chunks.length === 0 && text.trim()) {
187
+ const words = text.split(/\s+/);
188
+ let buf = '';
189
+ for (const w of words) {
190
+ if (buf.length + w.length + 1 > CHUNK_MAX_CHARS && buf) {
191
+ chunks.push({
192
+ title: filename,
193
+ content: buf.trim(),
194
+ metadata: { source: filename, format, chunkIndex: chunks.length },
195
+ });
196
+ buf = '';
197
+ }
198
+ buf += w + ' ';
199
+ }
200
+ if (buf.trim()) {
201
+ chunks.push({
202
+ title: filename,
203
+ content: buf.trim(),
204
+ metadata: { source: filename, format, chunkIndex: chunks.length },
205
+ });
206
+ }
207
+ }
208
+
209
+ // Set totalChunks
210
+ for (const c of chunks) c.metadata.totalChunks = chunks.length;
211
+ return chunks;
212
+ }
213
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Image Generator — multi-backend image generation tool.
3
+ * Supports DALL·E (OpenAI), Stable Diffusion (local), and Replicate.
4
+ */
5
+
6
+ export interface ImageGenConfig {
7
+ provider?: 'dalle' | 'stable-diffusion' | 'replicate';
8
+ openaiApiKey?: string;
9
+ replicateApiKey?: string;
10
+ sdApiUrl?: string;
11
+ defaultModel?: string;
12
+ defaultSize?: string;
13
+ }
14
+
15
+ export interface ImageGenResult {
16
+ success: boolean;
17
+ url?: string;
18
+ base64?: string;
19
+ error?: string;
20
+ provider: string;
21
+ }
22
+
23
+ export class ImageGenerator {
24
+ private config: ImageGenConfig;
25
+
26
+ constructor(config?: ImageGenConfig) {
27
+ this.config = {
28
+ provider: config?.provider,
29
+ openaiApiKey: config?.openaiApiKey || process.env.OPENAI_API_KEY,
30
+ replicateApiKey: config?.replicateApiKey || process.env.REPLICATE_API_TOKEN,
31
+ sdApiUrl: config?.sdApiUrl || process.env.SD_API_URL,
32
+ defaultModel: config?.defaultModel || 'dall-e-3',
33
+ defaultSize: config?.defaultSize || '1024x1024',
34
+ };
35
+ }
36
+
37
+ /** Auto-detect best available provider */
38
+ detectProvider(): string | null {
39
+ if (this.config.openaiApiKey) return 'dalle';
40
+ if (this.config.sdApiUrl) return 'stable-diffusion';
41
+ if (this.config.replicateApiKey) return 'replicate';
42
+ return null;
43
+ }
44
+
45
+ /** Get configuration status for the settings UI */
46
+ getStatus(): { configured: boolean; providers: { name: string; configured: boolean }[] } {
47
+ return {
48
+ configured: !!this.detectProvider(),
49
+ providers: [
50
+ { name: 'dalle', configured: !!this.config.openaiApiKey },
51
+ { name: 'stable-diffusion', configured: !!this.config.sdApiUrl },
52
+ { name: 'replicate', configured: !!this.config.replicateApiKey },
53
+ ],
54
+ };
55
+ }
56
+
57
+ async generate(prompt: string, options?: { provider?: string; size?: string; model?: string }): Promise<ImageGenResult> {
58
+ const provider = options?.provider || this.config.provider || this.detectProvider();
59
+ if (!provider) {
60
+ return { success: false, error: 'No image generation provider configured. Please set OPENAI_API_KEY, SD_API_URL, or REPLICATE_API_TOKEN.', provider: 'none' };
61
+ }
62
+
63
+ switch (provider) {
64
+ case 'dalle': return this.generateDalle(prompt, options);
65
+ case 'stable-diffusion': return this.generateSD(prompt, options);
66
+ case 'replicate': return this.generateReplicate(prompt, options);
67
+ default: return { success: false, error: `Unknown provider: ${provider}`, provider };
68
+ }
69
+ }
70
+
71
+ private async generateDalle(prompt: string, options?: { size?: string; model?: string }): Promise<ImageGenResult> {
72
+ const apiKey = this.config.openaiApiKey;
73
+ if (!apiKey) return { success: false, error: 'OPENAI_API_KEY not configured', provider: 'dalle' };
74
+
75
+ try {
76
+ const res = await fetch('https://api.openai.com/v1/images/generations', {
77
+ method: 'POST',
78
+ headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
79
+ body: JSON.stringify({
80
+ model: options?.model || this.config.defaultModel || 'dall-e-3',
81
+ prompt,
82
+ size: options?.size || this.config.defaultSize || '1024x1024',
83
+ n: 1,
84
+ }),
85
+ });
86
+ const data = await res.json() as { data?: Array<{ url: string }>; error?: { message: string } };
87
+ if (data.error) return { success: false, error: data.error.message, provider: 'dalle' };
88
+ const url = data.data?.[0]?.url;
89
+ return url ? { success: true, url, provider: 'dalle' } : { success: false, error: 'No image returned', provider: 'dalle' };
90
+ } catch (err) {
91
+ return { success: false, error: (err as Error).message, provider: 'dalle' };
92
+ }
93
+ }
94
+
95
+ private async generateSD(prompt: string, options?: { size?: string }): Promise<ImageGenResult> {
96
+ const apiUrl = this.config.sdApiUrl;
97
+ if (!apiUrl) return { success: false, error: 'SD_API_URL not configured', provider: 'stable-diffusion' };
98
+
99
+ try {
100
+ const [w, h] = (options?.size || '1024x1024').split('x').map(Number);
101
+ const res = await fetch(`${apiUrl}/sdapi/v1/txt2img`, {
102
+ method: 'POST',
103
+ headers: { 'Content-Type': 'application/json' },
104
+ body: JSON.stringify({ prompt, width: w || 1024, height: h || 1024 }),
105
+ });
106
+ const data = await res.json() as { images?: string[] };
107
+ if (data.images?.length) {
108
+ return { success: true, base64: data.images[0], provider: 'stable-diffusion' };
109
+ }
110
+ return { success: false, error: 'No image generated', provider: 'stable-diffusion' };
111
+ } catch (err) {
112
+ return { success: false, error: (err as Error).message, provider: 'stable-diffusion' };
113
+ }
114
+ }
115
+
116
+ private async generateReplicate(prompt: string, _options?: { model?: string }): Promise<ImageGenResult> {
117
+ const apiKey = this.config.replicateApiKey;
118
+ if (!apiKey) return { success: false, error: 'REPLICATE_API_TOKEN not configured', provider: 'replicate' };
119
+
120
+ try {
121
+ const res = await fetch('https://api.replicate.com/v1/predictions', {
122
+ method: 'POST',
123
+ headers: { 'Authorization': `Token ${apiKey}`, 'Content-Type': 'application/json' },
124
+ body: JSON.stringify({
125
+ version: 'stability-ai/sdxl:39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b',
126
+ input: { prompt },
127
+ }),
128
+ });
129
+ const prediction = await res.json() as { id: string; urls?: { get: string }; error?: string };
130
+ if (prediction.error) return { success: false, error: prediction.error, provider: 'replicate' };
131
+
132
+ // Poll for completion (max 60s)
133
+ const getUrl = prediction.urls?.get || `https://api.replicate.com/v1/predictions/${prediction.id}`;
134
+ for (let i = 0; i < 30; i++) {
135
+ await new Promise(r => setTimeout(r, 2000));
136
+ const poll = await fetch(getUrl, { headers: { 'Authorization': `Token ${apiKey}` } });
137
+ const result = await poll.json() as { status: string; output?: string[]; error?: string };
138
+ if (result.status === 'succeeded' && result.output?.length) {
139
+ return { success: true, url: result.output[0], provider: 'replicate' };
140
+ }
141
+ if (result.status === 'failed') {
142
+ return { success: false, error: result.error || 'Generation failed', provider: 'replicate' };
143
+ }
144
+ }
145
+ return { success: false, error: 'Timeout waiting for image generation', provider: 'replicate' };
146
+ } catch (err) {
147
+ return { success: false, error: (err as Error).message, provider: 'replicate' };
148
+ }
149
+ }
150
+ }