@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.
@@ -0,0 +1,589 @@
1
+ import { useState, useCallback, useRef, useEffect, type ComponentType, createElement, type ReactNode } from "react";
2
+ import type { ChatStreamEvent, ArcToolAny } from "@arcote.tech/arc-ai";
3
+ import type { ChatLabels, ChatMessageData, SendMessageOptions } from "@arcote.tech/arc-ds";
4
+ import { Chat, ChatMessage, ChatInputProvider, ChatLabelsProvider, ChatToolLog, useChatLabels } from "@arcote.tech/arc-ds";
5
+
6
+ interface ChatComponentConfig {
7
+ chatName: string;
8
+ tools: ArcToolAny[];
9
+ messageElementName: string;
10
+ /** Show the model selector dropdown in ChatInput. Default true. */
11
+ showModelSelector?: boolean;
12
+ /** Show the web search toggle in ChatInput. Default true. */
13
+ showWebSearch?: boolean;
14
+ /**
15
+ * Render slot for ChatInput's send button. Receives `onClick` and
16
+ * `disabled` — caller renders its own button (e.g. branded with a logo).
17
+ */
18
+ renderSendButton?: (props: {
19
+ onClick: () => void;
20
+ disabled: boolean;
21
+ }) => ReactNode;
22
+ /** Partial overrides for chat i18n labels. Falls back to English defaults. */
23
+ labels?: Partial<ChatLabels>;
24
+ }
25
+
26
+ type TimelineItem =
27
+ | { type: "message"; id: string; role: "user" | "assistant"; content: string; isStreaming?: boolean }
28
+ | { type: "tool"; id: string; toolCallId: string; toolName: string; params: Record<string, unknown>; result?: unknown; calling: boolean; error?: string };
29
+
30
+ export function createChatComponent(
31
+ config: ChatComponentConfig,
32
+ ): ComponentType<{ scope: any; identifyBy: string }> {
33
+ const {
34
+ chatName,
35
+ tools,
36
+ messageElementName,
37
+ showModelSelector = true,
38
+ showWebSearch = true,
39
+ renderSendButton,
40
+ labels,
41
+ } = config;
42
+ const toolsMap = new Map(tools.map((t) => [t.name, t]));
43
+
44
+ function ChatComponentInner({ scope, identifyBy }: { scope: any; identifyBy: string }) {
45
+ const chatLabels = useChatLabels();
46
+ const [timeline, setTimeline] = useState<TimelineItem[]>([]);
47
+ const [isStreaming, setIsStreaming] = useState(false);
48
+ const sessionIdRef = useRef<string | null>(null);
49
+ const currentAssistantIdRef = useRef<string | null>(null);
50
+ const lastHistoryLenRef = useRef(0);
51
+
52
+ const queries = scope.useQuery();
53
+ const mutations = scope.useMutation();
54
+ const messageQueries = queries[messageElementName];
55
+ const messageMutations = mutations[messageElementName];
56
+
57
+ const scopeId = identifyBy;
58
+ const historyResult = scopeId && messageQueries?.getByScope
59
+ ? messageQueries.getByScope({ scopeId })
60
+ : [undefined, false];
61
+ const historyData = historyResult?.[0];
62
+ const historyLen = historyData?.length ?? 0;
63
+
64
+ // ─── Restore timeline from DB history ───────────────────────
65
+ useEffect(() => {
66
+ if (isStreaming || !historyData || historyLen === 0) return;
67
+ if (historyLen === lastHistoryLenRef.current) return;
68
+ lastHistoryLenRef.current = historyLen;
69
+
70
+ const resultIds = new Set<string>();
71
+ const resultMap = new Map<string, { content: string; isError?: boolean }>();
72
+ for (const msg of historyData) {
73
+ if (msg.role === "tool_result" && msg.toolCallId) {
74
+ resultIds.add(msg.toolCallId);
75
+ resultMap.set(msg.toolCallId, { content: msg.content, isError: msg.isError });
76
+ }
77
+ }
78
+
79
+ const items: TimelineItem[] = [];
80
+ let hasActiveGeneration = false;
81
+
82
+ for (const msg of historyData) {
83
+ // System messages are developer-injected priming prompts. They go
84
+ // to the LLM via buildHistory() but must not appear in the user's
85
+ // chat timeline.
86
+ if (msg.role === "system") continue;
87
+
88
+ if (msg.role === "user") {
89
+ items.push({
90
+ type: "message",
91
+ id: msg._id,
92
+ role: "user",
93
+ content: msg.content ?? "",
94
+ });
95
+ continue;
96
+ }
97
+
98
+ if (msg.role === "assistant") {
99
+ // Placeholder row created at the start of generation. We track its
100
+ // session for SSE reconnect, but don't render it yet — the loop
101
+ // will save the real assistant row with `blocks` once streaming
102
+ // completes.
103
+ const blocksStr = msg.blocks ?? "";
104
+ if (msg.isGenerating && !blocksStr) {
105
+ if (msg.sessionId) sessionIdRef.current = msg.sessionId;
106
+ hasActiveGeneration = true;
107
+ continue;
108
+ }
109
+
110
+ // Walk the assistant's blocks in order — each TextBlock becomes a
111
+ // message item, each ToolCallBlock becomes a tool item paired with
112
+ // its result row.
113
+ const blocks =
114
+ (tryParseJson(blocksStr) as Array<
115
+ | { type: "text"; text: string }
116
+ | {
117
+ type: "tool_call";
118
+ id: string;
119
+ name: string;
120
+ arguments: Record<string, unknown>;
121
+ }
122
+ >) ?? [];
123
+
124
+ let blockIdx = 0;
125
+ for (const block of blocks) {
126
+ if (block.type === "text") {
127
+ if (block.text) {
128
+ items.push({
129
+ type: "message",
130
+ id: `${msg._id}_b${blockIdx}`,
131
+ role: "assistant",
132
+ content: block.text,
133
+ });
134
+ }
135
+ } else {
136
+ const result = resultMap.get(block.id);
137
+ items.push({
138
+ type: "tool",
139
+ id: `${msg._id}_b${blockIdx}`,
140
+ toolCallId: block.id,
141
+ toolName: block.name,
142
+ params: block.arguments,
143
+ result: result ? tryParseJson(result.content) : undefined,
144
+ calling: !resultIds.has(block.id),
145
+ error: result?.isError ? result.content : undefined,
146
+ });
147
+ }
148
+ blockIdx++;
149
+ }
150
+
151
+ if (msg.isGenerating === true) hasActiveGeneration = true;
152
+ }
153
+ }
154
+
155
+ setTimeline(items);
156
+ if (!isStreaming && hasActiveGeneration) {
157
+ setIsStreaming(true);
158
+ }
159
+ }, [historyLen, isStreaming]);
160
+
161
+ // ─── SSE event processing ───────────────────────────────────
162
+ const processEvent = useCallback(
163
+ async (event: ChatStreamEvent) => {
164
+ switch (event.type) {
165
+ case "content_delta":
166
+ if (!event.content) break;
167
+ // Append to the trailing assistant text bubble. If there isn't
168
+ // one (e.g. just finished a tool, or no streaming bubble yet),
169
+ // create a new one. This produces correctly interleaved
170
+ // text/tool/text/tool ordering during streaming.
171
+ setTimeline((prev) => {
172
+ const last = prev[prev.length - 1];
173
+ if (
174
+ last &&
175
+ last.type === "message" &&
176
+ last.role === "assistant" &&
177
+ last.isStreaming
178
+ ) {
179
+ return prev.map((item, i) =>
180
+ i === prev.length - 1 && item.type === "message"
181
+ ? { ...item, content: item.content + event.content }
182
+ : item,
183
+ );
184
+ }
185
+ const newId = `assistant_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
186
+ currentAssistantIdRef.current = newId;
187
+ return [
188
+ ...prev,
189
+ {
190
+ type: "message",
191
+ id: newId,
192
+ role: "assistant",
193
+ content: event.content!,
194
+ isStreaming: true,
195
+ },
196
+ ];
197
+ });
198
+ break;
199
+
200
+ case "server_tool_start":
201
+ if (event.toolCall) {
202
+ // Finalize any streaming text bubble before the tool — next
203
+ // content_delta will start a fresh bubble after the tool.
204
+ setTimeline((prev) => {
205
+ const next = prev.map((item) =>
206
+ item.type === "message" && item.isStreaming
207
+ ? { ...item, isStreaming: false }
208
+ : item,
209
+ );
210
+ next.push({
211
+ type: "tool",
212
+ id: `tc_${event.toolCall!.id}`,
213
+ toolCallId: event.toolCall!.id,
214
+ toolName: event.toolCall!.name,
215
+ params: event.toolCall!.arguments ?? {},
216
+ calling: true,
217
+ });
218
+ return next;
219
+ });
220
+ currentAssistantIdRef.current = null;
221
+ }
222
+ break;
223
+
224
+ case "server_tool_result":
225
+ if (event.toolResult) {
226
+ setTimeline((prev) =>
227
+ prev.map((item) =>
228
+ item.type === "tool" && item.toolCallId === event.toolResult!.toolCallId
229
+ ? {
230
+ ...item,
231
+ result: tryParseJson(event.toolResult!.content),
232
+ calling: false,
233
+ error: event.toolResult!.isError ? event.toolResult!.content : undefined,
234
+ }
235
+ : item,
236
+ ),
237
+ );
238
+ }
239
+ break;
240
+
241
+ case "interactive_tool_request":
242
+ if (event.toolCalls) {
243
+ // Finalize current text bubble and append the interactive tool
244
+ // items. After this, the loop pauses until userResponded.
245
+ setTimeline((prev) => {
246
+ const next = prev.map((item) =>
247
+ item.type === "message" && item.isStreaming
248
+ ? { ...item, isStreaming: false }
249
+ : item,
250
+ );
251
+ for (const tc of event.toolCalls!) {
252
+ next.push({
253
+ type: "tool",
254
+ id: `tc_${tc.id}`,
255
+ toolCallId: tc.id,
256
+ toolName: tc.name,
257
+ params: tc.arguments ?? {},
258
+ calling: true,
259
+ });
260
+ }
261
+ return next;
262
+ });
263
+ currentAssistantIdRef.current = null;
264
+ }
265
+ break;
266
+
267
+ case "done":
268
+ // Mark any trailing streaming bubble as done.
269
+ setTimeline((prev) =>
270
+ prev.map((item) =>
271
+ item.type === "message" && item.isStreaming
272
+ ? { ...item, isStreaming: false }
273
+ : item,
274
+ ),
275
+ );
276
+ setIsStreaming(false);
277
+ currentAssistantIdRef.current = null;
278
+ break;
279
+
280
+ case "error":
281
+ setTimeline((prev) => {
282
+ const last = prev[prev.length - 1];
283
+ if (
284
+ last &&
285
+ last.type === "message" &&
286
+ last.role === "assistant" &&
287
+ last.isStreaming
288
+ ) {
289
+ return prev.map((item, i) =>
290
+ i === prev.length - 1 && item.type === "message"
291
+ ? {
292
+ ...item,
293
+ content: item.content || event.error || chatLabels.errorLabel,
294
+ isStreaming: false,
295
+ }
296
+ : item,
297
+ );
298
+ }
299
+ return [
300
+ ...prev,
301
+ {
302
+ type: "message",
303
+ id: `error_${Date.now()}`,
304
+ role: "assistant",
305
+ content: event.error || chatLabels.errorLabel,
306
+ },
307
+ ];
308
+ });
309
+ setIsStreaming(false);
310
+ currentAssistantIdRef.current = null;
311
+ break;
312
+ }
313
+ },
314
+ [chatLabels],
315
+ );
316
+
317
+ // ─── Send message ───────────────────────────────────────────
318
+ const handleSend = useCallback(
319
+ async (content: string, options: SendMessageOptions) => {
320
+ if (isStreaming || !scopeId) return;
321
+
322
+ setIsStreaming(true);
323
+
324
+ const userMsgId = `user_${Date.now()}`;
325
+ setTimeline((prev) => [
326
+ ...prev,
327
+ { type: "message", id: userMsgId, role: "user", content },
328
+ ]);
329
+
330
+ try {
331
+ const sendResult = await messageMutations.sendMessage({
332
+ scopeId,
333
+ content,
334
+ model: options.model,
335
+ });
336
+
337
+ const { sessionId } = sendResult as { sessionId: string; messageId: string };
338
+ sessionIdRef.current = sessionId;
339
+
340
+ const streamUrl = `/route/chat/${chatName}/stream/${sessionId}`;
341
+ const response = await fetch(streamUrl, {
342
+ credentials: "include",
343
+ headers: { Accept: "text/event-stream" },
344
+ });
345
+
346
+ if (!response.ok) throw new Error(`Stream failed: ${response.status}`);
347
+
348
+ // Don't pre-create an assistant bubble here — the SSE handler will
349
+ // create one lazily on the first content_delta. This way, if the
350
+ // model jumps straight to a tool call (no text), we don't render an
351
+ // empty bubble.
352
+ currentAssistantIdRef.current = null;
353
+
354
+ const reader = response.body!.getReader();
355
+ const decoder = new TextDecoder();
356
+ let partialLine = "";
357
+
358
+ while (true) {
359
+ const { value, done } = await reader.read();
360
+ if (done) break;
361
+
362
+ const text = partialLine + decoder.decode(value, { stream: true });
363
+ const lines = text.split("\n");
364
+ partialLine = lines.pop() ?? "";
365
+
366
+ for (const line of lines) {
367
+ if (line.startsWith("data: ")) {
368
+ try {
369
+ const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
370
+ await processEvent(event);
371
+ } catch {}
372
+ }
373
+ }
374
+ }
375
+
376
+ if (partialLine.startsWith("data: ")) {
377
+ try {
378
+ const event = JSON.parse(partialLine.slice(6)) as ChatStreamEvent;
379
+ await processEvent(event);
380
+ } catch {}
381
+ }
382
+
383
+ setIsStreaming(false);
384
+ sessionIdRef.current = null;
385
+ currentAssistantIdRef.current = null;
386
+ } catch (err) {
387
+ const errorMsg = err instanceof Error ? err.message : chatLabels.errorLabel;
388
+ setTimeline((prev) => [
389
+ ...prev,
390
+ { type: "message", id: `error_${Date.now()}`, role: "assistant", content: `${chatLabels.errorLabel}: ${errorMsg}` },
391
+ ]);
392
+ setIsStreaming(false);
393
+ sessionIdRef.current = null;
394
+ currentAssistantIdRef.current = null;
395
+ }
396
+ },
397
+ [isStreaming, scopeId, messageMutations, processEvent, chatLabels],
398
+ );
399
+
400
+ // ─── Build messages for Chat DS (only user/assistant) ───────
401
+ const chatMessages: ChatMessageData[] = timeline
402
+ .filter((item): item is TimelineItem & { type: "message" } => item.type === "message")
403
+ .map((item) => ({
404
+ id: item.id,
405
+ role: item.role,
406
+ content: item.content,
407
+ isStreaming: item.isStreaming,
408
+ }));
409
+
410
+ // ─── Render tool view ───────────────────────────────────────
411
+ const renderToolItem = (item: TimelineItem & { type: "tool" }) => {
412
+ const tool = toolsMap.get(item.toolName);
413
+ const ViewComponent = tool?.viewComponent;
414
+
415
+ if (!ViewComponent) {
416
+ return createElement(ChatToolLog, {
417
+ calling: item.calling,
418
+ label: item.toolName,
419
+ }, item.calling ? chatLabels.toolCallingLabel : item.error ?? chatLabels.toolDoneLabel);
420
+ }
421
+
422
+ if (tool.isServerTool) {
423
+ return createElement(ViewComponent, {
424
+ params: item.params,
425
+ result: item.result,
426
+ calling: item.calling,
427
+ error: item.error,
428
+ });
429
+ }
430
+
431
+ // Interactive tool
432
+ const respond = async (result: unknown) => {
433
+ if (!scopeId) return;
434
+
435
+ // Update timeline immediately
436
+ setTimeline((prev) =>
437
+ prev.map((t) =>
438
+ t.type === "tool" && t.toolCallId === item.toolCallId
439
+ ? { ...t, calling: false, result }
440
+ : t,
441
+ ),
442
+ );
443
+
444
+ // Send response — returns new sessionId for SSE
445
+ const respondResult = await messageMutations.respondToTool({
446
+ scopeId,
447
+ toolCallId: item.toolCallId,
448
+ toolName: item.toolName,
449
+ result: JSON.stringify(result),
450
+ });
451
+
452
+ const { sessionId: newSessionId } = respondResult as { sessionId: string };
453
+ if (!newSessionId) return;
454
+
455
+ // Connect to new SSE stream for resumed generation. Don't pre-create
456
+ // an assistant bubble — the SSE handler creates one lazily on the
457
+ // first content_delta (same pattern as handleSend).
458
+ sessionIdRef.current = newSessionId;
459
+ setIsStreaming(true);
460
+ currentAssistantIdRef.current = null;
461
+
462
+ try {
463
+ const streamUrl = `/route/chat/${chatName}/stream/${newSessionId}`;
464
+ const response = await fetch(streamUrl, {
465
+ credentials: "include",
466
+ headers: { Accept: "text/event-stream" },
467
+ });
468
+
469
+ if (!response.ok) throw new Error(`Stream failed: ${response.status}`);
470
+
471
+ const reader = response.body!.getReader();
472
+ const decoder = new TextDecoder();
473
+ let partialLine = "";
474
+
475
+ while (true) {
476
+ const { value, done } = await reader.read();
477
+ if (done) break;
478
+ const text = partialLine + decoder.decode(value, { stream: true });
479
+ const lines = text.split("\n");
480
+ partialLine = lines.pop() ?? "";
481
+ for (const line of lines) {
482
+ if (line.startsWith("data: ")) {
483
+ try {
484
+ const evt = JSON.parse(line.slice(6)) as ChatStreamEvent;
485
+ await processEvent(evt);
486
+ } catch {}
487
+ }
488
+ }
489
+ }
490
+ if (partialLine.startsWith("data: ")) {
491
+ try {
492
+ const evt = JSON.parse(partialLine.slice(6)) as ChatStreamEvent;
493
+ await processEvent(evt);
494
+ } catch {}
495
+ }
496
+ } catch (err) {
497
+ setTimeline((prev) => [
498
+ ...prev,
499
+ {
500
+ type: "message",
501
+ id: `error_${Date.now()}`,
502
+ role: "assistant",
503
+ content: `${chatLabels.errorLabel}: ${err instanceof Error ? err.message : chatLabels.errorLabel}`,
504
+ },
505
+ ]);
506
+ } finally {
507
+ setIsStreaming(false);
508
+ sessionIdRef.current = null;
509
+ currentAssistantIdRef.current = null;
510
+ }
511
+ };
512
+
513
+ return createElement(ViewComponent, {
514
+ params: item.params,
515
+ respond,
516
+ calling: item.calling,
517
+ result: item.result,
518
+ });
519
+ };
520
+
521
+ // ─── Render full timeline ───────────────────────────────────
522
+ // Streaming message renders last (after tools) for correct visual order
523
+ const sortedTimeline = [...timeline].sort((a, b) => {
524
+ const aStreaming = a.type === "message" && a.isStreaming ? 1 : 0;
525
+ const bStreaming = b.type === "message" && b.isStreaming ? 1 : 0;
526
+ return aStreaming - bStreaming;
527
+ });
528
+
529
+ const timelineElements: ReactNode[] = [];
530
+ for (const item of sortedTimeline) {
531
+ if (item.type === "message") {
532
+ timelineElements.push(
533
+ createElement(ChatMessage, {
534
+ key: item.id,
535
+ message: { id: item.id, role: item.role, content: item.content, isStreaming: item.isStreaming },
536
+ }),
537
+ );
538
+ } else if (item.type === "tool") {
539
+ timelineElements.push(
540
+ createElement("div", { key: item.id }, renderToolItem(item)),
541
+ );
542
+ }
543
+ }
544
+
545
+ // Check if any interactive tool is waiting for response
546
+ const hasWaitingInteractive = timeline.some(
547
+ (item) => item.type === "tool" && item.calling && !toolsMap.get(item.toolName)?.isServerTool,
548
+ );
549
+
550
+ return createElement(
551
+ ChatInputProvider,
552
+ null,
553
+ createElement(
554
+ "div",
555
+ { className: "flex flex-col h-full" },
556
+ createElement(
557
+ "div",
558
+ { className: "max-w-3xl mx-auto w-full space-y-4 flex-1" },
559
+ ...timelineElements,
560
+ ),
561
+ createElement(Chat, {
562
+ messages: [],
563
+ models: [{ value: "gpt-5.4-mini", label: "GPT-5.4 Mini" }],
564
+ defaultModel: "gpt-5.4-mini",
565
+ onSend: handleSend,
566
+ showModelSelector,
567
+ showWebSearch,
568
+ renderSendButton,
569
+ disabled: isStreaming || hasWaitingInteractive,
570
+ }),
571
+ ),
572
+ );
573
+ }
574
+
575
+ return function ChatComponent(props: { scope: any; identifyBy: string }) {
576
+ return createElement(
577
+ ChatLabelsProvider,
578
+ { labels, children: createElement(ChatComponentInner, props) },
579
+ );
580
+ };
581
+ }
582
+
583
+ function tryParseJson(str: string): unknown {
584
+ try {
585
+ return JSON.parse(str);
586
+ } catch {
587
+ return str;
588
+ }
589
+ }
@@ -10,6 +10,5 @@ export type {
10
10
  QuestionAnswers,
11
11
  } from "@arcote.tech/arc-ds";
12
12
 
13
- // Chat hook
14
- export { useChat } from "./use-chat";
15
- export type { UseChatConfig, UseChatReturn } from "./use-chat";
13
+ // Internal — chat component factory (used by chat-builder.ts toReactComponent)
14
+ export { createChatComponent } from "./chat-component";