@arcote.tech/arc-chat 0.7.19 → 0.7.21
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/README.md +175 -101
- package/package.json +7 -7
- package/src/aggregates/message.ts +83 -2
- package/src/chat-builder.ts +1 -0
- package/src/listeners/ai-generation-listener.ts +18 -3
- package/src/ordering.test.ts +118 -0
- package/src/ordering.ts +88 -0
- package/src/react/chat-component.tsx +189 -770
- package/src/react/derive-timeline.test.ts +654 -0
- package/src/react/derive-timeline.ts +416 -0
- package/src/react/use-assistant-overlays.ts +269 -0
- package/src/routes/chat-stream-route.ts +19 -5
- package/src/streaming/blocks-reducer.test.ts +126 -0
- package/src/streaming/blocks-reducer.ts +88 -0
- package/src/streaming/stream-registry.test.ts +64 -0
- package/src/streaming/stream-registry.ts +21 -49
- package/src/tools/ask-questions.tsx +7 -4
- package/src/react/use-chat.ts +0 -1
|
@@ -1,40 +1,51 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
useCallback,
|
|
4
|
+
useMemo,
|
|
5
|
+
useEffect,
|
|
6
|
+
type ComponentType,
|
|
7
|
+
createElement,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
import type { ArcToolAny } from "@arcote.tech/arc-ai";
|
|
3
11
|
import type {
|
|
4
12
|
ChatInputTextareaSlotProps,
|
|
5
13
|
ChatLabels,
|
|
6
|
-
ChatMessageData,
|
|
7
14
|
SendMessageOptions,
|
|
8
15
|
} from "@arcote.tech/arc-ds";
|
|
9
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
Chat,
|
|
18
|
+
ChatMessage,
|
|
19
|
+
ChatInputProvider,
|
|
20
|
+
ChatLabelsProvider,
|
|
21
|
+
ChatToolLog,
|
|
22
|
+
useChatLabels,
|
|
23
|
+
} from "@arcote.tech/arc-ds";
|
|
10
24
|
import { VoiceTextarea } from "@arcote.tech/arc-ai-voice";
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
console.debug(`[arc-chat +${ts}ms]`, ...args);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
25
|
+
import {
|
|
26
|
+
deriveTimeline,
|
|
27
|
+
isPendingSendSettled,
|
|
28
|
+
type PendingSend,
|
|
29
|
+
type TimelineItem,
|
|
30
|
+
} from "./derive-timeline";
|
|
31
|
+
import { useAssistantOverlays } from "./use-assistant-overlays";
|
|
32
|
+
|
|
33
|
+
// ─── Architektura ────────────────────────────────────────────────
|
|
34
|
+
//
|
|
35
|
+
// Timeline jest CZYSTĄ FUNKCJĄ (deriveTimeline) dwóch źródeł:
|
|
36
|
+
// - DB przez liveQuery `getByScope` — źródło prawdy o strukturze
|
|
37
|
+
// (wiadomości, finalne blocks, isGenerating/interrupted/error,
|
|
38
|
+
// tool_results),
|
|
39
|
+
// - overlayów SSE (useAssistantOverlays) — ulotny in-progress stan
|
|
40
|
+
// generujących rzędów, budowany shared reducerem.
|
|
41
|
+
// Plus lokalny optimistic state (pendingSends / pendingToolResults /
|
|
42
|
+
// localErrors).
|
|
43
|
+
//
|
|
44
|
+
// NIE pisz do timeline'u imperatywnie. Każdy "merge" dwóch kanałów przez
|
|
45
|
+
// mutowalny stan + flagę trybu kończył się tu produkcyjnymi race'ami
|
|
46
|
+
// (watchdogi, backstopy, nonce — patrz git log). Derywacja liczy się
|
|
47
|
+
// zawsze od najnowszego stanu obu źródeł, więc kolejność dostarczenia
|
|
48
|
+
// (SSE `done` vs flip `isGenerating` w DB) jest bez znaczenia.
|
|
38
49
|
|
|
39
50
|
interface ChatComponentConfig {
|
|
40
51
|
chatName: string;
|
|
@@ -67,28 +78,6 @@ interface ChatComponentConfig {
|
|
|
67
78
|
footer?: ReactNode;
|
|
68
79
|
}
|
|
69
80
|
|
|
70
|
-
type ToolStatus = "pending" | "executing" | "complete" | "error";
|
|
71
|
-
|
|
72
|
-
type TimelineItem =
|
|
73
|
-
| { type: "message"; id: string; role: "user" | "assistant"; content: string; isStreaming?: boolean }
|
|
74
|
-
| {
|
|
75
|
-
type: "tool";
|
|
76
|
-
id: string; // = toolCallId, stabilne pomiędzy SSE i DB rebuild
|
|
77
|
-
toolCallId: string;
|
|
78
|
-
toolName: string;
|
|
79
|
-
params: Record<string, unknown>;
|
|
80
|
-
result?: unknown;
|
|
81
|
-
status: ToolStatus;
|
|
82
|
-
/** Legacy compat — true gdy status !== complete. Renderowane przez stary ChatToolLog. */
|
|
83
|
-
calling: boolean;
|
|
84
|
-
error?: string;
|
|
85
|
-
}
|
|
86
|
-
| {
|
|
87
|
-
type: "interrupted";
|
|
88
|
-
id: string;
|
|
89
|
-
messageId: string;
|
|
90
|
-
};
|
|
91
|
-
|
|
92
81
|
export function createChatComponent(
|
|
93
82
|
config: ChatComponentConfig,
|
|
94
83
|
): ComponentType<{ scope: any; identifyBy: string }> {
|
|
@@ -104,26 +93,29 @@ export function createChatComponent(
|
|
|
104
93
|
footer,
|
|
105
94
|
} = config;
|
|
106
95
|
const toolsMap = new Map(tools.map((t) => [t.name, t]));
|
|
107
|
-
|
|
108
|
-
|
|
96
|
+
const isServerTool = (toolName: string) =>
|
|
97
|
+
toolsMap.get(toolName)?.isServerTool === true;
|
|
98
|
+
|
|
99
|
+
function ChatComponentInner({
|
|
100
|
+
scope,
|
|
101
|
+
identifyBy,
|
|
102
|
+
}: {
|
|
103
|
+
scope: any;
|
|
104
|
+
identifyBy: string;
|
|
105
|
+
}) {
|
|
109
106
|
const chatLabels = useChatLabels();
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
*
|
|
121
|
-
const
|
|
122
|
-
/** Last SSE event type seen (for stuck telemetry — was `done` ever sent?). */
|
|
123
|
-
const lastSseEventRef = useRef<string | null>(null);
|
|
124
|
-
/** When the (isStreaming=true ∧ DB-idle) divergence first appeared — for
|
|
125
|
-
* the debounce window and the `waitedMs` stuck attribute. */
|
|
126
|
-
const stuckSinceRef = useRef<number | null>(null);
|
|
107
|
+
|
|
108
|
+
// ─── Lokalny optimistic state ─────────────────────────────────
|
|
109
|
+
const [pendingSends, setPendingSends] = useState<PendingSend[]>([]);
|
|
110
|
+
const [pendingToolResults, setPendingToolResults] = useState<
|
|
111
|
+
Map<string, unknown>
|
|
112
|
+
>(() => new Map());
|
|
113
|
+
const [localErrors, setLocalErrors] = useState<
|
|
114
|
+
Array<{ id: string; content: string }>
|
|
115
|
+
>([]);
|
|
116
|
+
/** Wymusza re-derywację co interwał gdy busy — klauzule busy mają
|
|
117
|
+
* staleness cutoff, który musi móc wygasnąć bez zmiany danych. */
|
|
118
|
+
const [busyTick, setBusyTick] = useState(0);
|
|
127
119
|
|
|
128
120
|
const queries = scope.useQuery();
|
|
129
121
|
const mutations = scope.useMutation();
|
|
@@ -131,641 +123,95 @@ export function createChatComponent(
|
|
|
131
123
|
const messageMutations = mutations[messageElementName];
|
|
132
124
|
|
|
133
125
|
const scopeId = identifyBy;
|
|
134
|
-
const historyResult =
|
|
135
|
-
|
|
136
|
-
|
|
126
|
+
const historyResult =
|
|
127
|
+
scopeId && messageQueries?.getByScope
|
|
128
|
+
? messageQueries.getByScope({ scopeId })
|
|
129
|
+
: [undefined, false];
|
|
137
130
|
const historyData = historyResult?.[0];
|
|
138
|
-
const historyLen = historyData?.length ?? 0;
|
|
139
131
|
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
// isGenerating to false), so the timeline-rebuild effect refires for
|
|
143
|
-
// both cases. `historyLen` alone misses updates that don't change count.
|
|
144
|
-
const historySig = useMemo(
|
|
132
|
+
// ─── Overlaye SSE per generujący row ──────────────────────────
|
|
133
|
+
const generatingIds = useMemo<string[]>(
|
|
145
134
|
() =>
|
|
146
|
-
historyData
|
|
147
|
-
|
|
135
|
+
(historyData ?? [])
|
|
136
|
+
.filter(
|
|
148
137
|
(m: any) =>
|
|
149
|
-
|
|
138
|
+
m.role === "assistant" && m.isGenerating && !m.interrupted,
|
|
150
139
|
)
|
|
151
|
-
.
|
|
140
|
+
.map((m: any) => m._id as string),
|
|
152
141
|
[historyData],
|
|
153
142
|
);
|
|
143
|
+
const overlays = useAssistantOverlays(chatName, generatingIds);
|
|
154
144
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
return null;
|
|
170
|
-
}, [historyData, interruptedIds]);
|
|
145
|
+
// ─── Derywacja ────────────────────────────────────────────────
|
|
146
|
+
const derived = useMemo(
|
|
147
|
+
() =>
|
|
148
|
+
deriveTimeline({
|
|
149
|
+
history: historyData,
|
|
150
|
+
overlays,
|
|
151
|
+
pendingSends,
|
|
152
|
+
pendingToolResults,
|
|
153
|
+
isServerTool,
|
|
154
|
+
now: Date.now(),
|
|
155
|
+
}),
|
|
156
|
+
[historyData, overlays, pendingSends, pendingToolResults, busyTick],
|
|
157
|
+
);
|
|
171
158
|
|
|
172
|
-
// ─── Restore timeline from DB history ───────────────────────
|
|
173
|
-
// Podczas streamingu SSE jest źródłem prawdy dla aktualnie generowanej
|
|
174
|
-
// wiadomości — rebuild byłby kolizją. Po `done` klient ustawia
|
|
175
|
-
// `isStreaming=false` i ten useEffect refireuje — zaaplikuje final
|
|
176
|
-
// blocks z `assistantTurnCompleted` projection (jedyny moment gdy treść
|
|
177
|
-
// assistant'a ląduje w DB).
|
|
178
159
|
useEffect(() => {
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const resultMap = new Map<string, { content: string; isError?: boolean }>();
|
|
184
|
-
for (const msg of historyData) {
|
|
185
|
-
if (msg.role === "tool_result" && msg.toolCallId) {
|
|
186
|
-
resultIds.add(msg.toolCallId);
|
|
187
|
-
resultMap.set(msg.toolCallId, { content: msg.content, isError: msg.isError });
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const items: TimelineItem[] = [];
|
|
192
|
-
|
|
193
|
-
for (const msg of historyData) {
|
|
194
|
-
// System messages are developer-injected priming prompts. They go
|
|
195
|
-
// to the LLM via buildHistory() but must not appear in the user's
|
|
196
|
-
// chat timeline.
|
|
197
|
-
if (msg.role === "system") continue;
|
|
198
|
-
|
|
199
|
-
if (msg.role === "user") {
|
|
200
|
-
items.push({
|
|
201
|
-
type: "message",
|
|
202
|
-
id: msg._id,
|
|
203
|
-
role: "user",
|
|
204
|
-
content: msg.content ?? "",
|
|
205
|
-
});
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (msg.role === "assistant") {
|
|
210
|
-
const renderBlocks = (
|
|
211
|
-
blocks: AssistantContentBlock[],
|
|
212
|
-
options: { isStreaming?: boolean } = {},
|
|
213
|
-
) => {
|
|
214
|
-
let textCount = 0;
|
|
215
|
-
for (const block of blocks) {
|
|
216
|
-
if (block.type === "text") {
|
|
217
|
-
if (block.text) {
|
|
218
|
-
items.push({
|
|
219
|
-
type: "message",
|
|
220
|
-
id: `${msg._id}_t${textCount}`,
|
|
221
|
-
role: "assistant",
|
|
222
|
-
content: block.text,
|
|
223
|
-
isStreaming: options.isStreaming,
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
textCount++;
|
|
227
|
-
} else {
|
|
228
|
-
const result = resultMap.get(block.id);
|
|
229
|
-
const hasResult = resultIds.has(block.id);
|
|
230
|
-
// Brak result w DB = tool wciąż w toku (server: executing,
|
|
231
|
-
// interactive: pending). `calling: true` w obu utrzymuje loader/input override.
|
|
232
|
-
const status: ToolStatus = result?.isError
|
|
233
|
-
? "error"
|
|
234
|
-
: hasResult
|
|
235
|
-
? "complete"
|
|
236
|
-
: options.isStreaming
|
|
237
|
-
? "executing"
|
|
238
|
-
: "pending";
|
|
239
|
-
items.push({
|
|
240
|
-
type: "tool",
|
|
241
|
-
id: block.id,
|
|
242
|
-
toolCallId: block.id,
|
|
243
|
-
toolName: block.name,
|
|
244
|
-
params: block.arguments,
|
|
245
|
-
result: result ? tryParseJson(result.content) : undefined,
|
|
246
|
-
status,
|
|
247
|
-
calling: !hasResult,
|
|
248
|
-
error: result?.isError ? result.content : undefined,
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
// Open turn (in progress). The row exists w DB z `isGenerating: true`
|
|
255
|
-
// ale BEZ `blocks` — final treść ląduje w DB dopiero po
|
|
256
|
-
// `assistantTurnCompleted`. Live wartość jest in-memory w stream-registry
|
|
257
|
-
// i zostanie pobrana przez SSE `init` event.
|
|
258
|
-
if (msg.isGenerating) {
|
|
259
|
-
if (interruptedIds.has(msg._id)) {
|
|
260
|
-
items.push({
|
|
261
|
-
type: "interrupted",
|
|
262
|
-
id: `${msg._id}_interrupted`,
|
|
263
|
-
messageId: msg._id,
|
|
264
|
-
});
|
|
265
|
-
} else {
|
|
266
|
-
items.push({
|
|
267
|
-
type: "message",
|
|
268
|
-
id: `${msg._id}_t0`,
|
|
269
|
-
role: "assistant",
|
|
270
|
-
content: "",
|
|
271
|
-
isStreaming: true,
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Closed turn — render z final blocks.
|
|
278
|
-
const blocks =
|
|
279
|
-
(tryParseJson(msg.blocks ?? "") as AssistantContentBlock[]) ?? [];
|
|
280
|
-
renderBlocks(blocks);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
setTimeline(items);
|
|
285
|
-
// Deps: `historySig` + `interruptedIds` + `reconcileNonce`. Wciąż BEZ
|
|
286
|
-
// `isStreaming` — gdyby było w deps: po `done` SSE flips isStreaming=false →
|
|
287
|
-
// refire → może zobaczyć STARĄ `isGenerating:1` (DB jeszcze nie
|
|
288
|
-
// zaprojektowała assistantTurnCompleted) → reset do streaming → caret nie
|
|
289
|
-
// znika. `reconcileNonce` (bumpowany przez watchdog) wymusza rebuild w
|
|
290
|
-
// odwrotnym race (query-update wyprzedził SSE `done` → historySig zmienił
|
|
291
|
-
// się PODCZAS isStreaming=true → rebuild pominięty guardem i bez nonce
|
|
292
|
-
// nigdy nie nadrobiony → produkcyjny hang).
|
|
293
|
-
}, [historySig, interruptedIds, reconcileNonce]);
|
|
160
|
+
if (!derived.busy) return;
|
|
161
|
+
const id = setInterval(() => setBusyTick((t) => t + 1), 10_000);
|
|
162
|
+
return () => clearInterval(id);
|
|
163
|
+
}, [derived.busy]);
|
|
294
164
|
|
|
295
|
-
//
|
|
296
|
-
// this shows the channel skew: who arrives first, and how far apart.
|
|
165
|
+
// ─── GC optimistic state potwierdzonego przez liveQuery ───────
|
|
297
166
|
useEffect(() => {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
167
|
+
setPendingSends((prev) => {
|
|
168
|
+
const next = prev.filter((p) => !isPendingSendSettled(historyData, p));
|
|
169
|
+
return next.length === prev.length ? prev : next;
|
|
301
170
|
});
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
// ─── Reconcile watchdog — DB is the arbiter of "is a turn running" ──
|
|
305
|
-
// Production hang: SSE `done` and the DB `assistantTurnCompleted` projection
|
|
306
|
-
// travel on separate channels (SSE vs WS query sync). When the query-update
|
|
307
|
-
// (`isGenerating→false`) wins the race while `isStreaming` is still true — or
|
|
308
|
-
// when `done` is lost entirely (410 exhaustion / server restart) — the
|
|
309
|
-
// rebuild guard skips and never retries, so the chat sticks in a streaming
|
|
310
|
-
// state until a page refresh.
|
|
311
|
-
//
|
|
312
|
-
// DB truth: `activeGeneratingMessageId === null` ⇒ no turn is generating. If
|
|
313
|
-
// we still believe we're streaming after a short debounce, force-close the
|
|
314
|
-
// turn: abort the (dead) SSE, drop `isStreaming`, and bump `reconcileNonce`
|
|
315
|
-
// so the rebuild runs with the final DB blocks. The debounce avoids
|
|
316
|
-
// flicker in the brief between-turns window (old turn done, next not started
|
|
317
|
-
// yet). No false fires during a real stream — the DB then holds
|
|
318
|
-
// `isGenerating:1`, so `activeGeneratingMessageId` is non-null.
|
|
319
|
-
useEffect(() => {
|
|
320
|
-
if (!(isStreaming && activeGeneratingMessageId === null)) {
|
|
321
|
-
stuckSinceRef.current = null;
|
|
322
|
-
return;
|
|
323
|
-
}
|
|
324
|
-
if (stuckSinceRef.current === null) stuckSinceRef.current = Date.now();
|
|
325
|
-
const since = stuckSinceRef.current;
|
|
326
|
-
const RECONCILE_DEBOUNCE_MS = 200;
|
|
327
|
-
const timer = setTimeout(() => {
|
|
328
|
-
reportReconcileStuck({
|
|
329
|
-
scopeId,
|
|
330
|
-
chatName,
|
|
331
|
-
lastSseEvent: lastSseEventRef.current,
|
|
332
|
-
waitedMs: Date.now() - since,
|
|
333
|
-
});
|
|
334
|
-
sseCtrlRef.current?.abort();
|
|
335
|
-
sseCtrlRef.current = null;
|
|
336
|
-
stuckSinceRef.current = null;
|
|
337
|
-
setIsStreaming(false);
|
|
338
|
-
setReconcileNonce((n) => n + 1);
|
|
339
|
-
}, RECONCILE_DEBOUNCE_MS);
|
|
340
|
-
return () => clearTimeout(timer);
|
|
341
|
-
}, [isStreaming, activeGeneratingMessageId, scopeId]);
|
|
342
|
-
|
|
343
|
-
// ─── Backstop: merge interactive-tool answers regardless of isStreaming ──
|
|
344
|
-
// Answering an interactive tool (askQuestions) creates a `tool_result` row
|
|
345
|
-
// AND starts a new assistant turn — so `isStreaming` flips back to true and
|
|
346
|
-
// the global rebuild guard freezes the whole timeline. Without this, the
|
|
347
|
-
// answered question stays in input-view ("Odpowiedz na pytania" + the
|
|
348
|
-
// questions) instead of the answer-view, until that next turn ends — and on
|
|
349
|
-
// prod its SSE `done` is often lost (the turn finalizes after the DB
|
|
350
|
-
// projection that nulls `activeGeneratingMessageId` aborts the stream), so
|
|
351
|
-
// it never catches up. This flips any `calling` tool block to answer-view
|
|
352
|
-
// the moment its `tool_result` lands in the DB, bypassing the guard.
|
|
353
|
-
useEffect(() => {
|
|
354
|
-
if (!historyData) return;
|
|
355
|
-
const resultMap = new Map<string, { content: string; isError?: boolean }>();
|
|
356
|
-
for (const msg of historyData) {
|
|
357
|
-
if (msg.role === "tool_result" && msg.toolCallId) {
|
|
358
|
-
resultMap.set(msg.toolCallId, {
|
|
359
|
-
content: msg.content,
|
|
360
|
-
isError: msg.isError,
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
if (resultMap.size === 0) return;
|
|
365
|
-
setTimeline((prev) => {
|
|
171
|
+
setPendingToolResults((prev) => {
|
|
172
|
+
if (prev.size === 0) return prev;
|
|
366
173
|
let changed = false;
|
|
367
|
-
const next = prev
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
};
|
|
379
|
-
});
|
|
174
|
+
const next = new Map(prev);
|
|
175
|
+
for (const msg of historyData ?? []) {
|
|
176
|
+
if (
|
|
177
|
+
msg.role === "tool_result" &&
|
|
178
|
+
msg.toolCallId &&
|
|
179
|
+
next.has(msg.toolCallId)
|
|
180
|
+
) {
|
|
181
|
+
next.delete(msg.toolCallId);
|
|
182
|
+
changed = true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
380
185
|
return changed ? next : prev;
|
|
381
186
|
});
|
|
382
|
-
}, [
|
|
383
|
-
|
|
384
|
-
// ─── SSE event processing ───────────────────────────────────
|
|
385
|
-
const processEventRef = useRef<((event: ChatStreamEvent) => void) | null>(null);
|
|
386
|
-
const processEvent = useCallback(
|
|
387
|
-
(event: ChatStreamEvent) => {
|
|
388
|
-
lastSseEventRef.current = event.type;
|
|
389
|
-
chatDebug("sse event", event.type);
|
|
390
|
-
switch (event.type) {
|
|
391
|
-
case "init": {
|
|
392
|
-
// Pierwszy event po `subscribe(messageId)`. Niesie snapshot
|
|
393
|
-
// in-memory `currentBlocks` — może być pusty (świeży stream) albo
|
|
394
|
-
// wypełniony (graceful reconnect mid-stream). Hydratuje bubble
|
|
395
|
-
// assistant'a.
|
|
396
|
-
if (!event.messageId) break;
|
|
397
|
-
const messageId = event.messageId;
|
|
398
|
-
const blocks = event.currentBlocks ?? [];
|
|
399
|
-
setTimeline((prev) => {
|
|
400
|
-
// Wytnij placeholdery i istniejące bubble assistantów dla tego
|
|
401
|
-
// messageId (z timeline rebuild) — zastąpimy świeżą wersją.
|
|
402
|
-
const cleaned = prev.filter(
|
|
403
|
-
(it) =>
|
|
404
|
-
!(
|
|
405
|
-
it.type === "message" &&
|
|
406
|
-
typeof it.id === "string" &&
|
|
407
|
-
it.id.startsWith(`${messageId}_t`)
|
|
408
|
-
) &&
|
|
409
|
-
!(it.type === "tool" && blocks.some((b) => b.type === "tool_call" && b.id === it.id)),
|
|
410
|
-
);
|
|
411
|
-
let textCount = 0;
|
|
412
|
-
const newItems: TimelineItem[] = [];
|
|
413
|
-
for (const block of blocks) {
|
|
414
|
-
if (block.type === "text") {
|
|
415
|
-
newItems.push({
|
|
416
|
-
type: "message",
|
|
417
|
-
id: `${messageId}_t${textCount}`,
|
|
418
|
-
role: "assistant",
|
|
419
|
-
content: block.text,
|
|
420
|
-
isStreaming: true,
|
|
421
|
-
});
|
|
422
|
-
textCount++;
|
|
423
|
-
} else {
|
|
424
|
-
newItems.push({
|
|
425
|
-
type: "tool",
|
|
426
|
-
id: block.id,
|
|
427
|
-
toolCallId: block.id,
|
|
428
|
-
toolName: block.name,
|
|
429
|
-
params: block.arguments,
|
|
430
|
-
status: "executing",
|
|
431
|
-
calling: true,
|
|
432
|
-
});
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
// Jeśli init przyniósł 0 text blocks → wciąż dodaj pusty
|
|
436
|
-
// streaming placeholder, żeby text_delta miał gdzie dolepić.
|
|
437
|
-
if (!newItems.some((it) => it.type === "message" && it.role === "assistant")) {
|
|
438
|
-
newItems.push({
|
|
439
|
-
type: "message",
|
|
440
|
-
id: `${messageId}_t0`,
|
|
441
|
-
role: "assistant",
|
|
442
|
-
content: "",
|
|
443
|
-
isStreaming: true,
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
return [...cleaned, ...newItems];
|
|
447
|
-
});
|
|
448
|
-
break;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
case "text_delta":
|
|
452
|
-
if (!event.textDelta) break;
|
|
453
|
-
setTimeline((prev) => {
|
|
454
|
-
const last = prev[prev.length - 1];
|
|
455
|
-
const willAppend = !!(
|
|
456
|
-
last &&
|
|
457
|
-
last.type === "message" &&
|
|
458
|
-
last.role === "assistant" &&
|
|
459
|
-
last.isStreaming
|
|
460
|
-
);
|
|
461
|
-
if (willAppend) {
|
|
462
|
-
return prev.map((item, i) =>
|
|
463
|
-
i === prev.length - 1 && item.type === "message"
|
|
464
|
-
? { ...item, content: item.content + event.textDelta }
|
|
465
|
-
: item,
|
|
466
|
-
);
|
|
467
|
-
}
|
|
468
|
-
const newId = event.messageId
|
|
469
|
-
? `${event.messageId}_streaming_${prev.length}`
|
|
470
|
-
: `assistant_${Date.now()}`;
|
|
471
|
-
return [
|
|
472
|
-
...prev,
|
|
473
|
-
{
|
|
474
|
-
type: "message",
|
|
475
|
-
id: newId,
|
|
476
|
-
role: "assistant",
|
|
477
|
-
content: event.textDelta!,
|
|
478
|
-
isStreaming: true,
|
|
479
|
-
},
|
|
480
|
-
];
|
|
481
|
-
});
|
|
482
|
-
break;
|
|
187
|
+
}, [historyData]);
|
|
483
188
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
if (prev.some((it) => it.type === "tool" && it.id === event.toolCallId)) {
|
|
495
|
-
return prev;
|
|
496
|
-
}
|
|
497
|
-
const next = prev.map((item) =>
|
|
498
|
-
item.type === "message" && item.isStreaming
|
|
499
|
-
? { ...item, isStreaming: false }
|
|
500
|
-
: item,
|
|
501
|
-
);
|
|
502
|
-
next.push({
|
|
503
|
-
type: "tool",
|
|
504
|
-
id: event.toolCallId!,
|
|
505
|
-
toolCallId: event.toolCallId!,
|
|
506
|
-
toolName: event.toolCallName ?? "",
|
|
507
|
-
params: {},
|
|
508
|
-
status: "pending",
|
|
509
|
-
calling: true,
|
|
510
|
-
});
|
|
511
|
-
return next;
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
break;
|
|
515
|
-
|
|
516
|
-
case "tool_call_arguments_delta":
|
|
517
|
-
// Args lecą znak po znaku — UI nie potrzebuje tej granularności.
|
|
518
|
-
break;
|
|
519
|
-
|
|
520
|
-
case "tool_call_arguments_complete":
|
|
521
|
-
// Pełne args dostępne. Trzy przypadki:
|
|
522
|
-
// 1. Server tool już jest w timeline (od tool_call_pending) → update.
|
|
523
|
-
// 2. Interactive tool jeszcze nie jest w timeline → ADD tutaj.
|
|
524
|
-
// 3. Tool już dodany przez interactive_tool_request → no-op.
|
|
525
|
-
if (event.toolCallId) {
|
|
526
|
-
setTimeline((prev) => {
|
|
527
|
-
const existing = prev.find(
|
|
528
|
-
(it): it is Extract<TimelineItem, { type: "tool" }> =>
|
|
529
|
-
it.type === "tool" && it.toolCallId === event.toolCallId,
|
|
530
|
-
);
|
|
531
|
-
if (existing) {
|
|
532
|
-
return prev.map((item) =>
|
|
533
|
-
item.type === "tool" && item.toolCallId === event.toolCallId
|
|
534
|
-
? {
|
|
535
|
-
...item,
|
|
536
|
-
params: event.arguments ?? item.params,
|
|
537
|
-
status: "executing",
|
|
538
|
-
calling: true,
|
|
539
|
-
toolName: event.toolCallName ?? item.toolName,
|
|
540
|
-
}
|
|
541
|
-
: item,
|
|
542
|
-
);
|
|
543
|
-
}
|
|
544
|
-
const next = prev.map((item) =>
|
|
545
|
-
item.type === "message" && item.isStreaming
|
|
546
|
-
? { ...item, isStreaming: false }
|
|
547
|
-
: item,
|
|
548
|
-
);
|
|
549
|
-
next.push({
|
|
550
|
-
type: "tool",
|
|
551
|
-
id: event.toolCallId!,
|
|
552
|
-
toolCallId: event.toolCallId!,
|
|
553
|
-
toolName: event.toolCallName ?? "",
|
|
554
|
-
params: event.arguments ?? {},
|
|
555
|
-
status: "pending",
|
|
556
|
-
calling: true,
|
|
557
|
-
});
|
|
558
|
-
return next;
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
break;
|
|
562
|
-
|
|
563
|
-
case "tool_call_executed":
|
|
564
|
-
if (event.toolResult) {
|
|
565
|
-
setTimeline((prev) =>
|
|
566
|
-
prev.map((item) =>
|
|
567
|
-
item.type === "tool" && item.toolCallId === event.toolResult!.toolCallId
|
|
568
|
-
? {
|
|
569
|
-
...item,
|
|
570
|
-
result: tryParseJson(event.toolResult!.content),
|
|
571
|
-
status: event.toolResult!.isError ? "error" : "complete",
|
|
572
|
-
calling: false,
|
|
573
|
-
error: event.toolResult!.isError ? event.toolResult!.content : undefined,
|
|
574
|
-
}
|
|
575
|
-
: item,
|
|
576
|
-
),
|
|
577
|
-
);
|
|
578
|
-
}
|
|
579
|
-
break;
|
|
580
|
-
|
|
581
|
-
case "interactive_tool_request":
|
|
582
|
-
if (event.toolCalls) {
|
|
583
|
-
setTimeline((prev) => {
|
|
584
|
-
const byId = new Map(
|
|
585
|
-
prev
|
|
586
|
-
.filter((it) => it.type === "tool")
|
|
587
|
-
.map((it) => [(it as any).id, it]),
|
|
588
|
-
);
|
|
589
|
-
const next = prev.map((item) =>
|
|
590
|
-
item.type === "message" && item.isStreaming
|
|
591
|
-
? { ...item, isStreaming: false }
|
|
592
|
-
: item,
|
|
593
|
-
);
|
|
594
|
-
for (const tc of event.toolCalls!) {
|
|
595
|
-
const existing = byId.get(tc.id);
|
|
596
|
-
if (existing) {
|
|
597
|
-
const idx = next.findIndex(
|
|
598
|
-
(it) => it.type === "tool" && (it as any).id === tc.id,
|
|
599
|
-
);
|
|
600
|
-
if (idx >= 0) {
|
|
601
|
-
next[idx] = {
|
|
602
|
-
...(next[idx] as any),
|
|
603
|
-
toolName: tc.name || (next[idx] as any).toolName,
|
|
604
|
-
params: tc.arguments ?? (next[idx] as any).params,
|
|
605
|
-
};
|
|
606
|
-
}
|
|
607
|
-
continue;
|
|
608
|
-
}
|
|
609
|
-
next.push({
|
|
610
|
-
type: "tool",
|
|
611
|
-
id: tc.id,
|
|
612
|
-
toolCallId: tc.id,
|
|
613
|
-
toolName: tc.name,
|
|
614
|
-
params: tc.arguments ?? {},
|
|
615
|
-
status: "pending",
|
|
616
|
-
calling: true,
|
|
617
|
-
});
|
|
618
|
-
}
|
|
619
|
-
return next;
|
|
620
|
-
});
|
|
621
|
-
}
|
|
622
|
-
break;
|
|
623
|
-
|
|
624
|
-
case "done":
|
|
625
|
-
// Stream zakończył turn. setIsStreaming(false) odpali timeline
|
|
626
|
-
// rebuild z DB — final blocks z `assistantTurnCompleted` projection
|
|
627
|
-
// zastąpią streaming bubble.
|
|
628
|
-
setTimeline((prev) =>
|
|
629
|
-
prev.map((item) =>
|
|
630
|
-
item.type === "message" && item.isStreaming
|
|
631
|
-
? { ...item, isStreaming: false }
|
|
632
|
-
: item,
|
|
633
|
-
),
|
|
634
|
-
);
|
|
635
|
-
setIsStreaming(false);
|
|
636
|
-
break;
|
|
637
|
-
|
|
638
|
-
case "error":
|
|
639
|
-
setTimeline((prev) => {
|
|
640
|
-
const last = prev[prev.length - 1];
|
|
641
|
-
if (
|
|
642
|
-
last &&
|
|
643
|
-
last.type === "message" &&
|
|
644
|
-
last.role === "assistant" &&
|
|
645
|
-
last.isStreaming
|
|
646
|
-
) {
|
|
647
|
-
return prev.map((item, i) =>
|
|
648
|
-
i === prev.length - 1 && item.type === "message"
|
|
649
|
-
? {
|
|
650
|
-
...item,
|
|
651
|
-
content: item.content || event.error || chatLabels.errorLabel,
|
|
652
|
-
isStreaming: false,
|
|
653
|
-
}
|
|
654
|
-
: item,
|
|
655
|
-
);
|
|
656
|
-
}
|
|
657
|
-
return [
|
|
658
|
-
...prev,
|
|
659
|
-
{
|
|
660
|
-
type: "message",
|
|
661
|
-
id: `error_${Date.now()}`,
|
|
662
|
-
role: "assistant",
|
|
663
|
-
content: event.error || chatLabels.errorLabel,
|
|
664
|
-
},
|
|
665
|
-
];
|
|
666
|
-
});
|
|
667
|
-
setIsStreaming(false);
|
|
668
|
-
break;
|
|
669
|
-
}
|
|
189
|
+
const pushLocalError = useCallback(
|
|
190
|
+
(err: unknown) => {
|
|
191
|
+
const msg = err instanceof Error ? err.message : chatLabels.errorLabel;
|
|
192
|
+
setLocalErrors((prev) => [
|
|
193
|
+
...prev,
|
|
194
|
+
{
|
|
195
|
+
id: `error_${Date.now()}_${prev.length}`,
|
|
196
|
+
content: `${chatLabels.errorLabel}: ${msg}`,
|
|
197
|
+
},
|
|
198
|
+
]);
|
|
670
199
|
},
|
|
671
200
|
[chatLabels],
|
|
672
201
|
);
|
|
673
|
-
processEventRef.current = processEvent;
|
|
674
|
-
|
|
675
|
-
// ─── Auto-subscribe SSE per active assistant messageId ──────
|
|
676
|
-
// Driven przez DB history: za każdym razem gdy w DB pojawia się
|
|
677
|
-
// assistant row z `isGenerating=true`, otwieramy do niego SSE.
|
|
678
|
-
// Wszystkie scenariusze (send, respond, retry, page reload mid-stream)
|
|
679
|
-
// przechodzą przez ten sam mechanizm — handleSend/respond/retry tylko
|
|
680
|
-
// wywołują mutację, ten effect załapie nowy generating row z DB query
|
|
681
|
-
// update.
|
|
682
|
-
useEffect(() => {
|
|
683
|
-
if (!activeGeneratingMessageId) return;
|
|
684
|
-
const messageId = activeGeneratingMessageId;
|
|
685
|
-
const ctrl = new AbortController();
|
|
686
|
-
sseCtrlRef.current = ctrl;
|
|
687
|
-
lastSseEventRef.current = null;
|
|
688
|
-
let cancelled = false;
|
|
689
|
-
setIsStreaming(true);
|
|
690
|
-
chatDebug("sse open", { messageId });
|
|
691
202
|
|
|
692
|
-
|
|
693
|
-
try {
|
|
694
|
-
// 410 = brak in-memory streamu dla messageId. Serwer tworzy stream
|
|
695
|
-
// synchronicznie ze startem turnu (listener przed 1. awaitem), więc
|
|
696
|
-
// race "GET przed startStream" jest zamknięty — ale zostaje krótki
|
|
697
|
-
// residualny race i okno restartu serwera. Ponów kilka razy z
|
|
698
|
-
// backoffem zanim uznasz turn za przerwany: startStream / grace
|
|
699
|
-
// window zwykle dogania w tym czasie.
|
|
700
|
-
let res: Response | null = null;
|
|
701
|
-
const MAX_410_RETRIES = 4;
|
|
702
|
-
const RETRY_DELAY_MS = 300;
|
|
703
|
-
for (let attempt = 0; ; attempt++) {
|
|
704
|
-
res = await fetch(`/route/chat/${chatName}/stream/${messageId}`, {
|
|
705
|
-
credentials: "include",
|
|
706
|
-
signal: ctrl.signal,
|
|
707
|
-
headers: { Accept: "text/event-stream" },
|
|
708
|
-
});
|
|
709
|
-
if (res.status !== 410) break;
|
|
710
|
-
if (attempt >= MAX_410_RETRIES) {
|
|
711
|
-
// Naprawdę nieosiągalny (restart mid-stream / poza grace window).
|
|
712
|
-
setInterruptedIds((prev) => new Set(prev).add(messageId));
|
|
713
|
-
setIsStreaming(false);
|
|
714
|
-
return;
|
|
715
|
-
}
|
|
716
|
-
await new Promise<void>((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
717
|
-
if (cancelled) return;
|
|
718
|
-
}
|
|
719
|
-
if (!res.ok) throw new Error(`Stream failed: ${res.status}`);
|
|
720
|
-
|
|
721
|
-
const reader = res.body!.getReader();
|
|
722
|
-
const decoder = new TextDecoder();
|
|
723
|
-
let buf = "";
|
|
724
|
-
while (!cancelled) {
|
|
725
|
-
const { value, done } = await reader.read();
|
|
726
|
-
if (done) break;
|
|
727
|
-
buf += decoder.decode(value, { stream: true });
|
|
728
|
-
const lines = buf.split("\n");
|
|
729
|
-
buf = lines.pop() ?? "";
|
|
730
|
-
for (const line of lines) {
|
|
731
|
-
if (!line.startsWith("data: ")) continue;
|
|
732
|
-
try {
|
|
733
|
-
const event = JSON.parse(line.slice(6)) as ChatStreamEvent;
|
|
734
|
-
processEventRef.current?.(event);
|
|
735
|
-
// Yield between events in the same TCP chunk — bez tego
|
|
736
|
-
// React batchuje setTimeline w jeden render, streaming
|
|
737
|
-
// niewidoczny.
|
|
738
|
-
await new Promise<void>((r) => setTimeout(r, 0));
|
|
739
|
-
} catch {}
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
} catch (err) {
|
|
743
|
-
if ((err as any)?.name === "AbortError") return;
|
|
744
|
-
// Network glitch / SSE hang — treat as interrupted.
|
|
745
|
-
setInterruptedIds((prev) => new Set(prev).add(messageId));
|
|
746
|
-
setIsStreaming(false);
|
|
747
|
-
}
|
|
748
|
-
})();
|
|
749
|
-
|
|
750
|
-
return () => {
|
|
751
|
-
cancelled = true;
|
|
752
|
-
ctrl.abort();
|
|
753
|
-
if (sseCtrlRef.current === ctrl) sseCtrlRef.current = null;
|
|
754
|
-
};
|
|
755
|
-
}, [activeGeneratingMessageId]);
|
|
756
|
-
|
|
757
|
-
// ─── Send message ───────────────────────────────────────────
|
|
203
|
+
// ─── Send message ─────────────────────────────────────────────
|
|
758
204
|
const handleSend = useCallback(
|
|
759
205
|
async (content: string, options: SendMessageOptions) => {
|
|
760
|
-
if (
|
|
761
|
-
|
|
762
|
-
const
|
|
763
|
-
|
|
206
|
+
if (!scopeId) return;
|
|
207
|
+
if (derived.busy || derived.hasWaitingInteractive) return;
|
|
208
|
+
const localId = `pending_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
209
|
+
setPendingSends((prev) => [
|
|
764
210
|
...prev,
|
|
765
|
-
{
|
|
211
|
+
{ localId, content, sentAt: Date.now() },
|
|
766
212
|
]);
|
|
767
213
|
try {
|
|
768
|
-
await messageMutations.sendMessage({
|
|
214
|
+
const res = await messageMutations.sendMessage({
|
|
769
215
|
scopeId,
|
|
770
216
|
content,
|
|
771
217
|
model: options.model,
|
|
@@ -773,66 +219,54 @@ export function createChatComponent(
|
|
|
773
219
|
? { attachmentsJson: JSON.stringify(options.attachments) }
|
|
774
220
|
: {}),
|
|
775
221
|
});
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
222
|
+
const dbId = (res as any)?.messageId;
|
|
223
|
+
if (dbId) {
|
|
224
|
+
setPendingSends((prev) =>
|
|
225
|
+
prev.map((p) => (p.localId === localId ? { ...p, dbId } : p)),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
// Reszta dzieje się sama: mutacja emituje `assistantTurnStarted` →
|
|
229
|
+
// liveQuery pushuje generujący row → useAssistantOverlays otwiera
|
|
230
|
+
// SSE → deriveTimeline renderuje overlay.
|
|
780
231
|
} catch (err) {
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
...prev,
|
|
784
|
-
{ type: "message", id: `error_${Date.now()}`, role: "assistant", content: `${chatLabels.errorLabel}: ${errorMsg}` },
|
|
785
|
-
]);
|
|
786
|
-
setIsStreaming(false);
|
|
232
|
+
setPendingSends((prev) => prev.filter((p) => p.localId !== localId));
|
|
233
|
+
pushLocalError(err);
|
|
787
234
|
}
|
|
788
235
|
},
|
|
789
|
-
[
|
|
236
|
+
[scopeId, messageMutations, pushLocalError, derived.busy, derived.hasWaitingInteractive],
|
|
790
237
|
);
|
|
791
238
|
|
|
792
|
-
// ─── Retry interrupted generation
|
|
239
|
+
// ─── Retry interrupted generation ─────────────────────────────
|
|
793
240
|
const handleRetry = useCallback(
|
|
794
241
|
async (messageId: string) => {
|
|
795
242
|
if (!scopeId) return;
|
|
796
243
|
try {
|
|
797
244
|
await messageMutations.retryGeneration({ messageId });
|
|
798
|
-
// Mutacja usuwa interrupted row + tworzy fresh
|
|
799
|
-
//
|
|
800
|
-
setInterruptedIds((prev) => {
|
|
801
|
-
const next = new Set(prev);
|
|
802
|
-
next.delete(messageId);
|
|
803
|
-
return next;
|
|
804
|
-
});
|
|
245
|
+
// Mutacja usuwa interrupted row + tworzy fresh generujący row —
|
|
246
|
+
// liveQuery i overlay hook przejmują dalej.
|
|
805
247
|
} catch (err) {
|
|
806
|
-
|
|
807
|
-
setTimeline((prev) => [
|
|
808
|
-
...prev,
|
|
809
|
-
{ type: "message", id: `error_${Date.now()}`, role: "assistant", content: `${chatLabels.errorLabel}: ${errorMsg}` },
|
|
810
|
-
]);
|
|
248
|
+
pushLocalError(err);
|
|
811
249
|
}
|
|
812
250
|
},
|
|
813
|
-
[scopeId, messageMutations,
|
|
251
|
+
[scopeId, messageMutations, pushLocalError],
|
|
814
252
|
);
|
|
815
253
|
|
|
816
|
-
// ───
|
|
817
|
-
const chatMessages: ChatMessageData[] = timeline
|
|
818
|
-
.filter((item): item is TimelineItem & { type: "message" } => item.type === "message")
|
|
819
|
-
.map((item) => ({
|
|
820
|
-
id: item.id,
|
|
821
|
-
role: item.role,
|
|
822
|
-
content: item.content,
|
|
823
|
-
isStreaming: item.isStreaming,
|
|
824
|
-
}));
|
|
825
|
-
|
|
826
|
-
// ─── Render tool view ───────────────────────────────────────
|
|
254
|
+
// ─── Render tool view ─────────────────────────────────────────
|
|
827
255
|
const renderToolItem = (item: TimelineItem & { type: "tool" }) => {
|
|
828
256
|
const tool = toolsMap.get(item.toolName);
|
|
829
257
|
const ViewComponent = tool?.viewComponent;
|
|
830
258
|
|
|
831
259
|
if (!ViewComponent) {
|
|
832
|
-
return createElement(
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
260
|
+
return createElement(
|
|
261
|
+
ChatToolLog,
|
|
262
|
+
{
|
|
263
|
+
calling: item.calling,
|
|
264
|
+
label: item.toolName,
|
|
265
|
+
},
|
|
266
|
+
item.calling
|
|
267
|
+
? chatLabels.toolCallingLabel
|
|
268
|
+
: (item.error ?? chatLabels.toolDoneLabel),
|
|
269
|
+
);
|
|
836
270
|
}
|
|
837
271
|
|
|
838
272
|
if (tool.isServerTool) {
|
|
@@ -844,15 +278,13 @@ export function createChatComponent(
|
|
|
844
278
|
});
|
|
845
279
|
}
|
|
846
280
|
|
|
847
|
-
// Interactive tool
|
|
281
|
+
// Interactive tool — odpowiedź idzie przez optimistic
|
|
282
|
+
// pendingToolResults (answer-view od razu), mutacja respondToTool
|
|
283
|
+
// tworzy tool_result row + nowy turn.
|
|
848
284
|
const respond = async (result: unknown) => {
|
|
849
285
|
if (!scopeId) return;
|
|
850
|
-
|
|
851
|
-
prev.
|
|
852
|
-
t.type === "tool" && t.toolCallId === item.toolCallId
|
|
853
|
-
? { ...t, calling: false, result }
|
|
854
|
-
: t,
|
|
855
|
-
),
|
|
286
|
+
setPendingToolResults((prev) =>
|
|
287
|
+
new Map(prev).set(item.toolCallId, result),
|
|
856
288
|
);
|
|
857
289
|
try {
|
|
858
290
|
await messageMutations.respondToTool({
|
|
@@ -861,18 +293,13 @@ export function createChatComponent(
|
|
|
861
293
|
toolName: item.toolName,
|
|
862
294
|
result: JSON.stringify(result),
|
|
863
295
|
});
|
|
864
|
-
// Auto-subscribe effect załapie nowy assistant row (utworzony
|
|
865
|
-
// atomowo przez mutację respondToTool razem z userResponded event).
|
|
866
296
|
} catch (err) {
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
content: `${chatLabels.errorLabel}: ${err instanceof Error ? err.message : chatLabels.errorLabel}`,
|
|
874
|
-
},
|
|
875
|
-
]);
|
|
297
|
+
setPendingToolResults((prev) => {
|
|
298
|
+
const next = new Map(prev);
|
|
299
|
+
next.delete(item.toolCallId);
|
|
300
|
+
return next;
|
|
301
|
+
});
|
|
302
|
+
pushLocalError(err);
|
|
876
303
|
}
|
|
877
304
|
};
|
|
878
305
|
|
|
@@ -884,21 +311,19 @@ export function createChatComponent(
|
|
|
884
311
|
});
|
|
885
312
|
};
|
|
886
313
|
|
|
887
|
-
// ─── Render
|
|
888
|
-
// Streaming message renders last (after tools) for correct visual order
|
|
889
|
-
const sortedTimeline = [...timeline].sort((a, b) => {
|
|
890
|
-
const aStreaming = a.type === "message" && a.isStreaming ? 1 : 0;
|
|
891
|
-
const bStreaming = b.type === "message" && b.isStreaming ? 1 : 0;
|
|
892
|
-
return aStreaming - bStreaming;
|
|
893
|
-
});
|
|
894
|
-
|
|
314
|
+
// ─── Render timeline ──────────────────────────────────────────
|
|
895
315
|
const timelineElements: ReactNode[] = [];
|
|
896
|
-
for (const item of
|
|
316
|
+
for (const item of derived.items) {
|
|
897
317
|
if (item.type === "message") {
|
|
898
318
|
timelineElements.push(
|
|
899
319
|
createElement(ChatMessage, {
|
|
900
320
|
key: item.id,
|
|
901
|
-
message: {
|
|
321
|
+
message: {
|
|
322
|
+
id: item.id,
|
|
323
|
+
role: item.role,
|
|
324
|
+
content: item.content,
|
|
325
|
+
isStreaming: item.isStreaming,
|
|
326
|
+
},
|
|
902
327
|
}),
|
|
903
328
|
);
|
|
904
329
|
} else if (item.type === "tool") {
|
|
@@ -911,7 +336,8 @@ export function createChatComponent(
|
|
|
911
336
|
"div",
|
|
912
337
|
{
|
|
913
338
|
key: item.id,
|
|
914
|
-
className:
|
|
339
|
+
className:
|
|
340
|
+
"flex items-center gap-2 text-sm text-muted-foreground",
|
|
915
341
|
},
|
|
916
342
|
createElement("span", null, chatLabels.interruptedLabel),
|
|
917
343
|
createElement(
|
|
@@ -927,13 +353,14 @@ export function createChatComponent(
|
|
|
927
353
|
);
|
|
928
354
|
}
|
|
929
355
|
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
356
|
+
for (const err of localErrors) {
|
|
357
|
+
timelineElements.push(
|
|
358
|
+
createElement(ChatMessage, {
|
|
359
|
+
key: err.id,
|
|
360
|
+
message: { id: err.id, role: "assistant", content: err.content },
|
|
361
|
+
}),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
937
364
|
|
|
938
365
|
return createElement(
|
|
939
366
|
ChatInputProvider,
|
|
@@ -967,24 +394,16 @@ export function createChatComponent(
|
|
|
967
394
|
placeholder,
|
|
968
395
|
rows,
|
|
969
396
|
})),
|
|
970
|
-
disabled:
|
|
397
|
+
disabled: derived.busy || derived.hasWaitingInteractive,
|
|
971
398
|
}),
|
|
972
399
|
),
|
|
973
400
|
);
|
|
974
401
|
}
|
|
975
402
|
|
|
976
403
|
return function ChatComponent(props: { scope: any; identifyBy: string }) {
|
|
977
|
-
return createElement(
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
);
|
|
404
|
+
return createElement(ChatLabelsProvider, {
|
|
405
|
+
labels,
|
|
406
|
+
children: createElement(ChatComponentInner, props),
|
|
407
|
+
});
|
|
981
408
|
};
|
|
982
409
|
}
|
|
983
|
-
|
|
984
|
-
function tryParseJson(str: string): unknown {
|
|
985
|
-
try {
|
|
986
|
-
return JSON.parse(str);
|
|
987
|
-
} catch {
|
|
988
|
-
return str;
|
|
989
|
-
}
|
|
990
|
-
}
|