@arcote.tech/arc-chat 0.5.1 → 0.5.5
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/package.json +6 -6
- package/src/aggregates/message.ts +293 -47
- package/src/chat-builder.ts +276 -83
- package/src/index.ts +4 -22
- package/src/listeners/ai-generation-listener.ts +522 -246
- package/src/react/chat-component.tsx +589 -0
- package/src/react/index.ts +2 -3
- package/src/react/use-chat.ts +1 -260
- package/src/routes/chat-stream-route.ts +4 -10
- package/src/streaming/stream-registry.ts +92 -124
- package/src/tools/ask-questions.tsx +126 -0
- package/src/routes/tool-results-route.ts +0 -49
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/arc-chat",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.5",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "Chat module with AI integration for Arc framework",
|
|
7
7
|
"main": "./src/index.ts",
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
"type-check": "tsc --noEmit"
|
|
11
11
|
},
|
|
12
12
|
"peerDependencies": {
|
|
13
|
-
"@arcote.tech/arc": "^0.5.
|
|
14
|
-
"@arcote.tech/arc-ai": "^0.5.
|
|
15
|
-
"@arcote.tech/arc-auth": "^0.5.
|
|
16
|
-
"@arcote.tech/arc-ds": "^0.5.
|
|
17
|
-
"@arcote.tech/platform": "^0.5.
|
|
13
|
+
"@arcote.tech/arc": "^0.5.5",
|
|
14
|
+
"@arcote.tech/arc-ai": "^0.5.5",
|
|
15
|
+
"@arcote.tech/arc-auth": "^0.5.5",
|
|
16
|
+
"@arcote.tech/arc-ds": "^0.5.5",
|
|
17
|
+
"@arcote.tech/platform": "^0.5.5",
|
|
18
18
|
"lucide-react": ">=0.400.0",
|
|
19
19
|
"react": ">=18.0.0",
|
|
20
20
|
"typescript": "^5.0.0"
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/// <reference path="../arc.d.ts" />
|
|
2
2
|
import {
|
|
3
3
|
aggregate,
|
|
4
|
+
boolean,
|
|
4
5
|
date,
|
|
5
6
|
id,
|
|
6
7
|
string,
|
|
7
8
|
type ArcId,
|
|
8
9
|
} from "@arcote.tech/arc";
|
|
9
10
|
import type { Token } from "@arcote.tech/arc-auth";
|
|
10
|
-
|
|
11
|
+
// Stream sessions are managed by listeners, not mutations
|
|
11
12
|
|
|
12
|
-
// ─── ID
|
|
13
|
+
// ─── ID ──────────────���───────────────────────────────────────────
|
|
13
14
|
|
|
14
15
|
export const createMessageId = <const Name extends string>(data: {
|
|
15
16
|
name: Name;
|
|
@@ -27,6 +28,21 @@ export type MessageAggregateData = {
|
|
|
27
28
|
scopeId: ArcId<any>;
|
|
28
29
|
accountId: ArcId<any>;
|
|
29
30
|
userToken: Token;
|
|
31
|
+
/**
|
|
32
|
+
* Optional scope restriction function used by the message aggregate's
|
|
33
|
+
* `.protectBy()` call. Receives the token payload and returns an object
|
|
34
|
+
* of field→value restrictions (see Arc protectBy contract).
|
|
35
|
+
*
|
|
36
|
+
* Default: `(p) => ({ scopeId: p.workspaceId })` — assumes the chat is
|
|
37
|
+
* identified by the workspace id (matches the strategy use-case where
|
|
38
|
+
* `chat(...).identifyBy(workspaceId)`).
|
|
39
|
+
*
|
|
40
|
+
* Chats identified by anything else (e.g. `contentTopicId`) should pass
|
|
41
|
+
* `() => ({})` — no per-field restriction. The protection token itself
|
|
42
|
+
* still gates access; cross-scope leakage is bounded by the aggregate
|
|
43
|
+
* this chat belongs to.
|
|
44
|
+
*/
|
|
45
|
+
protectCheck?: (payload: any) => Record<string, unknown>;
|
|
30
46
|
};
|
|
31
47
|
|
|
32
48
|
export const createMessageAggregate = <
|
|
@@ -34,18 +50,38 @@ export const createMessageAggregate = <
|
|
|
34
50
|
>(
|
|
35
51
|
data: Data,
|
|
36
52
|
) => {
|
|
37
|
-
const { messageId, scopeId, userToken } = data;
|
|
53
|
+
const { messageId, scopeId, userToken, protectCheck } = data;
|
|
54
|
+
const scopeCheck =
|
|
55
|
+
protectCheck ?? ((p: { workspaceId: string }) => ({ scopeId: p.workspaceId }));
|
|
38
56
|
|
|
39
57
|
return aggregate(`${data.name}Messages`, messageId, {
|
|
40
58
|
scopeId,
|
|
41
59
|
role: string(),
|
|
42
|
-
content
|
|
60
|
+
/** User message text, or tool_result content. Empty for assistant rows. */
|
|
61
|
+
content: string().optional(),
|
|
62
|
+
/**
|
|
63
|
+
* Assistant message blocks — JSON-serialized AssistantContentBlock[].
|
|
64
|
+
* Preserves text/tool_call ordering produced by the model in a single
|
|
65
|
+
* provider response. Empty for non-assistant rows.
|
|
66
|
+
*/
|
|
67
|
+
blocks: string().optional(),
|
|
43
68
|
model: string().optional(),
|
|
44
|
-
|
|
45
|
-
|
|
69
|
+
/** Tool result rows: name + id of the tool call this responds to. */
|
|
70
|
+
toolName: string().optional(),
|
|
71
|
+
toolCallId: string().optional(),
|
|
72
|
+
sessionId: string().optional(),
|
|
73
|
+
/**
|
|
74
|
+
* For assistant rows: provider-issued response ID for THIS turn. Used by
|
|
75
|
+
* the listener to anchor `previous_response_id` continuation requests on
|
|
76
|
+
* the next turn (OpenAI Responses API).
|
|
77
|
+
*/
|
|
78
|
+
previousResponseId: string().optional(),
|
|
79
|
+
isGenerating: boolean().optional(),
|
|
46
80
|
usage: string().optional(),
|
|
47
81
|
createdAt: date(),
|
|
48
82
|
})
|
|
83
|
+
|
|
84
|
+
// ─── messageSent — user sends a message ─────────────────────
|
|
49
85
|
.publicEvent(
|
|
50
86
|
"messageSent",
|
|
51
87
|
{
|
|
@@ -54,7 +90,8 @@ export const createMessageAggregate = <
|
|
|
54
90
|
sessionId: string(),
|
|
55
91
|
role: string(),
|
|
56
92
|
content: string(),
|
|
57
|
-
model: string(),
|
|
93
|
+
model: string().optional(),
|
|
94
|
+
isGenerating: boolean().optional(),
|
|
58
95
|
},
|
|
59
96
|
async (ctx, event) => {
|
|
60
97
|
const p = event.payload;
|
|
@@ -63,52 +100,126 @@ export const createMessageAggregate = <
|
|
|
63
100
|
role: p.role,
|
|
64
101
|
content: p.content,
|
|
65
102
|
model: p.model,
|
|
103
|
+
sessionId: p.sessionId,
|
|
104
|
+
isGenerating: p.isGenerating,
|
|
66
105
|
createdAt: event.createdAt,
|
|
67
106
|
});
|
|
68
107
|
},
|
|
69
108
|
)
|
|
70
109
|
|
|
110
|
+
// ─── assistantResponded — AI generates one turn ─────────────
|
|
111
|
+
// The `blocks` field is the JSON-serialized AssistantContentBlock[]
|
|
112
|
+
// produced by the provider for this single turn — text and tool_call
|
|
113
|
+
// blocks interleaved in the order the model emitted them.
|
|
71
114
|
.publicEvent(
|
|
72
|
-
"
|
|
115
|
+
"assistantResponded",
|
|
73
116
|
{
|
|
74
117
|
messageId,
|
|
75
118
|
scopeId,
|
|
76
119
|
sessionId: string(),
|
|
77
|
-
|
|
120
|
+
blocks: string(),
|
|
78
121
|
model: string().optional(),
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
usage: string().optional(),
|
|
122
|
+
previousResponseId: string().optional(),
|
|
123
|
+
isGenerating: boolean().optional(),
|
|
82
124
|
},
|
|
83
125
|
async (ctx, event) => {
|
|
84
126
|
const p = event.payload;
|
|
85
127
|
await ctx.set(p.messageId, {
|
|
86
128
|
scopeId: p.scopeId,
|
|
87
129
|
role: "assistant",
|
|
88
|
-
|
|
130
|
+
blocks: p.blocks,
|
|
89
131
|
model: p.model,
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
132
|
+
sessionId: p.sessionId,
|
|
133
|
+
previousResponseId: p.previousResponseId,
|
|
134
|
+
isGenerating: p.isGenerating,
|
|
135
|
+
createdAt: event.createdAt,
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
// ─── toolExecuted — server tool returns result ──────────────
|
|
141
|
+
.publicEvent(
|
|
142
|
+
"toolExecuted",
|
|
143
|
+
{
|
|
144
|
+
messageId,
|
|
145
|
+
scopeId,
|
|
146
|
+
sessionId: string(),
|
|
147
|
+
toolName: string(),
|
|
148
|
+
toolCallId: string(),
|
|
149
|
+
content: string(),
|
|
150
|
+
isError: boolean().optional(),
|
|
151
|
+
},
|
|
152
|
+
async (ctx, event) => {
|
|
153
|
+
const p = event.payload;
|
|
154
|
+
await ctx.set(p.messageId, {
|
|
155
|
+
scopeId: p.scopeId,
|
|
156
|
+
role: "tool_result",
|
|
157
|
+
content: p.content,
|
|
158
|
+
toolName: p.toolName,
|
|
159
|
+
toolCallId: p.toolCallId,
|
|
160
|
+
sessionId: p.sessionId,
|
|
161
|
+
createdAt: event.createdAt,
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
// ─── userResponded — user answers interactive tool ──────────
|
|
167
|
+
.publicEvent(
|
|
168
|
+
"userResponded",
|
|
169
|
+
{
|
|
170
|
+
messageId,
|
|
171
|
+
scopeId,
|
|
172
|
+
sessionId: string(),
|
|
173
|
+
toolName: string(),
|
|
174
|
+
toolCallId: string(),
|
|
175
|
+
content: string(),
|
|
176
|
+
},
|
|
177
|
+
async (ctx, event) => {
|
|
178
|
+
const p = event.payload;
|
|
179
|
+
await ctx.set(p.messageId, {
|
|
180
|
+
scopeId: p.scopeId,
|
|
181
|
+
role: "tool_result",
|
|
182
|
+
content: p.content,
|
|
183
|
+
toolName: p.toolName,
|
|
184
|
+
toolCallId: p.toolCallId,
|
|
185
|
+
sessionId: p.sessionId,
|
|
93
186
|
createdAt: event.createdAt,
|
|
94
187
|
});
|
|
95
188
|
},
|
|
96
189
|
)
|
|
97
190
|
|
|
191
|
+
// ─── generationCompleted — AI loop finished ─────────────────
|
|
192
|
+
.publicEvent(
|
|
193
|
+
"generationCompleted",
|
|
194
|
+
{
|
|
195
|
+
messageId,
|
|
196
|
+
sessionId: string(),
|
|
197
|
+
usage: string().optional(),
|
|
198
|
+
},
|
|
199
|
+
async (ctx, event) => {
|
|
200
|
+
const p = event.payload;
|
|
201
|
+
// PARTIAL update — `ctx.set` replaces the whole row and would null
|
|
202
|
+
// out scopeId/role/blocks. Use `modify` to only flip isGenerating.
|
|
203
|
+
await ctx.modify(p.messageId, {
|
|
204
|
+
isGenerating: false,
|
|
205
|
+
usage: p.usage,
|
|
206
|
+
} as any);
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
// ─── sendMessage — user sends message, creates session ──────
|
|
98
211
|
.mutateMethod(
|
|
99
212
|
"sendMessage",
|
|
100
213
|
(fn) => fn.withParams({
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
214
|
+
scopeId,
|
|
215
|
+
content: string().minLength(1),
|
|
216
|
+
model: string(),
|
|
217
|
+
}).handle(
|
|
218
|
+
ONLY_SERVER &&
|
|
106
219
|
(async (ctx, params) => {
|
|
107
220
|
const userMsgId = messageId.generate();
|
|
108
221
|
const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
109
222
|
|
|
110
|
-
createStreamSession(sessionId);
|
|
111
|
-
|
|
112
223
|
await ctx.messageSent.emit({
|
|
113
224
|
messageId: userMsgId,
|
|
114
225
|
scopeId: params.scopeId,
|
|
@@ -117,42 +228,177 @@ export const createMessageAggregate = <
|
|
|
117
228
|
content: params.content,
|
|
118
229
|
model: params.model,
|
|
119
230
|
});
|
|
231
|
+
return { messageId: userMsgId, sessionId };
|
|
232
|
+
}),
|
|
233
|
+
),
|
|
234
|
+
)
|
|
120
235
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
236
|
+
// ─── saveAssistantMessage ───────────────────────────────────
|
|
237
|
+
// `blocks` is the JSON-serialized AssistantContentBlock[] from the
|
|
238
|
+
// provider's response — single source of truth for the model's output.
|
|
239
|
+
.mutateMethod(
|
|
240
|
+
"saveAssistantMessage",
|
|
241
|
+
(fn) => fn.withParams({
|
|
242
|
+
scopeId,
|
|
243
|
+
sessionId: string(),
|
|
244
|
+
blocks: string(),
|
|
245
|
+
model: string().optional(),
|
|
246
|
+
previousResponseId: string().optional(),
|
|
247
|
+
isGenerating: boolean().optional(),
|
|
248
|
+
}).handle(
|
|
249
|
+
ONLY_SERVER &&
|
|
250
|
+
(async (ctx, params) => {
|
|
251
|
+
const msgId = messageId.generate();
|
|
252
|
+
await ctx.assistantResponded.emit({
|
|
253
|
+
messageId: msgId,
|
|
254
|
+
scopeId: params.scopeId,
|
|
255
|
+
sessionId: params.sessionId,
|
|
256
|
+
blocks: params.blocks,
|
|
257
|
+
model: params.model,
|
|
258
|
+
previousResponseId: params.previousResponseId,
|
|
259
|
+
isGenerating: params.isGenerating,
|
|
260
|
+
});
|
|
261
|
+
return { messageId: msgId };
|
|
125
262
|
}),
|
|
126
|
-
|
|
263
|
+
),
|
|
264
|
+
)
|
|
127
265
|
|
|
266
|
+
// ─── saveToolResult — server tool executed ──────────────────
|
|
128
267
|
.mutateMethod(
|
|
129
|
-
"
|
|
268
|
+
"saveToolResult",
|
|
130
269
|
(fn) => fn.withParams({
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
ONLY_SERVER &&
|
|
270
|
+
scopeId,
|
|
271
|
+
sessionId: string(),
|
|
272
|
+
toolName: string(),
|
|
273
|
+
toolCallId: string(),
|
|
274
|
+
content: string(),
|
|
275
|
+
isError: boolean().optional(),
|
|
276
|
+
}).handle(
|
|
277
|
+
ONLY_SERVER &&
|
|
140
278
|
(async (ctx, params) => {
|
|
141
|
-
const
|
|
142
|
-
await ctx.
|
|
143
|
-
messageId:
|
|
279
|
+
const msgId = messageId.generate();
|
|
280
|
+
await ctx.toolExecuted.emit({
|
|
281
|
+
messageId: msgId,
|
|
144
282
|
scopeId: params.scopeId,
|
|
145
283
|
sessionId: params.sessionId,
|
|
284
|
+
toolName: params.toolName,
|
|
285
|
+
toolCallId: params.toolCallId,
|
|
146
286
|
content: params.content,
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
287
|
+
isError: params.isError,
|
|
288
|
+
});
|
|
289
|
+
return { messageId: msgId };
|
|
290
|
+
}),
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
// ─── completeGeneration ─────────────────────────────────────
|
|
295
|
+
.mutateMethod(
|
|
296
|
+
"completeGeneration",
|
|
297
|
+
(fn) => fn.withParams({
|
|
298
|
+
generationMessageId: messageId,
|
|
299
|
+
sessionId: string(),
|
|
300
|
+
usage: string().optional(),
|
|
301
|
+
}).handle(
|
|
302
|
+
ONLY_SERVER &&
|
|
303
|
+
(async (ctx, params) => {
|
|
304
|
+
await ctx.generationCompleted.emit({
|
|
305
|
+
messageId: params.generationMessageId,
|
|
306
|
+
sessionId: params.sessionId,
|
|
150
307
|
usage: params.usage,
|
|
151
308
|
});
|
|
152
|
-
return {
|
|
309
|
+
return { ok: true };
|
|
153
310
|
}),
|
|
154
|
-
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
// ─── respondToTool — user answers interactive tool ──────────
|
|
315
|
+
.mutateMethod(
|
|
316
|
+
"respondToTool",
|
|
317
|
+
(fn) => fn.withParams({
|
|
318
|
+
scopeId,
|
|
319
|
+
toolCallId: string(),
|
|
320
|
+
toolName: string(),
|
|
321
|
+
result: string(),
|
|
322
|
+
}).handle(
|
|
323
|
+
ONLY_SERVER &&
|
|
324
|
+
(async (ctx, params) => {
|
|
325
|
+
const msgId = messageId.generate();
|
|
326
|
+
const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
327
|
+
|
|
328
|
+
await ctx.userResponded.emit({
|
|
329
|
+
messageId: msgId,
|
|
330
|
+
scopeId: params.scopeId,
|
|
331
|
+
sessionId,
|
|
332
|
+
toolName: params.toolName,
|
|
333
|
+
toolCallId: params.toolCallId,
|
|
334
|
+
content: params.result,
|
|
335
|
+
});
|
|
336
|
+
return { messageId: msgId, sessionId };
|
|
337
|
+
}),
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
// ─── startStage — initiate stage with a default priming prompt ─
|
|
342
|
+
// Stored as role="system" so the UI timeline hides it, but the AI
|
|
343
|
+
// generation listener still picks it up as a conversational turn
|
|
344
|
+
// (mapped to "user" for LLM providers that don't support system role
|
|
345
|
+
// mid-conversation — see ai-generation-listener buildHistory).
|
|
346
|
+
.mutateMethod(
|
|
347
|
+
"startStage",
|
|
348
|
+
(fn) => fn.withParams({
|
|
349
|
+
scopeId,
|
|
350
|
+
model: string().optional(),
|
|
351
|
+
}).handle(
|
|
352
|
+
ONLY_SERVER &&
|
|
353
|
+
(async (ctx, params) => {
|
|
354
|
+
const msgId = messageId.generate();
|
|
355
|
+
const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
356
|
+
|
|
357
|
+
await ctx.messageSent.emit({
|
|
358
|
+
messageId: msgId,
|
|
359
|
+
scopeId: params.scopeId,
|
|
360
|
+
sessionId,
|
|
361
|
+
role: "system",
|
|
362
|
+
content: "Rozpocznij ten etap. Przywitaj się i zadaj pierwsze pytanie.",
|
|
363
|
+
model: params.model ?? "gpt-5.4-mini",
|
|
364
|
+
});
|
|
365
|
+
return { messageId: msgId, sessionId };
|
|
366
|
+
}),
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
// ─── systemMessage — inject a developer-supplied priming prompt ─
|
|
371
|
+
// Used by stage onEnter hooks to inject context-aware welcome prompts
|
|
372
|
+
// (e.g. "User finished identity — greet them and ask about goals").
|
|
373
|
+
// Stored with role="system" — the UI filters it out of the timeline
|
|
374
|
+
// (see chat-component Restore timeline loop) while the AI generation
|
|
375
|
+
// listener still sees it in history as a conversational turn.
|
|
376
|
+
.mutateMethod(
|
|
377
|
+
"systemMessage",
|
|
378
|
+
(fn) => fn.withParams({
|
|
379
|
+
scopeId,
|
|
380
|
+
content: string().minLength(1),
|
|
381
|
+
model: string().optional(),
|
|
382
|
+
}).handle(
|
|
383
|
+
ONLY_SERVER &&
|
|
384
|
+
(async (ctx, params) => {
|
|
385
|
+
const msgId = messageId.generate();
|
|
386
|
+
const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
|
387
|
+
|
|
388
|
+
await ctx.messageSent.emit({
|
|
389
|
+
messageId: msgId,
|
|
390
|
+
scopeId: params.scopeId,
|
|
391
|
+
sessionId,
|
|
392
|
+
role: "system",
|
|
393
|
+
content: params.content,
|
|
394
|
+
model: params.model ?? "gpt-5.4-mini",
|
|
395
|
+
});
|
|
396
|
+
return { messageId: msgId, sessionId };
|
|
397
|
+
}),
|
|
398
|
+
),
|
|
399
|
+
)
|
|
155
400
|
|
|
401
|
+
// ─── getByScope ─────────────────────────────────────────────
|
|
156
402
|
.clientQuery(
|
|
157
403
|
"getByScope",
|
|
158
404
|
(fn) => fn
|
|
@@ -162,7 +408,7 @@ export const createMessageAggregate = <
|
|
|
162
408
|
),
|
|
163
409
|
)
|
|
164
410
|
|
|
165
|
-
.protectBy(userToken,
|
|
411
|
+
.protectBy(userToken, scopeCheck as any);
|
|
166
412
|
};
|
|
167
413
|
|
|
168
414
|
export type MessageAggregate = ReturnType<typeof createMessageAggregate>;
|