@markbrutx/promptbook-viewer 0.1.0 → 0.2.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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/server/api.d.ts +4 -5
- package/dist/server/api.d.ts.map +1 -1
- package/dist/server/api.js +49 -12
- package/dist/server/api.js.map +1 -1
- package/dist/server/book-source.d.ts +24 -0
- package/dist/server/book-source.d.ts.map +1 -1
- package/dist/server/book-source.js +39 -0
- package/dist/server/book-source.js.map +1 -1
- package/dist/server/server.d.ts +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +21 -8
- package/dist/server/server.js.map +1 -1
- package/dist/server/workspace.d.ts +13 -0
- package/dist/server/workspace.d.ts.map +1 -0
- package/dist/server/workspace.js +55 -0
- package/dist/server/workspace.js.map +1 -0
- package/dist/shared/types.d.ts +9 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/web/assets/index-BCBuW76o.css +1 -0
- package/dist/web/assets/index-BwIAKPNq.js +51 -0
- package/dist/web/favicon.png +0 -0
- package/dist/web/index.html +3 -2
- package/dist/web/promptbook-logo.png +0 -0
- package/package.json +19 -2
- package/src/index.ts +2 -0
- package/src/server/api.ts +57 -18
- package/src/server/book-source.ts +67 -0
- package/src/server/server.ts +22 -9
- package/src/server/workspace.ts +64 -0
- package/src/shared/types.ts +11 -0
- package/src/web/App.tsx +91 -28
- package/src/web/api.ts +26 -10
- package/src/web/components/Sidebar.tsx +37 -0
- package/src/web/index.html +1 -0
- package/src/web/public/favicon.png +0 -0
- package/src/web/public/promptbook-logo.png +0 -0
- package/src/web/styles.css +35 -0
- package/src/web/types.ts +2 -0
- package/dist/web/assets/index-B2Wxtb-f.css +0 -1
- package/dist/web/assets/index-C8f_6lr_.js +0 -51
|
Binary file
|
package/dist/web/index.html
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
6
7
|
<title>promptbook viewer</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-BwIAKPNq.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BCBuW76o.css">
|
|
9
10
|
</head>
|
|
10
11
|
<body>
|
|
11
12
|
<div id="root"></div>
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markbrutx/promptbook-viewer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Storybook-for-prompts: a local web app that renders a prompts folder via @markbrutx/promptbook-core.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"prompt",
|
|
9
|
+
"prompt-engineering",
|
|
10
|
+
"prompt-management",
|
|
11
|
+
"llm",
|
|
12
|
+
"ai",
|
|
13
|
+
"viewer",
|
|
14
|
+
"storybook",
|
|
15
|
+
"composition"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/markbrutx/promptbook.git",
|
|
20
|
+
"directory": "packages/viewer"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/markbrutx/promptbook#readme",
|
|
23
|
+
"bugs": "https://github.com/markbrutx/promptbook/issues",
|
|
7
24
|
"exports": {
|
|
8
25
|
".": {
|
|
9
26
|
"types": "./dist/index.d.ts",
|
|
@@ -26,7 +43,7 @@
|
|
|
26
43
|
"check": "biome check ."
|
|
27
44
|
},
|
|
28
45
|
"dependencies": {
|
|
29
|
-
"@markbrutx/promptbook-core": "^0.
|
|
46
|
+
"@markbrutx/promptbook-core": "^0.2.0"
|
|
30
47
|
},
|
|
31
48
|
"devDependencies": {
|
|
32
49
|
"@biomejs/biome": "latest",
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type {
|
|
|
7
7
|
Annotation,
|
|
8
8
|
AnnotationsResponse,
|
|
9
9
|
BookResponse,
|
|
10
|
+
BooksResponse,
|
|
10
11
|
CompositionSummary,
|
|
11
12
|
FragmentSummary,
|
|
12
13
|
LintResponse,
|
|
@@ -16,4 +17,5 @@ export type {
|
|
|
16
17
|
UsedInReference,
|
|
17
18
|
UsedInResponse,
|
|
18
19
|
VariantSummary,
|
|
20
|
+
WorkspaceBook,
|
|
19
21
|
} from "./shared/types.js";
|
package/src/server/api.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import type { Context } from "@markbrutx/promptbook-core";
|
|
3
3
|
import type { AnnotateRequest, ResolveRequest } from "../shared/types.js";
|
|
4
|
-
import { type AnnotateInput, createAnnotationStore } from "./annotations.js";
|
|
5
|
-
import type {
|
|
4
|
+
import { type AnnotateInput, type AnnotationStore, createAnnotationStore } from "./annotations.js";
|
|
5
|
+
import type { ResolvedBook, WorkspaceSource } from "./book-source.js";
|
|
6
6
|
import {
|
|
7
7
|
buildBookResponse,
|
|
8
8
|
buildLintResponse,
|
|
@@ -12,8 +12,7 @@ import {
|
|
|
12
12
|
import { serveStatic } from "./static.js";
|
|
13
13
|
|
|
14
14
|
export interface RequestHandlerOptions {
|
|
15
|
-
|
|
16
|
-
promptsDir: string;
|
|
15
|
+
workspace: WorkspaceSource;
|
|
17
16
|
/** Directory of the built web bundle (dist/web). */
|
|
18
17
|
webRoot: string;
|
|
19
18
|
}
|
|
@@ -21,8 +20,8 @@ export interface RequestHandlerOptions {
|
|
|
21
20
|
/** A request handler plus the hook the folder watcher uses to push reloads. */
|
|
22
21
|
export interface RequestHandler {
|
|
23
22
|
handle(req: IncomingMessage, res: ServerResponse): void;
|
|
24
|
-
/** Invalidate
|
|
25
|
-
notifyReload(): void;
|
|
23
|
+
/** Invalidate a changed book (or all) and notify clients to refetch. */
|
|
24
|
+
notifyReload(book?: string): void;
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
function sendJson(res: ServerResponse, status: number, payload: unknown): void {
|
|
@@ -80,48 +79,87 @@ function asAnnotateRequest(body: unknown): AnnotateInput {
|
|
|
80
79
|
return input;
|
|
81
80
|
}
|
|
82
81
|
|
|
82
|
+
const EMPTY_BOOK = { compositions: [], codePrompts: [], fragments: [], warnings: [] } as const;
|
|
83
|
+
|
|
83
84
|
export function createRequestHandler(options: RequestHandlerOptions): RequestHandler {
|
|
84
|
-
const {
|
|
85
|
+
const { workspace, webRoot } = options;
|
|
85
86
|
const sseClients = new Set<ServerResponse>();
|
|
86
|
-
const
|
|
87
|
+
const annotationStores = new Map<string, AnnotationStore>();
|
|
88
|
+
|
|
89
|
+
/** Per-book annotation queue, keyed by directory; bookless roots write at root. */
|
|
90
|
+
const annotationsFor = async (bookName: string | undefined): Promise<AnnotationStore> => {
|
|
91
|
+
const resolved = await workspace.resolve(bookName);
|
|
92
|
+
const dir = resolved?.book.dir ?? workspace.root;
|
|
93
|
+
let store = annotationStores.get(dir);
|
|
94
|
+
if (store === undefined) {
|
|
95
|
+
store = createAnnotationStore(dir);
|
|
96
|
+
annotationStores.set(dir, store);
|
|
97
|
+
}
|
|
98
|
+
return store;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Resolve the active book or throw a 400-worthy error when the workspace is empty. */
|
|
102
|
+
const requireBook = async (bookName: string | undefined): Promise<ResolvedBook> => {
|
|
103
|
+
const resolved = await workspace.resolve(bookName);
|
|
104
|
+
if (resolved === undefined) {
|
|
105
|
+
throw new Error("no book found in the workspace");
|
|
106
|
+
}
|
|
107
|
+
return resolved;
|
|
108
|
+
};
|
|
87
109
|
|
|
88
110
|
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
89
111
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
90
112
|
const path = url.pathname;
|
|
91
113
|
const method = req.method ?? "GET";
|
|
114
|
+
const bookName = url.searchParams.get("book") ?? undefined;
|
|
92
115
|
|
|
93
116
|
try {
|
|
117
|
+
if (path === "/api/books" && method === "GET") {
|
|
118
|
+
sendJson(res, 200, { books: await workspace.books() });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
94
121
|
if (path === "/api/book" && method === "GET") {
|
|
95
|
-
|
|
122
|
+
const resolved = await workspace.resolve(bookName);
|
|
123
|
+
if (resolved === undefined) {
|
|
124
|
+
sendJson(res, 200, EMPTY_BOOK);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
sendJson(res, 200, buildBookResponse(resolved.folder, resolved.book.dir));
|
|
96
128
|
return;
|
|
97
129
|
}
|
|
98
130
|
if (path === "/api/resolve" && method === "POST") {
|
|
99
131
|
const { prompt, context } = asResolveRequest(await readJsonBody(req));
|
|
100
|
-
|
|
132
|
+
const { folder } = await requireBook(bookName);
|
|
133
|
+
sendJson(res, 200, buildResolveResponse(folder, prompt, context));
|
|
101
134
|
return;
|
|
102
135
|
}
|
|
103
136
|
if (path === "/api/lint" && method === "POST") {
|
|
104
137
|
const { prompt, context } = asResolveRequest(await readJsonBody(req));
|
|
105
|
-
|
|
138
|
+
const { folder } = await requireBook(bookName);
|
|
139
|
+
sendJson(res, 200, buildLintResponse(folder, prompt, context));
|
|
106
140
|
return;
|
|
107
141
|
}
|
|
108
142
|
if (path.startsWith("/api/used-in/") && method === "GET") {
|
|
109
143
|
const id = decodeURIComponent(path.slice("/api/used-in/".length));
|
|
110
|
-
|
|
144
|
+
const { folder } = await requireBook(bookName);
|
|
145
|
+
sendJson(res, 200, buildUsedInResponse(folder, id));
|
|
111
146
|
return;
|
|
112
147
|
}
|
|
113
148
|
if (path === "/api/annotate" && method === "POST") {
|
|
114
|
-
const
|
|
149
|
+
const store = await annotationsFor(bookName);
|
|
150
|
+
const annotation = await store.append(asAnnotateRequest(await readJsonBody(req)));
|
|
115
151
|
sendJson(res, 200, annotation);
|
|
116
152
|
return;
|
|
117
153
|
}
|
|
118
154
|
if (path === "/api/annotations" && method === "GET") {
|
|
119
|
-
|
|
155
|
+
const store = await annotationsFor(bookName);
|
|
156
|
+
sendJson(res, 200, { annotations: await store.list() });
|
|
120
157
|
return;
|
|
121
158
|
}
|
|
122
159
|
if (path.startsWith("/api/annotations/") && method === "DELETE") {
|
|
123
160
|
const id = decodeURIComponent(path.slice("/api/annotations/".length));
|
|
124
|
-
const
|
|
161
|
+
const store = await annotationsFor(bookName);
|
|
162
|
+
const removed = await store.remove(id);
|
|
125
163
|
sendJson(res, removed ? 200 : 404, { id, removed });
|
|
126
164
|
return;
|
|
127
165
|
}
|
|
@@ -154,10 +192,11 @@ export function createRequestHandler(options: RequestHandlerOptions): RequestHan
|
|
|
154
192
|
handle(req, res) {
|
|
155
193
|
void handle(req, res);
|
|
156
194
|
},
|
|
157
|
-
notifyReload() {
|
|
158
|
-
|
|
195
|
+
notifyReload(book) {
|
|
196
|
+
workspace.invalidate(book);
|
|
197
|
+
const data = book !== undefined ? JSON.stringify({ book }) : "{}";
|
|
159
198
|
for (const client of sseClients) {
|
|
160
|
-
client.write(
|
|
199
|
+
client.write(`event: reload\ndata: ${data}\n\n`);
|
|
161
200
|
}
|
|
162
201
|
},
|
|
163
202
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Fixture, FsAdapter, PromptBook } from "@markbrutx/promptbook-core";
|
|
2
2
|
import { loadFixtures, loadPrompts } from "@markbrutx/promptbook-core";
|
|
3
|
+
import { type Book, loadWorkspaceBooks } from "./workspace.js";
|
|
3
4
|
|
|
4
5
|
/** A loaded prompts folder plus the fixtures that supply named variants. */
|
|
5
6
|
export interface LoadedFolder {
|
|
@@ -52,3 +53,69 @@ export function createBookSource(promptsDir: string, fs?: FsAdapter): BookSource
|
|
|
52
53
|
},
|
|
53
54
|
};
|
|
54
55
|
}
|
|
56
|
+
|
|
57
|
+
/** A book resolved for a request: its identity plus its loaded folder. */
|
|
58
|
+
export interface ResolvedBook {
|
|
59
|
+
book: Book;
|
|
60
|
+
folder: LoadedFolder;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Caches a {@link BookSource} per book across a whole workspace, discovering the
|
|
65
|
+
* books under the root lazily. One viewer serves every book through a single
|
|
66
|
+
* switcher: `books()` lists them, `resolve(name)` loads one (defaulting to the
|
|
67
|
+
* first), and `invalidate(name)` drops one book's cache while always re-scanning
|
|
68
|
+
* the book set (so a new folder appears without a restart).
|
|
69
|
+
*/
|
|
70
|
+
export interface WorkspaceSource {
|
|
71
|
+
/** Root the workspace was discovered from (annotation fallback when bookless). */
|
|
72
|
+
root: string;
|
|
73
|
+
/** Discovered books (name + dir), re-scanned after {@link invalidate}. */
|
|
74
|
+
books(): Promise<Book[]>;
|
|
75
|
+
/** Load a book by name, or the first book when the name is missing/unknown. */
|
|
76
|
+
resolve(name?: string): Promise<ResolvedBook | undefined>;
|
|
77
|
+
/** Drop one book's cache (or none) and re-scan the book set. */
|
|
78
|
+
invalidate(name?: string): void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createWorkspaceSource(root: string, fs?: FsAdapter): WorkspaceSource {
|
|
82
|
+
let bookList: Promise<Book[]> | undefined;
|
|
83
|
+
const sources = new Map<string, BookSource>();
|
|
84
|
+
|
|
85
|
+
const discover = (): Promise<Book[]> => {
|
|
86
|
+
if (bookList === undefined) {
|
|
87
|
+
bookList = loadWorkspaceBooks(root, fs);
|
|
88
|
+
}
|
|
89
|
+
return bookList;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const sourceFor = (book: Book): BookSource => {
|
|
93
|
+
let source = sources.get(book.name);
|
|
94
|
+
if (source === undefined) {
|
|
95
|
+
source = createBookSource(book.dir, fs);
|
|
96
|
+
sources.set(book.name, source);
|
|
97
|
+
}
|
|
98
|
+
return source;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
root,
|
|
103
|
+
books() {
|
|
104
|
+
return discover();
|
|
105
|
+
},
|
|
106
|
+
async resolve(name) {
|
|
107
|
+
const books = await discover();
|
|
108
|
+
if (books.length === 0) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
const book = books.find((b) => b.name === name) ?? (books[0] as Book);
|
|
112
|
+
return { book, folder: await sourceFor(book).get() };
|
|
113
|
+
},
|
|
114
|
+
invalidate(name) {
|
|
115
|
+
bookList = undefined;
|
|
116
|
+
if (name !== undefined) {
|
|
117
|
+
sources.get(name)?.invalidate();
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
package/src/server/server.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { type FSWatcher, watch } from "node:fs";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
|
+
import { sep } from "node:path";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import type { FsAdapter } from "@markbrutx/promptbook-core";
|
|
6
7
|
import { createRequestHandler } from "./api.js";
|
|
7
|
-
import {
|
|
8
|
+
import { createWorkspaceSource } from "./book-source.js";
|
|
8
9
|
|
|
9
10
|
export interface ViewerOptions {
|
|
10
|
-
/**
|
|
11
|
+
/** Workspace root (a single book, or a folder of sibling books). */
|
|
11
12
|
promptsDir: string;
|
|
12
13
|
/** Port to listen on; 0 (default) picks a free port. */
|
|
13
14
|
port?: number;
|
|
@@ -41,15 +42,27 @@ function openBrowser(url: string): void {
|
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Watch the workspace root and call `onChange` (debounced) with the changed
|
|
47
|
+
* book. A change path's first segment names its top-level book; when several
|
|
48
|
+
* books or unknown paths change in one window, `undefined` is passed so the
|
|
49
|
+
* client refetches the active book regardless.
|
|
50
|
+
*/
|
|
51
|
+
function watchFolder(rootDir: string, onChange: (book: string | undefined) => void): FSWatcher | undefined {
|
|
46
52
|
try {
|
|
47
53
|
let timer: NodeJS.Timeout | undefined;
|
|
48
|
-
|
|
54
|
+
let changed = new Set<string | undefined>();
|
|
55
|
+
const watcher = watch(rootDir, { recursive: true }, (_event, filename) => {
|
|
56
|
+
const name = typeof filename === "string" && filename.length > 0 ? filename : undefined;
|
|
57
|
+
changed.add(name === undefined ? undefined : name.split(sep)[0]);
|
|
49
58
|
if (timer !== undefined) {
|
|
50
59
|
clearTimeout(timer);
|
|
51
60
|
}
|
|
52
|
-
timer = setTimeout(
|
|
61
|
+
timer = setTimeout(() => {
|
|
62
|
+
const books = [...changed];
|
|
63
|
+
changed = new Set();
|
|
64
|
+
onChange(books.length === 1 ? books[0] : undefined);
|
|
65
|
+
}, 50);
|
|
53
66
|
});
|
|
54
67
|
watcher.on("error", () => {});
|
|
55
68
|
return watcher;
|
|
@@ -66,8 +79,8 @@ function watchFolder(promptsDir: string, onChange: () => void): FSWatcher | unde
|
|
|
66
79
|
export async function startViewer(options: ViewerOptions): Promise<Viewer> {
|
|
67
80
|
const { promptsDir, port = 0, open = false, fs } = options;
|
|
68
81
|
const webRoot = fileURLToPath(new URL("../web", import.meta.url));
|
|
69
|
-
const
|
|
70
|
-
const handler = createRequestHandler({
|
|
82
|
+
const workspace = createWorkspaceSource(promptsDir, fs);
|
|
83
|
+
const handler = createRequestHandler({ workspace, webRoot });
|
|
71
84
|
|
|
72
85
|
const server = createServer((req, res) => handler.handle(req, res));
|
|
73
86
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -79,7 +92,7 @@ export async function startViewer(options: ViewerOptions): Promise<Viewer> {
|
|
|
79
92
|
const boundPort = typeof address === "object" && address !== null ? address.port : port;
|
|
80
93
|
const url = `http://localhost:${boundPort}`;
|
|
81
94
|
|
|
82
|
-
const watcher = watchFolder(promptsDir, () => handler.notifyReload());
|
|
95
|
+
const watcher = watchFolder(promptsDir, (book) => handler.notifyReload(book));
|
|
83
96
|
|
|
84
97
|
if (open) {
|
|
85
98
|
openBrowser(url);
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { basename, join } from "node:path";
|
|
2
|
+
import type { FsAdapter } from "@markbrutx/promptbook-core";
|
|
3
|
+
import { nodeFs } from "@markbrutx/promptbook-core";
|
|
4
|
+
|
|
5
|
+
/** A discovered prompts book: its folder name and absolute directory. */
|
|
6
|
+
export interface Book {
|
|
7
|
+
name: string;
|
|
8
|
+
dir: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Folder names that are book internals or noise, never workspace sub-books. */
|
|
12
|
+
const NON_BOOK_DIRS = new Set(["node_modules", "fragments", "rules", "code-prompts"]);
|
|
13
|
+
|
|
14
|
+
/** Markers that make a folder a loadable book (a config or a loadable form). */
|
|
15
|
+
const BOOK_MARKERS = ["promptbook.json", "rules", "code-prompts"];
|
|
16
|
+
|
|
17
|
+
/** True when `dir` looks like a prompts book (has a config or a loadable form). */
|
|
18
|
+
async function isBook(fs: FsAdapter, dir: string): Promise<boolean> {
|
|
19
|
+
let entries: string[];
|
|
20
|
+
try {
|
|
21
|
+
entries = await fs.readDir(dir);
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return BOOK_MARKERS.some((marker) => entries.includes(marker));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Find the books directly under `rootDir`: each non-internal subfolder that is
|
|
30
|
+
* itself a book. One level deep, sorted by name. This mirrors the CLI's
|
|
31
|
+
* discovery (kept as a server-local copy so the viewer never depends on the
|
|
32
|
+
* CLI), so `view` and `ls --all` see the same workspace.
|
|
33
|
+
*/
|
|
34
|
+
async function discoverBooks(rootDir: string, fs: FsAdapter = nodeFs()): Promise<Book[]> {
|
|
35
|
+
let entries: string[];
|
|
36
|
+
try {
|
|
37
|
+
entries = await fs.readDir(rootDir);
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
const books: Book[] = [];
|
|
42
|
+
for (const name of [...entries].sort()) {
|
|
43
|
+
if (name.startsWith(".") || name.startsWith("_") || NON_BOOK_DIRS.has(name)) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const dir = join(rootDir, name);
|
|
47
|
+
if (await isBook(fs, dir)) {
|
|
48
|
+
books.push({ name, dir });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return books;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read a workspace from `rootDir`. When the root is itself a book it is the only
|
|
56
|
+
* book (back-compat single-book view, named after the folder); otherwise the
|
|
57
|
+
* root is a workspace of its sub-books.
|
|
58
|
+
*/
|
|
59
|
+
export async function loadWorkspaceBooks(rootDir: string, fs: FsAdapter = nodeFs()): Promise<Book[]> {
|
|
60
|
+
if (await isBook(fs, rootDir)) {
|
|
61
|
+
return [{ name: basename(rootDir), dir: rootDir }];
|
|
62
|
+
}
|
|
63
|
+
return discoverBooks(rootDir, fs);
|
|
64
|
+
}
|
package/src/shared/types.ts
CHANGED
|
@@ -68,6 +68,17 @@ export interface CodePromptSummary {
|
|
|
68
68
|
sourceFile: string;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** One book in the workspace, as `GET /api/books` lists it. */
|
|
72
|
+
export interface WorkspaceBook {
|
|
73
|
+
name: string;
|
|
74
|
+
dir: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Response of `GET /api/books`: the switcher menu. */
|
|
78
|
+
export interface BooksResponse {
|
|
79
|
+
books: WorkspaceBook[];
|
|
80
|
+
}
|
|
81
|
+
|
|
71
82
|
/** Response of `GET /api/book`. */
|
|
72
83
|
export interface BookResponse {
|
|
73
84
|
compositions: CompositionSummary[];
|
package/src/web/App.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
LintResponse,
|
|
18
18
|
ResolveResponse,
|
|
19
19
|
UsedInResponse,
|
|
20
|
+
WorkspaceBook,
|
|
20
21
|
} from "./types.js";
|
|
21
22
|
|
|
22
23
|
/** Order-independent key for comparing two contexts (variant identity). */
|
|
@@ -47,6 +48,8 @@ function controlKeys(composition: CompositionSummary | undefined): string[] {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export function App() {
|
|
51
|
+
const [books, setBooks] = useState<WorkspaceBook[]>([]);
|
|
52
|
+
const [activeBook, setActiveBook] = useState<string | null>(null);
|
|
50
53
|
const [book, setBook] = useState<BookResponse | null>(null);
|
|
51
54
|
const [error, setError] = useState<string | null>(null);
|
|
52
55
|
const [selection, setSelection] = useState<Selection | null>(null);
|
|
@@ -58,19 +61,34 @@ export function App() {
|
|
|
58
61
|
const [compareResolved, setCompareResolved] = useState<ResolveResponse | null>(null);
|
|
59
62
|
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
|
60
63
|
const requestId = useRef(0);
|
|
64
|
+
// Mirror activeBook into a ref so the (book-independent) SSE subscription can
|
|
65
|
+
// read the current book without re-subscribing on every switch.
|
|
66
|
+
const activeBookRef = useRef<string | null>(null);
|
|
67
|
+
activeBookRef.current = activeBook;
|
|
61
68
|
|
|
62
|
-
const
|
|
69
|
+
const loadBooks = useCallback(async () => {
|
|
63
70
|
try {
|
|
64
|
-
const {
|
|
71
|
+
const { books: next } = await api.books();
|
|
72
|
+
setBooks(next);
|
|
73
|
+
return next;
|
|
74
|
+
} catch {
|
|
75
|
+
setBooks([]);
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
const loadAnnotations = useCallback(async (which: string | null) => {
|
|
81
|
+
try {
|
|
82
|
+
const { annotations: next } = await api.annotations(which);
|
|
65
83
|
setAnnotations(next);
|
|
66
84
|
} catch {
|
|
67
85
|
setAnnotations([]);
|
|
68
86
|
}
|
|
69
87
|
}, []);
|
|
70
88
|
|
|
71
|
-
const loadBook = useCallback(async () => {
|
|
89
|
+
const loadBook = useCallback(async (which: string | null) => {
|
|
72
90
|
try {
|
|
73
|
-
const next = await api.book();
|
|
91
|
+
const next = await api.book(which);
|
|
74
92
|
setBook(next);
|
|
75
93
|
setError(null);
|
|
76
94
|
return next;
|
|
@@ -80,25 +98,54 @@ export function App() {
|
|
|
80
98
|
}
|
|
81
99
|
}, []);
|
|
82
100
|
|
|
83
|
-
//
|
|
101
|
+
// Discover the workspace's books, then activate the first one.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
void loadBooks().then((list) => {
|
|
104
|
+
setActiveBook((current) => current ?? list[0]?.name ?? null);
|
|
105
|
+
});
|
|
106
|
+
}, [loadBooks]);
|
|
107
|
+
|
|
108
|
+
// Load the active book's tree + annotations and reset the selection to its
|
|
109
|
+
// first composition. Re-runs on every book switch (a fresh menu each time).
|
|
84
110
|
useEffect(() => {
|
|
85
|
-
|
|
111
|
+
if (activeBook === null) {
|
|
112
|
+
setBook(null);
|
|
113
|
+
setSelection(null);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
void loadBook(activeBook).then((next) => {
|
|
86
117
|
const first = next?.compositions[0];
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
118
|
+
setSelection(
|
|
119
|
+
first !== undefined
|
|
120
|
+
? { kind: "variant", composition: first.name, variant: DEFAULT_VARIANT.name }
|
|
121
|
+
: null,
|
|
122
|
+
);
|
|
123
|
+
setContext({});
|
|
124
|
+
setCompareVariant("");
|
|
91
125
|
});
|
|
92
|
-
void loadAnnotations();
|
|
93
|
-
}, [loadBook, loadAnnotations]);
|
|
126
|
+
void loadAnnotations(activeBook);
|
|
127
|
+
}, [activeBook, loadBook, loadAnnotations]);
|
|
94
128
|
|
|
95
|
-
// Hot-reload: refetch the book
|
|
96
|
-
// fresh reference, so the
|
|
129
|
+
// Hot-reload: refetch the book list, and the active book's tree when it (or
|
|
130
|
+
// an unknown path) changed. The new book object is a fresh reference, so the
|
|
131
|
+
// resolve/used-in effects below re-run automatically; the selection persists.
|
|
97
132
|
useEffect(() => {
|
|
98
133
|
const source = new EventSource("/api/events");
|
|
99
|
-
source.addEventListener("reload", () =>
|
|
134
|
+
source.addEventListener("reload", (event) => {
|
|
135
|
+
let changed: string | undefined;
|
|
136
|
+
try {
|
|
137
|
+
changed = (JSON.parse((event as MessageEvent).data) as { book?: string }).book;
|
|
138
|
+
} catch {
|
|
139
|
+
changed = undefined;
|
|
140
|
+
}
|
|
141
|
+
void loadBooks();
|
|
142
|
+
const active = activeBookRef.current;
|
|
143
|
+
if (active !== null && (changed === undefined || changed === active)) {
|
|
144
|
+
void loadBook(active);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
100
147
|
return () => source.close();
|
|
101
|
-
}, [loadBook]);
|
|
148
|
+
}, [loadBook, loadBooks]);
|
|
102
149
|
|
|
103
150
|
const compositions = book?.compositions ?? [];
|
|
104
151
|
const selectedComposition =
|
|
@@ -113,7 +160,10 @@ export function App() {
|
|
|
113
160
|
requestId.current += 1;
|
|
114
161
|
const id = requestId.current;
|
|
115
162
|
const { composition } = selection;
|
|
116
|
-
void Promise.all([
|
|
163
|
+
void Promise.all([
|
|
164
|
+
api.resolve(activeBook, composition, context),
|
|
165
|
+
api.lint(activeBook, composition, context),
|
|
166
|
+
])
|
|
117
167
|
.then(([r, l]) => {
|
|
118
168
|
if (requestId.current === id) {
|
|
119
169
|
setResolved(r);
|
|
@@ -126,7 +176,7 @@ export function App() {
|
|
|
126
176
|
setError((e as Error).message);
|
|
127
177
|
}
|
|
128
178
|
});
|
|
129
|
-
}, [book, selection, context]);
|
|
179
|
+
}, [book, selection, context, activeBook]);
|
|
130
180
|
|
|
131
181
|
useEffect(() => {
|
|
132
182
|
if (book === null || selection?.kind !== "fragment") {
|
|
@@ -134,10 +184,10 @@ export function App() {
|
|
|
134
184
|
return;
|
|
135
185
|
}
|
|
136
186
|
void api
|
|
137
|
-
.usedIn(selection.id)
|
|
187
|
+
.usedIn(activeBook, selection.id)
|
|
138
188
|
.then(setUsedIn)
|
|
139
189
|
.catch(() => setUsedIn(null));
|
|
140
|
-
}, [book, selection]);
|
|
190
|
+
}, [book, selection, activeBook]);
|
|
141
191
|
|
|
142
192
|
// Resolve the comparison variant for the Diff panel.
|
|
143
193
|
useEffect(() => {
|
|
@@ -147,10 +197,14 @@ export function App() {
|
|
|
147
197
|
}
|
|
148
198
|
const ctx = variantContext(selectedComposition, compareVariant);
|
|
149
199
|
void api
|
|
150
|
-
.resolve(selection.composition, ctx)
|
|
200
|
+
.resolve(activeBook, selection.composition, ctx)
|
|
151
201
|
.then(setCompareResolved)
|
|
152
202
|
.catch(() => setCompareResolved(null));
|
|
153
|
-
}, [selection, compareVariant, selectedComposition]);
|
|
203
|
+
}, [selection, compareVariant, selectedComposition, activeBook]);
|
|
204
|
+
|
|
205
|
+
const selectBook = useCallback((name: string) => {
|
|
206
|
+
setActiveBook(name);
|
|
207
|
+
}, []);
|
|
154
208
|
|
|
155
209
|
const selectVariant = useCallback(
|
|
156
210
|
(composition: string, variant: string) => {
|
|
@@ -175,18 +229,24 @@ export function App() {
|
|
|
175
229
|
if (selection?.kind !== "variant") {
|
|
176
230
|
return;
|
|
177
231
|
}
|
|
178
|
-
await api.annotate(
|
|
179
|
-
|
|
232
|
+
await api.annotate(activeBook, {
|
|
233
|
+
prompt: selection.composition,
|
|
234
|
+
context,
|
|
235
|
+
fragmentId,
|
|
236
|
+
anchorText,
|
|
237
|
+
comment,
|
|
238
|
+
});
|
|
239
|
+
await loadAnnotations(activeBook);
|
|
180
240
|
},
|
|
181
|
-
[selection, context, loadAnnotations],
|
|
241
|
+
[selection, context, activeBook, loadAnnotations],
|
|
182
242
|
);
|
|
183
243
|
|
|
184
244
|
const resolveAnnotation = useCallback(
|
|
185
245
|
async (id: string) => {
|
|
186
|
-
await api.resolveAnnotation(id);
|
|
187
|
-
await loadAnnotations();
|
|
246
|
+
await api.resolveAnnotation(activeBook, id);
|
|
247
|
+
await loadAnnotations(activeBook);
|
|
188
248
|
},
|
|
189
|
-
[loadAnnotations],
|
|
249
|
+
[activeBook, loadAnnotations],
|
|
190
250
|
);
|
|
191
251
|
|
|
192
252
|
// Annotations belonging to exactly the variant on screen (composition + context).
|
|
@@ -211,6 +271,9 @@ export function App() {
|
|
|
211
271
|
return (
|
|
212
272
|
<div className="layout">
|
|
213
273
|
<Sidebar
|
|
274
|
+
books={books}
|
|
275
|
+
activeBook={activeBook}
|
|
276
|
+
onSelectBook={selectBook}
|
|
214
277
|
tree={tree}
|
|
215
278
|
fragmentGroups={fragmentGroups}
|
|
216
279
|
selection={selection}
|