@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-chat",
3
3
  "type": "module",
4
- "version": "0.5.1",
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.1",
14
- "@arcote.tech/arc-ai": "^0.5.1",
15
- "@arcote.tech/arc-auth": "^0.5.1",
16
- "@arcote.tech/arc-ds": "^0.5.1",
17
- "@arcote.tech/platform": "^0.5.1",
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
- import { createStreamSession } from "../streaming/stream-registry";
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: string(),
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
- toolCalls: string().optional(),
45
- toolResults: string().optional(),
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
- "assistantMessageCompleted",
115
+ "assistantResponded",
73
116
  {
74
117
  messageId,
75
118
  scopeId,
76
119
  sessionId: string(),
77
- content: string(),
120
+ blocks: string(),
78
121
  model: string().optional(),
79
- toolCalls: string().optional(),
80
- toolResults: string().optional(),
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
- content: p.content,
130
+ blocks: p.blocks,
89
131
  model: p.model,
90
- toolCalls: p.toolCalls,
91
- toolResults: p.toolResults,
92
- usage: p.usage,
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
- scopeId,
102
- content: string().minLength(1),
103
- model: string(),
104
- }).handle(
105
- ONLY_SERVER &&
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
- return {
122
- messageId: userMsgId,
123
- sessionId,
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
- "completeAssistantMessage",
268
+ "saveToolResult",
130
269
  (fn) => fn.withParams({
131
- scopeId,
132
- sessionId: string(),
133
- content: string(),
134
- model: string().optional(),
135
- toolCalls: string().optional(),
136
- toolResults: string().optional(),
137
- usage: string().optional(),
138
- }).handle(
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 assistantMsgId = messageId.generate();
142
- await ctx.assistantMessageCompleted.emit({
143
- messageId: assistantMsgId,
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
- model: params.model,
148
- toolCalls: params.toolCalls,
149
- toolResults: params.toolResults,
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 { messageId: assistantMsgId };
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, (p) => ({ accountId: p.accountId }));
411
+ .protectBy(userToken, scopeCheck as any);
166
412
  };
167
413
 
168
414
  export type MessageAggregate = ReturnType<typeof createMessageAggregate>;