@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/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from './agent-stream'
2
+ export * from './event-channel'
3
+ export * from './event-parser'
4
+ export * from './markdown'
5
+ export * from './markdown-view'
6
+ export * from './sse'
7
+ export * from './text-stream'
8
+ export * from './types'
@@ -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
+ }
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ export function isUnknownRecord(value: unknown): value is Record<string, unknown> {
2
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
3
+ }