@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,60 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import type { ServerResponse } from "node:http";
|
|
3
|
+
import { extname, join, normalize } from "node:path";
|
|
4
|
+
|
|
5
|
+
const MIME: Record<string, string> = {
|
|
6
|
+
".html": "text/html; charset=utf-8",
|
|
7
|
+
".js": "text/javascript; charset=utf-8",
|
|
8
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
9
|
+
".css": "text/css; charset=utf-8",
|
|
10
|
+
".json": "application/json; charset=utf-8",
|
|
11
|
+
".svg": "image/svg+xml",
|
|
12
|
+
".png": "image/png",
|
|
13
|
+
".ico": "image/x-icon",
|
|
14
|
+
".woff2": "font/woff2",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Placeholder shown when the web bundle is missing (folder not built yet). */
|
|
18
|
+
const NOT_BUILT = `<!doctype html><meta charset="utf-8"><title>promptbook viewer</title>
|
|
19
|
+
<body style="font-family:system-ui;padding:2rem;line-height:1.5">
|
|
20
|
+
<h1>promptbook viewer</h1>
|
|
21
|
+
<p>The web bundle was not found. Build it with:</p>
|
|
22
|
+
<pre>npm -w @markbrutx/promptbook-viewer run build</pre>
|
|
23
|
+
<p>The API is live at <code>/api/book</code>.</p>`;
|
|
24
|
+
|
|
25
|
+
/** Resolve a request path to a file inside the web root, blocking traversal. */
|
|
26
|
+
function resolveAsset(webRoot: string, urlPath: string): string {
|
|
27
|
+
const clean = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
|
|
28
|
+
const rel = clean === "/" || clean === "" ? "index.html" : clean.replace(/^\/+/, "");
|
|
29
|
+
return join(webRoot, rel);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readIfFile(path: string): Promise<Buffer | undefined> {
|
|
33
|
+
try {
|
|
34
|
+
if ((await stat(path)).isFile()) {
|
|
35
|
+
return await readFile(path);
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// fall through
|
|
39
|
+
}
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Serve a static asset from the built web bundle. Unknown paths fall back to
|
|
45
|
+
* `index.html` (single-page app), and a missing bundle yields a friendly page
|
|
46
|
+
* pointing at the build command rather than a bare 404.
|
|
47
|
+
*/
|
|
48
|
+
export async function serveStatic(res: ServerResponse, webRoot: string, urlPath: string): Promise<void> {
|
|
49
|
+
const asset = resolveAsset(webRoot, urlPath);
|
|
50
|
+
const direct = await readIfFile(asset);
|
|
51
|
+
if (direct !== undefined) {
|
|
52
|
+
res.writeHead(200, { "content-type": MIME[extname(asset)] ?? "application/octet-stream" });
|
|
53
|
+
res.end(direct);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const index = await readIfFile(join(webRoot, "index.html"));
|
|
58
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
59
|
+
res.end(index ?? NOT_BUILT);
|
|
60
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PromptBook } from "@markbrutx/promptbook-core";
|
|
2
|
+
import { iterateReferences } from "@markbrutx/promptbook-core";
|
|
3
|
+
import type { UsedInReference } from "../shared/types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* All references to `fragmentId` across compositions (base/order + each rule's
|
|
7
|
+
* add/after/replace/forbid/order), reported per-composition for the used-in
|
|
8
|
+
* panel. Reuses core's reference walk so the viewer can't drift from lint.
|
|
9
|
+
*/
|
|
10
|
+
export function usedIn(book: PromptBook, fragmentId: string): UsedInReference[] {
|
|
11
|
+
const references: UsedInReference[] = [];
|
|
12
|
+
for (const ref of iterateReferences(book)) {
|
|
13
|
+
if (ref.id !== fragmentId) {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
references.push(
|
|
17
|
+
ref.ruleIndex === undefined
|
|
18
|
+
? { composition: ref.composition, role: ref.role }
|
|
19
|
+
: { composition: ref.composition, role: ref.role, ruleIndex: ref.ruleIndex },
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return references;
|
|
23
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types shared by the server and the web app. These are the JSON shapes
|
|
3
|
+
* the `/api/*` routes speak; importing them on both sides keeps the contract
|
|
4
|
+
* in one place. Only type-only imports from `@markbrutx/promptbook-core` are used so the
|
|
5
|
+
* file carries no runtime dependency.
|
|
6
|
+
*/
|
|
7
|
+
import type {
|
|
8
|
+
Annotation,
|
|
9
|
+
Context,
|
|
10
|
+
FragmentReference,
|
|
11
|
+
LintFinding,
|
|
12
|
+
RuleAction,
|
|
13
|
+
Trace,
|
|
14
|
+
When,
|
|
15
|
+
} from "@markbrutx/promptbook-core";
|
|
16
|
+
|
|
17
|
+
export type { Annotation } from "@markbrutx/promptbook-core";
|
|
18
|
+
|
|
19
|
+
/** A rule reduced to what the viewer renders (no behavior, just the shape). */
|
|
20
|
+
export interface RuleSummary {
|
|
21
|
+
index: number;
|
|
22
|
+
when: When;
|
|
23
|
+
action: RuleAction;
|
|
24
|
+
add?: string[];
|
|
25
|
+
after?: string;
|
|
26
|
+
replace?: Record<string, string>;
|
|
27
|
+
forbid?: string[];
|
|
28
|
+
order?: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A named context preset for a composition, sourced from `fixtures/`. */
|
|
32
|
+
export interface VariantSummary {
|
|
33
|
+
name: string;
|
|
34
|
+
context: Context;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A composition as the sidebar/canvas needs it. */
|
|
38
|
+
export interface CompositionSummary {
|
|
39
|
+
name: string;
|
|
40
|
+
base: string[];
|
|
41
|
+
order?: string[];
|
|
42
|
+
rules: RuleSummary[];
|
|
43
|
+
sourceFile: string;
|
|
44
|
+
variants: VariantSummary[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A fragment as the inspector/used-in panels need it. */
|
|
48
|
+
export interface FragmentSummary {
|
|
49
|
+
id: string;
|
|
50
|
+
kind?: string;
|
|
51
|
+
tags: string[];
|
|
52
|
+
body: string;
|
|
53
|
+
sourceFile: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One captured snapshot of a code-prompt's output (the builder is never run). */
|
|
57
|
+
interface CodePromptSampleSummary {
|
|
58
|
+
label: string;
|
|
59
|
+
context?: Context;
|
|
60
|
+
output: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A builder-backed prompt as the unified menu/canvas needs it. */
|
|
64
|
+
export interface CodePromptSummary {
|
|
65
|
+
name: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
samples: CodePromptSampleSummary[];
|
|
68
|
+
sourceFile: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Response of `GET /api/book`. */
|
|
72
|
+
export interface BookResponse {
|
|
73
|
+
compositions: CompositionSummary[];
|
|
74
|
+
codePrompts: CodePromptSummary[];
|
|
75
|
+
fragments: FragmentSummary[];
|
|
76
|
+
warnings: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One colored slice of the assembled prompt, mapped to its source fragment. */
|
|
80
|
+
export interface Segment {
|
|
81
|
+
fragmentId: string;
|
|
82
|
+
text: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Request body for `POST /api/resolve` and `POST /api/lint`. */
|
|
86
|
+
export interface ResolveRequest {
|
|
87
|
+
prompt: string;
|
|
88
|
+
context?: Context;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Response of `POST /api/resolve`. */
|
|
92
|
+
export interface ResolveResponse {
|
|
93
|
+
text: string;
|
|
94
|
+
trace: Trace;
|
|
95
|
+
segments: Segment[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Response of `POST /api/lint`. */
|
|
99
|
+
export interface LintResponse {
|
|
100
|
+
findings: LintFinding[];
|
|
101
|
+
errorCount: number;
|
|
102
|
+
warningCount: number;
|
|
103
|
+
tokens: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Where a fragment id is referenced inside a composition (core's role union). */
|
|
107
|
+
type UsedInRole = FragmentReference["role"];
|
|
108
|
+
|
|
109
|
+
/** One reference to a fragment, as `GET /api/used-in/:id` reports it. */
|
|
110
|
+
export interface UsedInReference {
|
|
111
|
+
composition: string;
|
|
112
|
+
role: UsedInRole;
|
|
113
|
+
ruleIndex?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Response of `GET /api/used-in/:id`. */
|
|
117
|
+
export interface UsedInResponse {
|
|
118
|
+
fragmentId: string;
|
|
119
|
+
references: UsedInReference[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Request body for `POST /api/annotate`. */
|
|
123
|
+
export interface AnnotateRequest {
|
|
124
|
+
/** Composition of the annotated variant (omit when targeting a fragment). */
|
|
125
|
+
prompt?: string;
|
|
126
|
+
context?: Context;
|
|
127
|
+
fragmentId: string;
|
|
128
|
+
anchorText: string;
|
|
129
|
+
comment: string;
|
|
130
|
+
offset?: number;
|
|
131
|
+
sourceFile?: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Response of `GET /api/annotations`. */
|
|
135
|
+
export interface AnnotationsResponse {
|
|
136
|
+
annotations: Annotation[];
|
|
137
|
+
}
|
package/src/web/App.tsx
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { api } from "./api.js";
|
|
3
|
+
import { Addons } from "./components/Addons.js";
|
|
4
|
+
import { Canvas } from "./components/Canvas.js";
|
|
5
|
+
import { CodePromptView } from "./components/CodePromptView.js";
|
|
6
|
+
import { Controls } from "./components/Controls.js";
|
|
7
|
+
import { Diff } from "./components/Diff.js";
|
|
8
|
+
import { FragmentView } from "./components/FragmentView.js";
|
|
9
|
+
import { Sidebar } from "./components/Sidebar.js";
|
|
10
|
+
import type { Selection } from "./selection.js";
|
|
11
|
+
import { buildCompositionTree, buildFragmentGroups, DEFAULT_VARIANT } from "./tree.js";
|
|
12
|
+
import type {
|
|
13
|
+
Annotation,
|
|
14
|
+
BookResponse,
|
|
15
|
+
CompositionSummary,
|
|
16
|
+
Context,
|
|
17
|
+
LintResponse,
|
|
18
|
+
ResolveResponse,
|
|
19
|
+
UsedInResponse,
|
|
20
|
+
} from "./types.js";
|
|
21
|
+
|
|
22
|
+
/** Order-independent key for comparing two contexts (variant identity). */
|
|
23
|
+
function contextKey(context: Context): string {
|
|
24
|
+
return JSON.stringify(Object.entries(context).sort(([a], [b]) => a.localeCompare(b)));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The context a named variant carries (Default = empty). */
|
|
28
|
+
function variantContext(composition: CompositionSummary | undefined, variant: string): Context {
|
|
29
|
+
if (composition === undefined || variant === DEFAULT_VARIANT.name) {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
return composition.variants.find((v) => v.name === variant)?.context ?? {};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The context axes worth offering as Controls for a composition. */
|
|
36
|
+
function controlKeys(composition: CompositionSummary | undefined): string[] {
|
|
37
|
+
if (composition === undefined) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
const keys = new Set<string>();
|
|
41
|
+
for (const rule of composition.rules) {
|
|
42
|
+
for (const key of Object.keys(rule.when)) {
|
|
43
|
+
keys.add(key);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return [...keys];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function App() {
|
|
50
|
+
const [book, setBook] = useState<BookResponse | null>(null);
|
|
51
|
+
const [error, setError] = useState<string | null>(null);
|
|
52
|
+
const [selection, setSelection] = useState<Selection | null>(null);
|
|
53
|
+
const [context, setContext] = useState<Context>({});
|
|
54
|
+
const [resolved, setResolved] = useState<ResolveResponse | null>(null);
|
|
55
|
+
const [lint, setLint] = useState<LintResponse | null>(null);
|
|
56
|
+
const [usedIn, setUsedIn] = useState<UsedInResponse | null>(null);
|
|
57
|
+
const [compareVariant, setCompareVariant] = useState<string>("");
|
|
58
|
+
const [compareResolved, setCompareResolved] = useState<ResolveResponse | null>(null);
|
|
59
|
+
const [annotations, setAnnotations] = useState<Annotation[]>([]);
|
|
60
|
+
const requestId = useRef(0);
|
|
61
|
+
|
|
62
|
+
const loadAnnotations = useCallback(async () => {
|
|
63
|
+
try {
|
|
64
|
+
const { annotations: next } = await api.annotations();
|
|
65
|
+
setAnnotations(next);
|
|
66
|
+
} catch {
|
|
67
|
+
setAnnotations([]);
|
|
68
|
+
}
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const loadBook = useCallback(async () => {
|
|
72
|
+
try {
|
|
73
|
+
const next = await api.book();
|
|
74
|
+
setBook(next);
|
|
75
|
+
setError(null);
|
|
76
|
+
return next;
|
|
77
|
+
} catch (e) {
|
|
78
|
+
setError((e as Error).message);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
// Initial load + pick the first composition's Default variant.
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
void loadBook().then((next) => {
|
|
86
|
+
const first = next?.compositions[0];
|
|
87
|
+
if (first !== undefined) {
|
|
88
|
+
setSelection({ kind: "variant", composition: first.name, variant: DEFAULT_VARIANT.name });
|
|
89
|
+
setContext({});
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
void loadAnnotations();
|
|
93
|
+
}, [loadBook, loadAnnotations]);
|
|
94
|
+
|
|
95
|
+
// Hot-reload: refetch the book on folder changes. The new book object is a
|
|
96
|
+
// fresh reference, so the resolve/used-in effects below re-run automatically.
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const source = new EventSource("/api/events");
|
|
99
|
+
source.addEventListener("reload", () => void loadBook());
|
|
100
|
+
return () => source.close();
|
|
101
|
+
}, [loadBook]);
|
|
102
|
+
|
|
103
|
+
const compositions = book?.compositions ?? [];
|
|
104
|
+
const selectedComposition =
|
|
105
|
+
selection?.kind === "variant" ? compositions.find((c) => c.name === selection.composition) : undefined;
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (book === null || selection?.kind !== "variant") {
|
|
109
|
+
setResolved(null);
|
|
110
|
+
setLint(null);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
requestId.current += 1;
|
|
114
|
+
const id = requestId.current;
|
|
115
|
+
const { composition } = selection;
|
|
116
|
+
void Promise.all([api.resolve(composition, context), api.lint(composition, context)])
|
|
117
|
+
.then(([r, l]) => {
|
|
118
|
+
if (requestId.current === id) {
|
|
119
|
+
setResolved(r);
|
|
120
|
+
setLint(l);
|
|
121
|
+
setError(null);
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
.catch((e) => {
|
|
125
|
+
if (requestId.current === id) {
|
|
126
|
+
setError((e as Error).message);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}, [book, selection, context]);
|
|
130
|
+
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (book === null || selection?.kind !== "fragment") {
|
|
133
|
+
setUsedIn(null);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
void api
|
|
137
|
+
.usedIn(selection.id)
|
|
138
|
+
.then(setUsedIn)
|
|
139
|
+
.catch(() => setUsedIn(null));
|
|
140
|
+
}, [book, selection]);
|
|
141
|
+
|
|
142
|
+
// Resolve the comparison variant for the Diff panel.
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (selection?.kind !== "variant" || compareVariant === "") {
|
|
145
|
+
setCompareResolved(null);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const ctx = variantContext(selectedComposition, compareVariant);
|
|
149
|
+
void api
|
|
150
|
+
.resolve(selection.composition, ctx)
|
|
151
|
+
.then(setCompareResolved)
|
|
152
|
+
.catch(() => setCompareResolved(null));
|
|
153
|
+
}, [selection, compareVariant, selectedComposition]);
|
|
154
|
+
|
|
155
|
+
const selectVariant = useCallback(
|
|
156
|
+
(composition: string, variant: string) => {
|
|
157
|
+
const summary = compositions.find((c) => c.name === composition);
|
|
158
|
+
setSelection({ kind: "variant", composition, variant });
|
|
159
|
+
setContext(variantContext(summary, variant));
|
|
160
|
+
setCompareVariant("");
|
|
161
|
+
},
|
|
162
|
+
[compositions],
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const selectCode = useCallback((name: string, sample: string) => {
|
|
166
|
+
setSelection({ kind: "code", name, sample });
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
const selectFragment = useCallback((id: string) => {
|
|
170
|
+
setSelection({ kind: "fragment", id });
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
const addAnnotation = useCallback(
|
|
174
|
+
async (fragmentId: string, anchorText: string, comment: string) => {
|
|
175
|
+
if (selection?.kind !== "variant") {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
await api.annotate({ prompt: selection.composition, context, fragmentId, anchorText, comment });
|
|
179
|
+
await loadAnnotations();
|
|
180
|
+
},
|
|
181
|
+
[selection, context, loadAnnotations],
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const resolveAnnotation = useCallback(
|
|
185
|
+
async (id: string) => {
|
|
186
|
+
await api.resolveAnnotation(id);
|
|
187
|
+
await loadAnnotations();
|
|
188
|
+
},
|
|
189
|
+
[loadAnnotations],
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
// Annotations belonging to exactly the variant on screen (composition + context).
|
|
193
|
+
const variantAnnotations = useMemo(() => {
|
|
194
|
+
if (selection?.kind !== "variant") {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
const key = contextKey(context);
|
|
198
|
+
return annotations.filter(
|
|
199
|
+
(a) => a.target.prompt === selection.composition && contextKey(a.target.context ?? {}) === key,
|
|
200
|
+
);
|
|
201
|
+
}, [annotations, selection, context]);
|
|
202
|
+
|
|
203
|
+
const codePrompts = book?.codePrompts ?? [];
|
|
204
|
+
const tree = useMemo(() => buildCompositionTree(compositions, codePrompts), [compositions, codePrompts]);
|
|
205
|
+
const fragmentGroups = useMemo(() => buildFragmentGroups(book?.fragments ?? []), [book]);
|
|
206
|
+
const selectedFragment =
|
|
207
|
+
selection?.kind === "fragment" ? book?.fragments.find((f) => f.id === selection.id) : undefined;
|
|
208
|
+
const selectedCodePrompt =
|
|
209
|
+
selection?.kind === "code" ? codePrompts.find((c) => c.name === selection.name) : undefined;
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<div className="layout">
|
|
213
|
+
<Sidebar
|
|
214
|
+
tree={tree}
|
|
215
|
+
fragmentGroups={fragmentGroups}
|
|
216
|
+
selection={selection}
|
|
217
|
+
onSelectVariant={selectVariant}
|
|
218
|
+
onSelectCode={selectCode}
|
|
219
|
+
onSelectFragment={selectFragment}
|
|
220
|
+
/>
|
|
221
|
+
|
|
222
|
+
{error !== null ? (
|
|
223
|
+
<main className="canvas">
|
|
224
|
+
<p className="warn">{error}</p>
|
|
225
|
+
</main>
|
|
226
|
+
) : null}
|
|
227
|
+
|
|
228
|
+
{error === null && selection?.kind === "variant" && resolved !== null ? (
|
|
229
|
+
<Canvas
|
|
230
|
+
title={selection.composition}
|
|
231
|
+
subtitle={selection.variant}
|
|
232
|
+
segments={resolved.segments}
|
|
233
|
+
tokens={lint?.tokens}
|
|
234
|
+
annotations={variantAnnotations}
|
|
235
|
+
onAnnotate={addAnnotation}
|
|
236
|
+
/>
|
|
237
|
+
) : null}
|
|
238
|
+
|
|
239
|
+
{error === null && selection?.kind === "code" && selectedCodePrompt !== undefined ? (
|
|
240
|
+
<CodePromptView
|
|
241
|
+
codePrompt={selectedCodePrompt}
|
|
242
|
+
sample={selection.sample}
|
|
243
|
+
onSelectSample={(label) => selectCode(selectedCodePrompt.name, label)}
|
|
244
|
+
/>
|
|
245
|
+
) : null}
|
|
246
|
+
|
|
247
|
+
{error === null && selectedFragment !== undefined ? (
|
|
248
|
+
<FragmentView
|
|
249
|
+
fragment={selectedFragment}
|
|
250
|
+
usedIn={usedIn}
|
|
251
|
+
onSelectVariant={(composition) => selectVariant(composition, DEFAULT_VARIANT.name)}
|
|
252
|
+
/>
|
|
253
|
+
) : null}
|
|
254
|
+
|
|
255
|
+
{selection?.kind === "variant" && resolved !== null ? (
|
|
256
|
+
<aside className="rail">
|
|
257
|
+
<section>
|
|
258
|
+
<h3>Controls</h3>
|
|
259
|
+
<Controls keys={controlKeys(selectedComposition)} context={context} onChange={setContext} />
|
|
260
|
+
</section>
|
|
261
|
+
<section>
|
|
262
|
+
<h3>Diff</h3>
|
|
263
|
+
<select value={compareVariant} onChange={(event) => setCompareVariant(event.target.value)}>
|
|
264
|
+
<option value="">Compare with…</option>
|
|
265
|
+
<option value={DEFAULT_VARIANT.name}>{DEFAULT_VARIANT.name}</option>
|
|
266
|
+
{(selectedComposition?.variants ?? []).map((variant) => (
|
|
267
|
+
<option key={variant.name} value={variant.name}>
|
|
268
|
+
{variant.name}
|
|
269
|
+
</option>
|
|
270
|
+
))}
|
|
271
|
+
</select>
|
|
272
|
+
{compareResolved !== null ? (
|
|
273
|
+
<Diff
|
|
274
|
+
leftLabel={compareVariant}
|
|
275
|
+
rightLabel={selection.variant}
|
|
276
|
+
leftText={compareResolved.text}
|
|
277
|
+
rightText={resolved.text}
|
|
278
|
+
/>
|
|
279
|
+
) : null}
|
|
280
|
+
</section>
|
|
281
|
+
<section>
|
|
282
|
+
<h3>Annotations</h3>
|
|
283
|
+
{variantAnnotations.length === 0 ? (
|
|
284
|
+
<p className="muted">None yet. Select text in the prompt to leave a comment for the agent.</p>
|
|
285
|
+
) : (
|
|
286
|
+
<ul className="annot-list">
|
|
287
|
+
{variantAnnotations.map((a) => (
|
|
288
|
+
<li key={a.id}>
|
|
289
|
+
<p className="annot-quote">“{a.anchor.anchorText}”</p>
|
|
290
|
+
<p className="annot-comment">{a.comment}</p>
|
|
291
|
+
<div className="annot-meta">
|
|
292
|
+
<code className="muted">{a.anchor.fragmentId}</code>
|
|
293
|
+
<button type="button" className="link" onClick={() => void resolveAnnotation(a.id)}>
|
|
294
|
+
Resolve
|
|
295
|
+
</button>
|
|
296
|
+
</div>
|
|
297
|
+
</li>
|
|
298
|
+
))}
|
|
299
|
+
</ul>
|
|
300
|
+
)}
|
|
301
|
+
</section>
|
|
302
|
+
<Addons trace={resolved.trace} lint={lint} />
|
|
303
|
+
</aside>
|
|
304
|
+
) : null}
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** One run of a segment's text; `annotationId` marks an annotated span. */
|
|
2
|
+
export interface TextRun {
|
|
3
|
+
text: string;
|
|
4
|
+
annotationId?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** An existing annotation's anchor, reduced to what highlighting needs. */
|
|
8
|
+
export interface AnchorSpec {
|
|
9
|
+
id: string;
|
|
10
|
+
anchorText: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Split `text` into runs, marking the first non-overlapping occurrence of each
|
|
15
|
+
* anchor. Used to highlight existing annotations inside a colored segment.
|
|
16
|
+
* Pure and DOM-free so it is unit-tested directly.
|
|
17
|
+
*/
|
|
18
|
+
export function markAnchors(text: string, anchors: AnchorSpec[]): TextRun[] {
|
|
19
|
+
interface Range {
|
|
20
|
+
start: number;
|
|
21
|
+
end: number;
|
|
22
|
+
id: string;
|
|
23
|
+
}
|
|
24
|
+
const ranges: Range[] = [];
|
|
25
|
+
for (const anchor of anchors) {
|
|
26
|
+
if (anchor.anchorText === "") {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
let from = 0;
|
|
30
|
+
while (from <= text.length) {
|
|
31
|
+
const start = text.indexOf(anchor.anchorText, from);
|
|
32
|
+
if (start === -1) {
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
const end = start + anchor.anchorText.length;
|
|
36
|
+
if (!ranges.some((r) => start < r.end && end > r.start)) {
|
|
37
|
+
ranges.push({ start, end, id: anchor.id });
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
from = start + 1;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
ranges.sort((a, b) => a.start - b.start);
|
|
44
|
+
|
|
45
|
+
const runs: TextRun[] = [];
|
|
46
|
+
let cursor = 0;
|
|
47
|
+
for (const range of ranges) {
|
|
48
|
+
if (range.start > cursor) {
|
|
49
|
+
runs.push({ text: text.slice(cursor, range.start) });
|
|
50
|
+
}
|
|
51
|
+
runs.push({ text: text.slice(range.start, range.end), annotationId: range.id });
|
|
52
|
+
cursor = range.end;
|
|
53
|
+
}
|
|
54
|
+
if (cursor < text.length || runs.length === 0) {
|
|
55
|
+
runs.push({ text: text.slice(cursor) });
|
|
56
|
+
}
|
|
57
|
+
return runs;
|
|
58
|
+
}
|
package/src/web/api.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnnotateRequest,
|
|
3
|
+
Annotation,
|
|
4
|
+
AnnotationsResponse,
|
|
5
|
+
BookResponse,
|
|
6
|
+
Context,
|
|
7
|
+
LintResponse,
|
|
8
|
+
ResolveResponse,
|
|
9
|
+
UsedInResponse,
|
|
10
|
+
} from "./types.js";
|
|
11
|
+
|
|
12
|
+
async function getJson<T>(path: string): Promise<T> {
|
|
13
|
+
const res = await fetch(path);
|
|
14
|
+
if (!res.ok) {
|
|
15
|
+
throw new Error(`${path} → ${res.status}`);
|
|
16
|
+
}
|
|
17
|
+
return (await res.json()) as T;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function sendJson<T>(method: "POST" | "DELETE", path: string, body?: unknown): Promise<T> {
|
|
21
|
+
const res = await fetch(path, {
|
|
22
|
+
method,
|
|
23
|
+
headers: { "content-type": "application/json" },
|
|
24
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
const payload = (await res.json().catch(() => ({}))) as { error?: string };
|
|
28
|
+
throw new Error(payload.error ?? `${path} → ${res.status}`);
|
|
29
|
+
}
|
|
30
|
+
return (await res.json()) as T;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const api = {
|
|
34
|
+
book: () => getJson<BookResponse>("/api/book"),
|
|
35
|
+
resolve: (prompt: string, context: Context) =>
|
|
36
|
+
sendJson<ResolveResponse>("POST", "/api/resolve", { prompt, context }),
|
|
37
|
+
lint: (prompt: string, context: Context) =>
|
|
38
|
+
sendJson<LintResponse>("POST", "/api/lint", { prompt, context }),
|
|
39
|
+
usedIn: (fragmentId: string) => getJson<UsedInResponse>(`/api/used-in/${encodeURIComponent(fragmentId)}`),
|
|
40
|
+
annotations: () => getJson<AnnotationsResponse>("/api/annotations"),
|
|
41
|
+
annotate: (body: AnnotateRequest) => sendJson<Annotation>("POST", "/api/annotate", body),
|
|
42
|
+
resolveAnnotation: (id: string) =>
|
|
43
|
+
sendJson<{ id: string; removed: boolean }>("DELETE", `/api/annotations/${encodeURIComponent(id)}`),
|
|
44
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable hue per fragment id, so a fragment keeps the same color across the
|
|
3
|
+
* canvas, sidebar and legend. Deterministic and dependency-free.
|
|
4
|
+
*/
|
|
5
|
+
function hueOf(id: string): number {
|
|
6
|
+
let hash = 0;
|
|
7
|
+
for (let i = 0; i < id.length; i += 1) {
|
|
8
|
+
hash = (hash * 31 + id.charCodeAt(i)) % 360;
|
|
9
|
+
}
|
|
10
|
+
return (hash + 360) % 360;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Soft background tint for a fragment's segment. */
|
|
14
|
+
export function fragmentColor(id: string): string {
|
|
15
|
+
return `hsl(${hueOf(id)}deg 70% 88%)`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Saturated variant of {@link fragmentColor} for borders/legend swatches. */
|
|
19
|
+
export function fragmentAccent(id: string): string {
|
|
20
|
+
return `hsl(${hueOf(id)}deg 60% 45%)`;
|
|
21
|
+
}
|