@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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -0
  3. package/dist/index.d.ts +6 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +3 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/server/annotations.d.ts +30 -0
  8. package/dist/server/annotations.d.ts.map +1 -0
  9. package/dist/server/annotations.js +62 -0
  10. package/dist/server/annotations.js.map +1 -0
  11. package/dist/server/api.d.ts +16 -0
  12. package/dist/server/api.d.ts.map +1 -0
  13. package/dist/server/api.js +134 -0
  14. package/dist/server/api.js.map +1 -0
  15. package/dist/server/book-source.d.ts +20 -0
  16. package/dist/server/book-source.d.ts.map +1 -0
  17. package/dist/server/book-source.js +32 -0
  18. package/dist/server/book-source.js.map +1 -0
  19. package/dist/server/responses.d.ts +12 -0
  20. package/dist/server/responses.d.ts.map +1 -0
  21. package/dist/server/responses.js +106 -0
  22. package/dist/server/responses.js.map +1 -0
  23. package/dist/server/segments.d.ts +12 -0
  24. package/dist/server/segments.d.ts.map +1 -0
  25. package/dist/server/segments.js +24 -0
  26. package/dist/server/segments.js.map +1 -0
  27. package/dist/server/server.d.ts +24 -0
  28. package/dist/server/server.d.ts.map +1 -0
  29. package/dist/server/server.js +71 -0
  30. package/dist/server/server.js.map +1 -0
  31. package/dist/server/static.d.ts +8 -0
  32. package/dist/server/static.d.ts.map +1 -0
  33. package/dist/server/static.js +55 -0
  34. package/dist/server/static.js.map +1 -0
  35. package/dist/server/used-in.d.ts +9 -0
  36. package/dist/server/used-in.d.ts.map +1 -0
  37. package/dist/server/used-in.js +19 -0
  38. package/dist/server/used-in.js.map +1 -0
  39. package/dist/shared/types.d.ts +113 -0
  40. package/dist/shared/types.d.ts.map +1 -0
  41. package/dist/shared/types.js +2 -0
  42. package/dist/shared/types.js.map +1 -0
  43. package/dist/web/assets/index-B2Wxtb-f.css +1 -0
  44. package/dist/web/assets/index-C8f_6lr_.js +51 -0
  45. package/dist/web/index.html +13 -0
  46. package/package.json +47 -0
  47. package/src/index.ts +19 -0
  48. package/src/server/annotations.ts +96 -0
  49. package/src/server/api.ts +164 -0
  50. package/src/server/book-source.ts +54 -0
  51. package/src/server/responses.ts +127 -0
  52. package/src/server/segments.ts +26 -0
  53. package/src/server/server.ts +96 -0
  54. package/src/server/static.ts +60 -0
  55. package/src/server/used-in.ts +23 -0
  56. package/src/shared/types.ts +137 -0
  57. package/src/web/App.tsx +307 -0
  58. package/src/web/annotations.ts +58 -0
  59. package/src/web/api.ts +44 -0
  60. package/src/web/colors.ts +21 -0
  61. package/src/web/components/Addons.tsx +109 -0
  62. package/src/web/components/Canvas.tsx +180 -0
  63. package/src/web/components/CodePromptView.tsx +87 -0
  64. package/src/web/components/Controls.tsx +71 -0
  65. package/src/web/components/Diff.tsx +30 -0
  66. package/src/web/components/FragmentView.tsx +54 -0
  67. package/src/web/components/Sidebar.tsx +178 -0
  68. package/src/web/diff.ts +51 -0
  69. package/src/web/env.d.ts +3 -0
  70. package/src/web/format.ts +8 -0
  71. package/src/web/index.html +12 -0
  72. package/src/web/main.tsx +15 -0
  73. package/src/web/selection.ts +5 -0
  74. package/src/web/styles.css +484 -0
  75. package/src/web/tree.ts +134 -0
  76. package/src/web/types.ts +17 -0
@@ -0,0 +1,109 @@
1
+ import { kvLabel } from "../format.js";
2
+ import type { LintResponse, Trace, When } from "../types.js";
3
+
4
+ interface AddonsProps {
5
+ trace: Trace;
6
+ lint: LintResponse | null;
7
+ }
8
+
9
+ function whenLabel(when: When): string {
10
+ return Object.keys(when).length === 0 ? "always" : kvLabel(when);
11
+ }
12
+
13
+ /** Lint findings panel: severity-tagged messages with the fragment/rule origin. */
14
+ function LintPanel({ lint }: { lint: LintResponse | null }) {
15
+ if (lint === null) {
16
+ return <p className="muted">—</p>;
17
+ }
18
+ if (lint.findings.length === 0) {
19
+ return <p className="muted">No findings ({lint.tokens} tokens).</p>;
20
+ }
21
+ return (
22
+ <ul className="findings">
23
+ {lint.findings.map((finding, index) => (
24
+ <li key={`${finding.ruleId}:${index}`} className={`finding ${finding.severity}`}>
25
+ <span className="finding-sev">{finding.severity}</span>
26
+ <span className="finding-rule">{finding.ruleId}</span>
27
+ <span>{finding.message}</span>
28
+ {finding.fragmentId ? <code className="finding-frag">{finding.fragmentId}</code> : null}
29
+ </li>
30
+ ))}
31
+ </ul>
32
+ );
33
+ }
34
+
35
+ /** Explain trace panel: which rules fired and why, plus the net effects. */
36
+ function ExplainPanel({ trace }: { trace: Trace }) {
37
+ return (
38
+ <div className="explain">
39
+ <h4>Rules</h4>
40
+ <ul className="rules">
41
+ {trace.rules.map((rule) => (
42
+ <li key={rule.index} className={rule.fired ? "fired" : "skipped"}>
43
+ <span className="rule-mark">{rule.fired ? "✓" : "·"}</span>
44
+ <span className="rule-action">{rule.action}</span>
45
+ <span className="muted">when {whenLabel(rule.when)}</span>
46
+ {rule.effect ? <div className="rule-effect">{rule.effect}</div> : null}
47
+ {rule.reason ? <div className="rule-reason">{rule.reason}</div> : null}
48
+ </li>
49
+ ))}
50
+ {trace.rules.length === 0 ? <li className="muted">No rules.</li> : null}
51
+ </ul>
52
+
53
+ <h4>Final order</h4>
54
+ <p className="order">{trace.finalOrder.join(" → ") || "(empty)"}</p>
55
+
56
+ {trace.replaced.length > 0 ? (
57
+ <p>
58
+ <strong>replaced:</strong> {trace.replaced.map((r) => `${r.from}→${r.to}`).join(", ")}
59
+ </p>
60
+ ) : null}
61
+ {trace.added.length > 0 ? (
62
+ <p>
63
+ <strong>added:</strong> {trace.added.map((a) => a.id).join(", ")}
64
+ </p>
65
+ ) : null}
66
+ {trace.forbidden.length > 0 ? (
67
+ <p>
68
+ <strong>forbidden:</strong> {trace.forbidden.map((f) => f.id).join(", ")}
69
+ </p>
70
+ ) : null}
71
+
72
+ {trace.unmatchedAxes.length > 0 ? (
73
+ <p className="warn">
74
+ <strong>unmatched axes:</strong>{" "}
75
+ {trace.unmatchedAxes.map((axis) => `${axis.key}=${axis.value}`).join(", ")}
76
+ </p>
77
+ ) : null}
78
+ {trace.warnings.length > 0 ? (
79
+ <ul className="warnings">
80
+ {trace.warnings.map((warning) => (
81
+ <li key={warning} className="warn">
82
+ {warning}
83
+ </li>
84
+ ))}
85
+ </ul>
86
+ ) : null}
87
+ </div>
88
+ );
89
+ }
90
+
91
+ /** Right-hand Addons rail: tokens, lint, and the explain trace. */
92
+ export function Addons({ trace, lint }: AddonsProps) {
93
+ return (
94
+ <aside className="addons">
95
+ <section>
96
+ <h3>Tokens</h3>
97
+ <p className="tokens">{lint ? `~${lint.tokens}` : "—"}</p>
98
+ </section>
99
+ <section>
100
+ <h3>Lint</h3>
101
+ <LintPanel lint={lint} />
102
+ </section>
103
+ <section>
104
+ <h3>Explain</h3>
105
+ <ExplainPanel trace={trace} />
106
+ </section>
107
+ </aside>
108
+ );
109
+ }
@@ -0,0 +1,180 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import { type AnchorSpec, markAnchors } from "../annotations.js";
3
+ import { fragmentAccent, fragmentColor } from "../colors.js";
4
+ import type { Annotation, Segment } from "../types.js";
5
+
6
+ interface CanvasProps {
7
+ title: string;
8
+ subtitle?: string;
9
+ segments: Segment[];
10
+ tokens?: number;
11
+ /** Open annotations for this variant, highlighted inside their fragment. */
12
+ annotations: Annotation[];
13
+ /** Persist a new annotation for the selected fragment text. */
14
+ onAnnotate: (fragmentId: string, anchorText: string, comment: string) => Promise<void>;
15
+ }
16
+
17
+ /** A pending selection awaiting a comment, anchored near the selected text. */
18
+ interface Draft {
19
+ fragmentId: string;
20
+ anchorText: string;
21
+ top: number;
22
+ left: number;
23
+ }
24
+
25
+ /** Read the current text selection, if it lands inside a segment's body. */
26
+ function selectionDraft(): Draft | null {
27
+ const selection = window.getSelection();
28
+ if (selection === null || selection.isCollapsed) {
29
+ return null;
30
+ }
31
+ const anchorText = selection.toString().trim();
32
+ if (anchorText === "") {
33
+ return null;
34
+ }
35
+ const node = selection.anchorNode;
36
+ const element = node instanceof Element ? node : (node?.parentElement ?? null);
37
+ const host = element?.closest<HTMLElement>("[data-fragment-id]") ?? null;
38
+ const fragmentId = host?.dataset.fragmentId;
39
+ if (fragmentId === undefined) {
40
+ return null;
41
+ }
42
+ const rect = selection.getRangeAt(0).getBoundingClientRect();
43
+ return { fragmentId, anchorText, top: rect.bottom, left: rect.left };
44
+ }
45
+
46
+ /**
47
+ * The assembled prompt, each segment tinted by its source fragment. The
48
+ * segments concatenate (with blank lines between them) into the exact resolved
49
+ * text, so what is shown here is byte-for-byte the prompt the model would see.
50
+ * Selecting text opens a comment popover; existing annotations are highlighted.
51
+ */
52
+ export function Canvas({ title, subtitle, segments, tokens, annotations, onAnnotate }: CanvasProps) {
53
+ const ids = [...new Set(segments.map((s) => s.fragmentId))];
54
+ const [draft, setDraft] = useState<Draft | null>(null);
55
+ const [comment, setComment] = useState("");
56
+ const [saving, setSaving] = useState(false);
57
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
58
+
59
+ // A document-level listener keeps interaction handlers off the static prompt
60
+ // markup (and selections outside a segment are simply ignored).
61
+ useEffect(() => {
62
+ const onMouseUp = (): void => {
63
+ const next = selectionDraft();
64
+ if (next !== null) {
65
+ setDraft(next);
66
+ setComment("");
67
+ }
68
+ };
69
+ document.addEventListener("mouseup", onMouseUp);
70
+ return () => document.removeEventListener("mouseup", onMouseUp);
71
+ }, []);
72
+
73
+ useEffect(() => {
74
+ if (draft !== null) {
75
+ textareaRef.current?.focus();
76
+ }
77
+ }, [draft]);
78
+
79
+ // Group anchors by fragment once per annotations change, so each segment does
80
+ // a map lookup instead of scanning the whole list on every render.
81
+ const anchorsByFragment = useMemo(() => {
82
+ const byFragment = new Map<string, AnchorSpec[]>();
83
+ for (const a of annotations) {
84
+ const list = byFragment.get(a.anchor.fragmentId) ?? [];
85
+ list.push({ id: a.id, anchorText: a.anchor.anchorText });
86
+ byFragment.set(a.anchor.fragmentId, list);
87
+ }
88
+ return byFragment;
89
+ }, [annotations]);
90
+
91
+ const submit = async (): Promise<void> => {
92
+ if (draft === null || comment.trim() === "") {
93
+ return;
94
+ }
95
+ setSaving(true);
96
+ try {
97
+ await onAnnotate(draft.fragmentId, draft.anchorText, comment.trim());
98
+ setDraft(null);
99
+ setComment("");
100
+ } finally {
101
+ setSaving(false);
102
+ }
103
+ };
104
+
105
+ return (
106
+ <main className="canvas">
107
+ <header className="canvas-head">
108
+ <div>
109
+ <h1 className="canvas-title">{title}</h1>
110
+ {subtitle ? <p className="muted">{subtitle}</p> : null}
111
+ </div>
112
+ {tokens !== undefined ? <span className="badge">~{tokens} tokens</span> : null}
113
+ </header>
114
+
115
+ {ids.length > 0 ? (
116
+ <ul className="legend">
117
+ {ids.map((id) => (
118
+ <li key={id}>
119
+ <span className="swatch" style={{ background: fragmentAccent(id) }} />
120
+ {id}
121
+ </li>
122
+ ))}
123
+ </ul>
124
+ ) : null}
125
+
126
+ <div className="prompt">
127
+ {segments.length === 0 ? (
128
+ <p className="muted">No fragments rendered for this context.</p>
129
+ ) : (
130
+ segments.map((segment) => (
131
+ <pre
132
+ key={segment.fragmentId}
133
+ className="segment"
134
+ data-fragment-id={segment.fragmentId}
135
+ style={{
136
+ background: fragmentColor(segment.fragmentId),
137
+ borderColor: fragmentAccent(segment.fragmentId),
138
+ }}
139
+ title={segment.fragmentId}
140
+ >
141
+ <span className="segment-tag">{segment.fragmentId}</span>
142
+ {annotations.length === 0
143
+ ? segment.text
144
+ : markAnchors(segment.text, anchorsByFragment.get(segment.fragmentId) ?? []).map(
145
+ (run, index) =>
146
+ run.annotationId !== undefined ? (
147
+ <mark key={index} className="annot-mark">
148
+ {run.text}
149
+ </mark>
150
+ ) : (
151
+ <span key={index}>{run.text}</span>
152
+ ),
153
+ )}
154
+ </pre>
155
+ ))
156
+ )}
157
+ </div>
158
+
159
+ {draft !== null ? (
160
+ <div className="annot-popover" style={{ top: draft.top, left: draft.left }}>
161
+ <p className="annot-quote">“{draft.anchorText}”</p>
162
+ <textarea
163
+ ref={textareaRef}
164
+ value={comment}
165
+ placeholder="Comment for the agent…"
166
+ onChange={(event) => setComment(event.target.value)}
167
+ />
168
+ <div className="annot-actions">
169
+ <button type="button" className="link" onClick={() => setDraft(null)}>
170
+ Cancel
171
+ </button>
172
+ <button type="button" disabled={saving || comment.trim() === ""} onClick={() => void submit()}>
173
+ Save
174
+ </button>
175
+ </div>
176
+ </div>
177
+ ) : null}
178
+ </main>
179
+ );
180
+ }
@@ -0,0 +1,87 @@
1
+ import { useState } from "react";
2
+ import { kvLabel } from "../format.js";
3
+ import type { CodePromptSummary } from "../types.js";
4
+ import { Diff } from "./Diff.js";
5
+
6
+ interface CodePromptViewProps {
7
+ codePrompt: CodePromptSummary;
8
+ /** The sample label currently shown. */
9
+ sample: string;
10
+ onSelectSample: (label: string) => void;
11
+ }
12
+
13
+ /**
14
+ * A code-prompt node: a builder-backed prompt the core never executes. The
15
+ * canvas shows a captured snapshot of its output (no fragment breakdown, since
16
+ * the builder is opaque) with a "code" badge, lets you switch samples, and
17
+ * diffs two snapshots. Honest about being computed, not declarative.
18
+ */
19
+ export function CodePromptView({ codePrompt, sample, onSelectSample }: CodePromptViewProps) {
20
+ const [compare, setCompare] = useState("");
21
+ const samples = codePrompt.samples;
22
+ const current = samples.find((s) => s.label === sample) ?? samples[0];
23
+ const other = samples.find((s) => s.label === compare);
24
+
25
+ return (
26
+ <main className="canvas">
27
+ <header className="canvas-head">
28
+ <div>
29
+ <h1 className="canvas-title">
30
+ {codePrompt.name} <span className="badge badge-code">code</span>
31
+ </h1>
32
+ {codePrompt.description ? <p className="muted">{codePrompt.description}</p> : null}
33
+ </div>
34
+ </header>
35
+
36
+ {samples.length === 0 || current === undefined ? (
37
+ <p className="muted">No samples captured for this code-prompt.</p>
38
+ ) : (
39
+ <>
40
+ <div className="code-tabs">
41
+ {samples.map((s) => (
42
+ <button
43
+ key={s.label}
44
+ type="button"
45
+ className={`code-tab${s.label === current.label ? " active" : ""}`}
46
+ onClick={() => onSelectSample(s.label)}
47
+ >
48
+ {s.label}
49
+ </button>
50
+ ))}
51
+ </div>
52
+
53
+ <div className="prompt">
54
+ <pre className="segment code-sample">
55
+ <span className="segment-tag">
56
+ code{current.context ? ` · ${kvLabel(current.context)}` : ""}
57
+ </span>
58
+ {current.output}
59
+ </pre>
60
+ </div>
61
+
62
+ <section className="code-diff">
63
+ <h3>Diff</h3>
64
+ <select value={compare} onChange={(event) => setCompare(event.target.value)}>
65
+ <option value="">Compare with…</option>
66
+ {samples
67
+ .filter((s) => s.label !== current.label)
68
+ .map((s) => (
69
+ <option key={s.label} value={s.label}>
70
+ {s.label}
71
+ </option>
72
+ ))}
73
+ </select>
74
+ {other !== undefined ? (
75
+ <Diff
76
+ leftLabel={other.label}
77
+ rightLabel={current.label}
78
+ leftText={other.output}
79
+ rightText={current.output}
80
+ />
81
+ ) : null}
82
+ </section>
83
+ </>
84
+ )}
85
+ </main>
86
+ );
87
+ }
@@ -0,0 +1,71 @@
1
+ import { useState } from "react";
2
+ import type { Context, ContextValue } from "../types.js";
3
+
4
+ interface ControlsProps {
5
+ /** Keys worth offering: the union of rule `when` keys and current context. */
6
+ keys: string[];
7
+ context: Context;
8
+ onChange: (context: Context) => void;
9
+ }
10
+
11
+ const NUMERIC = /^-?\d+(?:\.\d+)?$/;
12
+
13
+ /** Coerce a raw input string like the CLI's `--ctx`: bool, number, else string. */
14
+ function coerce(raw: string): ContextValue {
15
+ if (raw === "true") return true;
16
+ if (raw === "false") return false;
17
+ if (NUMERIC.test(raw)) return Number(raw);
18
+ return raw;
19
+ }
20
+
21
+ /**
22
+ * Context pickers = Storybook Controls. Editing a field re-resolves the variant
23
+ * live. Keys come from the composition's `when` conditions plus whatever the
24
+ * selected variant set; an extra row adds ad-hoc keys.
25
+ */
26
+ export function Controls({ keys, context, onChange }: ControlsProps) {
27
+ const [newKey, setNewKey] = useState("");
28
+
29
+ const setValue = (key: string, raw: string): void => {
30
+ const next: Context = { ...context };
31
+ if (raw === "") {
32
+ delete next[key];
33
+ } else {
34
+ next[key] = coerce(raw);
35
+ }
36
+ onChange(next);
37
+ };
38
+
39
+ const offered = [...new Set([...keys, ...Object.keys(context)])].sort();
40
+
41
+ return (
42
+ <div className="controls">
43
+ {offered.length === 0 ? <p className="muted">No context axes.</p> : null}
44
+ {offered.map((key) => (
45
+ <label key={key} className="control-row">
46
+ <span className="control-key">{key}</span>
47
+ <input
48
+ value={context[key] === undefined ? "" : String(context[key])}
49
+ placeholder="(unset)"
50
+ onChange={(event) => setValue(key, event.target.value)}
51
+ />
52
+ </label>
53
+ ))}
54
+
55
+ <form
56
+ className="control-add"
57
+ onSubmit={(event) => {
58
+ event.preventDefault();
59
+ const key = newKey.trim();
60
+ if (key !== "") {
61
+ onChange({ ...context, [key]: "" });
62
+ setNewKey("");
63
+ }
64
+ }}
65
+ >
66
+ <input value={newKey} placeholder="add axis…" onChange={(event) => setNewKey(event.target.value)} />
67
+ <button type="submit">+</button>
68
+ </form>
69
+ </div>
70
+ );
71
+ }
@@ -0,0 +1,30 @@
1
+ import { diffLines } from "../diff.js";
2
+
3
+ interface DiffProps {
4
+ leftLabel: string;
5
+ rightLabel: string;
6
+ leftText: string;
7
+ rightText: string;
8
+ }
9
+
10
+ const MARK: Record<string, string> = { equal: " ", add: "+", remove: "-" };
11
+
12
+ /** Line diff of two variants of the same composition. */
13
+ export function Diff({ leftLabel, rightLabel, leftText, rightText }: DiffProps) {
14
+ const rows = diffLines(leftText, rightText);
15
+ return (
16
+ <div className="diff">
17
+ <p className="muted">
18
+ − {leftLabel} &nbsp; + {rightLabel}
19
+ </p>
20
+ <pre className="diff-body">
21
+ {rows.map((row, index) => (
22
+ <div key={`${index}:${row.text}`} className={`diff-row ${row.type}`}>
23
+ <span className="diff-mark">{MARK[row.type]}</span>
24
+ {row.text}
25
+ </div>
26
+ ))}
27
+ </pre>
28
+ </div>
29
+ );
30
+ }
@@ -0,0 +1,54 @@
1
+ import { fragmentAccent } from "../colors.js";
2
+ import type { FragmentSummary, UsedInResponse } from "../types.js";
3
+
4
+ interface FragmentViewProps {
5
+ fragment: FragmentSummary;
6
+ usedIn: UsedInResponse | null;
7
+ onSelectVariant: (composition: string) => void;
8
+ }
9
+
10
+ /** Inspect a fragment: its body, metadata, and where it is used (the graph). */
11
+ export function FragmentView({ fragment, usedIn, onSelectVariant }: FragmentViewProps) {
12
+ return (
13
+ <main className="canvas">
14
+ <header className="canvas-head">
15
+ <div>
16
+ <h1 className="canvas-title">
17
+ <span className="swatch" style={{ background: fragmentAccent(fragment.id) }} />
18
+ {fragment.id}
19
+ </h1>
20
+ <p className="muted">
21
+ {fragment.kind ?? "—"}
22
+ {fragment.tags.length > 0 ? ` · ${fragment.tags.join(", ")}` : ""} · {fragment.sourceFile}
23
+ </p>
24
+ </div>
25
+ </header>
26
+
27
+ <pre className="segment" style={{ background: "#f6f8fa" }}>
28
+ {fragment.body}
29
+ </pre>
30
+
31
+ <section className="used-in">
32
+ <h3>Used in</h3>
33
+ {usedIn === null || usedIn.references.length === 0 ? (
34
+ <p className="muted">Not referenced by any composition.</p>
35
+ ) : (
36
+ <ul className="used-list">
37
+ {usedIn.references.map((ref, index) => (
38
+ <li key={`${ref.composition}:${ref.role}:${index}`}>
39
+ <button type="button" className="link" onClick={() => onSelectVariant(ref.composition)}>
40
+ {ref.composition}
41
+ </button>
42
+ <span className="muted">
43
+ {" "}
44
+ {ref.role}
45
+ {ref.ruleIndex !== undefined ? ` (rule #${ref.ruleIndex})` : ""}
46
+ </span>
47
+ </li>
48
+ ))}
49
+ </ul>
50
+ )}
51
+ </section>
52
+ </main>
53
+ );
54
+ }