@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.
@@ -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 { chatName, tools, messageElementName } = config;
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
- return function ChatComponent({ scope, identifyBy }: { scope: any; identifyBy: string }) {
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
- if (msg.role === "user" || msg.role === "assistant") {
61
- if (msg.isGenerating && !msg.content) {
62
- if (msg.sessionId) sessionIdRef.current = msg.sessionId;
63
- hasActiveGeneration = msg.isGenerating === true;
64
- continue;
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: msg.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 && assistantId) {
103
- setTimeline((prev) =>
104
- prev.map((item) =>
105
- item.type === "message" && item.id === assistantId
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
- setTimeline((prev) => [
116
- ...prev,
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
- setTimeline((prev) => [
149
- ...prev,
150
- ...event.toolCalls!.map((tc) => ({
151
- type: "tool" as const,
152
- id: `tc_${tc.id}`,
153
- toolCallId: tc.id,
154
- toolName: tc.name,
155
- params: tc.arguments ?? {},
156
- calling: true,
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
- if (assistantId) {
164
- setTimeline((prev) =>
165
- prev.map((item) =>
166
- item.type === "message" && item.id === assistantId
167
- ? { ...item, isStreaming: false }
168
- : item,
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
- if (assistantId) {
177
- setTimeline((prev) =>
178
- prev.map((item) =>
179
- item.type === "message" && item.id === assistantId
180
- ? { ...item, content: item.content || event.error || "Error", isStreaming: false }
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
- const assistantMsgId = `assistant_${Date.now()}`;
224
- currentAssistantIdRef.current = assistantMsgId;
225
- setTimeline((prev) => [
226
- ...prev,
227
- { type: "message", id: assistantMsgId, role: "assistant", content: "", isStreaming: true },
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 : "Unknown error";
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: `Error: ${errorMsg}` },
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 ? "Wykonuję..." : item.error ?? "Gotowe");
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.map((t) =>
379
- t.type === "message" && t.id === assistantMsgId
380
- ? { ...t, content: `Error: ${err instanceof Error ? err.message : "Unknown"}`, isStreaming: false }
381
- : t,
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-nano", label: "GPT-5.4 Nano" }],
442
- defaultModel: "gpt-5.4-nano",
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 { ChatToolQuestion, QuestionTabs, useChatInput } from "@arcote.tech/arc-ds";
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: QuestionAnswers) => void;
35
+ respond: (result: AskQuestionsResult) => void;
25
36
  calling: boolean;
26
- result?: QuestionAnswers;
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 as Record<string, { selected?: string[]; text?: string }>;
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
- {Object.entries(answers).map(([questionId, answer]) => {
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
- {params.comment && (
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 pytania z predefiniowanymi odpowiedziami. ZAWSZE podaj comment krótki, entuzjastyczny komentarz do tego co użytkownik napisał.",
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(),