@varo-ui/ai 1.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/dist/index.d.mts +249 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +984 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +52 -0
- package/src/agent-stream.ts +284 -0
- package/src/event-channel.ts +49 -0
- package/src/event-parser.ts +159 -0
- package/src/index.ts +8 -0
- package/src/markdown-view.ts +227 -0
- package/src/markdown.ts +58 -0
- package/src/sse.ts +161 -0
- package/src/text-stream.ts +202 -0
- package/src/type-guards.ts +3 -0
- package/src/types.ts +222 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { isUnknownRecord } from './type-guards'
|
|
2
|
+
import type { ParsedNode } from 'stream-markdown-parser'
|
|
3
|
+
|
|
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
|
+
|
|
35
|
+
export interface AgentMarkdownViewNode {
|
|
36
|
+
align?: 'center' | 'left' | 'right'
|
|
37
|
+
alt?: string
|
|
38
|
+
checked?: boolean
|
|
39
|
+
children?: AgentMarkdownViewNode[]
|
|
40
|
+
header?: boolean
|
|
41
|
+
href?: string
|
|
42
|
+
kind: AgentMarkdownNodeKind
|
|
43
|
+
language?: string
|
|
44
|
+
level?: number
|
|
45
|
+
loading?: boolean
|
|
46
|
+
ordered?: boolean
|
|
47
|
+
start?: number
|
|
48
|
+
text?: string
|
|
49
|
+
title?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stringValue(value: unknown, fallback = '') {
|
|
53
|
+
return typeof value === 'string' ? value : fallback
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function booleanValue(value: unknown) {
|
|
57
|
+
return typeof value === 'boolean' ? value : undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function numberValue(value: unknown) {
|
|
61
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function alignValue(value: unknown): 'center' | 'left' | 'right' | undefined {
|
|
65
|
+
return value === 'center' || value === 'left' || value === 'right' ? value : undefined
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function nodeField(node: ParsedNode, key: string): unknown {
|
|
69
|
+
return key in node ? Reflect.get(node, key) : undefined
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeUnknownNode(value: unknown): AgentMarkdownViewNode | undefined {
|
|
73
|
+
if (!isUnknownRecord(value) || typeof value.type !== 'string' || typeof value.raw !== 'string') return undefined
|
|
74
|
+
// The required BaseNode fields are validated above; extension fields stay runtime-checked.
|
|
75
|
+
const node = value as ParsedNode
|
|
76
|
+
return normalizeMarkdownNode(node)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeChildren(value: unknown): AgentMarkdownViewNode[] {
|
|
80
|
+
if (!Array.isArray(value)) return []
|
|
81
|
+
const result: AgentMarkdownViewNode[] = []
|
|
82
|
+
value.forEach((item) => {
|
|
83
|
+
const normalized = normalizeUnknownNode(item)
|
|
84
|
+
if (normalized) result.push(normalized)
|
|
85
|
+
})
|
|
86
|
+
return result
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function textNode(text: string): AgentMarkdownViewNode {
|
|
90
|
+
return { kind: 'text', text }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function normalizeMarkdownNode(node: ParsedNode): AgentMarkdownViewNode {
|
|
94
|
+
const fallback = stringValue(node.raw)
|
|
95
|
+
|
|
96
|
+
switch (node.type) {
|
|
97
|
+
case 'text':
|
|
98
|
+
return textNode(stringValue(node.content, fallback))
|
|
99
|
+
case 'heading':
|
|
100
|
+
return {
|
|
101
|
+
children: normalizeChildren(node.children),
|
|
102
|
+
kind: 'heading',
|
|
103
|
+
level: numberValue(nodeField(node, 'level')) ?? 2,
|
|
104
|
+
text: stringValue(nodeField(node, 'text'))
|
|
105
|
+
}
|
|
106
|
+
case 'paragraph':
|
|
107
|
+
case 'inline':
|
|
108
|
+
return { children: normalizeChildren(node.children), kind: 'paragraph' }
|
|
109
|
+
case 'list':
|
|
110
|
+
return {
|
|
111
|
+
children: normalizeChildren(nodeField(node, 'items')),
|
|
112
|
+
kind: 'list',
|
|
113
|
+
ordered: booleanValue(nodeField(node, 'ordered')) ?? false,
|
|
114
|
+
start: numberValue(nodeField(node, 'start'))
|
|
115
|
+
}
|
|
116
|
+
case 'list_item':
|
|
117
|
+
return { children: normalizeChildren(node.children), kind: 'list-item' }
|
|
118
|
+
case 'code_block':
|
|
119
|
+
return {
|
|
120
|
+
kind: 'code-block',
|
|
121
|
+
language: stringValue(nodeField(node, 'language')),
|
|
122
|
+
loading: booleanValue(node.loading),
|
|
123
|
+
text: stringValue(node.code, fallback)
|
|
124
|
+
}
|
|
125
|
+
case 'inline_code':
|
|
126
|
+
return { kind: 'inline-code', text: stringValue(node.code, fallback) }
|
|
127
|
+
case 'link':
|
|
128
|
+
return {
|
|
129
|
+
children: normalizeChildren(node.children),
|
|
130
|
+
href: stringValue(nodeField(node, 'href')),
|
|
131
|
+
kind: 'link',
|
|
132
|
+
text: stringValue(nodeField(node, 'text')),
|
|
133
|
+
title: stringValue(nodeField(node, 'title'))
|
|
134
|
+
}
|
|
135
|
+
case 'image':
|
|
136
|
+
return {
|
|
137
|
+
alt: stringValue(nodeField(node, 'alt')),
|
|
138
|
+
href: stringValue(nodeField(node, 'src')),
|
|
139
|
+
kind: 'image',
|
|
140
|
+
title: stringValue(nodeField(node, 'title'))
|
|
141
|
+
}
|
|
142
|
+
case 'thematic_break':
|
|
143
|
+
return { kind: 'thematic-break' }
|
|
144
|
+
case 'blockquote':
|
|
145
|
+
return { children: normalizeChildren(node.children), kind: 'blockquote' }
|
|
146
|
+
case 'table': {
|
|
147
|
+
const header = nodeField(node, 'header')
|
|
148
|
+
const rows = nodeField(node, 'rows')
|
|
149
|
+
return {
|
|
150
|
+
children: normalizeChildren(Array.isArray(rows) ? [header, ...rows] : [header]),
|
|
151
|
+
kind: 'table'
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
case 'table_row':
|
|
155
|
+
return { children: normalizeChildren(nodeField(node, 'cells')), kind: 'table-row' }
|
|
156
|
+
case 'table_cell':
|
|
157
|
+
return {
|
|
158
|
+
align: alignValue(nodeField(node, 'align')),
|
|
159
|
+
children: normalizeChildren(node.children),
|
|
160
|
+
header: booleanValue(nodeField(node, 'header')),
|
|
161
|
+
kind: 'table-cell'
|
|
162
|
+
}
|
|
163
|
+
case 'strong':
|
|
164
|
+
return { children: normalizeChildren(node.children), kind: 'strong' }
|
|
165
|
+
case 'emphasis':
|
|
166
|
+
return { children: normalizeChildren(node.children), kind: 'emphasis' }
|
|
167
|
+
case 'strikethrough':
|
|
168
|
+
return { children: normalizeChildren(node.children), kind: 'strikethrough' }
|
|
169
|
+
case 'highlight':
|
|
170
|
+
return { children: normalizeChildren(node.children), kind: 'highlight' }
|
|
171
|
+
case 'insert':
|
|
172
|
+
return { children: normalizeChildren(node.children), kind: 'insert' }
|
|
173
|
+
case 'subscript':
|
|
174
|
+
return { children: normalizeChildren(node.children), kind: 'subscript' }
|
|
175
|
+
case 'superscript':
|
|
176
|
+
return { children: normalizeChildren(node.children), kind: 'superscript' }
|
|
177
|
+
case 'checkbox':
|
|
178
|
+
case 'checkbox_input':
|
|
179
|
+
return { checked: booleanValue(nodeField(node, 'checked')), kind: 'checkbox' }
|
|
180
|
+
case 'emoji':
|
|
181
|
+
return textNode(stringValue(nodeField(node, 'markup'), fallback))
|
|
182
|
+
case 'definition_list':
|
|
183
|
+
return { children: normalizeChildren(nodeField(node, 'items')), kind: 'definition-list' }
|
|
184
|
+
case 'definition_item':
|
|
185
|
+
return {
|
|
186
|
+
children: [
|
|
187
|
+
{ children: normalizeChildren(nodeField(node, 'term')), header: true, kind: 'definition' },
|
|
188
|
+
{ children: normalizeChildren(nodeField(node, 'definition')), kind: 'definition' }
|
|
189
|
+
],
|
|
190
|
+
kind: 'definition'
|
|
191
|
+
}
|
|
192
|
+
case 'footnote':
|
|
193
|
+
return { children: normalizeChildren(node.children), kind: 'footnote', title: stringValue(nodeField(node, 'id')) }
|
|
194
|
+
case 'footnote_reference':
|
|
195
|
+
case 'reference':
|
|
196
|
+
return { kind: 'footnote', text: stringValue(nodeField(node, 'id')) }
|
|
197
|
+
case 'admonition':
|
|
198
|
+
return {
|
|
199
|
+
children: normalizeChildren(node.children),
|
|
200
|
+
kind: 'admonition',
|
|
201
|
+
text: stringValue(nodeField(node, 'kind')),
|
|
202
|
+
title: stringValue(nodeField(node, 'title'))
|
|
203
|
+
}
|
|
204
|
+
case 'vmr_container':
|
|
205
|
+
return {
|
|
206
|
+
children: normalizeChildren(node.children),
|
|
207
|
+
kind: 'admonition',
|
|
208
|
+
loading: booleanValue(node.loading),
|
|
209
|
+
title: stringValue(nodeField(node, 'name'))
|
|
210
|
+
}
|
|
211
|
+
case 'hardbreak':
|
|
212
|
+
return { kind: 'hardbreak' }
|
|
213
|
+
case 'math_inline':
|
|
214
|
+
return { kind: 'math-inline', text: stringValue(node.content, fallback) }
|
|
215
|
+
case 'math_block':
|
|
216
|
+
return { kind: 'math-block', text: stringValue(node.content, fallback) }
|
|
217
|
+
case 'html_block':
|
|
218
|
+
case 'html_inline':
|
|
219
|
+
return textNode(stringValue(node.content, fallback))
|
|
220
|
+
default:
|
|
221
|
+
return textNode(fallback)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function normalizeMarkdownNodes(nodes: ParsedNode[]): AgentMarkdownViewNode[] {
|
|
226
|
+
return nodes.map(normalizeMarkdownNode)
|
|
227
|
+
}
|
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getMarkdown,
|
|
3
|
+
isUnsafeHtmlUrl,
|
|
4
|
+
parseMarkdownToStructure,
|
|
5
|
+
type GetMarkdownOptions,
|
|
6
|
+
type ParsedNode
|
|
7
|
+
} from 'stream-markdown-parser'
|
|
8
|
+
|
|
9
|
+
export interface StreamingMarkdownParserOptions {
|
|
10
|
+
customHtmlTags?: readonly string[]
|
|
11
|
+
enableMath?: boolean
|
|
12
|
+
includeSourceMap?: boolean
|
|
13
|
+
markdownItOptions?: GetMarkdownOptions['markdownItOptions']
|
|
14
|
+
validateLink?: (url: string) => boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface StreamingMarkdownParser {
|
|
18
|
+
parse: (content: string, options?: { final?: boolean }) => ParsedNode[]
|
|
19
|
+
reset: () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let parserId = 0
|
|
23
|
+
|
|
24
|
+
export function createStreamingMarkdownParser(
|
|
25
|
+
options: StreamingMarkdownParserOptions = {}
|
|
26
|
+
): StreamingMarkdownParser {
|
|
27
|
+
parserId += 1
|
|
28
|
+
const validateLink = options.validateLink ?? ((url: string) => !isUnsafeHtmlUrl(url))
|
|
29
|
+
const markdown = getMarkdown(`varo-agent-${parserId}`, {
|
|
30
|
+
customHtmlTags: options.customHtmlTags,
|
|
31
|
+
enableMath: options.enableMath ?? true,
|
|
32
|
+
markdownItOptions: {
|
|
33
|
+
breaks: true,
|
|
34
|
+
html: false,
|
|
35
|
+
linkify: true,
|
|
36
|
+
...options.markdownItOptions,
|
|
37
|
+
validateLink
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
parse(content, parseOptions = {}) {
|
|
43
|
+
return parseMarkdownToStructure(content, markdown, {
|
|
44
|
+
customHtmlTags: options.customHtmlTags,
|
|
45
|
+
final: parseOptions.final ?? false,
|
|
46
|
+
includeSourceMap: options.includeSourceMap ?? false,
|
|
47
|
+
reuseStableTopLevelNodes: true,
|
|
48
|
+
streamParse: 'auto',
|
|
49
|
+
validateLink
|
|
50
|
+
})
|
|
51
|
+
},
|
|
52
|
+
reset() {
|
|
53
|
+
markdown.stream?.reset?.()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type { ParsedNode } from 'stream-markdown-parser'
|
package/src/sse.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createAgentEventChannel } from './event-channel'
|
|
2
|
+
import { parseAgentStreamEvent } from './event-parser'
|
|
3
|
+
import type { AgentStreamEvent } from './types'
|
|
4
|
+
|
|
5
|
+
export interface AgentSseEventSource {
|
|
6
|
+
end: () => void
|
|
7
|
+
fail: (error: unknown) => void
|
|
8
|
+
feed: (chunk: string | ArrayBuffer | Uint8Array) => void
|
|
9
|
+
source: AsyncIterable<AgentStreamEvent>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Utf8ChunkDecoder {
|
|
13
|
+
decode: (chunk: Uint8Array, final?: boolean) => string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function createFallbackUtf8Decoder(): Utf8ChunkDecoder {
|
|
17
|
+
let carry = new Uint8Array(0)
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
decode(chunk, final = false) {
|
|
21
|
+
const bytes = new Uint8Array(carry.length + chunk.length)
|
|
22
|
+
bytes.set(carry)
|
|
23
|
+
bytes.set(chunk, carry.length)
|
|
24
|
+
let cursor = 0
|
|
25
|
+
let result = ''
|
|
26
|
+
|
|
27
|
+
while (cursor < bytes.length) {
|
|
28
|
+
const first = bytes[cursor]
|
|
29
|
+
if (first < 0x80) {
|
|
30
|
+
result += String.fromCodePoint(first)
|
|
31
|
+
cursor += 1
|
|
32
|
+
continue
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const size = first >= 0xf0 ? 4 : first >= 0xe0 ? 3 : first >= 0xc0 ? 2 : 1
|
|
36
|
+
if (size === 1) {
|
|
37
|
+
result += '\uFFFD'
|
|
38
|
+
cursor += 1
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (cursor + size > bytes.length) break
|
|
42
|
+
|
|
43
|
+
let codePoint = first & (size === 2 ? 0x1f : size === 3 ? 0x0f : 0x07)
|
|
44
|
+
let valid = true
|
|
45
|
+
for (let index = 1; index < size; index += 1) {
|
|
46
|
+
const continuation = bytes[cursor + index]
|
|
47
|
+
if ((continuation & 0xc0) !== 0x80) {
|
|
48
|
+
valid = false
|
|
49
|
+
break
|
|
50
|
+
}
|
|
51
|
+
codePoint = (codePoint << 6) | (continuation & 0x3f)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!valid || codePoint > 0x10ffff) {
|
|
55
|
+
result += '\uFFFD'
|
|
56
|
+
cursor += 1
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
result += String.fromCodePoint(codePoint)
|
|
61
|
+
cursor += size
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
carry = bytes.slice(cursor)
|
|
65
|
+
if (final && carry.length > 0) {
|
|
66
|
+
result += '\uFFFD'
|
|
67
|
+
carry = new Uint8Array(0)
|
|
68
|
+
}
|
|
69
|
+
return result
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createUtf8Decoder(): Utf8ChunkDecoder {
|
|
75
|
+
if (typeof TextDecoder === 'function') {
|
|
76
|
+
const decoder = new TextDecoder()
|
|
77
|
+
return {
|
|
78
|
+
decode(chunk, final = false) {
|
|
79
|
+
return decoder.decode(chunk, { stream: !final })
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return createFallbackUtf8Decoder()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toBytes(chunk: ArrayBuffer | Uint8Array) {
|
|
87
|
+
return chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createAgentSseEventSource(): AgentSseEventSource {
|
|
91
|
+
const channel = createAgentEventChannel()
|
|
92
|
+
const decoder = createUtf8Decoder()
|
|
93
|
+
const dataLines: string[] = []
|
|
94
|
+
let textBuffer = ''
|
|
95
|
+
let ended = false
|
|
96
|
+
|
|
97
|
+
function dispatch() {
|
|
98
|
+
if (dataLines.length === 0 || ended) return
|
|
99
|
+
const payload = dataLines.join('\n')
|
|
100
|
+
dataLines.length = 0
|
|
101
|
+
if (payload === '[DONE]') {
|
|
102
|
+
channel.push({ type: 'done' })
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
channel.push(parseAgentStreamEvent(JSON.parse(payload)))
|
|
108
|
+
} catch (error) {
|
|
109
|
+
ended = true
|
|
110
|
+
channel.fail(error)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function processLine(line: string) {
|
|
115
|
+
const normalized = line.endsWith('\r') ? line.slice(0, -1) : line
|
|
116
|
+
if (normalized.length === 0) {
|
|
117
|
+
dispatch()
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
if (normalized.startsWith(':')) return
|
|
121
|
+
if (normalized === 'data') {
|
|
122
|
+
dataLines.push('')
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
if (normalized.startsWith('data:')) {
|
|
126
|
+
const value = normalized.slice(5)
|
|
127
|
+
dataLines.push(value.startsWith(' ') ? value.slice(1) : value)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function appendText(value: string) {
|
|
132
|
+
textBuffer += value
|
|
133
|
+
let lineEnd = textBuffer.indexOf('\n')
|
|
134
|
+
while (lineEnd >= 0) {
|
|
135
|
+
processLine(textBuffer.slice(0, lineEnd))
|
|
136
|
+
textBuffer = textBuffer.slice(lineEnd + 1)
|
|
137
|
+
lineEnd = textBuffer.indexOf('\n')
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
end() {
|
|
143
|
+
if (ended) return
|
|
144
|
+
appendText(decoder.decode(new Uint8Array(0), true))
|
|
145
|
+
if (textBuffer.length > 0) processLine(textBuffer)
|
|
146
|
+
dispatch()
|
|
147
|
+
ended = true
|
|
148
|
+
channel.end()
|
|
149
|
+
},
|
|
150
|
+
fail(error) {
|
|
151
|
+
if (ended) return
|
|
152
|
+
ended = true
|
|
153
|
+
channel.fail(error)
|
|
154
|
+
},
|
|
155
|
+
feed(chunk) {
|
|
156
|
+
if (ended) return
|
|
157
|
+
appendText(typeof chunk === 'string' ? chunk : decoder.decode(toBytes(chunk)))
|
|
158
|
+
},
|
|
159
|
+
source: channel.source
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createSmoothMarkdownStream } from 'markstream-core'
|
|
2
|
+
import type { TextStreamController, TextStreamOptions, TextStreamSnapshot } from './types'
|
|
3
|
+
|
|
4
|
+
interface SegmenterLike {
|
|
5
|
+
segment: (value: string) => Iterable<{ segment: string }>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function splitGraphemes(value: string): string[] {
|
|
9
|
+
const intlRuntime: unknown = Intl
|
|
10
|
+
if (
|
|
11
|
+
typeof intlRuntime !== 'object'
|
|
12
|
+
|| intlRuntime === null
|
|
13
|
+
|| !('Segmenter' in intlRuntime)
|
|
14
|
+
|| typeof intlRuntime.Segmenter !== 'function'
|
|
15
|
+
) {
|
|
16
|
+
return Array.from(value)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Runtime constructor shape is checked above; TypeScript's configured Intl lib omits Segmenter.
|
|
20
|
+
const Segmenter = intlRuntime.Segmenter as new (
|
|
21
|
+
locale?: string,
|
|
22
|
+
options?: { granularity: 'grapheme' }
|
|
23
|
+
) => SegmenterLike
|
|
24
|
+
return Array.from(new Segmenter(undefined, { granularity: 'grapheme' }).segment(value), (item) => item.segment)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function clamp(value: number, min: number, max: number) {
|
|
28
|
+
return Math.min(max, Math.max(min, value))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createTimerTextStream(options: TextStreamOptions = {}): TextStreamController {
|
|
32
|
+
const minCharsPerSecond = Math.max(1, options.minCharsPerSecond ?? 40)
|
|
33
|
+
const maxCharsPerSecond = Math.max(minCharsPerSecond, options.maxCharsPerSecond ?? 800)
|
|
34
|
+
const targetLatencyMs = Math.max(50, options.targetLatencyMs ?? 700)
|
|
35
|
+
const maxCommitFps = Math.max(1, Math.min(30, options.maxCommitFps ?? 20))
|
|
36
|
+
const maxCharsPerCommit = Math.max(1, options.maxCharsPerCommit ?? 64)
|
|
37
|
+
const catchUpThreshold = Math.max(1, options.catchUpThreshold ?? 320)
|
|
38
|
+
const listeners = new Set<() => void>()
|
|
39
|
+
const pending: string[] = []
|
|
40
|
+
let pendingOffset = 0
|
|
41
|
+
let source = ''
|
|
42
|
+
let visible = ''
|
|
43
|
+
let done = false
|
|
44
|
+
let paused = false
|
|
45
|
+
let destroyed = false
|
|
46
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
47
|
+
let lastTick = Date.now()
|
|
48
|
+
let budget = 0
|
|
49
|
+
|
|
50
|
+
function pendingCount() {
|
|
51
|
+
return pending.length - pendingOffset
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function snapshot(): TextStreamSnapshot {
|
|
55
|
+
const pendingChars = pendingCount()
|
|
56
|
+
return {
|
|
57
|
+
caughtUp: pendingChars === 0,
|
|
58
|
+
done,
|
|
59
|
+
final: done && pendingChars === 0,
|
|
60
|
+
paused,
|
|
61
|
+
pendingChars,
|
|
62
|
+
source,
|
|
63
|
+
visible
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function emit() {
|
|
68
|
+
listeners.forEach((listener) => listener())
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function compactPending() {
|
|
72
|
+
if (pendingOffset < 1024 || pendingOffset * 2 < pending.length) return
|
|
73
|
+
pending.splice(0, pendingOffset)
|
|
74
|
+
pendingOffset = 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function schedule() {
|
|
78
|
+
if (destroyed || paused || timer || pendingCount() === 0) return
|
|
79
|
+
timer = setTimeout(tick, Math.ceil(1000 / maxCommitFps))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function tick() {
|
|
83
|
+
timer = undefined
|
|
84
|
+
if (destroyed || paused) return
|
|
85
|
+
|
|
86
|
+
const now = Date.now()
|
|
87
|
+
const deltaMs = Math.max(1, now - lastTick)
|
|
88
|
+
lastTick = now
|
|
89
|
+
const backlog = pendingCount()
|
|
90
|
+
if (backlog === 0) {
|
|
91
|
+
emit()
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const latencyCps = (backlog * 1000) / targetLatencyMs
|
|
96
|
+
const catchUpBoost = backlog >= catchUpThreshold ? 1.8 : 1
|
|
97
|
+
const cps = clamp(latencyCps * catchUpBoost, minCharsPerSecond, maxCharsPerSecond)
|
|
98
|
+
budget += (cps * deltaMs) / 1000
|
|
99
|
+
const commitCount = Math.min(backlog, maxCharsPerCommit, Math.max(1, Math.floor(budget)))
|
|
100
|
+
budget = Math.max(0, budget - commitCount)
|
|
101
|
+
visible += pending.slice(pendingOffset, pendingOffset + commitCount).join('')
|
|
102
|
+
pendingOffset += commitCount
|
|
103
|
+
compactPending()
|
|
104
|
+
emit()
|
|
105
|
+
schedule()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function cancelTimer() {
|
|
109
|
+
clearTimeout(timer)
|
|
110
|
+
timer = undefined
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
destroy() {
|
|
115
|
+
if (destroyed) return
|
|
116
|
+
destroyed = true
|
|
117
|
+
cancelTimer()
|
|
118
|
+
pending.length = 0
|
|
119
|
+
listeners.clear()
|
|
120
|
+
},
|
|
121
|
+
enqueue(chunk) {
|
|
122
|
+
if (destroyed || !chunk) return
|
|
123
|
+
if (done) done = false
|
|
124
|
+
source += chunk
|
|
125
|
+
pending.push(...splitGraphemes(chunk))
|
|
126
|
+
lastTick = Date.now()
|
|
127
|
+
emit()
|
|
128
|
+
schedule()
|
|
129
|
+
},
|
|
130
|
+
finish(finishOptions = {}) {
|
|
131
|
+
if (destroyed) return
|
|
132
|
+
done = true
|
|
133
|
+
if (finishOptions.flush) {
|
|
134
|
+
visible = source
|
|
135
|
+
pending.length = 0
|
|
136
|
+
pendingOffset = 0
|
|
137
|
+
cancelTimer()
|
|
138
|
+
}
|
|
139
|
+
emit()
|
|
140
|
+
schedule()
|
|
141
|
+
},
|
|
142
|
+
flush() {
|
|
143
|
+
if (destroyed) return
|
|
144
|
+
visible = source
|
|
145
|
+
pending.length = 0
|
|
146
|
+
pendingOffset = 0
|
|
147
|
+
budget = 0
|
|
148
|
+
cancelTimer()
|
|
149
|
+
emit()
|
|
150
|
+
},
|
|
151
|
+
getSnapshot: snapshot,
|
|
152
|
+
pause() {
|
|
153
|
+
if (destroyed || paused) return
|
|
154
|
+
paused = true
|
|
155
|
+
cancelTimer()
|
|
156
|
+
emit()
|
|
157
|
+
},
|
|
158
|
+
reset(content = '') {
|
|
159
|
+
cancelTimer()
|
|
160
|
+
source = content
|
|
161
|
+
visible = content
|
|
162
|
+
pending.length = 0
|
|
163
|
+
pendingOffset = 0
|
|
164
|
+
done = false
|
|
165
|
+
paused = false
|
|
166
|
+
budget = 0
|
|
167
|
+
lastTick = Date.now()
|
|
168
|
+
emit()
|
|
169
|
+
},
|
|
170
|
+
resume() {
|
|
171
|
+
if (destroyed || !paused) return
|
|
172
|
+
paused = false
|
|
173
|
+
lastTick = Date.now()
|
|
174
|
+
emit()
|
|
175
|
+
schedule()
|
|
176
|
+
},
|
|
177
|
+
subscribe(listener) {
|
|
178
|
+
if (destroyed) return () => {}
|
|
179
|
+
listeners.add(listener)
|
|
180
|
+
return () => listeners.delete(listener)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function createTextStream(options: TextStreamOptions = {}): TextStreamController {
|
|
186
|
+
if (typeof requestAnimationFrame !== 'function' || typeof cancelAnimationFrame !== 'function') {
|
|
187
|
+
return createTimerTextStream(options)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const controller = createSmoothMarkdownStream(options)
|
|
191
|
+
return {
|
|
192
|
+
destroy: controller.destroy,
|
|
193
|
+
enqueue: controller.enqueue,
|
|
194
|
+
finish: controller.finish,
|
|
195
|
+
flush: controller.flush,
|
|
196
|
+
getSnapshot: controller.getSnapshot,
|
|
197
|
+
pause: controller.pause,
|
|
198
|
+
reset: controller.reset,
|
|
199
|
+
resume: controller.resume,
|
|
200
|
+
subscribe: controller.subscribe
|
|
201
|
+
}
|
|
202
|
+
}
|