@arcote.tech/arc-chat 0.5.2 → 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.
@@ -1,12 +1,26 @@
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>;
10
24
  }
11
25
 
12
26
  type TimelineItem =
@@ -16,10 +30,19 @@ type TimelineItem =
16
30
  export function createChatComponent(
17
31
  config: ChatComponentConfig,
18
32
  ): ComponentType<{ scope: any; identifyBy: string }> {
19
- const { chatName, tools, messageElementName } = config;
33
+ const {
34
+ chatName,
35
+ tools,
36
+ messageElementName,
37
+ showModelSelector = true,
38
+ showWebSearch = true,
39
+ renderSendButton,
40
+ labels,
41
+ } = config;
20
42
  const toolsMap = new Map(tools.map((t) => [t.name, t]));
21
43
 
22
- return function ChatComponent({ scope, identifyBy }: { scope: any; identifyBy: string }) {
44
+ function ChatComponentInner({ scope, identifyBy }: { scope: any; identifyBy: string }) {
45
+ const chatLabels = useChatLabels();
23
46
  const [timeline, setTimeline] = useState<TimelineItem[]>([]);
24
47
  const [isStreaming, setIsStreaming] = useState(false);
25
48
  const sessionIdRef = useRef<string | null>(null);
@@ -57,32 +80,75 @@ export function createChatComponent(
57
80
  let hasActiveGeneration = false;
58
81
 
59
82
  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
- }
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") {
66
89
  items.push({
67
90
  type: "message",
68
91
  id: msg._id,
69
- role: msg.role,
70
- content: msg.content,
71
- isStreaming: msg.isGenerating === true,
92
+ role: "user",
93
+ content: msg.content ?? "",
72
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
+
73
151
  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
152
  }
87
153
  }
88
154
 
@@ -95,34 +161,63 @@ export function createChatComponent(
95
161
  // ─── SSE event processing ───────────────────────────────────
96
162
  const processEvent = useCallback(
97
163
  async (event: ChatStreamEvent) => {
98
- const assistantId = currentAssistantIdRef.current;
99
-
100
164
  switch (event.type) {
101
165
  case "content_delta":
102
- if (event.content && assistantId) {
103
- setTimeline((prev) =>
104
- prev.map((item) =>
105
- item.type === "message" && item.id === assistantId
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"
106
181
  ? { ...item, content: item.content + event.content }
107
182
  : item,
108
- ),
109
- );
110
- }
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
+ });
111
198
  break;
112
199
 
113
200
  case "server_tool_start":
114
201
  if (event.toolCall) {
115
- setTimeline((prev) => [
116
- ...prev,
117
- {
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({
118
211
  type: "tool",
119
212
  id: `tc_${event.toolCall!.id}`,
120
213
  toolCallId: event.toolCall!.id,
121
214
  toolName: event.toolCall!.name,
122
215
  params: event.toolCall!.arguments ?? {},
123
216
  calling: true,
124
- },
125
- ]);
217
+ });
218
+ return next;
219
+ });
220
+ currentAssistantIdRef.current = null;
126
221
  }
127
222
  break;
128
223
 
@@ -145,48 +240,78 @@ export function createChatComponent(
145
240
 
146
241
  case "interactive_tool_request":
147
242
  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
- ]);
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;
159
264
  }
160
265
  break;
161
266
 
162
267
  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
- }
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
+ );
172
276
  setIsStreaming(false);
277
+ currentAssistantIdRef.current = null;
173
278
  break;
174
279
 
175
280
  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 }
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
+ }
181
296
  : item,
182
- ),
183
- );
184
- }
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
+ });
185
309
  setIsStreaming(false);
310
+ currentAssistantIdRef.current = null;
186
311
  break;
187
312
  }
188
313
  },
189
- [],
314
+ [chatLabels],
190
315
  );
191
316
 
192
317
  // ─── Send message ───────────────────────────────────────────
@@ -220,12 +345,11 @@ export function createChatComponent(
220
345
 
221
346
  if (!response.ok) throw new Error(`Stream failed: ${response.status}`);
222
347
 
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
- ]);
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;
229
353
 
230
354
  const reader = response.body!.getReader();
231
355
  const decoder = new TextDecoder();
@@ -260,17 +384,17 @@ export function createChatComponent(
260
384
  sessionIdRef.current = null;
261
385
  currentAssistantIdRef.current = null;
262
386
  } catch (err) {
263
- const errorMsg = err instanceof Error ? err.message : "Unknown error";
387
+ const errorMsg = err instanceof Error ? err.message : chatLabels.errorLabel;
264
388
  setTimeline((prev) => [
265
389
  ...prev,
266
- { type: "message", id: `error_${Date.now()}`, role: "assistant", content: `Error: ${errorMsg}` },
390
+ { type: "message", id: `error_${Date.now()}`, role: "assistant", content: `${chatLabels.errorLabel}: ${errorMsg}` },
267
391
  ]);
268
392
  setIsStreaming(false);
269
393
  sessionIdRef.current = null;
270
394
  currentAssistantIdRef.current = null;
271
395
  }
272
396
  },
273
- [isStreaming, scopeId, messageMutations, processEvent],
397
+ [isStreaming, scopeId, messageMutations, processEvent, chatLabels],
274
398
  );
275
399
 
276
400
  // ─── Build messages for Chat DS (only user/assistant) ───────
@@ -292,7 +416,7 @@ export function createChatComponent(
292
416
  return createElement(ChatToolLog, {
293
417
  calling: item.calling,
294
418
  label: item.toolName,
295
- }, item.calling ? "Wykonuję..." : item.error ?? "Gotowe");
419
+ }, item.calling ? chatLabels.toolCallingLabel : item.error ?? chatLabels.toolDoneLabel);
296
420
  }
297
421
 
298
422
  if (tool.isServerTool) {
@@ -328,16 +452,12 @@ export function createChatComponent(
328
452
  const { sessionId: newSessionId } = respondResult as { sessionId: string };
329
453
  if (!newSessionId) return;
330
454
 
331
- // Connect to new SSE stream for resumed generation
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).
332
458
  sessionIdRef.current = newSessionId;
333
459
  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
- ]);
460
+ currentAssistantIdRef.current = null;
341
461
 
342
462
  try {
343
463
  const streamUrl = `/route/chat/${chatName}/stream/${newSessionId}`;
@@ -374,13 +494,15 @@ export function createChatComponent(
374
494
  } catch {}
375
495
  }
376
496
  } 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
- );
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
+ ]);
384
506
  } finally {
385
507
  setIsStreaming(false);
386
508
  sessionIdRef.current = null;
@@ -438,13 +560,23 @@ export function createChatComponent(
438
560
  ),
439
561
  createElement(Chat, {
440
562
  messages: [],
441
- models: [{ value: "gpt-5.4-nano", label: "GPT-5.4 Nano" }],
442
- defaultModel: "gpt-5.4-nano",
563
+ models: [{ value: "gpt-5.4-mini", label: "GPT-5.4 Mini" }],
564
+ defaultModel: "gpt-5.4-mini",
443
565
  onSend: handleSend,
566
+ showModelSelector,
567
+ showWebSearch,
568
+ renderSendButton,
444
569
  disabled: isStreaming || hasWaitingInteractive,
445
570
  }),
446
571
  ),
447
572
  );
573
+ }
574
+
575
+ return function ChatComponent(props: { scope: any; identifyBy: string }) {
576
+ return createElement(
577
+ ChatLabelsProvider,
578
+ { labels, children: createElement(ChatComponentInner, props) },
579
+ );
448
580
  };
449
581
  }
450
582
 
@@ -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(),