@markbrutx/promptbook-viewer 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 +38 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/server/annotations.d.ts +30 -0
- package/dist/server/annotations.d.ts.map +1 -0
- package/dist/server/annotations.js +62 -0
- package/dist/server/annotations.js.map +1 -0
- package/dist/server/api.d.ts +16 -0
- package/dist/server/api.d.ts.map +1 -0
- package/dist/server/api.js +134 -0
- package/dist/server/api.js.map +1 -0
- package/dist/server/book-source.d.ts +20 -0
- package/dist/server/book-source.d.ts.map +1 -0
- package/dist/server/book-source.js +32 -0
- package/dist/server/book-source.js.map +1 -0
- package/dist/server/responses.d.ts +12 -0
- package/dist/server/responses.d.ts.map +1 -0
- package/dist/server/responses.js +106 -0
- package/dist/server/responses.js.map +1 -0
- package/dist/server/segments.d.ts +12 -0
- package/dist/server/segments.d.ts.map +1 -0
- package/dist/server/segments.js +24 -0
- package/dist/server/segments.js.map +1 -0
- package/dist/server/server.d.ts +24 -0
- package/dist/server/server.d.ts.map +1 -0
- package/dist/server/server.js +71 -0
- package/dist/server/server.js.map +1 -0
- package/dist/server/static.d.ts +8 -0
- package/dist/server/static.d.ts.map +1 -0
- package/dist/server/static.js +55 -0
- package/dist/server/static.js.map +1 -0
- package/dist/server/used-in.d.ts +9 -0
- package/dist/server/used-in.d.ts.map +1 -0
- package/dist/server/used-in.js +19 -0
- package/dist/server/used-in.js.map +1 -0
- package/dist/shared/types.d.ts +113 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +2 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/web/assets/index-B2Wxtb-f.css +1 -0
- package/dist/web/assets/index-C8f_6lr_.js +51 -0
- package/dist/web/index.html +13 -0
- package/package.json +47 -0
- package/src/index.ts +19 -0
- package/src/server/annotations.ts +96 -0
- package/src/server/api.ts +164 -0
- package/src/server/book-source.ts +54 -0
- package/src/server/responses.ts +127 -0
- package/src/server/segments.ts +26 -0
- package/src/server/server.ts +96 -0
- package/src/server/static.ts +60 -0
- package/src/server/used-in.ts +23 -0
- package/src/shared/types.ts +137 -0
- package/src/web/App.tsx +307 -0
- package/src/web/annotations.ts +58 -0
- package/src/web/api.ts +44 -0
- package/src/web/colors.ts +21 -0
- package/src/web/components/Addons.tsx +109 -0
- package/src/web/components/Canvas.tsx +180 -0
- package/src/web/components/CodePromptView.tsx +87 -0
- package/src/web/components/Controls.tsx +71 -0
- package/src/web/components/Diff.tsx +30 -0
- package/src/web/components/FragmentView.tsx +54 -0
- package/src/web/components/Sidebar.tsx +178 -0
- package/src/web/diff.ts +51 -0
- package/src/web/env.d.ts +3 -0
- package/src/web/format.ts +8 -0
- package/src/web/index.html +12 -0
- package/src/web/main.tsx +15 -0
- package/src/web/selection.ts +5 -0
- package/src/web/styles.css +484 -0
- package/src/web/tree.ts +134 -0
- package/src/web/types.ts +17 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>promptbook viewer</title>
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-C8f_6lr_.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B2Wxtb-f.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@markbrutx/promptbook-viewer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Storybook-for-prompts: a local web app that renders a prompts folder via @markbrutx/promptbook-core.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsgo -p tsconfig.build.json && vite build",
|
|
24
|
+
"typecheck": "tsgo --noEmit -p tsconfig.json && tsgo --noEmit -p tsconfig.web.json",
|
|
25
|
+
"test": "vitest --run",
|
|
26
|
+
"check": "biome check ."
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@markbrutx/promptbook-core": "^0.1.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@biomejs/biome": "latest",
|
|
33
|
+
"@types/node": "^20.14.0",
|
|
34
|
+
"@types/react": "^19.2.0",
|
|
35
|
+
"@types/react-dom": "^19.2.0",
|
|
36
|
+
"@typescript/native-preview": "latest",
|
|
37
|
+
"@vitejs/plugin-react": "^4.7.0",
|
|
38
|
+
"react": "^19.2.0",
|
|
39
|
+
"react-dom": "^19.2.0",
|
|
40
|
+
"typescript": "^5.6.0",
|
|
41
|
+
"vite": "^5.4.0",
|
|
42
|
+
"vitest": "^2.1.0"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20.6"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type { AnnotateInput, AnnotationStore } from "./server/annotations.js";
|
|
2
|
+
export { createAnnotationStore } from "./server/annotations.js";
|
|
3
|
+
export type { Viewer, ViewerOptions } from "./server/server.js";
|
|
4
|
+
export { startViewer } from "./server/server.js";
|
|
5
|
+
export type {
|
|
6
|
+
AnnotateRequest,
|
|
7
|
+
Annotation,
|
|
8
|
+
AnnotationsResponse,
|
|
9
|
+
BookResponse,
|
|
10
|
+
CompositionSummary,
|
|
11
|
+
FragmentSummary,
|
|
12
|
+
LintResponse,
|
|
13
|
+
ResolveResponse,
|
|
14
|
+
RuleSummary,
|
|
15
|
+
Segment,
|
|
16
|
+
UsedInReference,
|
|
17
|
+
UsedInResponse,
|
|
18
|
+
VariantSummary,
|
|
19
|
+
} from "./shared/types.js";
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Annotation, AnnotationAnchor, AnnotationTarget, Context } from "@markbrutx/promptbook-core";
|
|
5
|
+
import {
|
|
6
|
+
ANNOTATION_QUEUE_DIR,
|
|
7
|
+
ANNOTATION_QUEUE_FILE,
|
|
8
|
+
parseInbox,
|
|
9
|
+
serializeAnnotationLine,
|
|
10
|
+
serializeInbox,
|
|
11
|
+
} from "@markbrutx/promptbook-core";
|
|
12
|
+
|
|
13
|
+
/** The flat body the viewer posts; the store expands it into an {@link Annotation}. */
|
|
14
|
+
export interface AnnotateInput {
|
|
15
|
+
/** Composition the variant was assembled from (omit for a fragment target). */
|
|
16
|
+
prompt?: string;
|
|
17
|
+
/** Context the variant was resolved under (kept with `prompt`). */
|
|
18
|
+
context?: Context;
|
|
19
|
+
fragmentId: string;
|
|
20
|
+
anchorText: string;
|
|
21
|
+
comment: string;
|
|
22
|
+
offset?: number;
|
|
23
|
+
/** Source file of the fragment, when annotating a fragment directly. */
|
|
24
|
+
sourceFile?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The append-safe queue under `<promptsDir>/.annotations/inbox.jsonl`. */
|
|
28
|
+
export interface AnnotationStore {
|
|
29
|
+
/** Append a new open annotation and return it. */
|
|
30
|
+
append(input: AnnotateInput): Promise<Annotation>;
|
|
31
|
+
/** Open annotations in queue order (oldest first). */
|
|
32
|
+
list(): Promise<Annotation[]>;
|
|
33
|
+
/** Remove one annotation by id; resolves to whether it existed. */
|
|
34
|
+
remove(id: string): Promise<boolean>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Expand the posted body into a full annotation (id + timestamp added here). */
|
|
38
|
+
function buildAnnotation(input: AnnotateInput): Annotation {
|
|
39
|
+
const target: AnnotationTarget =
|
|
40
|
+
input.prompt !== undefined
|
|
41
|
+
? { prompt: input.prompt, context: input.context ?? {} }
|
|
42
|
+
: { fragmentId: input.fragmentId, ...(input.sourceFile ? { sourceFile: input.sourceFile } : {}) };
|
|
43
|
+
const anchor: AnnotationAnchor = {
|
|
44
|
+
fragmentId: input.fragmentId,
|
|
45
|
+
anchorText: input.anchorText,
|
|
46
|
+
...(input.offset !== undefined ? { offset: input.offset } : {}),
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
id: randomUUID(),
|
|
50
|
+
createdAt: new Date().toISOString(),
|
|
51
|
+
target,
|
|
52
|
+
anchor,
|
|
53
|
+
comment: input.comment,
|
|
54
|
+
status: "open",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* File-backed annotation queue. Appends are line-atomic (one JSONL line);
|
|
60
|
+
* removals rewrite the file. A missing queue simply reads as empty. This is the
|
|
61
|
+
* viewer's only write surface — `@markbrutx/promptbook-core` stays read-only.
|
|
62
|
+
*/
|
|
63
|
+
export function createAnnotationStore(promptsDir: string): AnnotationStore {
|
|
64
|
+
const dir = join(promptsDir, ANNOTATION_QUEUE_DIR);
|
|
65
|
+
const file = join(dir, ANNOTATION_QUEUE_FILE);
|
|
66
|
+
|
|
67
|
+
const readAll = async (): Promise<Annotation[]> => {
|
|
68
|
+
try {
|
|
69
|
+
return parseInbox(await readFile(file, "utf8"));
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
async append(input) {
|
|
77
|
+
const annotation = buildAnnotation(input);
|
|
78
|
+
await mkdir(dir, { recursive: true });
|
|
79
|
+
await appendFile(file, serializeAnnotationLine(annotation));
|
|
80
|
+
return annotation;
|
|
81
|
+
},
|
|
82
|
+
async list() {
|
|
83
|
+
return (await readAll()).filter((annotation) => annotation.status === "open");
|
|
84
|
+
},
|
|
85
|
+
async remove(id) {
|
|
86
|
+
const all = await readAll();
|
|
87
|
+
const next = all.filter((annotation) => annotation.id !== id);
|
|
88
|
+
if (next.length === all.length) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
// The dir/file already exist (an annotation was present), so no mkdir.
|
|
92
|
+
await writeFile(file, serializeInbox(next));
|
|
93
|
+
return true;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { Context } from "@markbrutx/promptbook-core";
|
|
3
|
+
import type { AnnotateRequest, ResolveRequest } from "../shared/types.js";
|
|
4
|
+
import { type AnnotateInput, createAnnotationStore } from "./annotations.js";
|
|
5
|
+
import type { BookSource } from "./book-source.js";
|
|
6
|
+
import {
|
|
7
|
+
buildBookResponse,
|
|
8
|
+
buildLintResponse,
|
|
9
|
+
buildResolveResponse,
|
|
10
|
+
buildUsedInResponse,
|
|
11
|
+
} from "./responses.js";
|
|
12
|
+
import { serveStatic } from "./static.js";
|
|
13
|
+
|
|
14
|
+
export interface RequestHandlerOptions {
|
|
15
|
+
source: BookSource;
|
|
16
|
+
promptsDir: string;
|
|
17
|
+
/** Directory of the built web bundle (dist/web). */
|
|
18
|
+
webRoot: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A request handler plus the hook the folder watcher uses to push reloads. */
|
|
22
|
+
export interface RequestHandler {
|
|
23
|
+
handle(req: IncomingMessage, res: ServerResponse): void;
|
|
24
|
+
/** Invalidate the cached book and notify connected clients to refetch. */
|
|
25
|
+
notifyReload(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
|
29
|
+
const body = JSON.stringify(payload);
|
|
30
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
31
|
+
res.end(body);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
35
|
+
const chunks: Buffer[] = [];
|
|
36
|
+
for await (const chunk of req) {
|
|
37
|
+
chunks.push(chunk as Buffer);
|
|
38
|
+
}
|
|
39
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
40
|
+
if (raw === "") {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
return JSON.parse(raw);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function asResolveRequest(body: unknown): { prompt: string; context: Context } {
|
|
47
|
+
const data = (body ?? {}) as Partial<ResolveRequest>;
|
|
48
|
+
if (typeof data.prompt !== "string" || data.prompt === "") {
|
|
49
|
+
throw new Error('request body must include a "prompt" name');
|
|
50
|
+
}
|
|
51
|
+
return { prompt: data.prompt, context: data.context ?? {} };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function asAnnotateRequest(body: unknown): AnnotateInput {
|
|
55
|
+
const data = (body ?? {}) as Partial<AnnotateRequest>;
|
|
56
|
+
if (typeof data.fragmentId !== "string" || data.fragmentId === "") {
|
|
57
|
+
throw new Error('request body must include a "fragmentId"');
|
|
58
|
+
}
|
|
59
|
+
if (typeof data.anchorText !== "string" || data.anchorText === "") {
|
|
60
|
+
throw new Error('request body must include "anchorText"');
|
|
61
|
+
}
|
|
62
|
+
if (typeof data.comment !== "string" || data.comment.trim() === "") {
|
|
63
|
+
throw new Error('request body must include a non-empty "comment"');
|
|
64
|
+
}
|
|
65
|
+
const input: AnnotateInput = {
|
|
66
|
+
fragmentId: data.fragmentId,
|
|
67
|
+
anchorText: data.anchorText,
|
|
68
|
+
comment: data.comment,
|
|
69
|
+
};
|
|
70
|
+
if (typeof data.prompt === "string" && data.prompt !== "") {
|
|
71
|
+
input.prompt = data.prompt;
|
|
72
|
+
input.context = data.context ?? {};
|
|
73
|
+
}
|
|
74
|
+
if (typeof data.offset === "number") {
|
|
75
|
+
input.offset = data.offset;
|
|
76
|
+
}
|
|
77
|
+
if (typeof data.sourceFile === "string") {
|
|
78
|
+
input.sourceFile = data.sourceFile;
|
|
79
|
+
}
|
|
80
|
+
return input;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createRequestHandler(options: RequestHandlerOptions): RequestHandler {
|
|
84
|
+
const { source, promptsDir, webRoot } = options;
|
|
85
|
+
const sseClients = new Set<ServerResponse>();
|
|
86
|
+
const annotations = createAnnotationStore(promptsDir);
|
|
87
|
+
|
|
88
|
+
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
89
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
90
|
+
const path = url.pathname;
|
|
91
|
+
const method = req.method ?? "GET";
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
if (path === "/api/book" && method === "GET") {
|
|
95
|
+
sendJson(res, 200, buildBookResponse(await source.get(), promptsDir));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (path === "/api/resolve" && method === "POST") {
|
|
99
|
+
const { prompt, context } = asResolveRequest(await readJsonBody(req));
|
|
100
|
+
sendJson(res, 200, buildResolveResponse(await source.get(), prompt, context));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (path === "/api/lint" && method === "POST") {
|
|
104
|
+
const { prompt, context } = asResolveRequest(await readJsonBody(req));
|
|
105
|
+
sendJson(res, 200, buildLintResponse(await source.get(), prompt, context));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (path.startsWith("/api/used-in/") && method === "GET") {
|
|
109
|
+
const id = decodeURIComponent(path.slice("/api/used-in/".length));
|
|
110
|
+
sendJson(res, 200, buildUsedInResponse(await source.get(), id));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (path === "/api/annotate" && method === "POST") {
|
|
114
|
+
const annotation = await annotations.append(asAnnotateRequest(await readJsonBody(req)));
|
|
115
|
+
sendJson(res, 200, annotation);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (path === "/api/annotations" && method === "GET") {
|
|
119
|
+
sendJson(res, 200, { annotations: await annotations.list() });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (path.startsWith("/api/annotations/") && method === "DELETE") {
|
|
123
|
+
const id = decodeURIComponent(path.slice("/api/annotations/".length));
|
|
124
|
+
const removed = await annotations.remove(id);
|
|
125
|
+
sendJson(res, removed ? 200 : 404, { id, removed });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (path === "/api/events" && method === "GET") {
|
|
129
|
+
addSseClient(res);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (path.startsWith("/api/")) {
|
|
133
|
+
sendJson(res, 404, { error: `unknown route ${method} ${path}` });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
await serveStatic(res, webRoot, path);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
sendJson(res, 400, { error: (error as Error).message });
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
function addSseClient(res: ServerResponse): void {
|
|
143
|
+
res.writeHead(200, {
|
|
144
|
+
"content-type": "text/event-stream",
|
|
145
|
+
"cache-control": "no-cache",
|
|
146
|
+
connection: "keep-alive",
|
|
147
|
+
});
|
|
148
|
+
res.write(": connected\n\n");
|
|
149
|
+
sseClients.add(res);
|
|
150
|
+
res.on("close", () => sseClients.delete(res));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
handle(req, res) {
|
|
155
|
+
void handle(req, res);
|
|
156
|
+
},
|
|
157
|
+
notifyReload() {
|
|
158
|
+
source.invalidate();
|
|
159
|
+
for (const client of sseClients) {
|
|
160
|
+
client.write("event: reload\ndata: {}\n\n");
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Fixture, FsAdapter, PromptBook } from "@markbrutx/promptbook-core";
|
|
2
|
+
import { loadFixtures, loadPrompts } from "@markbrutx/promptbook-core";
|
|
3
|
+
|
|
4
|
+
/** A loaded prompts folder plus the fixtures that supply named variants. */
|
|
5
|
+
export interface LoadedFolder {
|
|
6
|
+
book: PromptBook;
|
|
7
|
+
fixtures: Fixture[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Caches the loaded folder and reloads lazily after {@link invalidate}. The
|
|
12
|
+
* server wires a folder watcher to `invalidate` for hot-reload; tests can call
|
|
13
|
+
* it directly. Reads are serialized so a burst of requests during a reload
|
|
14
|
+
* shares one load.
|
|
15
|
+
*/
|
|
16
|
+
export interface BookSource {
|
|
17
|
+
/** Current folder contents, reloading first if it was invalidated. */
|
|
18
|
+
get(): Promise<LoadedFolder>;
|
|
19
|
+
/** Mark the cache stale; the next {@link get} reloads from disk. */
|
|
20
|
+
invalidate(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Fixtures are optional: a folder without a `fixtures/` dir simply has none. */
|
|
24
|
+
async function safeLoadFixtures(promptsDir: string, fs?: FsAdapter): Promise<Fixture[]> {
|
|
25
|
+
try {
|
|
26
|
+
return await loadFixtures(promptsDir, fs);
|
|
27
|
+
} catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createBookSource(promptsDir: string, fs?: FsAdapter): BookSource {
|
|
33
|
+
let cached: Promise<LoadedFolder> | undefined;
|
|
34
|
+
|
|
35
|
+
const load = async (): Promise<LoadedFolder> => {
|
|
36
|
+
const [book, fixtures] = await Promise.all([
|
|
37
|
+
loadPrompts(promptsDir, fs),
|
|
38
|
+
safeLoadFixtures(promptsDir, fs),
|
|
39
|
+
]);
|
|
40
|
+
return { book, fixtures };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
get() {
|
|
45
|
+
if (cached === undefined) {
|
|
46
|
+
cached = load();
|
|
47
|
+
}
|
|
48
|
+
return cached;
|
|
49
|
+
},
|
|
50
|
+
invalidate() {
|
|
51
|
+
cached = undefined;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { relative, sep } from "node:path";
|
|
2
|
+
import type { CodePrompt, Composition, Context, Rule } from "@markbrutx/promptbook-core";
|
|
3
|
+
import { estimateTokensByChars, lint, resolveBook } from "@markbrutx/promptbook-core";
|
|
4
|
+
import type {
|
|
5
|
+
BookResponse,
|
|
6
|
+
CodePromptSummary,
|
|
7
|
+
CompositionSummary,
|
|
8
|
+
LintResponse,
|
|
9
|
+
ResolveResponse,
|
|
10
|
+
RuleSummary,
|
|
11
|
+
UsedInResponse,
|
|
12
|
+
VariantSummary,
|
|
13
|
+
} from "../shared/types.js";
|
|
14
|
+
import type { LoadedFolder } from "./book-source.js";
|
|
15
|
+
import { deriveSegments } from "./segments.js";
|
|
16
|
+
import { usedIn } from "./used-in.js";
|
|
17
|
+
|
|
18
|
+
/** Path relative to the prompts folder, normalized to forward slashes. */
|
|
19
|
+
function relativeSource(promptsDir: string, sourceFile: string): string {
|
|
20
|
+
const rel = relative(promptsDir, sourceFile);
|
|
21
|
+
return (rel === "" ? sourceFile : rel).split(sep).join("/");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Drop undefined action fields so the wire shape stays minimal. */
|
|
25
|
+
function summarizeRule(rule: Rule): RuleSummary {
|
|
26
|
+
const summary: RuleSummary = { index: rule.index, when: rule.when, action: rule.action };
|
|
27
|
+
if (rule.add !== undefined) summary.add = rule.add;
|
|
28
|
+
if (rule.after !== undefined) summary.after = rule.after;
|
|
29
|
+
if (rule.replace !== undefined) summary.replace = rule.replace;
|
|
30
|
+
if (rule.forbid !== undefined) summary.forbid = rule.forbid;
|
|
31
|
+
if (rule.order !== undefined) summary.order = rule.order;
|
|
32
|
+
return summary;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Group fixtures by the composition they target, for variant lookup. */
|
|
36
|
+
function variantsByPrompt(folder: LoadedFolder): Map<string, VariantSummary[]> {
|
|
37
|
+
const byPrompt = new Map<string, VariantSummary[]>();
|
|
38
|
+
for (const fixture of folder.fixtures) {
|
|
39
|
+
const list = byPrompt.get(fixture.prompt) ?? [];
|
|
40
|
+
list.push({ name: fixture.name, context: fixture.context ?? {} });
|
|
41
|
+
byPrompt.set(fixture.prompt, list);
|
|
42
|
+
}
|
|
43
|
+
for (const list of byPrompt.values()) {
|
|
44
|
+
list.sort((a, b) => a.name.localeCompare(b.name));
|
|
45
|
+
}
|
|
46
|
+
return byPrompt;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Project a code-prompt to the wire shape, relativizing its manifest path. */
|
|
50
|
+
function summarizeCodePrompt(promptsDir: string, codePrompt: CodePrompt): CodePromptSummary {
|
|
51
|
+
const summary: CodePromptSummary = {
|
|
52
|
+
name: codePrompt.name,
|
|
53
|
+
samples: codePrompt.samples.map((s) => ({
|
|
54
|
+
label: s.label,
|
|
55
|
+
...(s.context !== undefined ? { context: s.context } : {}),
|
|
56
|
+
output: s.output,
|
|
57
|
+
})),
|
|
58
|
+
sourceFile: relativeSource(promptsDir, codePrompt.sourceFile),
|
|
59
|
+
};
|
|
60
|
+
if (codePrompt.description !== undefined) summary.description = codePrompt.description;
|
|
61
|
+
return summary;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function summarizeComposition(
|
|
65
|
+
variants: VariantSummary[],
|
|
66
|
+
promptsDir: string,
|
|
67
|
+
c: Composition,
|
|
68
|
+
): CompositionSummary {
|
|
69
|
+
const summary: CompositionSummary = {
|
|
70
|
+
name: c.name,
|
|
71
|
+
base: c.base,
|
|
72
|
+
rules: c.rules.map(summarizeRule),
|
|
73
|
+
sourceFile: relativeSource(promptsDir, c.sourceFile),
|
|
74
|
+
variants,
|
|
75
|
+
};
|
|
76
|
+
if (c.order !== undefined) summary.order = c.order;
|
|
77
|
+
return summary;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Build the `GET /api/book` payload: compositions + fragments + warnings. */
|
|
81
|
+
export function buildBookResponse(folder: LoadedFolder, promptsDir: string): BookResponse {
|
|
82
|
+
const { book } = folder;
|
|
83
|
+
const variants = variantsByPrompt(folder);
|
|
84
|
+
const compositions = [...book.compositions.values()]
|
|
85
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
86
|
+
.map((c) => summarizeComposition(variants.get(c.name) ?? [], promptsDir, c));
|
|
87
|
+
const codePrompts = [...book.codePrompts.values()]
|
|
88
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
89
|
+
.map((c) => summarizeCodePrompt(promptsDir, c));
|
|
90
|
+
const fragments = [...book.fragments.values()]
|
|
91
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
92
|
+
.map((f) => ({
|
|
93
|
+
id: f.id,
|
|
94
|
+
...(f.kind !== undefined ? { kind: f.kind } : {}),
|
|
95
|
+
tags: f.tags ?? [],
|
|
96
|
+
body: f.body,
|
|
97
|
+
sourceFile: relativeSource(promptsDir, f.sourceFile),
|
|
98
|
+
}));
|
|
99
|
+
return { compositions, codePrompts, fragments, warnings: book.warnings };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Resolve a variant and attach the colored segments. Throws on unknown prompt. */
|
|
103
|
+
export function buildResolveResponse(
|
|
104
|
+
folder: LoadedFolder,
|
|
105
|
+
prompt: string,
|
|
106
|
+
context: Context,
|
|
107
|
+
): ResolveResponse {
|
|
108
|
+
const { text, trace } = resolveBook(folder.book, prompt, context);
|
|
109
|
+
return { text, trace, segments: deriveSegments(folder.book, trace.finalOrder, context) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Lint a resolved variant and estimate its token count. Throws on unknown prompt. */
|
|
113
|
+
export function buildLintResponse(folder: LoadedFolder, prompt: string, context: Context): LintResponse {
|
|
114
|
+
const result = resolveBook(folder.book, prompt, context);
|
|
115
|
+
const report = lint({ book: folder.book, result });
|
|
116
|
+
return {
|
|
117
|
+
findings: report.findings,
|
|
118
|
+
errorCount: report.errorCount,
|
|
119
|
+
warningCount: report.warningCount,
|
|
120
|
+
tokens: estimateTokensByChars(result.text),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** List the compositions/rules that reference a fragment id. */
|
|
125
|
+
export function buildUsedInResponse(folder: LoadedFolder, fragmentId: string): UsedInResponse {
|
|
126
|
+
return { fragmentId, references: usedIn(folder.book, fragmentId) };
|
|
127
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Context, PromptBook } from "@markbrutx/promptbook-core";
|
|
2
|
+
import { interpolate } from "@markbrutx/promptbook-core";
|
|
3
|
+
import type { Segment } from "../shared/types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Re-derive the per-fragment slices of an assembled prompt for coloring.
|
|
7
|
+
*
|
|
8
|
+
* `resolveBook` joins the interpolated bodies of `trace.finalOrder` with
|
|
9
|
+
* `"\n\n"`. Running the same interpolation over the same ids reproduces those
|
|
10
|
+
* parts exactly, so `segments.map(s => s.text).join("\n\n")` is byte-identical
|
|
11
|
+
* to the resolved `text`. This is asserted in the tests.
|
|
12
|
+
*/
|
|
13
|
+
export function deriveSegments(book: PromptBook, finalOrder: string[], context: Context): Segment[] {
|
|
14
|
+
const segments: Segment[] = [];
|
|
15
|
+
for (const fragmentId of finalOrder) {
|
|
16
|
+
const fragment = book.fragments.get(fragmentId);
|
|
17
|
+
if (fragment === undefined) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
segments.push({
|
|
21
|
+
fragmentId,
|
|
22
|
+
text: interpolate(fragment.body, context, () => {}),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return segments;
|
|
26
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { type FSWatcher, watch } from "node:fs";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import type { FsAdapter } from "@markbrutx/promptbook-core";
|
|
6
|
+
import { createRequestHandler } from "./api.js";
|
|
7
|
+
import { createBookSource } from "./book-source.js";
|
|
8
|
+
|
|
9
|
+
export interface ViewerOptions {
|
|
10
|
+
/** Folder containing `fragments/`, `rules/` and optional `fixtures/`. */
|
|
11
|
+
promptsDir: string;
|
|
12
|
+
/** Port to listen on; 0 (default) picks a free port. */
|
|
13
|
+
port?: number;
|
|
14
|
+
/** Open the URL in the default browser once listening. Default false. */
|
|
15
|
+
open?: boolean;
|
|
16
|
+
/** Filesystem adapter forwarded to the core loader (defaults to Node fs). */
|
|
17
|
+
fs?: FsAdapter;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A running viewer: its URL, the bound port, and a shutdown hook. */
|
|
21
|
+
export interface Viewer {
|
|
22
|
+
url: string;
|
|
23
|
+
port: number;
|
|
24
|
+
close(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Best-effort: open `url` in the OS default browser; never throws. */
|
|
28
|
+
function openBrowser(url: string): void {
|
|
29
|
+
const command =
|
|
30
|
+
process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
31
|
+
try {
|
|
32
|
+
const child = spawn(command, [url], {
|
|
33
|
+
stdio: "ignore",
|
|
34
|
+
detached: true,
|
|
35
|
+
shell: process.platform === "win32",
|
|
36
|
+
});
|
|
37
|
+
child.on("error", () => {});
|
|
38
|
+
child.unref();
|
|
39
|
+
} catch {
|
|
40
|
+
// opening a browser is a convenience, not a requirement
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Watch the prompts folder and call `onChange` (debounced) on any edit. */
|
|
45
|
+
function watchFolder(promptsDir: string, onChange: () => void): FSWatcher | undefined {
|
|
46
|
+
try {
|
|
47
|
+
let timer: NodeJS.Timeout | undefined;
|
|
48
|
+
const watcher = watch(promptsDir, { recursive: true }, () => {
|
|
49
|
+
if (timer !== undefined) {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
}
|
|
52
|
+
timer = setTimeout(onChange, 50);
|
|
53
|
+
});
|
|
54
|
+
watcher.on("error", () => {});
|
|
55
|
+
return watcher;
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Start the viewer server: serves the built web bundle and the `/api/*` routes
|
|
63
|
+
* over a prompts folder, hot-reloading on folder edits. Render-only; no model
|
|
64
|
+
* calls. Resolves once the server is listening.
|
|
65
|
+
*/
|
|
66
|
+
export async function startViewer(options: ViewerOptions): Promise<Viewer> {
|
|
67
|
+
const { promptsDir, port = 0, open = false, fs } = options;
|
|
68
|
+
const webRoot = fileURLToPath(new URL("../web", import.meta.url));
|
|
69
|
+
const source = createBookSource(promptsDir, fs);
|
|
70
|
+
const handler = createRequestHandler({ source, promptsDir, webRoot });
|
|
71
|
+
|
|
72
|
+
const server = createServer((req, res) => handler.handle(req, res));
|
|
73
|
+
await new Promise<void>((resolve, reject) => {
|
|
74
|
+
server.once("error", reject);
|
|
75
|
+
server.listen(port, "127.0.0.1", resolve);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const address = server.address();
|
|
79
|
+
const boundPort = typeof address === "object" && address !== null ? address.port : port;
|
|
80
|
+
const url = `http://localhost:${boundPort}`;
|
|
81
|
+
|
|
82
|
+
const watcher = watchFolder(promptsDir, () => handler.notifyReload());
|
|
83
|
+
|
|
84
|
+
if (open) {
|
|
85
|
+
openBrowser(url);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
url,
|
|
90
|
+
port: boundPort,
|
|
91
|
+
close() {
|
|
92
|
+
watcher?.close();
|
|
93
|
+
return new Promise<void>((resolve) => server.close(() => resolve()));
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|