jev-devtools 0.1.3 → 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/README.md +5 -3
- package/THIRD_PARTY_NOTICES.txt +27 -0
- package/lib/server/index.js +78 -8
- package/lib/server/recorder.d.ts +8 -1
- package/lib/server/recorder.js +35 -8
- package/lib/src/ChoiceProbabilities.d.ts +4 -0
- package/lib/src/ChoiceProbabilities.js +19 -0
- package/lib/src/ConnectedInspector.js +37 -29
- package/lib/src/JevDevtools.js +37 -47
- package/lib/src/JevDrawer.d.ts +3 -2
- package/lib/src/JevDrawer.js +12 -3
- package/lib/src/Questions.d.ts +5 -0
- package/lib/src/Questions.js +174 -0
- package/lib/src/TraceContent.d.ts +11 -0
- package/lib/src/TraceContent.js +32 -0
- package/lib/src/TraceDiagram.js +86 -18
- package/lib/src/flow-model.js +1 -1
- package/lib/src/index.d.ts +1 -1
- package/lib/src/inspector-styles.js +1 -1
- package/lib/src/inspector.css +224 -0
- package/lib/src/protocol.d.ts +13 -0
- package/lib/src/trace-store.d.ts +6 -0
- package/lib/src/trace-store.js +66 -0
- package/lib/src/trace-stream.d.ts +2 -0
- package/lib/src/trace-stream.js +65 -0
- package/lib/src/useDiagramViewport.d.ts +1 -0
- package/lib/src/useDiagramViewport.js +15 -4
- package/package.json +2 -1
- package/skills/setup-jev-devtools/references/verify.md +2 -0
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# Jev DevTools
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/jev-devtools/v/0.2.0)
|
|
4
4
|
|
|
5
5
|
A React drawer and Node adapter for inspecting TypeSafe Jev requests with animated diagrams. Includes request history, context, questions, typed answers, transport attempts, and raw snapshots.
|
|
6
6
|
|
|
7
7
|
**Experimental 0.1.x.** React 18/19 and Node 22.12+ are supported. APIs may change before 1.0. Verification uses sample responses through the real TypeSafe SDK, not live inference.
|
|
8
8
|
|
|
9
|
+
[Changelog and release history](https://github.com/diegoag64/jev-devtools/blob/main/CHANGELOG.md) — published versions and unreleased work.
|
|
10
|
+
|
|
9
11
|
## Demo
|
|
10
12
|
|
|
11
13
|

|
|
@@ -42,7 +44,7 @@ To try unpublished local changes instead:
|
|
|
42
44
|
npm ci
|
|
43
45
|
npm run pack:devtools
|
|
44
46
|
# From the consuming app:
|
|
45
|
-
npm install /absolute/path/to/jev-devtools-0.
|
|
47
|
+
npm install /absolute/path/to/jev-devtools-0.2.0.tgz
|
|
46
48
|
```
|
|
47
49
|
|
|
48
50
|
Mount once:
|
|
@@ -69,7 +71,7 @@ app.use(inspector.middleware); // Mount at root, before fallback handlers.
|
|
|
69
71
|
// Call inspector.dispose() on shutdown.
|
|
70
72
|
```
|
|
71
73
|
|
|
72
|
-
Only calls through that client are captured. The React component subscribes via same-origin SSE. Both sides default to disabled. API keys remain server-side. Browser code is SSR-safe. The drawer uses a Shadow DOM boundary and its own stylesheet so host page selectors cannot alter its internal layout. Existing stylesheet imports remain supported; no Tailwind or shadcn installation is required. Drag the Flow canvas to pan, scroll/pinch to zoom, or use Fit.
|
|
74
|
+
Only calls through that client are captured. The React component subscribes via same-origin SSE, receiving an initial snapshot followed by changed traces. Older client/server combinations remain compatible through full snapshots. Both sides default to disabled. API keys remain server-side. Browser code is SSR-safe. The drawer uses a Shadow DOM boundary and its own stylesheet so host page selectors cannot alter its internal layout. Existing stylesheet imports remain supported; no Tailwind or shadcn installation is required. Drag the Flow canvas to pan, scroll/pinch to zoom, or use Fit.
|
|
73
75
|
|
|
74
76
|
History is bounded to 50 calls in memory by default. API routes and inspector endpoints must share an adapter instance; distributed processes and edge runtimes are not supported. Non-loopback use requires explicit authorization. Captures include bounded/redacted context and answers, not hidden model reasoning.
|
|
75
77
|
|
package/THIRD_PARTY_NOTICES.txt
CHANGED
|
@@ -48,3 +48,30 @@ trademark rights or imply endorsement.
|
|
|
48
48
|
The playground uses shadcn/ui components. Its MIT notice is preserved in
|
|
49
49
|
apps/playground/SHADCN_LICENSE.md. Those components are not bundled in the
|
|
50
50
|
DevTools package. No TanStack DevTools shell is included.
|
|
51
|
+
|
|
52
|
+
TanStack Virtual
|
|
53
|
+
|
|
54
|
+
Questions virtualization uses @tanstack/react-virtual and @tanstack/virtual-core.
|
|
55
|
+
https://github.com/TanStack/virtual
|
|
56
|
+
|
|
57
|
+
MIT License
|
|
58
|
+
|
|
59
|
+
Copyright (c) 2021-present Tanner Linsley
|
|
60
|
+
|
|
61
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
62
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
63
|
+
in the Software without restriction, including without limitation the rights
|
|
64
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
65
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
66
|
+
furnished to do so, subject to the following conditions:
|
|
67
|
+
|
|
68
|
+
The above copyright notice and this permission notice shall be included in all
|
|
69
|
+
copies or substantial portions of the Software.
|
|
70
|
+
|
|
71
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
72
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
73
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
74
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
75
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
76
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
77
|
+
SOFTWARE.
|
package/lib/server/index.js
CHANGED
|
@@ -18,6 +18,57 @@ export function createJevDevtools(options) {
|
|
|
18
18
|
throw new Error("Non-loopback inspector endpoints require authorize");
|
|
19
19
|
const recorder = new JevRecorder(options), scope = new AsyncLocalStorage();
|
|
20
20
|
const streams = new Set();
|
|
21
|
+
const pending = new Set();
|
|
22
|
+
const encoder = new TextEncoder();
|
|
23
|
+
let scheduled;
|
|
24
|
+
const encodeUpdate = (session, incremental, cursor) => {
|
|
25
|
+
if (!incremental)
|
|
26
|
+
return {
|
|
27
|
+
bytes: encoder.encode(`event: snapshot\ndata: ${JSON.stringify({ version: 1, traces: recorder.snapshot(session) })}\n\n`),
|
|
28
|
+
revision: 0,
|
|
29
|
+
};
|
|
30
|
+
const { revision, upserts, order } = recorder.changes(session, cursor);
|
|
31
|
+
const initial = cursor === undefined;
|
|
32
|
+
const data = initial
|
|
33
|
+
? { version: 2, revision, traces: upserts }
|
|
34
|
+
: { version: 2, base: cursor, revision, upserts, order };
|
|
35
|
+
return {
|
|
36
|
+
bytes: encoder.encode(`event: ${initial ? "snapshot" : "delta"}\ndata: ${JSON.stringify(data)}\n\n`),
|
|
37
|
+
revision,
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
function schedule(flush) {
|
|
41
|
+
pending.add(flush);
|
|
42
|
+
if (scheduled)
|
|
43
|
+
return;
|
|
44
|
+
scheduled = setImmediate(() => {
|
|
45
|
+
scheduled = undefined;
|
|
46
|
+
const batch = [...pending];
|
|
47
|
+
pending.clear();
|
|
48
|
+
// Cache only for this synchronous delivery turn and only within a session.
|
|
49
|
+
// Each reader owns its bytes; mutating one Response must not corrupt another.
|
|
50
|
+
const encoded = new Map();
|
|
51
|
+
const updateFor = (session, incremental, cursor) => {
|
|
52
|
+
// Slow readers can have older cursors even in the same session.
|
|
53
|
+
const key = JSON.stringify([session, incremental, cursor]);
|
|
54
|
+
let update = encoded.get(key);
|
|
55
|
+
if (!update) {
|
|
56
|
+
update = encodeUpdate(session, incremental, cursor);
|
|
57
|
+
encoded.set(key, update);
|
|
58
|
+
}
|
|
59
|
+
return { bytes: update.bytes.slice(), revision: update.revision };
|
|
60
|
+
};
|
|
61
|
+
for (const deliver of batch)
|
|
62
|
+
deliver(updateFor);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function unschedule(flush) {
|
|
66
|
+
pending.delete(flush);
|
|
67
|
+
if (!pending.size && scheduled) {
|
|
68
|
+
clearImmediate(scheduled);
|
|
69
|
+
scheduled = undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
21
72
|
let disposed = false;
|
|
22
73
|
const enabled = () => options.enabled === true && !disposed;
|
|
23
74
|
const sessionFor = async (request) => {
|
|
@@ -53,20 +104,36 @@ export function createJevDevtools(options) {
|
|
|
53
104
|
return json({ error: "Not found" }, 404);
|
|
54
105
|
if (request.method !== "GET")
|
|
55
106
|
return json({ error: "Method not allowed" }, 405);
|
|
56
|
-
const
|
|
57
|
-
let
|
|
107
|
+
const incremental = url.searchParams.get("protocol") === "2";
|
|
108
|
+
let cursor;
|
|
109
|
+
let cleanup = () => { }, wake = () => { }, flush = () => { };
|
|
58
110
|
const stream = new ReadableStream({
|
|
59
111
|
start(controller) {
|
|
60
112
|
let closed = false, dirty = true;
|
|
61
|
-
flush = () => {
|
|
113
|
+
flush = (updateFor) => {
|
|
62
114
|
if (closed || !dirty || (controller.desiredSize ?? 0) <= 0)
|
|
63
115
|
return;
|
|
64
116
|
dirty = false;
|
|
65
|
-
|
|
117
|
+
try {
|
|
118
|
+
const update = updateFor(session, incremental, cursor);
|
|
119
|
+
controller.enqueue(update.bytes);
|
|
120
|
+
cursor = update.revision;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Deferred observer failures must not escape into the application's
|
|
124
|
+
// event loop or prevent other viewers from receiving their update.
|
|
125
|
+
cleanup();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
wake = () => {
|
|
129
|
+
if (!closed && dirty && (controller.desiredSize ?? 0) > 0)
|
|
130
|
+
schedule(flush);
|
|
66
131
|
};
|
|
67
|
-
const unsubscribe = recorder.subscribe(() => {
|
|
132
|
+
const unsubscribe = recorder.subscribe((sessions) => {
|
|
133
|
+
if (!sessions.has(session))
|
|
134
|
+
return;
|
|
68
135
|
dirty = true;
|
|
69
|
-
|
|
136
|
+
wake();
|
|
70
137
|
});
|
|
71
138
|
const heartbeat = setInterval(() => {
|
|
72
139
|
if (!closed && (controller.desiredSize ?? 0) > 0)
|
|
@@ -77,6 +144,7 @@ export function createJevDevtools(options) {
|
|
|
77
144
|
return;
|
|
78
145
|
closed = true;
|
|
79
146
|
unsubscribe();
|
|
147
|
+
unschedule(flush);
|
|
80
148
|
clearInterval(heartbeat);
|
|
81
149
|
request.signal.removeEventListener("abort", cleanup);
|
|
82
150
|
streams.delete(cleanup);
|
|
@@ -92,10 +160,12 @@ export function createJevDevtools(options) {
|
|
|
92
160
|
if (request.signal.aborted)
|
|
93
161
|
cleanup();
|
|
94
162
|
else
|
|
95
|
-
flush();
|
|
163
|
+
flush(encodeUpdate);
|
|
96
164
|
},
|
|
97
165
|
pull() {
|
|
98
|
-
|
|
166
|
+
// Reading must not bypass the batching boundary. A full queue retains
|
|
167
|
+
// only a dirty flag; the next readable turn delivers current history.
|
|
168
|
+
wake();
|
|
99
169
|
},
|
|
100
170
|
cancel() {
|
|
101
171
|
cleanup();
|
package/lib/server/recorder.d.ts
CHANGED
|
@@ -9,11 +9,18 @@ export declare class JevRecorder {
|
|
|
9
9
|
private options;
|
|
10
10
|
private traces;
|
|
11
11
|
private listeners;
|
|
12
|
+
private revision;
|
|
13
|
+
private revisions;
|
|
12
14
|
private context;
|
|
13
15
|
private limit;
|
|
14
16
|
constructor(options?: CaptureOptions);
|
|
15
17
|
snapshot(session: string): Trace[];
|
|
16
|
-
|
|
18
|
+
changes(session: string, after?: number): {
|
|
19
|
+
revision: number;
|
|
20
|
+
order: string[];
|
|
21
|
+
upserts: Trace[];
|
|
22
|
+
};
|
|
23
|
+
subscribe(listener: (sessions: ReadonlySet<string>) => void): () => void;
|
|
17
24
|
clear(session: string): void;
|
|
18
25
|
private notify;
|
|
19
26
|
private update;
|
package/lib/server/recorder.js
CHANGED
|
@@ -79,6 +79,8 @@ export class JevRecorder {
|
|
|
79
79
|
options;
|
|
80
80
|
traces = new Map();
|
|
81
81
|
listeners = new Set();
|
|
82
|
+
revision = 0;
|
|
83
|
+
revisions = new WeakMap();
|
|
82
84
|
context = new AsyncLocalStorage();
|
|
83
85
|
limit;
|
|
84
86
|
constructor(options = {}) {
|
|
@@ -88,6 +90,18 @@ export class JevRecorder {
|
|
|
88
90
|
snapshot(session) {
|
|
89
91
|
return structuredClone([...this.traces.values()].filter((t) => t.session === session).reverse());
|
|
90
92
|
}
|
|
93
|
+
// Only changed payloads are cloned. IDs/order are bounded by the retained
|
|
94
|
+
// history; no tombstone log or per-session history cache is needed.
|
|
95
|
+
changes(session, after) {
|
|
96
|
+
const current = [...this.traces.values()]
|
|
97
|
+
.filter((t) => t.session === session)
|
|
98
|
+
.reverse();
|
|
99
|
+
return {
|
|
100
|
+
revision: this.revision,
|
|
101
|
+
order: current.map((t) => t.id),
|
|
102
|
+
upserts: structuredClone(current.filter((t) => after === undefined || this.revisions.get(t) > after)),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
91
105
|
subscribe(listener) {
|
|
92
106
|
this.listeners.add(listener);
|
|
93
107
|
return () => {
|
|
@@ -95,15 +109,21 @@ export class JevRecorder {
|
|
|
95
109
|
};
|
|
96
110
|
}
|
|
97
111
|
clear(session) {
|
|
112
|
+
let changed = false;
|
|
98
113
|
for (const [id, t] of this.traces)
|
|
99
|
-
if (t.session === session)
|
|
114
|
+
if (t.session === session) {
|
|
100
115
|
this.traces.delete(id);
|
|
101
|
-
|
|
116
|
+
changed = true;
|
|
117
|
+
}
|
|
118
|
+
if (changed) {
|
|
119
|
+
this.revision++;
|
|
120
|
+
this.notify(new Set([session]));
|
|
121
|
+
}
|
|
102
122
|
}
|
|
103
|
-
notify() {
|
|
123
|
+
notify(sessions) {
|
|
104
124
|
for (const listener of this.listeners) {
|
|
105
125
|
try {
|
|
106
|
-
listener();
|
|
126
|
+
listener(sessions);
|
|
107
127
|
}
|
|
108
128
|
catch {
|
|
109
129
|
/* Observers must not affect inference. */
|
|
@@ -114,7 +134,8 @@ export class JevRecorder {
|
|
|
114
134
|
const t = this.traces.get(id);
|
|
115
135
|
if (t) {
|
|
116
136
|
update(t);
|
|
117
|
-
this.
|
|
137
|
+
this.revisions.set(t, ++this.revision);
|
|
138
|
+
this.notify(new Set([t.session]));
|
|
118
139
|
}
|
|
119
140
|
}
|
|
120
141
|
// Use this in TypeSafeClient's supported `fetch` option. It observes attempts
|
|
@@ -181,9 +202,15 @@ export class JevRecorder {
|
|
|
181
202
|
attempts: [],
|
|
182
203
|
captureWarnings: [...warnings],
|
|
183
204
|
});
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
205
|
+
const trace = this.traces.get(id);
|
|
206
|
+
this.revisions.set(trace, ++this.revision);
|
|
207
|
+
const sessions = new Set([trace.session]);
|
|
208
|
+
while (this.traces.size > this.limit) {
|
|
209
|
+
const oldest = this.traces.values().next().value;
|
|
210
|
+
sessions.add(oldest.session);
|
|
211
|
+
this.traces.delete(oldest.id);
|
|
212
|
+
}
|
|
213
|
+
this.notify(sessions);
|
|
187
214
|
const fail = (error) => this.update(id, (t) => {
|
|
188
215
|
const e = error;
|
|
189
216
|
t.status = e.name === "APIUserAbortError" ? "cancelled" : "error";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
export function ChoiceProbabilities({ answer }) {
|
|
4
|
+
const [search, setSearch] = useState("");
|
|
5
|
+
const [page, setPage] = useState(0);
|
|
6
|
+
const rows = useMemo(() => Object.entries(answer.probabilities ?? {}).sort((a, b) => b[1] - a[1]), [answer.probabilities]);
|
|
7
|
+
const filtered = useMemo(() => {
|
|
8
|
+
const query = search.trim().toLocaleLowerCase();
|
|
9
|
+
return rows.filter(([label]) => label.toLocaleLowerCase().includes(query));
|
|
10
|
+
}, [rows, search]);
|
|
11
|
+
const pages = Math.max(1, Math.ceil(filtered.length / 20));
|
|
12
|
+
const current = Math.min(page, pages - 1);
|
|
13
|
+
return (_jsxs("div", { className: "lab-choice-browser", role: "region", "aria-label": "All Choice probabilities", children: [_jsxs("div", { className: "lab-choice-controls", children: [_jsxs("label", { children: ["Search categories", _jsx("input", { autoFocus: true, type: "search", value: search, onChange: (event) => {
|
|
14
|
+
setSearch(event.target.value);
|
|
15
|
+
setPage(0);
|
|
16
|
+
} })] }), _jsxs("span", { role: "status", children: [filtered.length, " of ", rows.length, " categories \u00B7 highest probability first"] })] }), _jsxs("div", { className: "lab-choice-table-scroll", tabIndex: 0, "aria-label": "Probability table", children: [_jsxs("table", { children: [_jsxs("caption", { children: ["Captured probabilities. Values are not renormalized. Returned choice: ", answer.choice ?? "unavailable", "."] }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Category" }), _jsx("th", { scope: "col", children: "Probability" }), _jsx("th", { scope: "col", children: "Exact value (0\u20131)" })] }) }), _jsx("tbody", { children: filtered
|
|
17
|
+
.slice(current * 20, (current + 1) * 20)
|
|
18
|
+
.map(([label, value]) => (_jsxs("tr", { children: [_jsxs("th", { scope: "row", children: [label, label === answer.choice && (_jsx("span", { className: "lab-choice-returned", children: "Returned choice" }))] }), _jsxs("td", { children: [Number((value * 100).toPrecision(6)), "%"] }), _jsx("td", { children: _jsx("code", { children: String(value) }) })] }, label))) })] }), !filtered.length && _jsx("p", { children: "No matching categories." })] }), _jsxs("div", { className: "lab-choice-pagination", children: [_jsx("button", { disabled: current === 0, onClick: () => setPage(current - 1), children: "Previous categories" }), _jsxs("span", { "aria-live": "polite", children: ["Page ", current + 1, " of ", pages] }), _jsx("button", { disabled: current + 1 >= pages, onClick: () => setPage(current + 1), children: "Next categories" })] })] }));
|
|
19
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect, useState } from "react";
|
|
2
|
+
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { JevPanel } from "./JevDevtools.js";
|
|
4
4
|
import { JevDrawer } from "./JevDrawer.js";
|
|
5
|
+
import { connectTraceStream } from "./trace-stream.js";
|
|
6
|
+
const RetainedPanel = memo(JevPanel);
|
|
5
7
|
function endpointUrl(endpoint) {
|
|
6
8
|
const url = new URL(endpoint, window.location.origin);
|
|
7
9
|
if (url.origin !== window.location.origin ||
|
|
@@ -13,12 +15,34 @@ function endpointUrl(endpoint) {
|
|
|
13
15
|
return url.href.replace(/\/$/, "");
|
|
14
16
|
}
|
|
15
17
|
export default function ConnectedInspector({ endpoint = "/api/jev-devtools", defaultOpen = true, }) {
|
|
16
|
-
const [
|
|
18
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
19
|
+
const [visited, setVisited] = useState(defaultOpen);
|
|
20
|
+
const visible = useRef(defaultOpen);
|
|
21
|
+
const latest = useRef({ traces: [], connection: "connecting" });
|
|
22
|
+
const [snapshot, setSnapshot] = useState(latest.current);
|
|
23
|
+
const toggle = (value) => {
|
|
24
|
+
visible.current = value;
|
|
25
|
+
setOpen(value);
|
|
26
|
+
if (value) {
|
|
27
|
+
setVisited(true);
|
|
28
|
+
setSnapshot(latest.current);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
17
31
|
useEffect(() => {
|
|
18
|
-
let alive = true,
|
|
32
|
+
let alive = true, disconnect = () => { };
|
|
19
33
|
const abort = new AbortController();
|
|
20
|
-
|
|
21
|
-
|
|
34
|
+
// Keep capture current while closed without scheduling React work. Reopening
|
|
35
|
+
// publishes only the latest snapshot; the retained panel keeps its UI state.
|
|
36
|
+
const update = (patch) => {
|
|
37
|
+
const next = { ...latest.current, ...patch };
|
|
38
|
+
if (next.traces === latest.current.traces &&
|
|
39
|
+
next.connection === latest.current.connection)
|
|
40
|
+
return;
|
|
41
|
+
latest.current = next;
|
|
42
|
+
if (visible.current)
|
|
43
|
+
setSnapshot(latest.current);
|
|
44
|
+
};
|
|
45
|
+
update({ traces: [], connection: "connecting" });
|
|
22
46
|
void (async () => {
|
|
23
47
|
try {
|
|
24
48
|
const url = endpointUrl(endpoint);
|
|
@@ -35,44 +59,28 @@ export default function ConnectedInspector({ endpoint = "/api/jev-devtools", def
|
|
|
35
59
|
throw new Error("Unsupported inspector endpoint");
|
|
36
60
|
if (!alive)
|
|
37
61
|
return;
|
|
38
|
-
|
|
39
|
-
events.addEventListener("snapshot", (event) => {
|
|
40
|
-
if (!alive)
|
|
41
|
-
return;
|
|
42
|
-
try {
|
|
43
|
-
const data = JSON.parse(event.data);
|
|
44
|
-
if (data.version !== 1 || !Array.isArray(data.traces))
|
|
45
|
-
throw new Error();
|
|
46
|
-
setTraces(data.traces);
|
|
47
|
-
setConnection("connected");
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
setConnection("Invalid inspector update");
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
events.onerror = () => {
|
|
54
|
-
if (alive)
|
|
55
|
-
setConnection("reconnecting");
|
|
56
|
-
};
|
|
62
|
+
disconnect = connectTraceStream(url, (traces) => update({ traces }), (connection) => update({ connection }));
|
|
57
63
|
}
|
|
58
64
|
catch (error) {
|
|
59
65
|
if (alive)
|
|
60
|
-
|
|
66
|
+
update({
|
|
67
|
+
connection: error instanceof Error ? error.message : "Inspector unavailable",
|
|
68
|
+
});
|
|
61
69
|
}
|
|
62
70
|
})();
|
|
63
71
|
return () => {
|
|
64
72
|
alive = false;
|
|
65
73
|
abort.abort();
|
|
66
|
-
|
|
74
|
+
disconnect();
|
|
67
75
|
};
|
|
68
76
|
}, [endpoint]);
|
|
69
|
-
|
|
77
|
+
const clear = useCallback(async () => {
|
|
70
78
|
const response = await fetch(endpointUrl(endpoint) + "/clear", {
|
|
71
79
|
method: "DELETE",
|
|
72
80
|
credentials: "same-origin",
|
|
73
81
|
});
|
|
74
82
|
if (!response.ok)
|
|
75
83
|
throw new Error(`Cannot clear history (${response.status})`);
|
|
76
|
-
}
|
|
77
|
-
return (_jsx(JevDrawer, {
|
|
84
|
+
}, [endpoint]);
|
|
85
|
+
return (_jsx(JevDrawer, { open: open, onOpenChange: toggle, children: visited && _jsx(RetainedPanel, { ...snapshot, onClear: clear }) }));
|
|
78
86
|
}
|
package/lib/src/JevDevtools.js
CHANGED
|
@@ -1,58 +1,48 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState } from "react";
|
|
2
|
+
import { lazy, memo, startTransition, Suspense, useMemo, useState, } from "react";
|
|
3
3
|
import { TraceDiagram } from "./TraceDiagram.js";
|
|
4
|
-
|
|
4
|
+
import { JsonBlock, findImage } from "./TraceContent.js";
|
|
5
|
+
let questionsLoaded = false;
|
|
6
|
+
const Questions = lazy(() => import("./Questions.js").then((module) => {
|
|
7
|
+
questionsLoaded = true;
|
|
8
|
+
return { default: module.Questions };
|
|
9
|
+
}));
|
|
5
10
|
const ms = (value) => value === undefined ? "—" : `${Math.round(value)} ms`;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
top: percent(answer.box.top),
|
|
30
|
-
width: percent(answer.box.right - answer.box.left),
|
|
31
|
-
height: percent(answer.box.bottom - answer.box.top),
|
|
32
|
-
} })] }), _jsx("code", { className: "coordinates", children: Object.entries(answer.box)
|
|
33
|
-
.map(([k, v]) => `${k} ${v.toFixed(2)}`)
|
|
34
|
-
.join(" · ") })] })), answer.confidence !== undefined && (_jsxs("p", { className: "confidence", children: ["Confidence ", _jsx("b", { children: percent(answer.confidence) })] }))] }));
|
|
35
|
-
}
|
|
11
|
+
// Delta updates retain unchanged Trace references. Default shallow comparison
|
|
12
|
+
// skips their render work while changed traces and selection still update.
|
|
13
|
+
const RequestRow = memo(function RequestRow({ trace, selected, onSelect, }) {
|
|
14
|
+
return (_jsxs("button", { className: `request-row ${selected ? "selected" : ""}`, onClick: () => onSelect(trace.id), children: [_jsx("span", { className: `status-dot ${trace.status}` }), _jsxs("span", { children: [_jsx("strong", { children: trace.label }), _jsxs("small", { children: [new Date(trace.startedAt).toLocaleTimeString(), " \u00B7 ", trace.source, " \u00B7", " ", ms(trace.durationMs)] })] }), _jsx("span", { className: "row-status", children: trace.status })] }));
|
|
15
|
+
});
|
|
16
|
+
const RequestDetail = memo(function RequestDetail({ trace, }) {
|
|
17
|
+
// Keep tab selection across request switches and clear, as before. Local tab
|
|
18
|
+
// changes no longer rerender the history list.
|
|
19
|
+
const [tab, setTab] = useState("Flow");
|
|
20
|
+
return (_jsx("section", { className: "request-detail", children: trace ? (_jsxs(_Fragment, { children: [_jsxs("header", { className: "detail-header", children: [_jsxs("div", { children: [_jsxs("span", { className: "eyebrow", children: [trace.source, " response \u00B7 ", trace.status] }), _jsx("h2", { children: trace.label })] }), _jsxs("div", { className: "metrics", children: [_jsxs("span", { children: [ms(trace.durationMs), _jsx("small", { children: "Total duration" })] }), _jsxs("span", { children: [trace.attempts.length, _jsx("small", { children: "Attempts" })] }), _jsxs("span", { children: [trace.result?.usage
|
|
21
|
+
? `${trace.result.usage.input_tokens} / ${trace.result.usage.output_tokens}`
|
|
22
|
+
: "—", _jsx("small", { children: "Tokens in / out" })] })] })] }), _jsxs("div", { className: "detail-meta", children: [_jsx("code", { children: trace.result?.model || trace.request.model }), _jsx("code", { children: trace.requestId ||
|
|
23
|
+
(trace.status === "pending"
|
|
24
|
+
? "Request ID pending"
|
|
25
|
+
: "No request ID") })] }), _jsx("nav", { className: "detail-tabs", "aria-label": "Request views", children: ["Flow", "Questions", "Context", "Transport", "Raw"].map((name) => (_jsx("button", { "aria-pressed": tab === name, onClick: () => {
|
|
26
|
+
// Keep the current view while its chunk loads, but don't
|
|
27
|
+
// defer subsequent opens once the component is available.
|
|
28
|
+
if (name === "Questions" && !questionsLoaded)
|
|
29
|
+
startTransition(() => setTab(name));
|
|
30
|
+
else
|
|
31
|
+
setTab(name);
|
|
32
|
+
}, children: name }, name))) }), _jsx("div", { className: `detail-body ${tab === "Flow" ? "detail-body-flow" : tab === "Questions" ? "detail-body-questions" : ""}`, children: _jsxs(Suspense, { fallback: _jsx("p", { className: "muted", role: "status", children: "Loading questions\u2026" }), children: [trace.error && (_jsxs("div", { role: "alert", className: "error-banner", children: [_jsx("strong", { children: trace.error.name }), " ", trace.error.message] })), trace.captureWarnings.map((w) => (_jsx("p", { className: "warning", children: w }, w))), tab === "Flow" && _jsx(TraceDiagram, { trace: trace }, trace.id), tab === "Questions" && (_jsx(Questions, { trace: trace }, trace.id)), tab === "Context" && (_jsxs(_Fragment, { children: [_jsx("p", { className: "muted", children: "Captured before dispatch. Known credential fields and email are redacted; captures are size limited." }), findImage(trace.request.state) && (_jsx("img", { className: "context-image", src: findImage(trace.request.state), alt: "Captured context" })), _jsx(JsonBlock, { value: trace.request.state }), trace.request.assets && (_jsx(JsonBlock, { value: trace.request.assets }))] })), tab === "Transport" && (_jsxs(_Fragment, { children: [_jsx("h3", { children: "HTTP attempts" }), _jsx("p", { className: "muted", children: "Attempt duration ends at response headers. Total duration includes SDK processing and retry waits." }), _jsxs("table", { children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "Attempt" }), _jsx("th", { children: "Status" }), _jsx("th", { children: "Duration" })] }) }), _jsx("tbody", { children: trace.attempts.map((a) => (_jsxs("tr", { children: [_jsx("td", { children: a.number }), _jsx("td", { children: a.error || a.status || "Pending" }), _jsx("td", { children: ms(a.durationMs) })] }, a.number))) })] }), !trace.attempts.length && _jsx("p", { children: "No HTTP attempt recorded." })] })), tab === "Raw" && _jsx(JsonBlock, { value: trace })] }) })] })) : (_jsxs("div", { className: "empty-detail", children: [_jsx("span", { className: "empty-symbol", children: "\u21B3" }), _jsx("h2", { children: "Follow a Jev request" }), _jsxs("p", { children: ["Context, parallel questions, typed answers.", _jsx("br", {}), "Run a sample above to see the complete flow."] })] })) }));
|
|
33
|
+
});
|
|
36
34
|
export function JevPanel({ traces, connection, onClear, }) {
|
|
37
35
|
const [selected, setSelected] = useState();
|
|
38
|
-
const [tab, setTab] = useState("Flow");
|
|
39
36
|
const [search, setSearch] = useState("");
|
|
40
37
|
const [status, setStatus] = useState("all");
|
|
41
38
|
const [error, setError] = useState("");
|
|
42
|
-
const filtered =
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
.
|
|
39
|
+
const filtered = useMemo(() => {
|
|
40
|
+
const query = search.toLowerCase();
|
|
41
|
+
return traces.filter((t) => (status === "all" || status === t.status) &&
|
|
42
|
+
`${t.label} ${t.request.model} ${t.id}`.toLowerCase().includes(query));
|
|
43
|
+
}, [traces, search, status]);
|
|
46
44
|
const trace = filtered.find((t) => t.id === selected) || filtered[0];
|
|
47
|
-
return (_jsxs("div", { className: "jev-inspector", children: [_jsxs("aside", { className: "request-list", children: [_jsxs("header", { children: [_jsxs("h2", { children: ["Jev requests ", _jsx("span", { children: traces.length })] }), _jsx("button", { onClick: () => void onClear().catch((e) => setError(e.message)), disabled: !traces.length, children: "Clear" })] }), _jsxs("div", { className: "list-filters", children: [_jsx("input", { "aria-label": "Search requests", placeholder: "Search requests\u2026", value: search, onChange: (e) => setSearch(e.target.value) }), _jsx("select", { "aria-label": "Filter request status", value: status, onChange: (e) => setStatus(e.target.value), children: ["all", "pending", "success", "error", "cancelled"].map((s) => (_jsx("option", { children: s }, s))) })] }), _jsxs("div", { className: "request-items", children: [filtered.map((t) => (
|
|
45
|
+
return (_jsxs("div", { className: "jev-inspector", children: [_jsxs("aside", { className: "request-list", children: [_jsxs("header", { children: [_jsxs("h2", { children: ["Jev requests ", _jsx("span", { children: traces.length })] }), _jsx("button", { onClick: () => void onClear().catch((e) => setError(e.message)), disabled: !traces.length, children: "Clear" })] }), _jsxs("div", { className: "list-filters", children: [_jsx("input", { "aria-label": "Search requests", placeholder: "Search requests\u2026", value: search, onChange: (e) => setSearch(e.target.value) }), _jsx("select", { "aria-label": "Filter request status", value: status, onChange: (e) => setStatus(e.target.value), children: ["all", "pending", "success", "error", "cancelled"].map((s) => (_jsx("option", { children: s }, s))) })] }), _jsxs("div", { className: "request-items", children: [filtered.map((t) => (_jsx(RequestRow, { trace: t, selected: trace?.id === t.id, onSelect: setSelected }, t.id))), !filtered.length && (_jsx("p", { className: "empty", children: traces.length
|
|
48
46
|
? "No matching requests."
|
|
49
|
-
: "Run a scenario to capture your first request." }))] }), (connection !== "connected" || error) && (_jsxs("footer", { children: [connection !== "connected" && (_jsx("span", { role: "status", children: connection })), error && _jsx("p", { role: "alert", children: error })] }))] }), _jsx(
|
|
50
|
-
? `${trace.result.usage.input_tokens} / ${trace.result.usage.output_tokens}`
|
|
51
|
-
: "—", _jsx("small", { children: "Tokens in / out" })] })] })] }), _jsxs("div", { className: "detail-meta", children: [_jsx("code", { children: trace.result?.model || trace.request.model }), _jsx("code", { children: trace.requestId ||
|
|
52
|
-
(trace.status === "pending"
|
|
53
|
-
? "Request ID pending"
|
|
54
|
-
: "No request ID") })] }), _jsx("nav", { className: "detail-tabs", "aria-label": "Request views", children: ["Flow", "Questions", "Context", "Transport", "Raw"].map((name) => (_jsx("button", { "aria-pressed": tab === name, onClick: () => setTab(name), children: name }, name))) }), _jsxs("div", { className: `detail-body ${tab === "Flow" ? "detail-body-flow" : ""}`, children: [trace.error && (_jsxs("div", { role: "alert", className: "error-banner", children: [_jsx("strong", { children: trace.error.name }), " ", trace.error.message] })), trace.captureWarnings.map((w) => (_jsx("p", { className: "warning", children: w }, w))), tab === "Flow" && _jsx(TraceDiagram, { trace: trace }, trace.id), tab === "Questions" &&
|
|
55
|
-
Object.entries(trace.request.questions ?? {})
|
|
56
|
-
.filter(([, q]) => q && typeof q === "object" && typeof q.type === "string")
|
|
57
|
-
.map(([key, q]) => (_jsxs("article", { className: "question-node standalone", children: [_jsxs("header", { children: [_jsx("h3", { children: key }), _jsx("span", { className: "type", children: q.type })] }), _jsx(JsonBlock, { value: q }), _jsx(Answer, { answer: trace.result?.answers[key], image: findImage(trace.request.state) })] }, key))), tab === "Context" && (_jsxs(_Fragment, { children: [_jsx("p", { className: "muted", children: "Captured before dispatch. Known credential fields and email are redacted; captures are size limited." }), findImage(trace.request.state) && (_jsx("img", { className: "context-image", src: findImage(trace.request.state), alt: "Captured context" })), _jsx(JsonBlock, { value: trace.request.state }), trace.request.assets && (_jsx(JsonBlock, { value: trace.request.assets }))] })), tab === "Transport" && (_jsxs(_Fragment, { children: [_jsx("h3", { children: "HTTP attempts" }), _jsx("p", { className: "muted", children: "Attempt duration ends at response headers. Total duration includes SDK processing and retry waits." }), _jsxs("table", { children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { children: "Attempt" }), _jsx("th", { children: "Status" }), _jsx("th", { children: "Duration" })] }) }), _jsx("tbody", { children: trace.attempts.map((a) => (_jsxs("tr", { children: [_jsx("td", { children: a.number }), _jsx("td", { children: a.error || a.status || "Pending" }), _jsx("td", { children: ms(a.durationMs) })] }, a.number))) })] }), !trace.attempts.length && _jsx("p", { children: "No HTTP attempt recorded." })] })), tab === "Raw" && _jsx(JsonBlock, { value: trace })] })] })) : (_jsxs("div", { className: "empty-detail", children: [_jsx("span", { className: "empty-symbol", children: "\u21B3" }), _jsx("h2", { children: "Follow a Jev request" }), _jsxs("p", { children: ["Context, parallel questions, typed answers.", _jsx("br", {}), "Run a sample above to see the complete flow."] })] })) })] }));
|
|
47
|
+
: "Run a scenario to capture your first request." }))] }), (connection !== "connected" || error) && (_jsxs("footer", { children: [connection !== "connected" && (_jsx("span", { role: "status", children: connection })), error && _jsx("p", { role: "alert", children: error })] }))] }), _jsx(RequestDetail, { trace: trace })] }));
|
|
58
48
|
}
|
package/lib/src/JevDrawer.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
/** Non-modal drawer: the app remains usable and children stay mounted when closed. */
|
|
3
|
-
export declare function JevDrawer({ children,
|
|
3
|
+
export declare function JevDrawer({ children, open, onOpenChange, }: {
|
|
4
4
|
children: ReactNode;
|
|
5
|
-
|
|
5
|
+
open: boolean;
|
|
6
|
+
onOpenChange: (open: boolean) => void;
|
|
6
7
|
}): import("react").ReactPortal | null;
|
package/lib/src/JevDrawer.js
CHANGED
|
@@ -25,10 +25,9 @@ function initialHeight() {
|
|
|
25
25
|
return clamp(Math.min(420, viewportHeight() * 0.56), viewportHeight());
|
|
26
26
|
}
|
|
27
27
|
/** Non-modal drawer: the app remains usable and children stay mounted when closed. */
|
|
28
|
-
export function JevDrawer({ children,
|
|
28
|
+
export function JevDrawer({ children, open, onOpenChange, }) {
|
|
29
29
|
const id = useId();
|
|
30
30
|
const [root, setRoot] = useState(null);
|
|
31
|
-
const [open, setOpen] = useState(defaultOpen);
|
|
32
31
|
const [expanded, setExpanded] = useState(false);
|
|
33
32
|
const [viewport, setViewport] = useState(viewportHeight);
|
|
34
33
|
const [height, setHeight] = useState(initialHeight);
|
|
@@ -39,6 +38,16 @@ export function JevDrawer({ children, defaultOpen = true, }) {
|
|
|
39
38
|
const drag = useRef(null);
|
|
40
39
|
const { min, max } = limits(viewport);
|
|
41
40
|
const visibleHeight = expanded ? max : clamp(height, viewport);
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
// Retain the diagram and its camera, but suspend native SVG timelines while
|
|
43
|
+
// hidden. This does not change the user's Pause/Play flow preference.
|
|
44
|
+
panel.current?.querySelectorAll("svg").forEach((svg) => {
|
|
45
|
+
if (open)
|
|
46
|
+
svg.unpauseAnimations();
|
|
47
|
+
else
|
|
48
|
+
svg.pauseAnimations();
|
|
49
|
+
});
|
|
50
|
+
}, [open, root]);
|
|
42
51
|
useEffect(() => {
|
|
43
52
|
const host = document.createElement("jev-devtools-root");
|
|
44
53
|
// The host is the only element exposed to page selectors. The overlay itself
|
|
@@ -76,7 +85,7 @@ export function JevDrawer({ children, defaultOpen = true, }) {
|
|
|
76
85
|
}, [open, root]);
|
|
77
86
|
const toggle = (value) => {
|
|
78
87
|
interacted.current = true;
|
|
79
|
-
|
|
88
|
+
onOpenChange(value);
|
|
80
89
|
};
|
|
81
90
|
const resizeTo = (value) => {
|
|
82
91
|
setExpanded(false);
|