gemi 0.60.0 → 0.62.0
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/dist/ai/Agent.d.ts +533 -25
- package/dist/ai/Agent.d.ts.map +1 -1
- package/dist/ai/Agent.test-d.d.ts +2 -0
- package/dist/ai/Agent.test-d.d.ts.map +1 -0
- package/dist/ai/AgentController.d.ts +275 -6
- package/dist/ai/AgentController.d.ts.map +1 -1
- package/dist/ai/AgentProvider.d.ts +255 -0
- package/dist/ai/AgentProvider.d.ts.map +1 -0
- package/dist/ai/Schema.d.ts +125 -0
- package/dist/ai/Schema.d.ts.map +1 -0
- package/dist/ai/Schema.test-d.d.ts +2 -0
- package/dist/ai/Schema.test-d.d.ts.map +1 -0
- package/dist/ai/client/index.d.ts +24 -0
- package/dist/ai/client/index.d.ts.map +1 -0
- package/dist/ai/client/index.js +1053 -0
- package/dist/ai/client/index.js.map +1 -0
- package/dist/ai/client/reducer.d.ts +127 -0
- package/dist/ai/client/reducer.d.ts.map +1 -0
- package/dist/ai/client/reducer.test-d.d.ts +2 -0
- package/dist/ai/client/reducer.test-d.d.ts.map +1 -0
- package/dist/ai/client/sse.d.ts +54 -0
- package/dist/ai/client/sse.d.ts.map +1 -0
- package/dist/ai/example.d.ts +129 -0
- package/dist/ai/example.d.ts.map +1 -0
- package/dist/ai/index.d.ts +35 -0
- package/dist/ai/index.d.ts.map +1 -0
- package/dist/ai/index.js +20 -0
- package/dist/ai/index.js.map +23 -0
- package/dist/ai/live/harness.d.ts +137 -0
- package/dist/ai/live/harness.d.ts.map +1 -0
- package/dist/ai/providers/call.d.ts +42 -0
- package/dist/ai/providers/call.d.ts.map +1 -0
- package/dist/ai/providers/capabilities.d.ts +50 -0
- package/dist/ai/providers/capabilities.d.ts.map +1 -0
- package/dist/ai/providers/errors.d.ts +37 -0
- package/dist/ai/providers/errors.d.ts.map +1 -0
- package/dist/ai/providers/fakeProvider.d.ts +35 -0
- package/dist/ai/providers/fakeProvider.d.ts.map +1 -0
- package/dist/ai/providers/http.d.ts +46 -0
- package/dist/ai/providers/http.d.ts.map +1 -0
- package/dist/ai/providers/request.d.ts +69 -0
- package/dist/ai/providers/request.d.ts.map +1 -0
- package/dist/ai/providers/stream.d.ts +42 -0
- package/dist/ai/providers/stream.d.ts.map +1 -0
- package/dist/ai/signing.d.ts +195 -0
- package/dist/ai/signing.d.ts.map +1 -0
- package/dist/ai/store/LiveRuns.d.ts +142 -0
- package/dist/ai/store/LiveRuns.d.ts.map +1 -0
- package/dist/ai/store/MemoryAgentStore.d.ts +61 -0
- package/dist/ai/store/MemoryAgentStore.d.ts.map +1 -0
- package/dist/ai/store/index.d.ts +4 -0
- package/dist/ai/store/index.d.ts.map +1 -0
- package/dist/ai/store/sse.d.ts +58 -0
- package/dist/ai/store/sse.d.ts.map +1 -0
- package/dist/ai/store/stubAgentRun.d.ts +56 -0
- package/dist/ai/store/stubAgentRun.d.ts.map +1 -0
- package/dist/ai/types.d.ts +443 -0
- package/dist/ai/types.d.ts.map +1 -0
- package/dist/ai/useChat.d.ts +253 -7
- package/dist/ai/useChat.d.ts.map +1 -1
- package/dist/chunk-1aqzcgfr.js +5 -0
- package/dist/chunk-1aqzcgfr.js.map +10 -0
- package/dist/{chunk-get4mkx8.js → chunk-1b7e9rj7.js} +2 -2
- package/dist/{chunk-get4mkx8.js.map → chunk-1b7e9rj7.js.map} +1 -1
- package/dist/{chunk-j06g4sqc.js → chunk-528n3vgy.js} +2 -2
- package/dist/{chunk-j06g4sqc.js.map → chunk-528n3vgy.js.map} +1 -1
- package/dist/{chunk-2khdxyjb.js → chunk-57a0nqfj.js} +1 -1
- package/dist/chunk-57a0nqfj.js.map +10 -0
- package/dist/chunk-5fj71djg.js +6 -0
- package/dist/{chunk-y3zz410b.js.map → chunk-5fj71djg.js.map} +2 -2
- package/dist/{chunk-c40n5r4v.js → chunk-5mhcwnyd.js} +2 -2
- package/dist/{chunk-c40n5r4v.js.map → chunk-5mhcwnyd.js.map} +1 -1
- package/dist/{chunk-gwchvzdp.js → chunk-71pk1mxx.js} +2 -2
- package/dist/{chunk-gwchvzdp.js.map → chunk-71pk1mxx.js.map} +1 -1
- package/dist/{chunk-spbgpndn.js → chunk-7t1hjs9f.js} +2 -2
- package/dist/{chunk-spbgpndn.js.map → chunk-7t1hjs9f.js.map} +1 -1
- package/dist/{chunk-9gsdcjt7.js → chunk-7xvaace2.js} +3 -3
- package/dist/{chunk-9gsdcjt7.js.map → chunk-7xvaace2.js.map} +1 -1
- package/dist/{chunk-fxy42w6n.js → chunk-8ag0da2s.js} +2 -2
- package/dist/{chunk-fxy42w6n.js.map → chunk-8ag0da2s.js.map} +1 -1
- package/dist/chunk-8r8epsef.js +5 -0
- package/dist/chunk-8r8epsef.js.map +11 -0
- package/dist/{chunk-0fm6jh9b.js → chunk-9nmvm20t.js} +2 -2
- package/dist/{chunk-0fm6jh9b.js.map → chunk-9nmvm20t.js.map} +1 -1
- package/dist/chunk-9penj44g.js +5 -0
- package/dist/{chunk-khf9xda6.js.map → chunk-9penj44g.js.map} +2 -2
- package/dist/{chunk-98a3k7bp.js → chunk-9xpa7dpy.js} +2 -2
- package/dist/{chunk-98a3k7bp.js.map → chunk-9xpa7dpy.js.map} +1 -1
- package/dist/{chunk-qva4841r.js → chunk-a1exbqcq.js} +3 -3
- package/dist/{chunk-qva4841r.js.map → chunk-a1exbqcq.js.map} +1 -1
- package/dist/{chunk-cw9y6k15.js → chunk-bb19bwg6.js} +2 -2
- package/dist/{chunk-cw9y6k15.js.map → chunk-bb19bwg6.js.map} +1 -1
- package/dist/{chunk-3gvjn3q4.js → chunk-cf7bvd12.js} +1 -1
- package/dist/{chunk-rkbv3df7.js → chunk-djp2xeqe.js} +2 -2
- package/dist/{chunk-rkbv3df7.js.map → chunk-djp2xeqe.js.map} +1 -1
- package/dist/chunk-ds44bqr9.js +4 -0
- package/dist/{chunk-4mcyyh1v.js.map → chunk-ds44bqr9.js.map} +4 -9
- package/dist/{chunk-wzvs3sym.js → chunk-exndjhza.js} +3 -3
- package/dist/{chunk-wzvs3sym.js.map → chunk-exndjhza.js.map} +1 -1
- package/dist/{chunk-f6dd4gd8.js → chunk-f233yzxf.js} +2 -2
- package/dist/{chunk-f6dd4gd8.js.map → chunk-f233yzxf.js.map} +1 -1
- package/dist/chunk-fz5g2z6h.js +4 -0
- package/dist/{chunk-fbvvqf9b.js.map → chunk-fz5g2z6h.js.map} +2 -2
- package/dist/{chunk-vj9538yn.js → chunk-gcszdwcb.js} +2 -2
- package/dist/{chunk-vj9538yn.js.map → chunk-gcszdwcb.js.map} +1 -1
- package/dist/{chunk-dgasxgsm.js → chunk-hk06nhxh.js} +4 -4
- package/dist/{chunk-dgasxgsm.js.map → chunk-hk06nhxh.js.map} +5 -5
- package/dist/{chunk-pkjq9833.js → chunk-htesx7ym.js} +4 -4
- package/dist/{chunk-pkjq9833.js.map → chunk-htesx7ym.js.map} +1 -1
- package/dist/{chunk-stq96kya.js → chunk-k2sjvt0c.js} +2 -2
- package/dist/{chunk-stq96kya.js.map → chunk-k2sjvt0c.js.map} +1 -1
- package/dist/{chunk-x8beq9c4.js → chunk-k4a2gzyc.js} +2 -2
- package/dist/{chunk-x8beq9c4.js.map → chunk-k4a2gzyc.js.map} +1 -1
- package/dist/{chunk-zhbrkpb3.js → chunk-k75phgj4.js} +4 -4
- package/dist/{chunk-zhbrkpb3.js.map → chunk-k75phgj4.js.map} +1 -1
- package/dist/{chunk-23h0dmx2.js → chunk-kac5vmvn.js} +2 -2
- package/dist/{chunk-23h0dmx2.js.map → chunk-kac5vmvn.js.map} +1 -1
- package/dist/{chunk-y64j80v9.js → chunk-m0tp7zjp.js} +2 -2
- package/dist/{chunk-y64j80v9.js.map → chunk-m0tp7zjp.js.map} +1 -1
- package/dist/{chunk-06j6rsew.js → chunk-m45j7p1y.js} +2 -2
- package/dist/{chunk-06j6rsew.js.map → chunk-m45j7p1y.js.map} +1 -1
- package/dist/chunk-mca9wsvs.js +5 -0
- package/dist/{chunk-z2tcxwyr.js.map → chunk-mca9wsvs.js.map} +3 -4
- package/dist/{chunk-tey1xayb.js → chunk-ms13evzp.js} +2 -2
- package/dist/{chunk-tey1xayb.js.map → chunk-ms13evzp.js.map} +1 -1
- package/dist/{chunk-bn1v4sfs.js → chunk-r962ae93.js} +2 -2
- package/dist/{chunk-bn1v4sfs.js.map → chunk-r962ae93.js.map} +1 -1
- package/dist/chunk-snb68dgr.js +4 -0
- package/dist/{chunk-hwhw98hc.js.map → chunk-snb68dgr.js.map} +1 -1
- package/dist/chunk-sz051605.js +5 -0
- package/dist/chunk-sz051605.js.map +14 -0
- package/dist/{chunk-2cwcfwg3.js → chunk-tr3cbx8k.js} +2 -2
- package/dist/{chunk-2cwcfwg3.js.map → chunk-tr3cbx8k.js.map} +2 -2
- package/dist/{chunk-zqsfanvk.js → chunk-wpb1xpdp.js} +2 -2
- package/dist/{chunk-zqsfanvk.js.map → chunk-wpb1xpdp.js.map} +1 -1
- package/dist/{chunk-z1e55w67.js → chunk-ybqss0jy.js} +2 -2
- package/dist/{chunk-z1e55w67.js.map → chunk-ybqss0jy.js.map} +1 -1
- package/dist/{chunk-cejf873g.js → chunk-yk5wqmyh.js} +2 -2
- package/dist/{chunk-cejf873g.js.map → chunk-yk5wqmyh.js.map} +1 -1
- package/dist/{chunk-8kj3zrm9.js → chunk-ywntv8yw.js} +4 -4
- package/dist/{chunk-8kj3zrm9.js.map → chunk-ywntv8yw.js.map} +1 -1
- package/dist/chunks/{ThemeProvider-ByU4BQdL.js → ThemeProvider-BZ2SsSZ3.js} +60 -39
- package/dist/chunks/ThemeProvider-BZ2SsSZ3.js.map +1 -0
- package/dist/chunks/useParams-BN3XXfmG.js +20 -0
- package/dist/chunks/useParams-BN3XXfmG.js.map +1 -0
- package/dist/client/index.js +3 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/useDictionary.d.ts.map +1 -1
- package/dist/database/index.js +1 -1
- package/dist/facades/Storage.d.ts +6 -2
- package/dist/facades/Storage.d.ts.map +1 -1
- package/dist/facades/index.js +2 -2
- package/dist/facades/index.js.map +1 -1
- package/dist/http/ApiRouter.d.ts +30 -2
- package/dist/http/ApiRouter.d.ts.map +1 -1
- package/dist/http/index.js +2 -2
- package/dist/http/index.js.map +1 -1
- package/dist/i18n/defineDictionary.d.ts +7 -4
- package/dist/i18n/defineDictionary.d.ts.map +1 -1
- package/dist/i18n/dictionaryRegistry.d.ts +38 -14
- package/dist/i18n/dictionaryRegistry.d.ts.map +1 -1
- package/dist/i18n/dictionaryRuntime.js +1 -1
- package/dist/i18n/index.js +2 -2
- package/dist/i18n/index.js.map +2 -2
- package/dist/kernel/index.js +2 -2
- package/dist/kernel/index.js.map +2 -2
- package/dist/orm/index.js +2 -2
- package/dist/orm/index.js.map +2 -2
- package/dist/server/index.js +1 -1
- package/dist/services/file-storage/drivers/AzureBlobDriver.d.ts +3 -2
- package/dist/services/file-storage/drivers/AzureBlobDriver.d.ts.map +1 -1
- package/dist/services/file-storage/drivers/FileStorageDriver.d.ts +2 -2
- package/dist/services/file-storage/drivers/FileStorageDriver.d.ts.map +1 -1
- package/dist/services/file-storage/drivers/FileSystemDriver.d.ts +2 -2
- package/dist/services/file-storage/drivers/FileSystemDriver.d.ts.map +1 -1
- package/dist/services/file-storage/drivers/S3Driver.d.ts +2 -2
- package/dist/services/file-storage/drivers/S3Driver.d.ts.map +1 -1
- package/dist/services/file-storage/drivers/types.d.ts +9 -1
- package/dist/services/file-storage/drivers/types.d.ts.map +1 -1
- package/dist/services/index.d.ts +1 -1
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +6 -6
- package/dist/services/index.js.map +4 -4
- package/dist/testing/index.js +2 -1
- package/dist/testing/index.js.map +1 -1
- package/package.json +3 -1
- package/dist/chunk-2khdxyjb.js.map +0 -10
- package/dist/chunk-4mcyyh1v.js +0 -4
- package/dist/chunk-fbvvqf9b.js +0 -4
- package/dist/chunk-hwhw98hc.js +0 -4
- package/dist/chunk-khf9xda6.js +0 -5
- package/dist/chunk-q0y0j3ne.js +0 -5
- package/dist/chunk-q0y0j3ne.js.map +0 -11
- package/dist/chunk-y3zz410b.js +0 -6
- package/dist/chunk-z2tcxwyr.js +0 -5
- package/dist/chunks/ThemeProvider-ByU4BQdL.js.map +0 -1
- /package/dist/{chunk-3gvjn3q4.js.map → chunk-cf7bvd12.js.map} +0 -0
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
import { t as useParams } from "../../chunks/useParams-BN3XXfmG.js";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
//#region ai/client/reducer.ts
|
|
4
|
+
function initialChatState(init = {}) {
|
|
5
|
+
return {
|
|
6
|
+
messages: init.messages ? [...init.messages] : [],
|
|
7
|
+
pending: init.pending ? [...init.pending] : [],
|
|
8
|
+
error: null,
|
|
9
|
+
threadId: init.threadId,
|
|
10
|
+
seq: init.seq ?? -1,
|
|
11
|
+
cursorRunId: init.cursorRunId,
|
|
12
|
+
runMessageIds: [],
|
|
13
|
+
loadedTools: []
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Apply one frame.
|
|
18
|
+
*
|
|
19
|
+
* `now` is an argument rather than a `Date.now()` call so the reducer stays a
|
|
20
|
+
* pure function of its inputs — it is only ever read when a frame arrives for a
|
|
21
|
+
* message the list has not seen, which is precisely the mid-run-attach path a
|
|
22
|
+
* test needs to pin down.
|
|
23
|
+
*/
|
|
24
|
+
function applyFrame(state, frame, now = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
25
|
+
if (!(frame.event.type === "run-start" && frame.event.runId !== state.cursorRunId) && frame.seq <= state.seq) return state;
|
|
26
|
+
return {
|
|
27
|
+
...reduce(state, frame.event, now),
|
|
28
|
+
seq: frame.seq
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function reduce(state, event, now) {
|
|
32
|
+
switch (event.type) {
|
|
33
|
+
case "run-start": return {
|
|
34
|
+
...state,
|
|
35
|
+
runId: event.runId,
|
|
36
|
+
cursorRunId: event.runId,
|
|
37
|
+
threadId: event.threadId ?? state.threadId,
|
|
38
|
+
runMessageIds: [],
|
|
39
|
+
loadedTools: [],
|
|
40
|
+
finishReason: void 0
|
|
41
|
+
};
|
|
42
|
+
case "message-start": return withMessage(state, event.messageId, now, (message) => ({
|
|
43
|
+
...message,
|
|
44
|
+
role: event.role
|
|
45
|
+
}));
|
|
46
|
+
case "text-delta":
|
|
47
|
+
if (isFinished(state, event.messageId)) return state;
|
|
48
|
+
return withMessage(state, event.messageId, now, (message) => ({
|
|
49
|
+
...message,
|
|
50
|
+
content: appendText(message.content, event.delta)
|
|
51
|
+
}));
|
|
52
|
+
case "reasoning-delta":
|
|
53
|
+
if (isFinished(state, event.messageId)) return state;
|
|
54
|
+
return withMessage(state, event.messageId, now, (message) => ({
|
|
55
|
+
...message,
|
|
56
|
+
content: appendReasoning(message.content, event.delta, event.id)
|
|
57
|
+
}));
|
|
58
|
+
case "output-delta": return withMessage(state, event.messageId, now, (message) => ({
|
|
59
|
+
...message,
|
|
60
|
+
content: upsert(message.content, {
|
|
61
|
+
type: "output",
|
|
62
|
+
value: event.snapshot,
|
|
63
|
+
partial: true
|
|
64
|
+
}, (part) => part.type === "output")
|
|
65
|
+
}));
|
|
66
|
+
case "tool-call": return withMessage(state, event.messageId, now, (message) => {
|
|
67
|
+
const index = message.content.findIndex((part) => part.type === "tool-call" && part.toolCallId === event.part.toolCallId);
|
|
68
|
+
const existing = index === -1 ? void 0 : message.content[index];
|
|
69
|
+
const progress = event.part.progress ?? existing?.progress;
|
|
70
|
+
const nested = event.part.nested ?? existing?.nested;
|
|
71
|
+
const part = {
|
|
72
|
+
...event.part,
|
|
73
|
+
...progress ? { progress } : {},
|
|
74
|
+
...nested ? { nested } : {}
|
|
75
|
+
};
|
|
76
|
+
const content = message.content.slice();
|
|
77
|
+
if (index === -1) content.push(part);
|
|
78
|
+
else content[index] = part;
|
|
79
|
+
return {
|
|
80
|
+
...message,
|
|
81
|
+
content
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
case "tool-search": {
|
|
85
|
+
const loaded = event.loaded.filter((name) => !state.loadedTools.includes(name));
|
|
86
|
+
if (loaded.length === 0) return state;
|
|
87
|
+
return {
|
|
88
|
+
...state,
|
|
89
|
+
loadedTools: [...state.loadedTools, ...loaded]
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
case "tool-progress": return withToolCall(state, event.toolCallId, (part) => ({
|
|
93
|
+
...part,
|
|
94
|
+
progress: [...part.progress ?? [], event.data]
|
|
95
|
+
}));
|
|
96
|
+
case "nested-event": {
|
|
97
|
+
const nestedEvent = event;
|
|
98
|
+
return withToolCall(state, event.toolCallId, (part) => {
|
|
99
|
+
const runs = part.nested ?? [];
|
|
100
|
+
const index = runs.findIndex((run) => run.runId === nestedEvent.runId);
|
|
101
|
+
const next = applyNested(runs[index] ?? {
|
|
102
|
+
runId: nestedEvent.runId,
|
|
103
|
+
agent: nestedEvent.agent,
|
|
104
|
+
messages: []
|
|
105
|
+
}, nestedEvent, now);
|
|
106
|
+
const nested = index === -1 ? [...runs, next] : runs.map((r, i) => i === index ? next : r);
|
|
107
|
+
return {
|
|
108
|
+
...part,
|
|
109
|
+
nested
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
case "tool-result": {
|
|
114
|
+
const next = withMessage(state, event.messageId, now, (message) => ({
|
|
115
|
+
...message,
|
|
116
|
+
content: upsert(message.content, event.part, (part) => part.type === "tool-result" && part.toolCallId === event.part.toolCallId)
|
|
117
|
+
}));
|
|
118
|
+
return {
|
|
119
|
+
...next,
|
|
120
|
+
pending: next.pending.filter((call) => call.toolCallId !== event.part.toolCallId)
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
case "awaiting-input": return {
|
|
124
|
+
...state,
|
|
125
|
+
runId: event.runId,
|
|
126
|
+
pending: [...event.pending]
|
|
127
|
+
};
|
|
128
|
+
case "message-end": return withMessage(state, event.messageId, now, (message) => ({
|
|
129
|
+
...message,
|
|
130
|
+
finishReason: event.finishReason,
|
|
131
|
+
content: message.content.map((part) => part.type === "output" && part.partial ? {
|
|
132
|
+
...part,
|
|
133
|
+
partial: false
|
|
134
|
+
} : part)
|
|
135
|
+
}));
|
|
136
|
+
case "usage": {
|
|
137
|
+
const index = lastIndexWhere(state.messages, (message) => message.role === "assistant");
|
|
138
|
+
if (index === -1) return state;
|
|
139
|
+
const messages = state.messages.slice();
|
|
140
|
+
messages[index] = {
|
|
141
|
+
...messages[index],
|
|
142
|
+
usage: event.usage
|
|
143
|
+
};
|
|
144
|
+
return {
|
|
145
|
+
...state,
|
|
146
|
+
messages
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
case "error": return {
|
|
150
|
+
...state,
|
|
151
|
+
error: event.error,
|
|
152
|
+
pending: []
|
|
153
|
+
};
|
|
154
|
+
case "run-end": return {
|
|
155
|
+
...state,
|
|
156
|
+
runId: void 0,
|
|
157
|
+
finishReason: event.finishReason,
|
|
158
|
+
messages: finishUnended(state, event.finishReason)
|
|
159
|
+
};
|
|
160
|
+
default: return state;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The interrupted turn, kept.
|
|
165
|
+
*
|
|
166
|
+
* `stop()` calls this before the server has said anything, because the point of
|
|
167
|
+
* stopping is that the UI reacts now. Whatever the assistant had produced stays
|
|
168
|
+
* in the list marked `aborted` — dropping it would lose text the user already
|
|
169
|
+
* read, and would leave the transcript unable to say it was cut short.
|
|
170
|
+
*
|
|
171
|
+
* `pending` is untouched: a question the conversation is holding survives a
|
|
172
|
+
* stopped run, and clearing it would silently discard the answer the user still
|
|
173
|
+
* owes.
|
|
174
|
+
*/
|
|
175
|
+
function markAborted(state) {
|
|
176
|
+
return {
|
|
177
|
+
...state,
|
|
178
|
+
runId: void 0,
|
|
179
|
+
finishReason: "aborted",
|
|
180
|
+
messages: finishUnended(state, "aborted")
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The messages this run wrote, finished off with `reason` if they never ended —
|
|
185
|
+
* and, inside them, the sub-runs this run left mid-sentence.
|
|
186
|
+
*
|
|
187
|
+
* The nesting descent is not an extra: a `NestedRun` renders with the component
|
|
188
|
+
* that renders `messages`, so a nested message with no `finishReason` draws a
|
|
189
|
+
* live cursor inside a block that will never receive another frame. The parent
|
|
190
|
+
* ending is the last word anything will say about it, and for `markAborted`
|
|
191
|
+
* there is no other word available at all — `stop()` marks the transcript
|
|
192
|
+
* optimistically, before the server has answered, so no forwarded nested
|
|
193
|
+
* `run-end` can arrive in time.
|
|
194
|
+
*
|
|
195
|
+
* It recurses through `closeRun`, which builds a `ChatState` around the sub-run
|
|
196
|
+
* and calls back here, so a sub-sub-run is closed by the same three lines that
|
|
197
|
+
* close the top level — the same trick `applyNested` plays on the way in.
|
|
198
|
+
*/
|
|
199
|
+
function finishUnended(state, reason) {
|
|
200
|
+
return state.messages.map((message) => message.role === "assistant" && state.runMessageIds.includes(message.id) ? closeMessage(message, reason) : message);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* One message of this run, and the sub-runs hanging off its tool calls.
|
|
204
|
+
*
|
|
205
|
+
* The message's own finish reason is only filled in when it is missing, but the
|
|
206
|
+
* descent happens either way: the message a run parks on is *finished* while the
|
|
207
|
+
* sub-run underneath it is still live, and that is the case where the block
|
|
208
|
+
* stuck streaming is easiest to hit.
|
|
209
|
+
*/
|
|
210
|
+
function closeMessage(message, reason) {
|
|
211
|
+
const content = message.content.map((part) => part.type === "tool-call" && part.nested?.some((run) => run.finishReason === void 0) ? {
|
|
212
|
+
...part,
|
|
213
|
+
nested: part.nested.map((run) => run.finishReason === void 0 ? closeRun(run, reason) : run)
|
|
214
|
+
} : part);
|
|
215
|
+
if (message.finishReason === void 0) return {
|
|
216
|
+
...message,
|
|
217
|
+
finishReason: reason,
|
|
218
|
+
content
|
|
219
|
+
};
|
|
220
|
+
return content.some((part, index) => part !== message.content[index]) ? {
|
|
221
|
+
...message,
|
|
222
|
+
content
|
|
223
|
+
} : message;
|
|
224
|
+
}
|
|
225
|
+
/** A sub-run the parent outlived, stamped with the parent's reason, recursively. */
|
|
226
|
+
function closeRun(run, reason) {
|
|
227
|
+
const sub = {
|
|
228
|
+
messages: run.messages,
|
|
229
|
+
pending: [],
|
|
230
|
+
error: null,
|
|
231
|
+
seq: -1,
|
|
232
|
+
runMessageIds: run.messages.map((message) => message.id),
|
|
233
|
+
loadedTools: []
|
|
234
|
+
};
|
|
235
|
+
return {
|
|
236
|
+
...run,
|
|
237
|
+
finishReason: reason,
|
|
238
|
+
messages: finishUnended(sub, reason)
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Run one update over the tool-call part with this id, wherever it lives.
|
|
243
|
+
*
|
|
244
|
+
* `tool-progress` and `nested-event` are the only events that name a tool call
|
|
245
|
+
* without naming a message, so the message has to be found rather than told —
|
|
246
|
+
* a scan from the newest end, because the call being worked on is the one that
|
|
247
|
+
* just arrived.
|
|
248
|
+
*
|
|
249
|
+
* **A frame for a tool call this transcript does not have is dropped.** That is
|
|
250
|
+
* the mid-run `/attach` case the whole file is written for, and it is the one
|
|
251
|
+
* place the usual answer — create it on the spot — is the wrong one: a
|
|
252
|
+
* `ToolCallPart` needs a tool `name` and an `input`, and neither is on these
|
|
253
|
+
* events. Inventing them would put a tool call the model never made into the
|
|
254
|
+
* transcript, under a name that is not a key of the agent's tools, which breaks
|
|
255
|
+
* the narrowing that is the reason the shapes are carried into the browser at
|
|
256
|
+
* all. So the progress is lost and the transcript stays true, rather than the
|
|
257
|
+
* other way round.
|
|
258
|
+
*
|
|
259
|
+
* In practice a client that has the cursor has the part: `useChat` documents
|
|
260
|
+
* `cursor` and `initialMessages` as things to persist together, and a client
|
|
261
|
+
* that persisted one without the other is already the case that doubles text.
|
|
262
|
+
* Parking the orphans until their tool call shows up was considered and is not
|
|
263
|
+
* worth it — the call is *earlier* in the stream, never later, so nothing would
|
|
264
|
+
* ever drain the parking lot.
|
|
265
|
+
*/
|
|
266
|
+
function withToolCall(state, toolCallId, update) {
|
|
267
|
+
for (let i = state.messages.length - 1; i >= 0; i--) {
|
|
268
|
+
const message = state.messages[i];
|
|
269
|
+
const index = message.content.findIndex((part) => part.type === "tool-call" && part.toolCallId === toolCallId);
|
|
270
|
+
if (index === -1) continue;
|
|
271
|
+
if (message.finishReason !== void 0 && !isReenterable(state, message, toolCallId)) return state;
|
|
272
|
+
const content = message.content.slice();
|
|
273
|
+
content[index] = update(content[index]);
|
|
274
|
+
const messages = state.messages.slice();
|
|
275
|
+
messages[i] = {
|
|
276
|
+
...message,
|
|
277
|
+
content
|
|
278
|
+
};
|
|
279
|
+
const runMessageIds = state.runMessageIds.includes(message.id) ? state.runMessageIds : [...state.runMessageIds, message.id];
|
|
280
|
+
return {
|
|
281
|
+
...state,
|
|
282
|
+
runMessageIds,
|
|
283
|
+
messages
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return state;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* One sub-run event, applied to the sub-run's own transcript.
|
|
290
|
+
*
|
|
291
|
+
* The sub-state is built and thrown away per event rather than kept, because
|
|
292
|
+
* there is nowhere to keep it: `NestedRun` is a wire type and holds a
|
|
293
|
+
* transcript, not a reducer state. Everything the inner `reduce` needs is
|
|
294
|
+
* derivable from it.
|
|
295
|
+
*
|
|
296
|
+
* - `seq` is `-1` and this calls `reduce`, not `applyFrame`. A nested event
|
|
297
|
+
* has no sequence of its own; it is numbered in the *parent's* stream, and
|
|
298
|
+
* the parent's cursor has already refused the duplicates.
|
|
299
|
+
* - `runMessageIds` is every message in the transcript, which for a nested
|
|
300
|
+
* run is exactly right and needs none of the parent's care. The parent
|
|
301
|
+
* scopes it because its list mixes runs; a `NestedRun` is keyed by `runId`
|
|
302
|
+
* and only ever written by events carrying that id, so every message in it
|
|
303
|
+
* belongs to the run that is ending.
|
|
304
|
+
* - `pending` is dropped. An escalated question reaches the user on the
|
|
305
|
+
* *parent's* `awaiting-input`, carrying the path that says which tool to
|
|
306
|
+
* re-enter; the copy inside the sub-run's own stream carries no path, so
|
|
307
|
+
* surfacing it would show the question twice and make one of them
|
|
308
|
+
* unanswerable.
|
|
309
|
+
*/
|
|
310
|
+
function applyNested(run, event, now) {
|
|
311
|
+
const next = reduce({
|
|
312
|
+
messages: run.messages,
|
|
313
|
+
pending: [],
|
|
314
|
+
error: null,
|
|
315
|
+
seq: -1,
|
|
316
|
+
runMessageIds: run.messages.map((message) => message.id),
|
|
317
|
+
loadedTools: [],
|
|
318
|
+
finishReason: run.finishReason
|
|
319
|
+
}, event.event, now);
|
|
320
|
+
return {
|
|
321
|
+
...run,
|
|
322
|
+
...event.label !== void 0 ? { label: event.label } : {},
|
|
323
|
+
messages: next.messages,
|
|
324
|
+
...next.finishReason !== void 0 ? { finishReason: next.finishReason } : {},
|
|
325
|
+
...event.event.type === "usage" ? { usage: event.event.usage } : {}
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Whether a finished message's tool call can still be written to.
|
|
330
|
+
*
|
|
331
|
+
* Exactly one message can say yes: the one a run parked on, holding a call
|
|
332
|
+
* nobody has answered yet. That is the resume turn — the tool is re-entered
|
|
333
|
+
* from the top and streams progress and sub-run events against a message that
|
|
334
|
+
* closed on the previous turn.
|
|
335
|
+
*
|
|
336
|
+
* "Open" is the server's own test, `Agent.openCalls`: a tool call with no
|
|
337
|
+
* `tool-result` for it anywhere in the transcript. The result is the thing that
|
|
338
|
+
* says the re-entry finished, and it always arrives *after* the frames this
|
|
339
|
+
* guards — `attachToHistory` emits it once `executeTool` returns.
|
|
340
|
+
*/
|
|
341
|
+
function isReenterable(state, message, toolCallId) {
|
|
342
|
+
if (message.finishReason !== "awaiting-input") return false;
|
|
343
|
+
return !state.messages.some((candidate) => candidate.content.some((part) => part.type === "tool-result" && part.toolCallId === toolCallId));
|
|
344
|
+
}
|
|
345
|
+
function isFinished(state, messageId) {
|
|
346
|
+
const message = state.messages.find((candidate) => candidate.id === messageId);
|
|
347
|
+
return message !== void 0 && message.finishReason !== void 0;
|
|
348
|
+
}
|
|
349
|
+
function lastIndexWhere(items, match) {
|
|
350
|
+
for (let i = items.length - 1; i >= 0; i--) if (match(items[i])) return i;
|
|
351
|
+
return -1;
|
|
352
|
+
}
|
|
353
|
+
function withMessage(state, messageId, now, update) {
|
|
354
|
+
const index = state.messages.findIndex((message) => message.id === messageId);
|
|
355
|
+
const runMessageIds = state.runMessageIds.includes(messageId) ? state.runMessageIds : [...state.runMessageIds, messageId];
|
|
356
|
+
if (index === -1) {
|
|
357
|
+
const blank = {
|
|
358
|
+
id: messageId,
|
|
359
|
+
role: "assistant",
|
|
360
|
+
content: [],
|
|
361
|
+
createdAt: now
|
|
362
|
+
};
|
|
363
|
+
return {
|
|
364
|
+
...state,
|
|
365
|
+
runMessageIds,
|
|
366
|
+
messages: [...state.messages, update(blank)]
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const messages = state.messages.slice();
|
|
370
|
+
messages[index] = update(messages[index]);
|
|
371
|
+
return {
|
|
372
|
+
...state,
|
|
373
|
+
runMessageIds,
|
|
374
|
+
messages
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function appendText(content, delta) {
|
|
378
|
+
const last = content[content.length - 1];
|
|
379
|
+
if (last && last.type === "text") return [...content.slice(0, -1), {
|
|
380
|
+
type: "text",
|
|
381
|
+
text: last.text + delta
|
|
382
|
+
}];
|
|
383
|
+
return [...content, {
|
|
384
|
+
type: "text",
|
|
385
|
+
text: delta
|
|
386
|
+
}];
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Mirrors `appendReasoning` in Agent.ts, including its merge rule: deltas join
|
|
390
|
+
* the last part only when the reasoning-item id matches, so a step that
|
|
391
|
+
* produced two items stays two parts instead of flattening into one under the
|
|
392
|
+
* wrong id. The two implementations have to agree, because a stateless client
|
|
393
|
+
* posts this content straight back and the provider matches on that id.
|
|
394
|
+
*/
|
|
395
|
+
function appendReasoning(content, delta, id) {
|
|
396
|
+
const last = content[content.length - 1];
|
|
397
|
+
if (last && last.type === "reasoning" && last.id === id) return [...content.slice(0, -1), {
|
|
398
|
+
...last,
|
|
399
|
+
text: (last.text ?? "") + delta
|
|
400
|
+
}];
|
|
401
|
+
return [...content, id ? {
|
|
402
|
+
type: "reasoning",
|
|
403
|
+
id,
|
|
404
|
+
text: delta
|
|
405
|
+
} : {
|
|
406
|
+
type: "reasoning",
|
|
407
|
+
text: delta
|
|
408
|
+
}];
|
|
409
|
+
}
|
|
410
|
+
function upsert(content, part, match) {
|
|
411
|
+
const index = content.findIndex(match);
|
|
412
|
+
if (index === -1) return [...content, part];
|
|
413
|
+
const next = content.slice();
|
|
414
|
+
next[index] = part;
|
|
415
|
+
return next;
|
|
416
|
+
}
|
|
417
|
+
//#endregion
|
|
418
|
+
//#region ai/client/sse.ts
|
|
419
|
+
/**
|
|
420
|
+
* Bytes to frames.
|
|
421
|
+
*
|
|
422
|
+
* Split out of the hook because none of it is React: it is a byte-level parser,
|
|
423
|
+
* and the interesting cases — a chunk boundary landing inside a `data:` line, a
|
|
424
|
+
* multi-byte character split across two reads, a stream that dies mid-event —
|
|
425
|
+
* are all reachable from a test with a string and no DOM.
|
|
426
|
+
*
|
|
427
|
+
* Deliberately not `EventSource`. The agent routes are POSTs carrying a turn,
|
|
428
|
+
* `EventSource` can only GET, and reattachment wants the cursor in the request
|
|
429
|
+
* body next to the thread id rather than in a header the browser owns.
|
|
430
|
+
*/
|
|
431
|
+
/**
|
|
432
|
+
* Feed it chunks, get frames. Holds whatever is left over between calls, which
|
|
433
|
+
* is the whole point: `push()` is called once per `reader.read()` and those
|
|
434
|
+
* boundaries have nothing to do with event boundaries.
|
|
435
|
+
*/
|
|
436
|
+
var SSEFrameDecoder = class {
|
|
437
|
+
buffer = "";
|
|
438
|
+
textDecoder = new TextDecoder();
|
|
439
|
+
/**
|
|
440
|
+
* A `\r` at the very end of a chunk is ambiguous — the next chunk may open
|
|
441
|
+
* with the `\n` that completes a CRLF, or with the next line. Holding it back
|
|
442
|
+
* for one chunk is what keeps a boundary there from being read as a blank
|
|
443
|
+
* line and cutting an event in half.
|
|
444
|
+
*/
|
|
445
|
+
pendingCR = false;
|
|
446
|
+
lastSeq = -1;
|
|
447
|
+
/** The cursor to resume from: the highest `id:` seen. */
|
|
448
|
+
get cursor() {
|
|
449
|
+
return this.lastSeq;
|
|
450
|
+
}
|
|
451
|
+
push(chunk) {
|
|
452
|
+
this.append(typeof chunk === "string" ? chunk : this.textDecoder.decode(chunk, { stream: true }));
|
|
453
|
+
return this.drain();
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* End of stream. Anything still buffered is an event with no terminating
|
|
457
|
+
* blank line, which the SSE spec discards and so does this — a truncated tail
|
|
458
|
+
* is usually truncated JSON, and half a `text-delta` appended to a message is
|
|
459
|
+
* worse than a missing one.
|
|
460
|
+
*/
|
|
461
|
+
flush() {
|
|
462
|
+
this.append(this.textDecoder.decode());
|
|
463
|
+
const frames = this.drain();
|
|
464
|
+
this.buffer = "";
|
|
465
|
+
this.pendingCR = false;
|
|
466
|
+
return frames;
|
|
467
|
+
}
|
|
468
|
+
/** Normalises every line ending to `\n` so the rest of the parser only has to
|
|
469
|
+
* know about one. */
|
|
470
|
+
append(text) {
|
|
471
|
+
if (text === "") return;
|
|
472
|
+
let rest = text;
|
|
473
|
+
if (this.pendingCR) {
|
|
474
|
+
this.buffer += "\n";
|
|
475
|
+
this.pendingCR = false;
|
|
476
|
+
if (rest.startsWith("\n")) rest = rest.slice(1);
|
|
477
|
+
}
|
|
478
|
+
if (rest.endsWith("\r")) {
|
|
479
|
+
rest = rest.slice(0, -1);
|
|
480
|
+
this.pendingCR = true;
|
|
481
|
+
}
|
|
482
|
+
this.buffer += rest.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
483
|
+
}
|
|
484
|
+
drain() {
|
|
485
|
+
const frames = [];
|
|
486
|
+
let boundary = this.buffer.indexOf("\n\n");
|
|
487
|
+
while (boundary !== -1) {
|
|
488
|
+
const block = this.buffer.slice(0, boundary);
|
|
489
|
+
this.buffer = this.buffer.slice(boundary + 2);
|
|
490
|
+
const frame = this.parse(block);
|
|
491
|
+
if (frame) frames.push(frame);
|
|
492
|
+
boundary = this.buffer.indexOf("\n\n");
|
|
493
|
+
}
|
|
494
|
+
return frames;
|
|
495
|
+
}
|
|
496
|
+
parse(block) {
|
|
497
|
+
let id;
|
|
498
|
+
const data = [];
|
|
499
|
+
for (const line of block.split("\n")) {
|
|
500
|
+
if (line === "" || line.startsWith(":")) continue;
|
|
501
|
+
const colon = line.indexOf(":");
|
|
502
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
503
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
504
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
505
|
+
if (field === "data") data.push(value);
|
|
506
|
+
else if (field === "id") id = value;
|
|
507
|
+
}
|
|
508
|
+
if (data.length === 0) return null;
|
|
509
|
+
let event;
|
|
510
|
+
try {
|
|
511
|
+
event = JSON.parse(data.join("\n"));
|
|
512
|
+
} catch {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
const parsed = id === void 0 || id === "" ? NaN : Number(id);
|
|
516
|
+
const seq = Number.isFinite(parsed) ? parsed : this.lastSeq + 1;
|
|
517
|
+
this.lastSeq = seq;
|
|
518
|
+
return {
|
|
519
|
+
seq,
|
|
520
|
+
event
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
/**
|
|
525
|
+
* The same decoder over a `Response.body`.
|
|
526
|
+
*
|
|
527
|
+
* Cancels the reader when the caller stops consuming — a `break` out of the
|
|
528
|
+
* `for await` is how the hook drops a stream on unmount, and without the cancel
|
|
529
|
+
* the connection stays open behind it.
|
|
530
|
+
*/
|
|
531
|
+
async function* decodeSSE(stream) {
|
|
532
|
+
if (!stream) return;
|
|
533
|
+
const reader = stream.getReader();
|
|
534
|
+
const decoder = new SSEFrameDecoder();
|
|
535
|
+
try {
|
|
536
|
+
while (true) {
|
|
537
|
+
const { done, value } = await reader.read();
|
|
538
|
+
if (done) {
|
|
539
|
+
yield* decoder.flush();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (value) yield* decoder.push(value);
|
|
543
|
+
}
|
|
544
|
+
} finally {
|
|
545
|
+
try {
|
|
546
|
+
await reader.cancel();
|
|
547
|
+
} catch {}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
//#endregion
|
|
551
|
+
//#region ai/useChat.tsx
|
|
552
|
+
var localIdCounter = 0;
|
|
553
|
+
/** Ids the client mints: messages it authored, and the correlation id it names a
|
|
554
|
+
* run by before the server has. Server-assigned ids are what keep a reattached
|
|
555
|
+
* stream from duplicating a message, so the two must not collide — hence the
|
|
556
|
+
* prefix, which also tells a server log which end invented an id. */
|
|
557
|
+
function localId() {
|
|
558
|
+
return `local_${typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}-${localIdCounter++}`}`;
|
|
559
|
+
}
|
|
560
|
+
/** The same substitution `useMutation` does, so a parameterised agent path
|
|
561
|
+
* behaves like every other route in the package. */
|
|
562
|
+
function applyRouteParams(url, params) {
|
|
563
|
+
let out = url;
|
|
564
|
+
for (const [key, value] of Object.entries(params)) out = out.replace(`:${key}?`, value).replace(`:${key}`, value);
|
|
565
|
+
return out;
|
|
566
|
+
}
|
|
567
|
+
function isAbort(error) {
|
|
568
|
+
return typeof error === "object" && error !== null && "name" in error && error.name === "AbortError";
|
|
569
|
+
}
|
|
570
|
+
async function httpError(response) {
|
|
571
|
+
let message = response.statusText || `Request failed with status ${response.status}`;
|
|
572
|
+
let code = response.status === 429 ? "rate_limited" : "unknown";
|
|
573
|
+
try {
|
|
574
|
+
const data = await response.json();
|
|
575
|
+
message = data?.error?.message ?? data?.message ?? message;
|
|
576
|
+
if (data?.error?.code === "thread_not_found") code = "thread_not_found";
|
|
577
|
+
} catch {}
|
|
578
|
+
return {
|
|
579
|
+
code,
|
|
580
|
+
message,
|
|
581
|
+
retryable: response.status === 429 || response.status >= 500
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
function turnFrom(message) {
|
|
585
|
+
let text = "";
|
|
586
|
+
const files = [];
|
|
587
|
+
for (const part of message.content) {
|
|
588
|
+
if (part.type === "text") text += part.text;
|
|
589
|
+
if (part.type === "file") files.push({
|
|
590
|
+
fileId: part.fileId,
|
|
591
|
+
name: part.name,
|
|
592
|
+
mimeType: part.mimeType
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
...text ? { text } : {},
|
|
597
|
+
...files.length > 0 ? { files } : {}
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* The transcript as the server needs it, without the progress logs.
|
|
602
|
+
*
|
|
603
|
+
* In stateless mode `send` posts the whole history on *every* turn, so anything
|
|
604
|
+
* that accumulates on a message is paid for again on each one. `progress` is
|
|
605
|
+
* the only part of the transcript that grows without a bound the model imposes:
|
|
606
|
+
* a generator tool yielding per chunk writes an entry per chunk, and a tool that
|
|
607
|
+
* yielded five thousand times would put five thousand objects in the body of
|
|
608
|
+
* turn 2, turn 3, and every turn after, forever.
|
|
609
|
+
*
|
|
610
|
+
* Nothing server-side reads it. It is not translated into a provider message,
|
|
611
|
+
* `openCalls` matches on `toolCallId`, and the resume path replays a sub-agent
|
|
612
|
+
* from `nested` — which is why `nested` is kept here, recursed into rather than
|
|
613
|
+
* dropped, while `progress` is not. The client's own copy is untouched: this
|
|
614
|
+
* shapes the request body only, so a UI still renders every yield it received.
|
|
615
|
+
*/
|
|
616
|
+
function forWire(messages) {
|
|
617
|
+
return messages.map((message) => {
|
|
618
|
+
if (!message.content.some((part) => part.type === "tool-call" && (part.progress || part.nested))) return message;
|
|
619
|
+
return {
|
|
620
|
+
...message,
|
|
621
|
+
content: message.content.map((part) => {
|
|
622
|
+
if (part.type !== "tool-call") return part;
|
|
623
|
+
const { progress: _progress, ...rest } = part;
|
|
624
|
+
return rest.nested ? {
|
|
625
|
+
...rest,
|
|
626
|
+
nested: rest.nested.map((run) => ({
|
|
627
|
+
...run,
|
|
628
|
+
messages: forWire(run.messages)
|
|
629
|
+
}))
|
|
630
|
+
} : rest;
|
|
631
|
+
})
|
|
632
|
+
};
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* The path is the agent's route, exactly as mounted:
|
|
637
|
+
*
|
|
638
|
+
* const { messages, sendMessage } = useChat("/chat")
|
|
639
|
+
*
|
|
640
|
+
* and a tool part inside `messages` narrows on `name`, giving `input` and
|
|
641
|
+
* `output` their real types — which is the whole reason the tool tuple is
|
|
642
|
+
* carried through `Agent`, `AgentRoute` and `RPC` instead of being erased at the
|
|
643
|
+
* first boundary.
|
|
644
|
+
*/
|
|
645
|
+
function useChat(path, params = {}) {
|
|
646
|
+
const { threadId: initialThreadId, initialMessages, cursor: initialCursor, attach: attachOnMount = true, body: extraBody, headers, onFinish, onError, onAwaitingInput, onAttachMiss } = params;
|
|
647
|
+
const routeParams = useParams();
|
|
648
|
+
const base = useMemo(() => `/api${applyRouteParams(String(path).replace(/^[A-Z]+:/, ""), routeParams)}`, [path, routeParams]);
|
|
649
|
+
/**
|
|
650
|
+
* The state lives in a ref and is mirrored into `useState` for rendering.
|
|
651
|
+
*
|
|
652
|
+
* A stream applies frames faster than React commits, and the reducer needs
|
|
653
|
+
* the result of the previous frame, not the value captured when the loop
|
|
654
|
+
* started. The ref is the source of truth; `setState` is the notification.
|
|
655
|
+
*
|
|
656
|
+
* `any` inside, exact at the boundary: the wire carries erased tool shapes and
|
|
657
|
+
* the route's real ones are re-asserted once, in the returned object.
|
|
658
|
+
*/
|
|
659
|
+
const stateRef = useRef(null);
|
|
660
|
+
if (stateRef.current === null) stateRef.current = initialChatState({
|
|
661
|
+
messages: initialMessages,
|
|
662
|
+
threadId: initialThreadId,
|
|
663
|
+
seq: initialCursor?.seq,
|
|
664
|
+
cursorRunId: initialCursor?.runId
|
|
665
|
+
});
|
|
666
|
+
const [state, setState] = useState(stateRef.current);
|
|
667
|
+
const [phase, setPhase] = useState("idle");
|
|
668
|
+
const mountedRef = useRef(true);
|
|
669
|
+
const abortRef = useRef(null);
|
|
670
|
+
const handlers = useRef({
|
|
671
|
+
onFinish,
|
|
672
|
+
onError,
|
|
673
|
+
onAwaitingInput,
|
|
674
|
+
onAttachMiss
|
|
675
|
+
});
|
|
676
|
+
handlers.current = {
|
|
677
|
+
onFinish,
|
|
678
|
+
onError,
|
|
679
|
+
onAwaitingInput,
|
|
680
|
+
onAttachMiss
|
|
681
|
+
};
|
|
682
|
+
const requestRef = useRef({
|
|
683
|
+
base,
|
|
684
|
+
headers,
|
|
685
|
+
extraBody
|
|
686
|
+
});
|
|
687
|
+
requestRef.current = {
|
|
688
|
+
base,
|
|
689
|
+
headers,
|
|
690
|
+
extraBody
|
|
691
|
+
};
|
|
692
|
+
useEffect(() => {
|
|
693
|
+
mountedRef.current = true;
|
|
694
|
+
return () => {
|
|
695
|
+
mountedRef.current = false;
|
|
696
|
+
abortRef.current?.controller.abort();
|
|
697
|
+
abortRef.current = null;
|
|
698
|
+
};
|
|
699
|
+
}, []);
|
|
700
|
+
const commit = useCallback((next) => {
|
|
701
|
+
stateRef.current = next;
|
|
702
|
+
if (mountedRef.current) setState(next);
|
|
703
|
+
}, []);
|
|
704
|
+
const setPhaseSafe = useCallback((next) => {
|
|
705
|
+
if (mountedRef.current) setPhase(next);
|
|
706
|
+
}, []);
|
|
707
|
+
/**
|
|
708
|
+
* Report a failure without touching the conversation.
|
|
709
|
+
*
|
|
710
|
+
* `pending` deliberately survives. Most of what fails here has nothing to do
|
|
711
|
+
* with the question the conversation is holding — an upload that 500s, an
|
|
712
|
+
* answer aimed at a call this client never had — and the pending calls carry
|
|
713
|
+
* the only signatures that can answer them, held nowhere else. Dropping them
|
|
714
|
+
* because an unrelated request failed would leave the user unable to approve
|
|
715
|
+
* anything at all, with a fresh turn (which the server reads as refusing
|
|
716
|
+
* everything) the only way out.
|
|
717
|
+
*
|
|
718
|
+
* The failures that really do end the turn clear `pending` where they happen:
|
|
719
|
+
* `send` clears it as the turn goes out, and the stream's own `error` event
|
|
720
|
+
* clears it in the reducer.
|
|
721
|
+
*/
|
|
722
|
+
const fail = useCallback((error) => {
|
|
723
|
+
commit({
|
|
724
|
+
...stateRef.current,
|
|
725
|
+
error
|
|
726
|
+
});
|
|
727
|
+
handlers.current.onError?.(error);
|
|
728
|
+
}, [commit]);
|
|
729
|
+
const post = useCallback((url, payload, signal) => {
|
|
730
|
+
const { headers: extraHeaders } = requestRef.current;
|
|
731
|
+
return fetch(url, {
|
|
732
|
+
method: "POST",
|
|
733
|
+
headers: {
|
|
734
|
+
"Content-Type": "application/json",
|
|
735
|
+
...extraHeaders
|
|
736
|
+
},
|
|
737
|
+
body: JSON.stringify(payload),
|
|
738
|
+
signal
|
|
739
|
+
});
|
|
740
|
+
}, []);
|
|
741
|
+
/**
|
|
742
|
+
* The server call that ends a run. Shared by `stop()` and by a `send` that
|
|
743
|
+
* supersedes one; what each does with a failure is its own, because a stop
|
|
744
|
+
* that did not land is a run still working through its tool loop and still
|
|
745
|
+
* billing, behind a transcript that says it was cut — and whose problem that
|
|
746
|
+
* is depends on who asked.
|
|
747
|
+
*/
|
|
748
|
+
const postStop = useCallback(async (body, report) => {
|
|
749
|
+
const { base: url } = requestRef.current;
|
|
750
|
+
try {
|
|
751
|
+
const response = await post(`${url}/stop`, body);
|
|
752
|
+
if (!response.ok) report(await httpError(response));
|
|
753
|
+
} catch (error) {
|
|
754
|
+
if (isAbort(error)) return;
|
|
755
|
+
report({
|
|
756
|
+
code: "unknown",
|
|
757
|
+
message: error instanceof Error ? error.message : String(error),
|
|
758
|
+
retryable: true
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
}, [post]);
|
|
762
|
+
const consume = useCallback(async (response, signal) => {
|
|
763
|
+
for await (const frame of decodeSSE(response.body)) {
|
|
764
|
+
if (!mountedRef.current || signal.aborted) return;
|
|
765
|
+
setPhaseSafe("streaming");
|
|
766
|
+
const previous = stateRef.current;
|
|
767
|
+
const next = applyFrame(previous, frame);
|
|
768
|
+
if (next === previous) continue;
|
|
769
|
+
commit(next);
|
|
770
|
+
const event = frame.event;
|
|
771
|
+
if (event.type === "message-end") {
|
|
772
|
+
const before = previous.messages.find((m) => m.id === event.messageId);
|
|
773
|
+
const message = next.messages.find((m) => m.id === event.messageId);
|
|
774
|
+
if (message && before?.finishReason === void 0) handlers.current.onFinish?.(message);
|
|
775
|
+
} else if (event.type === "awaiting-input") handlers.current.onAwaitingInput?.(event.pending);
|
|
776
|
+
else if (event.type === "error") handlers.current.onError?.(event.error);
|
|
777
|
+
}
|
|
778
|
+
}, [commit, setPhaseSafe]);
|
|
779
|
+
const send = useCallback(async (turn) => {
|
|
780
|
+
const { base: url, extraBody: body } = requestRef.current;
|
|
781
|
+
const superseded = abortRef.current;
|
|
782
|
+
superseded?.controller.abort();
|
|
783
|
+
const clientRunId = localId();
|
|
784
|
+
const controller = new AbortController();
|
|
785
|
+
abortRef.current = {
|
|
786
|
+
controller,
|
|
787
|
+
clientRunId
|
|
788
|
+
};
|
|
789
|
+
if (superseded) {
|
|
790
|
+
const { runId, threadId } = stateRef.current;
|
|
791
|
+
const body = {
|
|
792
|
+
...runId ? { runId } : {},
|
|
793
|
+
...superseded.clientRunId ? { clientRunId: superseded.clientRunId } : {}
|
|
794
|
+
};
|
|
795
|
+
if (Object.keys(body).length > 0) postStop(body, threadId ? () => {} : (error) => handlers.current.onError?.(error));
|
|
796
|
+
}
|
|
797
|
+
const previous = superseded ? markAborted(stateRef.current) : stateRef.current;
|
|
798
|
+
const history = previous.messages;
|
|
799
|
+
const authored = turn.text || turn.files?.length ? [{
|
|
800
|
+
id: localId(),
|
|
801
|
+
role: "user",
|
|
802
|
+
content: [...turn.text ? [{
|
|
803
|
+
type: "text",
|
|
804
|
+
text: turn.text
|
|
805
|
+
}] : [], ...(turn.files ?? []).map((file) => ({
|
|
806
|
+
type: "file",
|
|
807
|
+
...file
|
|
808
|
+
}))],
|
|
809
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
810
|
+
}] : [];
|
|
811
|
+
commit({
|
|
812
|
+
...previous,
|
|
813
|
+
messages: [...history, ...authored],
|
|
814
|
+
error: null,
|
|
815
|
+
pending: []
|
|
816
|
+
});
|
|
817
|
+
setPhaseSafe("submitted");
|
|
818
|
+
try {
|
|
819
|
+
const payload = {
|
|
820
|
+
turn,
|
|
821
|
+
clientRunId,
|
|
822
|
+
...stateRef.current.threadId ? { threadId: stateRef.current.threadId } : { messages: forWire(history) },
|
|
823
|
+
...body
|
|
824
|
+
};
|
|
825
|
+
const response = await post(url, payload, controller.signal);
|
|
826
|
+
if (!response.ok) {
|
|
827
|
+
fail(await httpError(response));
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
await consume(response, controller.signal);
|
|
831
|
+
} catch (error) {
|
|
832
|
+
if (!isAbort(error)) fail({
|
|
833
|
+
code: "unknown",
|
|
834
|
+
message: error instanceof Error ? error.message : String(error),
|
|
835
|
+
retryable: true
|
|
836
|
+
});
|
|
837
|
+
} finally {
|
|
838
|
+
if (abortRef.current?.controller === controller) {
|
|
839
|
+
abortRef.current = null;
|
|
840
|
+
setPhaseSafe("idle");
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
}, [
|
|
844
|
+
commit,
|
|
845
|
+
consume,
|
|
846
|
+
fail,
|
|
847
|
+
post,
|
|
848
|
+
postStop,
|
|
849
|
+
setPhaseSafe
|
|
850
|
+
]);
|
|
851
|
+
const sendMessage = useCallback((turn) => send(typeof turn === "string" ? { text: turn } : turn), [send]);
|
|
852
|
+
/**
|
|
853
|
+
* Answers queued within one tick become one turn.
|
|
854
|
+
*
|
|
855
|
+
* This is not an optimisation. A turn that leaves a pending call unanswered
|
|
856
|
+
* denies it, so sending three approvals as three turns would have the first
|
|
857
|
+
* one refuse the other two — and the natural UI code, a loop over `pending`
|
|
858
|
+
* calling `approve` for each, is exactly that shape. Coalescing on the
|
|
859
|
+
* microtask is what makes the declared per-call signature safe to use the way
|
|
860
|
+
* it reads.
|
|
861
|
+
*/
|
|
862
|
+
const queued = useRef(null);
|
|
863
|
+
const queueResult = useCallback((result) => {
|
|
864
|
+
if (queued.current) {
|
|
865
|
+
queued.current.results.push(result);
|
|
866
|
+
return queued.current.flushed;
|
|
867
|
+
}
|
|
868
|
+
const results = [result];
|
|
869
|
+
const flushed = Promise.resolve().then(() => {
|
|
870
|
+
queued.current = null;
|
|
871
|
+
return send({ toolResults: results });
|
|
872
|
+
});
|
|
873
|
+
queued.current = {
|
|
874
|
+
results,
|
|
875
|
+
flushed
|
|
876
|
+
};
|
|
877
|
+
return flushed;
|
|
878
|
+
}, [send]);
|
|
879
|
+
/**
|
|
880
|
+
* The half of a `ClientToolResult` the app never writes: the id, the
|
|
881
|
+
* signature, and the path.
|
|
882
|
+
*
|
|
883
|
+
* All three are carried by the hook and handed back untouched, which is what
|
|
884
|
+
* makes a sub-agent's question answerable with the same two lines as a
|
|
885
|
+
* top-level one. `path` is absent for a call the parent's own tools made, so
|
|
886
|
+
* an app cannot tell from its own code where the question came from — the only
|
|
887
|
+
* difference is that `pending[i].path` is there to render if it wants to say
|
|
888
|
+
* which agent is asking.
|
|
889
|
+
*/
|
|
890
|
+
const resultFor = useCallback((toolCallId, build) => {
|
|
891
|
+
const matches = stateRef.current.pending.filter((candidate) => candidate.toolCallId === toolCallId);
|
|
892
|
+
const call = matches[0];
|
|
893
|
+
if (!call) {
|
|
894
|
+
fail({
|
|
895
|
+
code: "invalid_tool_result",
|
|
896
|
+
message: `No pending tool call ${toolCallId}`,
|
|
897
|
+
toolCallId,
|
|
898
|
+
retryable: false
|
|
899
|
+
});
|
|
900
|
+
return null;
|
|
901
|
+
}
|
|
902
|
+
if (matches.length > 1) {
|
|
903
|
+
fail({
|
|
904
|
+
code: "invalid_tool_result",
|
|
905
|
+
message: `Ambiguous tool call ${toolCallId}: ${matches.length} pending calls share it`,
|
|
906
|
+
toolCallId,
|
|
907
|
+
retryable: false
|
|
908
|
+
});
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
return build({
|
|
912
|
+
toolCallId,
|
|
913
|
+
signature: call.signature,
|
|
914
|
+
...call.path ? { path: call.path } : {}
|
|
915
|
+
});
|
|
916
|
+
}, [fail]);
|
|
917
|
+
const approve = useCallback(async (toolCallId, approved, reason) => {
|
|
918
|
+
const result = resultFor(toolCallId, (base) => ({
|
|
919
|
+
...base,
|
|
920
|
+
approve: approved,
|
|
921
|
+
...reason ? { reason } : {}
|
|
922
|
+
}));
|
|
923
|
+
if (result) await queueResult(result);
|
|
924
|
+
}, [queueResult, resultFor]);
|
|
925
|
+
const answer = useCallback(async (toolCallId, output) => {
|
|
926
|
+
const result = resultFor(toolCallId, (base) => ({
|
|
927
|
+
...base,
|
|
928
|
+
output
|
|
929
|
+
}));
|
|
930
|
+
if (result) await queueResult(result);
|
|
931
|
+
}, [queueResult, resultFor]);
|
|
932
|
+
const stop = useCallback(async () => {
|
|
933
|
+
const { runId, threadId } = stateRef.current;
|
|
934
|
+
const inFlight = abortRef.current;
|
|
935
|
+
inFlight?.controller.abort();
|
|
936
|
+
abortRef.current = null;
|
|
937
|
+
commit(markAborted(stateRef.current));
|
|
938
|
+
setPhaseSafe("idle");
|
|
939
|
+
if (!inFlight && !runId) return;
|
|
940
|
+
const body = {
|
|
941
|
+
...runId ? { runId } : {},
|
|
942
|
+
...threadId ? { threadId } : {},
|
|
943
|
+
...inFlight?.clientRunId ? { clientRunId: inFlight.clientRunId } : {}
|
|
944
|
+
};
|
|
945
|
+
await postStop(body, fail);
|
|
946
|
+
}, [
|
|
947
|
+
commit,
|
|
948
|
+
fail,
|
|
949
|
+
postStop,
|
|
950
|
+
setPhaseSafe
|
|
951
|
+
]);
|
|
952
|
+
const regenerate = useCallback(async () => {
|
|
953
|
+
const messages = stateRef.current.messages;
|
|
954
|
+
let assistantIndex = -1;
|
|
955
|
+
for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "assistant") {
|
|
956
|
+
assistantIndex = i;
|
|
957
|
+
break;
|
|
958
|
+
}
|
|
959
|
+
if (assistantIndex === -1) return;
|
|
960
|
+
let userIndex = -1;
|
|
961
|
+
for (let i = assistantIndex - 1; i >= 0; i--) if (messages[i].role === "user") {
|
|
962
|
+
userIndex = i;
|
|
963
|
+
break;
|
|
964
|
+
}
|
|
965
|
+
if (userIndex === -1) return;
|
|
966
|
+
const turn = turnFrom(messages[userIndex]);
|
|
967
|
+
commit({
|
|
968
|
+
...stateRef.current,
|
|
969
|
+
messages: messages.slice(0, userIndex),
|
|
970
|
+
pending: [],
|
|
971
|
+
error: null
|
|
972
|
+
});
|
|
973
|
+
await send(turn);
|
|
974
|
+
}, [commit, send]);
|
|
975
|
+
const setMessages = useCallback((messages) => {
|
|
976
|
+
commit({
|
|
977
|
+
...stateRef.current,
|
|
978
|
+
messages
|
|
979
|
+
});
|
|
980
|
+
}, [commit]);
|
|
981
|
+
const uploadFile = useCallback(async (file) => {
|
|
982
|
+
const { base: url, headers: extraHeaders } = requestRef.current;
|
|
983
|
+
const form = new FormData();
|
|
984
|
+
form.append("file", file);
|
|
985
|
+
const response = await fetch(`${url}/files`, {
|
|
986
|
+
method: "POST",
|
|
987
|
+
headers: { ...extraHeaders },
|
|
988
|
+
body: form
|
|
989
|
+
});
|
|
990
|
+
if (!response.ok) {
|
|
991
|
+
const error = await httpError(response);
|
|
992
|
+
fail(error);
|
|
993
|
+
throw new Error(error.message);
|
|
994
|
+
}
|
|
995
|
+
const data = await response.json();
|
|
996
|
+
return {
|
|
997
|
+
fileId: data.fileId,
|
|
998
|
+
name: data.name ?? file.name,
|
|
999
|
+
mimeType: data.mimeType ?? file.type
|
|
1000
|
+
};
|
|
1001
|
+
}, [fail]);
|
|
1002
|
+
useEffect(() => {
|
|
1003
|
+
if (attachOnMount === false || !initialThreadId) return;
|
|
1004
|
+
const controller = new AbortController();
|
|
1005
|
+
abortRef.current = { controller };
|
|
1006
|
+
(async () => {
|
|
1007
|
+
try {
|
|
1008
|
+
const body = {
|
|
1009
|
+
threadId: initialThreadId,
|
|
1010
|
+
cursor: stateRef.current.seq,
|
|
1011
|
+
...stateRef.current.cursorRunId ? { runId: stateRef.current.cursorRunId } : {}
|
|
1012
|
+
};
|
|
1013
|
+
const response = await post(`${requestRef.current.base}/attach`, body, controller.signal);
|
|
1014
|
+
if (!response.ok || response.status === 204 || !response.body) {
|
|
1015
|
+
handlers.current.onAttachMiss?.({ threadId: initialThreadId });
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
await consume(response, controller.signal);
|
|
1019
|
+
} catch {} finally {
|
|
1020
|
+
if (abortRef.current?.controller === controller) {
|
|
1021
|
+
abortRef.current = null;
|
|
1022
|
+
setPhaseSafe("idle");
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
})();
|
|
1026
|
+
return () => controller.abort();
|
|
1027
|
+
}, []);
|
|
1028
|
+
const status = state.pending.length > 0 ? "awaiting-input" : phase !== "idle" ? phase : state.error ? "error" : "idle";
|
|
1029
|
+
return {
|
|
1030
|
+
messages: state.messages,
|
|
1031
|
+
status,
|
|
1032
|
+
error: state.error,
|
|
1033
|
+
threadId: state.threadId,
|
|
1034
|
+
runId: state.runId,
|
|
1035
|
+
cursor: {
|
|
1036
|
+
runId: state.cursorRunId,
|
|
1037
|
+
seq: state.seq
|
|
1038
|
+
},
|
|
1039
|
+
sendMessage,
|
|
1040
|
+
stop,
|
|
1041
|
+
regenerate,
|
|
1042
|
+
setMessages,
|
|
1043
|
+
pending: state.pending,
|
|
1044
|
+
loadedTools: state.loadedTools,
|
|
1045
|
+
approve,
|
|
1046
|
+
answer,
|
|
1047
|
+
attach: uploadFile
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
//#endregion
|
|
1051
|
+
export { useChat };
|
|
1052
|
+
|
|
1053
|
+
//# sourceMappingURL=index.js.map
|