@classic-homes/agent-chat 0.0.0-stage → 0.1.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/components/AgentChat.svelte +188 -0
- package/dist/components/AgentChat.svelte.d.ts +29 -0
- package/dist/components/AgentChat.svelte.d.ts.map +1 -0
- package/dist/components/ArtifactPanel.svelte +161 -0
- package/dist/components/ArtifactPanel.svelte.d.ts +11 -0
- package/dist/components/ArtifactPanel.svelte.d.ts.map +1 -0
- package/dist/components/Composer.svelte +211 -0
- package/dist/components/Composer.svelte.d.ts +21 -0
- package/dist/components/Composer.svelte.d.ts.map +1 -0
- package/dist/components/ConversationSidebar.svelte +188 -0
- package/dist/components/ConversationSidebar.svelte.d.ts +18 -0
- package/dist/components/ConversationSidebar.svelte.d.ts.map +1 -0
- package/dist/components/MessageItem.svelte +253 -0
- package/dist/components/MessageItem.svelte.d.ts +17 -0
- package/dist/components/MessageItem.svelte.d.ts.map +1 -0
- package/dist/components/MessageList.svelte +104 -0
- package/dist/components/MessageList.svelte.d.ts +18 -0
- package/dist/components/MessageList.svelte.d.ts.map +1 -0
- package/dist/components/ModeBanner.svelte +23 -0
- package/dist/components/ModeBanner.svelte.d.ts +8 -0
- package/dist/components/ModeBanner.svelte.d.ts.map +1 -0
- package/dist/components/ToolTimeline.svelte +113 -0
- package/dist/components/ToolTimeline.svelte.d.ts +10 -0
- package/dist/components/ToolTimeline.svelte.d.ts.map +1 -0
- package/dist/components/UiFrame.svelte +95 -0
- package/dist/components/UiFrame.svelte.d.ts +13 -0
- package/dist/components/UiFrame.svelte.d.ts.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/markdown.d.ts +13 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +325 -0
- package/dist/markdown.test.d.ts +2 -0
- package/dist/markdown.test.d.ts.map +1 -0
- package/dist/markdown.test.js +35 -0
- package/dist/reducer.d.ts +96 -0
- package/dist/reducer.d.ts.map +1 -0
- package/dist/reducer.js +186 -0
- package/dist/reducer.test.d.ts +2 -0
- package/dist/reducer.test.d.ts.map +1 -0
- package/dist/reducer.test.js +125 -0
- package/dist/session.svelte.d.ts +55 -0
- package/dist/session.svelte.d.ts.map +1 -0
- package/dist/session.svelte.js +157 -0
- package/dist/ui-frame-protocol.d.ts +42 -0
- package/dist/ui-frame-protocol.d.ts.map +1 -0
- package/dist/ui-frame-protocol.js +73 -0
- package/dist/ui-frame-protocol.test.d.ts +2 -0
- package/dist/ui-frame-protocol.test.d.ts.map +1 -0
- package/dist/ui-frame-protocol.test.js +34 -0
- package/package.json +47 -3
- package/README.md +0 -3
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Icon } from '@classic-homes/theme-svelte';
|
|
3
|
+
import type { DelegationState, ToolCallState } from '../reducer.js';
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
tools,
|
|
7
|
+
delegations,
|
|
8
|
+
streaming = false,
|
|
9
|
+
}: { tools: ToolCallState[]; delegations: DelegationState[]; streaming?: boolean } = $props();
|
|
10
|
+
let expanded = $state(false);
|
|
11
|
+
const running = $derived(tools.filter((t) => !t.status).length);
|
|
12
|
+
const label = $derived.by(() => {
|
|
13
|
+
if (streaming && running > 0)
|
|
14
|
+
return `Working: ${tools
|
|
15
|
+
.filter((t) => !t.status)
|
|
16
|
+
.map((t) => t.name)
|
|
17
|
+
.join(', ')}`;
|
|
18
|
+
const children = delegations.map((d) => d.child);
|
|
19
|
+
const parts = [tools.length ? `${tools.length} tool call${tools.length === 1 ? '' : 's'}` : ''];
|
|
20
|
+
if (children.length) parts.push(`via ${[...new Set(children)].join(', ')}`);
|
|
21
|
+
return parts.filter(Boolean).join(' ');
|
|
22
|
+
});
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
{#if tools.length > 0 || delegations.length > 0}
|
|
26
|
+
<div class="agent-timeline">
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
class="agent-timeline__toggle"
|
|
30
|
+
onclick={() => (expanded = !expanded)}
|
|
31
|
+
aria-expanded={expanded}
|
|
32
|
+
>
|
|
33
|
+
<Icon name={expanded ? 'chevron-down' : 'chevron-right'} size={14} />
|
|
34
|
+
<span>{label}</span>
|
|
35
|
+
{#if streaming && running > 0}<span class="agent-timeline__dot" aria-hidden="true"
|
|
36
|
+
></span>{/if}
|
|
37
|
+
</button>
|
|
38
|
+
{#if expanded}
|
|
39
|
+
<ol class="agent-timeline__list">
|
|
40
|
+
{#each tools as tool (tool.callId)}
|
|
41
|
+
<li
|
|
42
|
+
class="agent-timeline__item"
|
|
43
|
+
class:agent-timeline__item--error={tool.status === 'error' || tool.status === 'refused'}
|
|
44
|
+
>
|
|
45
|
+
<span class="agent-timeline__name"
|
|
46
|
+
>{tool.server ? `${tool.server}/` : ''}{tool.name}</span
|
|
47
|
+
>
|
|
48
|
+
<span class="agent-timeline__agent">{tool.agent}</span>
|
|
49
|
+
<span class="agent-timeline__status"
|
|
50
|
+
>{tool.status ?? 'running'}{tool.durationMs !== undefined
|
|
51
|
+
? ` · ${tool.durationMs} ms`
|
|
52
|
+
: ''}</span
|
|
53
|
+
>
|
|
54
|
+
{#if tool.summary}<div class="agent-timeline__summary">{tool.summary}</div>{/if}
|
|
55
|
+
</li>
|
|
56
|
+
{/each}
|
|
57
|
+
</ol>
|
|
58
|
+
{/if}
|
|
59
|
+
</div>
|
|
60
|
+
{/if}
|
|
61
|
+
|
|
62
|
+
<style>
|
|
63
|
+
.agent-timeline {
|
|
64
|
+
margin: 0.25rem 0 0.5rem;
|
|
65
|
+
font-size: 0.75rem;
|
|
66
|
+
color: hsl(var(--muted-foreground));
|
|
67
|
+
}
|
|
68
|
+
.agent-timeline__toggle {
|
|
69
|
+
display: inline-flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
gap: 0.375rem;
|
|
72
|
+
background: none;
|
|
73
|
+
border: 0;
|
|
74
|
+
padding: 0.125rem 0;
|
|
75
|
+
color: inherit;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
font: inherit;
|
|
78
|
+
}
|
|
79
|
+
.agent-timeline__dot {
|
|
80
|
+
width: 0.5rem;
|
|
81
|
+
height: 0.5rem;
|
|
82
|
+
border-radius: 9999px;
|
|
83
|
+
background: hsl(var(--primary));
|
|
84
|
+
animation: agent-pulse 1.2s ease-in-out infinite;
|
|
85
|
+
}
|
|
86
|
+
@keyframes agent-pulse {
|
|
87
|
+
50% {
|
|
88
|
+
opacity: 0.3;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
.agent-timeline__list {
|
|
92
|
+
margin: 0.375rem 0 0;
|
|
93
|
+
padding-left: 1rem;
|
|
94
|
+
display: grid;
|
|
95
|
+
gap: 0.25rem;
|
|
96
|
+
}
|
|
97
|
+
.agent-timeline__item {
|
|
98
|
+
display: grid;
|
|
99
|
+
grid-template-columns: auto 1fr auto;
|
|
100
|
+
gap: 0 0.5rem;
|
|
101
|
+
}
|
|
102
|
+
.agent-timeline__item--error .agent-timeline__status {
|
|
103
|
+
color: hsl(var(--destructive));
|
|
104
|
+
}
|
|
105
|
+
.agent-timeline__name {
|
|
106
|
+
font-family: ui-monospace, monospace;
|
|
107
|
+
}
|
|
108
|
+
.agent-timeline__summary {
|
|
109
|
+
grid-column: 1 / -1;
|
|
110
|
+
white-space: pre-wrap;
|
|
111
|
+
opacity: 0.8;
|
|
112
|
+
}
|
|
113
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { DelegationState, ToolCallState } from '../reducer.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
tools: ToolCallState[];
|
|
4
|
+
delegations: DelegationState[];
|
|
5
|
+
streaming?: boolean;
|
|
6
|
+
};
|
|
7
|
+
declare const ToolTimeline: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
|
+
type ToolTimeline = ReturnType<typeof ToolTimeline>;
|
|
9
|
+
export default ToolTimeline;
|
|
10
|
+
//# sourceMappingURL=ToolTimeline.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ToolTimeline.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ToolTimeline.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAEnE,KAAK,gBAAgB,GAAI;IAAE,KAAK,EAAE,aAAa,EAAE,CAAC;IAAC,WAAW,EAAE,eAAe,EAAE,CAAC;IAAC,SAAS,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAqD1G,QAAA,MAAM,YAAY,sDAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import type { UiResourceState } from '../reducer.js';
|
|
4
|
+
import {
|
|
5
|
+
MIN_FRAME_HEIGHT,
|
|
6
|
+
UI_FRAME_RENDER,
|
|
7
|
+
composerTextFor,
|
|
8
|
+
isFromFrame,
|
|
9
|
+
parseFrameMessage,
|
|
10
|
+
type FrameAction,
|
|
11
|
+
} from '../ui-frame-protocol.js';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
resource: UiResourceState;
|
|
15
|
+
/** Origin of the sandbox page (agent-api `/frame`); the iframe never shares the host origin. */
|
|
16
|
+
sandboxOrigin: string;
|
|
17
|
+
onprompt?: (text: string) => void;
|
|
18
|
+
ontool?: (toolName: string, params?: Record<string, unknown>) => void;
|
|
19
|
+
onnotify?: (message: string) => void;
|
|
20
|
+
}
|
|
21
|
+
let { resource, sandboxOrigin, onprompt, ontool, onnotify }: Props = $props();
|
|
22
|
+
|
|
23
|
+
let frame: HTMLIFrameElement | null = $state(null);
|
|
24
|
+
let height = $state(MIN_FRAME_HEIGHT);
|
|
25
|
+
const initialHeight = $derived(resource.preferredFrameSize?.[1] ?? `${MIN_FRAME_HEIGHT}px`);
|
|
26
|
+
|
|
27
|
+
function send(): void {
|
|
28
|
+
frame?.contentWindow?.postMessage(
|
|
29
|
+
{ type: UI_FRAME_RENDER, html: resource.html, uri: resource.uri },
|
|
30
|
+
sandboxOrigin,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function handle(action: FrameAction): void {
|
|
35
|
+
switch (action.type) {
|
|
36
|
+
case 'ready':
|
|
37
|
+
send();
|
|
38
|
+
break;
|
|
39
|
+
case 'size':
|
|
40
|
+
height = action.height;
|
|
41
|
+
break;
|
|
42
|
+
case 'prompt':
|
|
43
|
+
case 'intent':
|
|
44
|
+
onprompt?.(composerTextFor(action));
|
|
45
|
+
break;
|
|
46
|
+
case 'tool':
|
|
47
|
+
ontool?.(action.toolName, action.params); // never auto-run: the host confirms with the user
|
|
48
|
+
break;
|
|
49
|
+
case 'link':
|
|
50
|
+
window.open(action.url, '_blank', 'noopener,noreferrer');
|
|
51
|
+
break;
|
|
52
|
+
case 'notify':
|
|
53
|
+
onnotify?.(action.message);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
onMount(() => {
|
|
59
|
+
const listener = (event: MessageEvent) => {
|
|
60
|
+
if (!isFromFrame(event, frame, sandboxOrigin)) return;
|
|
61
|
+
const action = parseFrameMessage(event.data);
|
|
62
|
+
if (action) handle(action);
|
|
63
|
+
};
|
|
64
|
+
window.addEventListener('message', listener);
|
|
65
|
+
return () => window.removeEventListener('message', listener);
|
|
66
|
+
});
|
|
67
|
+
</script>
|
|
68
|
+
|
|
69
|
+
<div class="agent-ui-frame">
|
|
70
|
+
<iframe
|
|
71
|
+
bind:this={frame}
|
|
72
|
+
title={resource.uri}
|
|
73
|
+
src={`${sandboxOrigin}/frame`}
|
|
74
|
+
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms"
|
|
75
|
+
referrerpolicy="no-referrer"
|
|
76
|
+
allow=""
|
|
77
|
+
style={`height:${height > MIN_FRAME_HEIGHT ? `${height}px` : initialHeight}`}
|
|
78
|
+
onload={send}
|
|
79
|
+
></iframe>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<style>
|
|
83
|
+
.agent-ui-frame {
|
|
84
|
+
margin: 0.5rem 0;
|
|
85
|
+
border: 1px solid hsl(var(--border));
|
|
86
|
+
border-radius: 0.5rem;
|
|
87
|
+
overflow: hidden;
|
|
88
|
+
background: hsl(var(--card));
|
|
89
|
+
}
|
|
90
|
+
iframe {
|
|
91
|
+
display: block;
|
|
92
|
+
width: 100%;
|
|
93
|
+
border: 0;
|
|
94
|
+
}
|
|
95
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { UiResourceState } from '../reducer.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
resource: UiResourceState;
|
|
4
|
+
/** Origin of the sandbox page (agent-api `/frame`); the iframe never shares the host origin. */
|
|
5
|
+
sandboxOrigin: string;
|
|
6
|
+
onprompt?: (text: string) => void;
|
|
7
|
+
ontool?: (toolName: string, params?: Record<string, unknown>) => void;
|
|
8
|
+
onnotify?: (message: string) => void;
|
|
9
|
+
}
|
|
10
|
+
declare const UiFrame: import("svelte").Component<Props, {}, "">;
|
|
11
|
+
type UiFrame = ReturnType<typeof UiFrame>;
|
|
12
|
+
export default UiFrame;
|
|
13
|
+
//# sourceMappingURL=UiFrame.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"UiFrame.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/UiFrame.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAWnD,UAAU,KAAK;IACb,QAAQ,EAAE,eAAe,CAAC;IAC1B,gGAAgG;IAChG,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IACtE,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACtC;AA8DH,QAAA,MAAM,OAAO,2CAAwC,CAAC;AACtD,KAAK,OAAO,GAAG,UAAU,CAAC,OAAO,OAAO,CAAC,CAAC;AAC1C,eAAe,OAAO,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from './markdown.js';
|
|
2
|
+
export * from './reducer.js';
|
|
3
|
+
export * from './session.svelte.js';
|
|
4
|
+
export * from './ui-frame-protocol.js';
|
|
5
|
+
export { default as AgentChat } from './components/AgentChat.svelte';
|
|
6
|
+
export { default as ArtifactPanel } from './components/ArtifactPanel.svelte';
|
|
7
|
+
export { default as Composer } from './components/Composer.svelte';
|
|
8
|
+
export { default as ConversationSidebar } from './components/ConversationSidebar.svelte';
|
|
9
|
+
export { default as MessageItem } from './components/MessageItem.svelte';
|
|
10
|
+
export { default as MessageList } from './components/MessageList.svelte';
|
|
11
|
+
export { default as ModeBanner } from './components/ModeBanner.svelte';
|
|
12
|
+
export { default as ToolTimeline } from './components/ToolTimeline.svelte';
|
|
13
|
+
export { default as UiFrame } from './components/UiFrame.svelte';
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"AAAA,cAAc,eAAe,CAAC;AAC9B,cAAc,cAAc,CAAC;AAC7B,cAAc,qBAAqB,CAAC;AACpC,cAAc,wBAAwB,CAAC;AACvC,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,mCAAmC,CAAC;AAC7E,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAAE,OAAO,IAAI,mBAAmB,EAAE,MAAM,yCAAyC,CAAC;AACzF,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,6BAA6B,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export * from './markdown.js';
|
|
2
|
+
export * from './reducer.js';
|
|
3
|
+
export * from './session.svelte.js';
|
|
4
|
+
export * from './ui-frame-protocol.js';
|
|
5
|
+
export { default as AgentChat } from './components/AgentChat.svelte';
|
|
6
|
+
export { default as ArtifactPanel } from './components/ArtifactPanel.svelte';
|
|
7
|
+
export { default as Composer } from './components/Composer.svelte';
|
|
8
|
+
export { default as ConversationSidebar } from './components/ConversationSidebar.svelte';
|
|
9
|
+
export { default as MessageItem } from './components/MessageItem.svelte';
|
|
10
|
+
export { default as MessageList } from './components/MessageList.svelte';
|
|
11
|
+
export { default as ModeBanner } from './components/ModeBanner.svelte';
|
|
12
|
+
export { default as ToolTimeline } from './components/ToolTimeline.svelte';
|
|
13
|
+
export { default as UiFrame } from './components/UiFrame.svelte';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface MarkdownOptions {
|
|
2
|
+
/** Hosts (exact or `.suffix`) whose images may render; same-origin is always allowed. */
|
|
3
|
+
imageHosts?: string[];
|
|
4
|
+
/** Origin treated as internal for links (`data-internal`), default the page origin. */
|
|
5
|
+
internalOrigin?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function escapeHtml(text: string): string;
|
|
8
|
+
/** Safe scheme check; percent-decodes first so `javascript%3A` cannot slip through. */
|
|
9
|
+
export declare function isSafeUrl(url: string): boolean;
|
|
10
|
+
export declare function isAllowedImageUrl(url: string, hosts?: string[]): boolean;
|
|
11
|
+
export declare function isInternalUrl(url: string, internalOrigin?: string | undefined): boolean;
|
|
12
|
+
export declare function parseMarkdown(text: string, options?: MarkdownOptions): string;
|
|
13
|
+
//# sourceMappingURL=markdown.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../src/lib/markdown.ts"],"names":[],"mappings":"AAiEA,MAAM,WAAW,eAAe;IAC9B,yFAAyF;IACzF,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,uFAAuF;IACvF,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAID,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAK/C;AAED,uFAAuF;AACvF,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAO9C;AAMD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,WAAsB,GAAG,OAAO,CAenF;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,cAAc,qBAAe,GAAG,OAAO,CAOjF;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE,eAAoB,GAAG,MAAM,CA6DjF"}
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown → sanitized HTML for assistant text. Forked from @classic-homes/chat-core
|
|
3
|
+
* (packages/chat-core/src/lib/markdown.ts) and reduced to what the agent UI needs.
|
|
4
|
+
*
|
|
5
|
+
* Security: assistant output is prompt-injectable through tool results and documents, so the
|
|
6
|
+
* result is always passed through DOMPurify with a tight allow-list: no `style`, `class` only
|
|
7
|
+
* for the classes this file emits, images only from allowed hosts, links only with safe schemes.
|
|
8
|
+
* Rich HTML (artifacts, MCP-UI) never goes through here; it renders in the sandbox frame.
|
|
9
|
+
*/
|
|
10
|
+
import DOMPurify from 'dompurify';
|
|
11
|
+
const OWN_CLASS_RE = /^agent-[\w-]+( agent-[\w-]+)*$/;
|
|
12
|
+
let hooked = false;
|
|
13
|
+
function ensureHook() {
|
|
14
|
+
if (hooked || typeof DOMPurify.addHook !== 'function')
|
|
15
|
+
return;
|
|
16
|
+
hooked = true;
|
|
17
|
+
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
|
|
18
|
+
if (data.attrName === 'class' && !OWN_CLASS_RE.test(data.attrValue))
|
|
19
|
+
data.keepAttr = false;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
const DOMPURIFY_CONFIG = {
|
|
23
|
+
ALLOWED_TAGS: [
|
|
24
|
+
'p',
|
|
25
|
+
'strong',
|
|
26
|
+
'em',
|
|
27
|
+
'a',
|
|
28
|
+
'code',
|
|
29
|
+
'pre',
|
|
30
|
+
'ul',
|
|
31
|
+
'ol',
|
|
32
|
+
'li',
|
|
33
|
+
'h2',
|
|
34
|
+
'h3',
|
|
35
|
+
'h4',
|
|
36
|
+
'blockquote',
|
|
37
|
+
'table',
|
|
38
|
+
'thead',
|
|
39
|
+
'tbody',
|
|
40
|
+
'tr',
|
|
41
|
+
'th',
|
|
42
|
+
'td',
|
|
43
|
+
'br',
|
|
44
|
+
'hr',
|
|
45
|
+
'img',
|
|
46
|
+
'del',
|
|
47
|
+
'input',
|
|
48
|
+
],
|
|
49
|
+
ALLOWED_ATTR: [
|
|
50
|
+
'href',
|
|
51
|
+
'target',
|
|
52
|
+
'rel',
|
|
53
|
+
'class',
|
|
54
|
+
'data-internal',
|
|
55
|
+
'src',
|
|
56
|
+
'alt',
|
|
57
|
+
'loading',
|
|
58
|
+
'start',
|
|
59
|
+
'type',
|
|
60
|
+
'checked',
|
|
61
|
+
'disabled',
|
|
62
|
+
],
|
|
63
|
+
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
|
64
|
+
};
|
|
65
|
+
const DEFAULT_IMAGE_HOSTS = ['classichomes.com', '.classichomes.com'];
|
|
66
|
+
export function escapeHtml(text) {
|
|
67
|
+
return text.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
68
|
+
}
|
|
69
|
+
/** Safe scheme check; percent-decodes first so `javascript%3A` cannot slip through. */
|
|
70
|
+
export function isSafeUrl(url) {
|
|
71
|
+
try {
|
|
72
|
+
const decoded = decodeURIComponent(url).toLowerCase().trim();
|
|
73
|
+
return !['javascript:', 'data:', 'vbscript:'].some((s) => decoded.startsWith(s));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function pageOrigin() {
|
|
80
|
+
return typeof window !== 'undefined' ? window.location.origin : undefined;
|
|
81
|
+
}
|
|
82
|
+
export function isAllowedImageUrl(url, hosts = DEFAULT_IMAGE_HOSTS) {
|
|
83
|
+
if (!isSafeUrl(url))
|
|
84
|
+
return false;
|
|
85
|
+
if (url.startsWith('/') && !url.startsWith('//'))
|
|
86
|
+
return true;
|
|
87
|
+
try {
|
|
88
|
+
const origin = pageOrigin();
|
|
89
|
+
const parsed = new URL(url, origin ?? 'https://invalid.local');
|
|
90
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
|
|
91
|
+
return false;
|
|
92
|
+
if (origin && parsed.origin === origin)
|
|
93
|
+
return true;
|
|
94
|
+
const host = parsed.hostname.toLowerCase();
|
|
95
|
+
return hosts.some((h) => h.startsWith('.') ? host.endsWith(h) || host === h.slice(1) : host === h);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function isInternalUrl(url, internalOrigin = pageOrigin()) {
|
|
102
|
+
if (!isSafeUrl(url) || !internalOrigin)
|
|
103
|
+
return false;
|
|
104
|
+
try {
|
|
105
|
+
return new URL(url, internalOrigin).origin === internalOrigin;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export function parseMarkdown(text, options = {}) {
|
|
112
|
+
ensureHook();
|
|
113
|
+
const CODE = '\u0000CB';
|
|
114
|
+
const INLINE = '\u0000IC';
|
|
115
|
+
let html = text.replace(/\r\n/g, '\n');
|
|
116
|
+
const codeBlocks = [];
|
|
117
|
+
html = html.replace(/```([\w-]*)[^\n]*\n([\s\S]*?)```/g, (_, lang, code) => {
|
|
118
|
+
const cls = lang
|
|
119
|
+
? ` class="agent-code agent-lang-${lang.toLowerCase().replace(/[^a-z0-9-]/g, '')}"`
|
|
120
|
+
: ' class="agent-code"';
|
|
121
|
+
codeBlocks.push(`<pre${cls}><code>${escapeHtml(code.replace(/\n$/, ''))}</code></pre>`);
|
|
122
|
+
return `${CODE}${codeBlocks.length - 1}${CODE}`;
|
|
123
|
+
});
|
|
124
|
+
const inlineCodes = [];
|
|
125
|
+
html = html.replace(/`([^`\n]+)`/g, (_, code) => {
|
|
126
|
+
inlineCodes.push(`<code>${escapeHtml(code)}</code>`);
|
|
127
|
+
return `${INLINE}${inlineCodes.length - 1}${INLINE}`;
|
|
128
|
+
});
|
|
129
|
+
html = parseBlockquotes(html);
|
|
130
|
+
html = parseTables(html);
|
|
131
|
+
html = parseLists(html);
|
|
132
|
+
html = escapePreservingTags(html, [CODE, INLINE]);
|
|
133
|
+
html = html.replace(/^[\t ]*(?:---+|\*\*\*+|___+)[\t ]*$/gm, '<hr>');
|
|
134
|
+
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
|
|
135
|
+
html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>');
|
|
136
|
+
html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>');
|
|
137
|
+
html = html.replace(/^# (.+)$/gm, '<h2>$1</h2>');
|
|
138
|
+
html = html.replace(/~~([^~\n]+)~~/g, '<del>$1</del>');
|
|
139
|
+
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
140
|
+
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
|
141
|
+
html = html.replace(/(?<!^)(?<![*\w])\*([^*\n]+)\*(?![*\w])/gm, '<em>$1</em>');
|
|
142
|
+
html = html.replace(/(?<![_\w])_([^_\n]+)_(?![_\w])/g, '<em>$1</em>');
|
|
143
|
+
const hosts = options.imageHosts ?? DEFAULT_IMAGE_HOSTS;
|
|
144
|
+
html = html.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_, alt, url) => {
|
|
145
|
+
const raw = url.replace(/&/g, '&');
|
|
146
|
+
if (!isAllowedImageUrl(raw, hosts))
|
|
147
|
+
return alt;
|
|
148
|
+
return `<img src="${url}" alt="${alt}" class="agent-image" loading="lazy">`;
|
|
149
|
+
});
|
|
150
|
+
html = html.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
|
|
151
|
+
const raw = url.replace(/&/g, '&');
|
|
152
|
+
if (!isSafeUrl(raw))
|
|
153
|
+
return label;
|
|
154
|
+
if (isInternalUrl(raw, options.internalOrigin))
|
|
155
|
+
return `<a href="${url}" class="agent-internal-link" data-internal="true">${label}</a>`;
|
|
156
|
+
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
|
157
|
+
});
|
|
158
|
+
codeBlocks.forEach((block, i) => (html = html.replace(`${CODE}${i}${CODE}`, block)));
|
|
159
|
+
inlineCodes.forEach((code, i) => (html = html.replace(`${INLINE}${i}${INLINE}`, code)));
|
|
160
|
+
html = html.replace(/\n\n+/g, '</p><p>');
|
|
161
|
+
html = html.replace(/(?<!<\/li>|<\/ul>|<\/ol>|<\/pre>|<\/h[1-4]>|<\/table>|<\/tr>|<\/td>|<\/th>|<\/blockquote>|<hr>)\n(?!<)/g, '<br>');
|
|
162
|
+
if (!html.startsWith('<'))
|
|
163
|
+
html = `<p>${html}</p>`;
|
|
164
|
+
html = html.replace(/<p>\s*<\/p>/g, '');
|
|
165
|
+
return DOMPurify.sanitize(html, DOMPURIFY_CONFIG);
|
|
166
|
+
}
|
|
167
|
+
function escapePreservingTags(html, markers) {
|
|
168
|
+
const tags = [
|
|
169
|
+
'blockquote',
|
|
170
|
+
'table',
|
|
171
|
+
'thead',
|
|
172
|
+
'tbody',
|
|
173
|
+
'tr',
|
|
174
|
+
'th',
|
|
175
|
+
'td',
|
|
176
|
+
'ul',
|
|
177
|
+
'ol',
|
|
178
|
+
'li',
|
|
179
|
+
'br',
|
|
180
|
+
'input',
|
|
181
|
+
];
|
|
182
|
+
const tagPattern = tags.map((t) => `<\\/?${t}[^>]*>`).join('|');
|
|
183
|
+
const markerPattern = markers.map((m) => `${m}\\d+${m}`).join('|');
|
|
184
|
+
const pattern = new RegExp(`(${tagPattern}|${markerPattern})`, 'gi');
|
|
185
|
+
return html
|
|
186
|
+
.split(pattern)
|
|
187
|
+
.map((part) => {
|
|
188
|
+
if (tags.some((t) => new RegExp(`^<\\/?${t}[^>]*>$`, 'i').test(part)))
|
|
189
|
+
return part;
|
|
190
|
+
if (markers.some((m) => part.startsWith(m) && part.endsWith(m)))
|
|
191
|
+
return part;
|
|
192
|
+
return escapeHtml(part);
|
|
193
|
+
})
|
|
194
|
+
.join('');
|
|
195
|
+
}
|
|
196
|
+
function parseBlockquotes(html) {
|
|
197
|
+
const lines = html.split('\n');
|
|
198
|
+
const out = [];
|
|
199
|
+
let i = 0;
|
|
200
|
+
while (i < lines.length) {
|
|
201
|
+
if (/^\s*>/.test(lines[i])) {
|
|
202
|
+
const quote = [];
|
|
203
|
+
while (i < lines.length && /^\s*>/.test(lines[i])) {
|
|
204
|
+
quote.push(lines[i].replace(/^\s*>+\s?/, ''));
|
|
205
|
+
i += 1;
|
|
206
|
+
}
|
|
207
|
+
out.push(`<blockquote class="agent-blockquote">${quote.join('<br>')}</blockquote>`);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
out.push(lines[i]);
|
|
211
|
+
i += 1;
|
|
212
|
+
}
|
|
213
|
+
return out.join('\n');
|
|
214
|
+
}
|
|
215
|
+
function parseItem(line) {
|
|
216
|
+
const ul = /^(\s*)[-*+]\s+(.+)$/.exec(line);
|
|
217
|
+
if (ul) {
|
|
218
|
+
const task = /^\[([ xX])\]\s+(.*)$/.exec(ul[2]);
|
|
219
|
+
return {
|
|
220
|
+
content: task ? task[2] : ul[2],
|
|
221
|
+
indent: ul[1].replace(/\t/g, ' ').length,
|
|
222
|
+
type: 'ul',
|
|
223
|
+
task: task ? task[1] !== ' ' : undefined,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
const ol = /^(\s*)(\d+)\.\s+(.+)$/.exec(line);
|
|
227
|
+
if (ol)
|
|
228
|
+
return {
|
|
229
|
+
content: ol[3],
|
|
230
|
+
indent: ol[1].replace(/\t/g, ' ').length,
|
|
231
|
+
type: 'ol',
|
|
232
|
+
start: Number(ol[2]),
|
|
233
|
+
};
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
function buildList(items, start, indent) {
|
|
237
|
+
const first = items[start];
|
|
238
|
+
const startAttr = first.type === 'ol' && first.start !== 1 ? ` start="${first.start}"` : '';
|
|
239
|
+
let html = `<${first.type}${startAttr}${first.task !== undefined ? ' class="agent-tasklist"' : ''}>`;
|
|
240
|
+
let i = start;
|
|
241
|
+
while (i < items.length && items[i].indent >= indent) {
|
|
242
|
+
const item = items[i];
|
|
243
|
+
if (item.indent > indent) {
|
|
244
|
+
const nested = buildList(items, i, item.indent);
|
|
245
|
+
html += nested.html;
|
|
246
|
+
i = nested.end;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const box = item.task === undefined
|
|
250
|
+
? ''
|
|
251
|
+
: `<input type="checkbox" disabled${item.task ? ' checked' : ''}> `;
|
|
252
|
+
if (i + 1 < items.length && items[i + 1].indent > indent) {
|
|
253
|
+
html += `<li>${box}${item.content}`;
|
|
254
|
+
const nested = buildList(items, i + 1, items[i + 1].indent);
|
|
255
|
+
html += `${nested.html}</li>`;
|
|
256
|
+
i = nested.end;
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
html += `<li>${box}${item.content}</li>`;
|
|
260
|
+
i += 1;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return { html: `${html}</${first.type}>`, end: i };
|
|
264
|
+
}
|
|
265
|
+
function parseLists(html) {
|
|
266
|
+
const lines = html.split('\n');
|
|
267
|
+
const out = [];
|
|
268
|
+
let i = 0;
|
|
269
|
+
while (i < lines.length) {
|
|
270
|
+
const item = parseItem(lines[i]);
|
|
271
|
+
if (!item) {
|
|
272
|
+
out.push(lines[i]);
|
|
273
|
+
i += 1;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const items = [item];
|
|
277
|
+
i += 1;
|
|
278
|
+
while (i < lines.length) {
|
|
279
|
+
const next = parseItem(lines[i]);
|
|
280
|
+
if (next) {
|
|
281
|
+
items.push(next);
|
|
282
|
+
i += 1;
|
|
283
|
+
}
|
|
284
|
+
else if (lines[i].trim() === '' && i + 1 < lines.length && parseItem(lines[i + 1])) {
|
|
285
|
+
i += 1;
|
|
286
|
+
}
|
|
287
|
+
else
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
out.push(buildList(items, 0, item.indent).html);
|
|
291
|
+
}
|
|
292
|
+
return out.join('\n');
|
|
293
|
+
}
|
|
294
|
+
const isSeparator = (line) => /^\|?[\s\-:|]+\|?$/.test(line.trim()) && line.includes('-');
|
|
295
|
+
const cells = (line) => line
|
|
296
|
+
.trim()
|
|
297
|
+
.replace(/^\|/, '')
|
|
298
|
+
.replace(/\|$/, '')
|
|
299
|
+
.split('|')
|
|
300
|
+
.map((c) => c.trim());
|
|
301
|
+
function parseTables(html) {
|
|
302
|
+
const lines = html.split('\n');
|
|
303
|
+
const out = [];
|
|
304
|
+
let i = 0;
|
|
305
|
+
while (i < lines.length) {
|
|
306
|
+
const line = lines[i];
|
|
307
|
+
if (line.includes('|') && !isSeparator(line) && lines[i + 1] && isSeparator(lines[i + 1])) {
|
|
308
|
+
const header = cells(line);
|
|
309
|
+
const rows = [];
|
|
310
|
+
let j = i + 2;
|
|
311
|
+
while (j < lines.length && lines[j].includes('|') && !isSeparator(lines[j])) {
|
|
312
|
+
rows.push(cells(lines[j]));
|
|
313
|
+
j += 1;
|
|
314
|
+
}
|
|
315
|
+
out.push(`<table class="agent-table"><thead><tr>${header.map((h) => `<th>${h}</th>`).join('')}</tr></thead><tbody>${rows
|
|
316
|
+
.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join('')}</tr>`)
|
|
317
|
+
.join('')}</tbody></table>`);
|
|
318
|
+
i = j;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
out.push(line);
|
|
322
|
+
i += 1;
|
|
323
|
+
}
|
|
324
|
+
return out.join('\n');
|
|
325
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"markdown.test.d.ts","sourceRoot":"","sources":["../src/lib/markdown.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isAllowedImageUrl, isSafeUrl, parseMarkdown } from './markdown.js';
|
|
3
|
+
describe('parseMarkdown', () => {
|
|
4
|
+
it('renders common blocks and sanitizes', () => {
|
|
5
|
+
const html = parseMarkdown('# Title\n\nSome **bold** and `code`.\n\n- a\n- b\n\n| h | i |\n|---|---|\n| 1 | 2 |');
|
|
6
|
+
expect(html).toContain('<h2>Title</h2>');
|
|
7
|
+
expect(html).toContain('<strong>bold</strong>');
|
|
8
|
+
expect(html).toContain('<code>code</code>');
|
|
9
|
+
expect(html).toContain('<li>a</li>');
|
|
10
|
+
expect(html).toContain('<table class="agent-table">');
|
|
11
|
+
});
|
|
12
|
+
it('strips scripts, inline styles and foreign classes', () => {
|
|
13
|
+
const html = parseMarkdown('hi <script>alert(1)</script> <b style="position:fixed" class="evil">x</b>');
|
|
14
|
+
expect(html).not.toContain('<script');
|
|
15
|
+
expect(html).not.toMatch(/<[a-z]+[^>]*style=/);
|
|
16
|
+
expect(html).not.toMatch(/<[a-z]+[^>]*class="evil"/);
|
|
17
|
+
expect(html).not.toMatch(/<b[\s>]/);
|
|
18
|
+
});
|
|
19
|
+
it('allows images only from allowed hosts and blocks unsafe links', () => {
|
|
20
|
+
expect(parseMarkdown('')).not.toContain('<img');
|
|
21
|
+
expect(parseMarkdown('')).toContain('<img');
|
|
22
|
+
expect(parseMarkdown('[x](javascript:alert(1))')).not.toContain('<a');
|
|
23
|
+
const ext = parseMarkdown('[docs](https://developers.cloudflare.com/)');
|
|
24
|
+
expect(ext).toContain('target="_blank"');
|
|
25
|
+
expect(ext).toContain('rel="noopener noreferrer"');
|
|
26
|
+
});
|
|
27
|
+
it('keeps code blocks verbatim and renders task lists', () => {
|
|
28
|
+
const html = parseMarkdown('```ts\nconst a = "<b>";\n```\n\n- [x] done\n- [ ] todo');
|
|
29
|
+
expect(html).toContain('agent-lang-ts');
|
|
30
|
+
expect(html).toContain('<b>');
|
|
31
|
+
expect(html).toMatch(/<input[^>]*type="checkbox"[^>]*checked/);
|
|
32
|
+
expect(isSafeUrl('javascript%3Aalert(1)')).toBe(false);
|
|
33
|
+
expect(isAllowedImageUrl('/api/files/f1')).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
});
|