dsh-better-display 0.3.7 → 0.3.9
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/CHANGELOG.md +22 -1
- package/DESIGN.md +43 -3
- package/README.en.md +9 -1
- package/README.md +9 -1
- package/docs/dependency-update-policy.md +9 -0
- package/docs/extension-points.md +172 -0
- package/docs/official-rendering-bridge.md +59 -18
- package/docs/openapi.yaml +147 -0
- package/docs/runbooks/blank-reader.md +39 -0
- package/lib/client.js +1629 -586
- package/lib/client.js.map +1 -1
- package/lib/dsh-better-display.js +535 -57
- package/lib/types/client/AssistantNode.d.ts +20 -0
- package/lib/types/client/AssistantNode.d.ts.map +1 -0
- package/lib/types/client/AssistantNode.js +46 -0
- package/lib/types/client/AssistantNode.js.map +1 -0
- package/lib/types/client/Blocks.d.ts.map +1 -1
- package/lib/types/client/Blocks.js +33 -10
- package/lib/types/client/Blocks.js.map +1 -1
- package/lib/types/client/ChoreographedFlow.d.ts.map +1 -1
- package/lib/types/client/ChoreographedFlow.js +2 -0
- package/lib/types/client/ChoreographedFlow.js.map +1 -1
- package/lib/types/client/ClosedProcessSummary.d.ts.map +1 -1
- package/lib/types/client/ClosedProcessSummary.js +3 -1
- package/lib/types/client/ClosedProcessSummary.js.map +1 -1
- package/lib/types/client/McpAppFrame.d.ts.map +1 -1
- package/lib/types/client/McpAppFrame.js +5 -11
- package/lib/types/client/McpAppFrame.js.map +1 -1
- package/lib/types/client/OfficialContent.d.ts +17 -3
- package/lib/types/client/OfficialContent.d.ts.map +1 -1
- package/lib/types/client/OfficialContent.js +33 -7
- package/lib/types/client/OfficialContent.js.map +1 -1
- package/lib/types/client/Reader.d.ts.map +1 -1
- package/lib/types/client/Reader.js +76 -73
- package/lib/types/client/Reader.js.map +1 -1
- package/lib/types/client/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/SettingsSection.js +0 -5
- package/lib/types/client/SettingsSection.js.map +1 -1
- package/lib/types/client/StickyLane.d.ts +12 -0
- package/lib/types/client/StickyLane.d.ts.map +1 -1
- package/lib/types/client/StickyLane.js +78 -2
- package/lib/types/client/StickyLane.js.map +1 -1
- package/lib/types/client/ToolActivity.d.ts.map +1 -1
- package/lib/types/client/ToolActivity.js +10 -5
- package/lib/types/client/ToolActivity.js.map +1 -1
- package/lib/types/client/deliverables.d.ts.map +1 -1
- package/lib/types/client/deliverables.js +2 -0
- package/lib/types/client/deliverables.js.map +1 -1
- package/lib/types/client/index.d.ts +3 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +15 -1
- package/lib/types/client/index.js.map +1 -1
- package/lib/types/client/live-turn.d.ts +17 -5
- package/lib/types/client/live-turn.d.ts.map +1 -1
- package/lib/types/client/live-turn.js +106 -76
- package/lib/types/client/live-turn.js.map +1 -1
- package/lib/types/client/motion.d.ts +26 -2
- package/lib/types/client/motion.d.ts.map +1 -1
- package/lib/types/client/motion.js +144 -10
- package/lib/types/client/motion.js.map +1 -1
- package/lib/types/client/official-slots.d.ts +9 -0
- package/lib/types/client/official-slots.d.ts.map +1 -1
- package/lib/types/client/official-slots.js +22 -4
- package/lib/types/client/official-slots.js.map +1 -1
- package/lib/types/client/open-file.d.ts.map +1 -1
- package/lib/types/client/open-file.js +1 -6
- package/lib/types/client/open-file.js.map +1 -1
- package/lib/types/client/projection.d.ts +15 -2
- package/lib/types/client/projection.d.ts.map +1 -1
- package/lib/types/client/projection.js +16 -4
- package/lib/types/client/projection.js.map +1 -1
- package/lib/types/client/rewind-cut.d.ts +131 -0
- package/lib/types/client/rewind-cut.d.ts.map +1 -0
- package/lib/types/client/rewind-cut.js +321 -0
- package/lib/types/client/rewind-cut.js.map +1 -0
- package/lib/types/client/styles.d.ts +3 -0
- package/lib/types/client/styles.d.ts.map +1 -0
- package/lib/types/client/styles.js +25 -0
- package/lib/types/client/styles.js.map +1 -0
- package/lib/types/client/text-transform.d.ts +93 -0
- package/lib/types/client/text-transform.d.ts.map +1 -0
- package/lib/types/client/text-transform.js +183 -0
- package/lib/types/client/text-transform.js.map +1 -0
- package/lib/types/client/timeline.d.ts.map +1 -1
- package/lib/types/client/timeline.js.map +1 -1
- package/lib/types/client/tool-activity.d.ts +11 -4
- package/lib/types/client/tool-activity.d.ts.map +1 -1
- package/lib/types/client/tool-activity.js +54 -9
- package/lib/types/client/tool-activity.js.map +1 -1
- package/lib/types/client/turn-slots.d.ts +29 -0
- package/lib/types/client/turn-slots.d.ts.map +1 -0
- package/lib/types/client/turn-slots.js +62 -0
- package/lib/types/client/turn-slots.js.map +1 -0
- package/lib/types/client/types.d.ts +18 -0
- package/lib/types/client/types.d.ts.map +1 -1
- package/lib/types/dsh-better-display.d.ts +4 -1
- package/lib/types/dsh-better-display.d.ts.map +1 -1
- package/lib/types/dsh-better-display.js +55 -43
- package/lib/types/dsh-better-display.js.map +1 -1
- package/lib/types/flags.d.ts +7 -0
- package/lib/types/flags.d.ts.map +1 -0
- package/lib/types/flags.js +37 -0
- package/lib/types/flags.js.map +1 -0
- package/lib/types/log.d.ts +17 -0
- package/lib/types/log.d.ts.map +1 -0
- package/lib/types/log.js +122 -0
- package/lib/types/log.js.map +1 -0
- package/lib/types/metrics.d.ts +18 -0
- package/lib/types/metrics.d.ts.map +1 -0
- package/lib/types/metrics.js +35 -0
- package/lib/types/metrics.js.map +1 -0
- package/lib/types/reveal.d.ts +12 -0
- package/lib/types/reveal.d.ts.map +1 -0
- package/lib/types/reveal.js +147 -0
- package/lib/types/reveal.js.map +1 -0
- package/lib/types/workspace-path.d.ts +3 -0
- package/lib/types/workspace-path.d.ts.map +1 -0
- package/lib/types/workspace-path.js +5 -0
- package/lib/types/workspace-path.js.map +1 -0
- package/package.json +36 -5
- package/scripts/changelog.mjs +20 -0
- package/scripts/check-agents-md.mjs +67 -0
- package/scripts/check-bundle-size.mjs +38 -0
- package/scripts/check-dead-flags.mjs +55 -0
- package/scripts/check-todo-debt.mjs +69 -0
- package/scripts/security-audit-report.mjs +92 -0
- package/scripts/verify-commentary-visible.mjs +627 -0
- package/src/client/AssistantNode.tsx +72 -0
- package/src/client/Blocks.tsx +49 -10
- package/src/client/ChoreographedFlow.tsx +3 -1
- package/src/client/ClosedProcessSummary.tsx +3 -1
- package/src/client/McpAppFrame.tsx +5 -13
- package/src/client/OfficialContent.tsx +47 -9
- package/src/client/Reader.module.css +103 -21
- package/src/client/Reader.tsx +83 -88
- package/src/client/SettingsSection.tsx +1 -7
- package/src/client/StickyLane.tsx +81 -2
- package/src/client/ToolActivity.tsx +10 -6
- package/src/client/deliverables.ts +1 -0
- package/src/client/index.tsx +18 -2
- package/src/client/live-turn.ts +100 -69
- package/src/client/motion.tsx +142 -7
- package/src/client/official-slots.tsx +23 -4
- package/src/client/open-file.ts +2 -8
- package/src/client/projection.ts +16 -4
- package/src/client/rewind-cut.ts +340 -0
- package/src/client/styles.ts +22 -0
- package/src/client/text-transform.ts +265 -0
- package/src/client/timeline.ts +0 -2
- package/src/client/tool-activity.ts +67 -9
- package/src/client/turn-slots.ts +80 -0
- package/src/client/types.ts +19 -0
- package/src/dsh-better-display.ts +57 -42
- package/src/flags.ts +40 -0
- package/src/log.ts +137 -0
- package/src/metrics.ts +50 -0
- package/src/reveal.ts +90 -0
- package/src/workspace-path.ts +4 -0
- package/tests/browser-commentary.tsx +191 -0
- package/tests/chat-flow-hook.test.ts +1 -1
- package/tests/cloud-gate.test.ts +1 -1
- package/tests/context-tool-update.test.ts +1 -1
- package/tests/conversation-scroll.test.ts +1 -1
- package/tests/deliverables.test.ts +1 -1
- package/tests/entry.test.ts +1 -1
- package/tests/fixtures/reader-lane-owners.tsx +46 -0
- package/tests/fixtures/reader-official-users.tsx +234 -0
- package/tests/fixtures/reader-theme-overlap.html +80 -0
- package/tests/fixtures/reader-user-bubble.tsx +222 -0
- package/tests/flags.test.ts +66 -0
- package/tests/fold-choreography.test.ts +32 -8
- package/tests/fold-intensity.test.ts +11 -6
- package/tests/footer-precedence.test.ts +9 -1
- package/tests/history-word-regression.test.ts +1 -1
- package/tests/live-turn.test.ts +125 -54
- package/tests/log.test.ts +99 -0
- package/tests/mcp-apps.test.ts +1 -1
- package/tests/message-chrome.test.ts +1 -1
- package/tests/metrics.test.ts +52 -0
- package/tests/official-slots.test.ts +41 -2
- package/tests/open-file.test.ts +1 -1
- package/tests/pending-submission.test.ts +12 -1
- package/tests/pinned-selection.test.ts +16 -0
- package/tests/platform-media.test.ts +1 -1
- package/tests/projection.test.ts +66 -3
- package/tests/reader-glass-css.test.ts +28 -0
- package/tests/reader-official-users.test.ts +91 -0
- package/tests/reader-sticky-surface.test.ts +377 -0
- package/tests/reader-theme-lanes.test.ts +54 -0
- package/tests/reader-theme-overlap.test.ts +151 -0
- package/tests/reader-user-bubble.test.ts +137 -0
- package/tests/reasoning-follow.test.ts +1 -1
- package/tests/retiring-content.test.ts +229 -0
- package/tests/reveal.test.ts +128 -0
- package/tests/rewind-cut.test.ts +1090 -0
- package/tests/settings-copy.test.ts +1 -1
- package/tests/skill-status.test.ts +1 -1
- package/tests/stock-install.test.ts +9 -1
- package/tests/stream-buffer.test.ts +1 -1
- package/tests/styles.test.ts +75 -0
- package/tests/terminal-input-preview.test.ts +113 -0
- package/tests/text-transform.test.ts +272 -0
- package/tests/timeline.test.ts +1 -1
- package/tests/tool-clock.test.ts +1 -1
- package/tests/tool-diff.test.ts +1 -1
- package/tests/waiting-clock.test.ts +1 -1
- package/tests/word-timeline.test.ts +1 -1
- package/tsdown.config.ts +49 -1
|
@@ -2,6 +2,7 @@ import type { AssistantBlock, ToolCallBlock, TurnLocation } from '@deepseek-ai/d
|
|
|
2
2
|
import type { ChatConversationViewNode } from '@deepseek-ai/dsh-client-ui-chat/client';
|
|
3
3
|
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client';
|
|
4
4
|
import type { ReaderGroup } from './projection.js';
|
|
5
|
+
import { anchorSeqOf, cutContains, rewindCutSpans, type CutChatNode } from './rewind-cut.js';
|
|
5
6
|
|
|
6
7
|
export type ToolDraft = Extract<AssistantBlock, { kind: 'tool-call' }>;
|
|
7
8
|
export type ToolPhase = 'preparing' | 'running' | 'returned' | 'succeeded' | 'failed' | 'interrupted';
|
|
@@ -12,11 +13,52 @@ export interface ToolActivityEntry {
|
|
|
12
13
|
}
|
|
13
14
|
export type ReaderFlowEntry = ToolActivityEntry | { kind: 'node'; key: string; nodeKey: string; order: number };
|
|
14
15
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
16
|
+
/**
|
|
17
|
+
* callId of a tool-call chat node. Drafts for a call whose node was cut must
|
|
18
|
+
* not be reintroduced after `group.keys` has already dropped that node.
|
|
19
|
+
*/
|
|
20
|
+
function toolCallId(node: ChatConversationViewNode): string | undefined {
|
|
21
|
+
const callId = (node.data as { root?: { callId?: unknown } } | null)?.root?.callId;
|
|
22
|
+
return typeof callId === 'string' && callId.length > 0 ? callId : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** callIds whose tool-call node sits inside a rewind cut. */
|
|
26
|
+
function cutToolCallIds(
|
|
27
|
+
order: readonly string[],
|
|
28
|
+
get: (key: string) => ChatConversationViewNode | undefined,
|
|
29
|
+
spans: readonly { readonly start: number; readonly end: number }[],
|
|
30
|
+
): Set<string> {
|
|
31
|
+
const ids = new Set<string>();
|
|
32
|
+
for (const key of order) {
|
|
33
|
+
const node = get(key);
|
|
34
|
+
if (node?.kind !== 'tool-call') continue;
|
|
35
|
+
const seq = anchorSeqOf(node as CutChatNode);
|
|
36
|
+
if (seq === undefined || !cutContains(spans, seq)) continue;
|
|
37
|
+
const callId = toolCallId(node);
|
|
38
|
+
if (callId !== undefined) ids.add(callId);
|
|
39
|
+
}
|
|
40
|
+
return ids;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Public Step data includes tool-only model output before a chat node exists.
|
|
45
|
+
*
|
|
46
|
+
* `sourceOrder` is the unfiltered chat order. Drafts that belong to a cut step,
|
|
47
|
+
* or to a call whose tool-call node was cut, are not put back. A draft that
|
|
48
|
+
* still has no tool-call node, on a step outside the cut, stays — that is the
|
|
49
|
+
* in-flight call the host has not published a node for yet.
|
|
50
|
+
*/
|
|
51
|
+
export function readerFlow(
|
|
52
|
+
group: ReaderGroup,
|
|
53
|
+
turn: TurnLocation | undefined,
|
|
54
|
+
get: (key: string) => ChatConversationViewNode | undefined,
|
|
55
|
+
sourceOrder?: readonly string[],
|
|
56
|
+
): ReaderFlowEntry[] {
|
|
17
57
|
const flow: ReaderFlowEntry[] = [];
|
|
18
58
|
const calls = new Map<string, ToolActivityEntry>();
|
|
19
59
|
const assistantOrder = new Map<number, number>();
|
|
60
|
+
const spans = sourceOrder === undefined ? [] : rewindCutSpans({ order: sourceOrder, nodes: { get } });
|
|
61
|
+
const cutCalls = spans.length === 0 || sourceOrder === undefined ? undefined : cutToolCallIds(sourceOrder, get, spans);
|
|
20
62
|
for (const key of group.keys) {
|
|
21
63
|
const node = get(key);
|
|
22
64
|
if (!node || node.visibility === 'hidden') continue;
|
|
@@ -31,10 +73,15 @@ export function readerFlow(group: ReaderGroup, turn: TurnLocation | undefined, g
|
|
|
31
73
|
}
|
|
32
74
|
for (const step of turn?.steps ?? []) {
|
|
33
75
|
const data = step.data.get('assistant-step');
|
|
76
|
+
const stepSeq = step.start?.seq;
|
|
77
|
+
const stepCut = typeof stepSeq === 'number' && spans.length > 0 && cutContains(spans, stepSeq);
|
|
34
78
|
let index = 0;
|
|
35
79
|
for (const draft of data?.blocks ?? []) {
|
|
36
80
|
if (draft.kind !== 'tool-call' || !draft.callId) continue;
|
|
37
81
|
const previous = calls.get(draft.callId);
|
|
82
|
+
// A visible tool row still receives its draft. A new row is the streaming
|
|
83
|
+
// case — unless this step, or this call's tool-call node, was cut.
|
|
84
|
+
if (!previous && (stepCut || cutCalls?.has(draft.callId) === true)) continue;
|
|
38
85
|
calls.set(draft.callId, previous ? { ...previous, draft } : {
|
|
39
86
|
kind: 'tool', key: `reader-tool:${draft.callId}`, callId: draft.callId, step: step.step, draft,
|
|
40
87
|
order: (assistantOrder.get(step.step) ?? step.start?.seq ?? 0) + .01 + index++ / 10000,
|
|
@@ -53,10 +100,15 @@ export function stringValue(record: Record<string, unknown> | null, ...keys: str
|
|
|
53
100
|
return undefined;
|
|
54
101
|
}
|
|
55
102
|
|
|
103
|
+
/** Top-level fields from complete or in-flight JSON tool arguments. */
|
|
104
|
+
type ParsedInputFields =
|
|
105
|
+
| { kind: 'complete'; fields: Record<string, unknown> }
|
|
106
|
+
| { kind: 'partial'; fields: Record<string, unknown> };
|
|
107
|
+
|
|
56
108
|
/** Read only top-level JSON string values, including an unfinished final string.
|
|
57
109
|
* This never executes input or mistakes escaped/nested content for a path field. */
|
|
58
|
-
|
|
59
|
-
try { return objectValue(JSON.parse(raw)) ?? {}; } catch { /* an in-flight argument is normally incomplete */ }
|
|
110
|
+
function parseInputFields(raw: string): ParsedInputFields {
|
|
111
|
+
try { return { kind: 'complete', fields: objectValue(JSON.parse(raw)) ?? {} }; } catch { /* an in-flight argument is normally incomplete */ }
|
|
60
112
|
const fields: Record<string, unknown> = Object.create(null);
|
|
61
113
|
const prefix = raw.slice(0, 262144);
|
|
62
114
|
let depth = 0;
|
|
@@ -88,14 +140,19 @@ export function inputFields(raw: string): Record<string, unknown> {
|
|
|
88
140
|
if (closed && /^\s*:/.test(prefix.slice(index + 1))) key = value;
|
|
89
141
|
else if (key !== undefined) { fields[key] = value; key = undefined; }
|
|
90
142
|
}
|
|
91
|
-
return fields;
|
|
143
|
+
return { kind: 'partial', fields };
|
|
92
144
|
}
|
|
93
145
|
|
|
146
|
+
export function inputFields(raw: string): Record<string, unknown> {
|
|
147
|
+
return parseInputFields(raw).fields;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Name and raw arguments of any call block, whether it has landed or is pending. */
|
|
94
151
|
export function toolIdentity(entry: Pick<ToolActivityEntry, 'block' | 'draft'>) {
|
|
95
152
|
const block = entry.block;
|
|
96
153
|
return {
|
|
97
154
|
name: block ? 'kind' in block ? block.call?.name ?? entry.draft?.name ?? '工具调用' : block.name : entry.draft?.name ?? '工具调用',
|
|
98
|
-
raw: block ? 'kind' in block ? block.call?.argsRaw ?? entry.draft?.argsRaw ?? '' : ('argsRaw' in block ? block.argsRaw : '') : entry.draft?.argsRaw ?? '',
|
|
155
|
+
raw: block ? 'kind' in block ? block.call?.argsRaw ?? entry.draft?.argsRaw ?? '' : ('argsRaw' in block ? block.argsRaw : entry.draft?.argsRaw ?? '') : entry.draft?.argsRaw ?? '',
|
|
99
156
|
};
|
|
100
157
|
}
|
|
101
158
|
|
|
@@ -254,7 +311,7 @@ export function foldDiffHunks(steps: readonly { kind: string; entry?: ToolActivi
|
|
|
254
311
|
|
|
255
312
|
export function activityPhase(entry: Pick<ToolActivityEntry, 'block' | 'draft'>, turnClosed = false): ToolPhase {
|
|
256
313
|
if (!entry.block) return turnClosed ? 'interrupted' : 'preparing';
|
|
257
|
-
if (!('kind' in entry.block)) return turnClosed ? 'interrupted' : 'running';
|
|
314
|
+
if (!('kind' in entry.block)) return turnClosed ? 'interrupted' : entry.block.phase === 'preparing' ? 'preparing' : 'running';
|
|
258
315
|
// RC1 publishes canonical cancellation as an error result with a typed code.
|
|
259
316
|
if (entry.block.error?.code === 'ABORTED' || entry.block.error?.code === 'interrupted') return 'interrupted';
|
|
260
317
|
const facts = executionFacts(entry.block);
|
|
@@ -308,7 +365,8 @@ export function forgetCallClock(callId: string): void {
|
|
|
308
365
|
|
|
309
366
|
export function activitySummary(entry: Pick<ToolActivityEntry, 'block' | 'draft'>) {
|
|
310
367
|
const { name, raw } = toolIdentity(entry);
|
|
311
|
-
const
|
|
368
|
+
const parsed = parseInputFields(raw);
|
|
369
|
+
const args = parsed.fields;
|
|
312
370
|
const target = stringValue(args, 'file_path', 'path', 'filename', 'filePath');
|
|
313
371
|
const command = stringValue(args, 'command', 'cmd', 'script');
|
|
314
372
|
const description = stringValue(args, 'description');
|
|
@@ -331,7 +389,7 @@ export function activitySummary(entry: Pick<ToolActivityEntry, 'block' | 'draft'
|
|
|
331
389
|
: category === 'web' ? name === 'web_search' ? '搜索网页' : '读取网页'
|
|
332
390
|
: category === 'code' ? (description || '运行代码')
|
|
333
391
|
: name;
|
|
334
|
-
return { name, raw, args, category, title, target: target ?? command ?? stringValue(args, 'query', 'pattern', 'url'), command,
|
|
392
|
+
return { name, raw, args, argsState: parsed.kind, category, title, target: target ?? command ?? stringValue(args, 'query', 'pattern', 'url'), command,
|
|
335
393
|
cwd: stringValue(args, 'workdir', 'cwd'), content: stringValue(args, 'content', 'new_string', 'newText', 'file_text') };
|
|
336
394
|
}
|
|
337
395
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** One turn lane: `live` while it is still in the visible order. */
|
|
2
|
+
export interface TurnSlot<T extends { readonly key: string }> {
|
|
3
|
+
readonly group: T;
|
|
4
|
+
readonly live: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Keep live turns in the incoming `groups` order, and keep a departing turn
|
|
9
|
+
* next to the live neighbour that preceded it.
|
|
10
|
+
*
|
|
11
|
+
* A rewind removes turn keys from the visible order. Unmounting them in that
|
|
12
|
+
* same commit snaps the transcript, so the lane retains the last group object
|
|
13
|
+
* until the collapse finishes. A re-render, a stream tick, or a reconnect that
|
|
14
|
+
* still contains the same keys only replaces the group object and leaves
|
|
15
|
+
* `live` true — those updates must not start a collapse. A key that returns
|
|
16
|
+
* is live again where `groups` places it. Loading older history therefore
|
|
17
|
+
* stays in transcript order instead of appending the older turns after the
|
|
18
|
+
* ones already on screen. A departing slot is inserted immediately after the
|
|
19
|
+
* nearest preceding slot that is still live, or at the front when it had none,
|
|
20
|
+
* so several departing neighbours keep their relative order.
|
|
21
|
+
*
|
|
22
|
+
* Returns `current` itself when nothing changed, so a React state update can
|
|
23
|
+
* bail out on `Object.is`.
|
|
24
|
+
*/
|
|
25
|
+
export function reconcileTurnSlots<T extends { readonly key: string }>(
|
|
26
|
+
current: readonly TurnSlot<T>[],
|
|
27
|
+
groups: readonly T[],
|
|
28
|
+
): readonly TurnSlot<T>[] {
|
|
29
|
+
const incoming = new Map(groups.map(group => [group.key, group]));
|
|
30
|
+
const next: TurnSlot<T>[] = groups.map(group => ({ group, live: true }));
|
|
31
|
+
for (const slot of current) {
|
|
32
|
+
if (incoming.has(slot.group.key)) continue;
|
|
33
|
+
insertDeparting(next, { group: slot.group, live: false }, precedingLiveKey(current, incoming, slot.group.key));
|
|
34
|
+
}
|
|
35
|
+
const same =
|
|
36
|
+
next.length === current.length &&
|
|
37
|
+
next.every(
|
|
38
|
+
(slot, index) => slot.live === current[index]!.live && slot.group === current[index]!.group,
|
|
39
|
+
);
|
|
40
|
+
return same ? current : next;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Nearest preceding slot that is still in the incoming order, if any. */
|
|
44
|
+
function precedingLiveKey<T extends { readonly key: string }>(
|
|
45
|
+
current: readonly TurnSlot<T>[],
|
|
46
|
+
incoming: ReadonlyMap<string, T>,
|
|
47
|
+
key: string,
|
|
48
|
+
): string | undefined {
|
|
49
|
+
const index = current.findIndex(slot => slot.group.key === key);
|
|
50
|
+
for (let cursor = index - 1; cursor >= 0; cursor--) {
|
|
51
|
+
const candidate = current[cursor]!.group.key;
|
|
52
|
+
if (incoming.has(candidate)) return candidate;
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Place a departing slot after `anchorKey`, or at the front when the slot had
|
|
59
|
+
* no preceding live neighbour. Slots that share an anchor keep `current` order
|
|
60
|
+
* because each one is inserted at the end of the departing run after that anchor.
|
|
61
|
+
*/
|
|
62
|
+
function insertDeparting<T extends { readonly key: string }>(
|
|
63
|
+
next: TurnSlot<T>[],
|
|
64
|
+
slot: TurnSlot<T>,
|
|
65
|
+
anchorKey: string | undefined,
|
|
66
|
+
): void {
|
|
67
|
+
if (anchorKey === undefined) {
|
|
68
|
+
const firstLive = next.findIndex(item => item.live);
|
|
69
|
+
next.splice(firstLive < 0 ? next.length : firstLive, 0, slot);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const anchorIndex = next.findIndex(item => item.group.key === anchorKey);
|
|
73
|
+
if (anchorIndex < 0) {
|
|
74
|
+
next.unshift(slot);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
let insertAt = anchorIndex + 1;
|
|
78
|
+
while (insertAt < next.length && next[insertAt]?.live === false) insertAt++;
|
|
79
|
+
next.splice(insertAt, 0, slot);
|
|
80
|
+
}
|
package/src/client/types.ts
CHANGED
|
@@ -10,10 +10,29 @@ import type { OfficialSeat } from './official-slots.js';
|
|
|
10
10
|
import type { OpenFileOptions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client';
|
|
11
11
|
import type {} from '@deepseek-ai/dsh-client-ui-tool/client';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Display inputs the Reader already computed for this block.
|
|
15
|
+
* Older callers may omit the object. Registrants must tolerate that.
|
|
16
|
+
*/
|
|
17
|
+
export interface ReaderBlockPresentation {
|
|
18
|
+
/** Assistant step time, when the Reader has one. */
|
|
19
|
+
startedAt?: number;
|
|
20
|
+
/** True when this step was interrupted. */
|
|
21
|
+
interrupted?: boolean;
|
|
22
|
+
/** True when this block may run the live reveal. */
|
|
23
|
+
liveText?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
13
26
|
export interface ReaderBlockOwner {
|
|
14
27
|
block: AssistantBlock;
|
|
15
28
|
streaming: boolean;
|
|
16
29
|
source: 'assistant' | 'user' | 'tool';
|
|
30
|
+
/** Viewport and motion inputs. Absent on owners built before this field existed. */
|
|
31
|
+
presentation?: ReaderBlockPresentation;
|
|
32
|
+
/** Selection is holding Markdown formatting in place. */
|
|
33
|
+
holdFormatting?: boolean;
|
|
34
|
+
/** Same value as `presentation.liveText`, repeated so a registrant can read it directly. */
|
|
35
|
+
liveText?: boolean;
|
|
17
36
|
}
|
|
18
37
|
|
|
19
38
|
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
2
|
import type { Context } from '@deepseek-ai/cordis';
|
|
3
|
+
import { loadFlags, type Flags } from './flags.js';
|
|
4
|
+
import { createLogger, requestIdOf } from './log.js';
|
|
5
|
+
import { metricsPayload, recordRouteDuration, recordRouteHit, ROUTE_METRICS, ROUTE_REVEAL, ROUTE_SKILL_STATUS } from './metrics.js';
|
|
6
|
+
import { createRevealHandler, revealRoots } from './reveal.js';
|
|
4
7
|
import { scanGenerativeMcpappsStatus } from './skill-roots.js';
|
|
5
8
|
import { skillsFromListResult, toPublicSkillStatus } from './skill-status.js';
|
|
6
9
|
|
|
@@ -19,6 +22,8 @@ declare module '@deepseek-ai/cordis' {
|
|
|
19
22
|
export const name = 'dsh-better-display';
|
|
20
23
|
export const inject = ['webServer'];
|
|
21
24
|
|
|
25
|
+
const log = createLogger({ base: { plugin: name } });
|
|
26
|
+
|
|
22
27
|
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
23
28
|
res.setHeader('Content-Type', 'application/json');
|
|
24
29
|
res.statusCode = status;
|
|
@@ -31,56 +36,48 @@ function skillLister(ctx: Context): (() => Promise<readonly { readonly name?: st
|
|
|
31
36
|
return async () => skillsFromListResult(await skills.list!({}));
|
|
32
37
|
}
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
function beginRequest(req: IncomingMessage, res: ServerResponse, route: string): string {
|
|
40
|
+
const requestId = requestIdOf(req);
|
|
41
|
+
res.setHeader('X-Request-ID', requestId);
|
|
42
|
+
recordRouteHit(route);
|
|
43
|
+
const started = performance.now();
|
|
44
|
+
res.on('finish', () => {
|
|
45
|
+
recordRouteDuration(route, performance.now() - started);
|
|
46
|
+
});
|
|
47
|
+
log.info('http request', {
|
|
48
|
+
requestId,
|
|
49
|
+
route,
|
|
50
|
+
method: req.method ?? '',
|
|
51
|
+
url: req.url ?? '',
|
|
52
|
+
headers: req.headers,
|
|
53
|
+
});
|
|
54
|
+
return requestId;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function apply(ctx: Context, options?: { readonly flags?: Flags }): void {
|
|
58
|
+
const flags = options?.flags ?? loadFlags();
|
|
59
|
+
log.info('[my-plugins/dsh-better-display] loaded', { readerEnabled: flags.readerEnabled });
|
|
60
|
+
if (!flags.readerEnabled) {
|
|
61
|
+
log.info('route registration skipped', { readerEnabled: false });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
36
64
|
|
|
37
65
|
if (ctx.webServer) {
|
|
38
66
|
ctx.effect(() => {
|
|
67
|
+
const reveal = createRevealHandler({ roots: sessionId => revealRoots(ctx, sessionId) });
|
|
39
68
|
const disposeReveal = ctx.webServer!.register({
|
|
40
69
|
kind: 'exact',
|
|
41
|
-
path:
|
|
42
|
-
handler:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
res.end();
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
let body = '';
|
|
49
|
-
req.on('data', chunk => { body += chunk; });
|
|
50
|
-
req.on('end', () => {
|
|
51
|
-
try {
|
|
52
|
-
const data = JSON.parse(body);
|
|
53
|
-
const targetPath = typeof data.path === 'string' ? data.path.trim() : '';
|
|
54
|
-
if (!targetPath) {
|
|
55
|
-
res.statusCode = 400;
|
|
56
|
-
res.end(JSON.stringify({ ok: false, error: 'Empty path' }));
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (process.platform === 'darwin') {
|
|
61
|
-
// Exact file reveal in macOS Finder with selection highlight
|
|
62
|
-
spawn('open', ['-R', targetPath], { detached: true, stdio: 'ignore' });
|
|
63
|
-
} else if (process.platform === 'win32') {
|
|
64
|
-
// Exact file selection in Windows Explorer
|
|
65
|
-
spawn('explorer.exe', [`/select,${targetPath}`], { detached: true, stdio: 'ignore' });
|
|
66
|
-
} else {
|
|
67
|
-
spawn('xdg-open', [targetPath], { detached: true, stdio: 'ignore' });
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
res.setHeader('Content-Type', 'application/json');
|
|
71
|
-
res.statusCode = 200;
|
|
72
|
-
res.end(JSON.stringify({ ok: true }));
|
|
73
|
-
} catch (err) {
|
|
74
|
-
res.statusCode = 400;
|
|
75
|
-
res.end(JSON.stringify({ ok: false, error: String(err) }));
|
|
76
|
-
}
|
|
77
|
-
});
|
|
70
|
+
path: ROUTE_REVEAL,
|
|
71
|
+
handler: (req, res) => {
|
|
72
|
+
beginRequest(req, res, ROUTE_REVEAL);
|
|
73
|
+
reveal(req, res);
|
|
78
74
|
},
|
|
79
75
|
});
|
|
80
76
|
const disposeSkill = ctx.webServer!.register({
|
|
81
77
|
kind: 'exact',
|
|
82
|
-
path:
|
|
78
|
+
path: ROUTE_SKILL_STATUS,
|
|
83
79
|
handler: async (req, res) => {
|
|
80
|
+
const requestId = beginRequest(req, res, ROUTE_SKILL_STATUS);
|
|
84
81
|
if (req.method !== 'GET') {
|
|
85
82
|
res.statusCode = 405;
|
|
86
83
|
res.end();
|
|
@@ -95,14 +92,32 @@ export function apply(ctx: Context): void {
|
|
|
95
92
|
});
|
|
96
93
|
writeJson(res, 200, toPublicSkillStatus(status));
|
|
97
94
|
} catch (err) {
|
|
95
|
+
log.error('skill-status failed', {
|
|
96
|
+
requestId,
|
|
97
|
+
error: err instanceof Error ? err.message : String(err),
|
|
98
|
+
});
|
|
98
99
|
writeJson(res, 500, { ok: false, error: String(err) });
|
|
99
100
|
}
|
|
100
101
|
},
|
|
101
102
|
});
|
|
103
|
+
const disposeMetrics = ctx.webServer!.register({
|
|
104
|
+
kind: 'exact',
|
|
105
|
+
path: ROUTE_METRICS,
|
|
106
|
+
handler: (req, res) => {
|
|
107
|
+
beginRequest(req, res, ROUTE_METRICS);
|
|
108
|
+
if (req.method !== 'GET') {
|
|
109
|
+
res.statusCode = 405;
|
|
110
|
+
res.end();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
writeJson(res, 200, metricsPayload());
|
|
114
|
+
},
|
|
115
|
+
});
|
|
102
116
|
return () => {
|
|
103
117
|
disposeReveal();
|
|
104
118
|
disposeSkill();
|
|
119
|
+
disposeMetrics();
|
|
105
120
|
};
|
|
106
|
-
}, 'dsh-better-display: reveal
|
|
121
|
+
}, 'dsh-better-display: reveal, skill-status, and metrics routes');
|
|
107
122
|
}
|
|
108
123
|
}
|
package/src/flags.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
/** Startup kill switch loaded from flags.json. This is not the reader glass setting. */
|
|
6
|
+
export interface Flags {
|
|
7
|
+
readonly readerEnabled: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const defaultFlags: Flags = { readerEnabled: true };
|
|
11
|
+
|
|
12
|
+
function locateFlags(start = dirname(fileURLToPath(import.meta.url))): string | undefined {
|
|
13
|
+
let dir = start;
|
|
14
|
+
for (let depth = 0; depth < 6; depth += 1) {
|
|
15
|
+
const candidate = resolve(dir, 'flags.json');
|
|
16
|
+
if (existsSync(candidate)) return candidate;
|
|
17
|
+
const parent = dirname(dir);
|
|
18
|
+
if (parent === dir) return undefined;
|
|
19
|
+
dir = parent;
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalize(value: unknown): Flags {
|
|
25
|
+
const record = value !== null && typeof value === 'object' ? value as { readerEnabled?: unknown } : {};
|
|
26
|
+
return {
|
|
27
|
+
readerEnabled: typeof record.readerEnabled === 'boolean' ? record.readerEnabled : defaultFlags.readerEnabled,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function loadFlags(source?: string | Flags): Flags {
|
|
32
|
+
if (source !== undefined && typeof source !== 'string') return normalize(source);
|
|
33
|
+
try {
|
|
34
|
+
const path = typeof source === 'string' ? source : locateFlags();
|
|
35
|
+
if (path === undefined || !existsSync(path)) return { ...defaultFlags };
|
|
36
|
+
return normalize(JSON.parse(readFileSync(path, 'utf8')) as unknown);
|
|
37
|
+
} catch {
|
|
38
|
+
return { ...defaultFlags };
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { IncomingMessage } from 'node:http';
|
|
3
|
+
|
|
4
|
+
export const REDACTED = '[REDACTED]';
|
|
5
|
+
|
|
6
|
+
const REQUEST_ID_HEADER = 'x-request-id';
|
|
7
|
+
const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,200}$/;
|
|
8
|
+
|
|
9
|
+
const SECRET_KEYS = new Set([
|
|
10
|
+
'authorization',
|
|
11
|
+
'proxyauthorization',
|
|
12
|
+
'cookie',
|
|
13
|
+
'setcookie',
|
|
14
|
+
'token',
|
|
15
|
+
'accesstoken',
|
|
16
|
+
'refreshtoken',
|
|
17
|
+
'idtoken',
|
|
18
|
+
'apikey',
|
|
19
|
+
'apisecret',
|
|
20
|
+
'secret',
|
|
21
|
+
'clientsecret',
|
|
22
|
+
'password',
|
|
23
|
+
'passwd',
|
|
24
|
+
'credential',
|
|
25
|
+
'credentials',
|
|
26
|
+
'signature',
|
|
27
|
+
'sig',
|
|
28
|
+
'jwt',
|
|
29
|
+
'bearer',
|
|
30
|
+
'privatekey',
|
|
31
|
+
'xapikey',
|
|
32
|
+
'xauthtoken',
|
|
33
|
+
'xaccesstoken',
|
|
34
|
+
'auth',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export type LogFields = Record<string, unknown>;
|
|
38
|
+
|
|
39
|
+
export interface Logger {
|
|
40
|
+
info(msg: string, fields?: LogFields): void;
|
|
41
|
+
warn(msg: string, fields?: LogFields): void;
|
|
42
|
+
error(msg: string, fields?: LogFields): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface LoggerOptions {
|
|
46
|
+
readonly write?: (line: string) => void;
|
|
47
|
+
readonly base?: LogFields;
|
|
48
|
+
readonly clock?: () => Date;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeKey(key: string): string {
|
|
52
|
+
return key.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isSecretKey(key: string): boolean {
|
|
56
|
+
const normalized = normalizeKey(key);
|
|
57
|
+
if (SECRET_KEYS.has(normalized)) return true;
|
|
58
|
+
if (normalized === 'requestid' || normalized === 'xrequestid') return false;
|
|
59
|
+
return /(?:^|[^a-z])(?:token|secret|password|api[-_]?key)$/i.test(key);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function redactString(value: string): string {
|
|
63
|
+
const query = value.replace(/([?&#])([^&#=]+)=([^&#]*)/g, (match, sep: string, rawKey: string) => {
|
|
64
|
+
let key: string;
|
|
65
|
+
try {
|
|
66
|
+
key = decodeURIComponent(rawKey.replace(/\+/g, ' '));
|
|
67
|
+
} catch {
|
|
68
|
+
key = rawKey;
|
|
69
|
+
}
|
|
70
|
+
return isSecretKey(key) ? `${sep}${rawKey}=${REDACTED}` : match;
|
|
71
|
+
});
|
|
72
|
+
return query
|
|
73
|
+
.replace(/\b(Bearer|Basic)\s+\S+/gi, `$1 ${REDACTED}`)
|
|
74
|
+
.replace(
|
|
75
|
+
/\b((?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|client[_-]?secret|password|passwd|authorization|token|secret))(\s*[:=]\s*)([^\s&]+)/gi,
|
|
76
|
+
`$1$2${REDACTED}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function redact(value: unknown): unknown {
|
|
81
|
+
return redactValue(undefined, value, 0, new WeakSet());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function redactValue(key: string | undefined, value: unknown, depth: number, seen: WeakSet<object>): unknown {
|
|
85
|
+
if (key !== undefined && isSecretKey(key)) return REDACTED;
|
|
86
|
+
if (depth > 8) return REDACTED;
|
|
87
|
+
if (typeof value === 'string') return redactString(value);
|
|
88
|
+
if (typeof value === 'number' || typeof value === 'boolean' || value === null || value === undefined) return value;
|
|
89
|
+
if (typeof value !== 'object') return REDACTED;
|
|
90
|
+
if (value instanceof Date) return value.toISOString();
|
|
91
|
+
if (value instanceof Error) {
|
|
92
|
+
return redactValue(undefined, { name: value.name, message: value.message }, depth + 1, seen);
|
|
93
|
+
}
|
|
94
|
+
if (seen.has(value)) return REDACTED;
|
|
95
|
+
seen.add(value);
|
|
96
|
+
if (Array.isArray(value)) {
|
|
97
|
+
return value.map(item => redactValue(undefined, item, depth + 1, seen));
|
|
98
|
+
}
|
|
99
|
+
const output: Record<string, unknown> = {};
|
|
100
|
+
for (const [childKey, childValue] of Object.entries(value)) {
|
|
101
|
+
output[childKey] = redactValue(childKey, childValue, depth + 1, seen);
|
|
102
|
+
}
|
|
103
|
+
return output;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function createLogger(options: LoggerOptions = {}): Logger {
|
|
107
|
+
const write = options.write ?? ((line: string) => {
|
|
108
|
+
console.log(line);
|
|
109
|
+
});
|
|
110
|
+
const clock = options.clock ?? (() => new Date());
|
|
111
|
+
const emit = (level: 'info' | 'warn' | 'error', msg: string, fields?: LogFields): void => {
|
|
112
|
+
const details = redact({ ...options.base, ...fields });
|
|
113
|
+
const record = {
|
|
114
|
+
...(details !== null && typeof details === 'object' ? details as LogFields : {}),
|
|
115
|
+
time: clock().toISOString(),
|
|
116
|
+
level,
|
|
117
|
+
msg: redactString(msg),
|
|
118
|
+
};
|
|
119
|
+
try {
|
|
120
|
+
write(JSON.stringify(record));
|
|
121
|
+
} catch {
|
|
122
|
+
write(JSON.stringify({ time: record.time, level, msg: record.msg, error: 'log record was not serializable' }));
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
info: (msg, fields) => emit('info', msg, fields),
|
|
127
|
+
warn: (msg, fields) => emit('warn', msg, fields),
|
|
128
|
+
error: (msg, fields) => emit('error', msg, fields),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function requestIdOf(req: IncomingMessage): string {
|
|
133
|
+
const raw = req.headers[REQUEST_ID_HEADER];
|
|
134
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
135
|
+
if (typeof value === 'string' && REQUEST_ID_PATTERN.test(value)) return value;
|
|
136
|
+
return randomUUID();
|
|
137
|
+
}
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export const ROUTE_REVEAL = '/better-display/reveal';
|
|
2
|
+
export const ROUTE_SKILL_STATUS = '/better-display/skill-status';
|
|
3
|
+
export const ROUTE_METRICS = '/better-display/metrics';
|
|
4
|
+
|
|
5
|
+
export const ROUTE_HITS = [ROUTE_REVEAL, ROUTE_SKILL_STATUS, ROUTE_METRICS] as const;
|
|
6
|
+
|
|
7
|
+
export interface RouteTiming {
|
|
8
|
+
count: number;
|
|
9
|
+
totalMs: number;
|
|
10
|
+
maxMs: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const hits = new Map<string, number>(ROUTE_HITS.map(route => [route, 0]));
|
|
14
|
+
const timings = new Map<string, RouteTiming>(ROUTE_HITS.map(route => [route, { count: 0, totalMs: 0, maxMs: 0 }]));
|
|
15
|
+
|
|
16
|
+
function snapshotHits(): Record<string, number> {
|
|
17
|
+
return Object.fromEntries(hits);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function snapshotTimings(): Record<string, RouteTiming> {
|
|
21
|
+
return Object.fromEntries([...timings].map(([route, timing]) => [route, { ...timing }]));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function recordRouteHit(route: string): number {
|
|
25
|
+
const next = (hits.get(route) ?? 0) + 1;
|
|
26
|
+
hits.set(route, next);
|
|
27
|
+
return next;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function recordRouteDuration(route: string, elapsedMs: number): RouteTiming {
|
|
31
|
+
const ms = Number.isFinite(elapsedMs) && elapsedMs > 0 ? Math.round(elapsedMs) : 0;
|
|
32
|
+
const current = timings.get(route) ?? { count: 0, totalMs: 0, maxMs: 0 };
|
|
33
|
+
const next = {
|
|
34
|
+
count: current.count + 1,
|
|
35
|
+
totalMs: current.totalMs + ms,
|
|
36
|
+
maxMs: Math.max(current.maxMs, ms),
|
|
37
|
+
};
|
|
38
|
+
timings.set(route, next);
|
|
39
|
+
return next;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function metricsPayload(): {
|
|
43
|
+
counters: { dsh_better_display_route_hits_total: Record<string, number> };
|
|
44
|
+
timingsMs: Record<string, RouteTiming>;
|
|
45
|
+
} {
|
|
46
|
+
return {
|
|
47
|
+
counters: { dsh_better_display_route_hits_total: snapshotHits() },
|
|
48
|
+
timingsMs: snapshotTimings(),
|
|
49
|
+
};
|
|
50
|
+
}
|