@arcote.tech/arc-chat 0.5.5 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-chat",
3
3
  "type": "module",
4
- "version": "0.5.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.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",
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"
@@ -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 ──────────────────────────────────────────────────
@@ -267,6 +273,7 @@ export class ArcChat<const Data extends ArcChatData = DefaultChatData> {
267
273
  showWebSearch: options.showWebSearch,
268
274
  renderSendButton: options.renderSendButton,
269
275
  labels: options.labels,
276
+ footer: options.footer,
270
277
  });
271
278
  }
272
279
 
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<string> {
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
- return typeof result === "string" ? result : "";
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 instructions = await buildInstructions(instruction, ctx, scopeId);
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: toolDefs,
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: [],