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/lib/ui/OpenDeskChat.js
CHANGED
|
@@ -8,6 +8,8 @@ const react_2 = require("../react");
|
|
|
8
8
|
const BotFace_1 = require("./BotFace");
|
|
9
9
|
const icons_1 = require("./icons");
|
|
10
10
|
const keyboard_1 = require("./keyboard");
|
|
11
|
+
const rich_1 = require("./rich");
|
|
12
|
+
const RichText_1 = require("./RichText");
|
|
11
13
|
const strings_1 = require("./strings");
|
|
12
14
|
const theme_1 = require("./theme");
|
|
13
15
|
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
@@ -204,6 +206,7 @@ function Bubble({ ui, message, reply, first, last, gap, showHandoff, onDecide, o
|
|
|
204
206
|
const avatar = message ? (message.author.user?.avatar_url ?? bot?.avatar_url) : bot?.avatar_url;
|
|
205
207
|
const botLook = bot?.look ?? fallbackBot?.look;
|
|
206
208
|
const text = message ? (message.body_text ?? '') : (reply?.text ?? '');
|
|
209
|
+
const body = (0, react_1.useMemo)(() => bodyOf(message, reply, mine, ai), [message, reply, mine, ai]);
|
|
207
210
|
const local = message?.id.startsWith('local-') === true;
|
|
208
211
|
const action = message?.ai_action;
|
|
209
212
|
const tools = reply?.tools ?? [];
|
|
@@ -214,12 +217,29 @@ function Bubble({ ui, message, reply, first, last, gap, showHandoff, onDecide, o
|
|
|
214
217
|
const files = (message?.attachments ?? []).filter((a) => !a.content_type.startsWith('image/'));
|
|
215
218
|
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.row, mine ? styles.rowMe : styles.rowThem, gap && styles.rowGap], children: [!mine && first && author ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.who, children: [ai ? ((0, jsx_runtime_1.jsx)(BotFace_1.BotFace, { size: 18, look: botLook, avatarUrl: avatar })) : avatar ? ((0, jsx_runtime_1.jsx)(Avatar, { ui: ui, size: 18, name: author, url: avatar, tint: theme.bg2 })) : null, (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.whoName, children: author }), ai ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.aiPill, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.aiPillText, children: strings.ai }) })) : null] })) : null, tools.length > 0 ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.tools, children: tools.map((tool, index) => ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.tool, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.toolIc, tool.status === 'error' && { backgroundColor: theme.bg2 }], children: (0, jsx_runtime_1.jsx)(icons_1.SparkIcon, { color: tool.status === 'error' ? theme.fg3 : theme.ai, size: 8 }) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.toolText, tool.status === 'running' && { color: theme.fg2 }], children: (0, strings_1.format)(tool.status === 'running' ? strings.checking : tool.status === 'error' ? strings.toolFailed : strings.checked, {
|
|
216
219
|
tool: tool.name.replace(/_/g, ' '),
|
|
217
|
-
}) })] }, `${tool.name}-${index}`))) })) : null, text || reply ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
|
|
220
|
+
}) })] }, `${tool.name}-${index}`))) })) : null, text || body.blocks.length > 0 || reply ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
|
|
218
221
|
styles.msg,
|
|
219
222
|
mine ? styles.msgMe : ai ? styles.msgAi : styles.msgThem,
|
|
220
223
|
corners,
|
|
221
224
|
local && styles.sendingMsg,
|
|
222
|
-
], children:
|
|
225
|
+
], children: mine ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.msgText, { color: theme.accentFg }], children: text })) : body.blocks.length > 0 ? ((0, jsx_runtime_1.jsx)(RichText_1.RichText, { blocks: body.blocks, textStyle: styles.msgText, codeBackground: ai ? theme.bg2 : theme.line, quoteColor: theme.line2, mono: theme.mono })) : ((0, jsx_runtime_1.jsx)(Dots, { color: theme.ai })) })) : null, body.sources.length > 0 ? ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.sources, children: body.sources.map((source) => ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { accessibilityRole: "link", accessibilityHint: (0, rich_1.hostOf)(source.href), onPress: () => {
|
|
226
|
+
react_native_1.Linking.openURL(source.href).catch(() => undefined);
|
|
227
|
+
}, style: ({ pressed }) => [styles.src, pressed && { borderColor: theme.aiLine, backgroundColor: theme.aiSoft }], children: [(0, jsx_runtime_1.jsx)(icons_1.ArrowIcon, { color: theme.ai, size: 11, angle: -45 }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.srcText, numberOfLines: 1, children: source.title })] }, source.href))) })) : null, images.map((attachment) => ((0, jsx_runtime_1.jsx)(react_native_1.Pressable, { accessibilityRole: "imagebutton", accessibilityLabel: (0, strings_1.format)(strings.viewImage, { name: attachment.filename }), onPress: () => onOpenImage(attachment), style: ({ pressed }) => [styles.fileGap, pressed && styles.pressed], children: (0, jsx_runtime_1.jsx)(react_native_1.Image, { source: { uri: attachment.url }, style: styles.thumb, resizeMode: "cover" }) }, attachment.id))), files.map((attachment) => ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { onPress: () => react_native_1.Linking.openURL(attachment.url), style: [styles.chip, styles.fileGap], children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.chipText, numberOfLines: 1, children: attachment.filename }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.chipSize, children: fileSize(attachment.size_bytes) })] }, attachment.id))), message && action ? (0, jsx_runtime_1.jsx)(ActionCard, { ui: ui, message: message, onDecide: onDecide }) : null, message && showHandoff ? ((0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { accessibilityRole: "button", onPress: () => onRequestHuman(message), style: ({ pressed }) => [styles.handoff, pressed && { backgroundColor: theme.bg2 }], children: [(0, jsx_runtime_1.jsx)(icons_1.PersonIcon, { color: theme.fg, size: 14 }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.handoffText, children: strings.talkToPerson })] })) : null, last && message ? (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.meta, children: local ? strings.sending : clockTime(message.created_at) }) : null] }));
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* What a bubble shows: the visitor's own text as typed; stored replies from their HTML (links
|
|
231
|
+
* included), falling back to markdown in the text; AI answers still streaming as markdown. An AI
|
|
232
|
+
* answer's trailing list of links becomes its sources.
|
|
233
|
+
*/
|
|
234
|
+
function bodyOf(message, reply, mine, ai) {
|
|
235
|
+
if (mine) {
|
|
236
|
+
return { blocks: [], sources: [] };
|
|
237
|
+
}
|
|
238
|
+
if (reply && !message) {
|
|
239
|
+
return { blocks: (0, rich_1.parseMarkdown)(reply.text, true), sources: [] };
|
|
240
|
+
}
|
|
241
|
+
const blocks = message?.body_html?.trim() ? (0, rich_1.parseHtml)(message.body_html, ai) : (0, rich_1.parseMarkdown)(message?.body_text ?? '');
|
|
242
|
+
return ai ? (0, rich_1.extractSources)(blocks) : { blocks, sources: [] };
|
|
223
243
|
}
|
|
224
244
|
function ActionCard({ ui, message, onDecide, }) {
|
|
225
245
|
const { theme, strings, styles } = ui;
|
|
@@ -504,6 +524,21 @@ function createStyles(t) {
|
|
|
504
524
|
msgMe: { backgroundColor: t.accent },
|
|
505
525
|
msgThem: { backgroundColor: t.bg2 },
|
|
506
526
|
msgAi: { backgroundColor: t.bg3, borderWidth: 1, borderColor: t.line },
|
|
527
|
+
sources: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 6 },
|
|
528
|
+
src: {
|
|
529
|
+
flexDirection: 'row',
|
|
530
|
+
alignItems: 'center',
|
|
531
|
+
gap: 6,
|
|
532
|
+
maxWidth: 240,
|
|
533
|
+
height: 26,
|
|
534
|
+
paddingLeft: 8,
|
|
535
|
+
paddingRight: 10,
|
|
536
|
+
borderRadius: r.ctl,
|
|
537
|
+
borderWidth: 1,
|
|
538
|
+
borderColor: t.line,
|
|
539
|
+
backgroundColor: t.bg,
|
|
540
|
+
},
|
|
541
|
+
srcText: { flexShrink: 1, fontSize: 12, color: t.fg2 },
|
|
507
542
|
msgText: { fontSize: 13.5, lineHeight: 19.5, color: t.fg },
|
|
508
543
|
sendingMsg: { opacity: 0.7 },
|
|
509
544
|
meta: { fontFamily: t.mono, fontSize: 10, color: t.fg4, marginTop: 3, marginHorizontal: 4 },
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type TextStyle } from 'react-native';
|
|
2
|
+
import type { Block } from './rich';
|
|
3
|
+
interface RichTextProps {
|
|
4
|
+
blocks: Block[];
|
|
5
|
+
/** The bubble's text style; colour and size carry into every block. */
|
|
6
|
+
textStyle: TextStyle;
|
|
7
|
+
/** Fill behind inline code and code blocks. */
|
|
8
|
+
codeBackground: string;
|
|
9
|
+
/** Rule beside quotes. */
|
|
10
|
+
quoteColor: string;
|
|
11
|
+
mono: string;
|
|
12
|
+
}
|
|
13
|
+
/** Renders parsed message blocks like the widget's `.msg`: paragraphs, lists, quotes, code and underlined links. */
|
|
14
|
+
export declare function RichText({ blocks, textStyle, codeBackground, quoteColor, mono }: RichTextProps): import("react").JSX.Element;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RichText = RichText;
|
|
4
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
5
|
+
const react_1 = require("react");
|
|
6
|
+
const react_native_1 = require("react-native");
|
|
7
|
+
/** Renders parsed message blocks like the widget's `.msg`: paragraphs, lists, quotes, code and underlined links. */
|
|
8
|
+
function RichText({ blocks, textStyle, codeBackground, quoteColor, mono }) {
|
|
9
|
+
const color = textStyle.color;
|
|
10
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.blocks, children: blocks.map((block, index) => {
|
|
11
|
+
switch (block.kind) {
|
|
12
|
+
case 'paragraph':
|
|
13
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: textStyle, children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: block.inlines, codeStyle: { fontFamily: mono, backgroundColor: codeBackground } }) }, index));
|
|
14
|
+
case 'heading':
|
|
15
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [textStyle, styles.bold], children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: block.inlines, codeStyle: { fontFamily: mono, backgroundColor: codeBackground } }) }, index));
|
|
16
|
+
case 'quote':
|
|
17
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.quote, { borderLeftColor: quoteColor }], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [textStyle, styles.quoteText], children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: block.inlines, codeStyle: { fontFamily: mono, backgroundColor: codeBackground } }) }) }, index));
|
|
18
|
+
case 'code':
|
|
19
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.pre, { backgroundColor: codeBackground }], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [textStyle, styles.preText, { fontFamily: mono }], children: block.text }) }, index));
|
|
20
|
+
case 'list':
|
|
21
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.list, children: block.items.map((item, itemIndex) => ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.item, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [textStyle, styles.marker, { color }], children: block.ordered ? `${itemIndex + 1}.` : '•' }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [textStyle, styles.itemText], children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: item, codeStyle: { fontFamily: mono, backgroundColor: codeBackground } }) })] }, itemIndex))) }, index));
|
|
22
|
+
}
|
|
23
|
+
}) }));
|
|
24
|
+
}
|
|
25
|
+
function Inlines({ inlines, codeStyle }) {
|
|
26
|
+
return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: inlines.map((inline, index) => {
|
|
27
|
+
switch (inline.kind) {
|
|
28
|
+
case 'text':
|
|
29
|
+
return (0, jsx_runtime_1.jsx)(react_1.Fragment, { children: inline.text }, index);
|
|
30
|
+
case 'break':
|
|
31
|
+
return (0, jsx_runtime_1.jsx)(react_1.Fragment, { children: '\n' }, index);
|
|
32
|
+
case 'bold':
|
|
33
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.bold, children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: inline.children, codeStyle: codeStyle }) }, index));
|
|
34
|
+
case 'italic':
|
|
35
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.italic, children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: inline.children, codeStyle: codeStyle }) }, index));
|
|
36
|
+
case 'code':
|
|
37
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.code, codeStyle], children: inline.text }, index));
|
|
38
|
+
case 'link':
|
|
39
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.Text, { accessibilityRole: "link", onPress: () => {
|
|
40
|
+
react_native_1.Linking.openURL(inline.href).catch(() => undefined);
|
|
41
|
+
}, style: styles.link, children: (0, jsx_runtime_1.jsx)(Inlines, { inlines: inline.children, codeStyle: codeStyle }) }, index));
|
|
42
|
+
}
|
|
43
|
+
}) }));
|
|
44
|
+
}
|
|
45
|
+
const styles = react_native_1.StyleSheet.create({
|
|
46
|
+
blocks: { gap: 6 },
|
|
47
|
+
bold: { fontWeight: '600' },
|
|
48
|
+
italic: { fontStyle: 'italic' },
|
|
49
|
+
link: { textDecorationLine: 'underline', fontWeight: '500' },
|
|
50
|
+
code: { fontSize: 12 },
|
|
51
|
+
pre: { borderRadius: 6, paddingHorizontal: 8, paddingVertical: 6 },
|
|
52
|
+
preText: { fontSize: 12, lineHeight: 17 },
|
|
53
|
+
quote: { borderLeftWidth: 2, paddingLeft: 8 },
|
|
54
|
+
quoteText: { opacity: 0.8 },
|
|
55
|
+
list: { gap: 2 },
|
|
56
|
+
item: { flexDirection: 'row' },
|
|
57
|
+
marker: { minWidth: 16, paddingRight: 4 },
|
|
58
|
+
itemText: { flex: 1 },
|
|
59
|
+
});
|
package/lib/ui/rich.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
export type Inline = {
|
|
8
|
+
kind: 'text';
|
|
9
|
+
text: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'bold' | 'italic';
|
|
12
|
+
children: Inline[];
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'code';
|
|
15
|
+
text: string;
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'link';
|
|
18
|
+
href: string;
|
|
19
|
+
children: Inline[];
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'break';
|
|
22
|
+
};
|
|
23
|
+
export type Block = {
|
|
24
|
+
kind: 'paragraph' | 'heading' | 'quote';
|
|
25
|
+
inlines: Inline[];
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'list';
|
|
28
|
+
ordered: boolean;
|
|
29
|
+
items: Inline[][];
|
|
30
|
+
} | {
|
|
31
|
+
kind: 'code';
|
|
32
|
+
text: string;
|
|
33
|
+
};
|
|
34
|
+
export interface Source {
|
|
35
|
+
href: string;
|
|
36
|
+
title: string;
|
|
37
|
+
}
|
|
38
|
+
/** Only web and mail links open; anything else (javascript:, intent:, …) renders as text. */
|
|
39
|
+
export declare function isSafeHref(href: string): boolean;
|
|
40
|
+
/** The domain shown with a source, without `www.`. */
|
|
41
|
+
export declare function hostOf(href: string): string;
|
|
42
|
+
/** Plain text of inlines, e.g. for accessibility labels. */
|
|
43
|
+
export declare function textOf(inlines: Inline[]): string;
|
|
44
|
+
export declare function parseMarkdown(source: string, streaming?: boolean): Block[];
|
|
45
|
+
/** Parses stored message HTML. `citations` drops AI `[1]` markers, as for markdown. */
|
|
46
|
+
export declare function parseHtml(html: string, citations?: boolean): Block[];
|
|
47
|
+
/**
|
|
48
|
+
* Splits off an AI answer's sources: a trailing list in which every item is just one web link.
|
|
49
|
+
* Like the web widget, it leaves the answer alone when the list is all there is.
|
|
50
|
+
*/
|
|
51
|
+
export declare function extractSources(blocks: Block[]): {
|
|
52
|
+
blocks: Block[];
|
|
53
|
+
sources: Source[];
|
|
54
|
+
};
|
package/lib/ui/rich.js
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Turns message bodies into a small block/inline tree that React Native can render, the way
|
|
4
|
+
* openDesk's web widget does: stored messages from `body_html`, AI answers still streaming (and
|
|
5
|
+
* messages with only `body_text`) from the widget's markdown subset. A trailing list of links in
|
|
6
|
+
* an AI answer becomes its sources.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.isSafeHref = isSafeHref;
|
|
10
|
+
exports.hostOf = hostOf;
|
|
11
|
+
exports.textOf = textOf;
|
|
12
|
+
exports.parseMarkdown = parseMarkdown;
|
|
13
|
+
exports.parseHtml = parseHtml;
|
|
14
|
+
exports.extractSources = extractSources;
|
|
15
|
+
/** Only web and mail links open; anything else (javascript:, intent:, …) renders as text. */
|
|
16
|
+
function isSafeHref(href) {
|
|
17
|
+
return /^(https?:\/\/|mailto:)/i.test(href.trim());
|
|
18
|
+
}
|
|
19
|
+
/** The domain shown with a source, without `www.`. */
|
|
20
|
+
function hostOf(href) {
|
|
21
|
+
const match = /^https?:\/\/([^/?#:]+)/i.exec(href);
|
|
22
|
+
return match ? match[1].replace(/^www\./i, '') : '';
|
|
23
|
+
}
|
|
24
|
+
/** Plain text of inlines, e.g. for accessibility labels. */
|
|
25
|
+
function textOf(inlines) {
|
|
26
|
+
return inlines
|
|
27
|
+
.map((inline) => {
|
|
28
|
+
switch (inline.kind) {
|
|
29
|
+
case 'text':
|
|
30
|
+
case 'code':
|
|
31
|
+
return inline.text;
|
|
32
|
+
case 'break':
|
|
33
|
+
return '\n';
|
|
34
|
+
default:
|
|
35
|
+
return textOf(inline.children);
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
.join('');
|
|
39
|
+
}
|
|
40
|
+
// ---------------------------------------------------------------------------------------------
|
|
41
|
+
// Markdown: the web widget's subset (bold, italic, code, links, bare URLs, lists, headings,
|
|
42
|
+
// quotes, fences), including its clean-ups for text that is still streaming in.
|
|
43
|
+
// ---------------------------------------------------------------------------------------------
|
|
44
|
+
const CITATIONS = /[ \t]*\[\d{1,3}(?:\s*,\s*\d{1,3})*\](?!\()/g;
|
|
45
|
+
const PARTIAL_CITATION = /[ \t]*\[\d{0,3}(?:\s*,\s*\d{0,3})*$/;
|
|
46
|
+
const INLINE = /(\*\*([^*\n]+?)\*\*)|(`([^`\n]+?)`)|(\[([^\]\n]+)\]\(([^)\s]+)\))|((^|[^\w*])\*([^*\s][^*\n]*?)\*(?![\w*]))|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/g;
|
|
47
|
+
const BULLET = /^\s{0,3}[-*+]\s+(.*)$/;
|
|
48
|
+
const NUMBERED = /^\s{0,3}\d{1,3}[.)]\s+(.*)$/;
|
|
49
|
+
const HEADING = /^\s{0,3}#{1,6}\s+(.*)$/;
|
|
50
|
+
const QUOTE = /^\s{0,3}>\s?(.*)$/;
|
|
51
|
+
const FENCE = /^\s{0,3}```/;
|
|
52
|
+
/** Drops `[1]`-style citation markers the AI leaves in its text. */
|
|
53
|
+
function stripCitations(text, streaming) {
|
|
54
|
+
const stripped = text.replace(CITATIONS, '');
|
|
55
|
+
return streaming ? stripped.replace(PARTIAL_CITATION, '') : stripped;
|
|
56
|
+
}
|
|
57
|
+
/** Hides half-typed markup while an answer streams in, so `**bo` never shows its asterisks. */
|
|
58
|
+
function closeOpenMarkup(text) {
|
|
59
|
+
let result = text;
|
|
60
|
+
if ((result.match(/```/g) ?? []).length % 2 === 1) {
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
if ((result.match(/\*\*/g) ?? []).length % 2 === 1) {
|
|
64
|
+
const at = result.lastIndexOf('**');
|
|
65
|
+
result = result.slice(0, at) + result.slice(at + 2);
|
|
66
|
+
}
|
|
67
|
+
if ((result.replace(/```/g, '').match(/`/g) ?? []).length % 2 === 1) {
|
|
68
|
+
const at = result.lastIndexOf('`');
|
|
69
|
+
result = result.slice(0, at) + result.slice(at + 1);
|
|
70
|
+
}
|
|
71
|
+
return result.replace(/\[([^\]\n]*)\]\([^)\s]*$/, '$1').replace(/\[([^\]\n]*)$/, '$1');
|
|
72
|
+
}
|
|
73
|
+
function markdownInlines(text) {
|
|
74
|
+
const inlines = [];
|
|
75
|
+
const pattern = new RegExp(INLINE.source, 'g');
|
|
76
|
+
let last = 0;
|
|
77
|
+
for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
|
|
78
|
+
if (match.index > last) {
|
|
79
|
+
inlines.push({ kind: 'text', text: text.slice(last, match.index) });
|
|
80
|
+
}
|
|
81
|
+
if (match[1]) {
|
|
82
|
+
inlines.push({ kind: 'bold', children: markdownInlines(match[2]) });
|
|
83
|
+
}
|
|
84
|
+
else if (match[3]) {
|
|
85
|
+
inlines.push({ kind: 'code', text: match[4] });
|
|
86
|
+
}
|
|
87
|
+
else if (match[5]) {
|
|
88
|
+
const href = match[7];
|
|
89
|
+
inlines.push(isSafeHref(href) ? { kind: 'link', href, children: markdownInlines(match[6]) } : { kind: 'text', text: match[6] });
|
|
90
|
+
}
|
|
91
|
+
else if (match[8]) {
|
|
92
|
+
if (match[9]) {
|
|
93
|
+
inlines.push({ kind: 'text', text: match[9] });
|
|
94
|
+
}
|
|
95
|
+
inlines.push({ kind: 'italic', children: markdownInlines(match[10]) });
|
|
96
|
+
}
|
|
97
|
+
else if (match[11]) {
|
|
98
|
+
inlines.push({ kind: 'link', href: match[11], children: [{ kind: 'text', text: match[11] }] });
|
|
99
|
+
}
|
|
100
|
+
last = match.index + match[0].length;
|
|
101
|
+
}
|
|
102
|
+
if (last < text.length) {
|
|
103
|
+
inlines.push({ kind: 'text', text: text.slice(last) });
|
|
104
|
+
}
|
|
105
|
+
return inlines;
|
|
106
|
+
}
|
|
107
|
+
function parseMarkdown(source, streaming = false) {
|
|
108
|
+
let text = stripCitations(source.replace(/\r\n?/g, '\n'), streaming);
|
|
109
|
+
if (streaming) {
|
|
110
|
+
text = closeOpenMarkup(text);
|
|
111
|
+
}
|
|
112
|
+
const lines = text.split('\n');
|
|
113
|
+
const blocks = [];
|
|
114
|
+
let paragraph = [];
|
|
115
|
+
const flush = () => {
|
|
116
|
+
if (paragraph.length === 0) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const inlines = [];
|
|
120
|
+
paragraph.forEach((line, index) => {
|
|
121
|
+
if (index > 0) {
|
|
122
|
+
inlines.push({ kind: 'break' });
|
|
123
|
+
}
|
|
124
|
+
inlines.push(...markdownInlines(line));
|
|
125
|
+
});
|
|
126
|
+
blocks.push({ kind: 'paragraph', inlines });
|
|
127
|
+
paragraph = [];
|
|
128
|
+
};
|
|
129
|
+
for (let i = 0; i < lines.length; i++) {
|
|
130
|
+
const line = lines[i];
|
|
131
|
+
if (FENCE.test(line)) {
|
|
132
|
+
flush();
|
|
133
|
+
const code = [];
|
|
134
|
+
for (i++; i < lines.length && !FENCE.test(lines[i]); i++) {
|
|
135
|
+
code.push(lines[i]);
|
|
136
|
+
}
|
|
137
|
+
blocks.push({ kind: 'code', text: code.join('\n') });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const listPattern = BULLET.test(line) ? BULLET : NUMBERED.test(line) ? NUMBERED : null;
|
|
141
|
+
if (listPattern) {
|
|
142
|
+
flush();
|
|
143
|
+
const items = [];
|
|
144
|
+
for (; i < lines.length && listPattern.test(lines[i]); i++) {
|
|
145
|
+
items.push(markdownInlines(listPattern.exec(lines[i])[1]));
|
|
146
|
+
}
|
|
147
|
+
i--;
|
|
148
|
+
blocks.push({ kind: 'list', ordered: listPattern === NUMBERED, items });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const heading = HEADING.exec(line);
|
|
152
|
+
if (heading) {
|
|
153
|
+
flush();
|
|
154
|
+
blocks.push({ kind: 'heading', inlines: markdownInlines(heading[1]) });
|
|
155
|
+
}
|
|
156
|
+
else if (QUOTE.test(line)) {
|
|
157
|
+
flush();
|
|
158
|
+
const quoted = [];
|
|
159
|
+
for (; i < lines.length && QUOTE.test(lines[i]); i++) {
|
|
160
|
+
quoted.push(QUOTE.exec(lines[i])[1]);
|
|
161
|
+
}
|
|
162
|
+
i--;
|
|
163
|
+
blocks.push({ kind: 'quote', inlines: markdownInlines(quoted.join(' ')) });
|
|
164
|
+
}
|
|
165
|
+
else if (line.trim()) {
|
|
166
|
+
paragraph.push(line.trim());
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
flush();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
flush();
|
|
173
|
+
return blocks;
|
|
174
|
+
}
|
|
175
|
+
const VOID = new Set(['br', 'img', 'hr', 'input', 'meta', 'link', 'wbr', 'source', 'col']);
|
|
176
|
+
const SKIP = new Set(['script', 'style', 'head', 'title', 'template', 'noscript', 'svg']);
|
|
177
|
+
const BLOCK = new Set([
|
|
178
|
+
'p',
|
|
179
|
+
'div',
|
|
180
|
+
'section',
|
|
181
|
+
'article',
|
|
182
|
+
'header',
|
|
183
|
+
'footer',
|
|
184
|
+
'main',
|
|
185
|
+
'aside',
|
|
186
|
+
'ul',
|
|
187
|
+
'ol',
|
|
188
|
+
'li',
|
|
189
|
+
'h1',
|
|
190
|
+
'h2',
|
|
191
|
+
'h3',
|
|
192
|
+
'h4',
|
|
193
|
+
'h5',
|
|
194
|
+
'h6',
|
|
195
|
+
'blockquote',
|
|
196
|
+
'pre',
|
|
197
|
+
'table',
|
|
198
|
+
'thead',
|
|
199
|
+
'tbody',
|
|
200
|
+
'tr',
|
|
201
|
+
'td',
|
|
202
|
+
'th',
|
|
203
|
+
'hr',
|
|
204
|
+
'figure',
|
|
205
|
+
'figcaption',
|
|
206
|
+
]);
|
|
207
|
+
const ENTITIES = {
|
|
208
|
+
amp: '&',
|
|
209
|
+
lt: '<',
|
|
210
|
+
gt: '>',
|
|
211
|
+
quot: '"',
|
|
212
|
+
apos: "'",
|
|
213
|
+
nbsp: ' ',
|
|
214
|
+
ndash: '–',
|
|
215
|
+
mdash: '—',
|
|
216
|
+
hellip: '…',
|
|
217
|
+
lsquo: '‘',
|
|
218
|
+
rsquo: '’',
|
|
219
|
+
ldquo: '“',
|
|
220
|
+
rdquo: '”',
|
|
221
|
+
bull: '•',
|
|
222
|
+
middot: '·',
|
|
223
|
+
copy: '©',
|
|
224
|
+
reg: '®',
|
|
225
|
+
trade: '™',
|
|
226
|
+
};
|
|
227
|
+
function decodeEntities(text) {
|
|
228
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, entity) => {
|
|
229
|
+
if (entity[0] === '#') {
|
|
230
|
+
const code = entity[1]?.toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : parseInt(entity.slice(1), 10);
|
|
231
|
+
return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
|
|
232
|
+
}
|
|
233
|
+
return ENTITIES[entity.toLowerCase()] ?? match;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
function parseAttributes(source) {
|
|
237
|
+
const attrs = {};
|
|
238
|
+
const pattern = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
239
|
+
for (let match = pattern.exec(source); match; match = pattern.exec(source)) {
|
|
240
|
+
attrs[match[1].toLowerCase()] = decodeEntities(match[2] ?? match[3] ?? match[4] ?? '');
|
|
241
|
+
}
|
|
242
|
+
return attrs;
|
|
243
|
+
}
|
|
244
|
+
function parseTree(html) {
|
|
245
|
+
const root = { tag: '#root', attrs: {}, children: [] };
|
|
246
|
+
const stack = [root];
|
|
247
|
+
const pattern = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][a-zA-Z0-9]*)([^>]*)>|([^<]+|<)/g;
|
|
248
|
+
let skipping = null;
|
|
249
|
+
for (let match = pattern.exec(html); match; match = pattern.exec(html)) {
|
|
250
|
+
const [whole, closing, rawTag, rawAttrs, text] = match;
|
|
251
|
+
const current = stack[stack.length - 1];
|
|
252
|
+
if (whole.startsWith('<!--')) {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (text !== undefined) {
|
|
256
|
+
if (!skipping) {
|
|
257
|
+
current.children.push(decodeEntities(text));
|
|
258
|
+
}
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const tag = rawTag.toLowerCase();
|
|
262
|
+
if (skipping) {
|
|
263
|
+
if (closing && tag === skipping) {
|
|
264
|
+
skipping = null;
|
|
265
|
+
}
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (closing) {
|
|
269
|
+
const at = stack.map((element) => element.tag).lastIndexOf(tag);
|
|
270
|
+
if (at > 0) {
|
|
271
|
+
stack.length = at;
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (SKIP.has(tag)) {
|
|
276
|
+
if (!rawAttrs.trim().endsWith('/')) {
|
|
277
|
+
skipping = tag;
|
|
278
|
+
}
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
const element = { tag, attrs: parseAttributes(rawAttrs ?? ''), children: [] };
|
|
282
|
+
current.children.push(element);
|
|
283
|
+
if (!VOID.has(tag) && !rawAttrs.trim().endsWith('/')) {
|
|
284
|
+
stack.push(element);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return root.children;
|
|
288
|
+
}
|
|
289
|
+
function textContent(nodes) {
|
|
290
|
+
return nodes.map((node) => (typeof node === 'string' ? node : node.tag === 'br' ? '\n' : textContent(node.children))).join('');
|
|
291
|
+
}
|
|
292
|
+
function htmlInlines(nodes, citations) {
|
|
293
|
+
const inlines = [];
|
|
294
|
+
for (const node of nodes) {
|
|
295
|
+
if (typeof node === 'string') {
|
|
296
|
+
const text = node.replace(/[ \t\r\n\f]+/g, ' ');
|
|
297
|
+
inlines.push({ kind: 'text', text: citations ? text.replace(CITATIONS, '') : text });
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
switch (node.tag) {
|
|
301
|
+
case 'br':
|
|
302
|
+
inlines.push({ kind: 'break' });
|
|
303
|
+
break;
|
|
304
|
+
case 'img':
|
|
305
|
+
if (node.attrs.alt) {
|
|
306
|
+
inlines.push({ kind: 'text', text: node.attrs.alt });
|
|
307
|
+
}
|
|
308
|
+
break;
|
|
309
|
+
case 'strong':
|
|
310
|
+
case 'b':
|
|
311
|
+
inlines.push({ kind: 'bold', children: htmlInlines(node.children, citations) });
|
|
312
|
+
break;
|
|
313
|
+
case 'em':
|
|
314
|
+
case 'i':
|
|
315
|
+
inlines.push({ kind: 'italic', children: htmlInlines(node.children, citations) });
|
|
316
|
+
break;
|
|
317
|
+
case 'code':
|
|
318
|
+
case 'kbd':
|
|
319
|
+
case 'samp':
|
|
320
|
+
inlines.push({ kind: 'code', text: textContent(node.children) });
|
|
321
|
+
break;
|
|
322
|
+
case 'a': {
|
|
323
|
+
const href = node.attrs.href ?? '';
|
|
324
|
+
const children = htmlInlines(node.children, citations);
|
|
325
|
+
if (isSafeHref(href)) {
|
|
326
|
+
inlines.push({ kind: 'link', href: href.trim(), children });
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
inlines.push(...children);
|
|
330
|
+
}
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
default:
|
|
334
|
+
if (BLOCK.has(node.tag) && inlines.length > 0) {
|
|
335
|
+
inlines.push({ kind: 'break' });
|
|
336
|
+
}
|
|
337
|
+
inlines.push(...htmlInlines(node.children, citations));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return inlines;
|
|
341
|
+
}
|
|
342
|
+
/** Trims whitespace and breaks at both ends of a run of inlines; returns [] when nothing is left. */
|
|
343
|
+
function tidy(inlines) {
|
|
344
|
+
const result = [...inlines];
|
|
345
|
+
const blank = (inline) => inline?.kind === 'break' || (inline?.kind === 'text' && !inline.text.trim());
|
|
346
|
+
while (blank(result[0])) {
|
|
347
|
+
result.shift();
|
|
348
|
+
}
|
|
349
|
+
while (blank(result[result.length - 1])) {
|
|
350
|
+
result.pop();
|
|
351
|
+
}
|
|
352
|
+
const first = result[0];
|
|
353
|
+
if (first?.kind === 'text') {
|
|
354
|
+
result[0] = { kind: 'text', text: first.text.replace(/^\s+/, '') };
|
|
355
|
+
}
|
|
356
|
+
const last = result[result.length - 1];
|
|
357
|
+
if (last?.kind === 'text') {
|
|
358
|
+
result[result.length - 1] = { kind: 'text', text: last.text.replace(/\s+$/, '') };
|
|
359
|
+
}
|
|
360
|
+
return textOf(result).trim() ? result : [];
|
|
361
|
+
}
|
|
362
|
+
function htmlBlocks(nodes, citations) {
|
|
363
|
+
const blocks = [];
|
|
364
|
+
let pending = [];
|
|
365
|
+
const flush = () => {
|
|
366
|
+
const inlines = tidy(htmlInlines(pending, citations));
|
|
367
|
+
if (inlines.length > 0) {
|
|
368
|
+
blocks.push({ kind: 'paragraph', inlines });
|
|
369
|
+
}
|
|
370
|
+
pending = [];
|
|
371
|
+
};
|
|
372
|
+
for (const node of nodes) {
|
|
373
|
+
if (typeof node === 'string' || !BLOCK.has(node.tag)) {
|
|
374
|
+
pending.push(node);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
flush();
|
|
378
|
+
switch (node.tag) {
|
|
379
|
+
case 'ul':
|
|
380
|
+
case 'ol': {
|
|
381
|
+
const items = node.children
|
|
382
|
+
.filter((child) => typeof child !== 'string' && child.tag === 'li')
|
|
383
|
+
.map((item) => tidy(htmlInlines(item.children, citations)))
|
|
384
|
+
.filter((item) => item.length > 0);
|
|
385
|
+
if (items.length > 0) {
|
|
386
|
+
blocks.push({ kind: 'list', ordered: node.tag === 'ol', items });
|
|
387
|
+
}
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
case 'pre':
|
|
391
|
+
blocks.push({ kind: 'code', text: textContent(node.children).replace(/^\n+|\s+$/g, '') });
|
|
392
|
+
break;
|
|
393
|
+
case 'blockquote': {
|
|
394
|
+
const inlines = tidy(htmlInlines(node.children, citations));
|
|
395
|
+
if (inlines.length > 0) {
|
|
396
|
+
blocks.push({ kind: 'quote', inlines });
|
|
397
|
+
}
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
case 'h1':
|
|
401
|
+
case 'h2':
|
|
402
|
+
case 'h3':
|
|
403
|
+
case 'h4':
|
|
404
|
+
case 'h5':
|
|
405
|
+
case 'h6': {
|
|
406
|
+
const inlines = tidy(htmlInlines(node.children, citations));
|
|
407
|
+
if (inlines.length > 0) {
|
|
408
|
+
blocks.push({ kind: 'heading', inlines });
|
|
409
|
+
}
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
case 'hr':
|
|
413
|
+
break;
|
|
414
|
+
default:
|
|
415
|
+
blocks.push(...htmlBlocks(node.children, citations));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
flush();
|
|
419
|
+
return blocks;
|
|
420
|
+
}
|
|
421
|
+
/** Parses stored message HTML. `citations` drops AI `[1]` markers, as for markdown. */
|
|
422
|
+
function parseHtml(html, citations = false) {
|
|
423
|
+
return htmlBlocks(parseTree(html), citations);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Splits off an AI answer's sources: a trailing list in which every item is just one web link.
|
|
427
|
+
* Like the web widget, it leaves the answer alone when the list is all there is.
|
|
428
|
+
*/
|
|
429
|
+
function extractSources(blocks) {
|
|
430
|
+
const last = blocks[blocks.length - 1];
|
|
431
|
+
if (!last || last.kind !== 'list' || blocks.length < 2) {
|
|
432
|
+
return { blocks, sources: [] };
|
|
433
|
+
}
|
|
434
|
+
const sources = [];
|
|
435
|
+
for (const item of last.items) {
|
|
436
|
+
const content = item.filter((inline) => !(inline.kind === 'text' && !inline.text.trim()));
|
|
437
|
+
const link = content[0];
|
|
438
|
+
if (content.length !== 1 || link?.kind !== 'link' || !/^https?:/i.test(link.href)) {
|
|
439
|
+
return { blocks, sources: [] };
|
|
440
|
+
}
|
|
441
|
+
sources.push({ href: link.href, title: textOf(link.children).trim() || link.href });
|
|
442
|
+
}
|
|
443
|
+
return { blocks: blocks.slice(0, -1), sources };
|
|
444
|
+
}
|
package/package.json
CHANGED