pi-studio-opencode 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/ARCHITECTURE.md +122 -0
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/demo-host-pi.d.ts +1 -0
- package/dist/demo-host-pi.js +71 -0
- package/dist/demo-host-pi.js.map +1 -0
- package/dist/demo-host.d.ts +1 -0
- package/dist/demo-host.js +154 -0
- package/dist/demo-host.js.map +1 -0
- package/dist/host-opencode-plugin.d.ts +52 -0
- package/dist/host-opencode-plugin.js +396 -0
- package/dist/host-opencode-plugin.js.map +1 -0
- package/dist/host-opencode.d.ts +154 -0
- package/dist/host-opencode.js +627 -0
- package/dist/host-opencode.js.map +1 -0
- package/dist/host-pi.d.ts +45 -0
- package/dist/host-pi.js +258 -0
- package/dist/host-pi.js.map +1 -0
- package/dist/install-config.d.ts +36 -0
- package/dist/install-config.js +136 -0
- package/dist/install-config.js.map +1 -0
- package/dist/install.d.ts +16 -0
- package/dist/install.js +168 -0
- package/dist/install.js.map +1 -0
- package/dist/launcher.d.ts +2 -0
- package/dist/launcher.js +124 -0
- package/dist/launcher.js.map +1 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +732 -0
- package/dist/main.js.map +1 -0
- package/dist/mock-pi-session.d.ts +27 -0
- package/dist/mock-pi-session.js +138 -0
- package/dist/mock-pi-session.js.map +1 -0
- package/dist/open-browser.d.ts +1 -0
- package/dist/open-browser.js +29 -0
- package/dist/open-browser.js.map +1 -0
- package/dist/opencode-plugin.d.ts +3 -0
- package/dist/opencode-plugin.js +326 -0
- package/dist/opencode-plugin.js.map +1 -0
- package/dist/prototype-pdf.d.ts +12 -0
- package/dist/prototype-pdf.js +991 -0
- package/dist/prototype-pdf.js.map +1 -0
- package/dist/prototype-server.d.ts +88 -0
- package/dist/prototype-server.js +1002 -0
- package/dist/prototype-server.js.map +1 -0
- package/dist/prototype-theme.d.ts +36 -0
- package/dist/prototype-theme.js +1471 -0
- package/dist/prototype-theme.js.map +1 -0
- package/dist/studio-core.d.ts +63 -0
- package/dist/studio-core.js +251 -0
- package/dist/studio-core.js.map +1 -0
- package/dist/studio-host-types.d.ts +50 -0
- package/dist/studio-host-types.js +14 -0
- package/dist/studio-host-types.js.map +1 -0
- package/examples/opencode/INSTALL.md +67 -0
- package/examples/opencode/opencode.local-path.jsonc +16 -0
- package/package.json +68 -0
- package/static/prototype.css +1277 -0
- package/static/prototype.html +173 -0
- package/static/prototype.js +3198 -0
|
@@ -0,0 +1,1002 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { readFile, writeFile, mkdir, stat } from "node:fs/promises";
|
|
5
|
+
import { basename, dirname, extname, isAbsolute, resolve } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { createOpencodeStudioHost } from "./host-opencode.js";
|
|
9
|
+
import { renderPrototypePdfWithPandoc, sanitizePrototypePdfFilename, PROTOTYPE_PDF_EXPORT_MAX_CHARS } from "./prototype-pdf.js";
|
|
10
|
+
import { buildPrototypeThemeStylesheet, readPrototypeThemeDescriptor } from "./prototype-theme.js";
|
|
11
|
+
const STATIC_DIR = resolve(fileURLToPath(new URL("../static", import.meta.url)));
|
|
12
|
+
const MAX_LOG_LINES = 200;
|
|
13
|
+
const REQUEST_BODY_MAX_BYTES = 1_000_000;
|
|
14
|
+
const PROTOTYPE_TOKEN_HEADER = "x-pi-studio-token";
|
|
15
|
+
function createPrototypeAccessToken() {
|
|
16
|
+
return randomUUID();
|
|
17
|
+
}
|
|
18
|
+
function buildPrototypeAccessUrl(host, port, token) {
|
|
19
|
+
return `http://${host}:${port}/?token=${encodeURIComponent(token)}`;
|
|
20
|
+
}
|
|
21
|
+
function readPrototypeRequestToken(request, url) {
|
|
22
|
+
const headerValue = request.headers[PROTOTYPE_TOKEN_HEADER];
|
|
23
|
+
const headerToken = Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
|
24
|
+
if (typeof headerToken === "string" && headerToken.trim()) {
|
|
25
|
+
return headerToken.trim();
|
|
26
|
+
}
|
|
27
|
+
return url.searchParams.get("token")?.trim() ?? "";
|
|
28
|
+
}
|
|
29
|
+
function hasValidPrototypeToken(request, url, token) {
|
|
30
|
+
return readPrototypeRequestToken(request, url) === token;
|
|
31
|
+
}
|
|
32
|
+
function respondInvalidPrototypeToken(response) {
|
|
33
|
+
sendJson(response, 403, { error: "Invalid or expired studio token. Re-run /studio." });
|
|
34
|
+
}
|
|
35
|
+
async function buildPrototypeHtml(theme, token) {
|
|
36
|
+
const templatePath = resolve(STATIC_DIR, "prototype.html");
|
|
37
|
+
const template = await readFile(templatePath, "utf8");
|
|
38
|
+
const bootJson = JSON.stringify({
|
|
39
|
+
token,
|
|
40
|
+
theme: {
|
|
41
|
+
raw: theme.raw,
|
|
42
|
+
preference: theme.preference,
|
|
43
|
+
source: theme.source,
|
|
44
|
+
family: theme.family,
|
|
45
|
+
},
|
|
46
|
+
}).replace(/</g, "\\u003c");
|
|
47
|
+
const themeStyles = buildPrototypeThemeStylesheet(theme);
|
|
48
|
+
const stylesheetHref = `/static/prototype.css?token=${encodeURIComponent(token)}`;
|
|
49
|
+
const scriptHref = `/static/prototype.js?token=${encodeURIComponent(token)}`;
|
|
50
|
+
return template
|
|
51
|
+
.replace('<link rel="stylesheet" href="/static/prototype.css" />', `<link rel="stylesheet" href="${stylesheetHref}" />\n <style id="pi-studio-opencode-theme">\n${themeStyles}\n </style>\n <script>window.__PI_STUDIO_OPENCODE_BOOT__ = ${bootJson};</script>`)
|
|
52
|
+
.replace('<script type="module" src="/static/prototype.js"></script>', `<script type="module" src="${scriptHref}"></script>`);
|
|
53
|
+
}
|
|
54
|
+
function expandHome(input) {
|
|
55
|
+
if (!input)
|
|
56
|
+
return input;
|
|
57
|
+
if (input === "~")
|
|
58
|
+
return homedir();
|
|
59
|
+
if (input.startsWith("~/"))
|
|
60
|
+
return resolve(homedir(), input.slice(2));
|
|
61
|
+
return input;
|
|
62
|
+
}
|
|
63
|
+
function resolvePrototypeBaseDir(sourcePath, resourceDir, fallbackCwd) {
|
|
64
|
+
const source = typeof sourcePath === "string" ? sourcePath.trim() : "";
|
|
65
|
+
if (source) {
|
|
66
|
+
const expanded = expandHome(source);
|
|
67
|
+
return dirname(isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded));
|
|
68
|
+
}
|
|
69
|
+
const resource = typeof resourceDir === "string" ? resourceDir.trim() : "";
|
|
70
|
+
if (resource) {
|
|
71
|
+
const expanded = expandHome(resource);
|
|
72
|
+
return isAbsolute(expanded) ? expanded : resolve(fallbackCwd, expanded);
|
|
73
|
+
}
|
|
74
|
+
return fallbackCwd;
|
|
75
|
+
}
|
|
76
|
+
function resolvePrototypeUserPath(targetPath, baseDir, fallbackCwd) {
|
|
77
|
+
const trimmed = String(targetPath || "").trim();
|
|
78
|
+
if (!trimmed) {
|
|
79
|
+
throw new Error("Path is required.");
|
|
80
|
+
}
|
|
81
|
+
const expanded = expandHome(trimmed);
|
|
82
|
+
if (isAbsolute(expanded)) {
|
|
83
|
+
return expanded;
|
|
84
|
+
}
|
|
85
|
+
const base = typeof baseDir === "string" && baseDir.trim()
|
|
86
|
+
? resolvePrototypeBaseDir(undefined, baseDir, fallbackCwd)
|
|
87
|
+
: fallbackCwd;
|
|
88
|
+
return resolve(base, expanded);
|
|
89
|
+
}
|
|
90
|
+
async function resolvePrototypePandocWorkingDir(baseDir) {
|
|
91
|
+
const normalized = typeof baseDir === "string" ? baseDir.trim() : "";
|
|
92
|
+
if (!normalized)
|
|
93
|
+
return undefined;
|
|
94
|
+
try {
|
|
95
|
+
return (await stat(normalized)).isDirectory() ? normalized : undefined;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function parseArgs(argv) {
|
|
102
|
+
const options = {
|
|
103
|
+
directory: process.cwd(),
|
|
104
|
+
host: "127.0.0.1",
|
|
105
|
+
port: 4312,
|
|
106
|
+
};
|
|
107
|
+
for (let i = 0; i < argv.length; i++) {
|
|
108
|
+
const arg = argv[i];
|
|
109
|
+
const next = argv[i + 1];
|
|
110
|
+
if (arg === "--directory" && next) {
|
|
111
|
+
options.directory = next;
|
|
112
|
+
i += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (arg === "--base-url" && next) {
|
|
116
|
+
options.baseUrl = next;
|
|
117
|
+
i += 1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (arg === "--session" && next) {
|
|
121
|
+
options.sessionId = next;
|
|
122
|
+
i += 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (arg === "--title" && next) {
|
|
126
|
+
options.title = next;
|
|
127
|
+
i += 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (arg === "--host" && next) {
|
|
131
|
+
options.host = next;
|
|
132
|
+
i += 1;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (arg === "--port" && next) {
|
|
136
|
+
options.port = Number.parseInt(next, 10);
|
|
137
|
+
if (!Number.isFinite(options.port) || options.port < 0) {
|
|
138
|
+
throw new Error(`Invalid --port value: ${next}`);
|
|
139
|
+
}
|
|
140
|
+
i += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (arg === "--help" || arg === "-h") {
|
|
144
|
+
printUsageAndExit();
|
|
145
|
+
}
|
|
146
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
147
|
+
}
|
|
148
|
+
return options;
|
|
149
|
+
}
|
|
150
|
+
function printUsageAndExit() {
|
|
151
|
+
console.log(`Usage: npm run prototype -- [options]
|
|
152
|
+
|
|
153
|
+
Options:
|
|
154
|
+
--directory <path> Project directory / working directory
|
|
155
|
+
--base-url <url> Use an existing opencode server
|
|
156
|
+
--session <id> Reuse an existing session
|
|
157
|
+
--title <title> Title for a newly created session
|
|
158
|
+
--host <host> HTTP bind host (default: 127.0.0.1)
|
|
159
|
+
--port <port> HTTP bind port (default: 4312; use 0 for auto-select)
|
|
160
|
+
`);
|
|
161
|
+
process.exit(0);
|
|
162
|
+
}
|
|
163
|
+
async function readJsonBody(request) {
|
|
164
|
+
const chunks = [];
|
|
165
|
+
for await (const chunk of request) {
|
|
166
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
167
|
+
}
|
|
168
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
169
|
+
if (!text) {
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
return JSON.parse(text);
|
|
173
|
+
}
|
|
174
|
+
function sendJson(response, statusCode, payload) {
|
|
175
|
+
response.statusCode = statusCode;
|
|
176
|
+
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
177
|
+
response.end(JSON.stringify(payload, null, 2));
|
|
178
|
+
}
|
|
179
|
+
function sendText(response, statusCode, body, contentType = "text/plain; charset=utf-8") {
|
|
180
|
+
response.statusCode = statusCode;
|
|
181
|
+
response.setHeader("Content-Type", contentType);
|
|
182
|
+
response.end(body);
|
|
183
|
+
}
|
|
184
|
+
function buildAttachmentContentDisposition(filename) {
|
|
185
|
+
const fallbackName = filename.replace(/[^\x00-\x7f]+/g, "_").replace(/["\\]/g, "_") || "studio-preview.pdf";
|
|
186
|
+
return `attachment; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(filename)}`;
|
|
187
|
+
}
|
|
188
|
+
function sendBinary(response, statusCode, content, contentType, extraHeaders = {}) {
|
|
189
|
+
response.statusCode = statusCode;
|
|
190
|
+
response.setHeader("Content-Type", contentType);
|
|
191
|
+
response.setHeader("Content-Length", String(content.length));
|
|
192
|
+
for (const [key, value] of Object.entries(extraHeaders)) {
|
|
193
|
+
response.setHeader(key, value);
|
|
194
|
+
}
|
|
195
|
+
response.end(content);
|
|
196
|
+
}
|
|
197
|
+
function contentTypeForPath(pathname) {
|
|
198
|
+
switch (extname(pathname)) {
|
|
199
|
+
case ".html":
|
|
200
|
+
return "text/html; charset=utf-8";
|
|
201
|
+
case ".css":
|
|
202
|
+
return "text/css; charset=utf-8";
|
|
203
|
+
case ".js":
|
|
204
|
+
return "text/javascript; charset=utf-8";
|
|
205
|
+
case ".json":
|
|
206
|
+
return "application/json; charset=utf-8";
|
|
207
|
+
default:
|
|
208
|
+
return "application/octet-stream";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async function serveStatic(response, pathname) {
|
|
212
|
+
const relativePath = pathname.replace(/^\/static\//, "");
|
|
213
|
+
const filePath = resolve(STATIC_DIR, relativePath);
|
|
214
|
+
if (!filePath.startsWith(STATIC_DIR)) {
|
|
215
|
+
sendText(response, 403, "Forbidden");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
const content = await readFile(filePath);
|
|
220
|
+
response.statusCode = 200;
|
|
221
|
+
response.setHeader("Content-Type", contentTypeForPath(filePath));
|
|
222
|
+
response.setHeader("Cache-Control", "no-store");
|
|
223
|
+
response.end(content);
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
sendText(response, 404, error instanceof Error ? error.message : "Not found");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function normalizePrompt(payload) {
|
|
230
|
+
const prompt = typeof payload.prompt === "string" ? payload.prompt.trim() : "";
|
|
231
|
+
if (!prompt) {
|
|
232
|
+
throw new Error("Prompt text is required.");
|
|
233
|
+
}
|
|
234
|
+
return prompt;
|
|
235
|
+
}
|
|
236
|
+
function createTurnRecord(item) {
|
|
237
|
+
return {
|
|
238
|
+
localPromptId: item.localPromptId,
|
|
239
|
+
chainIndex: item.chainIndex,
|
|
240
|
+
promptMode: item.promptMode,
|
|
241
|
+
promptSteeringCount: item.promptSteeringCount,
|
|
242
|
+
promptText: item.promptText,
|
|
243
|
+
submittedAt: item.submittedAt,
|
|
244
|
+
outputPreview: "",
|
|
245
|
+
latestTextParts: new Map(),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
function snapshotTurn(turn) {
|
|
249
|
+
if (!turn)
|
|
250
|
+
return null;
|
|
251
|
+
return {
|
|
252
|
+
localPromptId: turn.localPromptId,
|
|
253
|
+
chainIndex: turn.chainIndex,
|
|
254
|
+
promptMode: turn.promptMode,
|
|
255
|
+
promptSteeringCount: turn.promptSteeringCount,
|
|
256
|
+
promptText: turn.promptText,
|
|
257
|
+
submittedAt: turn.submittedAt,
|
|
258
|
+
backendBusyAt: turn.backendBusyAt,
|
|
259
|
+
firstAssistantMessageAt: turn.firstAssistantMessageAt,
|
|
260
|
+
firstOutputTextAt: turn.firstOutputTextAt,
|
|
261
|
+
latestAssistantMessageId: turn.latestAssistantMessageId,
|
|
262
|
+
latestPartType: turn.latestPartType,
|
|
263
|
+
outputPreview: turn.outputPreview,
|
|
264
|
+
completedAt: turn.completedAt,
|
|
265
|
+
responseText: turn.responseText,
|
|
266
|
+
responseError: turn.responseError,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function isLikelyMathExpression(expr) {
|
|
270
|
+
const content = expr.trim();
|
|
271
|
+
if (content.length === 0)
|
|
272
|
+
return false;
|
|
273
|
+
if (/\\[a-zA-Z]+/.test(content))
|
|
274
|
+
return true;
|
|
275
|
+
if (/[0-9]/.test(content))
|
|
276
|
+
return true;
|
|
277
|
+
if (/[=+\-*/^_<>≤≥±×÷]/u.test(content))
|
|
278
|
+
return true;
|
|
279
|
+
if (/[{}]/.test(content))
|
|
280
|
+
return true;
|
|
281
|
+
if (/[α-ωΑ-Ω]/u.test(content))
|
|
282
|
+
return true;
|
|
283
|
+
if (/^[A-Za-z]$/.test(content))
|
|
284
|
+
return true;
|
|
285
|
+
if (/^[A-Za-z][A-Za-z\s'".,:;!?-]*[A-Za-z]$/.test(content))
|
|
286
|
+
return false;
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
function collapseDisplayMathContent(expr) {
|
|
290
|
+
let content = expr.trim();
|
|
291
|
+
if (/\\begin\{[^}]+\}|\\end\{[^}]+\}/.test(content)) {
|
|
292
|
+
return content;
|
|
293
|
+
}
|
|
294
|
+
if (content.includes("\\\\") || content.includes("\n")) {
|
|
295
|
+
content = content.replace(/\\\\\s*/g, " ");
|
|
296
|
+
content = content.replace(/\s*\n\s*/g, " ");
|
|
297
|
+
content = content.replace(/\s{2,}/g, " ").trim();
|
|
298
|
+
}
|
|
299
|
+
return content;
|
|
300
|
+
}
|
|
301
|
+
function normalizeMathDelimitersInSegment(markdown) {
|
|
302
|
+
let normalized = markdown.replace(/\$\s*\\\(([\s\S]*?)\\\)\s*\$/g, (match, expr) => {
|
|
303
|
+
if (!isLikelyMathExpression(expr))
|
|
304
|
+
return match;
|
|
305
|
+
const content = expr.trim();
|
|
306
|
+
return content.length > 0 ? `\\(${content}\\)` : "\\(\\)";
|
|
307
|
+
});
|
|
308
|
+
normalized = normalized.replace(/\$\s*\\\[\s*([\s\S]*?)\s*\\\]\s*\$/g, (match, expr) => {
|
|
309
|
+
if (!isLikelyMathExpression(expr))
|
|
310
|
+
return match;
|
|
311
|
+
const content = collapseDisplayMathContent(expr);
|
|
312
|
+
return content.length > 0 ? `\\[${content}\\]` : "\\[\\]";
|
|
313
|
+
});
|
|
314
|
+
normalized = normalized.replace(/\\\[\s*([\s\S]*?)\s*\\\]/g, (match, expr) => {
|
|
315
|
+
if (!isLikelyMathExpression(expr))
|
|
316
|
+
return `[${expr.trim()}]`;
|
|
317
|
+
const content = collapseDisplayMathContent(expr);
|
|
318
|
+
return content.length > 0 ? `\\[${content}\\]` : "\\[\\]";
|
|
319
|
+
});
|
|
320
|
+
normalized = normalized.replace(/\\\(([\s\S]*?)\\\)/g, (match, expr) => {
|
|
321
|
+
if (!isLikelyMathExpression(expr))
|
|
322
|
+
return `(${expr})`;
|
|
323
|
+
const content = expr.trim();
|
|
324
|
+
return content.length > 0 ? `\\(${content}\\)` : "\\(\\)";
|
|
325
|
+
});
|
|
326
|
+
return normalized;
|
|
327
|
+
}
|
|
328
|
+
function normalizeMathDelimiters(markdown) {
|
|
329
|
+
const lines = markdown.split("\n");
|
|
330
|
+
const out = [];
|
|
331
|
+
let plainBuffer = [];
|
|
332
|
+
let inFence = false;
|
|
333
|
+
let fenceChar;
|
|
334
|
+
let fenceLength = 0;
|
|
335
|
+
const flushPlain = () => {
|
|
336
|
+
if (plainBuffer.length === 0)
|
|
337
|
+
return;
|
|
338
|
+
out.push(normalizeMathDelimitersInSegment(plainBuffer.join("\n")));
|
|
339
|
+
plainBuffer = [];
|
|
340
|
+
};
|
|
341
|
+
for (const line of lines) {
|
|
342
|
+
const trimmed = line.trimStart();
|
|
343
|
+
const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
|
|
344
|
+
if (fenceMatch) {
|
|
345
|
+
const marker = fenceMatch[1];
|
|
346
|
+
const markerChar = marker[0];
|
|
347
|
+
const markerLength = marker.length;
|
|
348
|
+
if (!inFence) {
|
|
349
|
+
flushPlain();
|
|
350
|
+
inFence = true;
|
|
351
|
+
fenceChar = markerChar;
|
|
352
|
+
fenceLength = markerLength;
|
|
353
|
+
out.push(line);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (fenceChar === markerChar && markerLength >= fenceLength) {
|
|
357
|
+
inFence = false;
|
|
358
|
+
fenceChar = undefined;
|
|
359
|
+
fenceLength = 0;
|
|
360
|
+
}
|
|
361
|
+
out.push(line);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (inFence) {
|
|
365
|
+
out.push(line);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
plainBuffer.push(line);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
flushPlain();
|
|
372
|
+
return out.join("\n");
|
|
373
|
+
}
|
|
374
|
+
function normalizeObsidianImages(markdown) {
|
|
375
|
+
return markdown
|
|
376
|
+
.replace(/!\[\[([^|\]]+)\|([^\]]+)\]\]/g, (_m, path, alt) => ``)
|
|
377
|
+
.replace(/!\[\[([^\]]+)\]\]/g, (_m, path) => ``);
|
|
378
|
+
}
|
|
379
|
+
function stripPrototypeMarkdownHtmlCommentsInSegment(markdown) {
|
|
380
|
+
const source = String(markdown ?? "");
|
|
381
|
+
let out = "";
|
|
382
|
+
let i = 0;
|
|
383
|
+
let codeSpanFenceLength = 0;
|
|
384
|
+
let inHtmlComment = false;
|
|
385
|
+
while (i < source.length) {
|
|
386
|
+
if (inHtmlComment) {
|
|
387
|
+
if (source.startsWith("-->", i)) {
|
|
388
|
+
inHtmlComment = false;
|
|
389
|
+
i += 3;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
const ch = source[i];
|
|
393
|
+
if (ch === "\n" || ch === "\r")
|
|
394
|
+
out += ch;
|
|
395
|
+
i += 1;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (codeSpanFenceLength > 0) {
|
|
399
|
+
const fence = "`".repeat(codeSpanFenceLength);
|
|
400
|
+
if (source.startsWith(fence, i)) {
|
|
401
|
+
out += fence;
|
|
402
|
+
i += codeSpanFenceLength;
|
|
403
|
+
codeSpanFenceLength = 0;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
out += source[i];
|
|
407
|
+
i += 1;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const backtickMatch = source.slice(i).match(/^`+/);
|
|
411
|
+
if (backtickMatch) {
|
|
412
|
+
const fence = backtickMatch[0];
|
|
413
|
+
codeSpanFenceLength = fence.length;
|
|
414
|
+
out += fence;
|
|
415
|
+
i += fence.length;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (source.startsWith("<!--", i)) {
|
|
419
|
+
inHtmlComment = true;
|
|
420
|
+
i += 4;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
out += source[i];
|
|
424
|
+
i += 1;
|
|
425
|
+
}
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
function stripPrototypeMarkdownHtmlComments(markdown) {
|
|
429
|
+
const lines = String(markdown ?? "").split("\n");
|
|
430
|
+
const out = [];
|
|
431
|
+
let plainBuffer = [];
|
|
432
|
+
let inFence = false;
|
|
433
|
+
let fenceChar;
|
|
434
|
+
let fenceLength = 0;
|
|
435
|
+
const flushPlain = () => {
|
|
436
|
+
if (plainBuffer.length === 0)
|
|
437
|
+
return;
|
|
438
|
+
out.push(stripPrototypeMarkdownHtmlCommentsInSegment(plainBuffer.join("\n")));
|
|
439
|
+
plainBuffer = [];
|
|
440
|
+
};
|
|
441
|
+
for (const line of lines) {
|
|
442
|
+
const trimmed = line.trimStart();
|
|
443
|
+
const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
|
|
444
|
+
if (fenceMatch) {
|
|
445
|
+
const marker = fenceMatch[1];
|
|
446
|
+
const markerChar = marker[0];
|
|
447
|
+
const markerLength = marker.length;
|
|
448
|
+
if (!inFence) {
|
|
449
|
+
flushPlain();
|
|
450
|
+
inFence = true;
|
|
451
|
+
fenceChar = markerChar;
|
|
452
|
+
fenceLength = markerLength;
|
|
453
|
+
out.push(line);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (fenceChar === markerChar && markerLength >= fenceLength) {
|
|
457
|
+
inFence = false;
|
|
458
|
+
fenceChar = undefined;
|
|
459
|
+
fenceLength = 0;
|
|
460
|
+
}
|
|
461
|
+
out.push(line);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (inFence) {
|
|
465
|
+
out.push(line);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
plainBuffer.push(line);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
flushPlain();
|
|
472
|
+
return out.join("\n");
|
|
473
|
+
}
|
|
474
|
+
const PROTOTYPE_PREVIEW_PAGE_BREAK_SENTINEL_PREFIX = "PI_STUDIO_PAGE_BREAK__";
|
|
475
|
+
function replacePrototypePreviewPageBreakCommands(markdown) {
|
|
476
|
+
const lines = String(markdown ?? "").split("\n");
|
|
477
|
+
const out = [];
|
|
478
|
+
let plainBuffer = [];
|
|
479
|
+
let inFence = false;
|
|
480
|
+
let fenceChar;
|
|
481
|
+
let fenceLength = 0;
|
|
482
|
+
const flushPlain = () => {
|
|
483
|
+
if (plainBuffer.length === 0)
|
|
484
|
+
return;
|
|
485
|
+
out.push(plainBuffer.map((line) => {
|
|
486
|
+
const match = line.trim().match(/^\\(newpage|pagebreak|clearpage)(?:\s*\[[^\]]*\])?\s*$/i);
|
|
487
|
+
if (!match)
|
|
488
|
+
return line;
|
|
489
|
+
const command = match[1].toLowerCase();
|
|
490
|
+
return `${PROTOTYPE_PREVIEW_PAGE_BREAK_SENTINEL_PREFIX}${command.toUpperCase()}__`;
|
|
491
|
+
}).join("\n"));
|
|
492
|
+
plainBuffer = [];
|
|
493
|
+
};
|
|
494
|
+
for (const line of lines) {
|
|
495
|
+
const trimmed = line.trimStart();
|
|
496
|
+
const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/);
|
|
497
|
+
if (fenceMatch) {
|
|
498
|
+
const marker = fenceMatch[1];
|
|
499
|
+
const markerChar = marker[0];
|
|
500
|
+
const markerLength = marker.length;
|
|
501
|
+
if (!inFence) {
|
|
502
|
+
flushPlain();
|
|
503
|
+
inFence = true;
|
|
504
|
+
fenceChar = markerChar;
|
|
505
|
+
fenceLength = markerLength;
|
|
506
|
+
out.push(line);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (fenceChar === markerChar && markerLength >= fenceLength) {
|
|
510
|
+
inFence = false;
|
|
511
|
+
fenceChar = undefined;
|
|
512
|
+
fenceLength = 0;
|
|
513
|
+
}
|
|
514
|
+
out.push(line);
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (inFence) {
|
|
518
|
+
out.push(line);
|
|
519
|
+
}
|
|
520
|
+
else {
|
|
521
|
+
plainBuffer.push(line);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
flushPlain();
|
|
525
|
+
return out.join("\n");
|
|
526
|
+
}
|
|
527
|
+
function escapePrototypeHtmlText(value) {
|
|
528
|
+
return String(value ?? "")
|
|
529
|
+
.replace(/&/g, "&")
|
|
530
|
+
.replace(/</g, "<")
|
|
531
|
+
.replace(/>/g, ">")
|
|
532
|
+
.replace(/\"/g, """)
|
|
533
|
+
.replace(/'/g, "'");
|
|
534
|
+
}
|
|
535
|
+
function decoratePrototypePreviewPageBreakHtml(html) {
|
|
536
|
+
return String(html ?? "").replace(new RegExp(`<p>${PROTOTYPE_PREVIEW_PAGE_BREAK_SENTINEL_PREFIX}(NEWPAGE|PAGEBREAK|CLEARPAGE)__<\\/p>`, "gi"), (_match, command) => {
|
|
537
|
+
const normalized = String(command || "").toLowerCase();
|
|
538
|
+
const label = normalized === "clearpage" ? "Clear page" : "Page break";
|
|
539
|
+
return `<div class="studio-page-break" data-page-break-kind="${normalized}"><span class="studio-page-break-rule" aria-hidden="true"></span><span class="studio-page-break-label">${escapePrototypeHtmlText(label)}</span><span class="studio-page-break-rule" aria-hidden="true"></span></div>`;
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
function stripMathMlAnnotationTags(html) {
|
|
543
|
+
return html
|
|
544
|
+
.replace(/<annotation-xml\b[\s\S]*?<\/annotation-xml>/gi, "")
|
|
545
|
+
.replace(/<annotation\b[\s\S]*?<\/annotation>/gi, "");
|
|
546
|
+
}
|
|
547
|
+
async function renderPrototypeMarkdownWithPandoc(markdown, resourcePath) {
|
|
548
|
+
const pandocCommand = process.env.PANDOC_PATH?.trim() || "pandoc";
|
|
549
|
+
const isLatex = /\\documentclass\b|\\begin\{document\}/.test(markdown);
|
|
550
|
+
const markdownWithoutHtmlComments = isLatex ? String(markdown || "") : stripPrototypeMarkdownHtmlComments(String(markdown || ""));
|
|
551
|
+
const markdownWithPreviewPageBreaks = isLatex ? markdownWithoutHtmlComments : replacePrototypePreviewPageBreakCommands(markdownWithoutHtmlComments);
|
|
552
|
+
const inputFormat = isLatex
|
|
553
|
+
? "latex"
|
|
554
|
+
: "markdown+lists_without_preceding_blankline-blank_before_blockquote-blank_before_header+tex_math_dollars+tex_math_single_backslash+tex_math_double_backslash+autolink_bare_uris-raw_html";
|
|
555
|
+
const args = ["-f", inputFormat, "-t", "html5", "--mathml", "--wrap=none"];
|
|
556
|
+
if (resourcePath) {
|
|
557
|
+
args.push(`--resource-path=${resourcePath}`);
|
|
558
|
+
args.push("--embed-resources", "--standalone");
|
|
559
|
+
}
|
|
560
|
+
const normalizedMarkdown = isLatex
|
|
561
|
+
? markdownWithPreviewPageBreaks
|
|
562
|
+
: normalizeObsidianImages(normalizeMathDelimiters(markdownWithPreviewPageBreaks));
|
|
563
|
+
const pandocWorkingDir = await resolvePrototypePandocWorkingDir(resourcePath);
|
|
564
|
+
return await new Promise((resolvePromise, rejectPromise) => {
|
|
565
|
+
const child = spawn(pandocCommand, args, { stdio: ["pipe", "pipe", "pipe"], cwd: pandocWorkingDir });
|
|
566
|
+
const stdoutChunks = [];
|
|
567
|
+
const stderrChunks = [];
|
|
568
|
+
let settled = false;
|
|
569
|
+
const fail = (error) => {
|
|
570
|
+
if (settled)
|
|
571
|
+
return;
|
|
572
|
+
settled = true;
|
|
573
|
+
rejectPromise(error);
|
|
574
|
+
};
|
|
575
|
+
const succeed = (html) => {
|
|
576
|
+
if (settled)
|
|
577
|
+
return;
|
|
578
|
+
settled = true;
|
|
579
|
+
resolvePromise(html);
|
|
580
|
+
};
|
|
581
|
+
child.stdout.on("data", (chunk) => {
|
|
582
|
+
stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
583
|
+
});
|
|
584
|
+
child.stderr.on("data", (chunk) => {
|
|
585
|
+
stderrChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
586
|
+
});
|
|
587
|
+
child.once("error", (error) => {
|
|
588
|
+
const errno = error;
|
|
589
|
+
if (errno.code === "ENOENT") {
|
|
590
|
+
fail(new Error("pandoc was not found. Install pandoc or set PANDOC_PATH to the pandoc binary."));
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
fail(error);
|
|
594
|
+
});
|
|
595
|
+
child.once("close", (code) => {
|
|
596
|
+
if (settled)
|
|
597
|
+
return;
|
|
598
|
+
if (code === 0) {
|
|
599
|
+
let renderedHtml = Buffer.concat(stdoutChunks).toString("utf-8");
|
|
600
|
+
if (resourcePath) {
|
|
601
|
+
const bodyMatch = renderedHtml.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
|
602
|
+
if (bodyMatch) {
|
|
603
|
+
renderedHtml = bodyMatch[1] ?? renderedHtml;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (!isLatex) {
|
|
607
|
+
renderedHtml = decoratePrototypePreviewPageBreakHtml(renderedHtml);
|
|
608
|
+
}
|
|
609
|
+
succeed(stripMathMlAnnotationTags(renderedHtml));
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
613
|
+
fail(new Error(`pandoc failed with exit code ${code}${stderr ? `: ${stderr}` : ""}`));
|
|
614
|
+
});
|
|
615
|
+
child.stdin.end(normalizedMarkdown);
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
export async function startPrototypeServer(options, hostFactory) {
|
|
619
|
+
const serverStartedAt = Date.now();
|
|
620
|
+
const accessToken = createPrototypeAccessToken();
|
|
621
|
+
let theme = await readPrototypeThemeDescriptor(options.directory);
|
|
622
|
+
let themeReadAt = Date.now();
|
|
623
|
+
const refreshTheme = async (force = false) => {
|
|
624
|
+
const now = Date.now();
|
|
625
|
+
if (!force && now - themeReadAt < 1000) {
|
|
626
|
+
return theme;
|
|
627
|
+
}
|
|
628
|
+
try {
|
|
629
|
+
theme = await readPrototypeThemeDescriptor(options.directory);
|
|
630
|
+
themeReadAt = now;
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
// keep the last successfully loaded theme
|
|
634
|
+
}
|
|
635
|
+
return theme;
|
|
636
|
+
};
|
|
637
|
+
const logLines = [];
|
|
638
|
+
let activeTurn = null;
|
|
639
|
+
let lastCompletedTurn = null;
|
|
640
|
+
let currentModel = null;
|
|
641
|
+
let listenHost = options.host;
|
|
642
|
+
let listenPort = options.port;
|
|
643
|
+
let stopped = false;
|
|
644
|
+
const modelCatalogByKey = new Map((options.modelCatalog ?? []).map((entry) => [`${entry.providerID}/${entry.modelID}`, entry]));
|
|
645
|
+
const updateCurrentModel = (input) => {
|
|
646
|
+
if (!input.providerID || !input.modelID)
|
|
647
|
+
return;
|
|
648
|
+
const modelKey = `${input.providerID}/${input.modelID}`;
|
|
649
|
+
const catalogEntry = modelCatalogByKey.get(modelKey);
|
|
650
|
+
const sameModel = currentModel && currentModel.providerID === input.providerID && currentModel.modelID === input.modelID;
|
|
651
|
+
currentModel = {
|
|
652
|
+
providerID: input.providerID,
|
|
653
|
+
modelID: input.modelID,
|
|
654
|
+
agent: input.agent ?? (sameModel ? currentModel?.agent : undefined),
|
|
655
|
+
variant: input.variant ?? (sameModel ? currentModel?.variant : undefined),
|
|
656
|
+
tokenUsage: input.tokenUsage ?? (sameModel ? currentModel?.tokenUsage : undefined),
|
|
657
|
+
contextLimit: catalogEntry?.contextLimit ?? (sameModel ? currentModel?.contextLimit : undefined),
|
|
658
|
+
source: input.source,
|
|
659
|
+
messageId: input.messageId,
|
|
660
|
+
at: input.at,
|
|
661
|
+
};
|
|
662
|
+
};
|
|
663
|
+
const handleTelemetry = (event) => {
|
|
664
|
+
if (event.type === "submission.dispatched") {
|
|
665
|
+
activeTurn = createTurnRecord(event.submission);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
if (event.type === "backend.status") {
|
|
669
|
+
if (activeTurn && event.status === "busy" && !activeTurn.backendBusyAt) {
|
|
670
|
+
activeTurn.backendBusyAt = event.at;
|
|
671
|
+
}
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (event.type === "user.message.updated") {
|
|
675
|
+
updateCurrentModel({
|
|
676
|
+
providerID: event.providerID,
|
|
677
|
+
modelID: event.modelID,
|
|
678
|
+
agent: event.agent,
|
|
679
|
+
variant: event.variant,
|
|
680
|
+
source: "user",
|
|
681
|
+
messageId: event.messageId,
|
|
682
|
+
at: event.at,
|
|
683
|
+
});
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
if (event.type === "assistant.message.updated") {
|
|
687
|
+
updateCurrentModel({
|
|
688
|
+
providerID: event.providerID,
|
|
689
|
+
modelID: event.modelID,
|
|
690
|
+
agent: event.agent,
|
|
691
|
+
variant: event.variant,
|
|
692
|
+
tokenUsage: event.tokenUsage,
|
|
693
|
+
source: "assistant",
|
|
694
|
+
messageId: event.messageId,
|
|
695
|
+
at: event.at,
|
|
696
|
+
});
|
|
697
|
+
if (!activeTurn)
|
|
698
|
+
return;
|
|
699
|
+
if (!activeTurn.firstAssistantMessageAt) {
|
|
700
|
+
activeTurn.firstAssistantMessageAt = event.at;
|
|
701
|
+
}
|
|
702
|
+
activeTurn.latestAssistantMessageId = event.messageId;
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (event.type === "assistant.part.updated") {
|
|
706
|
+
if (!activeTurn)
|
|
707
|
+
return;
|
|
708
|
+
activeTurn.latestAssistantMessageId = event.messageId;
|
|
709
|
+
activeTurn.latestPartType = event.partType;
|
|
710
|
+
if (event.partType === "text" && typeof event.text === "string") {
|
|
711
|
+
activeTurn.latestTextParts.set(event.partId, event.text);
|
|
712
|
+
activeTurn.outputPreview = Array.from(activeTurn.latestTextParts.values()).join("\n\n").trim();
|
|
713
|
+
if (activeTurn.outputPreview && !activeTurn.firstOutputTextAt) {
|
|
714
|
+
activeTurn.firstOutputTextAt = event.at;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (event.type === "assistant.part.delta") {
|
|
720
|
+
if (!activeTurn)
|
|
721
|
+
return;
|
|
722
|
+
if (event.field !== "text")
|
|
723
|
+
return;
|
|
724
|
+
if (event.partType && event.partType !== "text")
|
|
725
|
+
return;
|
|
726
|
+
const prior = activeTurn.latestTextParts.get(event.partId) ?? "";
|
|
727
|
+
activeTurn.latestTextParts.set(event.partId, `${prior}${event.delta}`);
|
|
728
|
+
activeTurn.outputPreview = Array.from(activeTurn.latestTextParts.values()).join("\n\n").trim();
|
|
729
|
+
activeTurn.latestAssistantMessageId = event.messageId;
|
|
730
|
+
activeTurn.latestPartType = event.partType ?? activeTurn.latestPartType;
|
|
731
|
+
if (event.delta && !activeTurn.firstOutputTextAt) {
|
|
732
|
+
activeTurn.firstOutputTextAt = event.at;
|
|
733
|
+
}
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (event.type === "submission.completed") {
|
|
737
|
+
const completed = activeTurn && activeTurn.localPromptId === event.historyItem.localPromptId
|
|
738
|
+
? activeTurn
|
|
739
|
+
: createTurnRecord(event.historyItem);
|
|
740
|
+
completed.completedAt = event.historyItem.completedAt ?? event.at;
|
|
741
|
+
completed.responseText = event.historyItem.responseText;
|
|
742
|
+
completed.responseError = event.historyItem.responseError;
|
|
743
|
+
if (typeof event.historyItem.responseText === "string" && event.historyItem.responseText.trim()) {
|
|
744
|
+
completed.outputPreview = event.historyItem.responseText;
|
|
745
|
+
}
|
|
746
|
+
lastCompletedTurn = completed;
|
|
747
|
+
activeTurn = null;
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
const eventLogger = (line) => {
|
|
751
|
+
logLines.push({ at: Date.now(), line });
|
|
752
|
+
if (logLines.length > MAX_LOG_LINES) {
|
|
753
|
+
logLines.splice(0, logLines.length - MAX_LOG_LINES);
|
|
754
|
+
}
|
|
755
|
+
if (options.consoleLogs !== false) {
|
|
756
|
+
console.log(line);
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
const host = await (hostFactory ?? (async ({ options: hostOptions, eventLogger, telemetryListener }) => {
|
|
760
|
+
return await createOpencodeStudioHost({
|
|
761
|
+
directory: hostOptions.directory,
|
|
762
|
+
baseUrl: hostOptions.baseUrl,
|
|
763
|
+
sessionId: hostOptions.sessionId,
|
|
764
|
+
title: hostOptions.title,
|
|
765
|
+
eventLogger,
|
|
766
|
+
telemetryListener,
|
|
767
|
+
});
|
|
768
|
+
}))({
|
|
769
|
+
options,
|
|
770
|
+
eventLogger,
|
|
771
|
+
telemetryListener: handleTelemetry,
|
|
772
|
+
});
|
|
773
|
+
let currentState = host.getState();
|
|
774
|
+
const capabilities = host.getCapabilities();
|
|
775
|
+
const unsubscribe = host.subscribe((state) => {
|
|
776
|
+
currentState = state;
|
|
777
|
+
});
|
|
778
|
+
function buildSnapshot() {
|
|
779
|
+
return {
|
|
780
|
+
state: currentState,
|
|
781
|
+
capabilities,
|
|
782
|
+
history: host.getHistory(),
|
|
783
|
+
logs: logLines.slice(-80),
|
|
784
|
+
activeTurn: snapshotTurn(activeTurn),
|
|
785
|
+
lastCompletedTurn: snapshotTurn(lastCompletedTurn),
|
|
786
|
+
currentModel,
|
|
787
|
+
launchContext: {
|
|
788
|
+
directory: options.directory,
|
|
789
|
+
baseUrl: options.baseUrl ?? null,
|
|
790
|
+
theme: {
|
|
791
|
+
raw: theme.raw,
|
|
792
|
+
preference: theme.preference,
|
|
793
|
+
source: theme.source,
|
|
794
|
+
family: theme.family,
|
|
795
|
+
},
|
|
796
|
+
},
|
|
797
|
+
serverStartedAt,
|
|
798
|
+
now: Date.now(),
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
const server = createServer(async (request, response) => {
|
|
802
|
+
const url = new URL(request.url ?? "/", `http://${request.headers.host ?? `${listenHost}:${listenPort}`}`);
|
|
803
|
+
try {
|
|
804
|
+
if (request.method === "GET" && url.pathname === "/") {
|
|
805
|
+
if (!hasValidPrototypeToken(request, url, accessToken)) {
|
|
806
|
+
respondInvalidPrototypeToken(response);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const pageTheme = await refreshTheme(true);
|
|
810
|
+
response.setHeader("Cache-Control", "no-store");
|
|
811
|
+
sendText(response, 200, await buildPrototypeHtml(pageTheme, accessToken), "text/html; charset=utf-8");
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (request.method === "GET" && url.pathname.startsWith("/static/")) {
|
|
815
|
+
if (!hasValidPrototypeToken(request, url, accessToken)) {
|
|
816
|
+
respondInvalidPrototypeToken(response);
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
await serveStatic(response, url.pathname);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (url.pathname.startsWith("/api/")) {
|
|
823
|
+
if (!hasValidPrototypeToken(request, url, accessToken)) {
|
|
824
|
+
respondInvalidPrototypeToken(response);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
await refreshTheme();
|
|
828
|
+
}
|
|
829
|
+
if (request.method === "GET" && url.pathname === "/api/snapshot") {
|
|
830
|
+
sendJson(response, 200, buildSnapshot());
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (request.method === "POST" && url.pathname === "/api/file/load") {
|
|
834
|
+
const payload = await readJsonBody(request);
|
|
835
|
+
const targetPath = typeof payload.path === "string" ? payload.path : "";
|
|
836
|
+
const baseDir = typeof payload.baseDir === "string" ? payload.baseDir : undefined;
|
|
837
|
+
const resolvedPath = resolvePrototypeUserPath(targetPath, baseDir, options.directory);
|
|
838
|
+
const content = await readFile(resolvedPath, "utf8");
|
|
839
|
+
sendJson(response, 200, {
|
|
840
|
+
ok: true,
|
|
841
|
+
path: resolvedPath,
|
|
842
|
+
label: basename(resolvedPath),
|
|
843
|
+
content,
|
|
844
|
+
});
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (request.method === "POST" && url.pathname === "/api/file/save") {
|
|
848
|
+
const payload = await readJsonBody(request);
|
|
849
|
+
const targetPath = typeof payload.path === "string" ? payload.path : "";
|
|
850
|
+
const content = typeof payload.content === "string" ? payload.content : "";
|
|
851
|
+
const baseDir = typeof payload.baseDir === "string" ? payload.baseDir : undefined;
|
|
852
|
+
if (!content.trim()) {
|
|
853
|
+
throw new Error("Nothing to save.");
|
|
854
|
+
}
|
|
855
|
+
const resolvedPath = resolvePrototypeUserPath(targetPath, baseDir, options.directory);
|
|
856
|
+
await mkdir(dirname(resolvedPath), { recursive: true });
|
|
857
|
+
await writeFile(resolvedPath, content, "utf8");
|
|
858
|
+
sendJson(response, 200, {
|
|
859
|
+
ok: true,
|
|
860
|
+
path: resolvedPath,
|
|
861
|
+
label: basename(resolvedPath),
|
|
862
|
+
});
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (request.method === "POST" && url.pathname === "/api/render-preview") {
|
|
866
|
+
const payload = await readJsonBody(request);
|
|
867
|
+
const markdown = typeof payload.markdown === "string" ? payload.markdown : "";
|
|
868
|
+
if (Buffer.byteLength(markdown, "utf8") > REQUEST_BODY_MAX_BYTES) {
|
|
869
|
+
sendJson(response, 413, { error: `Preview text exceeds ${REQUEST_BODY_MAX_BYTES} bytes.` });
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
const sourcePath = typeof payload.sourcePath === "string" ? payload.sourcePath : "";
|
|
873
|
+
const resourceDir = typeof payload.resourceDir === "string" ? payload.resourceDir : "";
|
|
874
|
+
const resourcePath = resolvePrototypeBaseDir(sourcePath || undefined, resourceDir || undefined, options.directory);
|
|
875
|
+
const html = await renderPrototypeMarkdownWithPandoc(markdown, resourcePath);
|
|
876
|
+
sendJson(response, 200, { ok: true, html, renderer: "pandoc" });
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
if (request.method === "POST" && url.pathname === "/api/export-pdf") {
|
|
880
|
+
const payload = await readJsonBody(request);
|
|
881
|
+
const markdown = typeof payload.markdown === "string" ? payload.markdown : "";
|
|
882
|
+
if (markdown.length > PROTOTYPE_PDF_EXPORT_MAX_CHARS) {
|
|
883
|
+
sendJson(response, 413, { error: `PDF export text exceeds ${PROTOTYPE_PDF_EXPORT_MAX_CHARS} characters.` });
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (Buffer.byteLength(markdown, "utf8") > REQUEST_BODY_MAX_BYTES) {
|
|
887
|
+
sendJson(response, 413, { error: `PDF export text exceeds ${REQUEST_BODY_MAX_BYTES} bytes.` });
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
const sourcePath = typeof payload.sourcePath === "string" ? payload.sourcePath : "";
|
|
891
|
+
const resourceDir = typeof payload.resourceDir === "string" ? payload.resourceDir : "";
|
|
892
|
+
const resourcePath = resolvePrototypeBaseDir(sourcePath || undefined, resourceDir || undefined, options.directory);
|
|
893
|
+
const requestedEditorPdfLanguage = typeof payload.editorPdfLanguage === "string" ? payload.editorPdfLanguage : "";
|
|
894
|
+
const requestedIsLatex = payload.isLatex === true;
|
|
895
|
+
const requestedFilename = typeof payload.filenameHint === "string" ? payload.filenameHint : "";
|
|
896
|
+
const filename = sanitizePrototypePdfFilename(requestedFilename || (requestedIsLatex ? "studio-latex-preview.pdf" : "studio-preview.pdf"));
|
|
897
|
+
const { pdf, warning } = await renderPrototypePdfWithPandoc(markdown, {
|
|
898
|
+
isLatex: requestedIsLatex,
|
|
899
|
+
resourcePath,
|
|
900
|
+
editorPdfLanguage: requestedEditorPdfLanguage,
|
|
901
|
+
});
|
|
902
|
+
const headers = {
|
|
903
|
+
"Cache-Control": "no-store",
|
|
904
|
+
"Content-Disposition": buildAttachmentContentDisposition(filename),
|
|
905
|
+
};
|
|
906
|
+
if (warning) {
|
|
907
|
+
headers["X-PI-STUDIO-EXPORT-WARNING"] = warning;
|
|
908
|
+
}
|
|
909
|
+
sendBinary(response, 200, pdf, "application/pdf", headers);
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (request.method === "POST" && url.pathname === "/api/run") {
|
|
913
|
+
const payload = await readJsonBody(request);
|
|
914
|
+
await host.startRun(normalizePrompt(payload));
|
|
915
|
+
sendJson(response, 200, { ok: true, snapshot: buildSnapshot() });
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (request.method === "POST" && url.pathname === "/api/steer") {
|
|
919
|
+
const payload = await readJsonBody(request);
|
|
920
|
+
await host.queueSteer(normalizePrompt(payload));
|
|
921
|
+
sendJson(response, 200, { ok: true, snapshot: buildSnapshot() });
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
if (request.method === "POST" && url.pathname === "/api/stop") {
|
|
925
|
+
await host.stop();
|
|
926
|
+
sendJson(response, 200, { ok: true, snapshot: buildSnapshot() });
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
sendJson(response, 404, { error: `Unknown route: ${request.method ?? "GET"} ${url.pathname}` });
|
|
930
|
+
}
|
|
931
|
+
catch (error) {
|
|
932
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
933
|
+
sendJson(response, 500, { error: message, snapshot: buildSnapshot() });
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
const stop = async () => {
|
|
937
|
+
if (stopped)
|
|
938
|
+
return;
|
|
939
|
+
stopped = true;
|
|
940
|
+
unsubscribe();
|
|
941
|
+
await host.close();
|
|
942
|
+
await new Promise((resolveClose, rejectClose) => {
|
|
943
|
+
server.close((error) => {
|
|
944
|
+
if (error) {
|
|
945
|
+
rejectClose(error);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
resolveClose();
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
};
|
|
952
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
953
|
+
server.once("error", rejectListen);
|
|
954
|
+
server.listen(options.port, options.host, () => {
|
|
955
|
+
server.off("error", rejectListen);
|
|
956
|
+
resolveListen();
|
|
957
|
+
});
|
|
958
|
+
});
|
|
959
|
+
const address = server.address();
|
|
960
|
+
if (address && typeof address !== "string") {
|
|
961
|
+
const info = address;
|
|
962
|
+
listenPort = info.port;
|
|
963
|
+
listenHost = info.address;
|
|
964
|
+
}
|
|
965
|
+
const baseUrl = `http://${listenHost}:${listenPort}`;
|
|
966
|
+
return {
|
|
967
|
+
url: buildPrototypeAccessUrl(listenHost, listenPort, accessToken),
|
|
968
|
+
baseUrl,
|
|
969
|
+
token: accessToken,
|
|
970
|
+
host: listenHost,
|
|
971
|
+
port: listenPort,
|
|
972
|
+
getSnapshot: buildSnapshot,
|
|
973
|
+
getState: () => currentState,
|
|
974
|
+
stop,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
async function runPrototypeCli() {
|
|
978
|
+
const options = parseArgs(process.argv.slice(2));
|
|
979
|
+
const instance = await startPrototypeServer(options);
|
|
980
|
+
const shutdown = async () => {
|
|
981
|
+
process.off("SIGINT", onSignal);
|
|
982
|
+
process.off("SIGTERM", onSignal);
|
|
983
|
+
await instance.stop();
|
|
984
|
+
};
|
|
985
|
+
const onSignal = () => {
|
|
986
|
+
void shutdown().finally(() => process.exit(0));
|
|
987
|
+
};
|
|
988
|
+
process.on("SIGINT", onSignal);
|
|
989
|
+
process.on("SIGTERM", onSignal);
|
|
990
|
+
console.log(`Prototype ready at ${instance.url}`);
|
|
991
|
+
console.log(`Session: ${instance.getState().sessionId ?? "(pending)"}`);
|
|
992
|
+
console.log(`Working directory: ${options.directory}`);
|
|
993
|
+
}
|
|
994
|
+
const isPrototypeServerDirectRun = process.argv[1]
|
|
995
|
+
&& resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
996
|
+
if (isPrototypeServerDirectRun) {
|
|
997
|
+
void runPrototypeCli().catch((error) => {
|
|
998
|
+
console.error(error instanceof Error ? error.stack ?? error.message : error);
|
|
999
|
+
process.exitCode = 1;
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
//# sourceMappingURL=prototype-server.js.map
|