@linxin666/dsh-remote-web-ui 0.1.16 → 0.1.18
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.i18n.yaml +2 -2
- package/README.md +5 -2
- package/README.zh.md +2 -2
- package/assets/apple-touch-icon.png +0 -0
- package/lib/client.js +48 -58
- package/lib/client.js.map +1 -1
- package/lib/index.js +76 -14
- package/lib/mobile.js +649 -45
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +11 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +11 -5
- package/lib/types/mobile/markdown.d.ts +26 -0
- package/lib/types/mobile/markdown.d.ts.map +1 -0
- package/lib/types/mobile/markdown.js +272 -0
- package/lib/types/mobile/markdown.test.d.ts +2 -0
- package/lib/types/mobile/markdown.test.d.ts.map +1 -0
- package/lib/types/mobile/markdown.test.js +67 -0
- package/lib/types/mobile/messages.d.ts +14 -0
- package/lib/types/mobile/messages.d.ts.map +1 -1
- package/lib/types/mobile/messages.js +54 -2
- package/lib/types/mobile/messages.test.js +99 -0
- package/lib/types/mobile/mobile-styles.d.ts +1 -1
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
- package/lib/types/mobile/mobile-styles.js +167 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +81 -5
- package/lib/types/mobile/views/ChatView.test.js +105 -0
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +19 -2
- package/lib/types/mobile-routes.d.ts.map +1 -1
- package/lib/types/mobile-routes.js +32 -0
- package/lib/types/update.d.ts +5 -3
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +22 -6
- package/package.json +2 -1
- package/src/client/PluginSettingsCard.tsx +50 -47
- package/src/client/settings-card.module.css +9 -0
- package/src/mobile/markdown.test.ts +77 -0
- package/src/mobile/markdown.ts +278 -0
- package/src/mobile/messages.test.ts +106 -0
- package/src/mobile/messages.ts +66 -2
- package/src/mobile/mobile-styles.ts +167 -0
- package/src/mobile/views/ChatView.test.tsx +121 -0
- package/src/mobile/views/ChatView.tsx +164 -6
- package/src/mobile-api.ts +19 -2
- package/src/mobile-routes.ts +33 -0
- package/src/update.ts +23 -6
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A compact GFM-subset markdown renderer for the mobile chat: headings,
|
|
3
|
+
* paragraphs, fenced + inline code, bold/italic/strikethrough, links,
|
|
4
|
+
* images, lists, blockquotes, hr, and tables. All HTML is escaped before
|
|
5
|
+
* transformation — the output only ever contains the renderer's own tags.
|
|
6
|
+
* Dependency-free on purpose (the mobile bundle stays at ~456 KB); the
|
|
7
|
+
* escape-first + protocol allow-list design mirrors the desktop panel's
|
|
8
|
+
* preview renderer (dsh-aionui-panel/src/client/preview/markdown.ts).
|
|
9
|
+
* Pure and exported for tests.
|
|
10
|
+
* @module dsh-remote-web-ui/mobile/markdown
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Escape HTML special characters. */
|
|
14
|
+
export function escapeHtml(text: string): string {
|
|
15
|
+
return text
|
|
16
|
+
.replace(/&/g, '&')
|
|
17
|
+
.replace(/</g, '<')
|
|
18
|
+
.replace(/>/g, '>')
|
|
19
|
+
.replace(/"/g, '"')
|
|
20
|
+
.replace(/'/g, ''')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Guard a raw link/image target against dangerous protocols. Returns the
|
|
25
|
+
* (trimmed) raw string when safe, else null. Only http:, https:, mailto:
|
|
26
|
+
* and fragment anchors (#...) are allowed; anything with another scheme
|
|
27
|
+
* — javascript:, data:, vbscript:, etc. — is rejected so the value never
|
|
28
|
+
* reaches dangerouslySetInnerHTML.
|
|
29
|
+
*/
|
|
30
|
+
export function safeUrl(raw: string): string | null {
|
|
31
|
+
const trimmed = raw.trim()
|
|
32
|
+
if (trimmed === '') return null
|
|
33
|
+
if (trimmed.startsWith('#')) return trimmed
|
|
34
|
+
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)
|
|
35
|
+
if (scheme === null) return trimmed
|
|
36
|
+
const name = scheme[1].toLowerCase()
|
|
37
|
+
return name === 'http' || name === 'https' || name === 'mailto' ? trimmed : null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Find the ')' closing a link/image target, skipping nested parens. */
|
|
41
|
+
function findCloseParen(text: string, from: number): number {
|
|
42
|
+
let depth = 0
|
|
43
|
+
for (let i = from; i < text.length; i += 1) {
|
|
44
|
+
const char = text[i]
|
|
45
|
+
if (char === '(') depth += 1
|
|
46
|
+
else if (char === ')') {
|
|
47
|
+
if (depth === 0) return i
|
|
48
|
+
depth -= 1
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return -1
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Inline pass: code spans, bold, italic, strikethrough, images, links. */
|
|
55
|
+
export function renderInline(text: string): string {
|
|
56
|
+
let out = ''
|
|
57
|
+
let i = 0
|
|
58
|
+
const n = text.length
|
|
59
|
+
while (i < n) {
|
|
60
|
+
const char = text[i]
|
|
61
|
+
// Fenced inline code first.
|
|
62
|
+
if (char === '`') {
|
|
63
|
+
const end = text.indexOf('`', i + 1)
|
|
64
|
+
if (end !== -1) {
|
|
65
|
+
out += '<code>' + escapeHtml(text.slice(i + 1, end)) + '</code>'
|
|
66
|
+
i = end + 1
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Image 
|
|
71
|
+
if (char === '!' && text[i + 1] === '[') {
|
|
72
|
+
const close = text.indexOf('](', i + 2)
|
|
73
|
+
if (close !== -1) {
|
|
74
|
+
const parenEnd = findCloseParen(text, close + 2)
|
|
75
|
+
if (parenEnd !== -1) {
|
|
76
|
+
const alt = text.slice(i + 2, close)
|
|
77
|
+
const src = text.slice(close + 2, parenEnd)
|
|
78
|
+
const safe = safeUrl(src)
|
|
79
|
+
if (safe === null) {
|
|
80
|
+
out += escapeHtml(alt)
|
|
81
|
+
} else {
|
|
82
|
+
const srcEsc = escapeHtml(safe).replace(/\s+/g, '%20')
|
|
83
|
+
out += '<img alt="' + escapeHtml(alt) + '" src="' + srcEsc + '" />'
|
|
84
|
+
}
|
|
85
|
+
i = parenEnd + 1
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Link [text](href)
|
|
91
|
+
if (char === '[') {
|
|
92
|
+
const close = text.indexOf('](', i + 1)
|
|
93
|
+
if (close !== -1) {
|
|
94
|
+
const parenEnd = findCloseParen(text, close + 2)
|
|
95
|
+
if (parenEnd !== -1) {
|
|
96
|
+
const label = text.slice(i + 1, close)
|
|
97
|
+
const href = text.slice(close + 2, parenEnd)
|
|
98
|
+
const safe = safeUrl(href)
|
|
99
|
+
if (safe === null) {
|
|
100
|
+
out += renderInline(label)
|
|
101
|
+
} else {
|
|
102
|
+
out += '<a href="' + escapeHtml(safe) + '" target="_blank" rel="noopener noreferrer">' + renderInline(label) + '</a>'
|
|
103
|
+
}
|
|
104
|
+
i = parenEnd + 1
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Bold **text**
|
|
110
|
+
if (char === '*' && text[i + 1] === '*') {
|
|
111
|
+
const end = text.indexOf('**', i + 2)
|
|
112
|
+
if (end !== -1) {
|
|
113
|
+
out += '<strong>' + renderInline(text.slice(i + 2, end)) + '</strong>'
|
|
114
|
+
i = end + 2
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Italic *text*
|
|
119
|
+
if (char === '*' && text[i - 1] !== '*' && text[i + 1] !== '*') {
|
|
120
|
+
const end = text.indexOf('*', i + 1)
|
|
121
|
+
if (end !== -1 && text[end + 1] !== '*') {
|
|
122
|
+
out += '<em>' + renderInline(text.slice(i + 1, end)) + '</em>'
|
|
123
|
+
i = end + 1
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Strikethrough ~~text~~
|
|
128
|
+
if (char === '~' && text[i + 1] === '~') {
|
|
129
|
+
const end = text.indexOf('~~', i + 2)
|
|
130
|
+
if (end !== -1) {
|
|
131
|
+
out += '<del>' + renderInline(text.slice(i + 2, end)) + '</del>'
|
|
132
|
+
i = end + 2
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
out += escapeHtml(char)
|
|
137
|
+
i += 1
|
|
138
|
+
}
|
|
139
|
+
return out
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Render a markdown document to HTML (block pass). */
|
|
143
|
+
export function renderMarkdown(source: string): string {
|
|
144
|
+
const lines = source.replace(/\r\n/g, '\n').split('\n')
|
|
145
|
+
const out: string[] = []
|
|
146
|
+
let i = 0
|
|
147
|
+
const n = lines.length
|
|
148
|
+
|
|
149
|
+
const flushParagraph = (buffer: string[]): void => {
|
|
150
|
+
if (buffer.length === 0) return
|
|
151
|
+
out.push('<p>' + renderInline(buffer.join('\n')) + '</p>')
|
|
152
|
+
buffer.length = 0
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let paragraph: string[] = []
|
|
156
|
+
while (i < n) {
|
|
157
|
+
const line = lines[i]
|
|
158
|
+
|
|
159
|
+
// Fenced code block.
|
|
160
|
+
const fence = /^```([\w+-]*)\s*$/.exec(line)
|
|
161
|
+
if (fence !== null) {
|
|
162
|
+
flushParagraph(paragraph)
|
|
163
|
+
const lang = fence[1] ?? ''
|
|
164
|
+
i += 1
|
|
165
|
+
const code: string[] = []
|
|
166
|
+
while (i < n && !/^```\s*$/.test(lines[i])) {
|
|
167
|
+
code.push(lines[i])
|
|
168
|
+
i += 1
|
|
169
|
+
}
|
|
170
|
+
i += 1 // closing fence
|
|
171
|
+
const langAttr = lang === '' ? '' : ' class="language-' + escapeHtml(lang) + '"'
|
|
172
|
+
out.push('<pre' + langAttr + '><code>' + escapeHtml(code.join('\n')) + '</code></pre>')
|
|
173
|
+
continue
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Heading.
|
|
177
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line)
|
|
178
|
+
if (heading !== null) {
|
|
179
|
+
flushParagraph(paragraph)
|
|
180
|
+
const level = heading[1].length
|
|
181
|
+
out.push('<h' + level + '>' + renderInline(heading[2] ?? '') + '</h' + level + '>')
|
|
182
|
+
i += 1
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Horizontal rule.
|
|
187
|
+
if (/^\s*(---+|\*\*\*+|___+)\s*$/.test(line)) {
|
|
188
|
+
flushParagraph(paragraph)
|
|
189
|
+
out.push('<hr />')
|
|
190
|
+
i += 1
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Table: header row then separator row.
|
|
195
|
+
if (line.includes('|') && i + 1 < n && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
|
|
196
|
+
flushParagraph(paragraph)
|
|
197
|
+
const headerCells = splitTableRow(line)
|
|
198
|
+
i += 2
|
|
199
|
+
const rows: string[][] = []
|
|
200
|
+
while (i < n && lines[i].includes('|')) {
|
|
201
|
+
rows.push(splitTableRow(lines[i]))
|
|
202
|
+
i += 1
|
|
203
|
+
}
|
|
204
|
+
out.push('<table>')
|
|
205
|
+
out.push('<thead><tr>' + headerCells.map((cell) => '<th>' + renderInline(cell) + '</th>').join('') + '</tr></thead>')
|
|
206
|
+
if (rows.length > 0) {
|
|
207
|
+
out.push('<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + renderInline(cell) + '</td>').join('') + '</tr>').join('') + '</tbody>')
|
|
208
|
+
}
|
|
209
|
+
out.push('</table>')
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Blockquote (one level).
|
|
214
|
+
const quote = /^>\s?(.*)$/.exec(line)
|
|
215
|
+
if (quote !== null) {
|
|
216
|
+
flushParagraph(paragraph)
|
|
217
|
+
const body: string[] = []
|
|
218
|
+
while (i < n) {
|
|
219
|
+
const q = /^>\s?(.*)$/.exec(lines[i])
|
|
220
|
+
if (q === null) break
|
|
221
|
+
body.push(q[1] ?? '')
|
|
222
|
+
i += 1
|
|
223
|
+
}
|
|
224
|
+
out.push('<blockquote><p>' + body.map((bodyLine) => renderInline(bodyLine)).join('<br />') + '</p></blockquote>')
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Unordered list.
|
|
229
|
+
const ul = /^\s*([-*+])\s+(.*)$/.exec(line)
|
|
230
|
+
if (ul !== null) {
|
|
231
|
+
flushParagraph(paragraph)
|
|
232
|
+
const items: string[] = []
|
|
233
|
+
while (i < n) {
|
|
234
|
+
const item = /^\s*([-*+])\s+(.*)$/.exec(lines[i])
|
|
235
|
+
if (item === null) break
|
|
236
|
+
items.push('<li>' + renderInline(item[2] ?? '') + '</li>')
|
|
237
|
+
i += 1
|
|
238
|
+
}
|
|
239
|
+
out.push('<ul>' + items.join('') + '</ul>')
|
|
240
|
+
continue
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Ordered list.
|
|
244
|
+
const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line)
|
|
245
|
+
if (ol !== null) {
|
|
246
|
+
flushParagraph(paragraph)
|
|
247
|
+
const items: string[] = []
|
|
248
|
+
while (i < n) {
|
|
249
|
+
const item = /^\s*\d+[.)]\s+(.*)$/.exec(lines[i])
|
|
250
|
+
if (item === null) break
|
|
251
|
+
items.push('<li>' + renderInline(item[1] ?? '') + '</li>')
|
|
252
|
+
i += 1
|
|
253
|
+
}
|
|
254
|
+
out.push('<ol>' + items.join('') + '</ol>')
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Blank line: flush the paragraph.
|
|
259
|
+
if (line.trim() === '') {
|
|
260
|
+
flushParagraph(paragraph)
|
|
261
|
+
i += 1
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
paragraph.push(line)
|
|
266
|
+
i += 1
|
|
267
|
+
}
|
|
268
|
+
flushParagraph(paragraph)
|
|
269
|
+
return out.join('\n')
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Split one table row into cells (respecting the leading/trailing pipes). */
|
|
273
|
+
function splitTableRow(line: string): string[] {
|
|
274
|
+
const trimmed = line.trim()
|
|
275
|
+
const inner = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed
|
|
276
|
+
const withoutTrailing = inner.endsWith('|') ? inner.slice(0, -1) : inner
|
|
277
|
+
return withoutTrailing.split('|').map((cell) => cell.trim())
|
|
278
|
+
}
|
|
@@ -219,4 +219,110 @@ describe('foldEvents', () => {
|
|
|
219
219
|
foldEvents([makeEvent('assistant/message', assistantMessageData('a-1', 0, 0, 'world'), 1)], first)
|
|
220
220
|
expect(JSON.stringify(first)).toBe(snapshot)
|
|
221
221
|
})
|
|
222
|
+
|
|
223
|
+
it('request/context sets the context window used by a later assistant/message with usage', () => {
|
|
224
|
+
const events: WireEvent[] = [
|
|
225
|
+
makeEvent('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
|
|
226
|
+
makeEvent('user/message', userMessageData('u-1', 'hi'), 1),
|
|
227
|
+
makeEvent('assistant/message', {
|
|
228
|
+
turn: 0,
|
|
229
|
+
step: 0,
|
|
230
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
|
231
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
232
|
+
}, 2),
|
|
233
|
+
]
|
|
234
|
+
const result = foldEvents(events)
|
|
235
|
+
const assistant = result.find(message => message.kind === 'assistant')
|
|
236
|
+
expect(assistant).toMatchObject({
|
|
237
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
238
|
+
contextWindow: 100_000,
|
|
239
|
+
})
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('usage without a context window carries no contextWindow', () => {
|
|
243
|
+
const events: WireEvent[] = [
|
|
244
|
+
makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
|
|
245
|
+
makeEvent('assistant/message', {
|
|
246
|
+
turn: 0,
|
|
247
|
+
step: 0,
|
|
248
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
|
249
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
250
|
+
}, 1),
|
|
251
|
+
]
|
|
252
|
+
const result = foldEvents(events)
|
|
253
|
+
const assistant = result.find(message => message.kind === 'assistant')
|
|
254
|
+
expect(assistant).toMatchObject({ usage: { inputTokens: 10, outputTokens: 5 } })
|
|
255
|
+
expect(assistant?.contextWindow).toBeUndefined()
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('usage and contextWindow are both attached when request/context precedes the message', () => {
|
|
259
|
+
const events: WireEvent[] = [
|
|
260
|
+
makeEvent('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 200_000 }, 0),
|
|
261
|
+
makeEvent('user/message', userMessageData('u-1', 'hi'), 1),
|
|
262
|
+
makeEvent('assistant/message', {
|
|
263
|
+
turn: 0,
|
|
264
|
+
step: 0,
|
|
265
|
+
message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
|
266
|
+
usage: { inputTokens: 30, outputTokens: 2, cacheReadTokens: 8, cacheWriteTokens: 1 },
|
|
267
|
+
}, 2),
|
|
268
|
+
]
|
|
269
|
+
const result = foldEvents(events)
|
|
270
|
+
const assistant = result.find(message => message.kind === 'assistant')
|
|
271
|
+
expect(assistant).toMatchObject({
|
|
272
|
+
usage: { inputTokens: 30, outputTokens: 2, cacheReadTokens: 8, cacheWriteTokens: 1 },
|
|
273
|
+
contextWindow: 200_000,
|
|
274
|
+
})
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('assistant/message without usage has no usage field', () => {
|
|
278
|
+
const events: WireEvent[] = [
|
|
279
|
+
makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
|
|
280
|
+
makeEvent('assistant/message', { turn: 0, step: 0, message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] } }, 1),
|
|
281
|
+
]
|
|
282
|
+
const result = foldEvents(events)
|
|
283
|
+
const assistant = result.find(message => message.kind === 'assistant')
|
|
284
|
+
expect(assistant?.usage).toBeUndefined()
|
|
285
|
+
expect(assistant?.contextWindow).toBeUndefined()
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('attaches sourceKind from source.kind for a user message', () => {
|
|
289
|
+
const events: WireEvent[] = [
|
|
290
|
+
makeEvent('user/message', {
|
|
291
|
+
id: 'u-plugin',
|
|
292
|
+
role: 'user',
|
|
293
|
+
content: [{ type: 'text', text: '系统注入' }],
|
|
294
|
+
source: { kind: 'plugin', name: 'react-extension' },
|
|
295
|
+
}, 0),
|
|
296
|
+
]
|
|
297
|
+
const result = foldEvents(events)
|
|
298
|
+
expect(result[0]).toMatchObject({ id: 'u-plugin', kind: 'user', sourceKind: 'plugin' })
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('keeps sourceKind on a replayed/replaced user/message', () => {
|
|
302
|
+
const first = foldEvents([makeEvent('user/message', {
|
|
303
|
+
id: 'u-1',
|
|
304
|
+
role: 'user',
|
|
305
|
+
content: [{ type: 'text', text: '第一版' }],
|
|
306
|
+
source: { kind: 'plugin' },
|
|
307
|
+
}, 0)])
|
|
308
|
+
const second = foldEvents([makeEvent('user/message', {
|
|
309
|
+
id: 'u-1',
|
|
310
|
+
role: 'user',
|
|
311
|
+
content: [{ type: 'text', text: '第二版' }],
|
|
312
|
+
source: { kind: 'plugin' },
|
|
313
|
+
}, 1)], first)
|
|
314
|
+
expect(second[0]).toMatchObject({ id: 'u-1', text: '第二版', sourceKind: 'plugin' })
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('request/context and unknown events are still ignored by the fold', () => {
|
|
318
|
+
const events: WireEvent[] = [
|
|
319
|
+
makeEvent('some/future-event', { nope: true }, 0),
|
|
320
|
+
makeEvent('request/context', { provider: 'fx', model: 'fx-1' }, 1),
|
|
321
|
+
makeEvent('user/message', userMessageData('u-1', '真实消息'), 2),
|
|
322
|
+
]
|
|
323
|
+
const result = foldEvents(events)
|
|
324
|
+
// request/context with no window and the unknown event render nothing.
|
|
325
|
+
expect(result).toHaveLength(1)
|
|
326
|
+
expect(result[0]).toMatchObject({ id: 'u-1', kind: 'user' })
|
|
327
|
+
})
|
|
222
328
|
})
|
package/src/mobile/messages.ts
CHANGED
|
@@ -68,6 +68,20 @@ export interface RenderMessage {
|
|
|
68
68
|
readonly toolSummary?: string
|
|
69
69
|
/** Set when the owning turn ended in an error. */
|
|
70
70
|
readonly failed?: boolean
|
|
71
|
+
/**
|
|
72
|
+
* Token usage reported by the final assistant event. cacheReadTokens and
|
|
73
|
+
* cacheWriteTokens are only attached when the wire carried finite values.
|
|
74
|
+
*/
|
|
75
|
+
readonly usage?: {
|
|
76
|
+
inputTokens: number
|
|
77
|
+
outputTokens: number
|
|
78
|
+
cacheReadTokens?: number
|
|
79
|
+
cacheWriteTokens?: number
|
|
80
|
+
}
|
|
81
|
+
/** Context window for the model that produced this message (from request/context). */
|
|
82
|
+
readonly contextWindow?: number
|
|
83
|
+
/** Wire source.kind of a user message (e.g. plugin or user). */
|
|
84
|
+
readonly sourceKind?: string
|
|
71
85
|
}
|
|
72
86
|
|
|
73
87
|
/** One tool call attached to an assistant message (callId dedupes repeats). */
|
|
@@ -199,6 +213,8 @@ interface FoldState {
|
|
|
199
213
|
messageTurn: Map<string, number>
|
|
200
214
|
/** Deduped tool names per assistant message id. */
|
|
201
215
|
toolNames: Map<string, Set<string>>
|
|
216
|
+
/** Context window for the current model (from request/context). */
|
|
217
|
+
contextWindow?: number
|
|
202
218
|
}
|
|
203
219
|
|
|
204
220
|
function createState(existing: readonly RenderMessage[] | undefined): FoldState {
|
|
@@ -294,6 +310,14 @@ function applyEvent(state: FoldState, event: WireEvent): void {
|
|
|
294
310
|
case 'tool/call':
|
|
295
311
|
applyToolCall(state, event)
|
|
296
312
|
break
|
|
313
|
+
case 'request/context': {
|
|
314
|
+
// Wire shape: { provider, model, contextWindow? }. A present finite
|
|
315
|
+
// contextWindow seeds every later assistant message that reports usage.
|
|
316
|
+
const data = isRecord(event.data) ? event.data : {}
|
|
317
|
+
const window = pickNumber(data['contextWindow'])
|
|
318
|
+
if (window !== undefined) state.contextWindow = window
|
|
319
|
+
break
|
|
320
|
+
}
|
|
297
321
|
// turn/start, session/end-seed, and every other/unknown type render nothing.
|
|
298
322
|
default:
|
|
299
323
|
break
|
|
@@ -304,13 +328,28 @@ function applyUserMessage(state: FoldState, event: WireEvent): void {
|
|
|
304
328
|
const data = isRecord(event.data) ? event.data : {}
|
|
305
329
|
const id = pickString(data['id']) ?? syntheticId('user', event.seq)
|
|
306
330
|
const text = textFromContent(data['content'])
|
|
331
|
+
const source = isRecord(data['source']) ? data['source'] : {}
|
|
332
|
+
const sourceKind = pickString(source['kind'])
|
|
307
333
|
const existing = state.byId.get(id)
|
|
308
334
|
if (existing !== undefined) {
|
|
309
335
|
// Idempotent replace (replayed events update in place, never duplicate).
|
|
310
|
-
replaceMessage(state, existing, {
|
|
336
|
+
replaceMessage(state, existing, {
|
|
337
|
+
...existing,
|
|
338
|
+
...(sourceKind !== undefined ? { sourceKind } : {}),
|
|
339
|
+
text,
|
|
340
|
+
seq: event.seq,
|
|
341
|
+
time: event.time,
|
|
342
|
+
})
|
|
311
343
|
return
|
|
312
344
|
}
|
|
313
|
-
const message: RenderMessage = {
|
|
345
|
+
const message: RenderMessage = {
|
|
346
|
+
id,
|
|
347
|
+
kind: 'user',
|
|
348
|
+
text,
|
|
349
|
+
...(sourceKind !== undefined ? { sourceKind } : {}),
|
|
350
|
+
seq: event.seq,
|
|
351
|
+
time: event.time,
|
|
352
|
+
}
|
|
314
353
|
state.messages.push(message)
|
|
315
354
|
state.byId.set(id, message)
|
|
316
355
|
}
|
|
@@ -324,6 +363,8 @@ function applyAssistantMessage(state: FoldState, event: WireEvent): void {
|
|
|
324
363
|
const finalText = textFromContent(messageData['content'])
|
|
325
364
|
const finalReasoning = reasoningFromContent(messageData['content'])
|
|
326
365
|
const key = tsKey(turn, step)
|
|
366
|
+
const usage = usageFromData(data)
|
|
367
|
+
const contextWindow = state.contextWindow
|
|
327
368
|
|
|
328
369
|
// Finalize the matching assistant message (by id, or by turn/step for the
|
|
329
370
|
// streaming partial that chunks built before the final event arrived).
|
|
@@ -338,6 +379,8 @@ function applyAssistantMessage(state: FoldState, event: WireEvent): void {
|
|
|
338
379
|
// The final content block list is authoritative; an adapter that omits
|
|
339
380
|
// reasoning from the final message keeps the streamed reasoning text.
|
|
340
381
|
...(finalReasoning !== '' ? { reasoning: finalReasoning } : {}),
|
|
382
|
+
...(usage !== undefined ? { usage } : {}),
|
|
383
|
+
...(usage !== undefined && contextWindow !== undefined ? { contextWindow } : {}),
|
|
341
384
|
seq: event.seq,
|
|
342
385
|
time: event.time,
|
|
343
386
|
pending: false,
|
|
@@ -353,6 +396,8 @@ function applyAssistantMessage(state: FoldState, event: WireEvent): void {
|
|
|
353
396
|
kind: 'assistant',
|
|
354
397
|
text: finalText,
|
|
355
398
|
...(finalReasoning !== '' ? { reasoning: finalReasoning } : {}),
|
|
399
|
+
...(usage !== undefined ? { usage } : {}),
|
|
400
|
+
...(usage !== undefined && contextWindow !== undefined ? { contextWindow } : {}),
|
|
356
401
|
seq: event.seq,
|
|
357
402
|
time: event.time,
|
|
358
403
|
}
|
|
@@ -365,6 +410,25 @@ function applyAssistantMessage(state: FoldState, event: WireEvent): void {
|
|
|
365
410
|
if (turn !== undefined) state.messageTurn.set(id, turn)
|
|
366
411
|
}
|
|
367
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Extract token usage from an assistant event payload. Only attaches when the
|
|
415
|
+
* wire carries finite `inputTokens` AND `outputTokens`; the cache fields are
|
|
416
|
+
* included only for finite numbers.
|
|
417
|
+
*/
|
|
418
|
+
function usageFromData(data: Record<string, unknown>): { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number } | undefined {
|
|
419
|
+
const usageData = data['usage']
|
|
420
|
+
if (!isRecord(usageData)) return undefined
|
|
421
|
+
const inputTokens = pickNumber(usageData['inputTokens'])
|
|
422
|
+
const outputTokens = pickNumber(usageData['outputTokens'])
|
|
423
|
+
if (inputTokens === undefined || outputTokens === undefined) return undefined
|
|
424
|
+
const usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number } = { inputTokens, outputTokens }
|
|
425
|
+
const cacheReadTokens = pickNumber(usageData['cacheReadTokens'])
|
|
426
|
+
const cacheWriteTokens = pickNumber(usageData['cacheWriteTokens'])
|
|
427
|
+
if (cacheReadTokens !== undefined) usage.cacheReadTokens = cacheReadTokens
|
|
428
|
+
if (cacheWriteTokens !== undefined) usage.cacheWriteTokens = cacheWriteTokens
|
|
429
|
+
return usage
|
|
430
|
+
}
|
|
431
|
+
|
|
368
432
|
function applyChunk(state: FoldState, event: WireEvent): void {
|
|
369
433
|
const target = chunkTarget(event.data)
|
|
370
434
|
if (target === null) return
|