@runtypelabs/persona 3.37.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -5
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-DgYsuwXL.d.cts → types-B_xbfvR0.d.cts} +263 -181
- package/dist/animations/{types-DgYsuwXL.d.ts → types-B_xbfvR0.d.ts} +263 -181
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/index.cjs +54 -52
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +328 -839
- package/dist/index.d.ts +328 -839
- package/dist/index.global.js +41 -39
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +54 -52
- package/dist/index.js.map +1 -1
- package/dist/install.global.js +1 -1
- package/dist/install.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +269 -192
- package/dist/smart-dom-reader.d.ts +269 -192
- package/dist/theme-editor-preview.cjs +55 -53
- package/dist/theme-editor-preview.d.cts +269 -192
- package/dist/theme-editor-preview.d.ts +269 -192
- package/dist/theme-editor-preview.js +57 -55
- package/dist/theme-editor.d.cts +269 -192
- package/dist/theme-editor.d.ts +269 -192
- package/dist/widget.css +14 -0
- package/package.json +1 -1
- package/src/client.test.ts +446 -1041
- package/src/client.ts +684 -967
- package/src/components/message-bubble.ts +7 -0
- package/src/components/tool-bubble.ts +46 -33
- package/src/generated/runtype-openapi-contract.ts +210 -714
- package/src/index-core.ts +2 -3
- package/src/install.test.ts +1 -34
- package/src/install.ts +1 -26
- package/src/runtime/init.test.ts +8 -67
- package/src/runtime/init.ts +2 -17
- package/src/session.test.ts +8 -8
- package/src/session.ts +1 -1
- package/src/styles/widget.css +14 -0
- package/src/types.ts +12 -13
- package/src/ui.postprocess.test.ts +107 -0
- package/src/ui.ts +45 -5
- package/src/utils/__fixtures__/unified-translator.oracle.ts +62 -14
- package/src/utils/copy-selection.test.ts +37 -0
- package/src/utils/copy-selection.ts +19 -0
- package/src/utils/event-stream-capture.test.ts +9 -64
- package/src/utils/streaming-table.test.ts +100 -0
- package/src/utils/streaming-table.ts +103 -0
- package/src/utils/sequence-buffer.test.ts +0 -256
- package/src/utils/sequence-buffer.ts +0 -130
- package/src/utils/unified-event-bridge.test.ts +0 -263
- package/src/utils/unified-event-bridge.ts +0 -431
|
@@ -36,7 +36,9 @@ class UnifiedEventTranslator {
|
|
|
36
36
|
private kind: ExecutionKind = "flow";
|
|
37
37
|
private blockCounter = 0;
|
|
38
38
|
private openText: string | null = null;
|
|
39
|
+
private openTextBuffer = "";
|
|
39
40
|
private openReasoning: string | null = null;
|
|
41
|
+
private openReasoningBuffer = "";
|
|
40
42
|
|
|
41
43
|
constructor(
|
|
42
44
|
private readonly sink: (chunk: string) => void,
|
|
@@ -56,40 +58,78 @@ class UnifiedEventTranslator {
|
|
|
56
58
|
this.sink(`event: ${type}\ndata: ${JSON.stringify(frame)}\n\n`);
|
|
57
59
|
}
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Parent model tool-call id stamped by the `tool_nested` FilteredStream as
|
|
63
|
+
* `toolContext.toolId` (a flow running as a tool enriches its frames this way).
|
|
64
|
+
* Surfaced on the text/reasoning channel as `parentToolCallId` so consumers can
|
|
65
|
+
* route nested streamed output into the parent tool's row (PR #4602). Undefined
|
|
66
|
+
* for top-level output.
|
|
67
|
+
*/
|
|
68
|
+
private parentToolCallId(data: Json): string | undefined {
|
|
69
|
+
const toolContext = data.toolContext;
|
|
70
|
+
if (toolContext && typeof toolContext === "object") {
|
|
71
|
+
const toolId = (toolContext as Json).toolId;
|
|
72
|
+
if (typeof toolId === "string" && toolId) return toolId;
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private emitTextDelta(delta: unknown, parentToolCallId?: string): void {
|
|
60
78
|
if (delta == null || delta === "") return;
|
|
61
79
|
if (!this.openText) {
|
|
62
80
|
this.openText = this.mint("text");
|
|
81
|
+
this.openTextBuffer = "";
|
|
63
82
|
this.out("text_start", {
|
|
64
83
|
id: this.openText,
|
|
65
84
|
...(this.kind === "agent" ? { role: "assistant" } : {}),
|
|
85
|
+
...(parentToolCallId ? { parentToolCallId } : {}),
|
|
66
86
|
});
|
|
67
87
|
}
|
|
68
|
-
|
|
88
|
+
const text = String(delta);
|
|
89
|
+
this.openTextBuffer += text;
|
|
90
|
+
this.out("text_delta", { id: this.openText, delta: text });
|
|
69
91
|
}
|
|
70
92
|
|
|
71
93
|
private closeText(): void {
|
|
72
94
|
if (!this.openText) return;
|
|
73
|
-
|
|
95
|
+
// U2: carry the assembled text so a non-streaming consumer can read the
|
|
96
|
+
// finished message off `text_complete` instead of re-concatenating deltas.
|
|
97
|
+
this.out("text_complete", {
|
|
98
|
+
id: this.openText,
|
|
99
|
+
...(this.openTextBuffer ? { text: this.openTextBuffer } : {}),
|
|
100
|
+
});
|
|
74
101
|
this.openText = null;
|
|
102
|
+
this.openTextBuffer = "";
|
|
75
103
|
}
|
|
76
104
|
|
|
77
|
-
private ensureReasoningOpen(scope?: "turn" | "loop"): void {
|
|
105
|
+
private ensureReasoningOpen(scope?: "turn" | "loop", parentToolCallId?: string): void {
|
|
78
106
|
if (this.openReasoning) return;
|
|
79
107
|
this.openReasoning = this.mint("reason");
|
|
80
|
-
this.
|
|
108
|
+
this.openReasoningBuffer = "";
|
|
109
|
+
this.out("reasoning_start", {
|
|
110
|
+
id: this.openReasoning,
|
|
111
|
+
...(scope ? { scope } : {}),
|
|
112
|
+
...(parentToolCallId ? { parentToolCallId } : {}),
|
|
113
|
+
});
|
|
81
114
|
}
|
|
82
115
|
|
|
83
|
-
private emitReasoningDelta(delta: unknown): void {
|
|
116
|
+
private emitReasoningDelta(delta: unknown, parentToolCallId?: string): void {
|
|
84
117
|
if (delta == null || delta === "") return;
|
|
85
|
-
this.ensureReasoningOpen();
|
|
86
|
-
|
|
118
|
+
this.ensureReasoningOpen(undefined, parentToolCallId);
|
|
119
|
+
const text = String(delta);
|
|
120
|
+
this.openReasoningBuffer += text;
|
|
121
|
+
this.out("reasoning_delta", { id: this.openReasoning, delta: text });
|
|
87
122
|
}
|
|
88
123
|
|
|
89
124
|
private closeReasoning(): void {
|
|
90
125
|
if (!this.openReasoning) return;
|
|
91
|
-
|
|
126
|
+
// U2: carry the assembled reasoning text (parity with text_complete).
|
|
127
|
+
this.out("reasoning_complete", {
|
|
128
|
+
id: this.openReasoning,
|
|
129
|
+
...(this.openReasoningBuffer ? { text: this.openReasoningBuffer } : {}),
|
|
130
|
+
});
|
|
92
131
|
this.openReasoning = null;
|
|
132
|
+
this.openReasoningBuffer = "";
|
|
93
133
|
}
|
|
94
134
|
|
|
95
135
|
private closeChannels(): void {
|
|
@@ -391,7 +431,7 @@ class UnifiedEventTranslator {
|
|
|
391
431
|
});
|
|
392
432
|
break;
|
|
393
433
|
case "step_delta":
|
|
394
|
-
this.emitTextDelta(data.text ?? data.delta);
|
|
434
|
+
this.emitTextDelta(data.text ?? data.delta, this.parentToolCallId(data));
|
|
395
435
|
break;
|
|
396
436
|
case "step_complete":
|
|
397
437
|
this.closeChannels();
|
|
@@ -492,23 +532,31 @@ class UnifiedEventTranslator {
|
|
|
492
532
|
});
|
|
493
533
|
break;
|
|
494
534
|
case "chunk":
|
|
495
|
-
this.emitTextDelta(data.text);
|
|
535
|
+
this.emitTextDelta(data.text, this.parentToolCallId(data));
|
|
496
536
|
break;
|
|
497
537
|
|
|
498
538
|
case "text_start":
|
|
499
539
|
if (!this.openText) {
|
|
500
540
|
this.openText = (data.id as string) ?? this.mint("text");
|
|
501
|
-
this.
|
|
541
|
+
this.openTextBuffer = "";
|
|
542
|
+
const parentToolCallId = this.parentToolCallId(data);
|
|
543
|
+
this.out("text_start", {
|
|
544
|
+
id: this.openText,
|
|
545
|
+
...(parentToolCallId ? { parentToolCallId } : {}),
|
|
546
|
+
});
|
|
502
547
|
}
|
|
503
548
|
break;
|
|
504
549
|
case "text_end":
|
|
505
550
|
this.closeText();
|
|
506
551
|
break;
|
|
507
552
|
case "reason_start":
|
|
508
|
-
this.ensureReasoningOpen();
|
|
553
|
+
this.ensureReasoningOpen(undefined, this.parentToolCallId(data));
|
|
509
554
|
break;
|
|
510
555
|
case "reason_delta":
|
|
511
|
-
this.emitReasoningDelta(
|
|
556
|
+
this.emitReasoningDelta(
|
|
557
|
+
data.reasoningText ?? data.delta ?? data.text,
|
|
558
|
+
this.parentToolCallId(data)
|
|
559
|
+
);
|
|
512
560
|
break;
|
|
513
561
|
case "reason_complete":
|
|
514
562
|
this.closeReasoning();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { normalizeCopiedSelectionText } from "./copy-selection";
|
|
3
|
+
|
|
4
|
+
describe("normalizeCopiedSelectionText", () => {
|
|
5
|
+
it("strips a single trailing newline left by block-element serialization", () => {
|
|
6
|
+
const raw = "Create a markdown document titled \"Bridge Test Report\".\n";
|
|
7
|
+
expect(normalizeCopiedSelectionText(raw)).toBe(
|
|
8
|
+
"Create a markdown document titled \"Bridge Test Report\"."
|
|
9
|
+
);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("strips multiple trailing blank lines and whitespace", () => {
|
|
13
|
+
expect(normalizeCopiedSelectionText("hello\n\n \n")).toBe("hello");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("strips leading blank lines", () => {
|
|
17
|
+
expect(normalizeCopiedSelectionText("\n\nhello")).toBe("hello");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("preserves interior newlines (multi-paragraph / multi-message selection)", () => {
|
|
21
|
+
expect(normalizeCopiedSelectionText("first\n\nsecond\n")).toBe("first\n\nsecond");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("preserves leading indentation on the first line (copied code)", () => {
|
|
25
|
+
expect(normalizeCopiedSelectionText(" indented line\nnext\n")).toBe(
|
|
26
|
+
" indented line\nnext"
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns an unchanged string when there is nothing to trim", () => {
|
|
31
|
+
expect(normalizeCopiedSelectionText("clean text")).toBe("clean text");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns an empty string for whitespace-only input", () => {
|
|
35
|
+
expect(normalizeCopiedSelectionText("\n\n \n")).toBe("");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize text pulled from a native browser selection before it is written to
|
|
3
|
+
* the clipboard.
|
|
4
|
+
*
|
|
5
|
+
* When a user triple-clicks a chat bubble and presses Ctrl/Cmd-C, the browser
|
|
6
|
+
* serializes the DOM selection itself. Markdown is rendered into block-level
|
|
7
|
+
* elements (`<p>`, `<li>`, `<pre>`, …), and browsers emit surrounding newlines
|
|
8
|
+
* for those blocks — so copying a single message drags along stray leading
|
|
9
|
+
* blank lines and a trailing newline that the user never visually selected.
|
|
10
|
+
*
|
|
11
|
+
* This trims the outer whitespace so the clipboard matches the visible
|
|
12
|
+
* selection, while preserving:
|
|
13
|
+
* - interior newlines (a multi-paragraph or multi-message selection keeps its
|
|
14
|
+
* line breaks), and
|
|
15
|
+
* - leading indentation on the first line (e.g. copied code keeps its indent;
|
|
16
|
+
* only fully-blank leading lines are dropped).
|
|
17
|
+
*/
|
|
18
|
+
export const normalizeCopiedSelectionText = (text: string): string =>
|
|
19
|
+
text.replace(/^\n+/, "").replace(/\s+$/, "");
|
|
@@ -304,28 +304,9 @@ describe("Event Capture Pipeline - no interference with message processing", ()
|
|
|
304
304
|
|
|
305
305
|
it("should still create assistant message correctly when event capture is active", async () => {
|
|
306
306
|
global.fetch = createMockFetch([
|
|
307
|
-
sseData({
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
name: "Prompt 1",
|
|
311
|
-
executionType: "prompt",
|
|
312
|
-
index: 1,
|
|
313
|
-
text: "Hello"
|
|
314
|
-
}),
|
|
315
|
-
sseData({
|
|
316
|
-
type: "step_chunk",
|
|
317
|
-
id: "step_1",
|
|
318
|
-
name: "Prompt 1",
|
|
319
|
-
executionType: "prompt",
|
|
320
|
-
index: 2,
|
|
321
|
-
text: " there"
|
|
322
|
-
}),
|
|
323
|
-
sseData({
|
|
324
|
-
type: "flow_complete",
|
|
325
|
-
flowId: "flow_1",
|
|
326
|
-
success: true,
|
|
327
|
-
duration: 100
|
|
328
|
-
})
|
|
307
|
+
sseData({ type: "text_delta", id: "text_1", delta: "Hello" }),
|
|
308
|
+
sseData({ type: "text_delta", id: "text_1", delta: " there" }),
|
|
309
|
+
sseData({ type: "execution_complete", kind: "flow", success: true })
|
|
329
310
|
]);
|
|
330
311
|
|
|
331
312
|
await client.dispatch(
|
|
@@ -374,20 +355,8 @@ describe("Event Capture Pipeline - no interference with message processing", ()
|
|
|
374
355
|
duration: 250,
|
|
375
356
|
completedAt: "2025-01-01T00:00:01.000Z"
|
|
376
357
|
}),
|
|
377
|
-
sseData({
|
|
378
|
-
|
|
379
|
-
id: "step_1",
|
|
380
|
-
name: "Prompt 1",
|
|
381
|
-
executionType: "prompt",
|
|
382
|
-
index: 1,
|
|
383
|
-
text: "Found results"
|
|
384
|
-
}),
|
|
385
|
-
sseData({
|
|
386
|
-
type: "flow_complete",
|
|
387
|
-
flowId: "flow_1",
|
|
388
|
-
success: true,
|
|
389
|
-
duration: 500
|
|
390
|
-
})
|
|
358
|
+
sseData({ type: "text_delta", id: "text_1", delta: "Found results" }),
|
|
359
|
+
sseData({ type: "execution_complete", kind: "flow", success: true })
|
|
391
360
|
]);
|
|
392
361
|
|
|
393
362
|
await client.dispatch(
|
|
@@ -442,20 +411,8 @@ describe("Event Capture Pipeline - no interference with message processing", ()
|
|
|
442
411
|
});
|
|
443
412
|
|
|
444
413
|
global.fetch = createMockFetch([
|
|
445
|
-
sseData({
|
|
446
|
-
|
|
447
|
-
id: "step_1",
|
|
448
|
-
name: "Prompt 1",
|
|
449
|
-
executionType: "prompt",
|
|
450
|
-
index: 1,
|
|
451
|
-
text: "Works fine"
|
|
452
|
-
}),
|
|
453
|
-
sseData({
|
|
454
|
-
type: "flow_complete",
|
|
455
|
-
flowId: "flow_1",
|
|
456
|
-
success: true,
|
|
457
|
-
duration: 50
|
|
458
|
-
})
|
|
414
|
+
sseData({ type: "text_delta", id: "text_1", delta: "Works fine" }),
|
|
415
|
+
sseData({ type: "execution_complete", kind: "flow", success: true })
|
|
459
416
|
]);
|
|
460
417
|
|
|
461
418
|
const events: AgentWidgetEvent[] = [];
|
|
@@ -496,20 +453,8 @@ describe("Event Capture Pipeline - no interference with message processing", ()
|
|
|
496
453
|
});
|
|
497
454
|
|
|
498
455
|
global.fetch = createMockFetch([
|
|
499
|
-
sseData({
|
|
500
|
-
|
|
501
|
-
id: "step_1",
|
|
502
|
-
name: "Prompt 1",
|
|
503
|
-
executionType: "prompt",
|
|
504
|
-
index: 1,
|
|
505
|
-
text: "Still works"
|
|
506
|
-
}),
|
|
507
|
-
sseData({
|
|
508
|
-
type: "flow_complete",
|
|
509
|
-
flowId: "flow_1",
|
|
510
|
-
success: true,
|
|
511
|
-
duration: 50
|
|
512
|
-
})
|
|
456
|
+
sseData({ type: "text_delta", id: "text_1", delta: "Still works" }),
|
|
457
|
+
sseData({ type: "execution_complete", kind: "flow", success: true })
|
|
513
458
|
]);
|
|
514
459
|
|
|
515
460
|
const events: AgentWidgetEvent[] = [];
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { stabilizeStreamingTables } from "./streaming-table";
|
|
3
|
+
import { createMarkdownProcessor } from "../postprocessors";
|
|
4
|
+
|
|
5
|
+
describe("stabilizeStreamingTables", () => {
|
|
6
|
+
it("leaves text without pipes untouched", () => {
|
|
7
|
+
const md = "# Heading\n\nA paragraph with no tables.";
|
|
8
|
+
expect(stabilizeStreamingTables(md)).toBe(md);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("does not treat a header-only line as a table (no delimiter yet)", () => {
|
|
12
|
+
// Ambiguous: could just be inline pipe text. Leave it alone until a
|
|
13
|
+
// delimiter row starts streaming in.
|
|
14
|
+
const md = "| Name | Price |";
|
|
15
|
+
expect(stabilizeStreamingTables(md)).toBe(md);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("completes a partial delimiter row so the table renders immediately", () => {
|
|
19
|
+
const md = "| Name | Price |\n| --";
|
|
20
|
+
expect(stabilizeStreamingTables(md)).toBe("| Name | Price |\n| --- | --- |");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("completes a delimiter that is just a leading pipe with one dash", () => {
|
|
24
|
+
const md = "| A | B | C |\n|-";
|
|
25
|
+
expect(stabilizeStreamingTables(md)).toBe("| A | B | C |\n| --- | --- | --- |");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("pads a partial trailing row to the header column count", () => {
|
|
29
|
+
const md = "| Name | Price |\n| --- | --- |\n| Apple";
|
|
30
|
+
expect(stabilizeStreamingTables(md)).toBe(
|
|
31
|
+
"| Name | Price |\n| --- | --- |\n| Apple | |"
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("keeps a complete table stable (idempotent)", () => {
|
|
36
|
+
const md = "| Name | Price |\n| --- | --- |\n| Apple | $1 |";
|
|
37
|
+
expect(stabilizeStreamingTables(md)).toBe(md);
|
|
38
|
+
expect(stabilizeStreamingTables(stabilizeStreamingTables(md))).toBe(md);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("counts columns from a header without outer pipes", () => {
|
|
42
|
+
const md = "Name | Price\n---";
|
|
43
|
+
expect(stabilizeStreamingTables(md)).toBe("Name | Price\n| --- | --- |");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("normalizes rows with extra cells down to the header column count", () => {
|
|
47
|
+
const md = "| A | B |\n| --- | --- |\n| 1 | 2 | 3 |";
|
|
48
|
+
expect(stabilizeStreamingTables(md)).toBe("| A | B |\n| --- | --- |\n| 1 | 2 |");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("stops the table region at a blank line and leaves following text alone", () => {
|
|
52
|
+
const md = "| A | B |\n| --- | --- |\n| 1 | 2 |\n\nSome | trailing | text";
|
|
53
|
+
expect(stabilizeStreamingTables(md)).toBe(md);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("preserves leading prose before a streaming table", () => {
|
|
57
|
+
const md = "Here you go:\n\n| A | B |\n| -";
|
|
58
|
+
expect(stabilizeStreamingTables(md)).toBe("Here you go:\n\n| A | B |\n| --- | --- |");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("handles two separate tables in one stream", () => {
|
|
62
|
+
const md =
|
|
63
|
+
"| A | B |\n| --- | --- |\n| 1 | 2 |\n\nmiddle\n\n| C | D | E |\n| --";
|
|
64
|
+
expect(stabilizeStreamingTables(md)).toBe(
|
|
65
|
+
"| A | B |\n| --- | --- |\n| 1 | 2 |\n\nmiddle\n\n| C | D | E |\n| --- | --- | --- |"
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("preserves alignment-colon delimiters as a valid table start", () => {
|
|
70
|
+
const md = "| A | B |\n| :-";
|
|
71
|
+
// Detection accepts colons; the synthesized delimiter normalizes to dashes
|
|
72
|
+
// (alignment reappears in the untouched final render).
|
|
73
|
+
expect(stabilizeStreamingTables(md)).toBe("| A | B |\n| --- | --- |");
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("stabilizeStreamingTables → marked integration", () => {
|
|
78
|
+
const md = createMarkdownProcessor();
|
|
79
|
+
|
|
80
|
+
it("renders a <table> from a header + partial delimiter that marked alone would not", () => {
|
|
81
|
+
const partial = "| Name | Price |\n| --";
|
|
82
|
+
|
|
83
|
+
// Without stabilization marked sees no complete delimiter → no table.
|
|
84
|
+
expect(md(partial)).not.toContain("<table");
|
|
85
|
+
|
|
86
|
+
const html = md(stabilizeStreamingTables(partial));
|
|
87
|
+
expect(html).toContain("<table");
|
|
88
|
+
expect(html).toContain("<th>Name</th>");
|
|
89
|
+
expect(html).toContain("<th>Price</th>");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("renders a body row for a partial trailing row", () => {
|
|
93
|
+
const partial = "| Name | Price |\n| --- | --- |\n| Apple";
|
|
94
|
+
const html = md(stabilizeStreamingTables(partial));
|
|
95
|
+
expect(html).toContain("<table");
|
|
96
|
+
expect(html).toContain("<td>Apple</td>");
|
|
97
|
+
// The padded empty cell keeps the column count stable.
|
|
98
|
+
expect(html.match(/<td/g)?.length).toBe(2);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming markdown table stabilizer (Telegram-style space reservation).
|
|
3
|
+
*
|
|
4
|
+
* During SSE streaming the full accumulated markdown is re-parsed by `marked`
|
|
5
|
+
* on every chunk. For GFM tables that produces two jarring jolts:
|
|
6
|
+
*
|
|
7
|
+
* 1. The paragraph→table flip. GFM only recognizes a table once the *delimiter*
|
|
8
|
+
* row (`| --- | --- |`) has arrived, so a freshly-streamed header line first
|
|
9
|
+
* renders as a plain paragraph and then snaps into a `<table>`.
|
|
10
|
+
* 2. Partial-row flicker. The in-flight last row grows cell-by-cell.
|
|
11
|
+
*
|
|
12
|
+
* This module rewrites table-in-progress regions so a real `<table>` renders
|
|
13
|
+
* from the first row onward with a stable column count: it completes the
|
|
14
|
+
* delimiter row as soon as it starts streaming and pads the trailing partial
|
|
15
|
+
* row to the header's column count. Combined with `table-layout: fixed` while
|
|
16
|
+
* streaming (see `.persona-content-streaming table` in widget.css), columns lock
|
|
17
|
+
* to even widths so rows append vertically without horizontal reflow.
|
|
18
|
+
*
|
|
19
|
+
* It runs ONLY while a message is streaming; the final render uses the real,
|
|
20
|
+
* untouched `marked` output, so correctness is never affected.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A GFM delimiter row, full or still streaming in: only delimiter characters
|
|
25
|
+
* (`-`, `:`, `|`, whitespace) and at least one dash. Matches `|`-led partials
|
|
26
|
+
* like `| -`, `|--`, `| :--`, and complete rows like `| --- | :--: |`.
|
|
27
|
+
*/
|
|
28
|
+
const DELIMITER_RE = /^\s*\|?[\s:|-]*-[\s:|-]*$/;
|
|
29
|
+
|
|
30
|
+
/** A candidate table row contains at least one pipe. */
|
|
31
|
+
const hasPipe = (line: string): boolean => line.includes("|");
|
|
32
|
+
|
|
33
|
+
/** Split a markdown table row into trimmed cell strings, ignoring outer pipes. */
|
|
34
|
+
const splitCells = (line: string): string[] => {
|
|
35
|
+
let s = line.trim();
|
|
36
|
+
if (s.startsWith("|")) s = s.slice(1);
|
|
37
|
+
if (s.endsWith("|")) s = s.slice(0, -1);
|
|
38
|
+
return s.split("|").map((cell) => cell.trim());
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Render cells back into a normalized, pipe-delimited row. */
|
|
42
|
+
const buildRow = (cells: string[]): string => `| ${cells.join(" | ")} |`;
|
|
43
|
+
|
|
44
|
+
/** Build a complete delimiter row with the given column count. */
|
|
45
|
+
const buildDelimiter = (cols: number): string =>
|
|
46
|
+
`| ${Array.from({ length: cols }, () => "---").join(" | ")} |`;
|
|
47
|
+
|
|
48
|
+
/** Pad (or trim) a row's cells to exactly `cols` columns. */
|
|
49
|
+
const fitCells = (cells: string[], cols: number): string[] => {
|
|
50
|
+
if (cells.length >= cols) return cells.slice(0, cols);
|
|
51
|
+
return cells.concat(Array.from({ length: cols - cells.length }, () => ""));
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Normalize any streaming-in-progress GFM tables in `markdown` so they render as
|
|
56
|
+
* complete tables with a stable column count. Returns the input unchanged when
|
|
57
|
+
* there is nothing to stabilize (cheap fast-path for the common no-table case).
|
|
58
|
+
*/
|
|
59
|
+
export const stabilizeStreamingTables = (markdown: string): string => {
|
|
60
|
+
if (!markdown || !markdown.includes("|")) return markdown;
|
|
61
|
+
|
|
62
|
+
const lines = markdown.split("\n");
|
|
63
|
+
let changed = false;
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < lines.length - 1; i++) {
|
|
66
|
+
const header = lines[i];
|
|
67
|
+
const delimiter = lines[i + 1];
|
|
68
|
+
|
|
69
|
+
// A table starts at a header line (has a pipe, is not itself a delimiter)
|
|
70
|
+
// immediately followed by a delimiter row that is full or still streaming.
|
|
71
|
+
if (!hasPipe(header) || DELIMITER_RE.test(header)) continue;
|
|
72
|
+
if (!DELIMITER_RE.test(delimiter)) continue;
|
|
73
|
+
|
|
74
|
+
const cols = splitCells(header).length;
|
|
75
|
+
if (cols < 1) continue;
|
|
76
|
+
|
|
77
|
+
// Complete the delimiter to match the header's column count so `marked`
|
|
78
|
+
// recognizes the table immediately instead of waiting for it to finish.
|
|
79
|
+
const fullDelimiter = buildDelimiter(cols);
|
|
80
|
+
if (lines[i + 1] !== fullDelimiter) {
|
|
81
|
+
lines[i + 1] = fullDelimiter;
|
|
82
|
+
changed = true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Normalize body rows (including a partial trailing one) to `cols` columns
|
|
86
|
+
// so each row occupies its slot instead of growing cell-by-cell. The region
|
|
87
|
+
// ends at the first blank or pipe-less line.
|
|
88
|
+
let j = i + 2;
|
|
89
|
+
for (; j < lines.length; j++) {
|
|
90
|
+
const row = lines[j];
|
|
91
|
+
if (row.trim() === "" || !hasPipe(row)) break;
|
|
92
|
+
const normalized = buildRow(fitCells(splitCells(row), cols));
|
|
93
|
+
if (lines[j] !== normalized) {
|
|
94
|
+
lines[j] = normalized;
|
|
95
|
+
changed = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
i = j - 1; // resume scanning after this table region
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return changed ? lines.join("\n") : markdown;
|
|
103
|
+
};
|