@arcote.tech/arc-chat 0.5.2 → 0.5.6
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 +89 -72
- package/src/chat-builder.ts +42 -2
- package/src/index.ts +2 -2
- package/src/listeners/ai-generation-listener.ts +378 -152
- package/src/react/chat-component.tsx +304 -95
- package/src/tools/ask-questions.tsx +38 -19
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.6",
|
|
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.6",
|
|
14
|
+
"@arcote.tech/arc-ai": "^0.5.6",
|
|
15
|
+
"@arcote.tech/arc-auth": "^0.5.6",
|
|
16
|
+
"@arcote.tech/arc-ds": "^0.5.6",
|
|
17
|
+
"@arcote.tech/platform": "^0.5.6",
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
358
|
+
messageId: msgId,
|
|
373
359
|
scopeId: params.scopeId,
|
|
374
360
|
sessionId,
|
|
375
|
-
role: "
|
|
361
|
+
role: "system",
|
|
376
362
|
content: "Rozpocznij ten etap. Przywitaj się i zadaj pierwsze pytanie.",
|
|
377
|
-
model: params.model ?? "gpt-5.4-
|
|
363
|
+
model: params.model ?? "gpt-5.4-mini",
|
|
378
364
|
});
|
|
379
|
-
return { messageId:
|
|
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,
|
|
411
|
+
.protectBy(userToken, scopeCheck as any);
|
|
395
412
|
};
|
|
396
413
|
|
|
397
414
|
export type MessageAggregate = ReturnType<typeof createMessageAggregate>;
|
package/src/chat-builder.ts
CHANGED
|
@@ -17,7 +17,31 @@ 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 {
|
|
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
|
+
/**
|
|
39
|
+
* Content rendered at the bottom of the scrollable messages area,
|
|
40
|
+
* after the last message/tool. Useful for persistent UI like stage
|
|
41
|
+
* advancement bars. Scrolls with messages.
|
|
42
|
+
*/
|
|
43
|
+
footer?: ReactNode;
|
|
44
|
+
}
|
|
21
45
|
|
|
22
46
|
// ─── Chat Data ──────────────────────────────────────────────────
|
|
23
47
|
|
|
@@ -167,6 +191,15 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
167
191
|
scopeId: identifyBy,
|
|
168
192
|
accountId,
|
|
169
193
|
userToken: protectByToken ?? userToken,
|
|
194
|
+
// Forward consumer's protectBy check so chats with non-workspace
|
|
195
|
+
// identifyBy (e.g. `contentTopicId`) can supply a custom scope
|
|
196
|
+
// restriction instead of inheriting the default
|
|
197
|
+
// `{ scopeId: p.workspaceId }` which only makes sense for
|
|
198
|
+
// workspace-scoped chats.
|
|
199
|
+
protectCheck: protectByCheck as
|
|
200
|
+
| ((p: any) => Record<string, unknown>)
|
|
201
|
+
| undefined
|
|
202
|
+
?? undefined,
|
|
170
203
|
});
|
|
171
204
|
|
|
172
205
|
// Collect query/mutate from instruction + tools
|
|
@@ -229,11 +262,18 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
229
262
|
streamRoute,
|
|
230
263
|
];
|
|
231
264
|
|
|
232
|
-
function toReactComponent(
|
|
265
|
+
function toReactComponent(
|
|
266
|
+
options: ChatReactComponentOptions = {},
|
|
267
|
+
): ComponentType<{ scope: any; identifyBy: string }> {
|
|
233
268
|
return createChatComponent({
|
|
234
269
|
chatName: name,
|
|
235
270
|
tools,
|
|
236
271
|
messageElementName: `${name}Messages`,
|
|
272
|
+
showModelSelector: options.showModelSelector,
|
|
273
|
+
showWebSearch: options.showWebSearch,
|
|
274
|
+
renderSendButton: options.renderSendButton,
|
|
275
|
+
labels: options.labels,
|
|
276
|
+
footer: options.footer,
|
|
237
277
|
});
|
|
238
278
|
}
|
|
239
279
|
|
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";
|
|
@@ -16,7 +16,7 @@ export type { StreamSession } from "./streaming/stream-registry";
|
|
|
16
16
|
|
|
17
17
|
// --- Listener ---
|
|
18
18
|
export { createAiGenerationListener } from "./listeners/ai-generation-listener";
|
|
19
|
-
export type { AiGenerationListenerConfig } from "./listeners/ai-generation-listener";
|
|
19
|
+
export type { AiGenerationListenerConfig, InstructionResult } from "./listeners/ai-generation-listener";
|
|
20
20
|
|
|
21
21
|
// --- Routes ---
|
|
22
22
|
export { createChatStreamRoute } from "./routes/chat-stream-route";
|