opendesk-react-native 0.1.6 → 0.1.7
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/lib/ui/OpenDeskChat.js +37 -2
- package/lib/ui/RichText.d.ts +15 -0
- package/lib/ui/RichText.js +59 -0
- package/lib/ui/rich.d.ts +54 -0
- package/lib/ui/rich.js +444 -0
- package/package.json +1 -1
- package/src/ui/OpenDeskChat.tsx +70 -2
- package/src/ui/RichText.tsx +129 -0
- package/src/ui/rich.ts +507 -0
package/src/ui/rich.ts
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns message bodies into a small block/inline tree that React Native can render, the way
|
|
3
|
+
* openDesk's web widget does: stored messages from `body_html`, AI answers still streaming (and
|
|
4
|
+
* messages with only `body_text`) from the widget's markdown subset. A trailing list of links in
|
|
5
|
+
* an AI answer becomes its sources.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type Inline =
|
|
9
|
+
| { kind: 'text'; text: string }
|
|
10
|
+
| { kind: 'bold' | 'italic'; children: Inline[] }
|
|
11
|
+
| { kind: 'code'; text: string }
|
|
12
|
+
| { kind: 'link'; href: string; children: Inline[] }
|
|
13
|
+
| { kind: 'break' };
|
|
14
|
+
|
|
15
|
+
export type Block =
|
|
16
|
+
| { kind: 'paragraph' | 'heading' | 'quote'; inlines: Inline[] }
|
|
17
|
+
| { kind: 'list'; ordered: boolean; items: Inline[][] }
|
|
18
|
+
| { kind: 'code'; text: string };
|
|
19
|
+
|
|
20
|
+
export interface Source {
|
|
21
|
+
href: string;
|
|
22
|
+
title: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Only web and mail links open; anything else (javascript:, intent:, …) renders as text. */
|
|
26
|
+
export function isSafeHref(href: string): boolean {
|
|
27
|
+
return /^(https?:\/\/|mailto:)/i.test(href.trim());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The domain shown with a source, without `www.`. */
|
|
31
|
+
export function hostOf(href: string): string {
|
|
32
|
+
const match = /^https?:\/\/([^/?#:]+)/i.exec(href);
|
|
33
|
+
|
|
34
|
+
return match ? match[1]!.replace(/^www\./i, '') : '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Plain text of inlines, e.g. for accessibility labels. */
|
|
38
|
+
export function textOf(inlines: Inline[]): string {
|
|
39
|
+
return inlines
|
|
40
|
+
.map((inline) => {
|
|
41
|
+
switch (inline.kind) {
|
|
42
|
+
case 'text':
|
|
43
|
+
case 'code':
|
|
44
|
+
return inline.text;
|
|
45
|
+
case 'break':
|
|
46
|
+
return '\n';
|
|
47
|
+
default:
|
|
48
|
+
return textOf(inline.children);
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
.join('');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------------------------
|
|
55
|
+
// Markdown: the web widget's subset (bold, italic, code, links, bare URLs, lists, headings,
|
|
56
|
+
// quotes, fences), including its clean-ups for text that is still streaming in.
|
|
57
|
+
// ---------------------------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
const CITATIONS = /[ \t]*\[\d{1,3}(?:\s*,\s*\d{1,3})*\](?!\()/g;
|
|
60
|
+
const PARTIAL_CITATION = /[ \t]*\[\d{0,3}(?:\s*,\s*\d{0,3})*$/;
|
|
61
|
+
const INLINE =
|
|
62
|
+
/(\*\*([^*\n]+?)\*\*)|(`([^`\n]+?)`)|(\[([^\]\n]+)\]\(([^)\s]+)\))|((^|[^\w*])\*([^*\s][^*\n]*?)\*(?![\w*]))|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/g;
|
|
63
|
+
const BULLET = /^\s{0,3}[-*+]\s+(.*)$/;
|
|
64
|
+
const NUMBERED = /^\s{0,3}\d{1,3}[.)]\s+(.*)$/;
|
|
65
|
+
const HEADING = /^\s{0,3}#{1,6}\s+(.*)$/;
|
|
66
|
+
const QUOTE = /^\s{0,3}>\s?(.*)$/;
|
|
67
|
+
const FENCE = /^\s{0,3}```/;
|
|
68
|
+
|
|
69
|
+
/** Drops `[1]`-style citation markers the AI leaves in its text. */
|
|
70
|
+
function stripCitations(text: string, streaming: boolean): string {
|
|
71
|
+
const stripped = text.replace(CITATIONS, '');
|
|
72
|
+
|
|
73
|
+
return streaming ? stripped.replace(PARTIAL_CITATION, '') : stripped;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Hides half-typed markup while an answer streams in, so `**bo` never shows its asterisks. */
|
|
77
|
+
function closeOpenMarkup(text: string): string {
|
|
78
|
+
let result = text;
|
|
79
|
+
if ((result.match(/```/g) ?? []).length % 2 === 1) {
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
if ((result.match(/\*\*/g) ?? []).length % 2 === 1) {
|
|
83
|
+
const at = result.lastIndexOf('**');
|
|
84
|
+
result = result.slice(0, at) + result.slice(at + 2);
|
|
85
|
+
}
|
|
86
|
+
if ((result.replace(/```/g, '').match(/`/g) ?? []).length % 2 === 1) {
|
|
87
|
+
const at = result.lastIndexOf('`');
|
|
88
|
+
result = result.slice(0, at) + result.slice(at + 1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return result.replace(/\[([^\]\n]*)\]\([^)\s]*$/, '$1').replace(/\[([^\]\n]*)$/, '$1');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function markdownInlines(text: string): Inline[] {
|
|
95
|
+
const inlines: Inline[] = [];
|
|
96
|
+
const pattern = new RegExp(INLINE.source, 'g');
|
|
97
|
+
let last = 0;
|
|
98
|
+
|
|
99
|
+
for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
|
|
100
|
+
if (match.index > last) {
|
|
101
|
+
inlines.push({ kind: 'text', text: text.slice(last, match.index) });
|
|
102
|
+
}
|
|
103
|
+
if (match[1]) {
|
|
104
|
+
inlines.push({ kind: 'bold', children: markdownInlines(match[2]!) });
|
|
105
|
+
} else if (match[3]) {
|
|
106
|
+
inlines.push({ kind: 'code', text: match[4]! });
|
|
107
|
+
} else if (match[5]) {
|
|
108
|
+
const href = match[7]!;
|
|
109
|
+
inlines.push(
|
|
110
|
+
isSafeHref(href) ? { kind: 'link', href, children: markdownInlines(match[6]!) } : { kind: 'text', text: match[6]! },
|
|
111
|
+
);
|
|
112
|
+
} else if (match[8]) {
|
|
113
|
+
if (match[9]) {
|
|
114
|
+
inlines.push({ kind: 'text', text: match[9] });
|
|
115
|
+
}
|
|
116
|
+
inlines.push({ kind: 'italic', children: markdownInlines(match[10]!) });
|
|
117
|
+
} else if (match[11]) {
|
|
118
|
+
inlines.push({ kind: 'link', href: match[11], children: [{ kind: 'text', text: match[11] }] });
|
|
119
|
+
}
|
|
120
|
+
last = match.index + match[0].length;
|
|
121
|
+
}
|
|
122
|
+
if (last < text.length) {
|
|
123
|
+
inlines.push({ kind: 'text', text: text.slice(last) });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return inlines;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function parseMarkdown(source: string, streaming = false): Block[] {
|
|
130
|
+
let text = stripCitations(source.replace(/\r\n?/g, '\n'), streaming);
|
|
131
|
+
if (streaming) {
|
|
132
|
+
text = closeOpenMarkup(text);
|
|
133
|
+
}
|
|
134
|
+
const lines = text.split('\n');
|
|
135
|
+
const blocks: Block[] = [];
|
|
136
|
+
let paragraph: string[] = [];
|
|
137
|
+
|
|
138
|
+
const flush = () => {
|
|
139
|
+
if (paragraph.length === 0) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const inlines: Inline[] = [];
|
|
143
|
+
paragraph.forEach((line, index) => {
|
|
144
|
+
if (index > 0) {
|
|
145
|
+
inlines.push({ kind: 'break' });
|
|
146
|
+
}
|
|
147
|
+
inlines.push(...markdownInlines(line));
|
|
148
|
+
});
|
|
149
|
+
blocks.push({ kind: 'paragraph', inlines });
|
|
150
|
+
paragraph = [];
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < lines.length; i++) {
|
|
154
|
+
const line = lines[i]!;
|
|
155
|
+
|
|
156
|
+
if (FENCE.test(line)) {
|
|
157
|
+
flush();
|
|
158
|
+
const code: string[] = [];
|
|
159
|
+
for (i++; i < lines.length && !FENCE.test(lines[i]!); i++) {
|
|
160
|
+
code.push(lines[i]!);
|
|
161
|
+
}
|
|
162
|
+
blocks.push({ kind: 'code', text: code.join('\n') });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const listPattern = BULLET.test(line) ? BULLET : NUMBERED.test(line) ? NUMBERED : null;
|
|
167
|
+
if (listPattern) {
|
|
168
|
+
flush();
|
|
169
|
+
const items: Inline[][] = [];
|
|
170
|
+
for (; i < lines.length && listPattern.test(lines[i]!); i++) {
|
|
171
|
+
items.push(markdownInlines(listPattern.exec(lines[i]!)![1]!));
|
|
172
|
+
}
|
|
173
|
+
i--;
|
|
174
|
+
blocks.push({ kind: 'list', ordered: listPattern === NUMBERED, items });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const heading = HEADING.exec(line);
|
|
179
|
+
if (heading) {
|
|
180
|
+
flush();
|
|
181
|
+
blocks.push({ kind: 'heading', inlines: markdownInlines(heading[1]!) });
|
|
182
|
+
} else if (QUOTE.test(line)) {
|
|
183
|
+
flush();
|
|
184
|
+
const quoted: string[] = [];
|
|
185
|
+
for (; i < lines.length && QUOTE.test(lines[i]!); i++) {
|
|
186
|
+
quoted.push(QUOTE.exec(lines[i]!)![1]!);
|
|
187
|
+
}
|
|
188
|
+
i--;
|
|
189
|
+
blocks.push({ kind: 'quote', inlines: markdownInlines(quoted.join(' ')) });
|
|
190
|
+
} else if (line.trim()) {
|
|
191
|
+
paragraph.push(line.trim());
|
|
192
|
+
} else {
|
|
193
|
+
flush();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
flush();
|
|
197
|
+
|
|
198
|
+
return blocks;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------------------------
|
|
202
|
+
// HTML: a forgiving parser for the HTML openDesk stores on messages. No DOM is available in
|
|
203
|
+
// React Native, so it builds a tiny tree and keeps only what a chat bubble can show.
|
|
204
|
+
// ---------------------------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
interface Element {
|
|
207
|
+
tag: string;
|
|
208
|
+
attrs: Record<string, string>;
|
|
209
|
+
children: Node[];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
type Node = Element | string;
|
|
213
|
+
|
|
214
|
+
const VOID = new Set(['br', 'img', 'hr', 'input', 'meta', 'link', 'wbr', 'source', 'col']);
|
|
215
|
+
const SKIP = new Set(['script', 'style', 'head', 'title', 'template', 'noscript', 'svg']);
|
|
216
|
+
const BLOCK = new Set([
|
|
217
|
+
'p',
|
|
218
|
+
'div',
|
|
219
|
+
'section',
|
|
220
|
+
'article',
|
|
221
|
+
'header',
|
|
222
|
+
'footer',
|
|
223
|
+
'main',
|
|
224
|
+
'aside',
|
|
225
|
+
'ul',
|
|
226
|
+
'ol',
|
|
227
|
+
'li',
|
|
228
|
+
'h1',
|
|
229
|
+
'h2',
|
|
230
|
+
'h3',
|
|
231
|
+
'h4',
|
|
232
|
+
'h5',
|
|
233
|
+
'h6',
|
|
234
|
+
'blockquote',
|
|
235
|
+
'pre',
|
|
236
|
+
'table',
|
|
237
|
+
'thead',
|
|
238
|
+
'tbody',
|
|
239
|
+
'tr',
|
|
240
|
+
'td',
|
|
241
|
+
'th',
|
|
242
|
+
'hr',
|
|
243
|
+
'figure',
|
|
244
|
+
'figcaption',
|
|
245
|
+
]);
|
|
246
|
+
|
|
247
|
+
const ENTITIES: Record<string, string> = {
|
|
248
|
+
amp: '&',
|
|
249
|
+
lt: '<',
|
|
250
|
+
gt: '>',
|
|
251
|
+
quot: '"',
|
|
252
|
+
apos: "'",
|
|
253
|
+
nbsp: ' ',
|
|
254
|
+
ndash: '–',
|
|
255
|
+
mdash: '—',
|
|
256
|
+
hellip: '…',
|
|
257
|
+
lsquo: '‘',
|
|
258
|
+
rsquo: '’',
|
|
259
|
+
ldquo: '“',
|
|
260
|
+
rdquo: '”',
|
|
261
|
+
bull: '•',
|
|
262
|
+
middot: '·',
|
|
263
|
+
copy: '©',
|
|
264
|
+
reg: '®',
|
|
265
|
+
trade: '™',
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
function decodeEntities(text: string): string {
|
|
269
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity: string) => {
|
|
270
|
+
if (entity[0] === '#') {
|
|
271
|
+
const code = entity[1]?.toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
|
|
272
|
+
|
|
273
|
+
return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return ENTITIES[entity.toLowerCase()] ?? match;
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function parseAttributes(source: string): Record<string, string> {
|
|
281
|
+
const attrs: Record<string, string> = {};
|
|
282
|
+
const pattern = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
283
|
+
for (let match = pattern.exec(source); match; match = pattern.exec(source)) {
|
|
284
|
+
attrs[match[1]!.toLowerCase()] = decodeEntities(match[2] ?? match[3] ?? match[4] ?? '');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return attrs;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function parseTree(html: string): Node[] {
|
|
291
|
+
const root: Element = { tag: '#root', attrs: {}, children: [] };
|
|
292
|
+
const stack: Element[] = [root];
|
|
293
|
+
const pattern = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][a-zA-Z0-9]*)([^>]*)>|([^<]+|<)/g;
|
|
294
|
+
let skipping: string | null = null;
|
|
295
|
+
|
|
296
|
+
for (let match = pattern.exec(html); match; match = pattern.exec(html)) {
|
|
297
|
+
const [whole, closing, rawTag, rawAttrs, text] = match;
|
|
298
|
+
const current = stack[stack.length - 1]!;
|
|
299
|
+
|
|
300
|
+
if (whole.startsWith('<!--')) {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (text !== undefined) {
|
|
304
|
+
if (!skipping) {
|
|
305
|
+
current.children.push(decodeEntities(text));
|
|
306
|
+
}
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const tag = rawTag!.toLowerCase();
|
|
311
|
+
if (skipping) {
|
|
312
|
+
if (closing && tag === skipping) {
|
|
313
|
+
skipping = null;
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (closing) {
|
|
318
|
+
const at = stack.map((element) => element.tag).lastIndexOf(tag);
|
|
319
|
+
if (at > 0) {
|
|
320
|
+
stack.length = at;
|
|
321
|
+
}
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (SKIP.has(tag)) {
|
|
325
|
+
if (!rawAttrs!.trim().endsWith('/')) {
|
|
326
|
+
skipping = tag;
|
|
327
|
+
}
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const element: Element = { tag, attrs: parseAttributes(rawAttrs ?? ''), children: [] };
|
|
331
|
+
current.children.push(element);
|
|
332
|
+
if (!VOID.has(tag) && !rawAttrs!.trim().endsWith('/')) {
|
|
333
|
+
stack.push(element);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return root.children;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function textContent(nodes: Node[]): string {
|
|
341
|
+
return nodes.map((node) => (typeof node === 'string' ? node : node.tag === 'br' ? '\n' : textContent(node.children))).join('');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function htmlInlines(nodes: Node[], citations: boolean): Inline[] {
|
|
345
|
+
const inlines: Inline[] = [];
|
|
346
|
+
|
|
347
|
+
for (const node of nodes) {
|
|
348
|
+
if (typeof node === 'string') {
|
|
349
|
+
const text = node.replace(/[ \t\r\n\f]+/g, ' ');
|
|
350
|
+
inlines.push({ kind: 'text', text: citations ? text.replace(CITATIONS, '') : text });
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
switch (node.tag) {
|
|
354
|
+
case 'br':
|
|
355
|
+
inlines.push({ kind: 'break' });
|
|
356
|
+
break;
|
|
357
|
+
case 'img':
|
|
358
|
+
if (node.attrs.alt) {
|
|
359
|
+
inlines.push({ kind: 'text', text: node.attrs.alt });
|
|
360
|
+
}
|
|
361
|
+
break;
|
|
362
|
+
case 'strong':
|
|
363
|
+
case 'b':
|
|
364
|
+
inlines.push({ kind: 'bold', children: htmlInlines(node.children, citations) });
|
|
365
|
+
break;
|
|
366
|
+
case 'em':
|
|
367
|
+
case 'i':
|
|
368
|
+
inlines.push({ kind: 'italic', children: htmlInlines(node.children, citations) });
|
|
369
|
+
break;
|
|
370
|
+
case 'code':
|
|
371
|
+
case 'kbd':
|
|
372
|
+
case 'samp':
|
|
373
|
+
inlines.push({ kind: 'code', text: textContent(node.children) });
|
|
374
|
+
break;
|
|
375
|
+
case 'a': {
|
|
376
|
+
const href = node.attrs.href ?? '';
|
|
377
|
+
const children = htmlInlines(node.children, citations);
|
|
378
|
+
if (isSafeHref(href)) {
|
|
379
|
+
inlines.push({ kind: 'link', href: href.trim(), children });
|
|
380
|
+
} else {
|
|
381
|
+
inlines.push(...children);
|
|
382
|
+
}
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
default:
|
|
386
|
+
if (BLOCK.has(node.tag) && inlines.length > 0) {
|
|
387
|
+
inlines.push({ kind: 'break' });
|
|
388
|
+
}
|
|
389
|
+
inlines.push(...htmlInlines(node.children, citations));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return inlines;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Trims whitespace and breaks at both ends of a run of inlines; returns [] when nothing is left. */
|
|
397
|
+
function tidy(inlines: Inline[]): Inline[] {
|
|
398
|
+
const result = [...inlines];
|
|
399
|
+
const blank = (inline: Inline | undefined) => inline?.kind === 'break' || (inline?.kind === 'text' && !inline.text.trim());
|
|
400
|
+
while (blank(result[0])) {
|
|
401
|
+
result.shift();
|
|
402
|
+
}
|
|
403
|
+
while (blank(result[result.length - 1])) {
|
|
404
|
+
result.pop();
|
|
405
|
+
}
|
|
406
|
+
const first = result[0];
|
|
407
|
+
if (first?.kind === 'text') {
|
|
408
|
+
result[0] = { kind: 'text', text: first.text.replace(/^\s+/, '') };
|
|
409
|
+
}
|
|
410
|
+
const last = result[result.length - 1];
|
|
411
|
+
if (last?.kind === 'text') {
|
|
412
|
+
result[result.length - 1] = { kind: 'text', text: last.text.replace(/\s+$/, '') };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return textOf(result).trim() ? result : [];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function htmlBlocks(nodes: Node[], citations: boolean): Block[] {
|
|
419
|
+
const blocks: Block[] = [];
|
|
420
|
+
let pending: Node[] = [];
|
|
421
|
+
|
|
422
|
+
const flush = () => {
|
|
423
|
+
const inlines = tidy(htmlInlines(pending, citations));
|
|
424
|
+
if (inlines.length > 0) {
|
|
425
|
+
blocks.push({ kind: 'paragraph', inlines });
|
|
426
|
+
}
|
|
427
|
+
pending = [];
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
for (const node of nodes) {
|
|
431
|
+
if (typeof node === 'string' || !BLOCK.has(node.tag)) {
|
|
432
|
+
pending.push(node);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
flush();
|
|
436
|
+
switch (node.tag) {
|
|
437
|
+
case 'ul':
|
|
438
|
+
case 'ol': {
|
|
439
|
+
const items = node.children
|
|
440
|
+
.filter((child): child is Element => typeof child !== 'string' && child.tag === 'li')
|
|
441
|
+
.map((item) => tidy(htmlInlines(item.children, citations)))
|
|
442
|
+
.filter((item) => item.length > 0);
|
|
443
|
+
if (items.length > 0) {
|
|
444
|
+
blocks.push({ kind: 'list', ordered: node.tag === 'ol', items });
|
|
445
|
+
}
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
case 'pre':
|
|
449
|
+
blocks.push({ kind: 'code', text: textContent(node.children).replace(/^\n+|\s+$/g, '') });
|
|
450
|
+
break;
|
|
451
|
+
case 'blockquote': {
|
|
452
|
+
const inlines = tidy(htmlInlines(node.children, citations));
|
|
453
|
+
if (inlines.length > 0) {
|
|
454
|
+
blocks.push({ kind: 'quote', inlines });
|
|
455
|
+
}
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
case 'h1':
|
|
459
|
+
case 'h2':
|
|
460
|
+
case 'h3':
|
|
461
|
+
case 'h4':
|
|
462
|
+
case 'h5':
|
|
463
|
+
case 'h6': {
|
|
464
|
+
const inlines = tidy(htmlInlines(node.children, citations));
|
|
465
|
+
if (inlines.length > 0) {
|
|
466
|
+
blocks.push({ kind: 'heading', inlines });
|
|
467
|
+
}
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
case 'hr':
|
|
471
|
+
break;
|
|
472
|
+
default:
|
|
473
|
+
blocks.push(...htmlBlocks(node.children, citations));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
flush();
|
|
477
|
+
|
|
478
|
+
return blocks;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Parses stored message HTML. `citations` drops AI `[1]` markers, as for markdown. */
|
|
482
|
+
export function parseHtml(html: string, citations = false): Block[] {
|
|
483
|
+
return htmlBlocks(parseTree(html), citations);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Splits off an AI answer's sources: a trailing list in which every item is just one web link.
|
|
488
|
+
* Like the web widget, it leaves the answer alone when the list is all there is.
|
|
489
|
+
*/
|
|
490
|
+
export function extractSources(blocks: Block[]): { blocks: Block[]; sources: Source[] } {
|
|
491
|
+
const last = blocks[blocks.length - 1];
|
|
492
|
+
if (!last || last.kind !== 'list' || blocks.length < 2) {
|
|
493
|
+
return { blocks, sources: [] };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const sources: Source[] = [];
|
|
497
|
+
for (const item of last.items) {
|
|
498
|
+
const content = item.filter((inline) => !(inline.kind === 'text' && !inline.text.trim()));
|
|
499
|
+
const link = content[0];
|
|
500
|
+
if (content.length !== 1 || link?.kind !== 'link' || !/^https?:/i.test(link.href)) {
|
|
501
|
+
return { blocks, sources: [] };
|
|
502
|
+
}
|
|
503
|
+
sources.push({ href: link.href, title: textOf(link.children).trim() || link.href });
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return { blocks: blocks.slice(0, -1), sources };
|
|
507
|
+
}
|