@arcote.tech/arc-chat 0.5.2 → 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.2",
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.2",
14
- "@arcote.tech/arc-ai": "^0.5.2",
15
- "@arcote.tech/arc-auth": "^0.5.2",
16
- "@arcote.tech/arc-ds": "^0.5.2",
17
- "@arcote.tech/platform": "^0.5.2",
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"
@@ -28,6 +28,21 @@ export type MessageAggregateData = {
28
28
  scopeId: ArcId<any>;
29
29
  accountId: ArcId<any>;
30
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>;
31
46
  };
32
47
 
33
48
  export const createMessageAggregate = <
@@ -35,16 +50,31 @@ export const createMessageAggregate = <
35
50
  >(
36
51
  data: Data,
37
52
  ) => {
38
- const { messageId, scopeId, userToken } = data;
53
+ const { messageId, scopeId, userToken, protectCheck } = data;
54
+ const scopeCheck =
55
+ protectCheck ?? ((p: { workspaceId: string }) => ({ scopeId: p.workspaceId }));
39
56
 
40
57
  return aggregate(`${data.name}Messages`, messageId, {
41
58
  scopeId,
42
59
  role: string(),
43
- 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(),
44
68
  model: string().optional(),
69
+ /** Tool result rows: name + id of the tool call this responds to. */
45
70
  toolName: string().optional(),
46
71
  toolCallId: string().optional(),
47
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
+ */
48
78
  previousResponseId: string().optional(),
49
79
  isGenerating: boolean().optional(),
50
80
  usage: string().optional(),
@@ -77,14 +107,17 @@ export const createMessageAggregate = <
77
107
  },
78
108
  )
79
109
 
80
- // ─── assistantResponded — AI generates text ─────────────────
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.
81
114
  .publicEvent(
82
115
  "assistantResponded",
83
116
  {
84
117
  messageId,
85
118
  scopeId,
86
119
  sessionId: string(),
87
- content: string(),
120
+ blocks: string(),
88
121
  model: string().optional(),
89
122
  previousResponseId: string().optional(),
90
123
  isGenerating: boolean().optional(),
@@ -94,7 +127,7 @@ export const createMessageAggregate = <
94
127
  await ctx.set(p.messageId, {
95
128
  scopeId: p.scopeId,
96
129
  role: "assistant",
97
- content: p.content,
130
+ blocks: p.blocks,
98
131
  model: p.model,
99
132
  sessionId: p.sessionId,
100
133
  previousResponseId: p.previousResponseId,
@@ -104,33 +137,6 @@ export const createMessageAggregate = <
104
137
  },
105
138
  )
106
139
 
107
- // ─── toolCalled — AI invokes a tool ─────────────────────────
108
- .publicEvent(
109
- "toolCalled",
110
- {
111
- messageId,
112
- scopeId,
113
- sessionId: string(),
114
- toolName: string(),
115
- toolCallId: string(),
116
- content: string(),
117
- previousResponseId: string().optional(),
118
- },
119
- async (ctx, event) => {
120
- const p = event.payload;
121
- await ctx.set(p.messageId, {
122
- scopeId: p.scopeId,
123
- role: "tool_call",
124
- content: p.content,
125
- toolName: p.toolName,
126
- toolCallId: p.toolCallId,
127
- sessionId: p.sessionId,
128
- previousResponseId: p.previousResponseId,
129
- createdAt: event.createdAt,
130
- });
131
- },
132
- )
133
-
134
140
  // ─── toolExecuted — server tool returns result ──────────────
135
141
  .publicEvent(
136
142
  "toolExecuted",
@@ -182,7 +188,7 @@ export const createMessageAggregate = <
182
188
  },
183
189
  )
184
190
 
185
- // ─── generationCompleted — AI loop finished ────��────────────
191
+ // ─── generationCompleted — AI loop finished ─────────────────
186
192
  .publicEvent(
187
193
  "generationCompleted",
188
194
  {
@@ -192,7 +198,9 @@ export const createMessageAggregate = <
192
198
  },
193
199
  async (ctx, event) => {
194
200
  const p = event.payload;
195
- await ctx.set(p.messageId, {
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, {
196
204
  isGenerating: false,
197
205
  usage: p.usage,
198
206
  } as any);
@@ -225,13 +233,15 @@ export const createMessageAggregate = <
225
233
  ),
226
234
  )
227
235
 
228
- // ─── saveAssistantMessage ─────��─────────────────────────────
236
+ // ─── saveAssistantMessage ───────────────────────────────────
237
+ // `blocks` is the JSON-serialized AssistantContentBlock[] from the
238
+ // provider's response — single source of truth for the model's output.
229
239
  .mutateMethod(
230
240
  "saveAssistantMessage",
231
241
  (fn) => fn.withParams({
232
242
  scopeId,
233
243
  sessionId: string(),
234
- content: string(),
244
+ blocks: string(),
235
245
  model: string().optional(),
236
246
  previousResponseId: string().optional(),
237
247
  isGenerating: boolean().optional(),
@@ -243,7 +253,7 @@ export const createMessageAggregate = <
243
253
  messageId: msgId,
244
254
  scopeId: params.scopeId,
245
255
  sessionId: params.sessionId,
246
- content: params.content,
256
+ blocks: params.blocks,
247
257
  model: params.model,
248
258
  previousResponseId: params.previousResponseId,
249
259
  isGenerating: params.isGenerating,
@@ -253,34 +263,6 @@ export const createMessageAggregate = <
253
263
  ),
254
264
  )
255
265
 
256
- // ─── saveToolCall ────────────���──────────────────────────────
257
- .mutateMethod(
258
- "saveToolCall",
259
- (fn) => fn.withParams({
260
- scopeId,
261
- sessionId: string(),
262
- toolName: string(),
263
- toolCallId: string(),
264
- content: string(),
265
- previousResponseId: string().optional(),
266
- }).handle(
267
- ONLY_SERVER &&
268
- (async (ctx, params) => {
269
- const msgId = messageId.generate();
270
- await ctx.toolCalled.emit({
271
- messageId: msgId,
272
- scopeId: params.scopeId,
273
- sessionId: params.sessionId,
274
- toolName: params.toolName,
275
- toolCallId: params.toolCallId,
276
- content: params.content,
277
- previousResponseId: params.previousResponseId,
278
- });
279
- return { messageId: msgId };
280
- }),
281
- ),
282
- )
283
-
284
266
  // ─── saveToolResult — server tool executed ──────────────────
285
267
  .mutateMethod(
286
268
  "saveToolResult",
@@ -356,7 +338,11 @@ export const createMessageAggregate = <
356
338
  ),
357
339
  )
358
340
 
359
- // ─── startStage — initiate stage with greeting ──────────────
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).
360
346
  .mutateMethod(
361
347
  "startStage",
362
348
  (fn) => fn.withParams({
@@ -365,18 +351,49 @@ export const createMessageAggregate = <
365
351
  }).handle(
366
352
  ONLY_SERVER &&
367
353
  (async (ctx, params) => {
368
- const userMsgId = messageId.generate();
354
+ const msgId = messageId.generate();
369
355
  const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
370
356
 
371
357
  await ctx.messageSent.emit({
372
- messageId: userMsgId,
358
+ messageId: msgId,
373
359
  scopeId: params.scopeId,
374
360
  sessionId,
375
- role: "user",
361
+ role: "system",
376
362
  content: "Rozpocznij ten etap. Przywitaj się i zadaj pierwsze pytanie.",
377
- model: params.model ?? "gpt-5.4-nano",
363
+ model: params.model ?? "gpt-5.4-mini",
378
364
  });
379
- return { messageId: userMsgId, sessionId };
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 };
380
397
  }),
381
398
  ),
382
399
  )
@@ -391,7 +408,7 @@ export const createMessageAggregate = <
391
408
  ),
392
409
  )
393
410
 
394
- .protectBy(userToken, (p: { workspaceId: string }) => ({ scopeId: p.workspaceId }));
411
+ .protectBy(userToken, scopeCheck as any);
395
412
  };
396
413
 
397
414
  export type MessageAggregate = ReturnType<typeof createMessageAggregate>;
@@ -17,7 +17,25 @@ import { createMessageId, createMessageAggregate } from "./aggregates/message";
17
17
  import { createAiGenerationListener, createAiResumeListener } from "./listeners/ai-generation-listener";
18
18
  import { createChatStreamRoute } from "./routes/chat-stream-route";
19
19
  import { createChatComponent } from "./react/chat-component";
20
- import type { ComponentType } from "react";
20
+ import type { ChatLabels } from "@arcote.tech/arc-ds";
21
+ import type { ComponentType, ReactNode } from "react";
22
+
23
+ export interface ChatReactComponentOptions {
24
+ /** Show the model selector dropdown in ChatInput. Default true. */
25
+ showModelSelector?: boolean;
26
+ /** Show the web search toggle in ChatInput. Default true. */
27
+ showWebSearch?: boolean;
28
+ /**
29
+ * Render slot for ChatInput's send button. Receives `onClick` and
30
+ * `disabled` — caller renders its own button (e.g. branded with a logo).
31
+ */
32
+ renderSendButton?: (props: {
33
+ onClick: () => void;
34
+ disabled: boolean;
35
+ }) => ReactNode;
36
+ /** Partial overrides for chat i18n labels. Falls back to English defaults. */
37
+ labels?: Partial<ChatLabels>;
38
+ }
21
39
 
22
40
  // ─── Chat Data ──────────────────────────────────────────────────
23
41
 
@@ -167,6 +185,15 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
167
185
  scopeId: identifyBy,
168
186
  accountId,
169
187
  userToken: protectByToken ?? userToken,
188
+ // Forward consumer's protectBy check so chats with non-workspace
189
+ // identifyBy (e.g. `contentTopicId`) can supply a custom scope
190
+ // restriction instead of inheriting the default
191
+ // `{ scopeId: p.workspaceId }` which only makes sense for
192
+ // workspace-scoped chats.
193
+ protectCheck: protectByCheck as
194
+ | ((p: any) => Record<string, unknown>)
195
+ | undefined
196
+ ?? undefined,
170
197
  });
171
198
 
172
199
  // Collect query/mutate from instruction + tools
@@ -229,11 +256,17 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
229
256
  streamRoute,
230
257
  ];
231
258
 
232
- function toReactComponent(): ComponentType<{ scope: any; identifyBy: string }> {
259
+ function toReactComponent(
260
+ options: ChatReactComponentOptions = {},
261
+ ): ComponentType<{ scope: any; identifyBy: string }> {
233
262
  return createChatComponent({
234
263
  chatName: name,
235
264
  tools,
236
265
  messageElementName: `${name}Messages`,
266
+ showModelSelector: options.showModelSelector,
267
+ showWebSearch: options.showWebSearch,
268
+ renderSendButton: options.renderSendButton,
269
+ labels: options.labels,
237
270
  });
238
271
  }
239
272
 
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // --- Builder API ---
2
2
  export { chat, ArcChat } from "./chat-builder";
3
- export type { ChatConfig, ArcChatData } from "./chat-builder";
3
+ export type { ChatConfig, ArcChatData, ChatReactComponentOptions } from "./chat-builder";
4
4
 
5
5
  // --- Aggregate factories & types ---
6
6
  export { createMessageAggregate, createMessageId } from "./aggregates/message";