@frockbot/plugin-shell 0.3.4 → 0.3.6
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/frockbot.json +1 -1
- package/package.json +31 -31
- package/src/backend-authoring.test.ts +72 -0
- package/src/backend-authoring.ts +82 -16
- package/src/backend-configuration.test.ts +6 -4
- package/src/backend-package-catalog.test.ts +23 -3
- package/src/backend-package-catalog.ts +7 -1
- package/src/backend-recovery-integration.test.ts +23 -23
- package/src/backend.ts +119 -29
- package/src/client/AppletCanvas.vue +3 -5
- package/src/client/FrockBotApp.vue +197 -79
- package/src/client/PackageIframeHost.vue +15 -6
- package/src/client/index.test.ts +111 -15
- package/src/client/index.ts +217 -43
- package/src/client/model-presentation.test.ts +4 -2
- package/src/client/model-presentation.ts +2 -2
- package/src/client/styles.css +76 -1
- package/src/run-protocol.test.ts +73 -0
- package/src/run-protocol.ts +161 -9
- package/src/shared.ts +6 -0
package/src/client/index.ts
CHANGED
|
@@ -53,7 +53,11 @@ import {
|
|
|
53
53
|
import { MCP_OAUTH_CONNECTION_TYPE_ID } from "@frockbot/plugin-mcp/agent";
|
|
54
54
|
import { decodeStartConnectionResultV1 } from "@frockbot/connection-core";
|
|
55
55
|
import { decodeClientSkillCatalogV1 } from "../skill-protocol.js";
|
|
56
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
ClientTurnRefusedErrorV1,
|
|
58
|
+
type ClientTurnRefusalReasonV1,
|
|
59
|
+
decodeClientTurnV1,
|
|
60
|
+
} from "../run-protocol.js";
|
|
57
61
|
import {
|
|
58
62
|
decodeApprovalDecisionReceiptV1,
|
|
59
63
|
decodeApprovalListViewV1,
|
|
@@ -240,16 +244,43 @@ function activeRunView(run: ClientRun): WebActiveRun | undefined {
|
|
|
240
244
|
runId: run.runId,
|
|
241
245
|
status: run.status,
|
|
242
246
|
message: run.stopRequestedAt
|
|
243
|
-
? "
|
|
244
|
-
:
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
247
|
+
? "Stopping…"
|
|
248
|
+
: "Something went wrong mid-reply. Try again to pick it up.",
|
|
249
|
+
// Offered whenever the run is parked, Stop included. Hiding it there
|
|
250
|
+
// hid it in exactly the case Stop creates: a Turn that was stopped
|
|
251
|
+
// while the model was mid-answer parks, and the person was left with a
|
|
252
|
+
// banner and no way to act on it.
|
|
253
|
+
canResume: run.recovery?.action === "resume",
|
|
248
254
|
};
|
|
249
255
|
}
|
|
250
256
|
return undefined;
|
|
251
257
|
}
|
|
252
258
|
|
|
259
|
+
/**
|
|
260
|
+
* What a refused send tells the person. The refusal's own `error` names the
|
|
261
|
+
* durable invariant that declined it, which the debug surface needs and the
|
|
262
|
+
* composer does not, so the typed reason picks the sentence instead.
|
|
263
|
+
*/
|
|
264
|
+
function turnRefusalCopyV1(reason: ClientTurnRefusalReasonV1): string {
|
|
265
|
+
if (reason === "busy")
|
|
266
|
+
return "This Bot is still working on your last message.";
|
|
267
|
+
if (reason === "reconciliation-required")
|
|
268
|
+
return "This Bot's last reply stopped partway. Try again to continue it.";
|
|
269
|
+
if (reason === "duplicate") return "That message was already sent.";
|
|
270
|
+
return "That message didn't go through. Try sending it again.";
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The Bot's voice is its sends. When a Turn delivered anything to the User the
|
|
275
|
+
* model's own assistant text is scratch space and the thread does not draw it
|
|
276
|
+
* (issue 153): drawing both is how a one-word reply arrived twice, once as the
|
|
277
|
+
* model's text and once as the bubble that was actually delivered.
|
|
278
|
+
*/
|
|
279
|
+
function visibleAssistantText(run: ClientRun, fallback = ""): string {
|
|
280
|
+
if (sendsFrom(run.events).length > 0) return "";
|
|
281
|
+
return run.responseText ?? fallback;
|
|
282
|
+
}
|
|
283
|
+
|
|
253
284
|
function isTerminalRun(run: ClientRun): boolean {
|
|
254
285
|
return (
|
|
255
286
|
run.status === "completed" ||
|
|
@@ -270,7 +301,7 @@ function assistantMessage(
|
|
|
270
301
|
id: `${run.runId}:assistant`,
|
|
271
302
|
runId: run.runId,
|
|
272
303
|
role: "assistant",
|
|
273
|
-
text: run
|
|
304
|
+
text: visibleAssistantText(run),
|
|
274
305
|
status: "streaming",
|
|
275
306
|
// A Turn that has not started shows nothing of its own: the greyed user
|
|
276
307
|
// message is the whole of what the thread says about it.
|
|
@@ -287,7 +318,8 @@ function assistantMessage(
|
|
|
287
318
|
id: `${run.runId}:assistant`,
|
|
288
319
|
runId: run.runId,
|
|
289
320
|
role: "assistant",
|
|
290
|
-
text: run
|
|
321
|
+
text: visibleAssistantText(run),
|
|
322
|
+
notice: "Interrupted by your next message.",
|
|
291
323
|
status: "aborted",
|
|
292
324
|
tools: toolsFrom(run.events),
|
|
293
325
|
sends: sendsFrom(run.events),
|
|
@@ -299,10 +331,7 @@ function assistantMessage(
|
|
|
299
331
|
id: `${run.runId}:assistant`,
|
|
300
332
|
runId: run.runId,
|
|
301
333
|
role: "assistant",
|
|
302
|
-
text:
|
|
303
|
-
run.recovery?.message ??
|
|
304
|
-
run.failure ??
|
|
305
|
-
"Provider reconciliation is required before this Turn can continue.",
|
|
334
|
+
text: "This reply stopped partway. Try again to continue it.",
|
|
306
335
|
status: "reconciliation-required",
|
|
307
336
|
tools: toolsFrom(run.events),
|
|
308
337
|
sends: sendsFrom(run.events),
|
|
@@ -314,21 +343,39 @@ function assistantMessage(
|
|
|
314
343
|
id: `${run.runId}:assistant`,
|
|
315
344
|
runId: run.runId,
|
|
316
345
|
role: "assistant",
|
|
317
|
-
text: run
|
|
346
|
+
text: visibleAssistantText(run),
|
|
347
|
+
notice: "You stopped this.",
|
|
318
348
|
status: "aborted",
|
|
319
349
|
tools: toolsFrom(run.events),
|
|
320
350
|
sends: sendsFrom(run.events),
|
|
321
351
|
tasks: tasksFrom(run.events),
|
|
322
352
|
};
|
|
323
353
|
}
|
|
354
|
+
// A Turn that broke after it had started talking keeps what it said, with
|
|
355
|
+
// the reason underneath it — the treatment a stopped Turn already gets, for
|
|
356
|
+
// the same reason: the words arrived and the person read them (ADR 0028).
|
|
357
|
+
// A Turn that broke before saying anything is still just the reason.
|
|
358
|
+
if (run.status === "failed" && run.responseText) {
|
|
359
|
+
return {
|
|
360
|
+
id: `${run.runId}:assistant`,
|
|
361
|
+
runId: run.runId,
|
|
362
|
+
role: "assistant",
|
|
363
|
+
text: run.responseText,
|
|
364
|
+
notice: run.failure ?? "Agent request failed.",
|
|
365
|
+
status: "error",
|
|
366
|
+
tools: toolsFrom(run.events),
|
|
367
|
+
sends: sendsFrom(run.events),
|
|
368
|
+
tasks: tasksFrom(run.events),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
324
371
|
return {
|
|
325
372
|
id: `${run.runId}:assistant`,
|
|
326
373
|
runId: run.runId,
|
|
327
374
|
role: "assistant",
|
|
328
375
|
text:
|
|
329
376
|
run.status === "failed"
|
|
330
|
-
?
|
|
331
|
-
: (run
|
|
377
|
+
? "This Bot couldn't finish its reply. Try again."
|
|
378
|
+
: visibleAssistantText(run, notification?.body ?? ""),
|
|
332
379
|
status: run.status === "failed" ? "error" : "completed",
|
|
333
380
|
tools: toolsFrom(run.events),
|
|
334
381
|
sends: sendsFrom(run.events),
|
|
@@ -420,8 +467,12 @@ export function projectDurableRuns(
|
|
|
420
467
|
activeRun = activeRunView(run) ?? activeRun;
|
|
421
468
|
if (run.status === "running" || run.status === "reconciliation-required") {
|
|
422
469
|
busyRunId = run.runId;
|
|
423
|
-
if (!run.queued) runningRunId = run.runId;
|
|
424
470
|
}
|
|
471
|
+
// Stop belongs to a Turn that is executing. A Turn parked on a
|
|
472
|
+
// reconciliation is busy but not running: there is nothing to stop, and
|
|
473
|
+
// offering it left a Stop button standing for good — across reloads,
|
|
474
|
+
// because the state it was keyed off never became terminal.
|
|
475
|
+
if (run.status === "running" && !run.queued) runningRunId = run.runId;
|
|
425
476
|
if (notification && isTerminalRun(run)) {
|
|
426
477
|
projected.add(notification.notificationId);
|
|
427
478
|
}
|
|
@@ -439,7 +490,13 @@ export function projectDurableRuns(
|
|
|
439
490
|
state.activeRunId = undefined;
|
|
440
491
|
}
|
|
441
492
|
if (runningRunId) state.runningRunId = runningRunId;
|
|
442
|
-
else if (
|
|
493
|
+
else if (
|
|
494
|
+
state.runningRunId &&
|
|
495
|
+
runs.some((run) => run.runId === state.runningRunId)
|
|
496
|
+
) {
|
|
497
|
+
// The channel is carrying this run and it is not executing, whatever it
|
|
498
|
+
// settled as. A run the list does not carry yet is the one this tab just
|
|
499
|
+
// submitted, which keeps its Stop.
|
|
443
500
|
state.runningRunId = undefined;
|
|
444
501
|
}
|
|
445
502
|
if (activeRun) state.activeRun = activeRun;
|
|
@@ -695,7 +752,7 @@ export function decodePluginCatalog(value: unknown): PluginCatalogItem[] {
|
|
|
695
752
|
!Array.isArray(value.packages) ||
|
|
696
753
|
value.packages.length > 256
|
|
697
754
|
) {
|
|
698
|
-
throw new Error("
|
|
755
|
+
throw new Error("FrockBot couldn't load this deployment. Reload the page.");
|
|
699
756
|
}
|
|
700
757
|
return value.packages.flatMap((candidate) => {
|
|
701
758
|
if (
|
|
@@ -728,7 +785,9 @@ export function decodePluginCatalog(value: unknown): PluginCatalogItem[] {
|
|
|
728
785
|
kind === "mobile",
|
|
729
786
|
)
|
|
730
787
|
) {
|
|
731
|
-
throw new Error(
|
|
788
|
+
throw new Error(
|
|
789
|
+
"FrockBot couldn't load this deployment. Reload the page.",
|
|
790
|
+
);
|
|
732
791
|
}
|
|
733
792
|
const decoded = decodeFrockBotManifest({
|
|
734
793
|
// v4, so a Capability carrying an admission ceiling decodes here too.
|
|
@@ -910,7 +969,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
910
969
|
web.value.activeRun = {
|
|
911
970
|
runId,
|
|
912
971
|
status: "running",
|
|
913
|
-
message: "
|
|
972
|
+
message: "Checking whether your message went through…",
|
|
914
973
|
canResume: false,
|
|
915
974
|
};
|
|
916
975
|
if (!ctx.transport.lookupRun || !ctx.transport.fenceRunAdmission) {
|
|
@@ -950,7 +1009,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
950
1009
|
reconciliationError = `${
|
|
951
1010
|
error instanceof Error
|
|
952
1011
|
? error.message
|
|
953
|
-
: "
|
|
1012
|
+
: "Couldn't check on your message."
|
|
954
1013
|
} Retrying…`;
|
|
955
1014
|
web.value.settingsError = reconciliationError;
|
|
956
1015
|
}
|
|
@@ -982,7 +1041,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
982
1041
|
) {
|
|
983
1042
|
return;
|
|
984
1043
|
}
|
|
985
|
-
if (!run) throw new Error("
|
|
1044
|
+
if (!run) throw new Error("Couldn't load that reply.");
|
|
986
1045
|
if (web.value.settingsError === observationError) {
|
|
987
1046
|
web.value.settingsError = undefined;
|
|
988
1047
|
}
|
|
@@ -992,7 +1051,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
992
1051
|
} catch (error) {
|
|
993
1052
|
if (signal.aborted) return;
|
|
994
1053
|
observationError = `${
|
|
995
|
-
error instanceof Error ? error.message : "
|
|
1054
|
+
error instanceof Error ? error.message : "Couldn't load that reply."
|
|
996
1055
|
} Retrying…`;
|
|
997
1056
|
web.value.settingsError = observationError;
|
|
998
1057
|
}
|
|
@@ -1180,7 +1239,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1180
1239
|
|
|
1181
1240
|
const web: Ref<ShellWebData> = ref({
|
|
1182
1241
|
connection: "ready",
|
|
1183
|
-
modelLabel: "
|
|
1242
|
+
modelLabel: "No model available — set one up in Models",
|
|
1184
1243
|
modelReady: false,
|
|
1185
1244
|
modelSource: "none",
|
|
1186
1245
|
settingsAvailable: true,
|
|
@@ -1510,12 +1569,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1510
1569
|
const botId = web.value.activeBotId;
|
|
1511
1570
|
const catalog = web.value.packageUi;
|
|
1512
1571
|
if (!post || !botId || !catalog || catalog.botId !== botId) {
|
|
1513
|
-
throw new Error("
|
|
1572
|
+
throw new Error("That plugin's page isn't available.");
|
|
1514
1573
|
}
|
|
1515
1574
|
if (!packageIframeToolAllowedV1(contribution, name)) {
|
|
1516
|
-
throw new Error(
|
|
1517
|
-
`Package "${contribution.packageId}" did not declare tool "${name}"`,
|
|
1518
|
-
);
|
|
1575
|
+
throw new Error(`That plugin isn't allowed to use ${name}.`);
|
|
1519
1576
|
}
|
|
1520
1577
|
const turn = decodeClientTurnV1(
|
|
1521
1578
|
await post(
|
|
@@ -1942,9 +1999,22 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1942
1999
|
updateSettingsLoadError("package-catalog");
|
|
1943
2000
|
} catch (error) {
|
|
1944
2001
|
if (generation !== packageCatalogGeneration) return;
|
|
2002
|
+
// The gateway answers 404 `catalog generation was not found` when the
|
|
2003
|
+
// deployment has published no Catalog at all. That is a state, not a
|
|
2004
|
+
// fault, and the raw server sentence means nothing to a person — so it
|
|
2005
|
+
// is translated here and the surface renders it instead of the
|
|
2006
|
+
// "nothing matched your search" empty state.
|
|
2007
|
+
const raw =
|
|
2008
|
+
error instanceof Error ? error.message : "Could not load the Catalog";
|
|
2009
|
+
web.value.packageCatalog = [];
|
|
2010
|
+
web.value.packageCatalogGeneration = undefined;
|
|
1945
2011
|
updateSettingsLoadError(
|
|
1946
2012
|
"package-catalog",
|
|
1947
|
-
|
|
2013
|
+
/catalog generation was not found|Package Catalog is not configured/.test(
|
|
2014
|
+
raw,
|
|
2015
|
+
)
|
|
2016
|
+
? "No plugins are published for this deployment yet."
|
|
2017
|
+
: `Plugins could not be loaded: ${raw}`,
|
|
1948
2018
|
);
|
|
1949
2019
|
}
|
|
1950
2020
|
},
|
|
@@ -1974,7 +2044,8 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1974
2044
|
if (!settings || !ctx.transport.executeConfiguration) {
|
|
1975
2045
|
throw new Error("Plugins are unavailable");
|
|
1976
2046
|
}
|
|
1977
|
-
if (!generation)
|
|
2047
|
+
if (!generation)
|
|
2048
|
+
throw new Error("The catalog isn't loaded yet. Try again in a moment.");
|
|
1978
2049
|
const receipt = await ctx.transport.executeConfiguration({
|
|
1979
2050
|
schemaVersion: 1,
|
|
1980
2051
|
type: "user/install-package",
|
|
@@ -2257,8 +2328,8 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2257
2328
|
if (result.status !== "applied") {
|
|
2258
2329
|
throw new Error(
|
|
2259
2330
|
result.status === "reconciliation-required"
|
|
2260
|
-
? "
|
|
2261
|
-
: "
|
|
2331
|
+
? "Disconnecting didn't finish. Try again."
|
|
2332
|
+
: "Couldn't disconnect that account.",
|
|
2262
2333
|
);
|
|
2263
2334
|
}
|
|
2264
2335
|
},
|
|
@@ -2296,6 +2367,12 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2296
2367
|
const observed = web.value.activeRunId;
|
|
2297
2368
|
const supersedes = observed ? { runId: observed } : {};
|
|
2298
2369
|
web.value.activeRunId = pendingRunId;
|
|
2370
|
+
// Stop is offered for the whole of the Turn the User just started, not
|
|
2371
|
+
// only from the moment a projection happens to arrive. A send that
|
|
2372
|
+
// supersedes a running Turn is the exception: the Turn Stop targets is
|
|
2373
|
+
// still the one executing, and this one is queued behind it. The durable
|
|
2374
|
+
// projection corrects both the instant it arrives.
|
|
2375
|
+
if (!observed) web.value.runningRunId = pendingRunId;
|
|
2299
2376
|
web.value.error = undefined;
|
|
2300
2377
|
web.value.messages.push(
|
|
2301
2378
|
{
|
|
@@ -2355,7 +2432,10 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2355
2432
|
id: `${result.runId}:assistant`,
|
|
2356
2433
|
runId: result.runId,
|
|
2357
2434
|
role: "assistant",
|
|
2358
|
-
|
|
2435
|
+
// The same rule the durable projection follows: a Turn that
|
|
2436
|
+
// delivered something speaks through its sends, not through the
|
|
2437
|
+
// model's own text (issue 153).
|
|
2438
|
+
text: sendsFrom(result.events).length > 0 ? "" : result.text,
|
|
2359
2439
|
at: optimisticAt,
|
|
2360
2440
|
status: "completed",
|
|
2361
2441
|
tools: toolsFrom(result.events),
|
|
@@ -2380,15 +2460,23 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2380
2460
|
web.value.activeBotId !== botId
|
|
2381
2461
|
)
|
|
2382
2462
|
return { accepted: true, runId: pendingRunId };
|
|
2463
|
+
// A refusal is a normal answer, not an uncertain send: the Bot
|
|
2464
|
+
// declined and said why. Show that, drop the optimistic bubbles, and
|
|
2465
|
+
// let the composer give the person their text back — fencing a run
|
|
2466
|
+
// that was never admitted only threw the reason away.
|
|
2467
|
+
if (error instanceof ClientTurnRefusedErrorV1) {
|
|
2468
|
+
removeMessages(web.value.messages, pendingRunId);
|
|
2469
|
+
const refusal = turnRefusalCopyV1(error.refusal.reason);
|
|
2470
|
+
web.value.error = refusal;
|
|
2471
|
+
return { accepted: false, error: refusal };
|
|
2472
|
+
}
|
|
2383
2473
|
const aborted =
|
|
2384
2474
|
error instanceof DOMException && error.name === "AbortError";
|
|
2385
2475
|
replaceMessage(web.value.messages, pendingRunId, {
|
|
2386
2476
|
id: `${pendingRunId}:assistant`,
|
|
2387
2477
|
runId: pendingRunId,
|
|
2388
2478
|
role: "assistant",
|
|
2389
|
-
text:
|
|
2390
|
-
? "Request stopped locally; checking whether it started."
|
|
2391
|
-
: "Confirming whether this Turn was admitted.",
|
|
2479
|
+
text: "Checking whether your message went through…",
|
|
2392
2480
|
at: optimisticAt,
|
|
2393
2481
|
status: "interrupted",
|
|
2394
2482
|
tools: [],
|
|
@@ -2417,14 +2505,18 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2417
2505
|
id: `${pendingRunId}:assistant`,
|
|
2418
2506
|
runId: pendingRunId,
|
|
2419
2507
|
role: "assistant",
|
|
2420
|
-
text: "
|
|
2508
|
+
text: "Your message didn't go through. Try sending it again.",
|
|
2421
2509
|
at: optimisticAt,
|
|
2422
2510
|
status: "error",
|
|
2423
2511
|
tools: [],
|
|
2424
2512
|
sends: [],
|
|
2425
2513
|
});
|
|
2426
|
-
web.value.error =
|
|
2427
|
-
|
|
2514
|
+
web.value.error =
|
|
2515
|
+
"Your message didn't go through. Try sending it again.";
|
|
2516
|
+
return {
|
|
2517
|
+
accepted: false,
|
|
2518
|
+
error: "Your message didn't go through. Try sending it again.",
|
|
2519
|
+
};
|
|
2428
2520
|
}
|
|
2429
2521
|
return { accepted: true, runId: pendingRunId };
|
|
2430
2522
|
} finally {
|
|
@@ -2435,18 +2527,26 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2435
2527
|
) {
|
|
2436
2528
|
web.value.activeRunId = undefined;
|
|
2437
2529
|
}
|
|
2530
|
+
// The optimistic Stop target goes with it: a Turn nobody is running is
|
|
2531
|
+
// not a Turn anybody can stop.
|
|
2532
|
+
if (
|
|
2533
|
+
web.value.runningRunId === pendingRunId &&
|
|
2534
|
+
web.value.activeRunId !== pendingRunId
|
|
2535
|
+
) {
|
|
2536
|
+
web.value.runningRunId = undefined;
|
|
2537
|
+
}
|
|
2438
2538
|
}
|
|
2439
2539
|
},
|
|
2440
2540
|
async resumeRun(runId: string): Promise<void> {
|
|
2441
2541
|
if (!ctx.transport.reconcileRun) {
|
|
2442
|
-
web.value.settingsError = "
|
|
2542
|
+
web.value.settingsError = "Can't retry this right now.";
|
|
2443
2543
|
return;
|
|
2444
2544
|
}
|
|
2445
2545
|
if (web.value.activeRun?.runId !== runId) return;
|
|
2446
2546
|
web.value.activeRun = {
|
|
2447
2547
|
runId,
|
|
2448
2548
|
status: "running",
|
|
2449
|
-
message: "
|
|
2549
|
+
message: "Retrying…",
|
|
2450
2550
|
canResume: false,
|
|
2451
2551
|
};
|
|
2452
2552
|
const botId = web.value.activeBotId;
|
|
@@ -2455,7 +2555,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2455
2555
|
await ctx.transport.reconcileRun(botId, runId);
|
|
2456
2556
|
} catch (error) {
|
|
2457
2557
|
web.value.settingsError =
|
|
2458
|
-
error instanceof Error ? error.message : "
|
|
2558
|
+
error instanceof Error ? error.message : "Couldn't retry that.";
|
|
2459
2559
|
}
|
|
2460
2560
|
try {
|
|
2461
2561
|
await deliverNotifications(botId);
|
|
@@ -2463,7 +2563,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2463
2563
|
web.value.settingsError =
|
|
2464
2564
|
error instanceof Error
|
|
2465
2565
|
? error.message
|
|
2466
|
-
: "
|
|
2566
|
+
: "Couldn't refresh this reply.";
|
|
2467
2567
|
}
|
|
2468
2568
|
},
|
|
2469
2569
|
async stopRun(): Promise<void> {
|
|
@@ -2606,6 +2706,70 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2606
2706
|
},
|
|
2607
2707
|
);
|
|
2608
2708
|
|
|
2709
|
+
/*
|
|
2710
|
+
* A running Turn reaches every browser that is looking, not only the one
|
|
2711
|
+
* holding its POST.
|
|
2712
|
+
*
|
|
2713
|
+
* The reply to `POST /api/bots/:bot/turns` is one client's copy of a Turn.
|
|
2714
|
+
* A reload, a second tab, or a dropped request has no such copy, so the
|
|
2715
|
+
* transcript would sit on a spinner until somebody reloaded again. Two
|
|
2716
|
+
* seams close that: the Bot's state channel pushes a `runs` invalidation
|
|
2717
|
+
* whenever the durable run records move, and — for any client that has no
|
|
2718
|
+
* socket — the run is polled to its terminal state. Both end in the same
|
|
2719
|
+
* `GET /api/bots/:bot/turns` projection, so neither invents client state
|
|
2720
|
+
* and the one-bubble-per-send contract is untouched.
|
|
2721
|
+
*/
|
|
2722
|
+
let stopRunChannel: (() => void) | undefined;
|
|
2723
|
+
const stopRunChannelWatch = watch(
|
|
2724
|
+
() => web.value.activeBotId,
|
|
2725
|
+
(botId) => {
|
|
2726
|
+
stopRunChannel?.();
|
|
2727
|
+
stopRunChannel = undefined;
|
|
2728
|
+
if (!botId || !ctx.transport.watchBotState) return;
|
|
2729
|
+
const generation = selectionGeneration;
|
|
2730
|
+
stopRunChannel = ctx.transport.watchBotState(botId, {
|
|
2731
|
+
async invalidate(topic) {
|
|
2732
|
+
// A reset carries no topic and means "read everything again".
|
|
2733
|
+
if (topic !== undefined && topic !== "runs") return;
|
|
2734
|
+
if (
|
|
2735
|
+
generation !== selectionGeneration ||
|
|
2736
|
+
web.value.activeBotId !== botId
|
|
2737
|
+
)
|
|
2738
|
+
return;
|
|
2739
|
+
await deliverNotifications(botId, generation);
|
|
2740
|
+
},
|
|
2741
|
+
status() {
|
|
2742
|
+
// The channel's health is not the transcript's: an unavailable
|
|
2743
|
+
// socket falls back to the observation below, which is what a
|
|
2744
|
+
// client without one uses anyway.
|
|
2745
|
+
},
|
|
2746
|
+
});
|
|
2747
|
+
},
|
|
2748
|
+
{ immediate: true },
|
|
2749
|
+
);
|
|
2750
|
+
|
|
2751
|
+
const stopRunObservation = watch(
|
|
2752
|
+
() => [web.value.activeBotId, web.value.activeRunId] as const,
|
|
2753
|
+
([botId, runId]) => {
|
|
2754
|
+
// The send path owns the run it started: its POST is the observation,
|
|
2755
|
+
// and `stopRun` starts its own. This is for every other way a client
|
|
2756
|
+
// finds itself watching a Turn it is not holding open.
|
|
2757
|
+
if (!botId || !runId || activeRequest || runObserver) return;
|
|
2758
|
+
const generation = selectionGeneration;
|
|
2759
|
+
const observer = new AbortController();
|
|
2760
|
+
runObserver = observer;
|
|
2761
|
+
void observeRunUntilTerminal(botId, runId, generation, observer.signal)
|
|
2762
|
+
.catch(() => {
|
|
2763
|
+
// `observeRunUntilTerminal` reports its own failures; a rejection
|
|
2764
|
+
// here would be an unhandled one.
|
|
2765
|
+
})
|
|
2766
|
+
.finally(() => {
|
|
2767
|
+
if (runObserver === observer) runObserver = undefined;
|
|
2768
|
+
});
|
|
2769
|
+
},
|
|
2770
|
+
{ immediate: true },
|
|
2771
|
+
);
|
|
2772
|
+
|
|
2609
2773
|
return [
|
|
2610
2774
|
ctx.provide(clientSurfaceRegistryKey, surfaces),
|
|
2611
2775
|
// The shared client projection is updated by the contracts lane. This cast
|
|
@@ -2624,6 +2788,9 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2624
2788
|
() => {
|
|
2625
2789
|
stopEntrySync();
|
|
2626
2790
|
stopRunFollow();
|
|
2791
|
+
stopRunChannelWatch();
|
|
2792
|
+
stopRunChannel?.();
|
|
2793
|
+
stopRunObservation();
|
|
2627
2794
|
stopSourceFollow();
|
|
2628
2795
|
for (const dispose of entryDisposers.splice(0).toReversed()) dispose();
|
|
2629
2796
|
activeRequest?.abort();
|
|
@@ -2644,6 +2811,13 @@ function replaceMessage(
|
|
|
2644
2811
|
if (index >= 0) messages[index] = replacement;
|
|
2645
2812
|
}
|
|
2646
2813
|
|
|
2814
|
+
/** Takes back both optimistic lines of a send the Bot never admitted. */
|
|
2815
|
+
function removeMessages(messages: WebChatMessage[], runId: string): void {
|
|
2816
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
2817
|
+
if (messages[index]?.runId === runId) messages.splice(index, 1);
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2647
2821
|
export default shellClientPlugin;
|
|
2648
2822
|
|
|
2649
2823
|
/**
|
|
@@ -31,12 +31,14 @@ describe("model runtime presentation", () => {
|
|
|
31
31
|
"Llama 3 · Ollama Cloud · Account model",
|
|
32
32
|
);
|
|
33
33
|
expect(modelRuntimeLabel({ ...label, source: "bot" })).toBe(
|
|
34
|
-
"Llama 3 · Ollama Cloud · Bot
|
|
34
|
+
"Llama 3 · Ollama Cloud · this Bot only",
|
|
35
35
|
);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
test("shows unavailable and backend failure states", () => {
|
|
39
|
-
expect(modelRuntimeLabel({ source: "none" })).toBe(
|
|
39
|
+
expect(modelRuntimeLabel({ source: "none" })).toBe(
|
|
40
|
+
"No model available — set one up in Models",
|
|
41
|
+
);
|
|
40
42
|
expect(
|
|
41
43
|
modelRuntimeLabel({
|
|
42
44
|
source: "account",
|
|
@@ -14,13 +14,13 @@ export function modelRuntimeLabel(input: {
|
|
|
14
14
|
}): string {
|
|
15
15
|
if (input.failure) return input.failure;
|
|
16
16
|
if (input.source === "none" || !input.providerModelId) {
|
|
17
|
-
return "
|
|
17
|
+
return "No model available — set one up in Models";
|
|
18
18
|
}
|
|
19
19
|
const model =
|
|
20
20
|
input.modelDisplayName ?? input.providerModelId ?? "Connected model";
|
|
21
21
|
const provider = input.packageDisplayName ?? input.connectionDisplayName;
|
|
22
22
|
const runtime = provider ? `${model} · ${provider}` : model;
|
|
23
|
-
if (input.source === "bot") return `${runtime} · Bot
|
|
23
|
+
if (input.source === "bot") return `${runtime} · this Bot only`;
|
|
24
24
|
if (input.source === "account") return `${runtime} · Account model`;
|
|
25
25
|
return runtime;
|
|
26
26
|
}
|
package/src/client/styles.css
CHANGED
|
@@ -290,13 +290,38 @@
|
|
|
290
290
|
text-align: center;
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
/*
|
|
293
|
+
/*
|
|
294
|
+
* Why a Turn ends where it does — stopped, or replaced by a later message.
|
|
295
|
+
* Quiet, and under the answer rather than instead of it: the words the Bot
|
|
296
|
+
* already said are the Turn's content, and this is only its last line.
|
|
297
|
+
*/
|
|
298
|
+
.message-notice {
|
|
299
|
+
margin: 2px 0 0;
|
|
300
|
+
color: var(--frock-text-muted);
|
|
301
|
+
font-size: var(--frock-text-xs);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/*
|
|
305
|
+
* An assistant Turn is its avatar and, beside it, one column holding
|
|
306
|
+
* everything the Turn produced. The row has exactly two children: bubbles,
|
|
307
|
+
* notices, sends and chips stack inside the column, so a one-word reply is a
|
|
308
|
+
* bubble the width of its word rather than a sliver of a shared row.
|
|
309
|
+
*/
|
|
294
310
|
.message-assistant {
|
|
295
311
|
flex-direction: row;
|
|
296
312
|
align-items: flex-start;
|
|
297
313
|
gap: 8px;
|
|
298
314
|
}
|
|
299
315
|
|
|
316
|
+
.message-column {
|
|
317
|
+
display: flex;
|
|
318
|
+
min-width: 0;
|
|
319
|
+
flex: 1 1 auto;
|
|
320
|
+
flex-direction: column;
|
|
321
|
+
align-items: flex-start;
|
|
322
|
+
gap: 6px;
|
|
323
|
+
}
|
|
324
|
+
|
|
300
325
|
.bot-avatar {
|
|
301
326
|
position: relative;
|
|
302
327
|
display: grid;
|
|
@@ -411,6 +436,56 @@
|
|
|
411
436
|
width: min(640px, 84%);
|
|
412
437
|
}
|
|
413
438
|
|
|
439
|
+
/*
|
|
440
|
+
* Tool calls. Quieter than anything the Bot said: the User is watching work
|
|
441
|
+
* happen, not reading a message, so a chip carries the name and its state and
|
|
442
|
+
* opens in place to what the tool returned.
|
|
443
|
+
*/
|
|
444
|
+
|
|
445
|
+
.message-tools {
|
|
446
|
+
display: flex;
|
|
447
|
+
flex-direction: column;
|
|
448
|
+
gap: 6px;
|
|
449
|
+
width: min(640px, 84%);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
.tool-chip {
|
|
453
|
+
display: flex;
|
|
454
|
+
flex-wrap: wrap;
|
|
455
|
+
gap: 8px;
|
|
456
|
+
align-items: baseline;
|
|
457
|
+
padding: 6px 10px;
|
|
458
|
+
font: inherit;
|
|
459
|
+
color: var(--frock-text-muted);
|
|
460
|
+
text-align: left;
|
|
461
|
+
cursor: pointer;
|
|
462
|
+
background: var(--frock-surface);
|
|
463
|
+
border: 1px solid var(--frock-border);
|
|
464
|
+
border-radius: 10px;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.tool-chip-name {
|
|
468
|
+
color: var(--frock-text);
|
|
469
|
+
font-weight: 600;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.tool-chip-status {
|
|
473
|
+
flex: 1 1 auto;
|
|
474
|
+
font-size: var(--frock-text-xs);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
.tool-chip-failed .tool-chip-status {
|
|
478
|
+
color: var(--frock-danger-text);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
.tool-chip-result {
|
|
482
|
+
flex: 1 0 100%;
|
|
483
|
+
max-height: 12em;
|
|
484
|
+
overflow: auto;
|
|
485
|
+
white-space: pre-wrap;
|
|
486
|
+
word-break: break-word;
|
|
487
|
+
}
|
|
488
|
+
|
|
414
489
|
/*
|
|
415
490
|
* Dispatched subagents. A chip is deliberately quiet — a subagent is work the
|
|
416
491
|
* Bot delegated, not something it said — and opens in place to the summary the
|