@arcote.tech/arc-chat 0.7.20 → 0.7.22
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 -879
- 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
|
@@ -5,19 +5,33 @@ import { subscribe } from "../streaming/stream-registry";
|
|
|
5
5
|
export function createChatStreamRoute(config: {
|
|
6
6
|
name: string;
|
|
7
7
|
userToken: Token;
|
|
8
|
+
/** Message aggregate tego chatu — używany do lazy repair przy 410. */
|
|
9
|
+
messageElement: any;
|
|
8
10
|
}) {
|
|
11
|
+
const { messageElement } = config;
|
|
9
12
|
return route(`${config.name}ChatStream`)
|
|
10
13
|
.path(`/chat/${config.name}/stream/:messageId`)
|
|
11
14
|
.protectBy(config.userToken, () => true)
|
|
15
|
+
.mutate([messageElement])
|
|
12
16
|
.handle({
|
|
13
|
-
GET: async (
|
|
17
|
+
GET: async (ctx: any, _req: Request, params: Record<string, string>) => {
|
|
14
18
|
const result = subscribe(params.messageId);
|
|
15
19
|
if (!result) {
|
|
16
20
|
// Brak in-memory streamu: generacja zakończona poza grace window
|
|
17
|
-
// ALBO proces zrestartował się mid-stream.
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
+
// ALBO proces zrestartował się mid-stream. Lazy repair: jeśli row
|
|
22
|
+
// w DB nadal ma `isGenerating=true` i jest wystarczająco stary
|
|
23
|
+
// (nie ściga się ze świeżym startStream), flipujemy go trwale na
|
|
24
|
+
// `interrupted` — pierwszy klient, który odkryje sierotę, naprawia
|
|
25
|
+
// ją dla wszystkich (cross-client, przeżywa F5). Walidacja wieku
|
|
26
|
+
// i stanu rowa siedzi w mutacji `markInterrupted` (no-op gdy
|
|
27
|
+
// warunki niespełnione), więc wołamy ją przy każdym 410.
|
|
28
|
+
try {
|
|
29
|
+
await ctx.mutate(messageElement).markInterrupted({
|
|
30
|
+
messageId: params.messageId,
|
|
31
|
+
});
|
|
32
|
+
} catch {
|
|
33
|
+
// Best-effort: klient i tak obsłuży 410 retry/interrupted path.
|
|
34
|
+
}
|
|
21
35
|
return new Response(
|
|
22
36
|
JSON.stringify({
|
|
23
37
|
error: "stream_not_found",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import type { AssistantContentBlock } from "@arcote.tech/arc-ai";
|
|
3
|
+
import { applyStreamEvent, type StreamBlocksEvent } from "./blocks-reducer";
|
|
4
|
+
|
|
5
|
+
function replay(
|
|
6
|
+
base: AssistantContentBlock[],
|
|
7
|
+
events: StreamBlocksEvent[],
|
|
8
|
+
): AssistantContentBlock[] {
|
|
9
|
+
return events.reduce(applyStreamEvent, base);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Reprezentatywny turn: tekst → tool call → args → dalszy tekst. */
|
|
13
|
+
const TURN_EVENTS: StreamBlocksEvent[] = [
|
|
14
|
+
{ type: "text_delta", textDelta: "Cześć" },
|
|
15
|
+
{ type: "text_delta", textDelta: ", już " },
|
|
16
|
+
{ type: "text_delta", textDelta: "sprawdzam." },
|
|
17
|
+
{ type: "tool_call_pending", toolCallId: "tc1", toolCallName: "search" },
|
|
18
|
+
{
|
|
19
|
+
type: "tool_call_arguments_delta",
|
|
20
|
+
toolCallId: "tc1",
|
|
21
|
+
// argumentsDelta nie mutuje blocks — identity
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
type: "tool_call_arguments_complete",
|
|
25
|
+
toolCallId: "tc1",
|
|
26
|
+
toolCallName: "search",
|
|
27
|
+
arguments: { query: "arc" },
|
|
28
|
+
},
|
|
29
|
+
{ type: "usage_update" },
|
|
30
|
+
{ type: "tool_call_pending", toolCallId: "tc2" },
|
|
31
|
+
{
|
|
32
|
+
type: "tool_call_arguments_complete",
|
|
33
|
+
toolCallId: "tc2",
|
|
34
|
+
toolCallName: "lateName",
|
|
35
|
+
arguments: { a: 1 },
|
|
36
|
+
},
|
|
37
|
+
{ type: "text_delta", textDelta: "Drugi blok tekstu" },
|
|
38
|
+
{ type: "done" },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
describe("applyStreamEvent — semantyka bloków", () => {
|
|
42
|
+
it("text_delta dokleja do ostatniego text blocka lub tworzy nowy", () => {
|
|
43
|
+
let blocks: AssistantContentBlock[] = [];
|
|
44
|
+
blocks = applyStreamEvent(blocks, { type: "text_delta", textDelta: "a" });
|
|
45
|
+
blocks = applyStreamEvent(blocks, { type: "text_delta", textDelta: "b" });
|
|
46
|
+
expect(blocks).toEqual([{ type: "text", text: "ab" }]);
|
|
47
|
+
|
|
48
|
+
blocks = applyStreamEvent(blocks, {
|
|
49
|
+
type: "tool_call_pending",
|
|
50
|
+
toolCallId: "t1",
|
|
51
|
+
});
|
|
52
|
+
blocks = applyStreamEvent(blocks, { type: "text_delta", textDelta: "c" });
|
|
53
|
+
expect(blocks).toEqual([
|
|
54
|
+
{ type: "text", text: "ab" },
|
|
55
|
+
{ type: "tool_call", id: "t1", name: "", arguments: {} },
|
|
56
|
+
{ type: "text", text: "c" },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("pusty text_delta jest identity", () => {
|
|
61
|
+
const blocks: AssistantContentBlock[] = [{ type: "text", text: "x" }];
|
|
62
|
+
expect(applyStreamEvent(blocks, { type: "text_delta" })).toBe(blocks);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("tool_call_arguments_complete aktualizuje args i nazwę po toolCallId", () => {
|
|
66
|
+
const blocks = replay([], [
|
|
67
|
+
{ type: "tool_call_pending", toolCallId: "t1" },
|
|
68
|
+
{
|
|
69
|
+
type: "tool_call_arguments_complete",
|
|
70
|
+
toolCallId: "t1",
|
|
71
|
+
toolCallName: "named",
|
|
72
|
+
arguments: { x: 1 },
|
|
73
|
+
},
|
|
74
|
+
]);
|
|
75
|
+
expect(blocks).toEqual([
|
|
76
|
+
{ type: "tool_call", id: "t1", name: "named", arguments: { x: 1 } },
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("tool_call_arguments_complete dla nieznanego id jest identity", () => {
|
|
81
|
+
const blocks: AssistantContentBlock[] = [{ type: "text", text: "x" }];
|
|
82
|
+
expect(
|
|
83
|
+
applyStreamEvent(blocks, {
|
|
84
|
+
type: "tool_call_arguments_complete",
|
|
85
|
+
toolCallId: "ghost",
|
|
86
|
+
arguments: {},
|
|
87
|
+
}),
|
|
88
|
+
).toBe(blocks);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("eventy nie kształtujące blocks są identity (ta sama referencja)", () => {
|
|
92
|
+
const blocks: AssistantContentBlock[] = [{ type: "text", text: "x" }];
|
|
93
|
+
for (const type of [
|
|
94
|
+
"init",
|
|
95
|
+
"tool_call_arguments_delta",
|
|
96
|
+
"tool_call_executed",
|
|
97
|
+
"interactive_tool_request",
|
|
98
|
+
"usage_update",
|
|
99
|
+
"done",
|
|
100
|
+
"error",
|
|
101
|
+
]) {
|
|
102
|
+
expect(applyStreamEvent(blocks, { type })).toBe(blocks);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("nigdy nie mutuje wejścia", () => {
|
|
107
|
+
const base: AssistantContentBlock[] = [
|
|
108
|
+
{ type: "text", text: "stary" },
|
|
109
|
+
{ type: "tool_call", id: "t1", name: "n", arguments: { a: 1 } },
|
|
110
|
+
];
|
|
111
|
+
const frozen = JSON.stringify(base);
|
|
112
|
+
replay(base, TURN_EVENTS);
|
|
113
|
+
expect(JSON.stringify(base)).toBe(frozen);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("applyStreamEvent — inwariant reconnectu (snapshot + replay)", () => {
|
|
118
|
+
it("snapshot w dowolnym punkcie K + replay reszty == pełny replay", () => {
|
|
119
|
+
const full = replay([], TURN_EVENTS);
|
|
120
|
+
for (let k = 0; k <= TURN_EVENTS.length; k++) {
|
|
121
|
+
const snapshot = replay([], TURN_EVENTS.slice(0, k));
|
|
122
|
+
const resumed = replay(snapshot, TURN_EVENTS.slice(k));
|
|
123
|
+
expect(resumed).toEqual(full);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { AssistantContentBlock } from "@arcote.tech/arc-ai";
|
|
2
|
+
|
|
3
|
+
// ─── Shared stream→blocks reducer ────────────────────────────────
|
|
4
|
+
//
|
|
5
|
+
// Jedyna implementacja semantyki "jak eventy streamu budują
|
|
6
|
+
// AssistantContentBlock[]". Używana w DWÓCH miejscach:
|
|
7
|
+
// - serwer: `stream-registry.publish()` akumuluje `currentBlocks`,
|
|
8
|
+
// - klient: hook overlayów aplikuje delty na bazie z eventu `init`.
|
|
9
|
+
//
|
|
10
|
+
// Inwariant reconnectu: snapshot blocks w dowolnym punkcie K + replay
|
|
11
|
+
// eventów od K == pełny replay wszystkich eventów. Dzięki temu `init`
|
|
12
|
+
// (snapshot) + dalsze delty dają na kliencie identyczny stan co na
|
|
13
|
+
// serwerze — bez sekwencerów i bufora replay.
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Strukturalny podzbiór eventu streamu — pasuje zarówno do
|
|
17
|
+
* `PublishableEvent` (serwer), jak i `ChatStreamEvent` (klient/SSE).
|
|
18
|
+
*/
|
|
19
|
+
export interface StreamBlocksEvent {
|
|
20
|
+
type: string;
|
|
21
|
+
textDelta?: string;
|
|
22
|
+
toolCallId?: string;
|
|
23
|
+
toolCallName?: string;
|
|
24
|
+
arguments?: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Czysta funkcja: zwraca NOWĄ tablicę blocks z zaaplikowanym eventem
|
|
29
|
+
* (structural sharing — niezmienione bloki są reused). Eventy nie
|
|
30
|
+
* mutujące blocks (`done`, `usage_update`, `tool_call_arguments_delta`,
|
|
31
|
+
* `error`, ...) zwracają wejściową tablicę bez zmian.
|
|
32
|
+
*
|
|
33
|
+
* Nigdy nie mutuje wejścia — snapshoty trzymane przez subskrybentów
|
|
34
|
+
* (event `init`) i poprzednie stany Reacta pozostają nietknięte.
|
|
35
|
+
*/
|
|
36
|
+
export function applyStreamEvent(
|
|
37
|
+
blocks: AssistantContentBlock[],
|
|
38
|
+
event: StreamBlocksEvent,
|
|
39
|
+
): AssistantContentBlock[] {
|
|
40
|
+
switch (event.type) {
|
|
41
|
+
case "text_delta": {
|
|
42
|
+
if (!event.textDelta) return blocks;
|
|
43
|
+
const last = blocks[blocks.length - 1];
|
|
44
|
+
if (last && last.type === "text") {
|
|
45
|
+
return [
|
|
46
|
+
...blocks.slice(0, -1),
|
|
47
|
+
{ type: "text", text: last.text + event.textDelta },
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
return [...blocks, { type: "text", text: event.textDelta }];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case "tool_call_pending": {
|
|
54
|
+
if (!event.toolCallId) return blocks;
|
|
55
|
+
return [
|
|
56
|
+
...blocks,
|
|
57
|
+
{
|
|
58
|
+
type: "tool_call",
|
|
59
|
+
id: event.toolCallId,
|
|
60
|
+
name: event.toolCallName ?? "",
|
|
61
|
+
arguments: {},
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
case "tool_call_arguments_complete": {
|
|
67
|
+
if (!event.toolCallId) return blocks;
|
|
68
|
+
const idx = blocks.findIndex(
|
|
69
|
+
(b) => b.type === "tool_call" && b.id === event.toolCallId,
|
|
70
|
+
);
|
|
71
|
+
if (idx === -1) return blocks;
|
|
72
|
+
const block = blocks[idx] as Extract<
|
|
73
|
+
AssistantContentBlock,
|
|
74
|
+
{ type: "tool_call" }
|
|
75
|
+
>;
|
|
76
|
+
const next = blocks.slice();
|
|
77
|
+
next[idx] = {
|
|
78
|
+
...block,
|
|
79
|
+
arguments: event.arguments ?? {},
|
|
80
|
+
name: event.toolCallName ?? block.name,
|
|
81
|
+
};
|
|
82
|
+
return next;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
default:
|
|
86
|
+
return blocks;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
finalize,
|
|
4
|
+
getCurrentBlocks,
|
|
5
|
+
isActive,
|
|
6
|
+
publish,
|
|
7
|
+
startStream,
|
|
8
|
+
subscribe,
|
|
9
|
+
} from "./stream-registry";
|
|
10
|
+
|
|
11
|
+
let nextId = 0;
|
|
12
|
+
const freshId = () => `msg_test_${nextId++}`;
|
|
13
|
+
|
|
14
|
+
describe("stream-registry — akumulacja przez shared reducer", () => {
|
|
15
|
+
it("publish akumuluje currentBlocks identycznie jak reducer", () => {
|
|
16
|
+
const id = freshId();
|
|
17
|
+
startStream(id);
|
|
18
|
+
publish(id, { type: "text_delta", textDelta: "Hej" });
|
|
19
|
+
publish(id, { type: "text_delta", textDelta: "!" });
|
|
20
|
+
publish(id, { type: "tool_call_pending", toolCallId: "t1", toolCallName: "x" });
|
|
21
|
+
publish(id, {
|
|
22
|
+
type: "tool_call_arguments_complete",
|
|
23
|
+
toolCallId: "t1",
|
|
24
|
+
arguments: { q: 1 },
|
|
25
|
+
});
|
|
26
|
+
expect(getCurrentBlocks(id)).toEqual([
|
|
27
|
+
{ type: "text", text: "Hej!" },
|
|
28
|
+
{ type: "tool_call", id: "t1", name: "x", arguments: { q: 1 } },
|
|
29
|
+
]);
|
|
30
|
+
finalize(id);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("snapshot z subscribe() jest stabilny mimo kolejnych publishy", () => {
|
|
34
|
+
const id = freshId();
|
|
35
|
+
startStream(id);
|
|
36
|
+
publish(id, { type: "text_delta", textDelta: "abc" });
|
|
37
|
+
|
|
38
|
+
const sub = subscribe(id);
|
|
39
|
+
expect(sub).not.toBeNull();
|
|
40
|
+
const snapshot = sub!.currentBlocks;
|
|
41
|
+
expect(snapshot).toEqual([{ type: "text", text: "abc" }]);
|
|
42
|
+
|
|
43
|
+
publish(id, { type: "text_delta", textDelta: "def" });
|
|
44
|
+
publish(id, { type: "tool_call_pending", toolCallId: "t1" });
|
|
45
|
+
|
|
46
|
+
// Immutable reducer: snapshot nietknięty, registry poszło dalej.
|
|
47
|
+
expect(snapshot).toEqual([{ type: "text", text: "abc" }]);
|
|
48
|
+
expect(getCurrentBlocks(id)).toEqual([
|
|
49
|
+
{ type: "text", text: "abcdef" },
|
|
50
|
+
{ type: "tool_call", id: "t1", name: "", arguments: {} },
|
|
51
|
+
]);
|
|
52
|
+
finalize(id);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("publish po finalize jest no-opem", () => {
|
|
56
|
+
const id = freshId();
|
|
57
|
+
startStream(id);
|
|
58
|
+
publish(id, { type: "text_delta", textDelta: "a" });
|
|
59
|
+
finalize(id);
|
|
60
|
+
expect(isActive(id)).toBe(false);
|
|
61
|
+
publish(id, { type: "text_delta", textDelta: "b" });
|
|
62
|
+
expect(getCurrentBlocks(id)).toEqual([{ type: "text", text: "a" }]);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -6,12 +6,14 @@ import type {
|
|
|
6
6
|
ToolCall,
|
|
7
7
|
ToolResult,
|
|
8
8
|
} from "@arcote.tech/arc-ai";
|
|
9
|
+
import { applyStreamEvent } from "./blocks-reducer";
|
|
9
10
|
|
|
10
11
|
// ─── Per-message in-memory stream registry ──────────────────────────
|
|
11
12
|
//
|
|
12
13
|
// Live state of an in-progress assistant message lives ONLY here, never in
|
|
13
14
|
// DB. `currentBlocks` accumulates text + tool_call blocks as the LLM streams.
|
|
14
|
-
// Each `publish()`
|
|
15
|
+
// Each `publish()` applies the shared `applyStreamEvent` reducer (immutable)
|
|
16
|
+
// AND broadcasts the SSE event to subscribers.
|
|
15
17
|
// A new subscriber receives `{ type: "init", currentBlocks }` as the first
|
|
16
18
|
// event — that's the snapshot of the in-memory state — and then live events.
|
|
17
19
|
//
|
|
@@ -26,11 +28,13 @@ import type {
|
|
|
26
28
|
|
|
27
29
|
interface MessageStream {
|
|
28
30
|
messageId: string;
|
|
31
|
+
/**
|
|
32
|
+
* Akumulowany stan bloków — aktualizowany WYŁĄCZNIE przez immutable
|
|
33
|
+
* `applyStreamEvent` (shared reducer, ten sam kod co klient). Każdy
|
|
34
|
+
* publish podmienia referencję; wcześniej rozdane snapshoty (`init`)
|
|
35
|
+
* pozostają nietknięte.
|
|
36
|
+
*/
|
|
29
37
|
currentBlocks: AssistantContentBlock[];
|
|
30
|
-
toolCallsById: Map<
|
|
31
|
-
string,
|
|
32
|
-
Extract<AssistantContentBlock, { type: "tool_call" }>
|
|
33
|
-
>;
|
|
34
38
|
subscribers: Set<ReadableStreamDefaultController<Uint8Array>>;
|
|
35
39
|
keepAliveInterval?: ReturnType<typeof setInterval>;
|
|
36
40
|
/**
|
|
@@ -88,7 +92,6 @@ export function startStream(messageId: string): void {
|
|
|
88
92
|
const s: MessageStream = {
|
|
89
93
|
messageId,
|
|
90
94
|
currentBlocks: [],
|
|
91
|
-
toolCallsById: new Map(),
|
|
92
95
|
subscribers: new Set(),
|
|
93
96
|
finalized: false,
|
|
94
97
|
};
|
|
@@ -156,8 +159,13 @@ export type PublishableEvent =
|
|
|
156
159
|
| { type: "error"; error: string; executionCount?: number };
|
|
157
160
|
|
|
158
161
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
162
|
+
* Apply the event to the in-memory `currentBlocks` (via the shared
|
|
163
|
+
* `applyStreamEvent` reducer — the same code the client runs on SSE
|
|
164
|
+
* events), then broadcast the event to all active subscribers as SSE.
|
|
165
|
+
*
|
|
166
|
+
* Events that don't shape blocks (`tool_call_arguments_delta`,
|
|
167
|
+
* `tool_call_executed`, `interactive_tool_request`, `usage_update`,
|
|
168
|
+
* `error`) are broadcast-only — the reducer returns the input unchanged.
|
|
161
169
|
*
|
|
162
170
|
* No-op if the stream has been finalized or never started (race with a
|
|
163
171
|
* client disconnect / listener teardown).
|
|
@@ -166,42 +174,7 @@ export function publish(messageId: string, event: PublishableEvent): void {
|
|
|
166
174
|
const s = streams.get(messageId);
|
|
167
175
|
if (!s || s.finalized) return;
|
|
168
176
|
|
|
169
|
-
|
|
170
|
-
case "text_delta": {
|
|
171
|
-
const last = s.currentBlocks[s.currentBlocks.length - 1];
|
|
172
|
-
if (last && last.type === "text") {
|
|
173
|
-
last.text += event.textDelta;
|
|
174
|
-
} else {
|
|
175
|
-
s.currentBlocks.push({ type: "text", text: event.textDelta });
|
|
176
|
-
}
|
|
177
|
-
break;
|
|
178
|
-
}
|
|
179
|
-
case "tool_call_pending": {
|
|
180
|
-
const block: Extract<AssistantContentBlock, { type: "tool_call" }> = {
|
|
181
|
-
type: "tool_call",
|
|
182
|
-
id: event.toolCallId,
|
|
183
|
-
name: event.toolCallName ?? "",
|
|
184
|
-
arguments: {},
|
|
185
|
-
};
|
|
186
|
-
s.currentBlocks.push(block);
|
|
187
|
-
s.toolCallsById.set(event.toolCallId, block);
|
|
188
|
-
break;
|
|
189
|
-
}
|
|
190
|
-
case "tool_call_arguments_complete": {
|
|
191
|
-
const block = s.toolCallsById.get(event.toolCallId);
|
|
192
|
-
if (block) {
|
|
193
|
-
block.arguments = event.arguments;
|
|
194
|
-
if (event.toolCallName) block.name = event.toolCallName;
|
|
195
|
-
}
|
|
196
|
-
break;
|
|
197
|
-
}
|
|
198
|
-
// tool_call_arguments_delta, tool_call_executed, interactive_tool_request,
|
|
199
|
-
// usage_update, error — broadcast-only. They don't mutate `currentBlocks`:
|
|
200
|
-
// - args_delta is incremental JSON the listener replaces via args_complete
|
|
201
|
-
// - executed / interactive_tool_request relate to tool execution flow
|
|
202
|
-
// (tool_result rows exist separately in DB)
|
|
203
|
-
// - usage_update / error are transient signals to the UI
|
|
204
|
-
}
|
|
177
|
+
s.currentBlocks = applyStreamEvent(s.currentBlocks, event);
|
|
205
178
|
|
|
206
179
|
const payload = { ...event, messageId } as ChatStreamEvent;
|
|
207
180
|
const data = encode(payload);
|
|
@@ -232,11 +205,10 @@ export function subscribe(messageId: string): {
|
|
|
232
205
|
const s = streams.get(messageId);
|
|
233
206
|
if (!s) return null;
|
|
234
207
|
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
);
|
|
208
|
+
// Stable snapshot for free: `applyStreamEvent` never mutates — every
|
|
209
|
+
// publish swaps the `currentBlocks` reference, so the array we capture
|
|
210
|
+
// here can't change under the subscriber.
|
|
211
|
+
const snapshot: AssistantContentBlock[] = s.currentBlocks;
|
|
240
212
|
|
|
241
213
|
const stream = new ReadableStream<Uint8Array>({
|
|
242
214
|
start(controller) {
|
|
@@ -39,10 +39,13 @@ function AskQuestionsView({
|
|
|
39
39
|
const { registerInputOverride, clearInputOverride } = useChatInput();
|
|
40
40
|
const { answerBelowLabel } = useChatLabels();
|
|
41
41
|
|
|
42
|
+
// Dep po TREŚCI pytań, nie referencji: derywacja timeline'u tworzy nowe
|
|
43
|
+
// obiekty params przy każdym przeliczeniu, a effect ma się odpalić tylko
|
|
44
|
+
// gdy pytania faktycznie dotrą / zmienią się (np. zamontowano komponent
|
|
45
|
+
// zanim argumenty toola się zestreamowały).
|
|
46
|
+
const questionsKey = JSON.stringify(params.questions ?? null);
|
|
47
|
+
|
|
42
48
|
useEffect(() => {
|
|
43
|
-
// `params.questions` może być undefined podczas streamingu tool args —
|
|
44
|
-
// partial JSON nie ma jeszcze tablicy. Czekamy aż streaming skończy
|
|
45
|
-
// wypełniać argumenty.
|
|
46
49
|
if (calling && Array.isArray(params.questions)) {
|
|
47
50
|
const questions: Question[] = params.questions.map((q) => ({
|
|
48
51
|
id: q.id,
|
|
@@ -63,7 +66,7 @@ function AskQuestionsView({
|
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
return () => clearInputOverride();
|
|
66
|
-
}, [calling]);
|
|
69
|
+
}, [calling, questionsKey]);
|
|
67
70
|
|
|
68
71
|
// Answered — show summary
|
|
69
72
|
if (!calling && result) {
|
package/src/react/use-chat.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
// Removed — use chat().toReactComponent() instead.
|