@arcote.tech/arc-chat 0.5.5 → 0.5.7
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.
|
|
4
|
+
"version": "0.5.7",
|
|
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.7",
|
|
14
|
+
"@arcote.tech/arc-ai": "^0.5.7",
|
|
15
|
+
"@arcote.tech/arc-auth": "^0.5.7",
|
|
16
|
+
"@arcote.tech/arc-ds": "^0.5.7",
|
|
17
|
+
"@arcote.tech/platform": "^0.5.7",
|
|
18
18
|
"lucide-react": ">=0.400.0",
|
|
19
19
|
"react": ">=18.0.0",
|
|
20
20
|
"typescript": "^5.0.0"
|
package/src/chat-builder.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { AccountId, Token } from "@arcote.tech/arc-auth";
|
|
|
12
12
|
import type { AIConfig, ArcToolAny } from "@arcote.tech/arc-ai";
|
|
13
13
|
import { tool as createToolFactory } from "@arcote.tech/arc-ai";
|
|
14
14
|
import type { ArcTokenAny } from "@arcote.tech/arc";
|
|
15
|
-
import type {
|
|
15
|
+
import type { ViewProtectionFn } from "@arcote.tech/arc";
|
|
16
16
|
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";
|
|
@@ -35,6 +35,12 @@ export interface ChatReactComponentOptions {
|
|
|
35
35
|
}) => ReactNode;
|
|
36
36
|
/** Partial overrides for chat i18n labels. Falls back to English defaults. */
|
|
37
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;
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
// ─── Chat Data ──────────────────────────────────────────────────
|
|
@@ -99,7 +105,17 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
99
105
|
} as any);
|
|
100
106
|
}
|
|
101
107
|
|
|
102
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Restrict chat access by token + read filter on token params.
|
|
110
|
+
*
|
|
111
|
+
* The callback receives **raw token params** (not a `TokenInstance`),
|
|
112
|
+
* mirroring `aggregate.protectBy` semantics — the chat's underlying
|
|
113
|
+
* Message aggregate is filtered by the returned WHERE clause. This
|
|
114
|
+
* matches the runtime behaviour in `build()` below where
|
|
115
|
+
* `protectByCheck` is forwarded directly into the message aggregate's
|
|
116
|
+
* view protection.
|
|
117
|
+
*/
|
|
118
|
+
protectBy<T extends ArcTokenAny>(token: T, check: ViewProtectionFn<T>) {
|
|
103
119
|
return new ArcChat<Merge<Data, { protectBy: T; protectByCheck: typeof check }>>({
|
|
104
120
|
...this.data,
|
|
105
121
|
protectBy: token,
|
|
@@ -267,6 +283,7 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
|
|
|
267
283
|
showWebSearch: options.showWebSearch,
|
|
268
284
|
renderSendButton: options.renderSendButton,
|
|
269
285
|
labels: options.labels,
|
|
286
|
+
footer: options.footer,
|
|
270
287
|
});
|
|
271
288
|
}
|
|
272
289
|
|
package/src/index.ts
CHANGED
|
@@ -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";
|
|
@@ -87,28 +87,46 @@ function buildHistory(
|
|
|
87
87
|
|
|
88
88
|
// ─── Instructions ───────────────────────────────────────────────
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Result from an instruction handler. Can be a plain string (prompt only)
|
|
92
|
+
* or an object with prompt + optional tool filtering.
|
|
93
|
+
*/
|
|
94
|
+
export interface InstructionResult {
|
|
95
|
+
/** System prompt text sent to the LLM. */
|
|
96
|
+
prompt: string;
|
|
97
|
+
/**
|
|
98
|
+
* If provided, only tools whose names appear in this array will be
|
|
99
|
+
* sent to the LLM for this generation call. Tools not listed are
|
|
100
|
+
* hidden — the LLM cannot call them. Omit to send all registered tools.
|
|
101
|
+
*/
|
|
102
|
+
enabledTools?: string[];
|
|
103
|
+
}
|
|
104
|
+
|
|
90
105
|
/**
|
|
91
106
|
* Render the system prompt by invoking the consumer's `instruction()` handler
|
|
92
107
|
* with a thin wrapper around the listener's ctx. Always called fresh — never
|
|
93
108
|
* cached — so dynamic state (e.g. identity just updated by a tool call) shows
|
|
94
109
|
* up in the next provider call.
|
|
110
|
+
*
|
|
111
|
+
* Returns `{ prompt, enabledTools? }`. When the handler returns a plain string,
|
|
112
|
+
* it's wrapped as `{ prompt: str }` with no tool filtering.
|
|
95
113
|
*/
|
|
96
114
|
async function buildInstructions(
|
|
97
115
|
instruction: ArcFunction<any> | undefined,
|
|
98
116
|
ctx: any,
|
|
99
117
|
scopeId: string,
|
|
100
|
-
): Promise<
|
|
101
|
-
if (!instruction?.handler) return "";
|
|
118
|
+
): Promise<InstructionResult> {
|
|
119
|
+
if (!instruction?.handler) return { prompt: "" };
|
|
102
120
|
const instructionCtx = {
|
|
103
121
|
query: (element: ArcContextElement<any>) => ctx.query(element),
|
|
104
122
|
mutate: (element: ArcContextElement<any>) => ctx.mutate(element),
|
|
105
|
-
// The chat's `identifyBy` value (= scopeId of the conversation thread).
|
|
106
|
-
// Lets the consumer prompt scope its queries to the current entity.
|
|
107
123
|
identifyBy: scopeId,
|
|
108
124
|
scopeId,
|
|
109
125
|
};
|
|
110
126
|
const result = await (instruction.handler as Function)(instructionCtx);
|
|
111
|
-
|
|
127
|
+
if (typeof result === "string") return { prompt: result };
|
|
128
|
+
if (result && typeof result === "object" && "prompt" in result) return result as InstructionResult;
|
|
129
|
+
return { prompt: "" };
|
|
112
130
|
}
|
|
113
131
|
|
|
114
132
|
// ─── Conversation mode selection ────────────────────────────────
|
|
@@ -197,7 +215,12 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
197
215
|
while (executionCount <= maxExecutionCount) {
|
|
198
216
|
// Always re-render instructions — picks up state mutated by tool calls
|
|
199
217
|
// in the previous iteration (e.g. updateIdentity).
|
|
200
|
-
const
|
|
218
|
+
const instructionResult = await buildInstructions(instruction, ctx, scopeId);
|
|
219
|
+
|
|
220
|
+
// Filter tools if instruction handler specified enabledTools
|
|
221
|
+
const effectiveToolDefs = instructionResult.enabledTools
|
|
222
|
+
? toolDefs?.filter((td) => instructionResult.enabledTools!.includes(td.name))
|
|
223
|
+
: toolDefs;
|
|
201
224
|
|
|
202
225
|
const lastResponseId = findLastResponseId(history);
|
|
203
226
|
const conversation = makeConversation(
|
|
@@ -210,9 +233,9 @@ async function runGenerationLoop(config: RunLoopConfig) {
|
|
|
210
233
|
const result = await provider.streamComplete(
|
|
211
234
|
{
|
|
212
235
|
model,
|
|
213
|
-
instructions,
|
|
236
|
+
instructions: instructionResult.prompt,
|
|
214
237
|
conversation,
|
|
215
|
-
tools:
|
|
238
|
+
tools: effectiveToolDefs,
|
|
216
239
|
toolChoice,
|
|
217
240
|
},
|
|
218
241
|
(chunk) => {
|
|
@@ -21,6 +21,11 @@ interface ChatComponentConfig {
|
|
|
21
21
|
}) => ReactNode;
|
|
22
22
|
/** Partial overrides for chat i18n labels. Falls back to English defaults. */
|
|
23
23
|
labels?: Partial<ChatLabels>;
|
|
24
|
+
/**
|
|
25
|
+
* Content rendered at the bottom of the scrollable messages area,
|
|
26
|
+
* after the last message/tool. Scrolls with messages.
|
|
27
|
+
*/
|
|
28
|
+
footer?: ReactNode;
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
type TimelineItem =
|
|
@@ -38,6 +43,7 @@ export function createChatComponent(
|
|
|
38
43
|
showWebSearch = true,
|
|
39
44
|
renderSendButton,
|
|
40
45
|
labels,
|
|
46
|
+
footer,
|
|
41
47
|
} = config;
|
|
42
48
|
const toolsMap = new Map(tools.map((t) => [t.name, t]));
|
|
43
49
|
|
|
@@ -48,6 +54,7 @@ export function createChatComponent(
|
|
|
48
54
|
const sessionIdRef = useRef<string | null>(null);
|
|
49
55
|
const currentAssistantIdRef = useRef<string | null>(null);
|
|
50
56
|
const lastHistoryLenRef = useRef(0);
|
|
57
|
+
const resumedSessionRef = useRef<string | null>(null);
|
|
51
58
|
|
|
52
59
|
const queries = scope.useQuery();
|
|
53
60
|
const mutations = scope.useMutation();
|
|
@@ -158,7 +165,50 @@ export function createChatComponent(
|
|
|
158
165
|
}
|
|
159
166
|
}, [historyLen, isStreaming]);
|
|
160
167
|
|
|
168
|
+
// ─── SSE stream consumer ────────────────────────────────────
|
|
169
|
+
// Reusable: handles fetch + read loop + processEvent dispatch.
|
|
170
|
+
// Caller is responsible for setting isStreaming/sessionIdRef before
|
|
171
|
+
// and clearing them after (different lifecycle in send vs respond vs resume).
|
|
172
|
+
const consumeStream = useCallback(
|
|
173
|
+
async (sessionId: string): Promise<void> => {
|
|
174
|
+
const streamUrl = `/route/chat/${chatName}/stream/${sessionId}`;
|
|
175
|
+
const response = await fetch(streamUrl, {
|
|
176
|
+
credentials: "include",
|
|
177
|
+
headers: { Accept: "text/event-stream" },
|
|
178
|
+
});
|
|
179
|
+
if (!response.ok) throw new Error(`Stream failed: ${response.status}`);
|
|
180
|
+
|
|
181
|
+
const reader = response.body!.getReader();
|
|
182
|
+
const decoder = new TextDecoder();
|
|
183
|
+
let partialLine = "";
|
|
184
|
+
|
|
185
|
+
while (true) {
|
|
186
|
+
const { value, done } = await reader.read();
|
|
187
|
+
if (done) break;
|
|
188
|
+
const text = partialLine + decoder.decode(value, { stream: true });
|
|
189
|
+
const lines = text.split("\n");
|
|
190
|
+
partialLine = lines.pop() ?? "";
|
|
191
|
+
for (const line of lines) {
|
|
192
|
+
if (line.startsWith("data: ")) {
|
|
193
|
+
try {
|
|
194
|
+
const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
|
|
195
|
+
await processEventRef.current?.(event);
|
|
196
|
+
} catch {}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (partialLine.startsWith("data: ")) {
|
|
201
|
+
try {
|
|
202
|
+
const event = JSON.parse(partialLine.slice(6)) as ChatStreamEvent;
|
|
203
|
+
await processEventRef.current?.(event);
|
|
204
|
+
} catch {}
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
[],
|
|
208
|
+
);
|
|
209
|
+
|
|
161
210
|
// ─── SSE event processing ───────────────────────────────────
|
|
211
|
+
const processEventRef = useRef<((event: ChatStreamEvent) => Promise<void>) | null>(null);
|
|
162
212
|
const processEvent = useCallback(
|
|
163
213
|
async (event: ChatStreamEvent) => {
|
|
164
214
|
switch (event.type) {
|
|
@@ -314,6 +364,32 @@ export function createChatComponent(
|
|
|
314
364
|
[chatLabels],
|
|
315
365
|
);
|
|
316
366
|
|
|
367
|
+
// Keep ref in sync so consumeStream (stable callback) can call latest version
|
|
368
|
+
processEventRef.current = processEvent;
|
|
369
|
+
|
|
370
|
+
// ─── Resume SSE on mount if there's an active generation ────
|
|
371
|
+
// After page reload, if the DB shows isGenerating=true with sessionId,
|
|
372
|
+
// we reconnect to the stream registry to consume any in-flight events.
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
if (!scopeId) return;
|
|
375
|
+
const sid = sessionIdRef.current;
|
|
376
|
+
if (!sid) return;
|
|
377
|
+
if (resumedSessionRef.current === sid) return;
|
|
378
|
+
if (!isStreaming) return;
|
|
379
|
+
resumedSessionRef.current = sid;
|
|
380
|
+
(async () => {
|
|
381
|
+
try {
|
|
382
|
+
await consumeStream(sid);
|
|
383
|
+
} catch {
|
|
384
|
+
// Stream may have already ended or been GC'd — fall through
|
|
385
|
+
} finally {
|
|
386
|
+
setIsStreaming(false);
|
|
387
|
+
sessionIdRef.current = null;
|
|
388
|
+
currentAssistantIdRef.current = null;
|
|
389
|
+
}
|
|
390
|
+
})();
|
|
391
|
+
}, [isStreaming, scopeId, consumeStream]);
|
|
392
|
+
|
|
317
393
|
// ─── Send message ───────────────────────────────────────────
|
|
318
394
|
const handleSend = useCallback(
|
|
319
395
|
async (content: string, options: SendMessageOptions) => {
|
|
@@ -557,6 +633,7 @@ export function createChatComponent(
|
|
|
557
633
|
"div",
|
|
558
634
|
{ className: "max-w-3xl mx-auto w-full space-y-4 flex-1" },
|
|
559
635
|
...timelineElements,
|
|
636
|
+
footer,
|
|
560
637
|
),
|
|
561
638
|
createElement(Chat, {
|
|
562
639
|
messages: [],
|