@ai-gui/devtools 0.3.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 +21 -0
- package/dist/index.cjs +271 -0
- package/dist/index.d.cts +56 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +267 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Liang Li
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @ai-gui/devtools
|
|
2
|
+
|
|
3
|
+
Framework-agnostic AIGUI runtime timeline and deterministic stream simulator.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { Renderer, createActionRuntime, CardStore } from "@ai-gui/core"
|
|
7
|
+
import { createDevTools, createStreamSimulator } from "@ai-gui/devtools"
|
|
8
|
+
|
|
9
|
+
const renderer = new Renderer({ debug: true })
|
|
10
|
+
const cards = new CardStore({ debug: true })
|
|
11
|
+
const actions = createActionRuntime({ registry, cardStore: cards, debug: true })
|
|
12
|
+
const devtools = createDevTools({ maxEvents: 500 })
|
|
13
|
+
|
|
14
|
+
devtools.attach(renderer, actions, cards)
|
|
15
|
+
devtools.subscribe(console.log)
|
|
16
|
+
|
|
17
|
+
const simulator = createStreamSimulator(markdown, { chunkSize: 4, delayMs: 20 })
|
|
18
|
+
await renderer.feed(simulator.stream)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`snapshot()` returns a bounded, globally ordered timeline. Core redacts credentials and text-level Bearer/query secrets before observers receive events and bounds payload construction. Debug may still contain application business data or form PII that cannot be recognized automatically, so use custom `redact` rules for those fields. Devtools also supports bounded event retention, `clear()`, and `destroy()`.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
//#region src/index.ts
|
|
4
|
+
const DEFAULT_SENSITIVE_KEY_PARTS = [
|
|
5
|
+
"authorization",
|
|
6
|
+
"auth",
|
|
7
|
+
"accesstoken",
|
|
8
|
+
"refreshtoken",
|
|
9
|
+
"clientsecret",
|
|
10
|
+
"credentials",
|
|
11
|
+
"cookie",
|
|
12
|
+
"setcookie",
|
|
13
|
+
"proxyauthorization",
|
|
14
|
+
"password",
|
|
15
|
+
"passwd",
|
|
16
|
+
"apikey",
|
|
17
|
+
"secret",
|
|
18
|
+
"token"
|
|
19
|
+
];
|
|
20
|
+
function createDevTools(options = {}) {
|
|
21
|
+
const maxEvents = positiveInteger(options.maxEvents, 1e3, "maxEvents");
|
|
22
|
+
const maxStringLength = positiveInteger(options.maxStringLength, 4096, "maxStringLength");
|
|
23
|
+
const maxDepth = positiveInteger(options.maxDepth, 24, "maxDepth");
|
|
24
|
+
const maxNodes = positiveInteger(options.maxNodes, 2e4, "maxNodes");
|
|
25
|
+
const now = options.now ?? Date.now;
|
|
26
|
+
const timeline = [];
|
|
27
|
+
const listeners = new Set();
|
|
28
|
+
const detachments = new Set();
|
|
29
|
+
let sequence = 0;
|
|
30
|
+
let dropped = 0;
|
|
31
|
+
let destroyed = false;
|
|
32
|
+
const record = (event) => {
|
|
33
|
+
if (destroyed) return;
|
|
34
|
+
const entry = Object.freeze({
|
|
35
|
+
type: event.type,
|
|
36
|
+
source: event.source,
|
|
37
|
+
timestamp: now(),
|
|
38
|
+
sequence: ++sequence,
|
|
39
|
+
sourceSequence: event.sequence,
|
|
40
|
+
data: limitValue(event.data, {
|
|
41
|
+
maxStringLength,
|
|
42
|
+
maxDepth,
|
|
43
|
+
maxNodes,
|
|
44
|
+
redact: options.redact
|
|
45
|
+
})
|
|
46
|
+
});
|
|
47
|
+
timeline.push(entry);
|
|
48
|
+
if (timeline.length > maxEvents) {
|
|
49
|
+
dropped += timeline.length - maxEvents;
|
|
50
|
+
timeline.splice(0, timeline.length - maxEvents);
|
|
51
|
+
}
|
|
52
|
+
for (const listener of listeners) try {
|
|
53
|
+
listener(entry);
|
|
54
|
+
} catch {}
|
|
55
|
+
};
|
|
56
|
+
const api = {
|
|
57
|
+
attach(...targets) {
|
|
58
|
+
if (destroyed) throw new Error("DevTools has been destroyed");
|
|
59
|
+
const listener = (event) => record(event);
|
|
60
|
+
const current = [];
|
|
61
|
+
try {
|
|
62
|
+
for (const target of targets) current.push(target.subscribeDebug(listener));
|
|
63
|
+
} catch (error) {
|
|
64
|
+
for (const unsubscribe of current) unsubscribe();
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
const detach = () => {
|
|
68
|
+
if (!detachments.delete(detach)) return;
|
|
69
|
+
for (const unsubscribe of current) unsubscribe();
|
|
70
|
+
};
|
|
71
|
+
detachments.add(detach);
|
|
72
|
+
return detach;
|
|
73
|
+
},
|
|
74
|
+
subscribe(listener) {
|
|
75
|
+
if (destroyed) return () => {};
|
|
76
|
+
listeners.add(listener);
|
|
77
|
+
return () => listeners.delete(listener);
|
|
78
|
+
},
|
|
79
|
+
snapshot() {
|
|
80
|
+
return timeline.map((event) => ({
|
|
81
|
+
...event,
|
|
82
|
+
data: cloneValue(event.data)
|
|
83
|
+
}));
|
|
84
|
+
},
|
|
85
|
+
clear() {
|
|
86
|
+
timeline.length = 0;
|
|
87
|
+
dropped = 0;
|
|
88
|
+
},
|
|
89
|
+
stats() {
|
|
90
|
+
return {
|
|
91
|
+
retained: timeline.length,
|
|
92
|
+
dropped
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
destroy() {
|
|
96
|
+
if (destroyed) return;
|
|
97
|
+
destroyed = true;
|
|
98
|
+
for (const detach of [...detachments]) detach();
|
|
99
|
+
listeners.clear();
|
|
100
|
+
timeline.length = 0;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
return api;
|
|
104
|
+
}
|
|
105
|
+
function limitValue(value, options) {
|
|
106
|
+
const seen = new WeakSet();
|
|
107
|
+
let nodes = 0;
|
|
108
|
+
const visit = (input, key, path, depth) => {
|
|
109
|
+
nodes++;
|
|
110
|
+
if (nodes > options.maxNodes) return "[MAX_NODES]";
|
|
111
|
+
if (isSensitiveKey(key) || options.redact?.({
|
|
112
|
+
key,
|
|
113
|
+
path,
|
|
114
|
+
value: input
|
|
115
|
+
})) return "[REDACTED]";
|
|
116
|
+
if (typeof input === "string") {
|
|
117
|
+
const safe = redactText(input);
|
|
118
|
+
return safe.length > options.maxStringLength ? `${safe.slice(0, options.maxStringLength)}[TRUNCATED]` : safe;
|
|
119
|
+
}
|
|
120
|
+
if (input === null || typeof input === "number" || typeof input === "boolean") return input;
|
|
121
|
+
if (typeof input !== "object") return String(input);
|
|
122
|
+
if (depth >= options.maxDepth) return "[MAX_DEPTH]";
|
|
123
|
+
if (seen.has(input)) return "[CIRCULAR]";
|
|
124
|
+
seen.add(input);
|
|
125
|
+
try {
|
|
126
|
+
if (Array.isArray(input)) return input.map((item, index) => visit(item, "", [...path, index], depth + 1));
|
|
127
|
+
const output = {};
|
|
128
|
+
for (const [childKey, child] of Object.entries(input)) output[childKey] = visit(child, childKey, [...path, childKey], depth + 1);
|
|
129
|
+
return output;
|
|
130
|
+
} finally {
|
|
131
|
+
seen.delete(input);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
return visit(value, "", [], 0);
|
|
135
|
+
}
|
|
136
|
+
function isSensitiveKey(key) {
|
|
137
|
+
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
138
|
+
return normalized !== "" && DEFAULT_SENSITIVE_KEY_PARTS.some((part) => normalized.includes(part));
|
|
139
|
+
}
|
|
140
|
+
function redactText(value) {
|
|
141
|
+
return value.replace(/\bBearer\s+[^\s,;]+/gi, "Bearer [REDACTED]").replace(/([?&](?:access[_-]?token|refresh[_-]?token|client[_-]?secret|api[_-]?key|password|passwd|token|secret)=)[^&#\s]*/gi, "$1[REDACTED]").replace(/\b((?:access[_-]?token|refresh[_-]?token|client[_-]?secret|api[_-]?key|password|passwd|token|secret)\s*[:=]\s*)[^\s,;]+/gi, "$1[REDACTED]");
|
|
142
|
+
}
|
|
143
|
+
function cloneValue(value) {
|
|
144
|
+
if (Array.isArray(value)) return value.map(cloneValue);
|
|
145
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, cloneValue(child)]));
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
function positiveInteger(value, fallback, name) {
|
|
149
|
+
if (value === void 0) return fallback;
|
|
150
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${name} must be a positive integer`);
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
const STREAM_FIXTURES = Object.freeze({
|
|
154
|
+
markdown: "# Streaming demo\n\nThis paragraph arrives in deterministic chunks.",
|
|
155
|
+
card: "```card:demo\n{\"id\":\"demo-card\",\"title\":\"Fixture card\",\"count\":1}\n```",
|
|
156
|
+
unicode: "UTF-8 boundaries: 你好, مرحبا, 🙂."
|
|
157
|
+
});
|
|
158
|
+
function createStreamSimulator(input, options = {}) {
|
|
159
|
+
const chunkSize = positiveInteger(options.chunkSize, 8, "chunkSize");
|
|
160
|
+
const delayMs = nonNegativeNumber(options.delayMs, 0, "delayMs");
|
|
161
|
+
const bytes = new TextEncoder().encode(input);
|
|
162
|
+
let currentState = "running";
|
|
163
|
+
let offset = 0;
|
|
164
|
+
let wake;
|
|
165
|
+
let timer;
|
|
166
|
+
let settleDelay;
|
|
167
|
+
let pending;
|
|
168
|
+
const waitUntilRunning = async () => {
|
|
169
|
+
while (currentState === "paused") await new Promise((resolve) => {
|
|
170
|
+
wake = resolve;
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
const finishWaits = () => {
|
|
174
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
175
|
+
timer = void 0;
|
|
176
|
+
settleDelay?.();
|
|
177
|
+
settleDelay = void 0;
|
|
178
|
+
wake?.();
|
|
179
|
+
wake = void 0;
|
|
180
|
+
};
|
|
181
|
+
const waitDelay = () => new Promise((resolve) => {
|
|
182
|
+
if (delayMs === 0 || isCancelled()) return resolve();
|
|
183
|
+
settleDelay = resolve;
|
|
184
|
+
timer = setTimeout(() => {
|
|
185
|
+
timer = void 0;
|
|
186
|
+
settleDelay = void 0;
|
|
187
|
+
resolve();
|
|
188
|
+
}, delayMs);
|
|
189
|
+
});
|
|
190
|
+
const iterator = {
|
|
191
|
+
next() {
|
|
192
|
+
if (pending) return pending;
|
|
193
|
+
pending = (async () => {
|
|
194
|
+
await waitUntilRunning();
|
|
195
|
+
if (isCancelled()) return {
|
|
196
|
+
done: true,
|
|
197
|
+
value: void 0
|
|
198
|
+
};
|
|
199
|
+
await waitDelay();
|
|
200
|
+
if (isCancelled()) return {
|
|
201
|
+
done: true,
|
|
202
|
+
value: void 0
|
|
203
|
+
};
|
|
204
|
+
await waitUntilRunning();
|
|
205
|
+
if (isCancelled()) return {
|
|
206
|
+
done: true,
|
|
207
|
+
value: void 0
|
|
208
|
+
};
|
|
209
|
+
if (offset >= bytes.length) {
|
|
210
|
+
currentState = "completed";
|
|
211
|
+
return {
|
|
212
|
+
done: true,
|
|
213
|
+
value: void 0
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const chunk = bytes.slice(offset, Math.min(offset + chunkSize, bytes.length));
|
|
217
|
+
offset += chunk.length;
|
|
218
|
+
return {
|
|
219
|
+
done: false,
|
|
220
|
+
value: chunk
|
|
221
|
+
};
|
|
222
|
+
})().finally(() => {
|
|
223
|
+
pending = void 0;
|
|
224
|
+
});
|
|
225
|
+
return pending;
|
|
226
|
+
},
|
|
227
|
+
async return() {
|
|
228
|
+
if (currentState !== "completed") currentState = "cancelled";
|
|
229
|
+
finishWaits();
|
|
230
|
+
await pending;
|
|
231
|
+
return {
|
|
232
|
+
done: true,
|
|
233
|
+
value: void 0
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
const stream = { [Symbol.asyncIterator]: () => iterator };
|
|
238
|
+
return {
|
|
239
|
+
stream,
|
|
240
|
+
pause() {
|
|
241
|
+
if (currentState === "running") currentState = "paused";
|
|
242
|
+
},
|
|
243
|
+
resume() {
|
|
244
|
+
if (currentState !== "paused") return;
|
|
245
|
+
currentState = "running";
|
|
246
|
+
wake?.();
|
|
247
|
+
wake = void 0;
|
|
248
|
+
},
|
|
249
|
+
cancel() {
|
|
250
|
+
if (currentState === "completed" || currentState === "cancelled") return;
|
|
251
|
+
currentState = "cancelled";
|
|
252
|
+
finishWaits();
|
|
253
|
+
},
|
|
254
|
+
state() {
|
|
255
|
+
return currentState;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
function isCancelled() {
|
|
259
|
+
return currentState === "cancelled";
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function nonNegativeNumber(value, fallback, name) {
|
|
263
|
+
if (value === void 0) return fallback;
|
|
264
|
+
if (!Number.isFinite(value) || value < 0) throw new TypeError(`${name} must be a non-negative number`);
|
|
265
|
+
return value;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
//#endregion
|
|
269
|
+
exports.STREAM_FIXTURES = STREAM_FIXTURES
|
|
270
|
+
exports.createDevTools = createDevTools
|
|
271
|
+
exports.createStreamSimulator = createStreamSimulator
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { DebugEventTarget, DebugSource } from "@ai-gui/core";
|
|
2
|
+
|
|
3
|
+
//#region src/index.d.ts
|
|
4
|
+
interface TimelineEvent {
|
|
5
|
+
type: string;
|
|
6
|
+
source: DebugSource;
|
|
7
|
+
timestamp: number;
|
|
8
|
+
sequence: number;
|
|
9
|
+
sourceSequence: number;
|
|
10
|
+
data: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
interface RedactContext {
|
|
13
|
+
key: string;
|
|
14
|
+
path: readonly (string | number)[];
|
|
15
|
+
value: unknown;
|
|
16
|
+
}
|
|
17
|
+
interface DevToolsOptions {
|
|
18
|
+
maxEvents?: number;
|
|
19
|
+
maxStringLength?: number;
|
|
20
|
+
maxDepth?: number;
|
|
21
|
+
maxNodes?: number;
|
|
22
|
+
redact?: (context: RedactContext) => boolean;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
}
|
|
25
|
+
interface DevToolsStats {
|
|
26
|
+
retained: number;
|
|
27
|
+
dropped: number;
|
|
28
|
+
}
|
|
29
|
+
interface DevTools {
|
|
30
|
+
attach(...targets: DebugEventTarget[]): () => void;
|
|
31
|
+
subscribe(listener: (event: TimelineEvent) => void): () => void;
|
|
32
|
+
snapshot(): TimelineEvent[];
|
|
33
|
+
clear(): void;
|
|
34
|
+
stats(): DevToolsStats;
|
|
35
|
+
destroy(): void;
|
|
36
|
+
}
|
|
37
|
+
declare function createDevTools(options?: DevToolsOptions): DevTools;
|
|
38
|
+
type StreamSimulatorState = "running" | "paused" | "completed" | "cancelled";
|
|
39
|
+
interface StreamSimulatorOptions {
|
|
40
|
+
chunkSize?: number;
|
|
41
|
+
delayMs?: number;
|
|
42
|
+
}
|
|
43
|
+
interface StreamSimulator {
|
|
44
|
+
stream: AsyncIterable<Uint8Array>;
|
|
45
|
+
pause(): void;
|
|
46
|
+
resume(): void;
|
|
47
|
+
cancel(): void;
|
|
48
|
+
state(): StreamSimulatorState;
|
|
49
|
+
}
|
|
50
|
+
declare const STREAM_FIXTURES: Readonly<{
|
|
51
|
+
markdown: "# Streaming demo\n\nThis paragraph arrives in deterministic chunks.";
|
|
52
|
+
card: "```card:demo\n{\"id\":\"demo-card\",\"title\":\"Fixture card\",\"count\":1}\n```";
|
|
53
|
+
unicode: "UTF-8 boundaries: 你好, مرحبا, 🙂.";
|
|
54
|
+
}>;
|
|
55
|
+
declare function createStreamSimulator(input: string, options?: StreamSimulatorOptions): StreamSimulator; //#endregion
|
|
56
|
+
export { DevTools, DevToolsOptions, DevToolsStats, RedactContext, STREAM_FIXTURES, StreamSimulator, StreamSimulatorOptions, StreamSimulatorState, TimelineEvent, createDevTools, createStreamSimulator };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { DebugEventTarget, DebugSource } from "@ai-gui/core";
|
|
2
|
+
|
|
3
|
+
//#region src/index.d.ts
|
|
4
|
+
interface TimelineEvent {
|
|
5
|
+
type: string;
|
|
6
|
+
source: DebugSource;
|
|
7
|
+
timestamp: number;
|
|
8
|
+
sequence: number;
|
|
9
|
+
sourceSequence: number;
|
|
10
|
+
data: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
interface RedactContext {
|
|
13
|
+
key: string;
|
|
14
|
+
path: readonly (string | number)[];
|
|
15
|
+
value: unknown;
|
|
16
|
+
}
|
|
17
|
+
interface DevToolsOptions {
|
|
18
|
+
maxEvents?: number;
|
|
19
|
+
maxStringLength?: number;
|
|
20
|
+
maxDepth?: number;
|
|
21
|
+
maxNodes?: number;
|
|
22
|
+
redact?: (context: RedactContext) => boolean;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
}
|
|
25
|
+
interface DevToolsStats {
|
|
26
|
+
retained: number;
|
|
27
|
+
dropped: number;
|
|
28
|
+
}
|
|
29
|
+
interface DevTools {
|
|
30
|
+
attach(...targets: DebugEventTarget[]): () => void;
|
|
31
|
+
subscribe(listener: (event: TimelineEvent) => void): () => void;
|
|
32
|
+
snapshot(): TimelineEvent[];
|
|
33
|
+
clear(): void;
|
|
34
|
+
stats(): DevToolsStats;
|
|
35
|
+
destroy(): void;
|
|
36
|
+
}
|
|
37
|
+
declare function createDevTools(options?: DevToolsOptions): DevTools;
|
|
38
|
+
type StreamSimulatorState = "running" | "paused" | "completed" | "cancelled";
|
|
39
|
+
interface StreamSimulatorOptions {
|
|
40
|
+
chunkSize?: number;
|
|
41
|
+
delayMs?: number;
|
|
42
|
+
}
|
|
43
|
+
interface StreamSimulator {
|
|
44
|
+
stream: AsyncIterable<Uint8Array>;
|
|
45
|
+
pause(): void;
|
|
46
|
+
resume(): void;
|
|
47
|
+
cancel(): void;
|
|
48
|
+
state(): StreamSimulatorState;
|
|
49
|
+
}
|
|
50
|
+
declare const STREAM_FIXTURES: Readonly<{
|
|
51
|
+
markdown: "# Streaming demo\n\nThis paragraph arrives in deterministic chunks.";
|
|
52
|
+
card: "```card:demo\n{\"id\":\"demo-card\",\"title\":\"Fixture card\",\"count\":1}\n```";
|
|
53
|
+
unicode: "UTF-8 boundaries: 你好, مرحبا, 🙂.";
|
|
54
|
+
}>;
|
|
55
|
+
declare function createStreamSimulator(input: string, options?: StreamSimulatorOptions): StreamSimulator; //#endregion
|
|
56
|
+
export { DevTools, DevToolsOptions, DevToolsStats, RedactContext, STREAM_FIXTURES, StreamSimulator, StreamSimulatorOptions, StreamSimulatorState, TimelineEvent, createDevTools, createStreamSimulator };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
//#region src/index.ts
|
|
2
|
+
const DEFAULT_SENSITIVE_KEY_PARTS = [
|
|
3
|
+
"authorization",
|
|
4
|
+
"auth",
|
|
5
|
+
"accesstoken",
|
|
6
|
+
"refreshtoken",
|
|
7
|
+
"clientsecret",
|
|
8
|
+
"credentials",
|
|
9
|
+
"cookie",
|
|
10
|
+
"setcookie",
|
|
11
|
+
"proxyauthorization",
|
|
12
|
+
"password",
|
|
13
|
+
"passwd",
|
|
14
|
+
"apikey",
|
|
15
|
+
"secret",
|
|
16
|
+
"token"
|
|
17
|
+
];
|
|
18
|
+
function createDevTools(options = {}) {
|
|
19
|
+
const maxEvents = positiveInteger(options.maxEvents, 1e3, "maxEvents");
|
|
20
|
+
const maxStringLength = positiveInteger(options.maxStringLength, 4096, "maxStringLength");
|
|
21
|
+
const maxDepth = positiveInteger(options.maxDepth, 24, "maxDepth");
|
|
22
|
+
const maxNodes = positiveInteger(options.maxNodes, 2e4, "maxNodes");
|
|
23
|
+
const now = options.now ?? Date.now;
|
|
24
|
+
const timeline = [];
|
|
25
|
+
const listeners = new Set();
|
|
26
|
+
const detachments = new Set();
|
|
27
|
+
let sequence = 0;
|
|
28
|
+
let dropped = 0;
|
|
29
|
+
let destroyed = false;
|
|
30
|
+
const record = (event) => {
|
|
31
|
+
if (destroyed) return;
|
|
32
|
+
const entry = Object.freeze({
|
|
33
|
+
type: event.type,
|
|
34
|
+
source: event.source,
|
|
35
|
+
timestamp: now(),
|
|
36
|
+
sequence: ++sequence,
|
|
37
|
+
sourceSequence: event.sequence,
|
|
38
|
+
data: limitValue(event.data, {
|
|
39
|
+
maxStringLength,
|
|
40
|
+
maxDepth,
|
|
41
|
+
maxNodes,
|
|
42
|
+
redact: options.redact
|
|
43
|
+
})
|
|
44
|
+
});
|
|
45
|
+
timeline.push(entry);
|
|
46
|
+
if (timeline.length > maxEvents) {
|
|
47
|
+
dropped += timeline.length - maxEvents;
|
|
48
|
+
timeline.splice(0, timeline.length - maxEvents);
|
|
49
|
+
}
|
|
50
|
+
for (const listener of listeners) try {
|
|
51
|
+
listener(entry);
|
|
52
|
+
} catch {}
|
|
53
|
+
};
|
|
54
|
+
const api = {
|
|
55
|
+
attach(...targets) {
|
|
56
|
+
if (destroyed) throw new Error("DevTools has been destroyed");
|
|
57
|
+
const listener = (event) => record(event);
|
|
58
|
+
const current = [];
|
|
59
|
+
try {
|
|
60
|
+
for (const target of targets) current.push(target.subscribeDebug(listener));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
for (const unsubscribe of current) unsubscribe();
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
const detach = () => {
|
|
66
|
+
if (!detachments.delete(detach)) return;
|
|
67
|
+
for (const unsubscribe of current) unsubscribe();
|
|
68
|
+
};
|
|
69
|
+
detachments.add(detach);
|
|
70
|
+
return detach;
|
|
71
|
+
},
|
|
72
|
+
subscribe(listener) {
|
|
73
|
+
if (destroyed) return () => {};
|
|
74
|
+
listeners.add(listener);
|
|
75
|
+
return () => listeners.delete(listener);
|
|
76
|
+
},
|
|
77
|
+
snapshot() {
|
|
78
|
+
return timeline.map((event) => ({
|
|
79
|
+
...event,
|
|
80
|
+
data: cloneValue(event.data)
|
|
81
|
+
}));
|
|
82
|
+
},
|
|
83
|
+
clear() {
|
|
84
|
+
timeline.length = 0;
|
|
85
|
+
dropped = 0;
|
|
86
|
+
},
|
|
87
|
+
stats() {
|
|
88
|
+
return {
|
|
89
|
+
retained: timeline.length,
|
|
90
|
+
dropped
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
destroy() {
|
|
94
|
+
if (destroyed) return;
|
|
95
|
+
destroyed = true;
|
|
96
|
+
for (const detach of [...detachments]) detach();
|
|
97
|
+
listeners.clear();
|
|
98
|
+
timeline.length = 0;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
return api;
|
|
102
|
+
}
|
|
103
|
+
function limitValue(value, options) {
|
|
104
|
+
const seen = new WeakSet();
|
|
105
|
+
let nodes = 0;
|
|
106
|
+
const visit = (input, key, path, depth) => {
|
|
107
|
+
nodes++;
|
|
108
|
+
if (nodes > options.maxNodes) return "[MAX_NODES]";
|
|
109
|
+
if (isSensitiveKey(key) || options.redact?.({
|
|
110
|
+
key,
|
|
111
|
+
path,
|
|
112
|
+
value: input
|
|
113
|
+
})) return "[REDACTED]";
|
|
114
|
+
if (typeof input === "string") {
|
|
115
|
+
const safe = redactText(input);
|
|
116
|
+
return safe.length > options.maxStringLength ? `${safe.slice(0, options.maxStringLength)}[TRUNCATED]` : safe;
|
|
117
|
+
}
|
|
118
|
+
if (input === null || typeof input === "number" || typeof input === "boolean") return input;
|
|
119
|
+
if (typeof input !== "object") return String(input);
|
|
120
|
+
if (depth >= options.maxDepth) return "[MAX_DEPTH]";
|
|
121
|
+
if (seen.has(input)) return "[CIRCULAR]";
|
|
122
|
+
seen.add(input);
|
|
123
|
+
try {
|
|
124
|
+
if (Array.isArray(input)) return input.map((item, index) => visit(item, "", [...path, index], depth + 1));
|
|
125
|
+
const output = {};
|
|
126
|
+
for (const [childKey, child] of Object.entries(input)) output[childKey] = visit(child, childKey, [...path, childKey], depth + 1);
|
|
127
|
+
return output;
|
|
128
|
+
} finally {
|
|
129
|
+
seen.delete(input);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
return visit(value, "", [], 0);
|
|
133
|
+
}
|
|
134
|
+
function isSensitiveKey(key) {
|
|
135
|
+
const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
136
|
+
return normalized !== "" && DEFAULT_SENSITIVE_KEY_PARTS.some((part) => normalized.includes(part));
|
|
137
|
+
}
|
|
138
|
+
function redactText(value) {
|
|
139
|
+
return value.replace(/\bBearer\s+[^\s,;]+/gi, "Bearer [REDACTED]").replace(/([?&](?:access[_-]?token|refresh[_-]?token|client[_-]?secret|api[_-]?key|password|passwd|token|secret)=)[^&#\s]*/gi, "$1[REDACTED]").replace(/\b((?:access[_-]?token|refresh[_-]?token|client[_-]?secret|api[_-]?key|password|passwd|token|secret)\s*[:=]\s*)[^\s,;]+/gi, "$1[REDACTED]");
|
|
140
|
+
}
|
|
141
|
+
function cloneValue(value) {
|
|
142
|
+
if (Array.isArray(value)) return value.map(cloneValue);
|
|
143
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, cloneValue(child)]));
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
function positiveInteger(value, fallback, name) {
|
|
147
|
+
if (value === void 0) return fallback;
|
|
148
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${name} must be a positive integer`);
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
const STREAM_FIXTURES = Object.freeze({
|
|
152
|
+
markdown: "# Streaming demo\n\nThis paragraph arrives in deterministic chunks.",
|
|
153
|
+
card: "```card:demo\n{\"id\":\"demo-card\",\"title\":\"Fixture card\",\"count\":1}\n```",
|
|
154
|
+
unicode: "UTF-8 boundaries: 你好, مرحبا, 🙂."
|
|
155
|
+
});
|
|
156
|
+
function createStreamSimulator(input, options = {}) {
|
|
157
|
+
const chunkSize = positiveInteger(options.chunkSize, 8, "chunkSize");
|
|
158
|
+
const delayMs = nonNegativeNumber(options.delayMs, 0, "delayMs");
|
|
159
|
+
const bytes = new TextEncoder().encode(input);
|
|
160
|
+
let currentState = "running";
|
|
161
|
+
let offset = 0;
|
|
162
|
+
let wake;
|
|
163
|
+
let timer;
|
|
164
|
+
let settleDelay;
|
|
165
|
+
let pending;
|
|
166
|
+
const waitUntilRunning = async () => {
|
|
167
|
+
while (currentState === "paused") await new Promise((resolve) => {
|
|
168
|
+
wake = resolve;
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
const finishWaits = () => {
|
|
172
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
173
|
+
timer = void 0;
|
|
174
|
+
settleDelay?.();
|
|
175
|
+
settleDelay = void 0;
|
|
176
|
+
wake?.();
|
|
177
|
+
wake = void 0;
|
|
178
|
+
};
|
|
179
|
+
const waitDelay = () => new Promise((resolve) => {
|
|
180
|
+
if (delayMs === 0 || isCancelled()) return resolve();
|
|
181
|
+
settleDelay = resolve;
|
|
182
|
+
timer = setTimeout(() => {
|
|
183
|
+
timer = void 0;
|
|
184
|
+
settleDelay = void 0;
|
|
185
|
+
resolve();
|
|
186
|
+
}, delayMs);
|
|
187
|
+
});
|
|
188
|
+
const iterator = {
|
|
189
|
+
next() {
|
|
190
|
+
if (pending) return pending;
|
|
191
|
+
pending = (async () => {
|
|
192
|
+
await waitUntilRunning();
|
|
193
|
+
if (isCancelled()) return {
|
|
194
|
+
done: true,
|
|
195
|
+
value: void 0
|
|
196
|
+
};
|
|
197
|
+
await waitDelay();
|
|
198
|
+
if (isCancelled()) return {
|
|
199
|
+
done: true,
|
|
200
|
+
value: void 0
|
|
201
|
+
};
|
|
202
|
+
await waitUntilRunning();
|
|
203
|
+
if (isCancelled()) return {
|
|
204
|
+
done: true,
|
|
205
|
+
value: void 0
|
|
206
|
+
};
|
|
207
|
+
if (offset >= bytes.length) {
|
|
208
|
+
currentState = "completed";
|
|
209
|
+
return {
|
|
210
|
+
done: true,
|
|
211
|
+
value: void 0
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
const chunk = bytes.slice(offset, Math.min(offset + chunkSize, bytes.length));
|
|
215
|
+
offset += chunk.length;
|
|
216
|
+
return {
|
|
217
|
+
done: false,
|
|
218
|
+
value: chunk
|
|
219
|
+
};
|
|
220
|
+
})().finally(() => {
|
|
221
|
+
pending = void 0;
|
|
222
|
+
});
|
|
223
|
+
return pending;
|
|
224
|
+
},
|
|
225
|
+
async return() {
|
|
226
|
+
if (currentState !== "completed") currentState = "cancelled";
|
|
227
|
+
finishWaits();
|
|
228
|
+
await pending;
|
|
229
|
+
return {
|
|
230
|
+
done: true,
|
|
231
|
+
value: void 0
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
const stream = { [Symbol.asyncIterator]: () => iterator };
|
|
236
|
+
return {
|
|
237
|
+
stream,
|
|
238
|
+
pause() {
|
|
239
|
+
if (currentState === "running") currentState = "paused";
|
|
240
|
+
},
|
|
241
|
+
resume() {
|
|
242
|
+
if (currentState !== "paused") return;
|
|
243
|
+
currentState = "running";
|
|
244
|
+
wake?.();
|
|
245
|
+
wake = void 0;
|
|
246
|
+
},
|
|
247
|
+
cancel() {
|
|
248
|
+
if (currentState === "completed" || currentState === "cancelled") return;
|
|
249
|
+
currentState = "cancelled";
|
|
250
|
+
finishWaits();
|
|
251
|
+
},
|
|
252
|
+
state() {
|
|
253
|
+
return currentState;
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
function isCancelled() {
|
|
257
|
+
return currentState === "cancelled";
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function nonNegativeNumber(value, fallback, name) {
|
|
261
|
+
if (value === void 0) return fallback;
|
|
262
|
+
if (!Number.isFinite(value) || value < 0) throw new TypeError(`${name} must be a non-negative number`);
|
|
263
|
+
return value;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
//#endregion
|
|
267
|
+
export { STREAM_FIXTURES, createDevTools, createStreamSimulator };
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ai-gui/devtools",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Framework-agnostic runtime timeline and stream simulator for AIGUI.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"aigui",
|
|
7
|
+
"devtools",
|
|
8
|
+
"timeline",
|
|
9
|
+
"debug",
|
|
10
|
+
"streaming"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "Liang Li <ll_faw@hotmail.com>",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/liliang-cn/aigui.git",
|
|
17
|
+
"directory": "packages/devtools"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"main": "./dist/index.cjs",
|
|
25
|
+
"module": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"import": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"require": {
|
|
34
|
+
"types": "./dist/index.d.cts",
|
|
35
|
+
"default": "./dist/index.cjs"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"README.md",
|
|
42
|
+
"LICENSE"
|
|
43
|
+
],
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@ai-gui/core": "0.3.0"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsdown",
|
|
52
|
+
"test": "pnpm --dir ../.. exec vitest run --project devtools",
|
|
53
|
+
"typecheck": "tsc --noEmit"
|
|
54
|
+
}
|
|
55
|
+
}
|