@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
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
import { useState, useCallback, useRef, useEffect, type ComponentType, createElement, type ReactNode } from "react";
|
|
2
2
|
import type { ChatStreamEvent, ArcToolAny } from "@arcote.tech/arc-ai";
|
|
3
|
-
import type { ChatMessageData, SendMessageOptions } from "@arcote.tech/arc-ds";
|
|
4
|
-
import { Chat, ChatMessage, ChatInputProvider, ChatToolLog } from "@arcote.tech/arc-ds";
|
|
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
5
|
|
|
6
6
|
interface ChatComponentConfig {
|
|
7
7
|
chatName: string;
|
|
8
8
|
tools: ArcToolAny[];
|
|
9
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
|
+
* Content rendered at the bottom of the scrollable messages area,
|
|
26
|
+
* after the last message/tool. Scrolls with messages.
|
|
27
|
+
*/
|
|
28
|
+
footer?: ReactNode;
|
|
10
29
|
}
|
|
11
30
|
|
|
12
31
|
type TimelineItem =
|
|
@@ -16,15 +35,26 @@ type TimelineItem =
|
|
|
16
35
|
export function createChatComponent(
|
|
17
36
|
config: ChatComponentConfig,
|
|
18
37
|
): ComponentType<{ scope: any; identifyBy: string }> {
|
|
19
|
-
const {
|
|
38
|
+
const {
|
|
39
|
+
chatName,
|
|
40
|
+
tools,
|
|
41
|
+
messageElementName,
|
|
42
|
+
showModelSelector = true,
|
|
43
|
+
showWebSearch = true,
|
|
44
|
+
renderSendButton,
|
|
45
|
+
labels,
|
|
46
|
+
footer,
|
|
47
|
+
} = config;
|
|
20
48
|
const toolsMap = new Map(tools.map((t) => [t.name, t]));
|
|
21
49
|
|
|
22
|
-
|
|
50
|
+
function ChatComponentInner({ scope, identifyBy }: { scope: any; identifyBy: string }) {
|
|
51
|
+
const chatLabels = useChatLabels();
|
|
23
52
|
const [timeline, setTimeline] = useState<TimelineItem[]>([]);
|
|
24
53
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
25
54
|
const sessionIdRef = useRef<string | null>(null);
|
|
26
55
|
const currentAssistantIdRef = useRef<string | null>(null);
|
|
27
56
|
const lastHistoryLenRef = useRef(0);
|
|
57
|
+
const resumedSessionRef = useRef<string | null>(null);
|
|
28
58
|
|
|
29
59
|
const queries = scope.useQuery();
|
|
30
60
|
const mutations = scope.useMutation();
|
|
@@ -57,32 +87,75 @@ export function createChatComponent(
|
|
|
57
87
|
let hasActiveGeneration = false;
|
|
58
88
|
|
|
59
89
|
for (const msg of historyData) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
90
|
+
// System messages are developer-injected priming prompts. They go
|
|
91
|
+
// to the LLM via buildHistory() but must not appear in the user's
|
|
92
|
+
// chat timeline.
|
|
93
|
+
if (msg.role === "system") continue;
|
|
94
|
+
|
|
95
|
+
if (msg.role === "user") {
|
|
66
96
|
items.push({
|
|
67
97
|
type: "message",
|
|
68
98
|
id: msg._id,
|
|
69
|
-
role:
|
|
70
|
-
content: msg.content,
|
|
71
|
-
isStreaming: msg.isGenerating === true,
|
|
99
|
+
role: "user",
|
|
100
|
+
content: msg.content ?? "",
|
|
72
101
|
});
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (msg.role === "assistant") {
|
|
106
|
+
// Placeholder row created at the start of generation. We track its
|
|
107
|
+
// session for SSE reconnect, but don't render it yet — the loop
|
|
108
|
+
// will save the real assistant row with `blocks` once streaming
|
|
109
|
+
// completes.
|
|
110
|
+
const blocksStr = msg.blocks ?? "";
|
|
111
|
+
if (msg.isGenerating && !blocksStr) {
|
|
112
|
+
if (msg.sessionId) sessionIdRef.current = msg.sessionId;
|
|
113
|
+
hasActiveGeneration = true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Walk the assistant's blocks in order — each TextBlock becomes a
|
|
118
|
+
// message item, each ToolCallBlock becomes a tool item paired with
|
|
119
|
+
// its result row.
|
|
120
|
+
const blocks =
|
|
121
|
+
(tryParseJson(blocksStr) as Array<
|
|
122
|
+
| { type: "text"; text: string }
|
|
123
|
+
| {
|
|
124
|
+
type: "tool_call";
|
|
125
|
+
id: string;
|
|
126
|
+
name: string;
|
|
127
|
+
arguments: Record<string, unknown>;
|
|
128
|
+
}
|
|
129
|
+
>) ?? [];
|
|
130
|
+
|
|
131
|
+
let blockIdx = 0;
|
|
132
|
+
for (const block of blocks) {
|
|
133
|
+
if (block.type === "text") {
|
|
134
|
+
if (block.text) {
|
|
135
|
+
items.push({
|
|
136
|
+
type: "message",
|
|
137
|
+
id: `${msg._id}_b${blockIdx}`,
|
|
138
|
+
role: "assistant",
|
|
139
|
+
content: block.text,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
const result = resultMap.get(block.id);
|
|
144
|
+
items.push({
|
|
145
|
+
type: "tool",
|
|
146
|
+
id: `${msg._id}_b${blockIdx}`,
|
|
147
|
+
toolCallId: block.id,
|
|
148
|
+
toolName: block.name,
|
|
149
|
+
params: block.arguments,
|
|
150
|
+
result: result ? tryParseJson(result.content) : undefined,
|
|
151
|
+
calling: !resultIds.has(block.id),
|
|
152
|
+
error: result?.isError ? result.content : undefined,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
blockIdx++;
|
|
156
|
+
}
|
|
157
|
+
|
|
73
158
|
if (msg.isGenerating === true) hasActiveGeneration = true;
|
|
74
|
-
} else if (msg.role === "tool_call" && msg.toolCallId) {
|
|
75
|
-
const result = resultMap.get(msg.toolCallId);
|
|
76
|
-
items.push({
|
|
77
|
-
type: "tool",
|
|
78
|
-
id: msg._id,
|
|
79
|
-
toolCallId: msg.toolCallId,
|
|
80
|
-
toolName: msg.toolName ?? "",
|
|
81
|
-
params: tryParseJson(msg.content) as Record<string, unknown>,
|
|
82
|
-
result: result ? tryParseJson(result.content) : undefined,
|
|
83
|
-
calling: !resultIds.has(msg.toolCallId),
|
|
84
|
-
error: result?.isError ? result.content : undefined,
|
|
85
|
-
});
|
|
86
159
|
}
|
|
87
160
|
}
|
|
88
161
|
|
|
@@ -92,37 +165,109 @@ export function createChatComponent(
|
|
|
92
165
|
}
|
|
93
166
|
}, [historyLen, isStreaming]);
|
|
94
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
|
+
|
|
95
210
|
// ─── SSE event processing ───────────────────────────────────
|
|
211
|
+
const processEventRef = useRef<((event: ChatStreamEvent) => Promise<void>) | null>(null);
|
|
96
212
|
const processEvent = useCallback(
|
|
97
213
|
async (event: ChatStreamEvent) => {
|
|
98
|
-
const assistantId = currentAssistantIdRef.current;
|
|
99
|
-
|
|
100
214
|
switch (event.type) {
|
|
101
215
|
case "content_delta":
|
|
102
|
-
if (event.content
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
216
|
+
if (!event.content) break;
|
|
217
|
+
// Append to the trailing assistant text bubble. If there isn't
|
|
218
|
+
// one (e.g. just finished a tool, or no streaming bubble yet),
|
|
219
|
+
// create a new one. This produces correctly interleaved
|
|
220
|
+
// text/tool/text/tool ordering during streaming.
|
|
221
|
+
setTimeline((prev) => {
|
|
222
|
+
const last = prev[prev.length - 1];
|
|
223
|
+
if (
|
|
224
|
+
last &&
|
|
225
|
+
last.type === "message" &&
|
|
226
|
+
last.role === "assistant" &&
|
|
227
|
+
last.isStreaming
|
|
228
|
+
) {
|
|
229
|
+
return prev.map((item, i) =>
|
|
230
|
+
i === prev.length - 1 && item.type === "message"
|
|
106
231
|
? { ...item, content: item.content + event.content }
|
|
107
232
|
: item,
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
const newId = `assistant_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
236
|
+
currentAssistantIdRef.current = newId;
|
|
237
|
+
return [
|
|
238
|
+
...prev,
|
|
239
|
+
{
|
|
240
|
+
type: "message",
|
|
241
|
+
id: newId,
|
|
242
|
+
role: "assistant",
|
|
243
|
+
content: event.content!,
|
|
244
|
+
isStreaming: true,
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
});
|
|
111
248
|
break;
|
|
112
249
|
|
|
113
250
|
case "server_tool_start":
|
|
114
251
|
if (event.toolCall) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
252
|
+
// Finalize any streaming text bubble before the tool — next
|
|
253
|
+
// content_delta will start a fresh bubble after the tool.
|
|
254
|
+
setTimeline((prev) => {
|
|
255
|
+
const next = prev.map((item) =>
|
|
256
|
+
item.type === "message" && item.isStreaming
|
|
257
|
+
? { ...item, isStreaming: false }
|
|
258
|
+
: item,
|
|
259
|
+
);
|
|
260
|
+
next.push({
|
|
118
261
|
type: "tool",
|
|
119
262
|
id: `tc_${event.toolCall!.id}`,
|
|
120
263
|
toolCallId: event.toolCall!.id,
|
|
121
264
|
toolName: event.toolCall!.name,
|
|
122
265
|
params: event.toolCall!.arguments ?? {},
|
|
123
266
|
calling: true,
|
|
124
|
-
}
|
|
125
|
-
|
|
267
|
+
});
|
|
268
|
+
return next;
|
|
269
|
+
});
|
|
270
|
+
currentAssistantIdRef.current = null;
|
|
126
271
|
}
|
|
127
272
|
break;
|
|
128
273
|
|
|
@@ -145,50 +290,106 @@ export function createChatComponent(
|
|
|
145
290
|
|
|
146
291
|
case "interactive_tool_request":
|
|
147
292
|
if (event.toolCalls) {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
293
|
+
// Finalize current text bubble and append the interactive tool
|
|
294
|
+
// items. After this, the loop pauses until userResponded.
|
|
295
|
+
setTimeline((prev) => {
|
|
296
|
+
const next = prev.map((item) =>
|
|
297
|
+
item.type === "message" && item.isStreaming
|
|
298
|
+
? { ...item, isStreaming: false }
|
|
299
|
+
: item,
|
|
300
|
+
);
|
|
301
|
+
for (const tc of event.toolCalls!) {
|
|
302
|
+
next.push({
|
|
303
|
+
type: "tool",
|
|
304
|
+
id: `tc_${tc.id}`,
|
|
305
|
+
toolCallId: tc.id,
|
|
306
|
+
toolName: tc.name,
|
|
307
|
+
params: tc.arguments ?? {},
|
|
308
|
+
calling: true,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return next;
|
|
312
|
+
});
|
|
313
|
+
currentAssistantIdRef.current = null;
|
|
159
314
|
}
|
|
160
315
|
break;
|
|
161
316
|
|
|
162
317
|
case "done":
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
}
|
|
318
|
+
// Mark any trailing streaming bubble as done.
|
|
319
|
+
setTimeline((prev) =>
|
|
320
|
+
prev.map((item) =>
|
|
321
|
+
item.type === "message" && item.isStreaming
|
|
322
|
+
? { ...item, isStreaming: false }
|
|
323
|
+
: item,
|
|
324
|
+
),
|
|
325
|
+
);
|
|
172
326
|
setIsStreaming(false);
|
|
327
|
+
currentAssistantIdRef.current = null;
|
|
173
328
|
break;
|
|
174
329
|
|
|
175
330
|
case "error":
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
331
|
+
setTimeline((prev) => {
|
|
332
|
+
const last = prev[prev.length - 1];
|
|
333
|
+
if (
|
|
334
|
+
last &&
|
|
335
|
+
last.type === "message" &&
|
|
336
|
+
last.role === "assistant" &&
|
|
337
|
+
last.isStreaming
|
|
338
|
+
) {
|
|
339
|
+
return prev.map((item, i) =>
|
|
340
|
+
i === prev.length - 1 && item.type === "message"
|
|
341
|
+
? {
|
|
342
|
+
...item,
|
|
343
|
+
content: item.content || event.error || chatLabels.errorLabel,
|
|
344
|
+
isStreaming: false,
|
|
345
|
+
}
|
|
181
346
|
: item,
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return [
|
|
350
|
+
...prev,
|
|
351
|
+
{
|
|
352
|
+
type: "message",
|
|
353
|
+
id: `error_${Date.now()}`,
|
|
354
|
+
role: "assistant",
|
|
355
|
+
content: event.error || chatLabels.errorLabel,
|
|
356
|
+
},
|
|
357
|
+
];
|
|
358
|
+
});
|
|
185
359
|
setIsStreaming(false);
|
|
360
|
+
currentAssistantIdRef.current = null;
|
|
186
361
|
break;
|
|
187
362
|
}
|
|
188
363
|
},
|
|
189
|
-
[],
|
|
364
|
+
[chatLabels],
|
|
190
365
|
);
|
|
191
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
|
+
|
|
192
393
|
// ─── Send message ───────────────────────────────────────────
|
|
193
394
|
const handleSend = useCallback(
|
|
194
395
|
async (content: string, options: SendMessageOptions) => {
|
|
@@ -220,12 +421,11 @@ export function createChatComponent(
|
|
|
220
421
|
|
|
221
422
|
if (!response.ok) throw new Error(`Stream failed: ${response.status}`);
|
|
222
423
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
]);
|
|
424
|
+
// Don't pre-create an assistant bubble here — the SSE handler will
|
|
425
|
+
// create one lazily on the first content_delta. This way, if the
|
|
426
|
+
// model jumps straight to a tool call (no text), we don't render an
|
|
427
|
+
// empty bubble.
|
|
428
|
+
currentAssistantIdRef.current = null;
|
|
229
429
|
|
|
230
430
|
const reader = response.body!.getReader();
|
|
231
431
|
const decoder = new TextDecoder();
|
|
@@ -260,17 +460,17 @@ export function createChatComponent(
|
|
|
260
460
|
sessionIdRef.current = null;
|
|
261
461
|
currentAssistantIdRef.current = null;
|
|
262
462
|
} catch (err) {
|
|
263
|
-
const errorMsg = err instanceof Error ? err.message :
|
|
463
|
+
const errorMsg = err instanceof Error ? err.message : chatLabels.errorLabel;
|
|
264
464
|
setTimeline((prev) => [
|
|
265
465
|
...prev,
|
|
266
|
-
{ type: "message", id: `error_${Date.now()}`, role: "assistant", content:
|
|
466
|
+
{ type: "message", id: `error_${Date.now()}`, role: "assistant", content: `${chatLabels.errorLabel}: ${errorMsg}` },
|
|
267
467
|
]);
|
|
268
468
|
setIsStreaming(false);
|
|
269
469
|
sessionIdRef.current = null;
|
|
270
470
|
currentAssistantIdRef.current = null;
|
|
271
471
|
}
|
|
272
472
|
},
|
|
273
|
-
[isStreaming, scopeId, messageMutations, processEvent],
|
|
473
|
+
[isStreaming, scopeId, messageMutations, processEvent, chatLabels],
|
|
274
474
|
);
|
|
275
475
|
|
|
276
476
|
// ─── Build messages for Chat DS (only user/assistant) ───────
|
|
@@ -292,7 +492,7 @@ export function createChatComponent(
|
|
|
292
492
|
return createElement(ChatToolLog, {
|
|
293
493
|
calling: item.calling,
|
|
294
494
|
label: item.toolName,
|
|
295
|
-
}, item.calling ?
|
|
495
|
+
}, item.calling ? chatLabels.toolCallingLabel : item.error ?? chatLabels.toolDoneLabel);
|
|
296
496
|
}
|
|
297
497
|
|
|
298
498
|
if (tool.isServerTool) {
|
|
@@ -328,16 +528,12 @@ export function createChatComponent(
|
|
|
328
528
|
const { sessionId: newSessionId } = respondResult as { sessionId: string };
|
|
329
529
|
if (!newSessionId) return;
|
|
330
530
|
|
|
331
|
-
// Connect to new SSE stream for resumed generation
|
|
531
|
+
// Connect to new SSE stream for resumed generation. Don't pre-create
|
|
532
|
+
// an assistant bubble — the SSE handler creates one lazily on the
|
|
533
|
+
// first content_delta (same pattern as handleSend).
|
|
332
534
|
sessionIdRef.current = newSessionId;
|
|
333
535
|
setIsStreaming(true);
|
|
334
|
-
|
|
335
|
-
const assistantMsgId = `assistant_${Date.now()}`;
|
|
336
|
-
currentAssistantIdRef.current = assistantMsgId;
|
|
337
|
-
setTimeline((prev) => [
|
|
338
|
-
...prev,
|
|
339
|
-
{ type: "message", id: assistantMsgId, role: "assistant", content: "", isStreaming: true },
|
|
340
|
-
]);
|
|
536
|
+
currentAssistantIdRef.current = null;
|
|
341
537
|
|
|
342
538
|
try {
|
|
343
539
|
const streamUrl = `/route/chat/${chatName}/stream/${newSessionId}`;
|
|
@@ -374,13 +570,15 @@ export function createChatComponent(
|
|
|
374
570
|
} catch {}
|
|
375
571
|
}
|
|
376
572
|
} catch (err) {
|
|
377
|
-
setTimeline((prev) =>
|
|
378
|
-
prev
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
573
|
+
setTimeline((prev) => [
|
|
574
|
+
...prev,
|
|
575
|
+
{
|
|
576
|
+
type: "message",
|
|
577
|
+
id: `error_${Date.now()}`,
|
|
578
|
+
role: "assistant",
|
|
579
|
+
content: `${chatLabels.errorLabel}: ${err instanceof Error ? err.message : chatLabels.errorLabel}`,
|
|
580
|
+
},
|
|
581
|
+
]);
|
|
384
582
|
} finally {
|
|
385
583
|
setIsStreaming(false);
|
|
386
584
|
sessionIdRef.current = null;
|
|
@@ -435,16 +633,27 @@ export function createChatComponent(
|
|
|
435
633
|
"div",
|
|
436
634
|
{ className: "max-w-3xl mx-auto w-full space-y-4 flex-1" },
|
|
437
635
|
...timelineElements,
|
|
636
|
+
footer,
|
|
438
637
|
),
|
|
439
638
|
createElement(Chat, {
|
|
440
639
|
messages: [],
|
|
441
|
-
models: [{ value: "gpt-5.4-
|
|
442
|
-
defaultModel: "gpt-5.4-
|
|
640
|
+
models: [{ value: "gpt-5.4-mini", label: "GPT-5.4 Mini" }],
|
|
641
|
+
defaultModel: "gpt-5.4-mini",
|
|
443
642
|
onSend: handleSend,
|
|
643
|
+
showModelSelector,
|
|
644
|
+
showWebSearch,
|
|
645
|
+
renderSendButton,
|
|
444
646
|
disabled: isStreaming || hasWaitingInteractive,
|
|
445
647
|
}),
|
|
446
648
|
),
|
|
447
649
|
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return function ChatComponent(props: { scope: any; identifyBy: string }) {
|
|
653
|
+
return createElement(
|
|
654
|
+
ChatLabelsProvider,
|
|
655
|
+
{ labels, children: createElement(ChatComponentInner, props) },
|
|
656
|
+
);
|
|
448
657
|
};
|
|
449
658
|
}
|
|
450
659
|
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { useEffect } from "react";
|
|
2
2
|
import { tool } from "@arcote.tech/arc-ai";
|
|
3
3
|
import { string, array, object } from "@arcote.tech/arc";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ChatToolQuestion,
|
|
6
|
+
QuestionTabs,
|
|
7
|
+
useChatInput,
|
|
8
|
+
useChatLabels,
|
|
9
|
+
} from "@arcote.tech/arc-ds";
|
|
5
10
|
import type { Question, QuestionAnswers } from "@arcote.tech/arc-ds";
|
|
6
11
|
|
|
7
12
|
type AskQuestionsParams = {
|
|
8
|
-
readonly comment: string;
|
|
9
13
|
readonly questions: readonly {
|
|
10
14
|
readonly id: string;
|
|
11
15
|
readonly label: string;
|
|
@@ -14,6 +18,13 @@ type AskQuestionsParams = {
|
|
|
14
18
|
}[];
|
|
15
19
|
};
|
|
16
20
|
|
|
21
|
+
/** Result shape returned by respond() — includes a discuss flag. */
|
|
22
|
+
export interface AskQuestionsResult {
|
|
23
|
+
answers: QuestionAnswers;
|
|
24
|
+
/** User clicked "Continue discussion" instead of submitting answers. */
|
|
25
|
+
wantsToDiscuss: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
function AskQuestionsView({
|
|
18
29
|
params,
|
|
19
30
|
respond,
|
|
@@ -21,11 +32,12 @@ function AskQuestionsView({
|
|
|
21
32
|
result,
|
|
22
33
|
}: {
|
|
23
34
|
params: AskQuestionsParams;
|
|
24
|
-
respond: (result:
|
|
35
|
+
respond: (result: AskQuestionsResult) => void;
|
|
25
36
|
calling: boolean;
|
|
26
|
-
result?:
|
|
37
|
+
result?: AskQuestionsResult;
|
|
27
38
|
}) {
|
|
28
39
|
const { registerInputOverride, clearInputOverride } = useChatInput();
|
|
40
|
+
const { answerBelowLabel } = useChatLabels();
|
|
29
41
|
|
|
30
42
|
useEffect(() => {
|
|
31
43
|
if (calling) {
|
|
@@ -39,8 +51,8 @@ function AskQuestionsView({
|
|
|
39
51
|
registerInputOverride(
|
|
40
52
|
<QuestionTabs
|
|
41
53
|
questions={questions}
|
|
42
|
-
onSubmit={(answers) => {
|
|
43
|
-
respond(answers);
|
|
54
|
+
onSubmit={(answers, { wantsToDiscuss }) => {
|
|
55
|
+
respond({ answers, wantsToDiscuss });
|
|
44
56
|
clearInputOverride();
|
|
45
57
|
}}
|
|
46
58
|
/>,
|
|
@@ -52,14 +64,14 @@ function AskQuestionsView({
|
|
|
52
64
|
|
|
53
65
|
// Answered — show summary
|
|
54
66
|
if (!calling && result) {
|
|
55
|
-
const answers = result
|
|
67
|
+
const { answers } = result;
|
|
68
|
+
const entries = Object.entries(answers) as Array<
|
|
69
|
+
[string, { selected?: string[]; text?: string }]
|
|
70
|
+
>;
|
|
56
71
|
return (
|
|
57
72
|
<ChatToolQuestion calling={false}>
|
|
58
|
-
{params.comment && (
|
|
59
|
-
<p className="text-sm mb-2">{params.comment}</p>
|
|
60
|
-
)}
|
|
61
73
|
<div className="space-y-1.5">
|
|
62
|
-
{
|
|
74
|
+
{entries.map(([questionId, answer]) => {
|
|
63
75
|
const question = params.questions.find((q) => q.id === questionId);
|
|
64
76
|
const selected = answer?.selected?.length ? answer.selected.join(", ") : "";
|
|
65
77
|
const text = answer?.text || "";
|
|
@@ -79,22 +91,29 @@ function AskQuestionsView({
|
|
|
79
91
|
// Waiting for response
|
|
80
92
|
return (
|
|
81
93
|
<ChatToolQuestion calling={true}>
|
|
82
|
-
|
|
83
|
-
<p className="text-sm mb-1">{params.comment}</p>
|
|
84
|
-
)}
|
|
85
|
-
<p className="text-xs text-muted-foreground">
|
|
86
|
-
Odpowiedz na pytania poniżej
|
|
87
|
-
</p>
|
|
94
|
+
<p className="text-xs text-muted-foreground">{answerBelowLabel}</p>
|
|
88
95
|
</ChatToolQuestion>
|
|
89
96
|
);
|
|
90
97
|
}
|
|
91
98
|
|
|
92
99
|
export const askQuestions = tool("askQuestions")
|
|
93
100
|
.description(
|
|
94
|
-
"Zadaj użytkownikowi
|
|
101
|
+
"Zadaj użytkownikowi **zamknięte** pytanie z predefiniowanymi opcjami — " +
|
|
102
|
+
"gdy wiesz jakich konkretnie odpowiedzi się spodziewasz i możesz je " +
|
|
103
|
+
"wymienić jako 3–6 opcji (np. preferowany ton, zakres tematyczny, " +
|
|
104
|
+
"formalność). Każde pytanie ma id, label, description i tablicę opcji. " +
|
|
105
|
+
"Komentarz/insight napisz jako zwykły tekst PRZED wywołaniem toola — " +
|
|
106
|
+
"nie wkładaj komentarzy do parametrów.\n\n" +
|
|
107
|
+
"NIE używaj do otwartych pytań narracyjnych (np. 'opowiedz o swoim " +
|
|
108
|
+
"zespole', 'opisz przełomowy moment') — na te pytaj zwykłym tekstem " +
|
|
109
|
+
"w jednym zdaniu, użytkownik odpowie własnymi słowami.\n\n" +
|
|
110
|
+
"Tool result: `{ answers, wantsToDiscuss }`. Gdy `wantsToDiscuss === true` " +
|
|
111
|
+
"— user kliknął 'Kontynuuj rozmowę' zamiast odpowiadać. W TEJ turze NIE " +
|
|
112
|
+
"wolno wołać żadnego toola (ani askQuestions ani innego). Zwróć zwykły " +
|
|
113
|
+
"tekst: krótkie podsumowanie (2-3 zdania) + jedno open-ended pytanie " +
|
|
114
|
+
"w prozie.",
|
|
95
115
|
)
|
|
96
116
|
.withParams({
|
|
97
|
-
comment: string(),
|
|
98
117
|
questions: array(
|
|
99
118
|
object({
|
|
100
119
|
id: string(),
|