@classic-homes/agent-chat 0.4.0 → 0.4.1
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/dist/artifact-pages.d.ts +18 -0
- package/dist/artifact-pages.d.ts.map +1 -0
- package/dist/artifact-pages.js +89 -0
- package/dist/artifact-pages.test.d.ts +2 -0
- package/dist/artifact-pages.test.d.ts.map +1 -0
- package/dist/artifact-pages.test.js +102 -0
- package/dist/components/ArtifactPanel.svelte +3 -6
- package/dist/components/ArtifactPanel.svelte.d.ts.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Pinned Mermaid ESM build, loaded from the CDN the sandbox CSP allows (`script-src https:`). */
|
|
2
|
+
export declare const MERMAID_MODULE_URL = "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs";
|
|
3
|
+
/** How long the renderer may take to load before the page reports a failure. */
|
|
4
|
+
export declare const MERMAID_LOAD_TIMEOUT_MS = 15000;
|
|
5
|
+
/**
|
|
6
|
+
* Serialize a value as a JavaScript literal that is safe inside an inline `<script>`: no `<`
|
|
7
|
+
* (so no `</script>` or `<!--` breakout), `>` or `&`, and no raw U+2028/U+2029.
|
|
8
|
+
*/
|
|
9
|
+
export declare function inlineScriptLiteral(value: unknown): string;
|
|
10
|
+
/**
|
|
11
|
+
* The initialization script of {@link mermaidPage}. It is emitted as `type="module"`, but is
|
|
12
|
+
* written to parse under either goal (dynamic `import()`, no import declarations, no top-level
|
|
13
|
+
* await), so a stripped `type` can never again leave the diagram unrendered without a message.
|
|
14
|
+
*/
|
|
15
|
+
export declare function mermaidScript(source: string): string;
|
|
16
|
+
/** A sandbox page that renders Mermaid `source` and shows a visible error if it cannot. */
|
|
17
|
+
export declare function mermaidPage(source: string): string;
|
|
18
|
+
//# sourceMappingURL=artifact-pages.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"artifact-pages.d.ts","sourceRoot":"","sources":["../src/lib/artifact-pages.ts"],"names":[],"mappings":"AAOA,kGAAkG;AAClG,eAAO,MAAM,kBAAkB,yEACyC,CAAC;AAEzE,gFAAgF;AAChF,eAAO,MAAM,uBAAuB,QAAS,CAAC;AAE9C;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAO1D;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAiDpD;AAED,2FAA2F;AAC3F,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAYlD"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Sandbox pages built for artifact types that are not HTML themselves. They render inside the
|
|
2
|
+
// opaque-origin sandbox frame (apps/agent-api/public/frame.html) and inherit its CSP.
|
|
3
|
+
//
|
|
4
|
+
// These builders live in a .ts module on purpose: svelte-package strips `type="…"` from every
|
|
5
|
+
// `<script` it finds in a .svelte file, string literals included, which silently turned the
|
|
6
|
+
// Mermaid loader into a classic script (audit R18). Keep page HTML out of .svelte files.
|
|
7
|
+
/** Pinned Mermaid ESM build, loaded from the CDN the sandbox CSP allows (`script-src https:`). */
|
|
8
|
+
export const MERMAID_MODULE_URL = 'https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs';
|
|
9
|
+
/** How long the renderer may take to load before the page reports a failure. */
|
|
10
|
+
export const MERMAID_LOAD_TIMEOUT_MS = 15_000;
|
|
11
|
+
/**
|
|
12
|
+
* Serialize a value as a JavaScript literal that is safe inside an inline `<script>`: no `<`
|
|
13
|
+
* (so no `</script>` or `<!--` breakout), `>` or `&`, and no raw U+2028/U+2029.
|
|
14
|
+
*/
|
|
15
|
+
export function inlineScriptLiteral(value) {
|
|
16
|
+
return JSON.stringify(value)
|
|
17
|
+
.replace(/</g, '\\u003c')
|
|
18
|
+
.replace(/>/g, '\\u003e')
|
|
19
|
+
.replace(/&/g, '\\u0026')
|
|
20
|
+
.replace(/\u2028/g, '\\u2028')
|
|
21
|
+
.replace(/\u2029/g, '\\u2029');
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The initialization script of {@link mermaidPage}. It is emitted as `type="module"`, but is
|
|
25
|
+
* written to parse under either goal (dynamic `import()`, no import declarations, no top-level
|
|
26
|
+
* await), so a stripped `type` can never again leave the diagram unrendered without a message.
|
|
27
|
+
*/
|
|
28
|
+
export function mermaidScript(source) {
|
|
29
|
+
return `(function () {
|
|
30
|
+
var source = ${inlineScriptLiteral(source)};
|
|
31
|
+
var out = document.getElementById('diagram');
|
|
32
|
+
var settled = false;
|
|
33
|
+
function fail(message) {
|
|
34
|
+
if (settled) return;
|
|
35
|
+
settled = true;
|
|
36
|
+
out.removeAttribute('aria-busy');
|
|
37
|
+
var alert = document.createElement('p');
|
|
38
|
+
alert.setAttribute('role', 'alert');
|
|
39
|
+
alert.className = 'error';
|
|
40
|
+
alert.textContent = message;
|
|
41
|
+
var pre = document.createElement('pre');
|
|
42
|
+
pre.textContent = source;
|
|
43
|
+
out.replaceChildren(alert, pre);
|
|
44
|
+
}
|
|
45
|
+
function reason(error) {
|
|
46
|
+
return error && error.message ? error.message : String(error);
|
|
47
|
+
}
|
|
48
|
+
var timer = setTimeout(function () {
|
|
49
|
+
fail('The diagram renderer did not load in time.');
|
|
50
|
+
}, ${MERMAID_LOAD_TIMEOUT_MS});
|
|
51
|
+
import(${inlineScriptLiteral(MERMAID_MODULE_URL)}).then(
|
|
52
|
+
function (mod) {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
if (settled) return;
|
|
55
|
+
var mermaid = mod.default;
|
|
56
|
+
// Our own error replaces Mermaid's bomb graphic, which it would append to the page body.
|
|
57
|
+
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', suppressErrorRendering: true });
|
|
58
|
+
return mermaid.render('agent-mermaid-svg', source).then(
|
|
59
|
+
function (result) {
|
|
60
|
+
if (settled) return;
|
|
61
|
+
settled = true;
|
|
62
|
+
out.removeAttribute('aria-busy');
|
|
63
|
+
out.innerHTML = result.svg;
|
|
64
|
+
if (result.bindFunctions) result.bindFunctions(out);
|
|
65
|
+
},
|
|
66
|
+
function (error) {
|
|
67
|
+
fail('The diagram could not be rendered: ' + reason(error));
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
},
|
|
71
|
+
function (error) {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
fail('The diagram renderer could not be loaded: ' + reason(error));
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
})();`;
|
|
77
|
+
}
|
|
78
|
+
/** A sandbox page that renders Mermaid `source` and shows a visible error if it cannot. */
|
|
79
|
+
export function mermaidPage(source) {
|
|
80
|
+
return ('<!doctype html><html><head><meta charset="utf-8"><style>' +
|
|
81
|
+
'body{margin:0;padding:12px;font:14px/1.5 system-ui,sans-serif;color:#1f2937}' +
|
|
82
|
+
'#diagram svg{max-width:100%;height:auto}' +
|
|
83
|
+
'.error{margin:0 0 8px;color:#b91c1c}' +
|
|
84
|
+
'pre{margin:0;white-space:pre-wrap;font:12px/1.5 ui-monospace,monospace;color:#4b5563}' +
|
|
85
|
+
'</style></head><body><div id="diagram" aria-busy="true">Rendering diagram…</div>' +
|
|
86
|
+
'<script type="module">' +
|
|
87
|
+
mermaidScript(source) +
|
|
88
|
+
'</script></body></html>');
|
|
89
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"artifact-pages.test.d.ts","sourceRoot":"","sources":["../src/lib/artifact-pages.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { runInThisContext, Script } from 'node:vm';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { inlineScriptLiteral, MERMAID_LOAD_TIMEOUT_MS, MERMAID_MODULE_URL, mermaidPage, mermaidScript, } from './artifact-pages.js';
|
|
6
|
+
const BREAKOUT = 'graph TD\n A["</script><script>alert(1)</script>"] --> B[<!-- x & y >]\u2028';
|
|
7
|
+
function scripts(html) {
|
|
8
|
+
const doc = new DOMParser().parseFromString(html, 'text/html');
|
|
9
|
+
return [...doc.querySelectorAll('script')].map((s) => ({
|
|
10
|
+
attrs: s
|
|
11
|
+
.getAttributeNames()
|
|
12
|
+
.map((n) => `${n}=${s.getAttribute(n)}`)
|
|
13
|
+
.join(' '),
|
|
14
|
+
body: s.textContent ?? '',
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
17
|
+
/** Run the page's init script against the current (jsdom) document with a stand-in `import()`. */
|
|
18
|
+
function run(source, load) {
|
|
19
|
+
document.body.innerHTML = '<div id="diagram" aria-busy="true"></div>';
|
|
20
|
+
const body = mermaidScript(source).replace('import(', '__load(');
|
|
21
|
+
const init = runInThisContext(`(function (__load) {\n${body}\n})`);
|
|
22
|
+
init(load);
|
|
23
|
+
}
|
|
24
|
+
describe('mermaidPage', () => {
|
|
25
|
+
it('initializes the renderer from a module script', () => {
|
|
26
|
+
const found = scripts(mermaidPage('graph TD; A-->B'));
|
|
27
|
+
expect(found).toHaveLength(1);
|
|
28
|
+
expect(found[0].attrs).toBe('type=module');
|
|
29
|
+
expect(found[0].body).toContain(`import(${JSON.stringify(MERMAID_MODULE_URL)})`);
|
|
30
|
+
});
|
|
31
|
+
it('emits a script that parses as a module and as a classic script', async () => {
|
|
32
|
+
const body = mermaidScript(BREAKOUT);
|
|
33
|
+
expect(() => new Script(body)).not.toThrow();
|
|
34
|
+
// Module goal: strict mode, `import`/`await` reserved. vm.SourceTextModule is behind a
|
|
35
|
+
// flag, so check the module-only constraints the classic parse cannot see.
|
|
36
|
+
expect(() => new Script(`'use strict';${body}`)).not.toThrow();
|
|
37
|
+
expect(body).not.toMatch(/^\s*import\s[^(]/m);
|
|
38
|
+
expect(body).not.toMatch(/^\s*export\s/m);
|
|
39
|
+
});
|
|
40
|
+
it('cannot be broken out of by the diagram source', () => {
|
|
41
|
+
const html = mermaidPage(BREAKOUT);
|
|
42
|
+
const found = scripts(html);
|
|
43
|
+
expect(found).toHaveLength(1);
|
|
44
|
+
expect(html.match(/<\/script/gi)).toHaveLength(1);
|
|
45
|
+
expect(found[0].body).not.toMatch(/<\/?script|<!--|\u2028|\u2029/i);
|
|
46
|
+
expect(found[0].body).toContain(inlineScriptLiteral(BREAKOUT));
|
|
47
|
+
// The literal round-trips to the exact source.
|
|
48
|
+
expect(JSON.parse(inlineScriptLiteral(BREAKOUT))).toBe(BREAKOUT);
|
|
49
|
+
expect(runInThisContext(`(${inlineScriptLiteral(BREAKOUT)})`)).toBe(BREAKOUT);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe('mermaid init script', () => {
|
|
53
|
+
beforeEach(() => vi.useFakeTimers());
|
|
54
|
+
afterEach(() => vi.useRealTimers());
|
|
55
|
+
it('renders the SVG and passes the exact source', async () => {
|
|
56
|
+
const render = vi.fn(async () => ({ svg: '<svg id="ok"></svg>' }));
|
|
57
|
+
const initialize = vi.fn();
|
|
58
|
+
const load = vi.fn(async () => ({ default: { initialize, render } }));
|
|
59
|
+
run(BREAKOUT, load);
|
|
60
|
+
await vi.runAllTimersAsync();
|
|
61
|
+
expect(load).toHaveBeenCalledWith(MERMAID_MODULE_URL);
|
|
62
|
+
expect(initialize).toHaveBeenCalledWith({
|
|
63
|
+
startOnLoad: false,
|
|
64
|
+
securityLevel: 'strict',
|
|
65
|
+
suppressErrorRendering: true,
|
|
66
|
+
});
|
|
67
|
+
expect(render).toHaveBeenCalledWith('agent-mermaid-svg', BREAKOUT);
|
|
68
|
+
expect(document.querySelector('#diagram svg#ok')).not.toBeNull();
|
|
69
|
+
expect(document.querySelector('[role="alert"]')).toBeNull();
|
|
70
|
+
expect(document.getElementById('diagram').hasAttribute('aria-busy')).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
it('shows an error and the source when the renderer fails to load', async () => {
|
|
73
|
+
run('graph TD; A-->B', () => Promise.reject(new TypeError('Failed to fetch module')));
|
|
74
|
+
await vi.runAllTimersAsync();
|
|
75
|
+
expect(document.querySelector('[role="alert"]')?.textContent).toBe('The diagram renderer could not be loaded: Failed to fetch module');
|
|
76
|
+
expect(document.querySelector('#diagram pre')?.textContent).toBe('graph TD; A-->B');
|
|
77
|
+
});
|
|
78
|
+
it('shows an error when the diagram does not parse', async () => {
|
|
79
|
+
const render = () => Promise.reject(new Error('Parse error on line 1'));
|
|
80
|
+
run('graph ??', async () => ({ default: { initialize() { }, render } }));
|
|
81
|
+
await vi.runAllTimersAsync();
|
|
82
|
+
expect(document.querySelector('[role="alert"]')?.textContent).toBe('The diagram could not be rendered: Parse error on line 1');
|
|
83
|
+
});
|
|
84
|
+
it('shows an error when the renderer never loads', async () => {
|
|
85
|
+
run('graph TD; A-->B', () => new Promise(() => { }));
|
|
86
|
+
await vi.advanceTimersByTimeAsync(MERMAID_LOAD_TIMEOUT_MS);
|
|
87
|
+
expect(document.querySelector('[role="alert"]')?.textContent).toBe('The diagram renderer did not load in time.');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
describe('svelte-package script-type stripping (R18)', () => {
|
|
91
|
+
// svelte-package removes `type="…"` from every `<script` in a .svelte file, string literals
|
|
92
|
+
// included. A .svelte file may hold only its own top-level <script> tags.
|
|
93
|
+
it('no component builds <script> markup in a string', () => {
|
|
94
|
+
const dir = join(import.meta.dirname, 'components');
|
|
95
|
+
for (const file of readdirSync(dir).filter((f) => f.endsWith('.svelte'))) {
|
|
96
|
+
const text = readFileSync(join(dir, file), 'utf8');
|
|
97
|
+
const tags = text.match(/<script\b[^>]*>/g) ?? [];
|
|
98
|
+
const topLevel = text.match(/^<script\b[^>]*>/gm) ?? [];
|
|
99
|
+
expect(tags, file).toEqual(topLevel);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { Button, cn, Icon } from '@classic-homes/theme-svelte';
|
|
3
3
|
import type { Artifact } from '../reducer.js';
|
|
4
|
+
import { mermaidPage } from '../artifact-pages.js';
|
|
4
5
|
import { parseMarkdown } from '../markdown.js';
|
|
5
6
|
import Markdown from './Markdown.svelte';
|
|
6
7
|
import SandboxFrame from './SandboxFrame.svelte';
|
|
@@ -55,11 +56,6 @@
|
|
|
55
56
|
if (timer) clearTimeout(timer);
|
|
56
57
|
});
|
|
57
58
|
|
|
58
|
-
function mermaidPage(source: string): string {
|
|
59
|
-
const escaped = source.replace(/</g, '<');
|
|
60
|
-
return `<!doctype html><html><body><pre class="mermaid">${escaped}</pre><script>import m from "https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs";m.initialize({startOnLoad:true});<\/script></body></html>`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
59
|
async function copy(): Promise<void> {
|
|
64
60
|
if (selected) await navigator.clipboard.writeText(selected.text);
|
|
65
61
|
}
|
|
@@ -119,7 +115,8 @@
|
|
|
119
115
|
<div class="min-h-0 flex-1 overflow-auto p-3">
|
|
120
116
|
{#if selected.mediaType === 'text/markdown'}
|
|
121
117
|
<Markdown html={markdownHtml} />
|
|
122
|
-
{:else if selected.mediaType === 'application/vnd.ant.code'}
|
|
118
|
+
{:else if selected.mediaType === 'application/vnd.ant.code' || (selected.mediaType === 'application/vnd.ant.mermaid' && selected.open)}
|
|
119
|
+
<!-- Mermaid renders once the artifact is complete; a partial diagram is a parse error. -->
|
|
123
120
|
<pre class="overflow-auto whitespace-pre font-mono text-[13px] leading-relaxed"><code
|
|
124
121
|
>{selected.text}</code
|
|
125
122
|
></pre>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ArtifactPanel.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ArtifactPanel.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"ArtifactPanel.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ArtifactPanel.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAO5C,UAAU,KAAK;IACb,SAAS,EAAE,QAAQ,EAAE,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AA+GH,QAAA,MAAM,aAAa,qDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classic-homes/agent-chat",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Svelte 5 chat UI for Classic Agent: session state, message list, composer, tool timeline, artifacts and MCP-UI sandbox frames. Consumed by the Classic App.",
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
"dist"
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@classic-homes/agent-client": "0.4.
|
|
21
|
-
"@classic-homes/agent-contracts": "0.4.
|
|
20
|
+
"@classic-homes/agent-client": "0.4.1",
|
|
21
|
+
"@classic-homes/agent-contracts": "0.4.1",
|
|
22
22
|
"dompurify": "3.4.17"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|