@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,96 @@
|
|
|
1
|
+
import type { ArtifactMediaType, BackendMode, ChatEvent, ClientKind, DocumentContext, FileRef, ProposedOperation, ToolResultStatus } from '@classic-homes/agent-contracts';
|
|
2
|
+
import type { ClientErrorCode } from '@classic-homes/agent-client';
|
|
3
|
+
/**
|
|
4
|
+
* Pure state model for one conversation view. `applyEvent` folds stream events into the
|
|
5
|
+
* assistant turn so the UI (and tests) never interpret events themselves.
|
|
6
|
+
*/
|
|
7
|
+
export interface ToolCallState {
|
|
8
|
+
callId: string;
|
|
9
|
+
name: string;
|
|
10
|
+
server?: string;
|
|
11
|
+
argsPreview: string;
|
|
12
|
+
agent: string;
|
|
13
|
+
runId: string;
|
|
14
|
+
parentRunId?: string;
|
|
15
|
+
status?: ToolResultStatus;
|
|
16
|
+
durationMs?: number;
|
|
17
|
+
summary?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface DelegationState {
|
|
20
|
+
child: string;
|
|
21
|
+
childRunId: string;
|
|
22
|
+
status: 'start' | 'end';
|
|
23
|
+
summary?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface Artifact {
|
|
26
|
+
artifactId: string;
|
|
27
|
+
mediaType: ArtifactMediaType;
|
|
28
|
+
title?: string;
|
|
29
|
+
language?: string;
|
|
30
|
+
text: string;
|
|
31
|
+
open: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface UiResourceState {
|
|
34
|
+
callId: string;
|
|
35
|
+
uri: string;
|
|
36
|
+
html: string;
|
|
37
|
+
preferredFrameSize?: [string, string];
|
|
38
|
+
}
|
|
39
|
+
export interface FileState {
|
|
40
|
+
fileId: string;
|
|
41
|
+
name: string;
|
|
42
|
+
mimeType: string;
|
|
43
|
+
bytes: number;
|
|
44
|
+
path: string;
|
|
45
|
+
}
|
|
46
|
+
export interface UsageState {
|
|
47
|
+
inputTokens: number;
|
|
48
|
+
outputTokens: number;
|
|
49
|
+
cacheReadTokens: number;
|
|
50
|
+
calls: number;
|
|
51
|
+
}
|
|
52
|
+
export type AssistantStatus = 'streaming' | 'done' | 'cancelled' | 'error' | 'incomplete';
|
|
53
|
+
export interface UserMessage {
|
|
54
|
+
id: string;
|
|
55
|
+
role: 'user';
|
|
56
|
+
text: string;
|
|
57
|
+
attachments: FileRef[];
|
|
58
|
+
contextLabel?: string;
|
|
59
|
+
createdAt: number;
|
|
60
|
+
}
|
|
61
|
+
export interface AssistantMessage {
|
|
62
|
+
id: string;
|
|
63
|
+
role: 'assistant';
|
|
64
|
+
requestId: string;
|
|
65
|
+
text: string;
|
|
66
|
+
status: AssistantStatus;
|
|
67
|
+
tools: ToolCallState[];
|
|
68
|
+
delegations: DelegationState[];
|
|
69
|
+
artifacts: Artifact[];
|
|
70
|
+
ui: UiResourceState[];
|
|
71
|
+
files: FileState[];
|
|
72
|
+
proposals: ProposedOperation[];
|
|
73
|
+
usage: UsageState;
|
|
74
|
+
error?: {
|
|
75
|
+
code: ClientErrorCode;
|
|
76
|
+
message: string;
|
|
77
|
+
retryable: boolean;
|
|
78
|
+
};
|
|
79
|
+
finishReason?: 'stop' | 'length' | 'cancelled' | 'error';
|
|
80
|
+
mode?: BackendMode;
|
|
81
|
+
createdAt: number;
|
|
82
|
+
}
|
|
83
|
+
export type ChatMessage = UserMessage | AssistantMessage;
|
|
84
|
+
export declare function newAssistantMessage(requestId: string, now?: number): AssistantMessage;
|
|
85
|
+
export declare function newUserMessage(requestId: string, text: string, options?: {
|
|
86
|
+
attachments?: FileRef[];
|
|
87
|
+
context?: DocumentContext;
|
|
88
|
+
now?: number;
|
|
89
|
+
}): UserMessage;
|
|
90
|
+
export declare function describeContext(context: DocumentContext): string;
|
|
91
|
+
/** Fold one stream event into the assistant message it belongs to. Returns a new object. */
|
|
92
|
+
export declare function applyEvent(message: AssistantMessage, event: ChatEvent): AssistantMessage;
|
|
93
|
+
/** Human titles for client error codes (mirrors the task pane's wording). */
|
|
94
|
+
export declare function errorTitle(code: ClientErrorCode): string;
|
|
95
|
+
export type { ClientKind };
|
|
96
|
+
//# sourceMappingURL=reducer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reducer.d.ts","sourceRoot":"","sources":["../src/lib/reducer.ts"],"names":[],"mappings":"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"}
|
package/dist/reducer.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
export function newAssistantMessage(requestId, now = Date.now()) {
|
|
2
|
+
return {
|
|
3
|
+
id: `a-${requestId}`,
|
|
4
|
+
role: 'assistant',
|
|
5
|
+
requestId,
|
|
6
|
+
text: '',
|
|
7
|
+
status: 'streaming',
|
|
8
|
+
tools: [],
|
|
9
|
+
delegations: [],
|
|
10
|
+
artifacts: [],
|
|
11
|
+
ui: [],
|
|
12
|
+
files: [],
|
|
13
|
+
proposals: [],
|
|
14
|
+
usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, calls: 0 },
|
|
15
|
+
createdAt: now,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function newUserMessage(requestId, text, options = {}) {
|
|
19
|
+
return {
|
|
20
|
+
id: `u-${requestId}`,
|
|
21
|
+
role: 'user',
|
|
22
|
+
text,
|
|
23
|
+
attachments: options.attachments ?? [],
|
|
24
|
+
contextLabel: options.context ? describeContext(options.context) : undefined,
|
|
25
|
+
createdAt: options.now ?? Date.now(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function describeContext(context) {
|
|
29
|
+
if (context.host === 'excel')
|
|
30
|
+
return `${context.selection.sheet}!${context.selection.address}`;
|
|
31
|
+
if (context.host === 'word')
|
|
32
|
+
return context.selection.isEmpty ? 'Word cursor' : 'Word selection';
|
|
33
|
+
return context.record ? `${context.record.label}` : context.title;
|
|
34
|
+
}
|
|
35
|
+
/** Fold one stream event into the assistant message it belongs to. Returns a new object. */
|
|
36
|
+
export function applyEvent(message, event) {
|
|
37
|
+
switch (event.type) {
|
|
38
|
+
case 'start':
|
|
39
|
+
return { ...message, mode: event.mode };
|
|
40
|
+
case 'delta':
|
|
41
|
+
return { ...message, text: message.text + event.text };
|
|
42
|
+
case 'proposal':
|
|
43
|
+
return message.proposals.some((p) => p.operationId === event.operation.operationId)
|
|
44
|
+
? message
|
|
45
|
+
: { ...message, proposals: [...message.proposals, event.operation] };
|
|
46
|
+
case 'tool_call':
|
|
47
|
+
return {
|
|
48
|
+
...message,
|
|
49
|
+
tools: [
|
|
50
|
+
...message.tools,
|
|
51
|
+
{
|
|
52
|
+
callId: event.callId,
|
|
53
|
+
name: event.name,
|
|
54
|
+
server: event.server,
|
|
55
|
+
argsPreview: event.argsPreview,
|
|
56
|
+
agent: event.agent,
|
|
57
|
+
runId: event.runId,
|
|
58
|
+
parentRunId: event.parentRunId,
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
case 'tool_result':
|
|
63
|
+
return {
|
|
64
|
+
...message,
|
|
65
|
+
tools: message.tools.map((t) => t.callId === event.callId
|
|
66
|
+
? { ...t, status: event.status, durationMs: event.durationMs, summary: event.summary }
|
|
67
|
+
: t),
|
|
68
|
+
};
|
|
69
|
+
case 'delegation': {
|
|
70
|
+
const existing = message.delegations.findIndex((d) => d.childRunId === event.childRunId);
|
|
71
|
+
const next = {
|
|
72
|
+
child: event.child,
|
|
73
|
+
childRunId: event.childRunId,
|
|
74
|
+
status: event.status,
|
|
75
|
+
summary: event.summary,
|
|
76
|
+
};
|
|
77
|
+
const delegations = existing === -1
|
|
78
|
+
? [...message.delegations, next]
|
|
79
|
+
: message.delegations.map((d, i) => (i === existing ? { ...d, ...next } : d));
|
|
80
|
+
return { ...message, delegations };
|
|
81
|
+
}
|
|
82
|
+
case 'artifact': {
|
|
83
|
+
const idx = message.artifacts.findIndex((a) => a.artifactId === event.artifactId);
|
|
84
|
+
if (event.op === 'open') {
|
|
85
|
+
const artifact = {
|
|
86
|
+
artifactId: event.artifactId,
|
|
87
|
+
mediaType: event.mediaType ?? 'text/markdown',
|
|
88
|
+
title: event.title,
|
|
89
|
+
language: event.language,
|
|
90
|
+
text: '',
|
|
91
|
+
open: true,
|
|
92
|
+
};
|
|
93
|
+
const artifacts = idx === -1
|
|
94
|
+
? [...message.artifacts, artifact]
|
|
95
|
+
: message.artifacts.map((a, i) => (i === idx ? { ...artifact, text: '' } : a));
|
|
96
|
+
return { ...message, artifacts };
|
|
97
|
+
}
|
|
98
|
+
if (idx === -1)
|
|
99
|
+
return message;
|
|
100
|
+
return {
|
|
101
|
+
...message,
|
|
102
|
+
artifacts: message.artifacts.map((a, i) => i !== idx
|
|
103
|
+
? a
|
|
104
|
+
: event.op === 'delta'
|
|
105
|
+
? { ...a, text: a.text + (event.text ?? '') }
|
|
106
|
+
: { ...a, open: false }),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
case 'ui':
|
|
110
|
+
return {
|
|
111
|
+
...message,
|
|
112
|
+
ui: [
|
|
113
|
+
...message.ui,
|
|
114
|
+
{
|
|
115
|
+
callId: event.callId,
|
|
116
|
+
uri: event.uri,
|
|
117
|
+
html: event.html,
|
|
118
|
+
preferredFrameSize: event.preferredFrameSize,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
};
|
|
122
|
+
case 'file':
|
|
123
|
+
return message.files.some((f) => f.fileId === event.fileId)
|
|
124
|
+
? message
|
|
125
|
+
: {
|
|
126
|
+
...message,
|
|
127
|
+
files: [
|
|
128
|
+
...message.files,
|
|
129
|
+
{
|
|
130
|
+
fileId: event.fileId,
|
|
131
|
+
name: event.name,
|
|
132
|
+
mimeType: event.mimeType,
|
|
133
|
+
bytes: event.bytes,
|
|
134
|
+
path: event.path,
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
case 'usage':
|
|
139
|
+
return {
|
|
140
|
+
...message,
|
|
141
|
+
usage: {
|
|
142
|
+
inputTokens: message.usage.inputTokens + event.inputTokens,
|
|
143
|
+
outputTokens: message.usage.outputTokens + event.outputTokens,
|
|
144
|
+
cacheReadTokens: message.usage.cacheReadTokens + (event.cacheReadTokens ?? 0),
|
|
145
|
+
calls: message.usage.calls + 1,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
case 'error':
|
|
149
|
+
return {
|
|
150
|
+
...message,
|
|
151
|
+
status: 'error',
|
|
152
|
+
error: { code: event.code, message: event.message, retryable: event.retryable },
|
|
153
|
+
};
|
|
154
|
+
case 'done':
|
|
155
|
+
return {
|
|
156
|
+
...message,
|
|
157
|
+
finishReason: event.finishReason,
|
|
158
|
+
status: message.status === 'error'
|
|
159
|
+
? 'error'
|
|
160
|
+
: event.finishReason === 'cancelled'
|
|
161
|
+
? 'cancelled'
|
|
162
|
+
: event.finishReason === 'length'
|
|
163
|
+
? 'incomplete'
|
|
164
|
+
: 'done',
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Human titles for client error codes (mirrors the task pane's wording). */
|
|
169
|
+
export function errorTitle(code) {
|
|
170
|
+
const titles = {
|
|
171
|
+
bad_request: 'The request was not accepted',
|
|
172
|
+
unauthenticated: 'Sign in to continue',
|
|
173
|
+
forbidden: 'You do not have access to this',
|
|
174
|
+
not_found: 'Not found',
|
|
175
|
+
message_too_large: 'Message too long',
|
|
176
|
+
rate_limited: 'Too many requests; try again shortly',
|
|
177
|
+
upstream_unavailable: 'The agent is unavailable right now',
|
|
178
|
+
upstream_error: 'The agent ran into a problem',
|
|
179
|
+
timeout: 'The agent took too long',
|
|
180
|
+
cancelled: 'Stopped',
|
|
181
|
+
internal: 'Something went wrong',
|
|
182
|
+
network: 'Cannot reach Classic Agent',
|
|
183
|
+
protocol: 'Unexpected response from Classic Agent',
|
|
184
|
+
};
|
|
185
|
+
return titles[code];
|
|
186
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reducer.test.d.ts","sourceRoot":"","sources":["../src/lib/reducer.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { applyEvent, errorTitle, newAssistantMessage, newUserMessage } from './reducer.js';
|
|
3
|
+
const requestId = '11111111-1111-4111-8111-111111111111';
|
|
4
|
+
const scope = { requestId, agent: 'classic-agent', runId: 'r1' };
|
|
5
|
+
const ev = (e, seq) => ({ ...e, seq });
|
|
6
|
+
describe('applyEvent', () => {
|
|
7
|
+
it('folds a full turn into the assistant message', () => {
|
|
8
|
+
const events = [
|
|
9
|
+
ev({ type: 'start', requestId, conversationId: 'c1', mode: 'mock' }, 0),
|
|
10
|
+
ev({ type: 'delegation', ...scope, child: 'company-data', childRunId: 'r2', status: 'start' }, 1),
|
|
11
|
+
ev({
|
|
12
|
+
type: 'tool_call',
|
|
13
|
+
requestId,
|
|
14
|
+
agent: 'company-data',
|
|
15
|
+
runId: 'r2',
|
|
16
|
+
parentRunId: 'r1',
|
|
17
|
+
callId: 'c1',
|
|
18
|
+
name: 'list_homes',
|
|
19
|
+
server: 'chapi',
|
|
20
|
+
argsPreview: '{}',
|
|
21
|
+
}, 2),
|
|
22
|
+
ev({
|
|
23
|
+
type: 'tool_result',
|
|
24
|
+
requestId,
|
|
25
|
+
agent: 'company-data',
|
|
26
|
+
runId: 'r2',
|
|
27
|
+
parentRunId: 'r1',
|
|
28
|
+
callId: 'c1',
|
|
29
|
+
status: 'ok',
|
|
30
|
+
durationMs: 5,
|
|
31
|
+
summary: '3 rows',
|
|
32
|
+
}, 3),
|
|
33
|
+
ev({
|
|
34
|
+
type: 'delegation',
|
|
35
|
+
...scope,
|
|
36
|
+
child: 'company-data',
|
|
37
|
+
childRunId: 'r2',
|
|
38
|
+
status: 'end',
|
|
39
|
+
summary: 'done',
|
|
40
|
+
}, 4),
|
|
41
|
+
ev({ type: 'delta', requestId, text: 'Hello ' }, 5),
|
|
42
|
+
ev({
|
|
43
|
+
type: 'artifact',
|
|
44
|
+
...scope,
|
|
45
|
+
artifactId: 'a1',
|
|
46
|
+
op: 'open',
|
|
47
|
+
mediaType: 'text/markdown',
|
|
48
|
+
title: 'Notes',
|
|
49
|
+
}, 6),
|
|
50
|
+
ev({ type: 'artifact', ...scope, artifactId: 'a1', op: 'delta', text: '# N' }, 7),
|
|
51
|
+
ev({ type: 'artifact', ...scope, artifactId: 'a1', op: 'close' }, 8),
|
|
52
|
+
ev({
|
|
53
|
+
type: 'ui',
|
|
54
|
+
...scope,
|
|
55
|
+
callId: 'c2',
|
|
56
|
+
uri: 'ui://map/1',
|
|
57
|
+
mimeType: 'text/html',
|
|
58
|
+
html: '<p>m</p>',
|
|
59
|
+
}, 9),
|
|
60
|
+
ev({
|
|
61
|
+
type: 'file',
|
|
62
|
+
...scope,
|
|
63
|
+
fileId: 'f1',
|
|
64
|
+
name: 'a.png',
|
|
65
|
+
mimeType: 'image/png',
|
|
66
|
+
bytes: 9,
|
|
67
|
+
path: '/api/files/f1',
|
|
68
|
+
}, 10),
|
|
69
|
+
ev({ type: 'usage', ...scope, model: 'm', inputTokens: 10, outputTokens: 2 }, 11),
|
|
70
|
+
ev({ type: 'delta', requestId, text: 'world' }, 12),
|
|
71
|
+
ev({ type: 'done', requestId, finishReason: 'stop' }, 13),
|
|
72
|
+
];
|
|
73
|
+
const message = events.reduce(applyEvent, newAssistantMessage(requestId, 0));
|
|
74
|
+
expect(message).toMatchObject({
|
|
75
|
+
text: 'Hello world',
|
|
76
|
+
status: 'done',
|
|
77
|
+
mode: 'mock',
|
|
78
|
+
finishReason: 'stop',
|
|
79
|
+
});
|
|
80
|
+
expect(message.tools[0]).toMatchObject({
|
|
81
|
+
name: 'list_homes',
|
|
82
|
+
status: 'ok',
|
|
83
|
+
durationMs: 5,
|
|
84
|
+
summary: '3 rows',
|
|
85
|
+
});
|
|
86
|
+
expect(message.delegations).toEqual([
|
|
87
|
+
{ child: 'company-data', childRunId: 'r2', status: 'end', summary: 'done' },
|
|
88
|
+
]);
|
|
89
|
+
expect(message.artifacts[0]).toMatchObject({
|
|
90
|
+
artifactId: 'a1',
|
|
91
|
+
text: '# N',
|
|
92
|
+
open: false,
|
|
93
|
+
title: 'Notes',
|
|
94
|
+
});
|
|
95
|
+
expect(message.ui[0]).toMatchObject({ uri: 'ui://map/1' });
|
|
96
|
+
expect(message.files[0]).toMatchObject({ fileId: 'f1', path: '/api/files/f1' });
|
|
97
|
+
expect(message.usage).toEqual({
|
|
98
|
+
inputTokens: 10,
|
|
99
|
+
outputTokens: 2,
|
|
100
|
+
cacheReadTokens: 0,
|
|
101
|
+
calls: 1,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
it('marks errors, cancellations and truncation', () => {
|
|
105
|
+
const base = newAssistantMessage(requestId, 0);
|
|
106
|
+
const errored = applyEvent(applyEvent(base, ev({ type: 'error', requestId, code: 'timeout', message: 'slow', retryable: true }, 1)), ev({ type: 'done', requestId, finishReason: 'error' }, 2));
|
|
107
|
+
expect(errored).toMatchObject({ status: 'error', error: { code: 'timeout', retryable: true } });
|
|
108
|
+
expect(applyEvent(base, ev({ type: 'done', requestId, finishReason: 'cancelled' }, 1)).status).toBe('cancelled');
|
|
109
|
+
expect(applyEvent(base, ev({ type: 'done', requestId, finishReason: 'length' }, 1)).status).toBe('incomplete');
|
|
110
|
+
expect(errorTitle('network')).toMatch(/Cannot reach/);
|
|
111
|
+
});
|
|
112
|
+
it('labels user context', () => {
|
|
113
|
+
const user = newUserMessage(requestId, 'hi', {
|
|
114
|
+
context: {
|
|
115
|
+
host: 'app',
|
|
116
|
+
capturedAt: '2026-10-30T00:00:00.000Z',
|
|
117
|
+
route: '/sales/homes/1',
|
|
118
|
+
title: 'Home 1',
|
|
119
|
+
record: { kind: 'home', id: '1', label: 'Lot 12' },
|
|
120
|
+
},
|
|
121
|
+
now: 0,
|
|
122
|
+
});
|
|
123
|
+
expect(user.contextLabel).toBe('Lot 12');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { AgentApiClient } from '@classic-homes/agent-client';
|
|
2
|
+
import type { AgentKey, BackendMode, ChatEvent, ClientKind, DocumentContext, FileRef } from '@classic-homes/agent-contracts';
|
|
3
|
+
import { type AssistantMessage, type ChatMessage } from './reducer.js';
|
|
4
|
+
export interface ChatSessionOptions {
|
|
5
|
+
client: AgentApiClient;
|
|
6
|
+
conversationId?: string | null;
|
|
7
|
+
/** Backend mode known before the first turn (from `/api/me`), so the mock banner shows at once. */
|
|
8
|
+
mode?: BackendMode;
|
|
9
|
+
agent?: AgentKey;
|
|
10
|
+
clientKind?: ClientKind;
|
|
11
|
+
/** Context attached to each turn when the host's toggle is on. */
|
|
12
|
+
getContext?: () => DocumentContext | null | undefined;
|
|
13
|
+
onConversationStarted?: (conversationId: string) => void;
|
|
14
|
+
onToolResult?: (name: string, status: string) => void;
|
|
15
|
+
onEvent?: (event: ChatEvent) => void;
|
|
16
|
+
}
|
|
17
|
+
export type SessionStatus = 'idle' | 'loading' | 'streaming';
|
|
18
|
+
/**
|
|
19
|
+
* Reactive conversation state (Svelte 5 runes class). Owns the stream lifecycle: one turn at a
|
|
20
|
+
* time, cancel, resume after reload, history load. Rendering is left to the components.
|
|
21
|
+
*/
|
|
22
|
+
export declare class ChatSession {
|
|
23
|
+
messages: ChatMessage[];
|
|
24
|
+
status: SessionStatus;
|
|
25
|
+
conversationId: string | null;
|
|
26
|
+
/** The conversation whose transcript `messages` holds (loaded or started here); null before. */
|
|
27
|
+
loadedConversationId: string | null;
|
|
28
|
+
mode: "mock" | "live" | undefined;
|
|
29
|
+
loadError: string | undefined;
|
|
30
|
+
pendingFiles: {
|
|
31
|
+
fileId: string;
|
|
32
|
+
name: string;
|
|
33
|
+
mimeType: string;
|
|
34
|
+
bytes: number;
|
|
35
|
+
status: "uploaded" | "indexed" | "failed";
|
|
36
|
+
}[];
|
|
37
|
+
private controller;
|
|
38
|
+
private readonly options;
|
|
39
|
+
constructor(options: ChatSessionOptions);
|
|
40
|
+
get streaming(): boolean;
|
|
41
|
+
get current(): AssistantMessage | undefined;
|
|
42
|
+
/** Load an existing conversation's transcript and re-attach to a running turn if any. */
|
|
43
|
+
load(conversationId: string): Promise<void>;
|
|
44
|
+
send(text: string, options?: {
|
|
45
|
+
files?: FileRef[];
|
|
46
|
+
}): Promise<void>;
|
|
47
|
+
/** Re-attach to a turn after reload: replays events after the last seq we hold (none here). */
|
|
48
|
+
resume(requestId: string): Promise<void>;
|
|
49
|
+
cancel(): void;
|
|
50
|
+
private run;
|
|
51
|
+
private toolName;
|
|
52
|
+
private update;
|
|
53
|
+
}
|
|
54
|
+
export declare function createChatSession(options: ChatSessionOptions): ChatSession;
|
|
55
|
+
//# sourceMappingURL=session.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.svelte.d.ts","sourceRoot":"","sources":["../src/lib/session.svelte.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAElE,OAAO,KAAK,EACV,QAAQ,EACR,WAAW,EACX,SAAS,EAET,UAAU,EACV,eAAe,EACf,OAAO,EACR,MAAM,gCAAgC,CAAC;AACxC,OAAO,EAIL,KAAK,gBAAgB,EACrB,KAAK,WAAW,EACjB,MAAM,cAAc,CAAC;AAEtB,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,cAAc,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,mGAAmG;IACnG,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,KAAK,CAAC,EAAE,QAAQ,CAAC;IACjB,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,eAAe,GAAG,IAAI,GAAG,SAAS,CAAC;IACtD,qBAAqB,CAAC,EAAE,CAAC,cAAc,EAAE,MAAM,KAAK,IAAI,CAAC;IACzD,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACtD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;CACtC;AAED,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,SAAS,GAAG,WAAW,CAAC;AAE7D;;;GAGG;AACH,qBAAa,WAAW;IACtB,QAAQ,gBAA6B;IACrC,MAAM,gBAAiC;IACvC,cAAc,gBAA+B;IAC7C,gGAAgG;IAChG,oBAAoB,gBAA+B;IACnD,IAAI,8BAA8C;IAClD,SAAS,qBAAyC;IAClD,YAAY;;;;;;QAAyB;IAErC,OAAO,CAAC,UAAU,CAA8B;IAChD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAqB;gBAEjC,OAAO,EAAE,kBAAkB;IAMvC,IAAI,SAAS,IAAI,OAAO,CAEvB;IAED,IAAI,OAAO,IAAI,gBAAgB,GAAG,SAAS,CAG1C;IAED,yFAAyF;IACnF,IAAI,CAAC,cAAc,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IA+B3C,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,GAAE;QAAE,KAAK,CAAC,EAAE,OAAO,EAAE,CAAA;KAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IA0B5E,+FAA+F;IACzF,MAAM,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAa9C,MAAM,IAAI,IAAI;YAOA,GAAG;IAmDjB,OAAO,CAAC,QAAQ;IAOhB,OAAO,CAAC,MAAM;CAKf;AAED,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,kBAAkB,GAAG,WAAW,CAE1E"}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { ApiRequestError } from '@classic-homes/agent-client';
|
|
2
|
+
import { applyEvent, newAssistantMessage, newUserMessage, } from './reducer.js';
|
|
3
|
+
/**
|
|
4
|
+
* Reactive conversation state (Svelte 5 runes class). Owns the stream lifecycle: one turn at a
|
|
5
|
+
* time, cancel, resume after reload, history load. Rendering is left to the components.
|
|
6
|
+
*/
|
|
7
|
+
export class ChatSession {
|
|
8
|
+
messages = $state([]);
|
|
9
|
+
status = $state('idle');
|
|
10
|
+
conversationId = $state(null);
|
|
11
|
+
/** The conversation whose transcript `messages` holds (loaded or started here); null before. */
|
|
12
|
+
loadedConversationId = $state(null);
|
|
13
|
+
mode = $state(undefined);
|
|
14
|
+
loadError = $state(undefined);
|
|
15
|
+
pendingFiles = $state([]);
|
|
16
|
+
controller;
|
|
17
|
+
options;
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.options = options;
|
|
20
|
+
this.conversationId = options.conversationId ?? null;
|
|
21
|
+
this.mode = options.mode;
|
|
22
|
+
}
|
|
23
|
+
get streaming() {
|
|
24
|
+
return this.status === 'streaming';
|
|
25
|
+
}
|
|
26
|
+
get current() {
|
|
27
|
+
const last = this.messages[this.messages.length - 1];
|
|
28
|
+
return last?.role === 'assistant' ? last : undefined;
|
|
29
|
+
}
|
|
30
|
+
/** Load an existing conversation's transcript and re-attach to a running turn if any. */
|
|
31
|
+
async load(conversationId) {
|
|
32
|
+
this.status = 'loading';
|
|
33
|
+
this.loadError = undefined;
|
|
34
|
+
try {
|
|
35
|
+
const detail = await this.options.client.getConversation(conversationId);
|
|
36
|
+
this.conversationId = conversationId;
|
|
37
|
+
this.loadedConversationId = conversationId;
|
|
38
|
+
this.messages = detail.messages.map((m) => m.role === 'user'
|
|
39
|
+
? {
|
|
40
|
+
id: m.id,
|
|
41
|
+
role: 'user',
|
|
42
|
+
text: m.content.map((c) => c.text).join('\n'),
|
|
43
|
+
attachments: [],
|
|
44
|
+
createdAt: m.createdAt,
|
|
45
|
+
}
|
|
46
|
+
: {
|
|
47
|
+
...newAssistantMessage(m.id.replace(/^a-/, ''), m.createdAt),
|
|
48
|
+
text: m.content.map((c) => c.text).join('\n'),
|
|
49
|
+
status: 'done',
|
|
50
|
+
});
|
|
51
|
+
const active = detail.conversation.activeRequestId;
|
|
52
|
+
this.status = 'idle';
|
|
53
|
+
if (active)
|
|
54
|
+
await this.resume(active);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
this.status = 'idle';
|
|
58
|
+
this.loadError = err instanceof Error ? err.message : 'Could not load the conversation';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async send(text, options = {}) {
|
|
62
|
+
const message = text.trim();
|
|
63
|
+
if (!message || this.status !== 'idle')
|
|
64
|
+
return;
|
|
65
|
+
const requestId = crypto.randomUUID();
|
|
66
|
+
const context = this.options.getContext?.() ?? undefined;
|
|
67
|
+
const files = options.files ?? this.pendingFiles;
|
|
68
|
+
this.pendingFiles = [];
|
|
69
|
+
const request = {
|
|
70
|
+
requestId,
|
|
71
|
+
agent: this.options.agent ?? 'classic-agent',
|
|
72
|
+
message,
|
|
73
|
+
...(this.conversationId ? { conversationId: this.conversationId } : {}),
|
|
74
|
+
...(context ? { context } : {}),
|
|
75
|
+
...(files.length ? { fileIds: files.map((f) => f.fileId) } : {}),
|
|
76
|
+
...(this.options.clientKind ? { client: this.options.clientKind } : {}),
|
|
77
|
+
};
|
|
78
|
+
this.messages = [
|
|
79
|
+
...this.messages,
|
|
80
|
+
newUserMessage(requestId, message, { attachments: files, context }),
|
|
81
|
+
newAssistantMessage(requestId),
|
|
82
|
+
];
|
|
83
|
+
await this.run(requestId, (signal, onEvent) => this.options.client.streamChat(request, { signal, onEvent }));
|
|
84
|
+
}
|
|
85
|
+
/** Re-attach to a turn after reload: replays events after the last seq we hold (none here). */
|
|
86
|
+
async resume(requestId) {
|
|
87
|
+
if (!this.conversationId || this.status !== 'idle')
|
|
88
|
+
return;
|
|
89
|
+
if (!this.messages.some((m) => m.role === 'assistant' && m.requestId === requestId)) {
|
|
90
|
+
this.messages = [...this.messages, newAssistantMessage(requestId)];
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
this.update(requestId, (m) => ({ ...m, status: 'streaming', text: '' }));
|
|
94
|
+
}
|
|
95
|
+
const conversationId = this.conversationId;
|
|
96
|
+
await this.run(requestId, (signal, onEvent) => this.options.client.resumeTurn(conversationId, requestId, -1, { signal, onEvent }));
|
|
97
|
+
}
|
|
98
|
+
cancel() {
|
|
99
|
+
const current = this.current;
|
|
100
|
+
if (!this.controller || !current)
|
|
101
|
+
return;
|
|
102
|
+
this.controller.abort();
|
|
103
|
+
void this.options.client.cancel(current.requestId);
|
|
104
|
+
}
|
|
105
|
+
async run(requestId, start) {
|
|
106
|
+
this.status = 'streaming';
|
|
107
|
+
this.controller = new AbortController();
|
|
108
|
+
const onEvent = (event) => {
|
|
109
|
+
if (event.type === 'start') {
|
|
110
|
+
this.mode = event.mode;
|
|
111
|
+
if (this.conversationId !== event.conversationId) {
|
|
112
|
+
this.conversationId = event.conversationId;
|
|
113
|
+
this.loadedConversationId = event.conversationId;
|
|
114
|
+
this.options.onConversationStarted?.(event.conversationId);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (event.type === 'tool_result')
|
|
118
|
+
this.options.onToolResult?.(this.toolName(requestId, event.callId), event.status);
|
|
119
|
+
this.update(requestId, (m) => applyEvent(m, event));
|
|
120
|
+
this.options.onEvent?.(event);
|
|
121
|
+
};
|
|
122
|
+
try {
|
|
123
|
+
const outcome = await start(this.controller.signal, onEvent);
|
|
124
|
+
if (outcome.kind === 'aborted')
|
|
125
|
+
this.update(requestId, (m) => m.status === 'streaming' ? { ...m, status: 'cancelled', finishReason: 'cancelled' } : m);
|
|
126
|
+
else if (outcome.kind === 'ended')
|
|
127
|
+
this.update(requestId, (m) => m.status === 'streaming' ? { ...m, status: 'incomplete' } : m);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
const code = err instanceof ApiRequestError ? err.code : 'network';
|
|
131
|
+
const text = err instanceof Error ? err.message : 'The request failed';
|
|
132
|
+
this.update(requestId, (m) => ({
|
|
133
|
+
...m,
|
|
134
|
+
status: 'error',
|
|
135
|
+
error: {
|
|
136
|
+
code,
|
|
137
|
+
message: text,
|
|
138
|
+
retryable: err instanceof ApiRequestError ? err.retryable : true,
|
|
139
|
+
},
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
this.controller = undefined;
|
|
144
|
+
this.status = 'idle';
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
toolName(requestId, callId) {
|
|
148
|
+
const m = this.messages.find((x) => x.role === 'assistant' && x.requestId === requestId);
|
|
149
|
+
return m?.tools.find((t) => t.callId === callId)?.name ?? '';
|
|
150
|
+
}
|
|
151
|
+
update(requestId, fn) {
|
|
152
|
+
this.messages = this.messages.map((m) => m.role === 'assistant' && m.requestId === requestId ? fn(m) : m);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
export function createChatSession(options) {
|
|
156
|
+
return new ChatSession(options);
|
|
157
|
+
}
|