@linxin666/dsh-remote-web-ui 0.1.17 → 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/lib/client.js +48 -58
- package/lib/client.js.map +1 -1
- 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/package.json +1 -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
|
@@ -28,6 +28,17 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
|
28
28
|
onSave: () => void;
|
|
29
29
|
/** Drop every staged edit. */
|
|
30
30
|
onDiscard: () => void;
|
|
31
|
+
/**
|
|
32
|
+
* Render the controls expanded on arrival (still collapsible). Defaults to
|
|
33
|
+
* true: promoted first-level sections show their settings immediately.
|
|
34
|
+
*/
|
|
35
|
+
defaultOpen?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Render without the collapse affordance: a static header and an always
|
|
38
|
+
* visible body. Used by first-level settings sections whose nav entry
|
|
39
|
+
* already provides the selection.
|
|
40
|
+
*/
|
|
41
|
+
alwaysOpen?: boolean;
|
|
31
42
|
/** The plugin's controls. */
|
|
32
43
|
children: ReactNode;
|
|
33
44
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"PluginSettingsCard.d.ts","sourceRoot":"","sources":["../../../src/client/PluginSettingsCard.tsx"],"names":[],"mappings":"AACA;;;;;;;GAOG;AAEH,OAAO,EAAY,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAGnD,oGAAoG;AACpG,eAAO,MAAM,cAAc,kMAUjB,CAAA;AAEV,6CAA6C;AAC7C,MAAM,MAAM,WAAW,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,CAAC,CAAA;AAEzD,oEAAoE;AACpE,MAAM,MAAM,eAAe,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM,IAAI,IAAI,GAAG,WAAW,CAAA;AAE9E,wDAAwD;AACxD,MAAM,WAAW,uBAAuB,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM;IACnE,0CAA0C;IAC1C,CAAC,EAAE,CAAC,GAAG,EAAE,eAAe,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,KAAK,MAAM,CAAA;IACnF,uCAAuC;IACvC,QAAQ,EAAE,IAAI,CAAA;IACd,4EAA4E;IAC5E,cAAc,EAAE,IAAI,CAAA;IACpB,kFAAkF;IAClF,KAAK,EAAE,SAAS,CAAA;IAChB,+BAA+B;IAC/B,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,8BAA8B;IAC9B,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,6BAA6B;IAC7B,QAAQ,EAAE,SAAS,CAAA;CACpB;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM,EAAE,KAAK,EAAE,uBAAuB,CAAC,IAAI,CAAC,
|
|
1
|
+
{"version":3,"file":"PluginSettingsCard.d.ts","sourceRoot":"","sources":["../../../src/client/PluginSettingsCard.tsx"],"names":[],"mappings":"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"}
|
|
@@ -28,24 +28,30 @@ export const CARD_COPY_KEYS = [
|
|
|
28
28
|
* @returns the card, or nothing while the namespace is still loading.
|
|
29
29
|
*/
|
|
30
30
|
export function PluginSettingsCard(props) {
|
|
31
|
-
const [open, setOpen] = useState(
|
|
32
|
-
const { state } = props;
|
|
31
|
+
const [open, setOpen] = useState(props.defaultOpen ?? true);
|
|
32
|
+
const { state, alwaysOpen } = props;
|
|
33
33
|
if (!state.available)
|
|
34
34
|
return null;
|
|
35
35
|
const title = props.t(props.titleKey);
|
|
36
36
|
const description = props.t(props.descriptionKey);
|
|
37
37
|
const blocked = !state.dirty || state.invalid || state.saving;
|
|
38
|
-
const
|
|
38
|
+
const expanded = alwaysOpen === true || open;
|
|
39
|
+
const cardClass = expanded ? `${css.cardOpen} ${css.card}` : css.card;
|
|
40
|
+
// With alwaysOpen the nav entry already provides the selection, so the
|
|
41
|
+
// header is a static title row instead of a disclosure button.
|
|
42
|
+
const header = alwaysOpen === true
|
|
43
|
+
? (_jsxs("div", { className: css.headerStatic, children: [_jsxs("span", { className: css.headText, children: [_jsx("span", { className: css.name, title: title, children: title }), _jsx("span", { className: css.description, title: description, children: description })] }), state.dirty ? _jsx("span", { className: css.pending, title: props.t('settings.unsaved'), children: props.t('settings.unsaved') }) : null] }))
|
|
44
|
+
: (_jsxs("button", { type: "button", className: css.header, "aria-expanded": open, "aria-label": `${props.t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`, onClick: () => { setOpen(!open); }, children: [_jsxs("span", { className: css.headText, children: [_jsx("span", { className: css.name, title: title, children: title }), _jsx("span", { className: css.description, title: description, children: description })] }), state.dirty ? _jsx("span", { className: css.pending, title: props.t('settings.unsaved'), children: props.t('settings.unsaved') }) : null, _jsx("svg", { width: "14", height: "14", viewBox: "0 0 14 14", fill: "none", xmlns: "http://www.w3.org/2000/svg", className: open ? `${css.chevron} ${css.chevronOpen}` : css.chevron, children: _jsx("path", { d: "M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5Z", fill: "currentColor" }) })] }));
|
|
39
45
|
// The namespace exists but the Host does not serve it to this client (the
|
|
40
46
|
// official settings allowlist omits third-party namespaces): show a card
|
|
41
47
|
// that explains the gap instead of vanishing, so a missing card never
|
|
42
48
|
// reads as a missing plugin.
|
|
43
49
|
if (!state.exposed) {
|
|
44
|
-
return (_jsxs("li", { className: cardClass, children: [
|
|
50
|
+
return (_jsxs("li", { className: cardClass, children: [header, expanded
|
|
45
51
|
? (_jsx("div", { className: css.body, children: _jsx("p", { className: css.notExposed, role: "status", children: props.t('settings.notExposed') }) }))
|
|
46
52
|
: null] }));
|
|
47
53
|
}
|
|
48
|
-
return (_jsxs("li", { className: cardClass, children: [
|
|
54
|
+
return (_jsxs("li", { className: cardClass, children: [header, expanded
|
|
49
55
|
? (_jsxs("div", { className: css.body, children: [!state.writable ? _jsx("p", { className: css.readOnly, role: "status", children: props.t('settings.readOnly') }) : null, props.children, _jsxs("div", { className: css.footer, children: [state.failed
|
|
50
56
|
? (_jsxs("p", { className: css.failed, role: "status", children: [props.t('settings.saveFailed'), state.failedReason ? ' - ' + state.failedReason : ''] }))
|
|
51
57
|
: null, _jsx("button", { type: "button", className: css.discard, disabled: !state.dirty || state.saving, onClick: props.onDiscard, children: props.t('settings.discard') }), _jsx("button", { type: "button", className: css.save, disabled: blocked, onClick: props.onSave, children: props.t(!state.saving ? 'settings.save' : 'settings.saving') })] })] }))
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
/** Escape HTML special characters. */
|
|
13
|
+
export declare function escapeHtml(text: string): string;
|
|
14
|
+
/**
|
|
15
|
+
* Guard a raw link/image target against dangerous protocols. Returns the
|
|
16
|
+
* (trimmed) raw string when safe, else null. Only http:, https:, mailto:
|
|
17
|
+
* and fragment anchors (#...) are allowed; anything with another scheme
|
|
18
|
+
* — javascript:, data:, vbscript:, etc. — is rejected so the value never
|
|
19
|
+
* reaches dangerouslySetInnerHTML.
|
|
20
|
+
*/
|
|
21
|
+
export declare function safeUrl(raw: string): string | null;
|
|
22
|
+
/** Inline pass: code spans, bold, italic, strikethrough, images, links. */
|
|
23
|
+
export declare function renderInline(text: string): string;
|
|
24
|
+
/** Render a markdown document to HTML (block pass). */
|
|
25
|
+
export declare function renderMarkdown(source: string): string;
|
|
26
|
+
//# sourceMappingURL=markdown.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../../../src/mobile/markdown.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,sCAAsC;AACtC,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAO/C;AAED;;;;;;GAMG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQlD;AAgBD,2EAA2E;AAC3E,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAqFjD;AAED,uDAAuD;AACvD,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CA+HrD"}
|
|
@@ -0,0 +1,272 @@
|
|
|
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
|
+
/** Escape HTML special characters. */
|
|
13
|
+
export function escapeHtml(text) {
|
|
14
|
+
return text
|
|
15
|
+
.replace(/&/g, '&')
|
|
16
|
+
.replace(/</g, '<')
|
|
17
|
+
.replace(/>/g, '>')
|
|
18
|
+
.replace(/"/g, '"')
|
|
19
|
+
.replace(/'/g, ''');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Guard a raw link/image target against dangerous protocols. Returns the
|
|
23
|
+
* (trimmed) raw string when safe, else null. Only http:, https:, mailto:
|
|
24
|
+
* and fragment anchors (#...) are allowed; anything with another scheme
|
|
25
|
+
* — javascript:, data:, vbscript:, etc. — is rejected so the value never
|
|
26
|
+
* reaches dangerouslySetInnerHTML.
|
|
27
|
+
*/
|
|
28
|
+
export function safeUrl(raw) {
|
|
29
|
+
const trimmed = raw.trim();
|
|
30
|
+
if (trimmed === '')
|
|
31
|
+
return null;
|
|
32
|
+
if (trimmed.startsWith('#'))
|
|
33
|
+
return trimmed;
|
|
34
|
+
const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed);
|
|
35
|
+
if (scheme === null)
|
|
36
|
+
return trimmed;
|
|
37
|
+
const name = scheme[1].toLowerCase();
|
|
38
|
+
return name === 'http' || name === 'https' || name === 'mailto' ? trimmed : null;
|
|
39
|
+
}
|
|
40
|
+
/** Find the ')' closing a link/image target, skipping nested parens. */
|
|
41
|
+
function findCloseParen(text, from) {
|
|
42
|
+
let depth = 0;
|
|
43
|
+
for (let i = from; i < text.length; i += 1) {
|
|
44
|
+
const char = text[i];
|
|
45
|
+
if (char === '(')
|
|
46
|
+
depth += 1;
|
|
47
|
+
else if (char === ')') {
|
|
48
|
+
if (depth === 0)
|
|
49
|
+
return i;
|
|
50
|
+
depth -= 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return -1;
|
|
54
|
+
}
|
|
55
|
+
/** Inline pass: code spans, bold, italic, strikethrough, images, links. */
|
|
56
|
+
export function renderInline(text) {
|
|
57
|
+
let out = '';
|
|
58
|
+
let i = 0;
|
|
59
|
+
const n = text.length;
|
|
60
|
+
while (i < n) {
|
|
61
|
+
const char = text[i];
|
|
62
|
+
// Fenced inline code first.
|
|
63
|
+
if (char === '`') {
|
|
64
|
+
const end = text.indexOf('`', i + 1);
|
|
65
|
+
if (end !== -1) {
|
|
66
|
+
out += '<code>' + escapeHtml(text.slice(i + 1, end)) + '</code>';
|
|
67
|
+
i = end + 1;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Image 
|
|
72
|
+
if (char === '!' && text[i + 1] === '[') {
|
|
73
|
+
const close = text.indexOf('](', i + 2);
|
|
74
|
+
if (close !== -1) {
|
|
75
|
+
const parenEnd = findCloseParen(text, close + 2);
|
|
76
|
+
if (parenEnd !== -1) {
|
|
77
|
+
const alt = text.slice(i + 2, close);
|
|
78
|
+
const src = text.slice(close + 2, parenEnd);
|
|
79
|
+
const safe = safeUrl(src);
|
|
80
|
+
if (safe === null) {
|
|
81
|
+
out += escapeHtml(alt);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
const srcEsc = escapeHtml(safe).replace(/\s+/g, '%20');
|
|
85
|
+
out += '<img alt="' + escapeHtml(alt) + '" src="' + srcEsc + '" />';
|
|
86
|
+
}
|
|
87
|
+
i = parenEnd + 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Link [text](href)
|
|
93
|
+
if (char === '[') {
|
|
94
|
+
const close = text.indexOf('](', i + 1);
|
|
95
|
+
if (close !== -1) {
|
|
96
|
+
const parenEnd = findCloseParen(text, close + 2);
|
|
97
|
+
if (parenEnd !== -1) {
|
|
98
|
+
const label = text.slice(i + 1, close);
|
|
99
|
+
const href = text.slice(close + 2, parenEnd);
|
|
100
|
+
const safe = safeUrl(href);
|
|
101
|
+
if (safe === null) {
|
|
102
|
+
out += renderInline(label);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
out += '<a href="' + escapeHtml(safe) + '" target="_blank" rel="noopener noreferrer">' + renderInline(label) + '</a>';
|
|
106
|
+
}
|
|
107
|
+
i = parenEnd + 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Bold **text**
|
|
113
|
+
if (char === '*' && text[i + 1] === '*') {
|
|
114
|
+
const end = text.indexOf('**', i + 2);
|
|
115
|
+
if (end !== -1) {
|
|
116
|
+
out += '<strong>' + renderInline(text.slice(i + 2, end)) + '</strong>';
|
|
117
|
+
i = end + 2;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Italic *text*
|
|
122
|
+
if (char === '*' && text[i - 1] !== '*' && text[i + 1] !== '*') {
|
|
123
|
+
const end = text.indexOf('*', i + 1);
|
|
124
|
+
if (end !== -1 && text[end + 1] !== '*') {
|
|
125
|
+
out += '<em>' + renderInline(text.slice(i + 1, end)) + '</em>';
|
|
126
|
+
i = end + 1;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Strikethrough ~~text~~
|
|
131
|
+
if (char === '~' && text[i + 1] === '~') {
|
|
132
|
+
const end = text.indexOf('~~', i + 2);
|
|
133
|
+
if (end !== -1) {
|
|
134
|
+
out += '<del>' + renderInline(text.slice(i + 2, end)) + '</del>';
|
|
135
|
+
i = end + 2;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
out += escapeHtml(char);
|
|
140
|
+
i += 1;
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
/** Render a markdown document to HTML (block pass). */
|
|
145
|
+
export function renderMarkdown(source) {
|
|
146
|
+
const lines = source.replace(/\r\n/g, '\n').split('\n');
|
|
147
|
+
const out = [];
|
|
148
|
+
let i = 0;
|
|
149
|
+
const n = lines.length;
|
|
150
|
+
const flushParagraph = (buffer) => {
|
|
151
|
+
if (buffer.length === 0)
|
|
152
|
+
return;
|
|
153
|
+
out.push('<p>' + renderInline(buffer.join('\n')) + '</p>');
|
|
154
|
+
buffer.length = 0;
|
|
155
|
+
};
|
|
156
|
+
let paragraph = [];
|
|
157
|
+
while (i < n) {
|
|
158
|
+
const line = lines[i];
|
|
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 = [];
|
|
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
|
+
// Heading.
|
|
176
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
177
|
+
if (heading !== null) {
|
|
178
|
+
flushParagraph(paragraph);
|
|
179
|
+
const level = heading[1].length;
|
|
180
|
+
out.push('<h' + level + '>' + renderInline(heading[2] ?? '') + '</h' + level + '>');
|
|
181
|
+
i += 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// Horizontal rule.
|
|
185
|
+
if (/^\s*(---+|\*\*\*+|___+)\s*$/.test(line)) {
|
|
186
|
+
flushParagraph(paragraph);
|
|
187
|
+
out.push('<hr />');
|
|
188
|
+
i += 1;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
// Table: header row then separator row.
|
|
192
|
+
if (line.includes('|') && i + 1 < n && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
|
|
193
|
+
flushParagraph(paragraph);
|
|
194
|
+
const headerCells = splitTableRow(line);
|
|
195
|
+
i += 2;
|
|
196
|
+
const rows = [];
|
|
197
|
+
while (i < n && lines[i].includes('|')) {
|
|
198
|
+
rows.push(splitTableRow(lines[i]));
|
|
199
|
+
i += 1;
|
|
200
|
+
}
|
|
201
|
+
out.push('<table>');
|
|
202
|
+
out.push('<thead><tr>' + headerCells.map((cell) => '<th>' + renderInline(cell) + '</th>').join('') + '</tr></thead>');
|
|
203
|
+
if (rows.length > 0) {
|
|
204
|
+
out.push('<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + renderInline(cell) + '</td>').join('') + '</tr>').join('') + '</tbody>');
|
|
205
|
+
}
|
|
206
|
+
out.push('</table>');
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
// Blockquote (one level).
|
|
210
|
+
const quote = /^>\s?(.*)$/.exec(line);
|
|
211
|
+
if (quote !== null) {
|
|
212
|
+
flushParagraph(paragraph);
|
|
213
|
+
const body = [];
|
|
214
|
+
while (i < n) {
|
|
215
|
+
const q = /^>\s?(.*)$/.exec(lines[i]);
|
|
216
|
+
if (q === null)
|
|
217
|
+
break;
|
|
218
|
+
body.push(q[1] ?? '');
|
|
219
|
+
i += 1;
|
|
220
|
+
}
|
|
221
|
+
out.push('<blockquote><p>' + body.map((bodyLine) => renderInline(bodyLine)).join('<br />') + '</p></blockquote>');
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
// Unordered list.
|
|
225
|
+
const ul = /^\s*([-*+])\s+(.*)$/.exec(line);
|
|
226
|
+
if (ul !== null) {
|
|
227
|
+
flushParagraph(paragraph);
|
|
228
|
+
const items = [];
|
|
229
|
+
while (i < n) {
|
|
230
|
+
const item = /^\s*([-*+])\s+(.*)$/.exec(lines[i]);
|
|
231
|
+
if (item === null)
|
|
232
|
+
break;
|
|
233
|
+
items.push('<li>' + renderInline(item[2] ?? '') + '</li>');
|
|
234
|
+
i += 1;
|
|
235
|
+
}
|
|
236
|
+
out.push('<ul>' + items.join('') + '</ul>');
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
// Ordered list.
|
|
240
|
+
const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line);
|
|
241
|
+
if (ol !== null) {
|
|
242
|
+
flushParagraph(paragraph);
|
|
243
|
+
const items = [];
|
|
244
|
+
while (i < n) {
|
|
245
|
+
const item = /^\s*\d+[.)]\s+(.*)$/.exec(lines[i]);
|
|
246
|
+
if (item === null)
|
|
247
|
+
break;
|
|
248
|
+
items.push('<li>' + renderInline(item[1] ?? '') + '</li>');
|
|
249
|
+
i += 1;
|
|
250
|
+
}
|
|
251
|
+
out.push('<ol>' + items.join('') + '</ol>');
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
// Blank line: flush the paragraph.
|
|
255
|
+
if (line.trim() === '') {
|
|
256
|
+
flushParagraph(paragraph);
|
|
257
|
+
i += 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
paragraph.push(line);
|
|
261
|
+
i += 1;
|
|
262
|
+
}
|
|
263
|
+
flushParagraph(paragraph);
|
|
264
|
+
return out.join('\n');
|
|
265
|
+
}
|
|
266
|
+
/** Split one table row into cells (respecting the leading/trailing pipes). */
|
|
267
|
+
function splitTableRow(line) {
|
|
268
|
+
const trimmed = line.trim();
|
|
269
|
+
const inner = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed;
|
|
270
|
+
const withoutTrailing = inner.endsWith('|') ? inner.slice(0, -1) : inner;
|
|
271
|
+
return withoutTrailing.split('|').map((cell) => cell.trim());
|
|
272
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"markdown.test.d.ts","sourceRoot":"","sources":["../../../src/mobile/markdown.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mobile markdown renderer tests: GFM subset coverage plus the security
|
|
3
|
+
* contract — raw HTML escapes, dangerous link/image protocols are
|
|
4
|
+
* stripped, and code fences never parse their content.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest';
|
|
7
|
+
import { escapeHtml, renderInline, renderMarkdown, safeUrl } from "./markdown.js";
|
|
8
|
+
describe('safeUrl', () => {
|
|
9
|
+
it('allows http/https/mailto/fragments and scheme-less targets', () => {
|
|
10
|
+
expect(safeUrl('https://example.com/a')).toBe('https://example.com/a');
|
|
11
|
+
expect(safeUrl('http://example.com')).toBe('http://example.com');
|
|
12
|
+
expect(safeUrl('mailto:a@b.c')).toBe('mailto:a@b.c');
|
|
13
|
+
expect(safeUrl('#top')).toBe('#top');
|
|
14
|
+
expect(safeUrl('./rel/path')).toBe('./rel/path');
|
|
15
|
+
});
|
|
16
|
+
it('rejects javascript:/data:/vbscript: and blanks', () => {
|
|
17
|
+
expect(safeUrl('javascript:alert(1)')).toBeNull();
|
|
18
|
+
expect(safeUrl('data:text/html,x')).toBeNull();
|
|
19
|
+
expect(safeUrl('vbscript:x')).toBeNull();
|
|
20
|
+
expect(safeUrl('')).toBeNull();
|
|
21
|
+
expect(safeUrl(' ')).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe('renderInline', () => {
|
|
25
|
+
it('renders code, bold, italic, strikethrough and links', () => {
|
|
26
|
+
expect(renderInline('`a<b`')).toBe('<code>a<b</code>');
|
|
27
|
+
expect(renderInline('**b**')).toBe('<strong>b</strong>');
|
|
28
|
+
expect(renderInline('*i*')).toBe('<em>i</em>');
|
|
29
|
+
expect(renderInline('~~d~~')).toBe('<del>d</del>');
|
|
30
|
+
expect(renderInline('[x](https://e.com)')).toBe('<a href="https://e.com" target="_blank" rel="noopener noreferrer">x</a>');
|
|
31
|
+
});
|
|
32
|
+
it('strips unsafe link targets and renders the label as plain text', () => {
|
|
33
|
+
expect(renderInline('[x](javascript:alert(1))')).toBe('x');
|
|
34
|
+
expect(renderInline('')).toBe('a');
|
|
35
|
+
});
|
|
36
|
+
it('escapes raw HTML instead of passing it through', () => {
|
|
37
|
+
expect(renderInline('<script>alert(1)</script>')).toBe('<script>alert(1)</script>');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('renderMarkdown', () => {
|
|
41
|
+
it('renders headings, hr, paragraphs', () => {
|
|
42
|
+
const html = renderMarkdown('# T\n\npara\n\n---');
|
|
43
|
+
expect(html).toContain('<h1>T</h1>');
|
|
44
|
+
expect(html).toContain('<p>para</p>');
|
|
45
|
+
expect(html).toContain('<hr />');
|
|
46
|
+
});
|
|
47
|
+
it('renders fenced code without parsing its content', () => {
|
|
48
|
+
const html = renderMarkdown('```ts\nconst a = **bold** <x>\n```');
|
|
49
|
+
expect(html).toBe('<pre class="language-ts"><code>const a = **bold** <x></code></pre>');
|
|
50
|
+
});
|
|
51
|
+
it('renders lists, blockquotes and tables', () => {
|
|
52
|
+
const html = renderMarkdown('- a\n- b\n\n1. x\n2. y\n\n> quote\n\n| h1 | h2 |\n| --- | --- |\n| c1 | c2 |');
|
|
53
|
+
expect(html).toContain('<ul><li>a</li><li>b</li></ul>');
|
|
54
|
+
expect(html).toContain('<ol><li>x</li><li>y</li></ol>');
|
|
55
|
+
expect(html).toContain('<blockquote><p>quote</p></blockquote>');
|
|
56
|
+
expect(html).toContain('<thead><tr><th>h1</th><th>h2</th></tr></thead>');
|
|
57
|
+
expect(html).toContain('<td>c1</td><td>c2</td>');
|
|
58
|
+
});
|
|
59
|
+
it('normalizes CRLF and keeps user-visible text intact', () => {
|
|
60
|
+
expect(renderMarkdown('a\r\nb')).toBe('<p>a\nb</p>');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe('escapeHtml', () => {
|
|
64
|
+
it('escapes all five HTML-significant characters', () => {
|
|
65
|
+
expect(escapeHtml('<&>"\'')).toBe('<&>"'');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -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
|
/** One tool call attached to an assistant message (callId dedupes repeats). */
|
|
73
87
|
export interface ToolCallInfo {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../../src/mobile/messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IACnC,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,EAAE,CAAA;IAC/B,8EAA8E;IAC9E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,kFAAkF;IAClF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,sFAAsF;IACtF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,kDAAkD;IAClD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../../src/mobile/messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IACnC,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,EAAE,CAAA;IAC/B,8EAA8E;IAC9E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,kFAAkF;IAClF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,sFAAsF;IACtF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,kDAAkD;IAClD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;IACzB;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE;QACf,WAAW,EAAE,MAAM,CAAA;QACnB,YAAY,EAAE,MAAM,CAAA;QACpB,eAAe,CAAC,EAAE,MAAM,CAAA;QACxB,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAC1B,CAAA;IACD,sFAAsF;IACtF,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B,gEAAgE;IAChE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,8BAA8B;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,qDAAqD;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;IACnC,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,IAAI,CAAA;CAC1B;AA6eD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,EAAE,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,GAAG,aAAa,EAAE,CAS7G"}
|
|
@@ -190,6 +190,15 @@ function applyEvent(state, event) {
|
|
|
190
190
|
case 'tool/call':
|
|
191
191
|
applyToolCall(state, event);
|
|
192
192
|
break;
|
|
193
|
+
case 'request/context': {
|
|
194
|
+
// Wire shape: { provider, model, contextWindow? }. A present finite
|
|
195
|
+
// contextWindow seeds every later assistant message that reports usage.
|
|
196
|
+
const data = isRecord(event.data) ? event.data : {};
|
|
197
|
+
const window = pickNumber(data['contextWindow']);
|
|
198
|
+
if (window !== undefined)
|
|
199
|
+
state.contextWindow = window;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
193
202
|
// turn/start, session/end-seed, and every other/unknown type render nothing.
|
|
194
203
|
default:
|
|
195
204
|
break;
|
|
@@ -199,13 +208,28 @@ function applyUserMessage(state, event) {
|
|
|
199
208
|
const data = isRecord(event.data) ? event.data : {};
|
|
200
209
|
const id = pickString(data['id']) ?? syntheticId('user', event.seq);
|
|
201
210
|
const text = textFromContent(data['content']);
|
|
211
|
+
const source = isRecord(data['source']) ? data['source'] : {};
|
|
212
|
+
const sourceKind = pickString(source['kind']);
|
|
202
213
|
const existing = state.byId.get(id);
|
|
203
214
|
if (existing !== undefined) {
|
|
204
215
|
// Idempotent replace (replayed events update in place, never duplicate).
|
|
205
|
-
replaceMessage(state, existing, {
|
|
216
|
+
replaceMessage(state, existing, {
|
|
217
|
+
...existing,
|
|
218
|
+
...(sourceKind !== undefined ? { sourceKind } : {}),
|
|
219
|
+
text,
|
|
220
|
+
seq: event.seq,
|
|
221
|
+
time: event.time,
|
|
222
|
+
});
|
|
206
223
|
return;
|
|
207
224
|
}
|
|
208
|
-
const message = {
|
|
225
|
+
const message = {
|
|
226
|
+
id,
|
|
227
|
+
kind: 'user',
|
|
228
|
+
text,
|
|
229
|
+
...(sourceKind !== undefined ? { sourceKind } : {}),
|
|
230
|
+
seq: event.seq,
|
|
231
|
+
time: event.time,
|
|
232
|
+
};
|
|
209
233
|
state.messages.push(message);
|
|
210
234
|
state.byId.set(id, message);
|
|
211
235
|
}
|
|
@@ -218,6 +242,8 @@ function applyAssistantMessage(state, event) {
|
|
|
218
242
|
const finalText = textFromContent(messageData['content']);
|
|
219
243
|
const finalReasoning = reasoningFromContent(messageData['content']);
|
|
220
244
|
const key = tsKey(turn, step);
|
|
245
|
+
const usage = usageFromData(data);
|
|
246
|
+
const contextWindow = state.contextWindow;
|
|
221
247
|
// Finalize the matching assistant message (by id, or by turn/step for the
|
|
222
248
|
// streaming partial that chunks built before the final event arrived).
|
|
223
249
|
let target = state.byId.get(id);
|
|
@@ -231,6 +257,8 @@ function applyAssistantMessage(state, event) {
|
|
|
231
257
|
// The final content block list is authoritative; an adapter that omits
|
|
232
258
|
// reasoning from the final message keeps the streamed reasoning text.
|
|
233
259
|
...(finalReasoning !== '' ? { reasoning: finalReasoning } : {}),
|
|
260
|
+
...(usage !== undefined ? { usage } : {}),
|
|
261
|
+
...(usage !== undefined && contextWindow !== undefined ? { contextWindow } : {}),
|
|
234
262
|
seq: event.seq,
|
|
235
263
|
time: event.time,
|
|
236
264
|
pending: false,
|
|
@@ -246,6 +274,8 @@ function applyAssistantMessage(state, event) {
|
|
|
246
274
|
kind: 'assistant',
|
|
247
275
|
text: finalText,
|
|
248
276
|
...(finalReasoning !== '' ? { reasoning: finalReasoning } : {}),
|
|
277
|
+
...(usage !== undefined ? { usage } : {}),
|
|
278
|
+
...(usage !== undefined && contextWindow !== undefined ? { contextWindow } : {}),
|
|
249
279
|
seq: event.seq,
|
|
250
280
|
time: event.time,
|
|
251
281
|
};
|
|
@@ -258,6 +288,28 @@ function applyAssistantMessage(state, event) {
|
|
|
258
288
|
if (turn !== undefined)
|
|
259
289
|
state.messageTurn.set(id, turn);
|
|
260
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Extract token usage from an assistant event payload. Only attaches when the
|
|
293
|
+
* wire carries finite `inputTokens` AND `outputTokens`; the cache fields are
|
|
294
|
+
* included only for finite numbers.
|
|
295
|
+
*/
|
|
296
|
+
function usageFromData(data) {
|
|
297
|
+
const usageData = data['usage'];
|
|
298
|
+
if (!isRecord(usageData))
|
|
299
|
+
return undefined;
|
|
300
|
+
const inputTokens = pickNumber(usageData['inputTokens']);
|
|
301
|
+
const outputTokens = pickNumber(usageData['outputTokens']);
|
|
302
|
+
if (inputTokens === undefined || outputTokens === undefined)
|
|
303
|
+
return undefined;
|
|
304
|
+
const usage = { inputTokens, outputTokens };
|
|
305
|
+
const cacheReadTokens = pickNumber(usageData['cacheReadTokens']);
|
|
306
|
+
const cacheWriteTokens = pickNumber(usageData['cacheWriteTokens']);
|
|
307
|
+
if (cacheReadTokens !== undefined)
|
|
308
|
+
usage.cacheReadTokens = cacheReadTokens;
|
|
309
|
+
if (cacheWriteTokens !== undefined)
|
|
310
|
+
usage.cacheWriteTokens = cacheWriteTokens;
|
|
311
|
+
return usage;
|
|
312
|
+
}
|
|
261
313
|
function applyChunk(state, event) {
|
|
262
314
|
const target = chunkTarget(event.data);
|
|
263
315
|
if (target === null)
|