@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.
- package/package.json +6 -6
- package/src/aggregates/message.ts +293 -47
- package/src/chat-builder.ts +276 -83
- package/src/index.ts +4 -22
- package/src/listeners/ai-generation-listener.ts +522 -246
- package/src/react/chat-component.tsx +589 -0
- package/src/react/index.ts +2 -3
- package/src/react/use-chat.ts +1 -260
- package/src/routes/chat-stream-route.ts +4 -10
- package/src/streaming/stream-registry.ts +92 -124
- package/src/tools/ask-questions.tsx +126 -0
- package/src/routes/tool-results-route.ts +0 -49
|
@@ -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
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -10,6 +10,5 @@ export type {
|
|
|
10
10
|
QuestionAnswers,
|
|
11
11
|
} from "@arcote.tech/arc-ds";
|
|
12
12
|
|
|
13
|
-
//
|
|
14
|
-
export {
|
|
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";
|