openmagic 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 +21 -0
- package/README.md +299 -0
- package/dist/cli.js +1222 -0
- package/dist/cli.js.map +1 -0
- package/dist/toolbar/index.global.js +584 -0
- package/dist/toolbar/index.global.js.map +1 -0
- package/package.json +57 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import open from "open";
|
|
7
|
+
import { resolve as resolve2 } from "path";
|
|
8
|
+
|
|
9
|
+
// src/proxy.ts
|
|
10
|
+
import http from "http";
|
|
11
|
+
import httpProxy from "http-proxy";
|
|
12
|
+
|
|
13
|
+
// src/security.ts
|
|
14
|
+
import { randomBytes } from "crypto";
|
|
15
|
+
var sessionToken = null;
|
|
16
|
+
function generateSessionToken() {
|
|
17
|
+
sessionToken = randomBytes(32).toString("hex");
|
|
18
|
+
return sessionToken;
|
|
19
|
+
}
|
|
20
|
+
function getSessionToken() {
|
|
21
|
+
if (!sessionToken) {
|
|
22
|
+
return generateSessionToken();
|
|
23
|
+
}
|
|
24
|
+
return sessionToken;
|
|
25
|
+
}
|
|
26
|
+
function validateToken(token) {
|
|
27
|
+
return token === sessionToken;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/proxy.ts
|
|
31
|
+
function createProxyServer(targetHost, targetPort, serverPort) {
|
|
32
|
+
const proxy = httpProxy.createProxyServer({
|
|
33
|
+
target: `http://${targetHost}:${targetPort}`,
|
|
34
|
+
ws: true,
|
|
35
|
+
selfHandleResponse: true
|
|
36
|
+
});
|
|
37
|
+
const token = getSessionToken();
|
|
38
|
+
proxy.on("proxyRes", (proxyRes, req, res) => {
|
|
39
|
+
const contentType = proxyRes.headers["content-type"] || "";
|
|
40
|
+
const isHtml = contentType.includes("text/html");
|
|
41
|
+
if (!isHtml) {
|
|
42
|
+
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
|
43
|
+
proxyRes.pipe(res);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const chunks = [];
|
|
47
|
+
proxyRes.on("data", (chunk) => chunks.push(chunk));
|
|
48
|
+
proxyRes.on("end", () => {
|
|
49
|
+
let body = Buffer.concat(chunks).toString("utf-8");
|
|
50
|
+
const toolbarScript = buildInjectionScript(serverPort, token);
|
|
51
|
+
if (body.includes("</body>")) {
|
|
52
|
+
body = body.replace("</body>", `${toolbarScript}</body>`);
|
|
53
|
+
} else if (body.includes("</html>")) {
|
|
54
|
+
body = body.replace("</html>", `${toolbarScript}</html>`);
|
|
55
|
+
} else {
|
|
56
|
+
body += toolbarScript;
|
|
57
|
+
}
|
|
58
|
+
const headers = { ...proxyRes.headers };
|
|
59
|
+
delete headers["content-length"];
|
|
60
|
+
delete headers["content-encoding"];
|
|
61
|
+
res.writeHead(proxyRes.statusCode || 200, headers);
|
|
62
|
+
res.end(body);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
proxy.on("error", (err, _req, res) => {
|
|
66
|
+
console.error("[OpenMagic] Proxy error:", err.message);
|
|
67
|
+
if (res instanceof http.ServerResponse && !res.headersSent) {
|
|
68
|
+
res.writeHead(502, { "Content-Type": "text/plain" });
|
|
69
|
+
res.end(
|
|
70
|
+
`OpenMagic proxy error: Could not connect to dev server at ${targetHost}:${targetPort}
|
|
71
|
+
|
|
72
|
+
Make sure your dev server is running.`
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
const server = http.createServer((req, res) => {
|
|
77
|
+
if (req.url?.startsWith("/__openmagic__/")) {
|
|
78
|
+
handleToolbarAsset(req, res, serverPort);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
proxy.web(req, res);
|
|
82
|
+
});
|
|
83
|
+
server.on("upgrade", (req, socket, head) => {
|
|
84
|
+
if (req.url?.startsWith("/__openmagic__")) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
proxy.ws(req, socket, head);
|
|
88
|
+
});
|
|
89
|
+
return server;
|
|
90
|
+
}
|
|
91
|
+
function handleToolbarAsset(_req, res, _serverPort) {
|
|
92
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
93
|
+
res.end("Not found");
|
|
94
|
+
}
|
|
95
|
+
function buildInjectionScript(serverPort, token) {
|
|
96
|
+
return `
|
|
97
|
+
<script data-openmagic="true">
|
|
98
|
+
(function() {
|
|
99
|
+
if (window.__OPENMAGIC_LOADED__) return;
|
|
100
|
+
window.__OPENMAGIC_LOADED__ = true;
|
|
101
|
+
window.__OPENMAGIC_CONFIG__ = {
|
|
102
|
+
wsPort: ${serverPort},
|
|
103
|
+
token: "${token}"
|
|
104
|
+
};
|
|
105
|
+
var script = document.createElement("script");
|
|
106
|
+
script.src = "http://127.0.0.1:${serverPort}/__openmagic__/toolbar.js";
|
|
107
|
+
script.dataset.openmagic = "true";
|
|
108
|
+
document.body.appendChild(script);
|
|
109
|
+
})();
|
|
110
|
+
</script>`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/server.ts
|
|
114
|
+
import http2 from "http";
|
|
115
|
+
import { readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
|
|
116
|
+
import { join as join3, dirname as dirname2 } from "path";
|
|
117
|
+
import { fileURLToPath } from "url";
|
|
118
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
119
|
+
|
|
120
|
+
// src/config.ts
|
|
121
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
122
|
+
import { join } from "path";
|
|
123
|
+
import { homedir } from "os";
|
|
124
|
+
var CONFIG_DIR = join(homedir(), ".openmagic");
|
|
125
|
+
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
126
|
+
function ensureConfigDir() {
|
|
127
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
128
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function loadConfig() {
|
|
132
|
+
ensureConfigDir();
|
|
133
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
134
|
+
return {};
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const raw = readFileSync(CONFIG_FILE, "utf-8");
|
|
138
|
+
return JSON.parse(raw);
|
|
139
|
+
} catch {
|
|
140
|
+
return {};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function saveConfig(updates) {
|
|
144
|
+
ensureConfigDir();
|
|
145
|
+
const existing = loadConfig();
|
|
146
|
+
const merged = { ...existing, ...updates };
|
|
147
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), "utf-8");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/filesystem.ts
|
|
151
|
+
import {
|
|
152
|
+
readFileSync as readFileSync2,
|
|
153
|
+
writeFileSync as writeFileSync2,
|
|
154
|
+
existsSync as existsSync2,
|
|
155
|
+
statSync,
|
|
156
|
+
readdirSync,
|
|
157
|
+
copyFileSync,
|
|
158
|
+
mkdirSync as mkdirSync2
|
|
159
|
+
} from "fs";
|
|
160
|
+
import { join as join2, resolve, relative, dirname, extname } from "path";
|
|
161
|
+
var IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
162
|
+
"node_modules",
|
|
163
|
+
".git",
|
|
164
|
+
".next",
|
|
165
|
+
".nuxt",
|
|
166
|
+
".svelte-kit",
|
|
167
|
+
"dist",
|
|
168
|
+
"build",
|
|
169
|
+
".cache",
|
|
170
|
+
".turbo",
|
|
171
|
+
"__pycache__",
|
|
172
|
+
".venv",
|
|
173
|
+
"venv",
|
|
174
|
+
".DS_Store"
|
|
175
|
+
]);
|
|
176
|
+
var IGNORED_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
177
|
+
".png",
|
|
178
|
+
".jpg",
|
|
179
|
+
".jpeg",
|
|
180
|
+
".gif",
|
|
181
|
+
".svg",
|
|
182
|
+
".ico",
|
|
183
|
+
".webp",
|
|
184
|
+
".mp4",
|
|
185
|
+
".mp3",
|
|
186
|
+
".woff",
|
|
187
|
+
".woff2",
|
|
188
|
+
".ttf",
|
|
189
|
+
".eot",
|
|
190
|
+
".lock"
|
|
191
|
+
]);
|
|
192
|
+
function isPathSafe(filePath, roots) {
|
|
193
|
+
const resolved = resolve(filePath);
|
|
194
|
+
return roots.some((root) => {
|
|
195
|
+
const resolvedRoot = resolve(root);
|
|
196
|
+
return resolved.startsWith(resolvedRoot);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
function readFileSafe(filePath, roots) {
|
|
200
|
+
if (!isPathSafe(filePath, roots)) {
|
|
201
|
+
return { error: "Path is outside allowed roots" };
|
|
202
|
+
}
|
|
203
|
+
if (!existsSync2(filePath)) {
|
|
204
|
+
return { error: "File not found" };
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const content = readFileSync2(filePath, "utf-8");
|
|
208
|
+
return { content };
|
|
209
|
+
} catch (e) {
|
|
210
|
+
return { error: `Failed to read file: ${e.message}` };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function writeFileSafe(filePath, content, roots) {
|
|
214
|
+
if (!isPathSafe(filePath, roots)) {
|
|
215
|
+
return { ok: false, error: "Path is outside allowed roots" };
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
let backupPath;
|
|
219
|
+
if (existsSync2(filePath)) {
|
|
220
|
+
backupPath = filePath + ".openmagic-backup";
|
|
221
|
+
copyFileSync(filePath, backupPath);
|
|
222
|
+
}
|
|
223
|
+
const dir = dirname(filePath);
|
|
224
|
+
if (!existsSync2(dir)) {
|
|
225
|
+
mkdirSync2(dir, { recursive: true });
|
|
226
|
+
}
|
|
227
|
+
writeFileSync2(filePath, content, "utf-8");
|
|
228
|
+
return { ok: true, backupPath };
|
|
229
|
+
} catch (e) {
|
|
230
|
+
return { ok: false, error: `Failed to write file: ${e.message}` };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function listFiles(rootPath, roots, maxDepth = 4) {
|
|
234
|
+
if (!isPathSafe(rootPath, roots)) {
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
237
|
+
const entries = [];
|
|
238
|
+
function walk(dir, depth) {
|
|
239
|
+
if (depth > maxDepth) return;
|
|
240
|
+
let items;
|
|
241
|
+
try {
|
|
242
|
+
items = readdirSync(dir);
|
|
243
|
+
} catch {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
for (const item of items) {
|
|
247
|
+
if (IGNORED_DIRS.has(item)) continue;
|
|
248
|
+
if (item.startsWith(".") && item !== ".env.example") continue;
|
|
249
|
+
const fullPath = join2(dir, item);
|
|
250
|
+
let stat;
|
|
251
|
+
try {
|
|
252
|
+
stat = statSync(fullPath);
|
|
253
|
+
} catch {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const relPath = relative(rootPath, fullPath);
|
|
257
|
+
if (stat.isDirectory()) {
|
|
258
|
+
entries.push({ path: relPath, type: "dir", name: item });
|
|
259
|
+
walk(fullPath, depth + 1);
|
|
260
|
+
} else if (stat.isFile()) {
|
|
261
|
+
const ext = extname(item).toLowerCase();
|
|
262
|
+
if (!IGNORED_EXTENSIONS.has(ext)) {
|
|
263
|
+
entries.push({ path: relPath, type: "file", name: item });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
walk(rootPath, 0);
|
|
269
|
+
return entries;
|
|
270
|
+
}
|
|
271
|
+
function getProjectTree(roots) {
|
|
272
|
+
const lines = [];
|
|
273
|
+
for (const root of roots) {
|
|
274
|
+
lines.push(`[${root}]`);
|
|
275
|
+
const files = listFiles(root, roots, 3);
|
|
276
|
+
for (const f of files) {
|
|
277
|
+
const indent = f.path.split("/").length - 1;
|
|
278
|
+
const prefix = " ".repeat(indent);
|
|
279
|
+
const icon = f.type === "dir" ? "/" : "";
|
|
280
|
+
lines.push(`${prefix}${f.name}${icon}`);
|
|
281
|
+
}
|
|
282
|
+
lines.push("");
|
|
283
|
+
}
|
|
284
|
+
return lines.join("\n");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/llm/registry.ts
|
|
288
|
+
var MODEL_REGISTRY = {
|
|
289
|
+
openai: {
|
|
290
|
+
name: "OpenAI",
|
|
291
|
+
models: [
|
|
292
|
+
{ id: "gpt-4.1", name: "GPT-4.1", vision: true, context: 1047576 },
|
|
293
|
+
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", vision: true, context: 1047576 },
|
|
294
|
+
{ id: "gpt-4.1-nano", name: "GPT-4.1 Nano", vision: true, context: 1047576 },
|
|
295
|
+
{ id: "gpt-4o", name: "GPT-4o", vision: true, context: 128e3 },
|
|
296
|
+
{ id: "gpt-4o-mini", name: "GPT-4o Mini", vision: true, context: 128e3 },
|
|
297
|
+
{ id: "o3", name: "o3 (Reasoning)", vision: true, context: 2e5 },
|
|
298
|
+
{ id: "o4-mini", name: "o4-mini (Reasoning)", vision: true, context: 2e5 }
|
|
299
|
+
],
|
|
300
|
+
apiBase: "https://api.openai.com/v1",
|
|
301
|
+
keyPrefix: "sk-",
|
|
302
|
+
keyPlaceholder: "sk-..."
|
|
303
|
+
},
|
|
304
|
+
anthropic: {
|
|
305
|
+
name: "Anthropic",
|
|
306
|
+
models: [
|
|
307
|
+
{ id: "claude-opus-4-20250514", name: "Claude Opus 4", vision: true, context: 2e5 },
|
|
308
|
+
{ id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4", vision: true, context: 2e5 },
|
|
309
|
+
{ id: "claude-haiku-4-20250514", name: "Claude Haiku 4", vision: true, context: 2e5 }
|
|
310
|
+
],
|
|
311
|
+
apiBase: "https://api.anthropic.com/v1",
|
|
312
|
+
keyPrefix: "sk-ant-",
|
|
313
|
+
keyPlaceholder: "sk-ant-..."
|
|
314
|
+
},
|
|
315
|
+
google: {
|
|
316
|
+
name: "Google Gemini",
|
|
317
|
+
models: [
|
|
318
|
+
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", vision: true, context: 1048576 },
|
|
319
|
+
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", vision: true, context: 1048576 },
|
|
320
|
+
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", vision: true, context: 1048576 }
|
|
321
|
+
],
|
|
322
|
+
apiBase: "https://generativelanguage.googleapis.com/v1beta",
|
|
323
|
+
keyPrefix: "AI",
|
|
324
|
+
keyPlaceholder: "AIza..."
|
|
325
|
+
},
|
|
326
|
+
deepseek: {
|
|
327
|
+
name: "DeepSeek",
|
|
328
|
+
models: [
|
|
329
|
+
{ id: "deepseek-chat", name: "DeepSeek V3", vision: false, context: 65536 },
|
|
330
|
+
{ id: "deepseek-reasoner", name: "DeepSeek R1", vision: false, context: 65536 }
|
|
331
|
+
],
|
|
332
|
+
apiBase: "https://api.deepseek.com/v1",
|
|
333
|
+
keyPrefix: "sk-",
|
|
334
|
+
keyPlaceholder: "sk-..."
|
|
335
|
+
},
|
|
336
|
+
groq: {
|
|
337
|
+
name: "Groq",
|
|
338
|
+
models: [
|
|
339
|
+
{ id: "llama-3.3-70b-versatile", name: "Llama 3.3 70B", vision: false, context: 131072 },
|
|
340
|
+
{ id: "llama-3.1-8b-instant", name: "Llama 3.1 8B", vision: false, context: 131072 },
|
|
341
|
+
{ id: "gemma2-9b-it", name: "Gemma 2 9B", vision: false, context: 8192 }
|
|
342
|
+
],
|
|
343
|
+
apiBase: "https://api.groq.com/openai/v1",
|
|
344
|
+
keyPrefix: "gsk_",
|
|
345
|
+
keyPlaceholder: "gsk_..."
|
|
346
|
+
},
|
|
347
|
+
mistral: {
|
|
348
|
+
name: "Mistral",
|
|
349
|
+
models: [
|
|
350
|
+
{ id: "mistral-large-latest", name: "Mistral Large", vision: false, context: 131072 },
|
|
351
|
+
{ id: "mistral-small-latest", name: "Mistral Small", vision: false, context: 131072 },
|
|
352
|
+
{ id: "codestral-latest", name: "Codestral", vision: false, context: 262144 }
|
|
353
|
+
],
|
|
354
|
+
apiBase: "https://api.mistral.ai/v1",
|
|
355
|
+
keyPrefix: "",
|
|
356
|
+
keyPlaceholder: "Enter API key..."
|
|
357
|
+
},
|
|
358
|
+
xai: {
|
|
359
|
+
name: "xAI (Grok)",
|
|
360
|
+
models: [
|
|
361
|
+
{ id: "grok-3", name: "Grok 3", vision: true, context: 131072 },
|
|
362
|
+
{ id: "grok-3-mini", name: "Grok 3 Mini", vision: true, context: 131072 }
|
|
363
|
+
],
|
|
364
|
+
apiBase: "https://api.x.ai/v1",
|
|
365
|
+
keyPrefix: "xai-",
|
|
366
|
+
keyPlaceholder: "xai-..."
|
|
367
|
+
},
|
|
368
|
+
ollama: {
|
|
369
|
+
name: "Ollama (Local)",
|
|
370
|
+
models: [],
|
|
371
|
+
apiBase: "http://localhost:11434/v1",
|
|
372
|
+
keyPrefix: "",
|
|
373
|
+
keyPlaceholder: "not required",
|
|
374
|
+
local: true
|
|
375
|
+
},
|
|
376
|
+
openrouter: {
|
|
377
|
+
name: "OpenRouter",
|
|
378
|
+
models: [],
|
|
379
|
+
apiBase: "https://openrouter.ai/api/v1",
|
|
380
|
+
keyPrefix: "sk-or-",
|
|
381
|
+
keyPlaceholder: "sk-or-..."
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// src/llm/prompts.ts
|
|
386
|
+
var SYSTEM_PROMPT = `You are OpenMagic, an AI coding assistant embedded in a developer's web application. You help modify the codebase based on visual context from the running app.
|
|
387
|
+
|
|
388
|
+
## Your Role
|
|
389
|
+
- You can see the developer's running web application (DOM elements, screenshots, styles)
|
|
390
|
+
- You propose code modifications to their source files
|
|
391
|
+
- Your changes are applied directly to their codebase and reflected via hot reload
|
|
392
|
+
|
|
393
|
+
## Response Format
|
|
394
|
+
You MUST respond with valid JSON in this exact format:
|
|
395
|
+
|
|
396
|
+
\`\`\`json
|
|
397
|
+
{
|
|
398
|
+
"modifications": [
|
|
399
|
+
{
|
|
400
|
+
"file": "relative/path/to/file.tsx",
|
|
401
|
+
"type": "edit",
|
|
402
|
+
"search": "exact code to find (multi-line ok)",
|
|
403
|
+
"replace": "replacement code"
|
|
404
|
+
}
|
|
405
|
+
],
|
|
406
|
+
"explanation": "Brief description of what was changed and why"
|
|
407
|
+
}
|
|
408
|
+
\`\`\`
|
|
409
|
+
|
|
410
|
+
## Modification Types
|
|
411
|
+
- \`edit\`: Replace existing code. \`search\` must match exactly in the file. \`replace\` is the new code.
|
|
412
|
+
- \`create\`: Create a new file. Use \`content\` instead of search/replace.
|
|
413
|
+
- \`delete\`: Delete a file. No search/replace/content needed.
|
|
414
|
+
|
|
415
|
+
## Rules
|
|
416
|
+
1. The \`search\` field must contain the EXACT text from the source file \u2014 copy it precisely, including whitespace and indentation
|
|
417
|
+
2. Keep modifications minimal \u2014 change only what's needed
|
|
418
|
+
3. If you need to read a file first, say so in the explanation and the developer can provide it
|
|
419
|
+
4. For style changes, prefer modifying existing CSS/Tailwind classes over adding inline styles
|
|
420
|
+
5. Always preserve the existing code style and conventions
|
|
421
|
+
6. If the change involves multiple files, include all modifications in the array
|
|
422
|
+
7. ALWAYS respond with the JSON format above, even for explanations (put them in the "explanation" field)
|
|
423
|
+
8. If you cannot make the requested change, set modifications to an empty array and explain why`;
|
|
424
|
+
function buildUserMessage(userPrompt, context) {
|
|
425
|
+
const parts = [];
|
|
426
|
+
if (context.projectTree) {
|
|
427
|
+
parts.push(`## Project Structure
|
|
428
|
+
\`\`\`
|
|
429
|
+
${context.projectTree}
|
|
430
|
+
\`\`\``);
|
|
431
|
+
}
|
|
432
|
+
if (context.filePath && context.fileContent) {
|
|
433
|
+
parts.push(
|
|
434
|
+
`## Source File: ${context.filePath}
|
|
435
|
+
\`\`\`
|
|
436
|
+
${context.fileContent}
|
|
437
|
+
\`\`\``
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
if (context.selectedElement) {
|
|
441
|
+
parts.push(`## Selected Element (DOM)
|
|
442
|
+
\`\`\`html
|
|
443
|
+
${context.selectedElement}
|
|
444
|
+
\`\`\``);
|
|
445
|
+
}
|
|
446
|
+
if (context.networkLogs) {
|
|
447
|
+
parts.push(`## Recent Network Requests
|
|
448
|
+
\`\`\`
|
|
449
|
+
${context.networkLogs}
|
|
450
|
+
\`\`\``);
|
|
451
|
+
}
|
|
452
|
+
if (context.consoleLogs) {
|
|
453
|
+
parts.push(`## Console Output
|
|
454
|
+
\`\`\`
|
|
455
|
+
${context.consoleLogs}
|
|
456
|
+
\`\`\``);
|
|
457
|
+
}
|
|
458
|
+
parts.push(`## User Request
|
|
459
|
+
${userPrompt}`);
|
|
460
|
+
return parts.join("\n\n");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// src/llm/openai.ts
|
|
464
|
+
async function chatOpenAICompatible(provider, model, apiKey, messages, context, onChunk, onDone, onError) {
|
|
465
|
+
const providerConfig = MODEL_REGISTRY[provider];
|
|
466
|
+
if (!providerConfig) {
|
|
467
|
+
onError(`Unknown provider: ${provider}`);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const apiBase = providerConfig.apiBase;
|
|
471
|
+
const url = `${apiBase}/chat/completions`;
|
|
472
|
+
const apiMessages = [
|
|
473
|
+
{ role: "system", content: SYSTEM_PROMPT }
|
|
474
|
+
];
|
|
475
|
+
for (const msg of messages) {
|
|
476
|
+
if (msg.role === "user" && typeof msg.content === "string") {
|
|
477
|
+
const contextParts = {};
|
|
478
|
+
if (context.selectedElement) {
|
|
479
|
+
contextParts.selectedElement = context.selectedElement.outerHTML;
|
|
480
|
+
}
|
|
481
|
+
if (context.files && context.files.length > 0) {
|
|
482
|
+
contextParts.filePath = context.files[0].path;
|
|
483
|
+
contextParts.fileContent = context.files[0].content;
|
|
484
|
+
}
|
|
485
|
+
if (context.projectTree) {
|
|
486
|
+
contextParts.projectTree = context.projectTree;
|
|
487
|
+
}
|
|
488
|
+
if (context.networkLogs) {
|
|
489
|
+
contextParts.networkLogs = context.networkLogs.map((l) => `${l.method} ${l.url} \u2192 ${l.status || "pending"}`).join("\n");
|
|
490
|
+
}
|
|
491
|
+
if (context.consoleLogs) {
|
|
492
|
+
contextParts.consoleLogs = context.consoleLogs.map((l) => `[${l.level}] ${l.args.join(" ")}`).join("\n");
|
|
493
|
+
}
|
|
494
|
+
const enrichedContent = buildUserMessage(msg.content, contextParts);
|
|
495
|
+
const modelInfo = providerConfig.models.find((m) => m.id === model);
|
|
496
|
+
if (context.screenshot && modelInfo?.vision) {
|
|
497
|
+
apiMessages.push({
|
|
498
|
+
role: "user",
|
|
499
|
+
content: [
|
|
500
|
+
{ type: "text", text: enrichedContent },
|
|
501
|
+
{
|
|
502
|
+
type: "image_url",
|
|
503
|
+
image_url: { url: context.screenshot }
|
|
504
|
+
}
|
|
505
|
+
]
|
|
506
|
+
});
|
|
507
|
+
} else {
|
|
508
|
+
apiMessages.push({ role: "user", content: enrichedContent });
|
|
509
|
+
}
|
|
510
|
+
} else {
|
|
511
|
+
apiMessages.push({
|
|
512
|
+
role: msg.role,
|
|
513
|
+
content: msg.content
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
const body = {
|
|
518
|
+
model,
|
|
519
|
+
messages: apiMessages,
|
|
520
|
+
stream: true,
|
|
521
|
+
max_tokens: 4096
|
|
522
|
+
};
|
|
523
|
+
try {
|
|
524
|
+
const headers = {
|
|
525
|
+
"Content-Type": "application/json"
|
|
526
|
+
};
|
|
527
|
+
if (provider === "ollama") {
|
|
528
|
+
} else {
|
|
529
|
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
530
|
+
}
|
|
531
|
+
const response = await fetch(url, {
|
|
532
|
+
method: "POST",
|
|
533
|
+
headers,
|
|
534
|
+
body: JSON.stringify(body)
|
|
535
|
+
});
|
|
536
|
+
if (!response.ok) {
|
|
537
|
+
const errorText = await response.text();
|
|
538
|
+
onError(`API error ${response.status}: ${errorText}`);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (!response.body) {
|
|
542
|
+
onError("No response body");
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const reader = response.body.getReader();
|
|
546
|
+
const decoder = new TextDecoder();
|
|
547
|
+
let fullContent = "";
|
|
548
|
+
let buffer = "";
|
|
549
|
+
while (true) {
|
|
550
|
+
const { done, value } = await reader.read();
|
|
551
|
+
if (done) break;
|
|
552
|
+
buffer += decoder.decode(value, { stream: true });
|
|
553
|
+
const lines = buffer.split("\n");
|
|
554
|
+
buffer = lines.pop() || "";
|
|
555
|
+
for (const line of lines) {
|
|
556
|
+
if (!line.startsWith("data: ")) continue;
|
|
557
|
+
const data = line.slice(6).trim();
|
|
558
|
+
if (data === "[DONE]") continue;
|
|
559
|
+
try {
|
|
560
|
+
const parsed = JSON.parse(data);
|
|
561
|
+
const delta = parsed.choices?.[0]?.delta?.content;
|
|
562
|
+
if (delta) {
|
|
563
|
+
fullContent += delta;
|
|
564
|
+
onChunk(delta);
|
|
565
|
+
}
|
|
566
|
+
} catch {
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
onDone({ content: fullContent });
|
|
571
|
+
} catch (e) {
|
|
572
|
+
onError(`Request failed: ${e.message}`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// src/llm/anthropic.ts
|
|
577
|
+
async function chatAnthropic(model, apiKey, messages, context, onChunk, onDone, onError) {
|
|
578
|
+
const url = "https://api.anthropic.com/v1/messages";
|
|
579
|
+
const apiMessages = [];
|
|
580
|
+
for (const msg of messages) {
|
|
581
|
+
if (msg.role === "system") continue;
|
|
582
|
+
if (msg.role === "user" && typeof msg.content === "string") {
|
|
583
|
+
const contextParts = {};
|
|
584
|
+
if (context.selectedElement) {
|
|
585
|
+
contextParts.selectedElement = context.selectedElement.outerHTML;
|
|
586
|
+
}
|
|
587
|
+
if (context.files && context.files.length > 0) {
|
|
588
|
+
contextParts.filePath = context.files[0].path;
|
|
589
|
+
contextParts.fileContent = context.files[0].content;
|
|
590
|
+
}
|
|
591
|
+
if (context.projectTree) {
|
|
592
|
+
contextParts.projectTree = context.projectTree;
|
|
593
|
+
}
|
|
594
|
+
if (context.networkLogs) {
|
|
595
|
+
contextParts.networkLogs = context.networkLogs.map((l) => `${l.method} ${l.url} \u2192 ${l.status || "pending"}`).join("\n");
|
|
596
|
+
}
|
|
597
|
+
if (context.consoleLogs) {
|
|
598
|
+
contextParts.consoleLogs = context.consoleLogs.map((l) => `[${l.level}] ${l.args.join(" ")}`).join("\n");
|
|
599
|
+
}
|
|
600
|
+
const enrichedContent = buildUserMessage(msg.content, contextParts);
|
|
601
|
+
if (context.screenshot) {
|
|
602
|
+
const base64Data = context.screenshot.replace(
|
|
603
|
+
/^data:image\/\w+;base64,/,
|
|
604
|
+
""
|
|
605
|
+
);
|
|
606
|
+
apiMessages.push({
|
|
607
|
+
role: "user",
|
|
608
|
+
content: [
|
|
609
|
+
{ type: "text", text: enrichedContent },
|
|
610
|
+
{
|
|
611
|
+
type: "image",
|
|
612
|
+
source: {
|
|
613
|
+
type: "base64",
|
|
614
|
+
media_type: "image/png",
|
|
615
|
+
data: base64Data
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
]
|
|
619
|
+
});
|
|
620
|
+
} else {
|
|
621
|
+
apiMessages.push({ role: "user", content: enrichedContent });
|
|
622
|
+
}
|
|
623
|
+
} else {
|
|
624
|
+
apiMessages.push({
|
|
625
|
+
role: msg.role,
|
|
626
|
+
content: msg.content
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const body = {
|
|
631
|
+
model,
|
|
632
|
+
max_tokens: 4096,
|
|
633
|
+
system: SYSTEM_PROMPT,
|
|
634
|
+
messages: apiMessages,
|
|
635
|
+
stream: true
|
|
636
|
+
};
|
|
637
|
+
try {
|
|
638
|
+
const response = await fetch(url, {
|
|
639
|
+
method: "POST",
|
|
640
|
+
headers: {
|
|
641
|
+
"Content-Type": "application/json",
|
|
642
|
+
"x-api-key": apiKey,
|
|
643
|
+
"anthropic-version": "2023-06-01"
|
|
644
|
+
},
|
|
645
|
+
body: JSON.stringify(body)
|
|
646
|
+
});
|
|
647
|
+
if (!response.ok) {
|
|
648
|
+
const errorText = await response.text();
|
|
649
|
+
onError(`Anthropic API error ${response.status}: ${errorText}`);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (!response.body) {
|
|
653
|
+
onError("No response body");
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
const reader = response.body.getReader();
|
|
657
|
+
const decoder = new TextDecoder();
|
|
658
|
+
let fullContent = "";
|
|
659
|
+
let buffer = "";
|
|
660
|
+
while (true) {
|
|
661
|
+
const { done, value } = await reader.read();
|
|
662
|
+
if (done) break;
|
|
663
|
+
buffer += decoder.decode(value, { stream: true });
|
|
664
|
+
const lines = buffer.split("\n");
|
|
665
|
+
buffer = lines.pop() || "";
|
|
666
|
+
for (const line of lines) {
|
|
667
|
+
if (!line.startsWith("data: ")) continue;
|
|
668
|
+
const data = line.slice(6).trim();
|
|
669
|
+
try {
|
|
670
|
+
const parsed = JSON.parse(data);
|
|
671
|
+
if (parsed.type === "content_block_delta") {
|
|
672
|
+
const delta = parsed.delta?.text;
|
|
673
|
+
if (delta) {
|
|
674
|
+
fullContent += delta;
|
|
675
|
+
onChunk(delta);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
} catch {
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
onDone({ content: fullContent });
|
|
683
|
+
} catch (e) {
|
|
684
|
+
onError(`Request failed: ${e.message}`);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// src/llm/google.ts
|
|
689
|
+
async function chatGoogle(model, apiKey, messages, context, onChunk, onDone, onError) {
|
|
690
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?key=${apiKey}&alt=sse`;
|
|
691
|
+
const contents = [];
|
|
692
|
+
for (const msg of messages) {
|
|
693
|
+
if (msg.role === "system") continue;
|
|
694
|
+
const role = msg.role === "assistant" ? "model" : "user";
|
|
695
|
+
if (msg.role === "user" && typeof msg.content === "string") {
|
|
696
|
+
const contextParts = {};
|
|
697
|
+
if (context.selectedElement) {
|
|
698
|
+
contextParts.selectedElement = context.selectedElement.outerHTML;
|
|
699
|
+
}
|
|
700
|
+
if (context.files && context.files.length > 0) {
|
|
701
|
+
contextParts.filePath = context.files[0].path;
|
|
702
|
+
contextParts.fileContent = context.files[0].content;
|
|
703
|
+
}
|
|
704
|
+
if (context.projectTree) {
|
|
705
|
+
contextParts.projectTree = context.projectTree;
|
|
706
|
+
}
|
|
707
|
+
const enrichedContent = buildUserMessage(msg.content, contextParts);
|
|
708
|
+
const parts = [
|
|
709
|
+
{ text: enrichedContent }
|
|
710
|
+
];
|
|
711
|
+
if (context.screenshot) {
|
|
712
|
+
const base64Data = context.screenshot.replace(
|
|
713
|
+
/^data:image\/\w+;base64,/,
|
|
714
|
+
""
|
|
715
|
+
);
|
|
716
|
+
parts.push({
|
|
717
|
+
inline_data: {
|
|
718
|
+
mime_type: "image/png",
|
|
719
|
+
data: base64Data
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
contents.push({ role, parts });
|
|
724
|
+
} else {
|
|
725
|
+
contents.push({
|
|
726
|
+
role,
|
|
727
|
+
parts: [{ text: msg.content }]
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
const body = {
|
|
732
|
+
system_instruction: {
|
|
733
|
+
parts: [{ text: SYSTEM_PROMPT }]
|
|
734
|
+
},
|
|
735
|
+
contents,
|
|
736
|
+
generationConfig: {
|
|
737
|
+
maxOutputTokens: 4096
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
try {
|
|
741
|
+
const response = await fetch(url, {
|
|
742
|
+
method: "POST",
|
|
743
|
+
headers: { "Content-Type": "application/json" },
|
|
744
|
+
body: JSON.stringify(body)
|
|
745
|
+
});
|
|
746
|
+
if (!response.ok) {
|
|
747
|
+
const errorText = await response.text();
|
|
748
|
+
onError(`Google API error ${response.status}: ${errorText}`);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (!response.body) {
|
|
752
|
+
onError("No response body");
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const reader = response.body.getReader();
|
|
756
|
+
const decoder = new TextDecoder();
|
|
757
|
+
let fullContent = "";
|
|
758
|
+
let buffer = "";
|
|
759
|
+
while (true) {
|
|
760
|
+
const { done, value } = await reader.read();
|
|
761
|
+
if (done) break;
|
|
762
|
+
buffer += decoder.decode(value, { stream: true });
|
|
763
|
+
const lines = buffer.split("\n");
|
|
764
|
+
buffer = lines.pop() || "";
|
|
765
|
+
for (const line of lines) {
|
|
766
|
+
if (!line.startsWith("data: ")) continue;
|
|
767
|
+
const data = line.slice(6).trim();
|
|
768
|
+
try {
|
|
769
|
+
const parsed = JSON.parse(data);
|
|
770
|
+
const text = parsed.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
771
|
+
if (text) {
|
|
772
|
+
fullContent += text;
|
|
773
|
+
onChunk(text);
|
|
774
|
+
}
|
|
775
|
+
} catch {
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
onDone({ content: fullContent });
|
|
780
|
+
} catch (e) {
|
|
781
|
+
onError(`Request failed: ${e.message}`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// src/llm/proxy.ts
|
|
786
|
+
var OPENAI_COMPATIBLE_PROVIDERS = /* @__PURE__ */ new Set([
|
|
787
|
+
"openai",
|
|
788
|
+
"deepseek",
|
|
789
|
+
"groq",
|
|
790
|
+
"mistral",
|
|
791
|
+
"xai",
|
|
792
|
+
"ollama",
|
|
793
|
+
"openrouter"
|
|
794
|
+
]);
|
|
795
|
+
async function handleLlmChat(params, onChunk, onDone, onError) {
|
|
796
|
+
const { provider, model, apiKey, messages, context } = params;
|
|
797
|
+
const wrappedOnDone = (result) => {
|
|
798
|
+
let modifications;
|
|
799
|
+
try {
|
|
800
|
+
const jsonMatch = result.content.match(/```json\s*([\s\S]*?)```/) || result.content.match(/\{[\s\S]*"modifications"[\s\S]*\}/);
|
|
801
|
+
if (jsonMatch) {
|
|
802
|
+
const jsonStr = jsonMatch[1] || jsonMatch[0];
|
|
803
|
+
const parsed = JSON.parse(jsonStr);
|
|
804
|
+
modifications = parsed.modifications;
|
|
805
|
+
}
|
|
806
|
+
} catch {
|
|
807
|
+
}
|
|
808
|
+
onDone({ content: result.content, modifications });
|
|
809
|
+
};
|
|
810
|
+
if (provider === "anthropic") {
|
|
811
|
+
await chatAnthropic(model, apiKey, messages, context, onChunk, wrappedOnDone, onError);
|
|
812
|
+
} else if (provider === "google") {
|
|
813
|
+
await chatGoogle(model, apiKey, messages, context, onChunk, wrappedOnDone, onError);
|
|
814
|
+
} else if (OPENAI_COMPATIBLE_PROVIDERS.has(provider)) {
|
|
815
|
+
await chatOpenAICompatible(
|
|
816
|
+
provider,
|
|
817
|
+
model,
|
|
818
|
+
apiKey,
|
|
819
|
+
messages,
|
|
820
|
+
context,
|
|
821
|
+
onChunk,
|
|
822
|
+
wrappedOnDone,
|
|
823
|
+
onError
|
|
824
|
+
);
|
|
825
|
+
} else {
|
|
826
|
+
onError(`Unsupported provider: ${provider}`);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// src/server.ts
|
|
831
|
+
var __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
832
|
+
function createOpenMagicServer(proxyPort, roots) {
|
|
833
|
+
const httpServer = http2.createServer((req, res) => {
|
|
834
|
+
if (req.url === "/__openmagic__/toolbar.js") {
|
|
835
|
+
serveToolbarBundle(res);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (req.url === "/__openmagic__/health") {
|
|
839
|
+
res.writeHead(200, {
|
|
840
|
+
"Content-Type": "application/json",
|
|
841
|
+
"Access-Control-Allow-Origin": "*"
|
|
842
|
+
});
|
|
843
|
+
res.end(JSON.stringify({ status: "ok", version: "0.1.0" }));
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
res.writeHead(404);
|
|
847
|
+
res.end("Not found");
|
|
848
|
+
});
|
|
849
|
+
const wss = new WebSocketServer({
|
|
850
|
+
server: httpServer,
|
|
851
|
+
path: "/__openmagic__/ws"
|
|
852
|
+
});
|
|
853
|
+
const clientStates = /* @__PURE__ */ new WeakMap();
|
|
854
|
+
wss.on("connection", (ws) => {
|
|
855
|
+
clientStates.set(ws, { authenticated: false });
|
|
856
|
+
ws.on("message", async (data) => {
|
|
857
|
+
let msg;
|
|
858
|
+
try {
|
|
859
|
+
msg = JSON.parse(data.toString());
|
|
860
|
+
} catch {
|
|
861
|
+
sendError(ws, "parse_error", "Invalid JSON");
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const state = clientStates.get(ws);
|
|
865
|
+
if (!state.authenticated && msg.type !== "handshake") {
|
|
866
|
+
sendError(ws, "auth_required", "Handshake required");
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
try {
|
|
870
|
+
await handleMessage(ws, msg, state, roots, proxyPort);
|
|
871
|
+
} catch (e) {
|
|
872
|
+
sendError(ws, "internal_error", e.message, msg.id);
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
ws.on("close", () => {
|
|
876
|
+
clientStates.delete(ws);
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
return { httpServer, wss };
|
|
880
|
+
}
|
|
881
|
+
async function handleMessage(ws, msg, state, roots, _proxyPort) {
|
|
882
|
+
switch (msg.type) {
|
|
883
|
+
case "handshake": {
|
|
884
|
+
const payload = msg.payload;
|
|
885
|
+
if (!validateToken(payload.token)) {
|
|
886
|
+
sendError(ws, "auth_failed", "Invalid token", msg.id);
|
|
887
|
+
ws.close();
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
state.authenticated = true;
|
|
891
|
+
const config = loadConfig();
|
|
892
|
+
send(ws, {
|
|
893
|
+
id: msg.id,
|
|
894
|
+
type: "handshake.ok",
|
|
895
|
+
payload: {
|
|
896
|
+
version: "0.1.0",
|
|
897
|
+
roots,
|
|
898
|
+
config: {
|
|
899
|
+
provider: config.provider,
|
|
900
|
+
model: config.model,
|
|
901
|
+
hasApiKey: !!config.apiKey
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
break;
|
|
906
|
+
}
|
|
907
|
+
case "fs.read": {
|
|
908
|
+
const payload = msg.payload;
|
|
909
|
+
const result = readFileSafe(payload.path, roots);
|
|
910
|
+
if ("error" in result) {
|
|
911
|
+
sendError(ws, "fs_error", result.error, msg.id);
|
|
912
|
+
} else {
|
|
913
|
+
send(ws, {
|
|
914
|
+
id: msg.id,
|
|
915
|
+
type: "fs.content",
|
|
916
|
+
payload: { path: payload.path, content: result.content }
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
break;
|
|
920
|
+
}
|
|
921
|
+
case "fs.write": {
|
|
922
|
+
const payload = msg.payload;
|
|
923
|
+
const result = writeFileSafe(payload.path, payload.content, roots);
|
|
924
|
+
send(ws, {
|
|
925
|
+
id: msg.id,
|
|
926
|
+
type: "fs.written",
|
|
927
|
+
payload: { path: payload.path, ok: result.ok, error: result.error }
|
|
928
|
+
});
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
case "fs.list": {
|
|
932
|
+
const payload = msg.payload;
|
|
933
|
+
const root = payload?.root || roots[0];
|
|
934
|
+
const files = listFiles(root, roots);
|
|
935
|
+
send(ws, {
|
|
936
|
+
id: msg.id,
|
|
937
|
+
type: "fs.tree",
|
|
938
|
+
payload: { files, projectTree: getProjectTree(roots) }
|
|
939
|
+
});
|
|
940
|
+
break;
|
|
941
|
+
}
|
|
942
|
+
case "llm.chat": {
|
|
943
|
+
const payload = msg.payload;
|
|
944
|
+
const config = loadConfig();
|
|
945
|
+
if (!config.apiKey) {
|
|
946
|
+
sendError(ws, "config_error", "API key not configured", msg.id);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
await handleLlmChat(
|
|
950
|
+
{
|
|
951
|
+
provider: payload.provider || config.provider || "openai",
|
|
952
|
+
model: payload.model || config.model || "gpt-4o",
|
|
953
|
+
apiKey: config.apiKey,
|
|
954
|
+
messages: payload.messages,
|
|
955
|
+
context: payload.context
|
|
956
|
+
},
|
|
957
|
+
(chunk) => {
|
|
958
|
+
send(ws, { id: msg.id, type: "llm.chunk", payload: { delta: chunk } });
|
|
959
|
+
},
|
|
960
|
+
(result) => {
|
|
961
|
+
send(ws, { id: msg.id, type: "llm.done", payload: result });
|
|
962
|
+
},
|
|
963
|
+
(error) => {
|
|
964
|
+
send(ws, { id: msg.id, type: "llm.error", payload: { message: error } });
|
|
965
|
+
}
|
|
966
|
+
);
|
|
967
|
+
break;
|
|
968
|
+
}
|
|
969
|
+
case "config.get": {
|
|
970
|
+
const config = loadConfig();
|
|
971
|
+
send(ws, {
|
|
972
|
+
id: msg.id,
|
|
973
|
+
type: "config.value",
|
|
974
|
+
payload: {
|
|
975
|
+
provider: config.provider,
|
|
976
|
+
model: config.model,
|
|
977
|
+
hasApiKey: !!config.apiKey,
|
|
978
|
+
roots: config.roots || roots
|
|
979
|
+
}
|
|
980
|
+
});
|
|
981
|
+
break;
|
|
982
|
+
}
|
|
983
|
+
case "config.set": {
|
|
984
|
+
const payload = msg.payload;
|
|
985
|
+
const updates = {};
|
|
986
|
+
if (payload.provider !== void 0) updates.provider = payload.provider;
|
|
987
|
+
if (payload.model !== void 0) updates.model = payload.model;
|
|
988
|
+
if (payload.apiKey !== void 0) updates.apiKey = payload.apiKey;
|
|
989
|
+
if (payload.roots !== void 0) updates.roots = payload.roots;
|
|
990
|
+
saveConfig(updates);
|
|
991
|
+
send(ws, {
|
|
992
|
+
id: msg.id,
|
|
993
|
+
type: "config.saved",
|
|
994
|
+
payload: { ok: true }
|
|
995
|
+
});
|
|
996
|
+
break;
|
|
997
|
+
}
|
|
998
|
+
default:
|
|
999
|
+
sendError(ws, "unknown_type", `Unknown message type: ${msg.type}`, msg.id);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
function send(ws, msg) {
|
|
1003
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1004
|
+
ws.send(JSON.stringify(msg));
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
function sendError(ws, code, message, id) {
|
|
1008
|
+
send(ws, {
|
|
1009
|
+
id: id || "error",
|
|
1010
|
+
type: "error",
|
|
1011
|
+
payload: { code, message }
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
function serveToolbarBundle(res) {
|
|
1015
|
+
const bundlePaths = [
|
|
1016
|
+
join3(__dirname, "toolbar", "index.global.js"),
|
|
1017
|
+
join3(__dirname, "..", "dist", "toolbar", "index.global.js")
|
|
1018
|
+
];
|
|
1019
|
+
for (const bundlePath of bundlePaths) {
|
|
1020
|
+
if (existsSync3(bundlePath)) {
|
|
1021
|
+
const content = readFileSync3(bundlePath, "utf-8");
|
|
1022
|
+
res.writeHead(200, {
|
|
1023
|
+
"Content-Type": "application/javascript",
|
|
1024
|
+
"Access-Control-Allow-Origin": "*",
|
|
1025
|
+
"Cache-Control": "no-cache"
|
|
1026
|
+
});
|
|
1027
|
+
res.end(content);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
res.writeHead(200, {
|
|
1032
|
+
"Content-Type": "application/javascript",
|
|
1033
|
+
"Access-Control-Allow-Origin": "*"
|
|
1034
|
+
});
|
|
1035
|
+
res.end(`
|
|
1036
|
+
(function() {
|
|
1037
|
+
var div = document.createElement("div");
|
|
1038
|
+
div.style.cssText = "position:fixed;bottom:20px;right:20px;background:#1a1a2e;color:#e94560;padding:16px 24px;border-radius:12px;font-family:system-ui;font-size:14px;z-index:2147483647;box-shadow:0 4px 24px rgba(0,0,0,0.3);";
|
|
1039
|
+
div.textContent = "OpenMagic: Toolbar bundle not found. Run 'npm run build:toolbar' first.";
|
|
1040
|
+
document.body.appendChild(div);
|
|
1041
|
+
})();
|
|
1042
|
+
`);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// src/detect.ts
|
|
1046
|
+
import { createConnection } from "net";
|
|
1047
|
+
var COMMON_DEV_PORTS = [
|
|
1048
|
+
3e3,
|
|
1049
|
+
// React (CRA), Next.js, Express
|
|
1050
|
+
5173,
|
|
1051
|
+
// Vite
|
|
1052
|
+
5174,
|
|
1053
|
+
// Vite (alternate)
|
|
1054
|
+
4200,
|
|
1055
|
+
// Angular
|
|
1056
|
+
8080,
|
|
1057
|
+
// Vue CLI, generic
|
|
1058
|
+
8e3,
|
|
1059
|
+
// Django, Python
|
|
1060
|
+
8888,
|
|
1061
|
+
// Jupyter, generic
|
|
1062
|
+
3001,
|
|
1063
|
+
// Common alternate
|
|
1064
|
+
4e3,
|
|
1065
|
+
// Phoenix, generic
|
|
1066
|
+
1234
|
|
1067
|
+
// Parcel
|
|
1068
|
+
];
|
|
1069
|
+
function checkPort(port, host = "127.0.0.1") {
|
|
1070
|
+
return new Promise((resolve3) => {
|
|
1071
|
+
const socket = createConnection({ port, host, timeout: 500 });
|
|
1072
|
+
socket.on("connect", () => {
|
|
1073
|
+
socket.destroy();
|
|
1074
|
+
resolve3(true);
|
|
1075
|
+
});
|
|
1076
|
+
socket.on("error", () => {
|
|
1077
|
+
socket.destroy();
|
|
1078
|
+
resolve3(false);
|
|
1079
|
+
});
|
|
1080
|
+
socket.on("timeout", () => {
|
|
1081
|
+
socket.destroy();
|
|
1082
|
+
resolve3(false);
|
|
1083
|
+
});
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
async function detectDevServer() {
|
|
1087
|
+
const checks = COMMON_DEV_PORTS.map(async (port) => {
|
|
1088
|
+
const isOpen = await checkPort(port);
|
|
1089
|
+
return isOpen ? port : null;
|
|
1090
|
+
});
|
|
1091
|
+
const results = await Promise.all(checks);
|
|
1092
|
+
const foundPort = results.find((p) => p !== null);
|
|
1093
|
+
if (foundPort) {
|
|
1094
|
+
return { port: foundPort, host: "127.0.0.1" };
|
|
1095
|
+
}
|
|
1096
|
+
return null;
|
|
1097
|
+
}
|
|
1098
|
+
async function isPortOpen(port) {
|
|
1099
|
+
return checkPort(port);
|
|
1100
|
+
}
|
|
1101
|
+
async function findAvailablePort(startPort) {
|
|
1102
|
+
let port = startPort;
|
|
1103
|
+
while (await isPortOpen(port)) {
|
|
1104
|
+
port++;
|
|
1105
|
+
if (port > startPort + 100) {
|
|
1106
|
+
throw new Error(`Could not find an available port near ${startPort}`);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
return port;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// src/cli.ts
|
|
1113
|
+
var VERSION = "0.1.0";
|
|
1114
|
+
var program = new Command();
|
|
1115
|
+
program.name("openmagic").description("AI-powered coding toolbar for any web application").version(VERSION).option("-p, --port <port>", "Dev server port to proxy", "").option(
|
|
1116
|
+
"-l, --listen <port>",
|
|
1117
|
+
"Port for the OpenMagic proxy",
|
|
1118
|
+
"4567"
|
|
1119
|
+
).option(
|
|
1120
|
+
"-r, --root <paths...>",
|
|
1121
|
+
"Project root directories (defaults to cwd)"
|
|
1122
|
+
).option("--no-open", "Don't auto-open browser").option("--host <host>", "Dev server host", "127.0.0.1").action(async (opts) => {
|
|
1123
|
+
console.log("");
|
|
1124
|
+
console.log(
|
|
1125
|
+
chalk.bold.magenta(" \u2728 OpenMagic") + chalk.dim(` v${VERSION}`)
|
|
1126
|
+
);
|
|
1127
|
+
console.log("");
|
|
1128
|
+
let targetPort;
|
|
1129
|
+
let targetHost = opts.host;
|
|
1130
|
+
if (opts.port) {
|
|
1131
|
+
targetPort = parseInt(opts.port, 10);
|
|
1132
|
+
const isRunning = await isPortOpen(targetPort);
|
|
1133
|
+
if (!isRunning) {
|
|
1134
|
+
console.log(
|
|
1135
|
+
chalk.yellow(
|
|
1136
|
+
` \u26A0 No server found at ${targetHost}:${targetPort}`
|
|
1137
|
+
)
|
|
1138
|
+
);
|
|
1139
|
+
console.log(
|
|
1140
|
+
chalk.dim(
|
|
1141
|
+
" Start your dev server first, then run openmagic again."
|
|
1142
|
+
)
|
|
1143
|
+
);
|
|
1144
|
+
console.log("");
|
|
1145
|
+
process.exit(1);
|
|
1146
|
+
}
|
|
1147
|
+
} else {
|
|
1148
|
+
console.log(chalk.dim(" Scanning for dev server..."));
|
|
1149
|
+
const detected = await detectDevServer();
|
|
1150
|
+
if (!detected) {
|
|
1151
|
+
console.log(
|
|
1152
|
+
chalk.yellow(
|
|
1153
|
+
" \u26A0 No dev server detected on common ports (3000, 5173, 8080, etc.)"
|
|
1154
|
+
)
|
|
1155
|
+
);
|
|
1156
|
+
console.log("");
|
|
1157
|
+
console.log(
|
|
1158
|
+
chalk.white(" Specify the port manually:")
|
|
1159
|
+
);
|
|
1160
|
+
console.log(
|
|
1161
|
+
chalk.cyan(" npx openmagic --port 3000")
|
|
1162
|
+
);
|
|
1163
|
+
console.log("");
|
|
1164
|
+
process.exit(1);
|
|
1165
|
+
}
|
|
1166
|
+
targetPort = detected.port;
|
|
1167
|
+
targetHost = detected.host;
|
|
1168
|
+
}
|
|
1169
|
+
console.log(
|
|
1170
|
+
chalk.green(` \u2713 Dev server found at ${targetHost}:${targetPort}`)
|
|
1171
|
+
);
|
|
1172
|
+
const roots = (opts.root || [process.cwd()]).map(
|
|
1173
|
+
(r) => resolve2(r)
|
|
1174
|
+
);
|
|
1175
|
+
const config = loadConfig();
|
|
1176
|
+
saveConfig({ ...config, roots, targetPort });
|
|
1177
|
+
const token = generateSessionToken();
|
|
1178
|
+
let proxyPort = parseInt(opts.listen, 10);
|
|
1179
|
+
if (await isPortOpen(proxyPort)) {
|
|
1180
|
+
proxyPort = await findAvailablePort(proxyPort);
|
|
1181
|
+
}
|
|
1182
|
+
const { httpServer: omServer } = createOpenMagicServer(proxyPort, roots);
|
|
1183
|
+
omServer.listen(proxyPort + 1, "127.0.0.1", () => {
|
|
1184
|
+
});
|
|
1185
|
+
const proxyServer = createProxyServer(
|
|
1186
|
+
targetHost,
|
|
1187
|
+
targetPort,
|
|
1188
|
+
proxyPort + 1
|
|
1189
|
+
);
|
|
1190
|
+
proxyServer.listen(proxyPort, "127.0.0.1", () => {
|
|
1191
|
+
console.log("");
|
|
1192
|
+
console.log(
|
|
1193
|
+
chalk.bold.green(` \u{1F680} Proxy running at \u2192 `) + chalk.bold.underline.cyan(`http://localhost:${proxyPort}`)
|
|
1194
|
+
);
|
|
1195
|
+
console.log("");
|
|
1196
|
+
console.log(
|
|
1197
|
+
chalk.dim(" Open the URL above in your browser to start.")
|
|
1198
|
+
);
|
|
1199
|
+
console.log(chalk.dim(" Press Ctrl+C to stop."));
|
|
1200
|
+
console.log("");
|
|
1201
|
+
if (opts.open !== false) {
|
|
1202
|
+
open(`http://localhost:${proxyPort}`).catch(() => {
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
});
|
|
1206
|
+
proxyServer.on("upgrade", (req, socket, head) => {
|
|
1207
|
+
if (req.url?.startsWith("/__openmagic__")) {
|
|
1208
|
+
omServer.emit("upgrade", req, socket, head);
|
|
1209
|
+
}
|
|
1210
|
+
});
|
|
1211
|
+
const shutdown = () => {
|
|
1212
|
+
console.log("");
|
|
1213
|
+
console.log(chalk.dim(" Shutting down OpenMagic..."));
|
|
1214
|
+
proxyServer.close();
|
|
1215
|
+
omServer.close();
|
|
1216
|
+
process.exit(0);
|
|
1217
|
+
};
|
|
1218
|
+
process.on("SIGINT", shutdown);
|
|
1219
|
+
process.on("SIGTERM", shutdown);
|
|
1220
|
+
});
|
|
1221
|
+
program.parse();
|
|
1222
|
+
//# sourceMappingURL=cli.js.map
|