src / tools / preview.ts
src / tools / preview.ts
import { tool, type Tool } from "@lmstudio/sdk";
import { spawn } from "child_process";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "http";
import { existsSync } from "fs";
import { readFile, stat } from "fs/promises";
import { extname, join } from "path";
import { z } from "zod";
import { clamp, type Workspace } from "../workspace";
/**
* Serves the workspace over HTTP and opens it in the user's browser, so a model
* building a web page or game can show the result. Files are served from disk,
* which avoids handing an entire page to a preview tool as one giant argument.
*
* Served HTML also gets a small reporting script injected, so runtime errors in
* the page come back to the model. Without it the model is blind: it writes a
* game, the page throws on load, and nobody tells it.
*/
const DEFAULT_PORT = 8777;
const MAX_PORT_ATTEMPTS = 12;
const REPORT_PATH = "/__preview_error";
const MAX_ERRORS = 50;
const MAX_BODY_BYTES = 64 * 1024;
interface PageError {
at: string;
type: string;
message: string;
source: string;
line: number;
stack: string;
}
interface RunningPreview {
server: Server;
port: number;
root: string;
}
// Module scope: both the server and its error log must outlive the tool call.
let current: RunningPreview | undefined;
const errorLog: PageError[] = [];
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".htm": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".ico": "image/x-icon",
".wav": "audio/wav",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".woff": "font/woff",
".woff2": "font/woff2",
};
/**
* Reports uncaught errors, rejected promises and console.error back to the
* server. Deliberately tiny and defensive -- it must never break the page it is
* inserted into.
*/
const REPORTER_SCRIPT = `<script>(function(){
if(window.__wsErrHook)return;window.__wsErrHook=1;
function post(p){try{fetch(${JSON.stringify(REPORT_PATH)},{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(p)})}catch(e){}}
function str(v){try{return typeof v==="string"?v:(v&&v.message)||JSON.stringify(v)}catch(e){return String(v)}}
window.addEventListener("error",function(e){post({type:"error",message:str(e.message||e.error),source:e.filename||"",line:e.lineno||0,stack:e.error&&e.error.stack?String(e.error.stack).slice(0,1200):""})});
window.addEventListener("unhandledrejection",function(e){post({type:"unhandled rejection",message:str(e.reason),stack:e.reason&&e.reason.stack?String(e.reason.stack).slice(0,1200):""})});
var ce=console.error;console.error=function(){try{post({type:"console.error",message:Array.prototype.map.call(arguments,str).join(" ")})}catch(e){}return ce.apply(console,arguments)};
})();</script>`;
export function previewTools(ws: Workspace): Tool[] {
return [
tool({
name: "preview_in_browser",
description:
"Serve the workspace on a local web server and open a page in the user's browser, so they " +
"can see and play what you built. Use this after creating or changing an HTML page or web " +
"game. Afterwards call preview_errors to find out whether the page actually ran. Only the " +
"workspace folder is served.",
parameters: {
path: z
.string()
.default("index.html")
.describe("Page to open, relative to the workspace root, e.g. 'index.html'."),
port: z
.number()
.int()
.min(1024)
.max(65535)
.default(DEFAULT_PORT)
.describe("Port to serve on. Leave as the default unless it is taken."),
},
implementation: async ({ path, port }, ctx) => {
const target = path.trim() === "" ? "index.html" : path.trim();
// Containment errors propagate: serving outside the root is never right.
const absPath = ws.resolveInRoot(target);
if (!existsSync(absPath)) {
return (
`Error: "${target}" does not exist, so there is nothing to preview. ` +
`Create the page first, then call this again.`
);
}
try {
const running = await ensureServer(ws, port);
errorLog.length = 0; // Fresh page load, fresh errors.
const relative = ws.rel(absPath).replace(/\\/g, "/");
const url = `http://localhost:${running.port}/${relative}`;
ctx.status(`Serving ${relative} on port ${running.port}`);
const opened = openBrowser(url);
return (
`Serving the workspace at http://localhost:${running.port}/ and ` +
`${opened ? "opened" : "tried to open"} ${url} in the browser.\n\n` +
`Now call preview_errors to see whether the page threw anything on load. ` +
`After later edits the user only needs to refresh -- do not call this repeatedly.`
);
} catch (error) {
return (
`Error: could not start the preview server -- ${(error as Error).message}. ` +
`Try a different port.`
);
}
},
}),
tool({
name: "preview_errors",
description:
"Read JavaScript errors reported by the previewed page: uncaught exceptions, rejected " +
"promises and console.error calls, with file and line numbers. Call this after " +
"preview_in_browser, and again after the user reloads, to find out whether your code " +
"actually runs. An empty result means the page loaded without errors.",
parameters: {
clear: z
.boolean()
.default(true)
.describe("Clear the log after reading, so the next call only shows new errors."),
},
implementation: async ({ clear }, ctx) => {
if (current === undefined) {
return "No preview server is running. Call preview_in_browser first.";
}
ctx.status("Reading page errors");
if (errorLog.length === 0) {
return (
"No errors reported by the page. Either it ran cleanly, or the user has not opened " +
"or reloaded it yet. Ask them to reload if you are unsure."
);
}
const lines = errorLog.map((entry) => {
const where =
entry.source === "" ? "" : ` (${entry.source.replace(/^https?:\/\/[^/]+/, "")}:${entry.line})`;
const stack = entry.stack === "" ? "" : `\n ${entry.stack.split("\n").slice(1, 4).join("\n ")}`;
return `[${entry.type}]${where} ${entry.message}${stack}`;
});
const report = `${errorLog.length} error(s) reported by the page:\n\n${lines.join("\n\n")}`;
if (clear) errorLog.length = 0;
return clamp(report, 6000, "page errors");
},
}),
tool({
name: "stop_preview",
description: "Stop the local preview web server started by preview_in_browser.",
parameters: {},
implementation: async (_params, ctx) => {
if (current === undefined) return "No preview server is running.";
const { port } = current;
ctx.status("Stopping preview server");
await new Promise<void>((done) => current?.server.close(() => done()));
current = undefined;
errorLog.length = 0;
return `Stopped the preview server on port ${port}.`;
},
}),
];
}
async function ensureServer(ws: Workspace, preferredPort: number): Promise<RunningPreview> {
// Reuse the existing server when it already serves this workspace.
if (current !== undefined && current.root === ws.root) return current;
if (current !== undefined) {
await new Promise<void>((done) => current?.server.close(() => done()));
current = undefined;
}
let lastError: Error | undefined;
for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
const port = preferredPort + attempt;
try {
const server = await listen(ws, port);
current = { server, port, root: ws.root };
return current;
} catch (error) {
lastError = error as Error;
if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") break;
}
}
throw lastError ?? new Error("no free port");
}
function listen(ws: Workspace, port: number): Promise<Server> {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
void handle(ws, req, res);
});
server.on("error", reject);
// Localhost only: this must never be reachable from the network.
server.listen(port, "127.0.0.1", () => {
server.removeListener("error", reject);
resolve(server);
});
});
}
async function handle(ws: Workspace, req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = req.url ?? "/";
if (req.method === "POST" && url.startsWith(REPORT_PATH)) {
await collectError(req, res);
return;
}
await serve(ws, url, res);
}
async function collectError(req: IncomingMessage, res: ServerResponse): Promise<void> {
try {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
size += (chunk as Buffer).length;
if (size > MAX_BODY_BYTES) break;
chunks.push(chunk as Buffer);
}
const payload = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as Partial<PageError>;
errorLog.push({
at: new Date().toISOString(),
type: String(payload.type ?? "error").slice(0, 40),
message: String(payload.message ?? "unknown").slice(0, 600),
source: String(payload.source ?? "").slice(0, 200),
line: Number(payload.line ?? 0) || 0,
stack: String(payload.stack ?? "").slice(0, 1200),
});
if (errorLog.length > MAX_ERRORS) errorLog.splice(0, errorLog.length - MAX_ERRORS);
} catch {
// A malformed report is not worth surfacing.
}
res.writeHead(204);
res.end();
}
async function serve(ws: Workspace, rawUrl: string, res: ServerResponse): Promise<void> {
try {
const decoded = decodeURIComponent(rawUrl.split("?")[0].split("#")[0]);
const wanted = decoded === "/" || decoded === "" ? "index.html" : decoded.replace(/^\/+/, "");
// The URL is attacker-controlled in principle, so it goes through the same
// containment check as every model-supplied path.
let absPath: string;
try {
absPath = ws.resolveInRoot(wanted);
} catch {
res.writeHead(403, { "content-type": "text/plain" });
res.end("Outside the workspace");
return;
}
let info;
try {
info = await stat(absPath);
} catch {
res.writeHead(404, { "content-type": "text/plain" });
res.end("Not found");
return;
}
const filePath = info.isDirectory() ? join(absPath, "index.html") : absPath;
if (!existsSync(filePath)) {
res.writeHead(404, { "content-type": "text/plain" });
res.end("Not found");
return;
}
const ext = extname(filePath).toLowerCase();
const type = MIME[ext] ?? "application/octet-stream";
if (ext === ".html" || ext === ".htm") {
const html = await readFile(filePath, "utf-8");
res.writeHead(200, { "content-type": type, "cache-control": "no-store" });
res.end(injectReporter(html));
return;
}
const body = await readFile(filePath);
res.writeHead(200, { "content-type": type, "cache-control": "no-store" });
res.end(body);
} catch {
res.writeHead(500, { "content-type": "text/plain" });
res.end("Preview server error");
}
}
/** Puts the reporter as early as possible, so it catches errors in the page's own scripts. */
function injectReporter(html: string): string {
const headOpen = /<head[^>]*>/i.exec(html);
if (headOpen !== null) {
const at = headOpen.index + headOpen[0].length;
return html.slice(0, at) + REPORTER_SCRIPT + html.slice(at);
}
const htmlOpen = /<html[^>]*>/i.exec(html);
if (htmlOpen !== null) {
const at = htmlOpen.index + htmlOpen[0].length;
return html.slice(0, at) + REPORTER_SCRIPT + html.slice(at);
}
return REPORTER_SCRIPT + html;
}
/**
* Opens the default browser. `cmd /c start` is avoided deliberately: on this
* machine `cmd` can resolve to a shim rather than the real one.
*/
function openBrowser(url: string): boolean {
try {
if (process.platform === "win32") {
spawn("rundll32", ["url.dll,FileProtocolHandler", url], {
detached: true,
stdio: "ignore",
}).unref();
} else if (process.platform === "darwin") {
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
} else {
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
}
return true;
} catch {
return false;
}
}
import { tool, type Tool } from "@lmstudio/sdk";
import { spawn } from "child_process";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "http";
import { existsSync } from "fs";
import { readFile, stat } from "fs/promises";
import { extname, join } from "path";
import { z } from "zod";
import { clamp, type Workspace } from "../workspace";
/**
* Serves the workspace over HTTP and opens it in the user's browser, so a model
* building a web page or game can show the result. Files are served from disk,
* which avoids handing an entire page to a preview tool as one giant argument.
*
* Served HTML also gets a small reporting script injected, so runtime errors in
* the page come back to the model. Without it the model is blind: it writes a
* game, the page throws on load, and nobody tells it.
*/
const DEFAULT_PORT = 8777;
const MAX_PORT_ATTEMPTS = 12;
const REPORT_PATH = "/__preview_error";
const MAX_ERRORS = 50;
const MAX_BODY_BYTES = 64 * 1024;
interface PageError {
at: string;
type: string;
message: string;
source: string;
line: number;
stack: string;
}
interface RunningPreview {
server: Server;
port: number;
root: string;
}
// Module scope: both the server and its error log must outlive the tool call.
let current: RunningPreview | undefined;
const errorLog: PageError[] = [];
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".htm": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".ico": "image/x-icon",
".wav": "audio/wav",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".woff": "font/woff",
".woff2": "font/woff2",
};
/**
* Reports uncaught errors, rejected promises and console.error back to the
* server. Deliberately tiny and defensive -- it must never break the page it is
* inserted into.
*/
const REPORTER_SCRIPT = `<script>(function(){
if(window.__wsErrHook)return;window.__wsErrHook=1;
function post(p){try{fetch(${JSON.stringify(REPORT_PATH)},{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(p)})}catch(e){}}
function str(v){try{return typeof v==="string"?v:(v&&v.message)||JSON.stringify(v)}catch(e){return String(v)}}
window.addEventListener("error",function(e){post({type:"error",message:str(e.message||e.error),source:e.filename||"",line:e.lineno||0,stack:e.error&&e.error.stack?String(e.error.stack).slice(0,1200):""})});
window.addEventListener("unhandledrejection",function(e){post({type:"unhandled rejection",message:str(e.reason),stack:e.reason&&e.reason.stack?String(e.reason.stack).slice(0,1200):""})});
var ce=console.error;console.error=function(){try{post({type:"console.error",message:Array.prototype.map.call(arguments,str).join(" ")})}catch(e){}return ce.apply(console,arguments)};
})();</script>`;
export function previewTools(ws: Workspace): Tool[] {
return [
tool({
name: "preview_in_browser",
description:
"Serve the workspace on a local web server and open a page in the user's browser, so they " +
"can see and play what you built. Use this after creating or changing an HTML page or web " +
"game. Afterwards call preview_errors to find out whether the page actually ran. Only the " +
"workspace folder is served.",
parameters: {
path: z
.string()
.default("index.html")
.describe("Page to open, relative to the workspace root, e.g. 'index.html'."),
port: z
.number()
.int()
.min(1024)
.max(65535)
.default(DEFAULT_PORT)
.describe("Port to serve on. Leave as the default unless it is taken."),
},
implementation: async ({ path, port }, ctx) => {
const target = path.trim() === "" ? "index.html" : path.trim();
// Containment errors propagate: serving outside the root is never right.
const absPath = ws.resolveInRoot(target);
if (!existsSync(absPath)) {
return (
`Error: "${target}" does not exist, so there is nothing to preview. ` +
`Create the page first, then call this again.`
);
}
try {
const running = await ensureServer(ws, port);
errorLog.length = 0; // Fresh page load, fresh errors.
const relative = ws.rel(absPath).replace(/\\/g, "/");
const url = `http://localhost:${running.port}/${relative}`;
ctx.status(`Serving ${relative} on port ${running.port}`);
const opened = openBrowser(url);
return (
`Serving the workspace at http://localhost:${running.port}/ and ` +
`${opened ? "opened" : "tried to open"} ${url} in the browser.\n\n` +
`Now call preview_errors to see whether the page threw anything on load. ` +
`After later edits the user only needs to refresh -- do not call this repeatedly.`
);
} catch (error) {
return (
`Error: could not start the preview server -- ${(error as Error).message}. ` +
`Try a different port.`
);
}
},
}),
tool({
name: "preview_errors",
description:
"Read JavaScript errors reported by the previewed page: uncaught exceptions, rejected " +
"promises and console.error calls, with file and line numbers. Call this after " +
"preview_in_browser, and again after the user reloads, to find out whether your code " +
"actually runs. An empty result means the page loaded without errors.",
parameters: {
clear: z
.boolean()
.default(true)
.describe("Clear the log after reading, so the next call only shows new errors."),
},
implementation: async ({ clear }, ctx) => {
if (current === undefined) {
return "No preview server is running. Call preview_in_browser first.";
}
ctx.status("Reading page errors");
if (errorLog.length === 0) {
return (
"No errors reported by the page. Either it ran cleanly, or the user has not opened " +
"or reloaded it yet. Ask them to reload if you are unsure."
);
}
const lines = errorLog.map((entry) => {
const where =
entry.source === "" ? "" : ` (${entry.source.replace(/^https?:\/\/[^/]+/, "")}:${entry.line})`;
const stack = entry.stack === "" ? "" : `\n ${entry.stack.split("\n").slice(1, 4).join("\n ")}`;
return `[${entry.type}]${where} ${entry.message}${stack}`;
});
const report = `${errorLog.length} error(s) reported by the page:\n\n${lines.join("\n\n")}`;
if (clear) errorLog.length = 0;
return clamp(report, 6000, "page errors");
},
}),
tool({
name: "stop_preview",
description: "Stop the local preview web server started by preview_in_browser.",
parameters: {},
implementation: async (_params, ctx) => {
if (current === undefined) return "No preview server is running.";
const { port } = current;
ctx.status("Stopping preview server");
await new Promise<void>((done) => current?.server.close(() => done()));
current = undefined;
errorLog.length = 0;
return `Stopped the preview server on port ${port}.`;
},
}),
];
}
async function ensureServer(ws: Workspace, preferredPort: number): Promise<RunningPreview> {
// Reuse the existing server when it already serves this workspace.
if (current !== undefined && current.root === ws.root) return current;
if (current !== undefined) {
await new Promise<void>((done) => current?.server.close(() => done()));
current = undefined;
}
let lastError: Error | undefined;
for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
const port = preferredPort + attempt;
try {
const server = await listen(ws, port);
current = { server, port, root: ws.root };
return current;
} catch (error) {
lastError = error as Error;
if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") break;
}
}
throw lastError ?? new Error("no free port");
}
function listen(ws: Workspace, port: number): Promise<Server> {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
void handle(ws, req, res);
});
server.on("error", reject);
// Localhost only: this must never be reachable from the network.
server.listen(port, "127.0.0.1", () => {
server.removeListener("error", reject);
resolve(server);
});
});
}
async function handle(ws: Workspace, req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = req.url ?? "/";
if (req.method === "POST" && url.startsWith(REPORT_PATH)) {
await collectError(req, res);
return;
}
await serve(ws, url, res);
}
async function collectError(req: IncomingMessage, res: ServerResponse): Promise<void> {
try {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
size += (chunk as Buffer).length;
if (size > MAX_BODY_BYTES) break;
chunks.push(chunk as Buffer);
}
const payload = JSON.parse(Buffer.concat(chunks).toString("utf-8")) as Partial<PageError>;
errorLog.push({
at: new Date().toISOString(),
type: String(payload.type ?? "error").slice(0, 40),
message: String(payload.message ?? "unknown").slice(0, 600),
source: String(payload.source ?? "").slice(0, 200),
line: Number(payload.line ?? 0) || 0,
stack: String(payload.stack ?? "").slice(0, 1200),
});
if (errorLog.length > MAX_ERRORS) errorLog.splice(0, errorLog.length - MAX_ERRORS);
} catch {
// A malformed report is not worth surfacing.
}
res.writeHead(204);
res.end();
}
async function serve(ws: Workspace, rawUrl: string, res: ServerResponse): Promise<void> {
try {
const decoded = decodeURIComponent(rawUrl.split("?")[0].split("#")[0]);
const wanted = decoded === "/" || decoded === "" ? "index.html" : decoded.replace(/^\/+/, "");
// The URL is attacker-controlled in principle, so it goes through the same
// containment check as every model-supplied path.
let absPath: string;
try {
absPath = ws.resolveInRoot(wanted);
} catch {
res.writeHead(403, { "content-type": "text/plain" });
res.end("Outside the workspace");
return;
}
let info;
try {
info = await stat(absPath);
} catch {
res.writeHead(404, { "content-type": "text/plain" });
res.end("Not found");
return;
}
const filePath = info.isDirectory() ? join(absPath, "index.html") : absPath;
if (!existsSync(filePath)) {
res.writeHead(404, { "content-type": "text/plain" });
res.end("Not found");
return;
}
const ext = extname(filePath).toLowerCase();
const type = MIME[ext] ?? "application/octet-stream";
if (ext === ".html" || ext === ".htm") {
const html = await readFile(filePath, "utf-8");
res.writeHead(200, { "content-type": type, "cache-control": "no-store" });
res.end(injectReporter(html));
return;
}
const body = await readFile(filePath);
res.writeHead(200, { "content-type": type, "cache-control": "no-store" });
res.end(body);
} catch {
res.writeHead(500, { "content-type": "text/plain" });
res.end("Preview server error");
}
}
/** Puts the reporter as early as possible, so it catches errors in the page's own scripts. */
function injectReporter(html: string): string {
const headOpen = /<head[^>]*>/i.exec(html);
if (headOpen !== null) {
const at = headOpen.index + headOpen[0].length;
return html.slice(0, at) + REPORTER_SCRIPT + html.slice(at);
}
const htmlOpen = /<html[^>]*>/i.exec(html);
if (htmlOpen !== null) {
const at = htmlOpen.index + htmlOpen[0].length;
return html.slice(0, at) + REPORTER_SCRIPT + html.slice(at);
}
return REPORTER_SCRIPT + html;
}
/**
* Opens the default browser. `cmd /c start` is avoided deliberately: on this
* machine `cmd` can resolve to a shim rather than the real one.
*/
function openBrowser(url: string): boolean {
try {
if (process.platform === "win32") {
spawn("rundll32", ["url.dll,FileProtocolHandler", url], {
detached: true,
stdio: "ignore",
}).unref();
} else if (process.platform === "darwin") {
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
} else {
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
}
return true;
} catch {
return false;
}
}