@varo-ui/ai 1.1.0 → 2.0.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 +16 -1
- package/dist/index.d.mts +44 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +340 -46
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/agent-stream.ts +17 -2
- package/src/event-channel.ts +35 -12
- package/src/index.ts +1 -0
- package/src/markdown-view.ts +126 -45
- package/src/sse.ts +34 -9
- package/src/text-stream.ts +126 -39
- package/src/thread.ts +160 -0
- package/src/types.ts +48 -17
package/src/markdown-view.ts
CHANGED
|
@@ -1,36 +1,36 @@
|
|
|
1
|
-
import { isUnknownRecord } from './type-guards'
|
|
2
1
|
import type { ParsedNode } from 'stream-markdown-parser'
|
|
2
|
+
import { isUnknownRecord } from './type-guards'
|
|
3
3
|
|
|
4
|
-
export type AgentMarkdownNodeKind
|
|
5
|
-
| 'admonition'
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
4
|
+
export type AgentMarkdownNodeKind
|
|
5
|
+
= | 'admonition'
|
|
6
|
+
| 'blockquote'
|
|
7
|
+
| 'checkbox'
|
|
8
|
+
| 'code-block'
|
|
9
|
+
| 'definition'
|
|
10
|
+
| 'definition-list'
|
|
11
|
+
| 'emphasis'
|
|
12
|
+
| 'footnote'
|
|
13
|
+
| 'hardbreak'
|
|
14
|
+
| 'heading'
|
|
15
|
+
| 'highlight'
|
|
16
|
+
| 'image'
|
|
17
|
+
| 'inline-code'
|
|
18
|
+
| 'insert'
|
|
19
|
+
| 'link'
|
|
20
|
+
| 'list'
|
|
21
|
+
| 'list-item'
|
|
22
|
+
| 'math-block'
|
|
23
|
+
| 'math-inline'
|
|
24
|
+
| 'paragraph'
|
|
25
|
+
| 'strikethrough'
|
|
26
|
+
| 'strong'
|
|
27
|
+
| 'subscript'
|
|
28
|
+
| 'superscript'
|
|
29
|
+
| 'table'
|
|
30
|
+
| 'table-cell'
|
|
31
|
+
| 'table-row'
|
|
32
|
+
| 'text'
|
|
33
|
+
| 'thematic-break'
|
|
34
34
|
|
|
35
35
|
export interface AgentMarkdownViewNode {
|
|
36
36
|
align?: 'center' | 'left' | 'right'
|
|
@@ -49,6 +49,85 @@ export interface AgentMarkdownViewNode {
|
|
|
49
49
|
title?: string
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export type AgentRichTextNode
|
|
53
|
+
= | {
|
|
54
|
+
text: string
|
|
55
|
+
type: 'text'
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
attrs?: Record<string, string>
|
|
59
|
+
children?: AgentRichTextNode[]
|
|
60
|
+
name: 'br' | 'code' | 'del' | 'em' | 'ins' | 'mark' | 'span' | 'strong' | 'sub' | 'sup'
|
|
61
|
+
}
|
|
62
|
+
type AgentRichTextElementName = Extract<AgentRichTextNode, { name: string }>['name']
|
|
63
|
+
|
|
64
|
+
function richTextChildren(children: AgentMarkdownViewNode[] | undefined): AgentRichTextNode[] | undefined {
|
|
65
|
+
const result: AgentRichTextNode[] = []
|
|
66
|
+
for (const child of children ?? []) {
|
|
67
|
+
// Recursive inline nodes intentionally alternate between element and child normalization.
|
|
68
|
+
// eslint-disable-next-line ts/no-use-before-define
|
|
69
|
+
const normalized = richTextNode(child)
|
|
70
|
+
if (!normalized) { return undefined }
|
|
71
|
+
result.push(normalized)
|
|
72
|
+
}
|
|
73
|
+
return result
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function richTextNode(node: AgentMarkdownViewNode): AgentRichTextNode | undefined {
|
|
77
|
+
if (node.kind === 'text') { return { text: node.text ?? '', type: 'text' } }
|
|
78
|
+
if (node.kind === 'hardbreak') { return { name: 'br' } }
|
|
79
|
+
if (node.kind === 'checkbox') { return { text: node.checked ? '☑ ' : '☐ ', type: 'text' } }
|
|
80
|
+
|
|
81
|
+
const names: Partial<Record<AgentMarkdownNodeKind, AgentRichTextElementName>> = {
|
|
82
|
+
emphasis: 'em',
|
|
83
|
+
strikethrough: 'del',
|
|
84
|
+
strong: 'strong',
|
|
85
|
+
subscript: 'sub',
|
|
86
|
+
superscript: 'sup',
|
|
87
|
+
}
|
|
88
|
+
const name = names[node.kind]
|
|
89
|
+
if (name) {
|
|
90
|
+
const children = richTextChildren(node.children)
|
|
91
|
+
return children ? { children, name } : undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (node.kind === 'highlight' || node.kind === 'insert') {
|
|
95
|
+
const children = richTextChildren(node.children)
|
|
96
|
+
if (!children) { return undefined }
|
|
97
|
+
return {
|
|
98
|
+
attrs: {
|
|
99
|
+
style: node.kind === 'highlight'
|
|
100
|
+
? 'background:rgba(250,146,0,.18);border-radius:2px;'
|
|
101
|
+
: 'text-decoration:underline;',
|
|
102
|
+
},
|
|
103
|
+
children,
|
|
104
|
+
name: 'span',
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (node.kind === 'inline-code') {
|
|
109
|
+
return {
|
|
110
|
+
attrs: {
|
|
111
|
+
style: 'font-family:monospace;padding:1px 4px;border-radius:4px;background:rgba(127,127,127,.12);',
|
|
112
|
+
},
|
|
113
|
+
children: [{ text: node.text ?? '', type: 'text' }],
|
|
114
|
+
name: 'code',
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (node.kind === 'math-inline') {
|
|
118
|
+
return {
|
|
119
|
+
attrs: { style: 'font-family:monospace;' },
|
|
120
|
+
children: [{ text: node.text ?? '', type: 'text' }],
|
|
121
|
+
name: 'span',
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function toAgentRichTextNodes(nodes: AgentMarkdownViewNode[]): AgentRichTextNode[] | undefined {
|
|
128
|
+
return richTextChildren(nodes)
|
|
129
|
+
}
|
|
130
|
+
|
|
52
131
|
function stringValue(value: unknown, fallback = '') {
|
|
53
132
|
return typeof value === 'string' ? value : fallback
|
|
54
133
|
}
|
|
@@ -70,18 +149,20 @@ function nodeField(node: ParsedNode, key: string): unknown {
|
|
|
70
149
|
}
|
|
71
150
|
|
|
72
151
|
function normalizeUnknownNode(value: unknown): AgentMarkdownViewNode | undefined {
|
|
73
|
-
if (!isUnknownRecord(value) || typeof value.type !== 'string' || typeof value.raw !== 'string') return undefined
|
|
152
|
+
if (!isUnknownRecord(value) || typeof value.type !== 'string' || typeof value.raw !== 'string') { return undefined }
|
|
74
153
|
// The required BaseNode fields are validated above; extension fields stay runtime-checked.
|
|
75
154
|
const node = value as ParsedNode
|
|
155
|
+
// Recursive block normalization is declared below to keep the public switch centralized.
|
|
156
|
+
// eslint-disable-next-line ts/no-use-before-define
|
|
76
157
|
return normalizeMarkdownNode(node)
|
|
77
158
|
}
|
|
78
159
|
|
|
79
160
|
function normalizeChildren(value: unknown): AgentMarkdownViewNode[] {
|
|
80
|
-
if (!Array.isArray(value)) return []
|
|
161
|
+
if (!Array.isArray(value)) { return [] }
|
|
81
162
|
const result: AgentMarkdownViewNode[] = []
|
|
82
163
|
value.forEach((item) => {
|
|
83
164
|
const normalized = normalizeUnknownNode(item)
|
|
84
|
-
if (normalized) result.push(normalized)
|
|
165
|
+
if (normalized) { result.push(normalized) }
|
|
85
166
|
})
|
|
86
167
|
return result
|
|
87
168
|
}
|
|
@@ -101,7 +182,7 @@ export function normalizeMarkdownNode(node: ParsedNode): AgentMarkdownViewNode {
|
|
|
101
182
|
children: normalizeChildren(node.children),
|
|
102
183
|
kind: 'heading',
|
|
103
184
|
level: numberValue(nodeField(node, 'level')) ?? 2,
|
|
104
|
-
text: stringValue(nodeField(node, 'text'))
|
|
185
|
+
text: stringValue(nodeField(node, 'text')),
|
|
105
186
|
}
|
|
106
187
|
case 'paragraph':
|
|
107
188
|
case 'inline':
|
|
@@ -111,7 +192,7 @@ export function normalizeMarkdownNode(node: ParsedNode): AgentMarkdownViewNode {
|
|
|
111
192
|
children: normalizeChildren(nodeField(node, 'items')),
|
|
112
193
|
kind: 'list',
|
|
113
194
|
ordered: booleanValue(nodeField(node, 'ordered')) ?? false,
|
|
114
|
-
start: numberValue(nodeField(node, 'start'))
|
|
195
|
+
start: numberValue(nodeField(node, 'start')),
|
|
115
196
|
}
|
|
116
197
|
case 'list_item':
|
|
117
198
|
return { children: normalizeChildren(node.children), kind: 'list-item' }
|
|
@@ -120,7 +201,7 @@ export function normalizeMarkdownNode(node: ParsedNode): AgentMarkdownViewNode {
|
|
|
120
201
|
kind: 'code-block',
|
|
121
202
|
language: stringValue(nodeField(node, 'language')),
|
|
122
203
|
loading: booleanValue(node.loading),
|
|
123
|
-
text: stringValue(node.code, fallback)
|
|
204
|
+
text: stringValue(node.code, fallback),
|
|
124
205
|
}
|
|
125
206
|
case 'inline_code':
|
|
126
207
|
return { kind: 'inline-code', text: stringValue(node.code, fallback) }
|
|
@@ -130,14 +211,14 @@ export function normalizeMarkdownNode(node: ParsedNode): AgentMarkdownViewNode {
|
|
|
130
211
|
href: stringValue(nodeField(node, 'href')),
|
|
131
212
|
kind: 'link',
|
|
132
213
|
text: stringValue(nodeField(node, 'text')),
|
|
133
|
-
title: stringValue(nodeField(node, 'title'))
|
|
214
|
+
title: stringValue(nodeField(node, 'title')),
|
|
134
215
|
}
|
|
135
216
|
case 'image':
|
|
136
217
|
return {
|
|
137
218
|
alt: stringValue(nodeField(node, 'alt')),
|
|
138
219
|
href: stringValue(nodeField(node, 'src')),
|
|
139
220
|
kind: 'image',
|
|
140
|
-
title: stringValue(nodeField(node, 'title'))
|
|
221
|
+
title: stringValue(nodeField(node, 'title')),
|
|
141
222
|
}
|
|
142
223
|
case 'thematic_break':
|
|
143
224
|
return { kind: 'thematic-break' }
|
|
@@ -148,7 +229,7 @@ export function normalizeMarkdownNode(node: ParsedNode): AgentMarkdownViewNode {
|
|
|
148
229
|
const rows = nodeField(node, 'rows')
|
|
149
230
|
return {
|
|
150
231
|
children: normalizeChildren(Array.isArray(rows) ? [header, ...rows] : [header]),
|
|
151
|
-
kind: 'table'
|
|
232
|
+
kind: 'table',
|
|
152
233
|
}
|
|
153
234
|
}
|
|
154
235
|
case 'table_row':
|
|
@@ -158,7 +239,7 @@ export function normalizeMarkdownNode(node: ParsedNode): AgentMarkdownViewNode {
|
|
|
158
239
|
align: alignValue(nodeField(node, 'align')),
|
|
159
240
|
children: normalizeChildren(node.children),
|
|
160
241
|
header: booleanValue(nodeField(node, 'header')),
|
|
161
|
-
kind: 'table-cell'
|
|
242
|
+
kind: 'table-cell',
|
|
162
243
|
}
|
|
163
244
|
case 'strong':
|
|
164
245
|
return { children: normalizeChildren(node.children), kind: 'strong' }
|
|
@@ -185,9 +266,9 @@ export function normalizeMarkdownNode(node: ParsedNode): AgentMarkdownViewNode {
|
|
|
185
266
|
return {
|
|
186
267
|
children: [
|
|
187
268
|
{ children: normalizeChildren(nodeField(node, 'term')), header: true, kind: 'definition' },
|
|
188
|
-
{ children: normalizeChildren(nodeField(node, 'definition')), kind: 'definition' }
|
|
269
|
+
{ children: normalizeChildren(nodeField(node, 'definition')), kind: 'definition' },
|
|
189
270
|
],
|
|
190
|
-
kind: 'definition'
|
|
271
|
+
kind: 'definition',
|
|
191
272
|
}
|
|
192
273
|
case 'footnote':
|
|
193
274
|
return { children: normalizeChildren(node.children), kind: 'footnote', title: stringValue(nodeField(node, 'id')) }
|
|
@@ -199,14 +280,14 @@ export function normalizeMarkdownNode(node: ParsedNode): AgentMarkdownViewNode {
|
|
|
199
280
|
children: normalizeChildren(node.children),
|
|
200
281
|
kind: 'admonition',
|
|
201
282
|
text: stringValue(nodeField(node, 'kind')),
|
|
202
|
-
title: stringValue(nodeField(node, 'title'))
|
|
283
|
+
title: stringValue(nodeField(node, 'title')),
|
|
203
284
|
}
|
|
204
285
|
case 'vmr_container':
|
|
205
286
|
return {
|
|
206
287
|
children: normalizeChildren(node.children),
|
|
207
288
|
kind: 'admonition',
|
|
208
289
|
loading: booleanValue(node.loading),
|
|
209
|
-
title: stringValue(nodeField(node, 'name'))
|
|
290
|
+
title: stringValue(nodeField(node, 'name')),
|
|
210
291
|
}
|
|
211
292
|
case 'hardbreak':
|
|
212
293
|
return { kind: 'hardbreak' }
|
package/src/sse.ts
CHANGED
|
@@ -94,20 +94,45 @@ export function createAgentSseEventSource(): AgentSseEventSource {
|
|
|
94
94
|
let textBuffer = ''
|
|
95
95
|
let ended = false
|
|
96
96
|
|
|
97
|
+
function clearBufferedInput() {
|
|
98
|
+
dataLines.length = 0
|
|
99
|
+
textBuffer = ''
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function closeSource() {
|
|
103
|
+
if (ended) return
|
|
104
|
+
ended = true
|
|
105
|
+
clearBufferedInput()
|
|
106
|
+
channel.end()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function failSource(error: unknown) {
|
|
110
|
+
if (ended) return
|
|
111
|
+
ended = true
|
|
112
|
+
clearBufferedInput()
|
|
113
|
+
channel.fail(error)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function finishWith(event: AgentStreamEvent) {
|
|
117
|
+
channel.push(event)
|
|
118
|
+
closeSource()
|
|
119
|
+
}
|
|
120
|
+
|
|
97
121
|
function dispatch() {
|
|
98
122
|
if (dataLines.length === 0 || ended) return
|
|
99
123
|
const payload = dataLines.join('\n')
|
|
100
124
|
dataLines.length = 0
|
|
101
125
|
if (payload === '[DONE]') {
|
|
102
|
-
|
|
126
|
+
finishWith({ type: 'done' })
|
|
103
127
|
return
|
|
104
128
|
}
|
|
105
129
|
|
|
106
130
|
try {
|
|
107
|
-
|
|
131
|
+
const event = parseAgentStreamEvent(JSON.parse(payload))
|
|
132
|
+
if (event.type === 'done' || event.type === 'error') finishWith(event)
|
|
133
|
+
else channel.push(event)
|
|
108
134
|
} catch (error) {
|
|
109
|
-
|
|
110
|
-
channel.fail(error)
|
|
135
|
+
failSource(error)
|
|
111
136
|
}
|
|
112
137
|
}
|
|
113
138
|
|
|
@@ -129,10 +154,12 @@ export function createAgentSseEventSource(): AgentSseEventSource {
|
|
|
129
154
|
}
|
|
130
155
|
|
|
131
156
|
function appendText(value: string) {
|
|
157
|
+
if (ended) return
|
|
132
158
|
textBuffer += value
|
|
133
159
|
let lineEnd = textBuffer.indexOf('\n')
|
|
134
160
|
while (lineEnd >= 0) {
|
|
135
161
|
processLine(textBuffer.slice(0, lineEnd))
|
|
162
|
+
if (ended) return
|
|
136
163
|
textBuffer = textBuffer.slice(lineEnd + 1)
|
|
137
164
|
lineEnd = textBuffer.indexOf('\n')
|
|
138
165
|
}
|
|
@@ -142,15 +169,13 @@ export function createAgentSseEventSource(): AgentSseEventSource {
|
|
|
142
169
|
end() {
|
|
143
170
|
if (ended) return
|
|
144
171
|
appendText(decoder.decode(new Uint8Array(0), true))
|
|
172
|
+
if (ended) return
|
|
145
173
|
if (textBuffer.length > 0) processLine(textBuffer)
|
|
146
174
|
dispatch()
|
|
147
|
-
|
|
148
|
-
channel.end()
|
|
175
|
+
closeSource()
|
|
149
176
|
},
|
|
150
177
|
fail(error) {
|
|
151
|
-
|
|
152
|
-
ended = true
|
|
153
|
-
channel.fail(error)
|
|
178
|
+
failSource(error)
|
|
154
179
|
},
|
|
155
180
|
feed(chunk) {
|
|
156
181
|
if (ended) return
|
package/src/text-stream.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createSmoothMarkdownStream } from 'markstream-core'
|
|
2
1
|
import type { TextStreamController, TextStreamOptions, TextStreamSnapshot } from './types'
|
|
2
|
+
import { createSmoothMarkdownStream } from 'markstream-core'
|
|
3
3
|
|
|
4
4
|
interface SegmenterLike {
|
|
5
5
|
segment: (value: string) => Iterable<{ segment: string }>
|
|
@@ -21,20 +21,64 @@ function splitGraphemes(value: string): string[] {
|
|
|
21
21
|
locale?: string,
|
|
22
22
|
options?: { granularity: 'grapheme' }
|
|
23
23
|
) => SegmenterLike
|
|
24
|
-
return Array.from(new Segmenter(undefined, { granularity: 'grapheme' }).segment(value),
|
|
24
|
+
return Array.from(new Segmenter(undefined, { granularity: 'grapheme' }).segment(value), item => item.segment)
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
function clamp(value: number, min: number, max: number) {
|
|
28
28
|
return Math.min(max, Math.max(min, value))
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
function hasUnclosedFence(value: string) {
|
|
32
|
+
let marker: '`' | '~' | undefined
|
|
33
|
+
let markerLength = 0
|
|
34
|
+
for (const line of value.split(/\r?\n/)) {
|
|
35
|
+
const match = /^ {0,3}(`{3,}|~{3,})/.exec(line)
|
|
36
|
+
if (!match) { continue }
|
|
37
|
+
const nextMarker = match[1][0] as '`' | '~'
|
|
38
|
+
const nextLength = match[1].length
|
|
39
|
+
if (!marker) {
|
|
40
|
+
marker = nextMarker
|
|
41
|
+
markerLength = nextLength
|
|
42
|
+
}
|
|
43
|
+
else if (marker === nextMarker && nextLength >= markerLength) {
|
|
44
|
+
marker = undefined
|
|
45
|
+
markerLength = 0
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return marker !== undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function fenceSafeCommitCount(visible: string, source: string, segments: string[]) {
|
|
52
|
+
if (segments.length === 0) { return 0 }
|
|
53
|
+
const candidate = visible + segments.join('')
|
|
54
|
+
const lineStart = candidate.lastIndexOf('\n') + 1
|
|
55
|
+
const candidateLine = candidate.slice(lineStart)
|
|
56
|
+
const sourceLineEnd = source.indexOf('\n', lineStart)
|
|
57
|
+
const sourceLine = source.slice(lineStart, sourceLineEnd < 0 ? source.length : sourceLineEnd)
|
|
58
|
+
const match = /^ {0,3}(`+|~+)/.exec(sourceLine)
|
|
59
|
+
if (!match) { return segments.length }
|
|
60
|
+
|
|
61
|
+
const marker = match[1]
|
|
62
|
+
const unresolvedCandidate = marker.length < 3 && sourceLine.trim() === marker
|
|
63
|
+
const completeFence = marker.length >= 3
|
|
64
|
+
if ((unresolvedCandidate || completeFence) && /^ {0,3}(?:`+|~+)/.test(candidateLine)) {
|
|
65
|
+
return 0
|
|
66
|
+
}
|
|
67
|
+
return segments.length
|
|
68
|
+
}
|
|
69
|
+
|
|
31
70
|
function createTimerTextStream(options: TextStreamOptions = {}): TextStreamController {
|
|
32
71
|
const minCharsPerSecond = Math.max(1, options.minCharsPerSecond ?? 40)
|
|
33
|
-
const maxCharsPerSecond = Math.max(minCharsPerSecond, options.maxCharsPerSecond ??
|
|
34
|
-
const targetLatencyMs = Math.max(
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
72
|
+
const maxCharsPerSecond = Math.max(minCharsPerSecond, options.maxCharsPerSecond ?? 1000)
|
|
73
|
+
const targetLatencyMs = Math.max(1, options.targetLatencyMs ?? 900)
|
|
74
|
+
const catchUpLatencyMs = Math.max(1, options.catchUpLatencyMs ?? 350)
|
|
75
|
+
const maxCommitFps = Math.max(1, Math.min(30, options.maxCommitFps ?? 30))
|
|
76
|
+
const maxCharsPerCommit = Math.max(1, options.maxCharsPerCommit ?? 80)
|
|
77
|
+
const catchUpThreshold = Math.max(0, options.catchUpThreshold ?? 600)
|
|
78
|
+
const startDelayMs = Math.max(0, options.startDelayMs ?? 80)
|
|
79
|
+
const flushOnFinish = options.flushOnFinish ?? false
|
|
80
|
+
const burstInitialContent = options.burstInitialContent ?? false
|
|
81
|
+
const burstRevealThresholdChars = Math.max(1, options.burstRevealThresholdChars ?? 2048)
|
|
38
82
|
const listeners = new Set<() => void>()
|
|
39
83
|
const pending: string[] = []
|
|
40
84
|
let pendingOffset = 0
|
|
@@ -45,6 +89,7 @@ function createTimerTextStream(options: TextStreamOptions = {}): TextStreamContr
|
|
|
45
89
|
let destroyed = false
|
|
46
90
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
47
91
|
let lastTick = Date.now()
|
|
92
|
+
let firstPendingAt = 0
|
|
48
93
|
let budget = 0
|
|
49
94
|
|
|
50
95
|
function pendingCount() {
|
|
@@ -52,38 +97,46 @@ function createTimerTextStream(options: TextStreamOptions = {}): TextStreamContr
|
|
|
52
97
|
}
|
|
53
98
|
|
|
54
99
|
function snapshot(): TextStreamSnapshot {
|
|
55
|
-
const pendingChars =
|
|
100
|
+
const pendingChars = Math.max(0, source.length - visible.length)
|
|
56
101
|
return {
|
|
57
|
-
caughtUp:
|
|
102
|
+
caughtUp: pendingCount() === 0,
|
|
58
103
|
done,
|
|
59
|
-
final: done &&
|
|
104
|
+
final: done && pendingCount() === 0,
|
|
60
105
|
paused,
|
|
61
106
|
pendingChars,
|
|
62
107
|
source,
|
|
63
|
-
visible
|
|
108
|
+
visible,
|
|
64
109
|
}
|
|
65
110
|
}
|
|
66
111
|
|
|
67
112
|
function emit() {
|
|
68
|
-
listeners.forEach(
|
|
113
|
+
listeners.forEach(listener => listener())
|
|
69
114
|
}
|
|
70
115
|
|
|
71
116
|
function compactPending() {
|
|
72
|
-
if (pendingOffset < 1024 || pendingOffset * 2 < pending.length) return
|
|
117
|
+
if (pendingOffset < 1024 || pendingOffset * 2 < pending.length) { return }
|
|
73
118
|
pending.splice(0, pendingOffset)
|
|
74
119
|
pendingOffset = 0
|
|
75
120
|
}
|
|
76
121
|
|
|
77
122
|
function schedule() {
|
|
78
|
-
if (destroyed || paused || timer || pendingCount() === 0) return
|
|
123
|
+
if (destroyed || paused || timer || pendingCount() === 0) { return }
|
|
124
|
+
// The scheduler and tick callback are mutually recursive by design.
|
|
125
|
+
// eslint-disable-next-line ts/no-use-before-define
|
|
79
126
|
timer = setTimeout(tick, Math.ceil(1000 / maxCommitFps))
|
|
80
127
|
}
|
|
81
128
|
|
|
82
129
|
function tick() {
|
|
83
130
|
timer = undefined
|
|
84
|
-
if (destroyed || paused) return
|
|
131
|
+
if (destroyed || paused) { return }
|
|
85
132
|
|
|
86
133
|
const now = Date.now()
|
|
134
|
+
if (firstPendingAt && now - firstPendingAt < startDelayMs) {
|
|
135
|
+
schedule()
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
firstPendingAt = 0
|
|
139
|
+
|
|
87
140
|
const deltaMs = Math.max(1, now - lastTick)
|
|
88
141
|
lastTick = now
|
|
89
142
|
const backlog = pendingCount()
|
|
@@ -92,14 +145,28 @@ function createTimerTextStream(options: TextStreamOptions = {}): TextStreamContr
|
|
|
92
145
|
return
|
|
93
146
|
}
|
|
94
147
|
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
const cps = clamp(latencyCps
|
|
148
|
+
const latencyMs = backlog >= catchUpThreshold ? catchUpLatencyMs : targetLatencyMs
|
|
149
|
+
const latencyCps = (backlog * 1000) / latencyMs
|
|
150
|
+
const cps = clamp(latencyCps, minCharsPerSecond, maxCharsPerSecond)
|
|
98
151
|
budget += (cps * deltaMs) / 1000
|
|
99
152
|
const commitCount = Math.min(backlog, maxCharsPerCommit, Math.max(1, Math.floor(budget)))
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
153
|
+
let proposedSegments = pending.slice(pendingOffset, pendingOffset + commitCount)
|
|
154
|
+
let safeCommitCount = fenceSafeCommitCount(visible, source, proposedSegments)
|
|
155
|
+
if (safeCommitCount === 0) {
|
|
156
|
+
const newlineOffset = pending.slice(pendingOffset).findIndex(segment => segment.includes('\n'))
|
|
157
|
+
if (newlineOffset >= 0) {
|
|
158
|
+
proposedSegments = pending.slice(pendingOffset, pendingOffset + newlineOffset + 1)
|
|
159
|
+
safeCommitCount = fenceSafeCommitCount(visible, source, proposedSegments)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (safeCommitCount === 0) {
|
|
163
|
+
emit()
|
|
164
|
+
schedule()
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
budget = Math.max(0, budget - safeCommitCount)
|
|
168
|
+
visible += proposedSegments.slice(0, safeCommitCount).join('')
|
|
169
|
+
pendingOffset += safeCommitCount
|
|
103
170
|
compactPending()
|
|
104
171
|
emit()
|
|
105
172
|
schedule()
|
|
@@ -110,27 +177,45 @@ function createTimerTextStream(options: TextStreamOptions = {}): TextStreamContr
|
|
|
110
177
|
timer = undefined
|
|
111
178
|
}
|
|
112
179
|
|
|
180
|
+
function destroy() {
|
|
181
|
+
if (destroyed) { return }
|
|
182
|
+
destroyed = true
|
|
183
|
+
cancelTimer()
|
|
184
|
+
pending.length = 0
|
|
185
|
+
listeners.clear()
|
|
186
|
+
}
|
|
187
|
+
|
|
113
188
|
return {
|
|
114
|
-
destroy
|
|
115
|
-
|
|
116
|
-
destroyed = true
|
|
117
|
-
cancelTimer()
|
|
118
|
-
pending.length = 0
|
|
119
|
-
listeners.clear()
|
|
120
|
-
},
|
|
189
|
+
destroy,
|
|
190
|
+
dispose: destroy,
|
|
121
191
|
enqueue(chunk) {
|
|
122
|
-
if (destroyed || !chunk) return
|
|
123
|
-
if (done) done = false
|
|
192
|
+
if (destroyed || !chunk) { return }
|
|
193
|
+
if (done) { done = false }
|
|
194
|
+
const initialChunk = source.length === 0
|
|
124
195
|
source += chunk
|
|
125
196
|
pending.push(...splitGraphemes(chunk))
|
|
197
|
+
if (initialChunk) { firstPendingAt = Date.now() }
|
|
126
198
|
lastTick = Date.now()
|
|
199
|
+
|
|
200
|
+
if (
|
|
201
|
+
initialChunk
|
|
202
|
+
&& burstInitialContent
|
|
203
|
+
&& pendingCount() >= burstRevealThresholdChars
|
|
204
|
+
&& !hasUnclosedFence(source)
|
|
205
|
+
) {
|
|
206
|
+
visible = source
|
|
207
|
+
pending.length = 0
|
|
208
|
+
pendingOffset = 0
|
|
209
|
+
firstPendingAt = 0
|
|
210
|
+
}
|
|
211
|
+
|
|
127
212
|
emit()
|
|
128
213
|
schedule()
|
|
129
214
|
},
|
|
130
215
|
finish(finishOptions = {}) {
|
|
131
|
-
if (destroyed) return
|
|
216
|
+
if (destroyed) { return }
|
|
132
217
|
done = true
|
|
133
|
-
if (finishOptions.flush) {
|
|
218
|
+
if (finishOptions.flush ?? flushOnFinish) {
|
|
134
219
|
visible = source
|
|
135
220
|
pending.length = 0
|
|
136
221
|
pendingOffset = 0
|
|
@@ -140,7 +225,7 @@ function createTimerTextStream(options: TextStreamOptions = {}): TextStreamContr
|
|
|
140
225
|
schedule()
|
|
141
226
|
},
|
|
142
227
|
flush() {
|
|
143
|
-
if (destroyed) return
|
|
228
|
+
if (destroyed) { return }
|
|
144
229
|
visible = source
|
|
145
230
|
pending.length = 0
|
|
146
231
|
pendingOffset = 0
|
|
@@ -150,12 +235,12 @@ function createTimerTextStream(options: TextStreamOptions = {}): TextStreamContr
|
|
|
150
235
|
},
|
|
151
236
|
getSnapshot: snapshot,
|
|
152
237
|
pause() {
|
|
153
|
-
if (destroyed || paused) return
|
|
238
|
+
if (destroyed || paused) { return }
|
|
154
239
|
paused = true
|
|
155
240
|
cancelTimer()
|
|
156
241
|
emit()
|
|
157
242
|
},
|
|
158
|
-
reset(content = '') {
|
|
243
|
+
reset(content = '', _resetOptions = {}) {
|
|
159
244
|
cancelTimer()
|
|
160
245
|
source = content
|
|
161
246
|
visible = content
|
|
@@ -164,21 +249,22 @@ function createTimerTextStream(options: TextStreamOptions = {}): TextStreamContr
|
|
|
164
249
|
done = false
|
|
165
250
|
paused = false
|
|
166
251
|
budget = 0
|
|
252
|
+
firstPendingAt = 0
|
|
167
253
|
lastTick = Date.now()
|
|
168
254
|
emit()
|
|
169
255
|
},
|
|
170
256
|
resume() {
|
|
171
|
-
if (destroyed || !paused) return
|
|
257
|
+
if (destroyed || !paused) { return }
|
|
172
258
|
paused = false
|
|
173
259
|
lastTick = Date.now()
|
|
174
260
|
emit()
|
|
175
261
|
schedule()
|
|
176
262
|
},
|
|
177
263
|
subscribe(listener) {
|
|
178
|
-
if (destroyed) return () => {}
|
|
264
|
+
if (destroyed) { return () => {} }
|
|
179
265
|
listeners.add(listener)
|
|
180
266
|
return () => listeners.delete(listener)
|
|
181
|
-
}
|
|
267
|
+
},
|
|
182
268
|
}
|
|
183
269
|
}
|
|
184
270
|
|
|
@@ -190,6 +276,7 @@ export function createTextStream(options: TextStreamOptions = {}): TextStreamCon
|
|
|
190
276
|
const controller = createSmoothMarkdownStream(options)
|
|
191
277
|
return {
|
|
192
278
|
destroy: controller.destroy,
|
|
279
|
+
dispose: controller.dispose,
|
|
193
280
|
enqueue: controller.enqueue,
|
|
194
281
|
finish: controller.finish,
|
|
195
282
|
flush: controller.flush,
|
|
@@ -197,6 +284,6 @@ export function createTextStream(options: TextStreamOptions = {}): TextStreamCon
|
|
|
197
284
|
pause: controller.pause,
|
|
198
285
|
reset: controller.reset,
|
|
199
286
|
resume: controller.resume,
|
|
200
|
-
subscribe: controller.subscribe
|
|
287
|
+
subscribe: controller.subscribe,
|
|
201
288
|
}
|
|
202
289
|
}
|