arcane-os 0.13.2 → 0.14.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/CHANGELOG.md +16 -0
- package/browser-runtime/ai/browser-wasm-llm-provider.mjs +79 -14
- package/browser-runtime/ai/model-controller.mjs +32 -4
- package/browser-runtime/ai/tool-text-stream.mjs +364 -0
- package/docs/reference/ai/browser-speech.md +6 -0
- package/docs/reference/ai/browser-wasm.md +62 -0
- package/docs/reference/inventory/package-api.json +18 -2
- package/docs/reference/inventory/runtime-modules.json +4 -3
- package/docs/reference/runtime-modules.md +36 -8
- package/docs/reference/sdk-api.md +72 -3
- package/package.json +2 -1
- package/runtime/arcane/modules/AI.js +69 -11
- package/runtime/arcane/modules/AIProviderRuntime.js +24 -7
- package/runtime/arcane/modules/ConversationClosingReport.js +2 -2
- package/src/import-map.mjs +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.14.0
|
|
4
|
+
|
|
5
|
+
- Add opt-in `toolText: {name, field}` and `onToolText(text, call, displayId)`
|
|
6
|
+
to model streaming requests. Decode the selected root string field as tool
|
|
7
|
+
arguments arrive, separately from ordinary text and final tool execution.
|
|
8
|
+
Preserve actual call identity, whitespace, ordered delivery, and cancellation
|
|
9
|
+
across HTTP, native, and browser provider routes.
|
|
10
|
+
- Export `formatConversationClosingReportText(value)` so streamed closing text
|
|
11
|
+
uses the same existing formatting as the complete report.
|
|
12
|
+
|
|
13
|
+
## 0.13.3
|
|
14
|
+
|
|
15
|
+
- Skip automatic TTS finalization calls when a model stream completes while
|
|
16
|
+
muted. Preserve pending speech cleanup, unmuted flushing, explicit public
|
|
17
|
+
speech methods, and model response callbacks across all streaming routes.
|
|
18
|
+
|
|
3
19
|
## 0.13.2
|
|
4
20
|
|
|
5
21
|
- Correct the PWA development guide to describe live descriptor refresh and
|
|
@@ -2271,13 +2271,24 @@ function createCompletionAccumulator(modelId, requestId) {
|
|
|
2271
2271
|
return completeValue({ push, result, hasToolCalls, correlateToolCalls });
|
|
2272
2272
|
}
|
|
2273
2273
|
|
|
2274
|
-
function callbackStreamHandle({ runtime, request, signal, onSettled }) {
|
|
2274
|
+
function callbackStreamHandle({ runtime, request, signal, onSettled, observeToolText = null }) {
|
|
2275
2275
|
const linked = linkAbortSignal(signal);
|
|
2276
2276
|
const accumulator = createCompletionAccumulator(request.model ?? null, request.id);
|
|
2277
2277
|
const chunks = [];
|
|
2278
2278
|
const waiters = [];
|
|
2279
2279
|
let ended = false;
|
|
2280
2280
|
let terminalError = null;
|
|
2281
|
+
let observation = Promise.resolve();
|
|
2282
|
+
let observationError = null;
|
|
2283
|
+
|
|
2284
|
+
function publishChunk(chunk) {
|
|
2285
|
+
if (ended || linked.controller.signal.aborted) return;
|
|
2286
|
+
const publicChunk = projectPublicStreamChunk(chunk);
|
|
2287
|
+
if (publicChunk === OMITTED_PUBLIC_STREAM_DATA) return;
|
|
2288
|
+
const waiter = waiters.shift();
|
|
2289
|
+
if (waiter) waiter.resolve({ value: publicChunk, done: false });
|
|
2290
|
+
else chunks.push(publicChunk);
|
|
2291
|
+
}
|
|
2281
2292
|
|
|
2282
2293
|
function deliver(value) {
|
|
2283
2294
|
// This gate prevents delivery after public cancellation. It is not proof
|
|
@@ -2287,11 +2298,20 @@ function callbackStreamHandle({ runtime, request, signal, onSettled }) {
|
|
|
2287
2298
|
? value
|
|
2288
2299
|
: { ...value, id: request.id };
|
|
2289
2300
|
accumulator.push(chunk);
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2301
|
+
if (!observeToolText) {
|
|
2302
|
+
publishChunk(chunk);
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
observation = observation.then(async function observeBrowserWasmChunk() {
|
|
2306
|
+
if (ended) return;
|
|
2307
|
+
throwIfAborted(linked.controller.signal);
|
|
2308
|
+
await observeToolText(chunk);
|
|
2309
|
+
publishChunk(chunk);
|
|
2310
|
+
});
|
|
2311
|
+
observation.catch(function cancelFailedBrowserWasmObservation(error) {
|
|
2312
|
+
observationError ??= error;
|
|
2313
|
+
linked.controller.abort(error);
|
|
2314
|
+
});
|
|
2295
2315
|
}
|
|
2296
2316
|
|
|
2297
2317
|
function finish(error = null) {
|
|
@@ -2313,17 +2333,27 @@ function callbackStreamHandle({ runtime, request, signal, onSettled }) {
|
|
|
2313
2333
|
);
|
|
2314
2334
|
const result = (async () => {
|
|
2315
2335
|
try {
|
|
2316
|
-
await terminal;
|
|
2336
|
+
const terminalValue = await terminal;
|
|
2337
|
+
if (observeToolText) await observation;
|
|
2338
|
+
throwIfAborted(linked.controller.signal);
|
|
2339
|
+
if (observeToolText) await observeToolText(terminalValue);
|
|
2317
2340
|
throwIfAborted(linked.controller.signal);
|
|
2318
2341
|
const value = accumulator.result();
|
|
2342
|
+
if (observeToolText) await observeToolText(value);
|
|
2343
|
+
throwIfAborted(linked.controller.signal);
|
|
2319
2344
|
finish();
|
|
2320
2345
|
return value;
|
|
2321
2346
|
} catch (error) {
|
|
2322
|
-
const normalized = normalizeArcaneAIError(error, {
|
|
2347
|
+
const normalized = normalizeArcaneAIError(observationError ?? error, {
|
|
2323
2348
|
kind: "llm",
|
|
2324
2349
|
operation: "request",
|
|
2325
|
-
signal: normalizationSignal(error, linked.controller.signal),
|
|
2350
|
+
signal: observationError ? null : normalizationSignal(error, linked.controller.signal),
|
|
2326
2351
|
});
|
|
2352
|
+
if (observeToolText) {
|
|
2353
|
+
await observation.catch(function retainFirstBrowserWasmStreamFailure() {
|
|
2354
|
+
// The failure captured above remains the terminal outcome.
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2327
2357
|
finish(normalized);
|
|
2328
2358
|
throw normalized;
|
|
2329
2359
|
}
|
|
@@ -2380,7 +2410,7 @@ function callbackStreamHandle({ runtime, request, signal, onSettled }) {
|
|
|
2380
2410
|
return completeValue(handle);
|
|
2381
2411
|
}
|
|
2382
2412
|
|
|
2383
|
-
function validatedV1StreamHandle(opened, request) {
|
|
2413
|
+
function validatedV1StreamHandle(opened, request, observeToolText = null, signal = null) {
|
|
2384
2414
|
if (
|
|
2385
2415
|
!opened
|
|
2386
2416
|
|| !is.object(opened)
|
|
@@ -2429,6 +2459,16 @@ function validatedV1StreamHandle(opened, request) {
|
|
|
2429
2459
|
let publicStreamSettled = false;
|
|
2430
2460
|
let publicStreamError = null;
|
|
2431
2461
|
|
|
2462
|
+
function stopV1ToolTextObservation(reason) {
|
|
2463
|
+
if (!observeToolText) return;
|
|
2464
|
+
publicStreamError ??= reason instanceof Error ? reason : fail(
|
|
2465
|
+
'ARCANE_AI_REQUEST_ABORTED',
|
|
2466
|
+
'The browser-WASM request was cancelled.',
|
|
2467
|
+
reason,
|
|
2468
|
+
);
|
|
2469
|
+
settlePublicStream(publicStreamError);
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2432
2472
|
function publishPublicChunk(value) {
|
|
2433
2473
|
if (publicStreamSettled) return;
|
|
2434
2474
|
const waiter = publicChunkWaiters.shift();
|
|
@@ -2459,10 +2499,24 @@ function validatedV1StreamHandle(opened, request) {
|
|
|
2459
2499
|
return true;
|
|
2460
2500
|
}
|
|
2461
2501
|
accumulator.push(next.value);
|
|
2502
|
+
if (observeToolText) {
|
|
2503
|
+
if (publicStreamError !== null) throw publicStreamError;
|
|
2504
|
+
throwIfAborted(signal);
|
|
2505
|
+
await observeToolText(next.value);
|
|
2506
|
+
if (publicStreamError !== null) throw publicStreamError;
|
|
2507
|
+
throwIfAborted(signal);
|
|
2508
|
+
}
|
|
2462
2509
|
const projected = projectPublicStreamChunk(next.value);
|
|
2463
2510
|
if (projected !== OMITTED_PUBLIC_STREAM_DATA) publishPublicChunk(projected);
|
|
2464
2511
|
}
|
|
2465
2512
|
} catch (error) {
|
|
2513
|
+
if (observeToolText) {
|
|
2514
|
+
Promise.resolve().then(function cancelFailedV1ToolTextStream() {
|
|
2515
|
+
return opened.cancel(error);
|
|
2516
|
+
}).catch(function reportFailedV1ToolTextCancellation(cleanupError) {
|
|
2517
|
+
arcaneLogging.error('Arcane v1 tool-text stream cancellation failed.', cleanupError);
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2466
2520
|
settlePublicStream(error);
|
|
2467
2521
|
throw error;
|
|
2468
2522
|
}
|
|
@@ -2476,8 +2530,15 @@ function validatedV1StreamHandle(opened, request) {
|
|
|
2476
2530
|
);
|
|
2477
2531
|
terminalResult.catch(function retainV1StreamTerminalRejection() {});
|
|
2478
2532
|
const result = Promise.all([terminalResult, privateStreamPump]).then(
|
|
2479
|
-
function correlateV1StreamTerminal([terminal]) {
|
|
2533
|
+
async function correlateV1StreamTerminal([terminal]) {
|
|
2534
|
+
if (observeToolText && publicStreamError !== null) throw publicStreamError;
|
|
2480
2535
|
accumulator.correlateToolCalls(terminal);
|
|
2536
|
+
if (observeToolText) {
|
|
2537
|
+
throwIfAborted(signal);
|
|
2538
|
+
await observeToolText(terminal);
|
|
2539
|
+
if (publicStreamError !== null) throw publicStreamError;
|
|
2540
|
+
throwIfAborted(signal);
|
|
2541
|
+
}
|
|
2481
2542
|
return terminal;
|
|
2482
2543
|
},
|
|
2483
2544
|
);
|
|
@@ -2485,6 +2546,7 @@ function validatedV1StreamHandle(opened, request) {
|
|
|
2485
2546
|
const handle = {
|
|
2486
2547
|
result,
|
|
2487
2548
|
cancel: function cancelValidatedV1Stream(reason) {
|
|
2549
|
+
stopV1ToolTextObservation(reason);
|
|
2488
2550
|
return opened.cancel(reason);
|
|
2489
2551
|
},
|
|
2490
2552
|
async next() {
|
|
@@ -2496,6 +2558,7 @@ function validatedV1StreamHandle(opened, request) {
|
|
|
2496
2558
|
});
|
|
2497
2559
|
},
|
|
2498
2560
|
async return(value) {
|
|
2561
|
+
stopV1ToolTextObservation('The stream consumer stopped before completion.');
|
|
2499
2562
|
if (is.function(iterator.return)) {
|
|
2500
2563
|
Promise.resolve().then(function returnUnderlyingV1Stream() {
|
|
2501
2564
|
return iterator.return(value);
|
|
@@ -2511,6 +2574,7 @@ function validatedV1StreamHandle(opened, request) {
|
|
|
2511
2574
|
return { value, done: true };
|
|
2512
2575
|
},
|
|
2513
2576
|
async throw(error) {
|
|
2577
|
+
stopV1ToolTextObservation(error);
|
|
2514
2578
|
await opened.cancel(error);
|
|
2515
2579
|
throw error;
|
|
2516
2580
|
},
|
|
@@ -3171,6 +3235,7 @@ export function createBrowserWasmLlmProvider({
|
|
|
3171
3235
|
runtime,
|
|
3172
3236
|
request,
|
|
3173
3237
|
signal: externalSignal,
|
|
3238
|
+
observeToolText: context.observeToolText,
|
|
3174
3239
|
onSettled(error) {
|
|
3175
3240
|
if (settled) return;
|
|
3176
3241
|
settled = true;
|
|
@@ -3452,7 +3517,7 @@ export function adaptV1LlmProvider(provider) {
|
|
|
3452
3517
|
});
|
|
3453
3518
|
return status();
|
|
3454
3519
|
},
|
|
3455
|
-
request({ role = "llm", selection, operation, payload, signal = null } = {}) {
|
|
3520
|
+
request({ role = "llm", selection, operation, payload, signal = null } = {}, controls = {}) {
|
|
3456
3521
|
assertSelection(selection, role);
|
|
3457
3522
|
assertActiveSelection(selection);
|
|
3458
3523
|
throwIfAborted(signal);
|
|
@@ -3466,9 +3531,9 @@ export function adaptV1LlmProvider(provider) {
|
|
|
3466
3531
|
}
|
|
3467
3532
|
if (operation === "stream") {
|
|
3468
3533
|
validateStructuralRequest(payload);
|
|
3469
|
-
return Promise.resolve(methods.stream(payload, { signal })).then(
|
|
3534
|
+
return Promise.resolve(methods.stream(payload, { signal, observeToolText: controls.observeToolText })).then(
|
|
3470
3535
|
function wrapV1StreamResult(opened) {
|
|
3471
|
-
return validatedV1StreamHandle(opened, payload);
|
|
3536
|
+
return validatedV1StreamHandle(opened, payload, controls.observeToolText, signal);
|
|
3472
3537
|
},
|
|
3473
3538
|
);
|
|
3474
3539
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Is from "../dependencies/strong-type/index.js";
|
|
2
2
|
import { arcaneLogging } from '../logging.mjs';
|
|
3
3
|
import { createArcaneEventSource } from "arcane-os/event-manager";
|
|
4
|
+
import { createToolTextObserver } from './tool-text-stream.mjs';
|
|
4
5
|
|
|
5
6
|
const is = new Is(false);
|
|
6
7
|
|
|
@@ -1311,13 +1312,22 @@ export class ModelController {
|
|
|
1311
1312
|
}
|
|
1312
1313
|
}
|
|
1313
1314
|
|
|
1314
|
-
stream(request = {}) {
|
|
1315
|
+
stream(request = {}, context = {}) {
|
|
1315
1316
|
this.#assertOperational();
|
|
1316
1317
|
request=structuralRequest(request);
|
|
1317
1318
|
localRequirement(request, this.#provider);
|
|
1318
1319
|
const controller = this;
|
|
1319
1320
|
const externalSignal = request.signal ?? null;
|
|
1320
1321
|
const linked = linkedAbortSignal(externalSignal);
|
|
1322
|
+
const observeToolText = context.observeToolText ? async function observeActiveModelToolText(chunk) {
|
|
1323
|
+
if (linked.controller.signal.aborted) {
|
|
1324
|
+
throw normalizeArcaneAIError(null, { operation: 'request', signal: linked.controller.signal });
|
|
1325
|
+
}
|
|
1326
|
+
await context.observeToolText(chunk);
|
|
1327
|
+
if (linked.controller.signal.aborted) {
|
|
1328
|
+
throw normalizeArcaneAIError(null, { operation: 'request', signal: linked.controller.signal });
|
|
1329
|
+
}
|
|
1330
|
+
} : null;
|
|
1321
1331
|
let opened = null;
|
|
1322
1332
|
let openedIterator = null;
|
|
1323
1333
|
let openError = null;
|
|
@@ -1363,6 +1373,7 @@ export class ModelController {
|
|
|
1363
1373
|
kind: "llm",
|
|
1364
1374
|
operation: "stream",
|
|
1365
1375
|
signal: linked.controller.signal,
|
|
1376
|
+
observeToolText,
|
|
1366
1377
|
},
|
|
1367
1378
|
);
|
|
1368
1379
|
if (
|
|
@@ -1427,10 +1438,18 @@ export class ModelController {
|
|
|
1427
1438
|
return true;
|
|
1428
1439
|
}
|
|
1429
1440
|
streamedToolCalls.observe(next.value);
|
|
1441
|
+
if (observeToolText) await observeToolText(next.value);
|
|
1430
1442
|
const projected=projectPublicStreamChunk(next.value);
|
|
1431
1443
|
if(projected!==OMITTED_PUBLIC_STREAM_DATA)publishPublicChunk(projected);
|
|
1432
1444
|
}
|
|
1433
1445
|
}catch(error){
|
|
1446
|
+
if (observeToolText) {
|
|
1447
|
+
Promise.resolve().then(function cancelFailedModelToolTextStream() {
|
|
1448
|
+
return opened.cancel(error);
|
|
1449
|
+
}).catch(function reportFailedModelToolTextCancellation(cleanupError) {
|
|
1450
|
+
arcaneLogging.error('Arcane model tool-text stream cancellation failed.', cleanupError);
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1434
1453
|
settlePublicStream(error);
|
|
1435
1454
|
throw error;
|
|
1436
1455
|
}
|
|
@@ -1446,8 +1465,9 @@ export class ModelController {
|
|
|
1446
1465
|
});
|
|
1447
1466
|
terminalResult.catch(()=>undefined);
|
|
1448
1467
|
|
|
1449
|
-
const result = Promise.all([terminalResult,privateStreamPump]).then(([terminal])
|
|
1468
|
+
const result = Promise.all([terminalResult,privateStreamPump]).then(async function completeModelStream([terminal]) {
|
|
1450
1469
|
streamedToolCalls.correlate(terminal);
|
|
1470
|
+
if (observeToolText) await observeToolText(terminal);
|
|
1451
1471
|
settlePublicStream();
|
|
1452
1472
|
return terminal;
|
|
1453
1473
|
}).catch((error)=>{
|
|
@@ -1533,10 +1553,18 @@ export class ModelController {
|
|
|
1533
1553
|
this.#assertOperational();
|
|
1534
1554
|
localRequirement(options, this.#provider);
|
|
1535
1555
|
const id = requestIdentity(options.id);
|
|
1536
|
-
const
|
|
1556
|
+
const { toolText, onToolText, ...requestOptions } = options;
|
|
1557
|
+
const request = structuralRequest({ ...requestOptions, id });
|
|
1537
1558
|
const displayId = displayRequestId(id);
|
|
1559
|
+
const observeToolText = createToolTextObserver(
|
|
1560
|
+
toolText,
|
|
1561
|
+
is.function(onToolText) ? function deliverSelectedToolText(text, call) {
|
|
1562
|
+
return onToolText(text, call, displayId);
|
|
1563
|
+
} : onToolText,
|
|
1564
|
+
{ signal: options.signal },
|
|
1565
|
+
);
|
|
1538
1566
|
fireAndForget(options.onRequest, request, id);
|
|
1539
|
-
const handle = this.stream(request);
|
|
1567
|
+
const handle = this.stream(request, { observeToolText });
|
|
1540
1568
|
|
|
1541
1569
|
try {
|
|
1542
1570
|
for await (const chunk of handle) {
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import Is from '../dependencies/strong-type/index.js';
|
|
2
|
+
|
|
3
|
+
const is = new Is(false);
|
|
4
|
+
const STRING_ESCAPES = {
|
|
5
|
+
'"': '"',
|
|
6
|
+
'\\': '\\',
|
|
7
|
+
'/': '/',
|
|
8
|
+
b: '\b',
|
|
9
|
+
f: '\f',
|
|
10
|
+
n: '\n',
|
|
11
|
+
r: '\r',
|
|
12
|
+
t: '\t'
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function projectionError(message, cause) {
|
|
16
|
+
const error = new SyntaxError(
|
|
17
|
+
`Tool text projection ${message}`,
|
|
18
|
+
cause === undefined ? undefined : {cause}
|
|
19
|
+
);
|
|
20
|
+
error.code = 'ARCANE_AI_TOOL_TEXT_INVALID';
|
|
21
|
+
return error;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isWhitespace(character) {
|
|
25
|
+
return character === ' ' || character === '\t'
|
|
26
|
+
|| character === '\n' || character === '\r';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createArgumentScanner(field, appendText, beginText, endText) {
|
|
30
|
+
const stack = [];
|
|
31
|
+
let started = false;
|
|
32
|
+
let finished = false;
|
|
33
|
+
let string = null;
|
|
34
|
+
let scalar = null;
|
|
35
|
+
|
|
36
|
+
function finishValue() {
|
|
37
|
+
stack[stack.length - 1].state = 'commaOrEnd';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function closeContainer() {
|
|
41
|
+
stack.pop();
|
|
42
|
+
if (stack.length) {
|
|
43
|
+
finishValue();
|
|
44
|
+
} else {
|
|
45
|
+
finished = true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function acceptStringCharacter(character) {
|
|
50
|
+
if (string.key) {
|
|
51
|
+
if (stack.length === 1) string.value += character;
|
|
52
|
+
} else if (string.selected) {
|
|
53
|
+
appendText(character, string.offset);
|
|
54
|
+
string.offset += character.length;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readStringCharacter(character) {
|
|
59
|
+
if (string.unicode !== null) {
|
|
60
|
+
if (!/[0-9a-fA-F]/.test(character)) {
|
|
61
|
+
throw projectionError('encountered an invalid Unicode escape.');
|
|
62
|
+
}
|
|
63
|
+
string.unicode += character;
|
|
64
|
+
if (string.unicode.length === 4) {
|
|
65
|
+
acceptStringCharacter(
|
|
66
|
+
String.fromCharCode(Number.parseInt(string.unicode, 16))
|
|
67
|
+
);
|
|
68
|
+
string.unicode = null;
|
|
69
|
+
}
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (string.escaped) {
|
|
73
|
+
string.escaped = false;
|
|
74
|
+
if (character === 'u') {
|
|
75
|
+
string.unicode = '';
|
|
76
|
+
} else if (Object.hasOwn(STRING_ESCAPES, character)) {
|
|
77
|
+
acceptStringCharacter(STRING_ESCAPES[character]);
|
|
78
|
+
} else {
|
|
79
|
+
throw projectionError('encountered an invalid string escape.');
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (character === '\\') {
|
|
84
|
+
string.escaped = true;
|
|
85
|
+
} else if (character === '"') {
|
|
86
|
+
const frame = stack[stack.length - 1];
|
|
87
|
+
if (string.key) {
|
|
88
|
+
frame.key = string.value;
|
|
89
|
+
frame.state = 'colon';
|
|
90
|
+
} else {
|
|
91
|
+
if (string.selected) endText(string.offset);
|
|
92
|
+
finishValue();
|
|
93
|
+
}
|
|
94
|
+
string = null;
|
|
95
|
+
} else {
|
|
96
|
+
if (character.charCodeAt(0) < 32) {
|
|
97
|
+
throw projectionError('encountered an unescaped control character.');
|
|
98
|
+
}
|
|
99
|
+
acceptStringCharacter(character);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function startString(key, selected = false) {
|
|
104
|
+
string = {key, selected, value: '', offset: 0, escaped: false, unicode: null};
|
|
105
|
+
if (selected) beginText();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function startValue(character, frame) {
|
|
109
|
+
const selected = stack.length === 1 && frame.kind === 'object'
|
|
110
|
+
&& frame.key === field;
|
|
111
|
+
if (selected && character !== '"') {
|
|
112
|
+
beginText();
|
|
113
|
+
endText(0);
|
|
114
|
+
}
|
|
115
|
+
if (character === '"') {
|
|
116
|
+
startString(false, selected);
|
|
117
|
+
} else if (character === '{') {
|
|
118
|
+
stack.push(
|
|
119
|
+
{kind: 'object', state: 'keyOrEnd', key: ''}
|
|
120
|
+
);
|
|
121
|
+
} else if (character === '[') {
|
|
122
|
+
stack.push(
|
|
123
|
+
{kind: 'array', state: 'valueOrEnd'}
|
|
124
|
+
);
|
|
125
|
+
} else if (character === '-' || character >= '0' && character <= '9'
|
|
126
|
+
|| character === 't' || character === 'f' || character === 'n') {
|
|
127
|
+
scalar = character;
|
|
128
|
+
} else {
|
|
129
|
+
throw projectionError('encountered an invalid argument value.');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function appendArguments(fragment) {
|
|
134
|
+
for (let position = 0; position < fragment.length; position += 1) {
|
|
135
|
+
const character = fragment[position];
|
|
136
|
+
if (string) {
|
|
137
|
+
readStringCharacter(character);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (scalar !== null) {
|
|
141
|
+
if (!isWhitespace(character) && character !== ','
|
|
142
|
+
&& character !== '}' && character !== ']') {
|
|
143
|
+
scalar += character;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
JSON.parse(scalar);
|
|
148
|
+
} catch (cause) {
|
|
149
|
+
throw projectionError('encountered an invalid argument value.', cause);
|
|
150
|
+
}
|
|
151
|
+
scalar = null;
|
|
152
|
+
finishValue();
|
|
153
|
+
}
|
|
154
|
+
if (isWhitespace(character)) continue;
|
|
155
|
+
if (finished) {
|
|
156
|
+
throw projectionError('encountered content after the argument object.');
|
|
157
|
+
}
|
|
158
|
+
if (!started) {
|
|
159
|
+
if (character !== '{') {
|
|
160
|
+
throw projectionError('requires a root argument object.');
|
|
161
|
+
}
|
|
162
|
+
started = true;
|
|
163
|
+
stack.push(
|
|
164
|
+
{kind: 'object', state: 'keyOrEnd', key: ''}
|
|
165
|
+
);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const frame = stack[stack.length - 1];
|
|
169
|
+
if (frame.state === 'keyOrEnd' || frame.state === 'key') {
|
|
170
|
+
if (character === '"') {
|
|
171
|
+
startString(true);
|
|
172
|
+
} else if (character === '}' && frame.state === 'keyOrEnd') {
|
|
173
|
+
closeContainer();
|
|
174
|
+
} else {
|
|
175
|
+
throw projectionError('encountered an invalid object key.');
|
|
176
|
+
}
|
|
177
|
+
} else if (frame.state === 'colon') {
|
|
178
|
+
if (character !== ':') {
|
|
179
|
+
throw projectionError('expected a colon after an object key.');
|
|
180
|
+
}
|
|
181
|
+
frame.state = 'value';
|
|
182
|
+
} else if (frame.state === 'value' || frame.state === 'valueOrEnd') {
|
|
183
|
+
if (character === ']' && frame.state === 'valueOrEnd') {
|
|
184
|
+
closeContainer();
|
|
185
|
+
} else {
|
|
186
|
+
startValue(character, frame);
|
|
187
|
+
}
|
|
188
|
+
} else if (character === ',') {
|
|
189
|
+
frame.state = frame.kind === 'object' ? 'key' : 'value';
|
|
190
|
+
} else if (character === (frame.kind === 'object' ? '}' : ']')) {
|
|
191
|
+
closeContainer();
|
|
192
|
+
} else {
|
|
193
|
+
throw projectionError('expected a comma or the end of an argument container.');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return appendArguments;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function createToolTextObserver(selection, onText, {signal} = {}) {
|
|
202
|
+
if (selection === undefined || selection === null || selection === false) return null;
|
|
203
|
+
if (!is.object(selection) || is.array(selection)
|
|
204
|
+
|| !is.string(selection.name) || !selection.name.trim()
|
|
205
|
+
|| !is.string(selection.field) || !selection.field.trim()) {
|
|
206
|
+
throw new TypeError('AI toolText must name a tool and a root string field.');
|
|
207
|
+
}
|
|
208
|
+
if (!is.function(onText)) {
|
|
209
|
+
throw new TypeError('AI onToolText must be a function when toolText is selected.');
|
|
210
|
+
}
|
|
211
|
+
const name = selection.name;
|
|
212
|
+
const field = selection.field;
|
|
213
|
+
const choices = new Map();
|
|
214
|
+
|
|
215
|
+
function recordFor(choiceIndex, index) {
|
|
216
|
+
let calls = choices.get(choiceIndex);
|
|
217
|
+
if (!calls) {
|
|
218
|
+
calls = new Map();
|
|
219
|
+
choices.set(choiceIndex, calls);
|
|
220
|
+
}
|
|
221
|
+
let record = calls.get(index);
|
|
222
|
+
if (!record) {
|
|
223
|
+
record = {
|
|
224
|
+
id: '', name: '', index, choiceIndex, pending: [], scanner: null,
|
|
225
|
+
text: '', emitted: 0, textOpen: false
|
|
226
|
+
};
|
|
227
|
+
calls.set(index, record);
|
|
228
|
+
}
|
|
229
|
+
return record;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function prepareScanner(record) {
|
|
233
|
+
if (!record.scanner) {
|
|
234
|
+
record.scanner = createArgumentScanner(
|
|
235
|
+
field,
|
|
236
|
+
function appendSelectedToolText(text, offset) {
|
|
237
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
238
|
+
const position = offset + index;
|
|
239
|
+
if (position < record.text.length) {
|
|
240
|
+
if (record.text[position] !== text[index]) {
|
|
241
|
+
throw projectionError('received conflicting selected argument text.');
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
record.text += text[index];
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
function beginSelectedToolText() {
|
|
249
|
+
if (record.emitted === 0) record.text = '';
|
|
250
|
+
record.textOpen = true;
|
|
251
|
+
},
|
|
252
|
+
function finishSelectedToolText(length) {
|
|
253
|
+
if (length < record.text.length) {
|
|
254
|
+
throw projectionError('received a shorter replacement for selected argument text.');
|
|
255
|
+
}
|
|
256
|
+
record.textOpen = false;
|
|
257
|
+
}
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
for (const fragment of record.pending) record.scanner(fragment);
|
|
261
|
+
record.pending = [];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function observeCompleteArguments(record, argumentsValue) {
|
|
265
|
+
let argumentsObject = argumentsValue;
|
|
266
|
+
if (is.string(argumentsObject)) {
|
|
267
|
+
try {
|
|
268
|
+
argumentsObject = JSON.parse(argumentsObject);
|
|
269
|
+
} catch (cause) {
|
|
270
|
+
throw projectionError('received invalid complete argument JSON.', cause);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (!argumentsObject || !is.object(argumentsObject) || is.array(argumentsObject)) {
|
|
274
|
+
throw projectionError('requires a complete argument object.');
|
|
275
|
+
}
|
|
276
|
+
if (!Object.hasOwn(argumentsObject, field)) {
|
|
277
|
+
if (record.emitted > 0) {
|
|
278
|
+
throw projectionError('lost the selected argument field in a complete response.');
|
|
279
|
+
}
|
|
280
|
+
record.text = '';
|
|
281
|
+
record.textOpen = false;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const text = argumentsObject[field];
|
|
285
|
+
if (!is.string(text)) {
|
|
286
|
+
throw projectionError('requires the selected argument field to be a string.');
|
|
287
|
+
}
|
|
288
|
+
// A terminal snapshot may repeat a complete value already streamed.
|
|
289
|
+
if (record.emitted > 0 && !text.startsWith(record.text)) {
|
|
290
|
+
throw projectionError('received conflicting complete argument text.');
|
|
291
|
+
}
|
|
292
|
+
record.text = text;
|
|
293
|
+
record.textOpen = false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function emitSelectedText(record) {
|
|
297
|
+
if (signal?.aborted || record.name !== name || !record.id) return;
|
|
298
|
+
let end = record.text.length;
|
|
299
|
+
if (record.textOpen && end > record.emitted) {
|
|
300
|
+
// Keep a split surrogate pair together without changing its code units.
|
|
301
|
+
const last = record.text.charCodeAt(end - 1);
|
|
302
|
+
if (last >= 0xD800 && last <= 0xDBFF) end -= 1;
|
|
303
|
+
}
|
|
304
|
+
if (end <= record.emitted) return;
|
|
305
|
+
const text = record.text.slice(record.emitted, end);
|
|
306
|
+
record.emitted = end;
|
|
307
|
+
await onText(
|
|
308
|
+
text,
|
|
309
|
+
{id: record.id, name: record.name, field, index: record.index, choiceIndex: record.choiceIndex}
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function observeCalls(calls, choiceIndex, complete) {
|
|
314
|
+
if (!is.array(calls)) return;
|
|
315
|
+
for (let position = 0; position < calls.length; position += 1) {
|
|
316
|
+
if (signal?.aborted) return;
|
|
317
|
+
const call = calls[position];
|
|
318
|
+
if (!call || !is.object(call)) continue;
|
|
319
|
+
const record = recordFor(choiceIndex, call.index ?? position);
|
|
320
|
+
if (is.string(call.id) && call.id) {
|
|
321
|
+
if (record.emitted && record.id !== call.id) {
|
|
322
|
+
throw projectionError('changed the identity of a displayed tool call.');
|
|
323
|
+
}
|
|
324
|
+
record.id = call.id;
|
|
325
|
+
}
|
|
326
|
+
const functionValue = call.function && is.object(call.function)
|
|
327
|
+
? call.function
|
|
328
|
+
: {};
|
|
329
|
+
if (is.string(functionValue.name)) {
|
|
330
|
+
const nextName = complete ? functionValue.name : record.name + functionValue.name;
|
|
331
|
+
if (record.emitted && nextName !== record.name) {
|
|
332
|
+
throw projectionError('changed the name of a displayed tool call.');
|
|
333
|
+
}
|
|
334
|
+
record.name = nextName;
|
|
335
|
+
}
|
|
336
|
+
if (!name.startsWith(record.name)) {
|
|
337
|
+
record.pending = [];
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (!complete && is.string(functionValue.arguments)) {
|
|
341
|
+
record.pending.push(functionValue.arguments);
|
|
342
|
+
}
|
|
343
|
+
if (record.name !== name) continue;
|
|
344
|
+
prepareScanner(record);
|
|
345
|
+
if (complete) observeCompleteArguments(record, functionValue.arguments);
|
|
346
|
+
await emitSelectedText(record);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return async function observeToolText(chunk) {
|
|
351
|
+
if (signal?.aborted || !chunk || !is.object(chunk)) return;
|
|
352
|
+
if (is.array(chunk.choices)) {
|
|
353
|
+
for (let position = 0; position < chunk.choices.length; position += 1) {
|
|
354
|
+
if (signal?.aborted) return;
|
|
355
|
+
const choice = chunk.choices[position];
|
|
356
|
+
if (!choice || !is.object(choice)) continue;
|
|
357
|
+
const choiceIndex = choice.index ?? position;
|
|
358
|
+
await observeCalls(choice.delta?.tool_calls, choiceIndex, false);
|
|
359
|
+
await observeCalls(choice.message?.tool_calls, choiceIndex, true);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (!signal?.aborted) await observeCalls(chunk.message?.tool_calls, 0, true);
|
|
363
|
+
};
|
|
364
|
+
}
|
|
@@ -598,6 +598,12 @@ boundary; `finishTTS()` flushes any remaining text. It is not a playback-ended
|
|
|
598
598
|
notification. Do not mute or dispose immediately after it if playback should
|
|
599
599
|
continue.
|
|
600
600
|
|
|
601
|
+
Automatic model-stream completion reads the AI instance's current mute state.
|
|
602
|
+
While muted, it clears pending speech text and formatting state and stops
|
|
603
|
+
prepared playback without invoking `finishTTS()` or `streamTTS()`. Unmuted
|
|
604
|
+
completion keeps its ordinary speech flush. Explicit calls to either public
|
|
605
|
+
speech method retain their existing behavior.
|
|
606
|
+
|
|
601
607
|
## Automatic speech-input formatting cleanup
|
|
602
608
|
|
|
603
609
|
Every TTS entrypoint removes repeated same formatting marks from the outbound
|