@classic-homes/agent-chat 0.2.1 → 0.3.0
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.md +14 -2
- package/dist/components/AgentChat.svelte +1 -0
- package/dist/components/ArtifactChip.svelte +45 -0
- package/dist/components/ArtifactChip.svelte.d.ts +14 -0
- package/dist/components/ArtifactChip.svelte.d.ts.map +1 -0
- package/dist/components/AssistantMessage.svelte +179 -0
- package/dist/components/AssistantMessage.svelte.d.ts +25 -0
- package/dist/components/AssistantMessage.svelte.d.ts.map +1 -0
- package/dist/components/Markdown.svelte +87 -5
- package/dist/components/Markdown.svelte.d.ts +9 -1
- package/dist/components/Markdown.svelte.d.ts.map +1 -1
- package/dist/components/MessageActions.svelte +56 -0
- package/dist/components/MessageActions.svelte.d.ts +17 -0
- package/dist/components/MessageActions.svelte.d.ts.map +1 -0
- package/dist/components/MessageFiles.svelte +83 -0
- package/dist/components/MessageFiles.svelte.d.ts +14 -0
- package/dist/components/MessageFiles.svelte.d.ts.map +1 -0
- package/dist/components/MessageItem.svelte +11 -144
- package/dist/components/MessageItem.svelte.d.ts +3 -0
- package/dist/components/MessageItem.svelte.d.ts.map +1 -1
- package/dist/components/MessageList.svelte +99 -30
- package/dist/components/MessageList.svelte.d.ts +1 -0
- package/dist/components/MessageList.svelte.d.ts.map +1 -1
- package/dist/components/ProposalCard.svelte +39 -0
- package/dist/components/ProposalCard.svelte.d.ts +13 -0
- package/dist/components/ProposalCard.svelte.d.ts.map +1 -0
- package/dist/components/UserMessage.svelte +46 -0
- package/dist/components/UserMessage.svelte.d.ts +10 -0
- package/dist/components/UserMessage.svelte.d.ts.map +1 -0
- package/dist/components/messages.svelte.test.d.ts +2 -0
- package/dist/components/messages.svelte.test.d.ts.map +1 -0
- package/dist/components/messages.svelte.test.js +202 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +12 -4
- package/dist/message-format.d.ts +30 -0
- package/dist/message-format.d.ts.map +1 -0
- package/dist/message-format.js +118 -0
- package/package.json +3 -3
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
|
3
|
+
import userEvent from '@testing-library/user-event';
|
|
4
|
+
import { parseMarkdown } from '../markdown.js';
|
|
5
|
+
import { daySeparators, describeProposal, formatBytes, formatDay } from '../message-format.js';
|
|
6
|
+
import { newAssistantMessage, newUserMessage } from '../reducer.js';
|
|
7
|
+
import AssistantMessageView from './AssistantMessage.svelte';
|
|
8
|
+
import Markdown from './Markdown.svelte';
|
|
9
|
+
import MessageList from './MessageList.svelte';
|
|
10
|
+
import UserMessageView from './UserMessage.svelte';
|
|
11
|
+
afterEach(() => cleanup());
|
|
12
|
+
const base = { sandboxOrigin: 'https://frame.test', fileUrl: (id) => `/files/${id}` };
|
|
13
|
+
const assistant = (patch = {}) => ({
|
|
14
|
+
...newAssistantMessage('r1', Date.UTC(2026, 10, 3, 15, 0)),
|
|
15
|
+
status: 'done',
|
|
16
|
+
...patch,
|
|
17
|
+
});
|
|
18
|
+
const DAY = 86_400_000;
|
|
19
|
+
describe('message formatting', () => {
|
|
20
|
+
it('formats sizes and relative days', () => {
|
|
21
|
+
expect(formatBytes(900)).toBe('900 B');
|
|
22
|
+
expect(formatBytes(2048)).toBe('2 KB');
|
|
23
|
+
expect(formatBytes(3 * 1048576)).toBe('3.0 MB');
|
|
24
|
+
const now = new Date(2026, 10, 3, 12).getTime();
|
|
25
|
+
expect(formatDay(now - 60_000, now)).toBe('Today');
|
|
26
|
+
expect(formatDay(now - DAY, now)).toBe('Yesterday');
|
|
27
|
+
expect(formatDay(now - 5 * DAY, now)).not.toMatch(/Today|Yesterday/);
|
|
28
|
+
});
|
|
29
|
+
it('marks the first message of each day', () => {
|
|
30
|
+
const t = new Date(2026, 10, 1, 10).getTime();
|
|
31
|
+
expect([...daySeparators([{ createdAt: t }, { createdAt: t + 60_000 }])]).toEqual([0]);
|
|
32
|
+
expect([...daySeparators([{ createdAt: t }, { createdAt: t + DAY }])]).toEqual([0, 1]);
|
|
33
|
+
});
|
|
34
|
+
it('describes proposals without applying them', () => {
|
|
35
|
+
const op = {
|
|
36
|
+
kind: 'excel.writeRange',
|
|
37
|
+
operationId: '00000000-0000-4000-8000-000000000001',
|
|
38
|
+
documentId: 'doc-1',
|
|
39
|
+
summary: 'Fill totals',
|
|
40
|
+
target: { sheet: 'Budget', address: 'B2:B4' },
|
|
41
|
+
write: { type: 'values', values: [[1], [2], [3]] },
|
|
42
|
+
precondition: { formulas: [[''], [''], ['']] },
|
|
43
|
+
};
|
|
44
|
+
expect(describeProposal(op)).toEqual({
|
|
45
|
+
action: 'Write values',
|
|
46
|
+
host: 'Excel',
|
|
47
|
+
target: 'Budget!B2:B4',
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('markdown output', () => {
|
|
52
|
+
it('wraps tables in a scrolling container and never trusts a literal div', () => {
|
|
53
|
+
const html = parseMarkdown('| A | B |\n|---|---|\n| 1 | 2 |\n\n<div class="agent-x">hi</div>');
|
|
54
|
+
expect(html).toMatch(/<div class="agent-table-wrap"><table class="agent-table">/);
|
|
55
|
+
expect(html).toContain('<div');
|
|
56
|
+
expect(html.match(/<div/g)).toHaveLength(1);
|
|
57
|
+
});
|
|
58
|
+
it('marks external links', () => {
|
|
59
|
+
expect(parseMarkdown('[docs](https://example.com/)')).toContain('class="agent-external-link"');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('Markdown code blocks', () => {
|
|
63
|
+
it('adds a language header whose button copies the code', async () => {
|
|
64
|
+
const writeText = vi.fn().mockResolvedValue(undefined);
|
|
65
|
+
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
|
66
|
+
render(Markdown, { props: { html: parseMarkdown('```ts\nconst a = 1;\n```') } });
|
|
67
|
+
const button = await screen.findByRole('button', { name: 'Copy ts code' });
|
|
68
|
+
expect(button.closest('.agent-codeblock')?.textContent).toContain('ts');
|
|
69
|
+
await fireEvent.click(button);
|
|
70
|
+
expect(writeText).toHaveBeenCalledWith('const a = 1;');
|
|
71
|
+
await waitFor(() => expect(button.textContent).toContain('Copied'));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
describe('AssistantMessage', () => {
|
|
75
|
+
it('shows a retryable error with Try again', async () => {
|
|
76
|
+
const onretry = vi.fn();
|
|
77
|
+
render(AssistantMessageView, {
|
|
78
|
+
props: {
|
|
79
|
+
...base,
|
|
80
|
+
last: true,
|
|
81
|
+
onretry,
|
|
82
|
+
message: assistant({
|
|
83
|
+
status: 'error',
|
|
84
|
+
error: { code: 'upstream_error', message: 'Model failed.', retryable: true },
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
expect(screen.getByText(/The agent ran into a problem/)).toBeTruthy();
|
|
89
|
+
await userEvent.click(screen.getByRole('button', { name: /Try again/ }));
|
|
90
|
+
expect(onretry).toHaveBeenCalledOnce();
|
|
91
|
+
});
|
|
92
|
+
it('offers Continue on a cut-short or stopped last answer only', async () => {
|
|
93
|
+
const oncontinue = vi.fn();
|
|
94
|
+
const { rerender } = render(AssistantMessageView, {
|
|
95
|
+
props: { ...base, last: true, oncontinue, message: assistant({ status: 'incomplete' }) },
|
|
96
|
+
});
|
|
97
|
+
expect(screen.getByText('The answer was cut short.')).toBeTruthy();
|
|
98
|
+
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
|
99
|
+
expect(oncontinue).toHaveBeenCalledOnce();
|
|
100
|
+
await rerender({
|
|
101
|
+
...base,
|
|
102
|
+
last: true,
|
|
103
|
+
oncontinue,
|
|
104
|
+
message: assistant({ status: 'cancelled' }),
|
|
105
|
+
});
|
|
106
|
+
expect(screen.getByText('Stopped')).toBeTruthy();
|
|
107
|
+
expect(screen.getByRole('button', { name: 'Continue' })).toBeTruthy();
|
|
108
|
+
await rerender({
|
|
109
|
+
...base,
|
|
110
|
+
last: false,
|
|
111
|
+
oncontinue,
|
|
112
|
+
message: assistant({ status: 'cancelled' }),
|
|
113
|
+
});
|
|
114
|
+
expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
it('renders artifacts, proposals and a thinking state', async () => {
|
|
117
|
+
const onopenartifact = vi.fn();
|
|
118
|
+
const { rerender } = render(AssistantMessageView, {
|
|
119
|
+
props: { ...base, streaming: true, message: assistant({ status: 'streaming' }) },
|
|
120
|
+
});
|
|
121
|
+
expect(screen.getByRole('status').textContent).toContain('Thinking');
|
|
122
|
+
await rerender({
|
|
123
|
+
...base,
|
|
124
|
+
onopenartifact,
|
|
125
|
+
message: assistant({
|
|
126
|
+
artifacts: [
|
|
127
|
+
{ artifactId: 'a1', mediaType: 'text/html', title: 'Plans', text: '', open: false },
|
|
128
|
+
{ artifactId: 'a2', mediaType: 'text/markdown', title: 'Memo', text: '', open: true },
|
|
129
|
+
],
|
|
130
|
+
proposals: [
|
|
131
|
+
{
|
|
132
|
+
kind: 'word.replaceSelection',
|
|
133
|
+
operationId: '00000000-0000-4000-8000-000000000002',
|
|
134
|
+
documentId: 'doc-1',
|
|
135
|
+
summary: 'Tighten the intro',
|
|
136
|
+
target: { targetId: 't1', expectedText: 'Old' },
|
|
137
|
+
text: 'New',
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
}),
|
|
141
|
+
});
|
|
142
|
+
expect(screen.queryByText('Thinking')).toBeNull();
|
|
143
|
+
await userEvent.click(screen.getByRole('button', { name: 'Open Plans' }));
|
|
144
|
+
expect(onopenartifact).toHaveBeenCalledWith('a1');
|
|
145
|
+
expect(screen.getByRole('button', { name: 'Memo (writing)' })).toBeTruthy();
|
|
146
|
+
expect(screen.getByText('Tighten the intro')).toBeTruthy();
|
|
147
|
+
expect(screen.getByText('Apply from the Word add-in.')).toBeTruthy();
|
|
148
|
+
});
|
|
149
|
+
it('has a copy action once the answer is done', () => {
|
|
150
|
+
render(AssistantMessageView, {
|
|
151
|
+
props: { ...base, message: assistant({ text: 'Hello there' }) },
|
|
152
|
+
});
|
|
153
|
+
expect(screen.getByRole('button', { name: 'Copy message' })).toBeTruthy();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
describe('UserMessage', () => {
|
|
157
|
+
it('shows the text in a bubble with its attachments', () => {
|
|
158
|
+
const message = newUserMessage('r1', 'See attached', {
|
|
159
|
+
attachments: [
|
|
160
|
+
{
|
|
161
|
+
fileId: 'f1',
|
|
162
|
+
name: 'plan.pdf',
|
|
163
|
+
mimeType: 'application/pdf',
|
|
164
|
+
bytes: 2048,
|
|
165
|
+
status: 'uploaded',
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
render(UserMessageView, { props: { message } });
|
|
170
|
+
expect(screen.getByText('See attached')).toBeTruthy();
|
|
171
|
+
expect(screen.getByText('plan.pdf')).toBeTruthy();
|
|
172
|
+
expect(screen.getByText('2 KB')).toBeTruthy();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
describe('MessageList', () => {
|
|
176
|
+
it('labels days only when the transcript spans more than one', () => {
|
|
177
|
+
const t = Date.now();
|
|
178
|
+
const one = [newUserMessage('r1', 'hi', { now: t }), assistant({ createdAt: t })];
|
|
179
|
+
const { rerender } = render(MessageList, {
|
|
180
|
+
props: { ...base, streaming: false, messages: one },
|
|
181
|
+
});
|
|
182
|
+
expect(screen.queryByRole('separator')).toBeNull();
|
|
183
|
+
const two = [newUserMessage('r0', 'old', { now: t - 3 * DAY }), ...one];
|
|
184
|
+
void rerender({ ...base, streaming: false, messages: two });
|
|
185
|
+
return waitFor(() => expect(screen.getAllByRole('separator').map((s) => s.textContent?.trim())).toContain('Today'));
|
|
186
|
+
});
|
|
187
|
+
it('offers a jump to the latest once the reader scrolls up', async () => {
|
|
188
|
+
render(MessageList, {
|
|
189
|
+
props: { ...base, streaming: false, messages: [newUserMessage('r1', 'hi'), assistant()] },
|
|
190
|
+
});
|
|
191
|
+
const log = screen.getByRole('log');
|
|
192
|
+
Object.defineProperty(log, 'scrollHeight', { value: 2000, configurable: true });
|
|
193
|
+
Object.defineProperty(log, 'clientHeight', { value: 500, configurable: true });
|
|
194
|
+
log.scrollTop = 100;
|
|
195
|
+
await fireEvent.scroll(log);
|
|
196
|
+
const jump = await screen.findByRole('button', { name: 'Jump to the latest message' });
|
|
197
|
+
log.scrollTo = vi.fn();
|
|
198
|
+
await userEvent.click(jump);
|
|
199
|
+
expect(log.scrollTo).toHaveBeenCalled();
|
|
200
|
+
expect(screen.queryByRole('button', { name: /latest/ })).toBeNull();
|
|
201
|
+
});
|
|
202
|
+
});
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './body-lock.js';
|
|
2
2
|
export * from './composer-keys.js';
|
|
3
3
|
export * from './markdown.js';
|
|
4
|
+
export * from './message-format.js';
|
|
4
5
|
export * from './reducer.js';
|
|
5
6
|
export * from './session.svelte.js';
|
|
6
7
|
export * from './ui-frame-protocol.js';
|
|
@@ -14,4 +15,10 @@ export { default as ModeBanner } from './components/ModeBanner.svelte';
|
|
|
14
15
|
export { default as ToolTimeline } from './components/ToolTimeline.svelte';
|
|
15
16
|
export { default as UiFrame } from './components/UiFrame.svelte';
|
|
16
17
|
export { default as ChatHeader } from './components/ChatHeader.svelte';
|
|
18
|
+
export { default as AssistantMessage } from './components/AssistantMessage.svelte';
|
|
19
|
+
export { default as UserMessage } from './components/UserMessage.svelte';
|
|
20
|
+
export { default as MessageActions } from './components/MessageActions.svelte';
|
|
21
|
+
export { default as ArtifactChip } from './components/ArtifactChip.svelte';
|
|
22
|
+
export { default as MessageFiles } from './components/MessageFiles.svelte';
|
|
23
|
+
export { default as ProposalCard } from './components/ProposalCard.svelte';
|
|
17
24
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,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;AACjE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAC;AAC/B,cAAc,oBAAoB,CAAC;AACnC,cAAc,eAAe,CAAC;AAC9B,cAAc,qBAAqB,CAAC;AACpC,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;AACjE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,gCAAgC,CAAC;AACvE,OAAO,EAAE,OAAO,IAAI,gBAAgB,EAAE,MAAM,sCAAsC,CAAC;AACnF,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,iCAAiC,CAAC;AACzE,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAC/E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC;AAC3E,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,kCAAkC,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './body-lock.js';
|
|
2
2
|
export * from './composer-keys.js';
|
|
3
3
|
export * from './markdown.js';
|
|
4
|
+
export * from './message-format.js';
|
|
4
5
|
export * from './reducer.js';
|
|
5
6
|
export * from './session.svelte.js';
|
|
6
7
|
export * from './ui-frame-protocol.js';
|
|
@@ -14,3 +15,9 @@ export { default as ModeBanner } from './components/ModeBanner.svelte';
|
|
|
14
15
|
export { default as ToolTimeline } from './components/ToolTimeline.svelte';
|
|
15
16
|
export { default as UiFrame } from './components/UiFrame.svelte';
|
|
16
17
|
export { default as ChatHeader } from './components/ChatHeader.svelte';
|
|
18
|
+
export { default as AssistantMessage } from './components/AssistantMessage.svelte';
|
|
19
|
+
export { default as UserMessage } from './components/UserMessage.svelte';
|
|
20
|
+
export { default as MessageActions } from './components/MessageActions.svelte';
|
|
21
|
+
export { default as ArtifactChip } from './components/ArtifactChip.svelte';
|
|
22
|
+
export { default as MessageFiles } from './components/MessageFiles.svelte';
|
|
23
|
+
export { default as ProposalCard } from './components/ProposalCard.svelte';
|
package/dist/markdown.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../src/lib/markdown.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"markdown.d.ts","sourceRoot":"","sources":["../src/lib/markdown.ts"],"names":[],"mappings":"AAkEA,MAAM,WAAW,eAAe;IAC9B,yFAAyF;IACzF,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,uFAAuF;IACvF,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAQD,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,CAiEjF"}
|
package/dist/markdown.js
CHANGED
|
@@ -22,6 +22,7 @@ function ensureHook() {
|
|
|
22
22
|
const DOMPURIFY_CONFIG = {
|
|
23
23
|
ALLOWED_TAGS: [
|
|
24
24
|
'p',
|
|
25
|
+
'div',
|
|
25
26
|
'strong',
|
|
26
27
|
'em',
|
|
27
28
|
'a',
|
|
@@ -62,6 +63,9 @@ const DOMPURIFY_CONFIG = {
|
|
|
62
63
|
],
|
|
63
64
|
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
|
64
65
|
};
|
|
66
|
+
// Sentinels for the scrolling wrapper around tables (see parseMarkdown).
|
|
67
|
+
const TABLE_OPEN = '\u0000TO\u0000';
|
|
68
|
+
const TABLE_CLOSE = '\u0000TC\u0000';
|
|
65
69
|
const DEFAULT_IMAGE_HOSTS = ['classichomes.com', '.classichomes.com'];
|
|
66
70
|
export function escapeHtml(text) {
|
|
67
71
|
return text.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
@@ -130,6 +134,10 @@ export function parseMarkdown(text, options = {}) {
|
|
|
130
134
|
html = parseTables(html);
|
|
131
135
|
html = parseLists(html);
|
|
132
136
|
html = escapePreservingTags(html, [CODE, INLINE]);
|
|
137
|
+
// Table wrappers go in after escaping, so a literal `<div>` in the text is never trusted.
|
|
138
|
+
html = html
|
|
139
|
+
.replaceAll(TABLE_OPEN, '<div class="agent-table-wrap">')
|
|
140
|
+
.replaceAll(TABLE_CLOSE, '</div>');
|
|
133
141
|
html = html.replace(/^[\t ]*(?:---+|\*\*\*+|___+)[\t ]*$/gm, '<hr>');
|
|
134
142
|
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>');
|
|
135
143
|
html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>');
|
|
@@ -153,12 +161,12 @@ export function parseMarkdown(text, options = {}) {
|
|
|
153
161
|
return label;
|
|
154
162
|
if (isInternalUrl(raw, options.internalOrigin))
|
|
155
163
|
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>`;
|
|
164
|
+
return `<a href="${url}" class="agent-external-link" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
|
157
165
|
});
|
|
158
166
|
codeBlocks.forEach((block, i) => (html = html.replace(`${CODE}${i}${CODE}`, block)));
|
|
159
167
|
inlineCodes.forEach((code, i) => (html = html.replace(`${INLINE}${i}${INLINE}`, code)));
|
|
160
168
|
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>');
|
|
169
|
+
html = html.replace(/(?<!<\/li>|<\/ul>|<\/ol>|<\/pre>|<\/h[1-4]>|<\/table>|<\/div>|<\/tr>|<\/td>|<\/th>|<\/blockquote>|<hr>)\n(?!<)/g, '<br>');
|
|
162
170
|
if (!html.startsWith('<'))
|
|
163
171
|
html = `<p>${html}</p>`;
|
|
164
172
|
html = html.replace(/<p>\s*<\/p>/g, '');
|
|
@@ -312,9 +320,9 @@ function parseTables(html) {
|
|
|
312
320
|
rows.push(cells(lines[j]));
|
|
313
321
|
j += 1;
|
|
314
322
|
}
|
|
315
|
-
out.push(
|
|
323
|
+
out.push(`${TABLE_OPEN}<table class="agent-table"><thead><tr>${header.map((h) => `<th>${h}</th>`).join('')}</tr></thead><tbody>${rows
|
|
316
324
|
.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join('')}</tr>`)
|
|
317
|
-
.join('')}</tbody></table
|
|
325
|
+
.join('')}</tbody></table>${TABLE_CLOSE}`);
|
|
318
326
|
i = j;
|
|
319
327
|
continue;
|
|
320
328
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure formatting helpers for the message components: sizes, times, day separators, icons for
|
|
3
|
+
* artifacts and files, and read-only descriptions of proposed document edits.
|
|
4
|
+
*/
|
|
5
|
+
import type { ArtifactMediaType, ProposedOperation } from '@classic-homes/agent-contracts';
|
|
6
|
+
import type { IconName } from '@classic-homes/theme-svelte';
|
|
7
|
+
import type { ChatMessage } from './reducer.js';
|
|
8
|
+
export declare function formatBytes(n: number): string;
|
|
9
|
+
/** "3:42 PM" in the viewer's locale. */
|
|
10
|
+
export declare function formatTime(ts: number, locale?: string): string;
|
|
11
|
+
/** Full date and time, for tooltips. */
|
|
12
|
+
export declare function formatDateTime(ts: number, locale?: string): string;
|
|
13
|
+
/** "Today", "Yesterday", or a short date ("Mon, Nov 3"; the year only when it differs). */
|
|
14
|
+
export declare function formatDay(ts: number, now?: number, locale?: string): string;
|
|
15
|
+
/** Indexes of messages that start a new calendar day (the first message always does). */
|
|
16
|
+
export declare function daySeparators(messages: Pick<ChatMessage, 'createdAt'>[]): Set<number>;
|
|
17
|
+
export declare function artifactIcon(mediaType: ArtifactMediaType): IconName;
|
|
18
|
+
export declare function artifactKind(mediaType: ArtifactMediaType): string;
|
|
19
|
+
export declare function fileIcon(mimeType: string): IconName;
|
|
20
|
+
export interface ProposalDescription {
|
|
21
|
+
/** What kind of edit, e.g. "Write values". */
|
|
22
|
+
action: string;
|
|
23
|
+
/** Which host applies it. */
|
|
24
|
+
host: 'Excel' | 'Word';
|
|
25
|
+
/** Where, when the operation names a readable target. */
|
|
26
|
+
target?: string;
|
|
27
|
+
}
|
|
28
|
+
/** Read-only description of a proposed document edit; applying happens in the Office add-in. */
|
|
29
|
+
export declare function describeProposal(op: ProposedOperation): ProposalDescription;
|
|
30
|
+
//# sourceMappingURL=message-format.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"message-format.d.ts","sourceRoot":"","sources":["../src/lib/message-format.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,gCAAgC,CAAC;AAC3F,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAC5D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAEhD,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAI7C;AAED,wCAAwC;AACxC,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAE9D;AAED,wCAAwC;AACxC,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CASlE;AAQD,2FAA2F;AAC3F,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,SAAa,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAW/E;AAED,yFAAyF;AACzF,wBAAgB,aAAa,CAAC,QAAQ,EAAE,IAAI,CAAC,WAAW,EAAE,WAAW,CAAC,EAAE,GAAG,GAAG,CAAC,MAAM,CAAC,CASrF;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,iBAAiB,GAAG,QAAQ,CAcnE;AAED,wBAAgB,YAAY,CAAC,SAAS,EAAE,iBAAiB,GAAG,MAAM,CAejE;AAED,wBAAgB,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,QAAQ,CAQnD;AAED,MAAM,WAAW,mBAAmB;IAClC,8CAA8C;IAC9C,MAAM,EAAE,MAAM,CAAC;IACf,6BAA6B;IAC7B,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC;IACvB,yDAAyD;IACzD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,gGAAgG;AAChG,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,iBAAiB,GAAG,mBAAmB,CAgB3E"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export function formatBytes(n) {
|
|
2
|
+
if (n < 1024)
|
|
3
|
+
return `${n} B`;
|
|
4
|
+
if (n < 1048576)
|
|
5
|
+
return `${Math.round(n / 1024)} KB`;
|
|
6
|
+
return `${(n / 1048576).toFixed(1)} MB`;
|
|
7
|
+
}
|
|
8
|
+
/** "3:42 PM" in the viewer's locale. */
|
|
9
|
+
export function formatTime(ts, locale) {
|
|
10
|
+
return new Date(ts).toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' });
|
|
11
|
+
}
|
|
12
|
+
/** Full date and time, for tooltips. */
|
|
13
|
+
export function formatDateTime(ts, locale) {
|
|
14
|
+
return new Date(ts).toLocaleString(locale, {
|
|
15
|
+
weekday: 'short',
|
|
16
|
+
month: 'short',
|
|
17
|
+
day: 'numeric',
|
|
18
|
+
year: 'numeric',
|
|
19
|
+
hour: 'numeric',
|
|
20
|
+
minute: '2-digit',
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function startOfDay(ts) {
|
|
24
|
+
const d = new Date(ts);
|
|
25
|
+
d.setHours(0, 0, 0, 0);
|
|
26
|
+
return d.getTime();
|
|
27
|
+
}
|
|
28
|
+
/** "Today", "Yesterday", or a short date ("Mon, Nov 3"; the year only when it differs). */
|
|
29
|
+
export function formatDay(ts, now = Date.now(), locale) {
|
|
30
|
+
const days = Math.round((startOfDay(now) - startOfDay(ts)) / 86_400_000);
|
|
31
|
+
if (days === 0)
|
|
32
|
+
return 'Today';
|
|
33
|
+
if (days === 1)
|
|
34
|
+
return 'Yesterday';
|
|
35
|
+
const sameYear = new Date(ts).getFullYear() === new Date(now).getFullYear();
|
|
36
|
+
return new Date(ts).toLocaleDateString(locale, {
|
|
37
|
+
weekday: 'short',
|
|
38
|
+
month: 'short',
|
|
39
|
+
day: 'numeric',
|
|
40
|
+
...(sameYear ? {} : { year: 'numeric' }),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/** Indexes of messages that start a new calendar day (the first message always does). */
|
|
44
|
+
export function daySeparators(messages) {
|
|
45
|
+
const starts = new Set();
|
|
46
|
+
let previous;
|
|
47
|
+
messages.forEach((m, i) => {
|
|
48
|
+
const day = startOfDay(m.createdAt);
|
|
49
|
+
if (day !== previous)
|
|
50
|
+
starts.add(i);
|
|
51
|
+
previous = day;
|
|
52
|
+
});
|
|
53
|
+
return starts;
|
|
54
|
+
}
|
|
55
|
+
export function artifactIcon(mediaType) {
|
|
56
|
+
switch (mediaType) {
|
|
57
|
+
case 'text/html':
|
|
58
|
+
case 'application/vnd.ant.react':
|
|
59
|
+
return 'layout';
|
|
60
|
+
case 'application/vnd.ant.code':
|
|
61
|
+
return 'file-code';
|
|
62
|
+
case 'application/vnd.ant.mermaid':
|
|
63
|
+
return 'git-branch';
|
|
64
|
+
case 'image/svg+xml':
|
|
65
|
+
return 'image';
|
|
66
|
+
default:
|
|
67
|
+
return 'file-text';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function artifactKind(mediaType) {
|
|
71
|
+
switch (mediaType) {
|
|
72
|
+
case 'text/html':
|
|
73
|
+
return 'Page';
|
|
74
|
+
case 'application/vnd.ant.react':
|
|
75
|
+
return 'Interactive';
|
|
76
|
+
case 'application/vnd.ant.code':
|
|
77
|
+
return 'Code';
|
|
78
|
+
case 'application/vnd.ant.mermaid':
|
|
79
|
+
return 'Diagram';
|
|
80
|
+
case 'image/svg+xml':
|
|
81
|
+
return 'Image';
|
|
82
|
+
default:
|
|
83
|
+
return 'Document';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export function fileIcon(mimeType) {
|
|
87
|
+
if (mimeType.startsWith('image/'))
|
|
88
|
+
return 'image';
|
|
89
|
+
if (mimeType.startsWith('video/'))
|
|
90
|
+
return 'video';
|
|
91
|
+
if (mimeType.startsWith('audio/'))
|
|
92
|
+
return 'music';
|
|
93
|
+
if (/spreadsheet|excel|csv/.test(mimeType))
|
|
94
|
+
return 'table';
|
|
95
|
+
if (/json|javascript|typescript|xml|x-python|x-sh/.test(mimeType))
|
|
96
|
+
return 'file-code';
|
|
97
|
+
if (/pdf|word|document|text\//.test(mimeType))
|
|
98
|
+
return 'file-text';
|
|
99
|
+
return 'file';
|
|
100
|
+
}
|
|
101
|
+
/** Read-only description of a proposed document edit; applying happens in the Office add-in. */
|
|
102
|
+
export function describeProposal(op) {
|
|
103
|
+
switch (op.kind) {
|
|
104
|
+
case 'excel.writeRange':
|
|
105
|
+
return {
|
|
106
|
+
action: op.write.type === 'formulas' ? 'Write formulas' : 'Write values',
|
|
107
|
+
host: 'Excel',
|
|
108
|
+
target: `${op.target.sheet}!${op.target.address}`,
|
|
109
|
+
};
|
|
110
|
+
case 'word.insertText':
|
|
111
|
+
return {
|
|
112
|
+
action: op.location === 'before' ? 'Insert text before' : 'Insert text after',
|
|
113
|
+
host: 'Word',
|
|
114
|
+
};
|
|
115
|
+
case 'word.replaceSelection':
|
|
116
|
+
return { action: 'Replace selection', host: 'Word' };
|
|
117
|
+
}
|
|
118
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@classic-homes/agent-chat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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.
|
|
21
|
-
"@classic-homes/agent-contracts": "0.
|
|
20
|
+
"@classic-homes/agent-client": "0.3.0",
|
|
21
|
+
"@classic-homes/agent-contracts": "0.3.0",
|
|
22
22
|
"dompurify": "3.4.17"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|