@xingjisoft/neutrino 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +398 -137
- package/assets/prompt.txt +4 -0
- package/dist/agents.js +114 -15
- package/dist/app.js +105 -4
- package/dist/bin.js +177 -5
- package/dist/colors.js +2 -0
- package/dist/compact.js +87 -0
- package/dist/config.js +121 -13
- package/dist/cwd.js +54 -0
- package/dist/header.js +8 -0
- package/dist/llm/approval.js +543 -0
- package/dist/llm/client.js +323 -84
- package/dist/llm/risk.js +377 -0
- package/dist/llm/tools.js +37 -21
- package/dist/persistence.js +51 -0
- package/dist/sanitize.js +61 -0
- package/dist/screens/agentHistory.js +7 -4
- package/dist/screens/chat.js +749 -44
- package/dist/screens/history.js +9 -7
- package/dist/screens/settings.js +201 -45
- package/dist/screens/start.js +64 -16
- package/dist/selection.js +25 -11
- package/dist/state.js +1 -0
- package/dist/terminal/buffer.js +24 -3
- package/dist/terminal/input.js +14 -3
- package/dist/terminal/renderer.js +72 -9
- package/dist/terminal/restore.js +147 -0
- package/dist/terminal/screen.js +15 -0
- package/dist/ui.js +19 -0
- package/dist/web/auth.js +82 -0
- package/dist/web/hub.js +180 -0
- package/dist/web/server.js +688 -0
- package/dist/web/session.js +572 -0
- package/dist/web/settings.js +101 -0
- package/dist/width.js +20 -0
- package/package.json +6 -4
- package/web/README.md +208 -0
- package/web/app.js +999 -0
- package/web/index.html +87 -0
- package/web/settings.js +193 -0
- package/web/style.css +941 -0
package/dist/llm/client.js
CHANGED
|
@@ -1,4 +1,138 @@
|
|
|
1
1
|
import { INTENSITY_PARAMS } from "../config.js";
|
|
2
|
+
/** How many extra times a request is retried when the connection drops. */
|
|
3
|
+
const NETWORK_RETRIES = 2;
|
|
4
|
+
/** Give up if the server has not started to respond within this window. */
|
|
5
|
+
const CONNECT_TIMEOUT_MS = 120_000;
|
|
6
|
+
/**
|
|
7
|
+
* System prompt used when compressing a long conversation. The instruction
|
|
8
|
+
* spells out exactly what must survive compression (goals, decisions, files,
|
|
9
|
+
* commands) so the summary stays useful for continuing the work.
|
|
10
|
+
*/
|
|
11
|
+
const SUMMARY_SYSTEM_PROMPT = "You compress the conversation history of an AI coding assistant so it fits in a smaller context window. " +
|
|
12
|
+
"Write a dense, factual summary in the same language as the conversation. Preserve: the user's goals and " +
|
|
13
|
+
"constraints, decisions that were made, files created or modified (with their paths), commands that were run " +
|
|
14
|
+
"and their meaningful results, and any unresolved problems or next steps. Drop pleasantries, restated " +
|
|
15
|
+
"instructions and redundant tool output. Output only the summary, with no preface.";
|
|
16
|
+
/**
|
|
17
|
+
* Terse transport messages that all mean "the connection dropped mid-flight".
|
|
18
|
+
* The \b keeps "terminated" from matching unrelated text such as
|
|
19
|
+
* "Unterminated string constant".
|
|
20
|
+
*/
|
|
21
|
+
const DROP_HINT_RE = /\bterminated\b|other side closed|socket hang up|premature close/i;
|
|
22
|
+
/**
|
|
23
|
+
* Flatten an error and its `cause` chain into one readable line.
|
|
24
|
+
*
|
|
25
|
+
* `fetch` reports almost every transport problem as a bare
|
|
26
|
+
* `TypeError: fetch failed`; the actual reason (`ECONNRESET`, `EMFILE`,
|
|
27
|
+
* `ENOTFOUND`, `UND_ERR_…`) is tucked away in `error.cause`. Without walking
|
|
28
|
+
* that chain a failure is impossible to diagnose, so this always reports it.
|
|
29
|
+
*/
|
|
30
|
+
export function describeError(e) {
|
|
31
|
+
if (!(e instanceof Error))
|
|
32
|
+
return String(e);
|
|
33
|
+
const parts = [];
|
|
34
|
+
const push = (msg, code) => {
|
|
35
|
+
const text = code && !msg.includes(code) ? `${msg} (${code})` : msg;
|
|
36
|
+
if (text && !parts.includes(text))
|
|
37
|
+
parts.push(text);
|
|
38
|
+
};
|
|
39
|
+
let cur = e;
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
while (cur != null && !seen.has(cur)) {
|
|
42
|
+
if (typeof cur === "string") {
|
|
43
|
+
// fetch/undici sometimes stash the real reason in a plain string
|
|
44
|
+
// (`cause: "other side closed"`), which an object-only walk would miss.
|
|
45
|
+
push(cur);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
if (typeof cur !== "object")
|
|
49
|
+
break;
|
|
50
|
+
seen.add(cur);
|
|
51
|
+
const err = cur;
|
|
52
|
+
const message = typeof err.message === "string" && err.message ? err.message : String(err.name ?? "Error");
|
|
53
|
+
const code = typeof err.code === "string" ? err.code : undefined;
|
|
54
|
+
push(message, code);
|
|
55
|
+
cur = err.cause;
|
|
56
|
+
}
|
|
57
|
+
const text = parts.join(": ") || e.message;
|
|
58
|
+
// A dropped mid-stream connection surfaces as a bare `TypeError: terminated`,
|
|
59
|
+
// which on its own tells the user nothing worth reading.
|
|
60
|
+
return DROP_HINT_RE.test(text) ? `${text} — the connection was closed before the response finished` : text;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Whether a failed request is worth retrying: a transient connection problem
|
|
64
|
+
* (reset / refused / DNS / timeout) rather than a real response from the API.
|
|
65
|
+
*/
|
|
66
|
+
function isRetriableNetworkError(e) {
|
|
67
|
+
const transient = new Set([
|
|
68
|
+
"ECONNRESET",
|
|
69
|
+
"ECONNREFUSED",
|
|
70
|
+
"ECONNABORTED",
|
|
71
|
+
"EPIPE",
|
|
72
|
+
"ETIMEDOUT",
|
|
73
|
+
"ENETUNREACH",
|
|
74
|
+
"ENETDOWN",
|
|
75
|
+
"EAI_AGAIN",
|
|
76
|
+
"ERR_STREAM_PREMATURE_CLOSE",
|
|
77
|
+
"UND_ERR_SOCKET",
|
|
78
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
79
|
+
]);
|
|
80
|
+
let cur = e;
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
while (cur != null && !seen.has(cur)) {
|
|
83
|
+
if (typeof cur === "string") {
|
|
84
|
+
if (DROP_HINT_RE.test(cur))
|
|
85
|
+
return true;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
if (typeof cur !== "object")
|
|
89
|
+
break;
|
|
90
|
+
seen.add(cur);
|
|
91
|
+
const err = cur;
|
|
92
|
+
if (typeof err.code === "string" && transient.has(err.code))
|
|
93
|
+
return true;
|
|
94
|
+
// Our own connect timeout aborts with a plain AbortError; undici wraps a
|
|
95
|
+
// dropped socket in a SocketError.
|
|
96
|
+
if (err.name === "AbortError" || err.name === "SocketError")
|
|
97
|
+
return true;
|
|
98
|
+
// A stream that dies mid-flight: "terminated", "socket hang up", …
|
|
99
|
+
if (typeof err.message === "string" && DROP_HINT_RE.test(err.message))
|
|
100
|
+
return true;
|
|
101
|
+
cur = err.cause;
|
|
102
|
+
}
|
|
103
|
+
// Undici's generic wrapper: assume the socket dropped and try again.
|
|
104
|
+
return e instanceof Error && /fetch failed/i.test(e.message);
|
|
105
|
+
}
|
|
106
|
+
function sleep(ms) {
|
|
107
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Issue a streaming request, retrying a few times on transient network errors
|
|
111
|
+
* and giving up with a clear error if the server never starts responding.
|
|
112
|
+
*
|
|
113
|
+
* Retries are safe here because they only ever happen *before* a response is
|
|
114
|
+
* returned; once bytes start flowing the caller is responsible for the stream.
|
|
115
|
+
*/
|
|
116
|
+
async function requestStream(url, init) {
|
|
117
|
+
let lastErr;
|
|
118
|
+
for (let attempt = 0; attempt <= NETWORK_RETRIES; attempt++) {
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
const timer = setTimeout(() => controller.abort(new Error(`request timed out after ${CONNECT_TIMEOUT_MS / 1000}s`)), CONNECT_TIMEOUT_MS);
|
|
121
|
+
try {
|
|
122
|
+
const resp = await fetch(url, { ...init, signal: controller.signal });
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
return resp;
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
lastErr = e;
|
|
129
|
+
if (attempt === NETWORK_RETRIES || !isRetriableNetworkError(e))
|
|
130
|
+
throw e;
|
|
131
|
+
await sleep(300 * (attempt + 1));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
throw lastErr;
|
|
135
|
+
}
|
|
2
136
|
export class LLMClient {
|
|
3
137
|
model;
|
|
4
138
|
provider;
|
|
@@ -15,6 +149,39 @@ export class LLMClient {
|
|
|
15
149
|
this.temperature = params.temperature;
|
|
16
150
|
this.top_p = params.top_p;
|
|
17
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Ask the model to compress a transcript into a short summary. Returns an
|
|
154
|
+
* empty string on any failure so the caller can fall back to simply dropping
|
|
155
|
+
* the old messages. Uses a throw-away request (no tools) via the normal
|
|
156
|
+
* streaming path, so provider differences are handled for free.
|
|
157
|
+
*/
|
|
158
|
+
async summarize(transcript) {
|
|
159
|
+
const messages = [
|
|
160
|
+
{ role: "system", content: SUMMARY_SYSTEM_PROMPT },
|
|
161
|
+
{ role: "user", content: transcript },
|
|
162
|
+
];
|
|
163
|
+
let out = "";
|
|
164
|
+
for await (const chunk of this.streamTurn(messages))
|
|
165
|
+
out += chunk;
|
|
166
|
+
const text = out.trim();
|
|
167
|
+
if (!text || text.startsWith("[Error"))
|
|
168
|
+
return "";
|
|
169
|
+
return text;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* One-shot completion with its own system prompt. The approval reviewers use
|
|
173
|
+
* this so they never share context with the conversation they are judging.
|
|
174
|
+
*/
|
|
175
|
+
async complete(system, user) {
|
|
176
|
+
let out = "";
|
|
177
|
+
for await (const chunk of this.streamTurn([
|
|
178
|
+
{ role: "system", content: system },
|
|
179
|
+
{ role: "user", content: user },
|
|
180
|
+
])) {
|
|
181
|
+
out += chunk;
|
|
182
|
+
}
|
|
183
|
+
return out.trim();
|
|
184
|
+
}
|
|
18
185
|
async *streamTurn(messages, tools) {
|
|
19
186
|
this.toolCalls = [];
|
|
20
187
|
this.usage = null;
|
|
@@ -26,10 +193,38 @@ export class LLMClient {
|
|
|
26
193
|
yield* this.streamOpenAI(messages, tools);
|
|
27
194
|
}
|
|
28
195
|
catch (e) {
|
|
29
|
-
yield `\n[Error: ${
|
|
196
|
+
yield `\n[Error: ${describeError(e)}]`;
|
|
30
197
|
}
|
|
31
198
|
}
|
|
199
|
+
/**
|
|
200
|
+
* Drive one request, retrying if the stream dies before it produced anything.
|
|
201
|
+
*
|
|
202
|
+
* A response can be cut off on the very first read — "terminated" when a
|
|
203
|
+
* proxy or the server closes the connection mid-flight. Nothing has reached
|
|
204
|
+
* the user yet, so re-issuing the request is invisible (and cannot duplicate
|
|
205
|
+
* text). Once a token has been emitted a retry is unsafe, so the error is
|
|
206
|
+
* surfaced instead.
|
|
207
|
+
*/
|
|
32
208
|
async *streamOpenAI(messages, tools) {
|
|
209
|
+
for (let attempt = 0;; attempt++) {
|
|
210
|
+
this.toolCalls = [];
|
|
211
|
+
let produced = false;
|
|
212
|
+
try {
|
|
213
|
+
for await (const token of this.openAIAttempt(messages, tools)) {
|
|
214
|
+
produced = true;
|
|
215
|
+
yield token;
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
catch (e) {
|
|
220
|
+
if (produced || attempt >= NETWORK_RETRIES || !isRetriableNetworkError(e))
|
|
221
|
+
throw e;
|
|
222
|
+
await sleep(400 * (attempt + 1));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** A single streaming attempt; see `streamOpenAI` for the retry policy. */
|
|
227
|
+
async *openAIAttempt(messages, tools) {
|
|
33
228
|
const url = this.model.url.replace(/\/+$/, "") + "/chat/completions";
|
|
34
229
|
const headers = {
|
|
35
230
|
"Content-Type": "application/json",
|
|
@@ -45,7 +240,7 @@ export class LLMClient {
|
|
|
45
240
|
};
|
|
46
241
|
if (tools && tools.length)
|
|
47
242
|
payload.tools = tools;
|
|
48
|
-
const resp = await
|
|
243
|
+
const resp = await requestStream(url, {
|
|
49
244
|
method: "POST",
|
|
50
245
|
headers,
|
|
51
246
|
body: JSON.stringify(payload),
|
|
@@ -64,66 +259,100 @@ export class LLMClient {
|
|
|
64
259
|
const decoder = new TextDecoder();
|
|
65
260
|
let buf = "";
|
|
66
261
|
let stopped = false;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
buf += decoder.decode(value, { stream: true });
|
|
72
|
-
let idx;
|
|
73
|
-
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
74
|
-
const raw = buf.slice(0, idx).replace(/\r$/, "");
|
|
75
|
-
buf = buf.slice(idx + 1);
|
|
76
|
-
const line = raw.trim();
|
|
77
|
-
if (!line)
|
|
78
|
-
continue;
|
|
79
|
-
if (!line.startsWith("data:"))
|
|
80
|
-
continue;
|
|
81
|
-
const data = line.slice(5).trim();
|
|
82
|
-
if (data === "[DONE]") {
|
|
83
|
-
stopped = true;
|
|
262
|
+
try {
|
|
263
|
+
while (!stopped) {
|
|
264
|
+
const { done, value } = await reader.read();
|
|
265
|
+
if (done)
|
|
84
266
|
break;
|
|
85
|
-
}
|
|
86
|
-
let
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
267
|
+
buf += decoder.decode(value, { stream: true });
|
|
268
|
+
let idx;
|
|
269
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
270
|
+
const raw = buf.slice(0, idx).replace(/\r$/, "");
|
|
271
|
+
buf = buf.slice(idx + 1);
|
|
272
|
+
const line = raw.trim();
|
|
273
|
+
if (!line)
|
|
274
|
+
continue;
|
|
275
|
+
if (!line.startsWith("data:"))
|
|
276
|
+
continue;
|
|
277
|
+
const data = line.slice(5).trim();
|
|
278
|
+
if (data === "[DONE]") {
|
|
279
|
+
stopped = true;
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
let chunk;
|
|
283
|
+
try {
|
|
284
|
+
chunk = JSON.parse(data);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const usage = chunk.usage;
|
|
290
|
+
if (usage) {
|
|
291
|
+
this.usage = {
|
|
292
|
+
prompt: usage.prompt_tokens ?? 0,
|
|
293
|
+
completion: usage.completion_tokens ?? 0,
|
|
294
|
+
total: usage.total_tokens ?? (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const choice = chunk.choices?.[0];
|
|
298
|
+
const delta = choice?.delta ?? {};
|
|
299
|
+
const content = delta.content;
|
|
300
|
+
if (typeof content === "string" && content)
|
|
301
|
+
yield content;
|
|
302
|
+
const toolCalls = delta.tool_calls;
|
|
303
|
+
if (toolCalls) {
|
|
304
|
+
for (const tc of toolCalls) {
|
|
305
|
+
const i = tc.index ?? 0;
|
|
306
|
+
const entry = (toolCallsByIndex[i] ??= { id: "", name: "", arguments: "" });
|
|
307
|
+
if (tc.id)
|
|
308
|
+
entry.id = tc.id;
|
|
309
|
+
if (tc.function?.name)
|
|
310
|
+
entry.name = tc.function.name;
|
|
311
|
+
if (tc.function?.arguments)
|
|
312
|
+
entry.arguments += tc.function.arguments;
|
|
313
|
+
}
|
|
117
314
|
}
|
|
118
315
|
}
|
|
119
316
|
}
|
|
120
317
|
}
|
|
318
|
+
finally {
|
|
319
|
+
// The consumer may walk away mid-stream (pause, a newer turn taking over,
|
|
320
|
+
// or `[DONE]` arriving before the body ends). Always release the reader so
|
|
321
|
+
// the socket is torn down instead of lingering — leaked streaming bodies
|
|
322
|
+
// are what eventually make every request fail with "fetch failed".
|
|
323
|
+
try {
|
|
324
|
+
await reader.cancel();
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
// Already closed / errored; nothing left to release.
|
|
328
|
+
}
|
|
329
|
+
}
|
|
121
330
|
this.toolCalls = Object.keys(toolCallsByIndex)
|
|
122
331
|
.map((k) => Number(k))
|
|
123
332
|
.sort((a, b) => a - b)
|
|
124
333
|
.map((k) => toolCallsByIndex[k]);
|
|
125
334
|
}
|
|
335
|
+
/** Anthropic counterpart of `streamOpenAI`, with the same retry policy. */
|
|
126
336
|
async *streamAnthropic(messages) {
|
|
337
|
+
for (let attempt = 0;; attempt++) {
|
|
338
|
+
this.toolCalls = [];
|
|
339
|
+
let produced = false;
|
|
340
|
+
try {
|
|
341
|
+
for await (const token of this.anthropicAttempt(messages)) {
|
|
342
|
+
produced = true;
|
|
343
|
+
yield token;
|
|
344
|
+
}
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
catch (e) {
|
|
348
|
+
if (produced || attempt >= NETWORK_RETRIES || !isRetriableNetworkError(e))
|
|
349
|
+
throw e;
|
|
350
|
+
await sleep(400 * (attempt + 1));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/** A single streaming attempt; see `streamAnthropic` for the retry policy. */
|
|
355
|
+
async *anthropicAttempt(messages) {
|
|
127
356
|
const url = this.model.url.replace(/\/+$/, "") + "/messages";
|
|
128
357
|
const headers = {
|
|
129
358
|
"Content-Type": "application/json",
|
|
@@ -147,7 +376,7 @@ export class LLMClient {
|
|
|
147
376
|
};
|
|
148
377
|
if (systemMsg)
|
|
149
378
|
payload.system = systemMsg;
|
|
150
|
-
const resp = await
|
|
379
|
+
const resp = await requestStream(url, {
|
|
151
380
|
method: "POST",
|
|
152
381
|
headers,
|
|
153
382
|
body: JSON.stringify(payload),
|
|
@@ -165,41 +394,51 @@ export class LLMClient {
|
|
|
165
394
|
const decoder = new TextDecoder();
|
|
166
395
|
let buf = "";
|
|
167
396
|
let stopped = false;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
buf += decoder.decode(value, { stream: true });
|
|
173
|
-
let idx;
|
|
174
|
-
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
175
|
-
const raw = buf.slice(0, idx).replace(/\r$/, "");
|
|
176
|
-
buf = buf.slice(idx + 1);
|
|
177
|
-
const line = raw.trim();
|
|
178
|
-
if (!line || !line.startsWith("data:"))
|
|
179
|
-
continue;
|
|
180
|
-
const data = line.slice(5).trim();
|
|
181
|
-
if (data === "[DONE]") {
|
|
182
|
-
stopped = true;
|
|
183
|
-
break;
|
|
184
|
-
}
|
|
185
|
-
let chunk;
|
|
186
|
-
try {
|
|
187
|
-
chunk = JSON.parse(data);
|
|
188
|
-
}
|
|
189
|
-
catch {
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
const type = chunk.type;
|
|
193
|
-
if (type === "content_block_delta") {
|
|
194
|
-
const text = chunk.delta?.text;
|
|
195
|
-
if (text)
|
|
196
|
-
yield text;
|
|
197
|
-
}
|
|
198
|
-
else if (type === "message_stop") {
|
|
199
|
-
stopped = true;
|
|
397
|
+
try {
|
|
398
|
+
while (!stopped) {
|
|
399
|
+
const { done, value } = await reader.read();
|
|
400
|
+
if (done)
|
|
200
401
|
break;
|
|
402
|
+
buf += decoder.decode(value, { stream: true });
|
|
403
|
+
let idx;
|
|
404
|
+
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
405
|
+
const raw = buf.slice(0, idx).replace(/\r$/, "");
|
|
406
|
+
buf = buf.slice(idx + 1);
|
|
407
|
+
const line = raw.trim();
|
|
408
|
+
if (!line || !line.startsWith("data:"))
|
|
409
|
+
continue;
|
|
410
|
+
const data = line.slice(5).trim();
|
|
411
|
+
if (data === "[DONE]") {
|
|
412
|
+
stopped = true;
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
let chunk;
|
|
416
|
+
try {
|
|
417
|
+
chunk = JSON.parse(data);
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
const type = chunk.type;
|
|
423
|
+
if (type === "content_block_delta") {
|
|
424
|
+
const text = chunk.delta?.text;
|
|
425
|
+
if (text)
|
|
426
|
+
yield text;
|
|
427
|
+
}
|
|
428
|
+
else if (type === "message_stop") {
|
|
429
|
+
stopped = true;
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
201
432
|
}
|
|
202
433
|
}
|
|
203
434
|
}
|
|
435
|
+
finally {
|
|
436
|
+
try {
|
|
437
|
+
await reader.cancel();
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
// Already closed / errored; nothing left to release.
|
|
441
|
+
}
|
|
442
|
+
}
|
|
204
443
|
}
|
|
205
444
|
}
|