@frockbot/plugin-shell 0.3.9 → 0.3.11
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/package.json +33 -31
- package/src/agent.test.ts +66 -0
- package/src/agent.ts +42 -1
- package/src/backend-configuration.test.ts +10 -2
- package/src/backend-package-catalog.ts +98 -21
- package/src/backend.ts +99 -13
- package/src/client/FrockBotApp.vue +52 -111
- package/src/client/activity-ring.test.ts +89 -0
- package/src/client/activity-ring.ts +94 -0
- package/src/client/index.ts +1 -0
- package/src/client/model-presentation.test.ts +14 -0
- package/src/client/model-presentation.ts +6 -0
- package/src/client/styles.css +39 -93
- package/src/composition-manifest.test.ts +126 -0
- package/src/composition-manifest.ts +55 -0
- package/src/notification-id.test.ts +93 -0
- package/src/notification-id.ts +0 -0
- package/src/run-protocol.ts +13 -2
- package/src/settings-links.ts +6 -0
- package/src/unread.ts +23 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { clientSurfaceRegistryKey } from "@frockbot/client-core";
|
|
3
3
|
import {
|
|
4
4
|
announceUiAnchor,
|
|
5
|
+
UiActivityRing,
|
|
5
6
|
UiIcon,
|
|
6
7
|
UiIconButton,
|
|
7
8
|
UiMarkdown,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
type WebToolActivity,
|
|
27
28
|
} from "../shared.js";
|
|
28
29
|
import { ComposerDraftStore } from "./composer-draft.js";
|
|
30
|
+
import { activityRingV1 } from "./activity-ring.js";
|
|
29
31
|
import {
|
|
30
32
|
TURN_TEXT_MAX_CHARACTERS_V1,
|
|
31
33
|
turnTextCounterVisibleV1,
|
|
@@ -431,66 +433,22 @@ function taskChipsOf(message: WebChatMessage): Array<{
|
|
|
431
433
|
}
|
|
432
434
|
|
|
433
435
|
/**
|
|
434
|
-
* The
|
|
436
|
+
* The activity ring for one assistant line.
|
|
435
437
|
*
|
|
436
|
-
* A
|
|
437
|
-
*
|
|
438
|
-
*
|
|
438
|
+
* A Turn that spends a minute making tool calls used to show the User nothing
|
|
439
|
+
* but a breathing avatar, and then — briefly — a list of tool names, which put
|
|
440
|
+
* the model's plumbing into a conversation. The ring is neither: it pulses
|
|
441
|
+
* while the Turn runs and ticks forward for every step that settles, so the
|
|
442
|
+
* account of an ordinary tool call is a segment of a stroke and no words at
|
|
443
|
+
* all. The rule lives in `activity-ring.ts`; this only reads the message.
|
|
439
444
|
*/
|
|
440
|
-
function
|
|
441
|
-
return message.tools.filter((tool) => iframeEntriesFor(tool).length === 0);
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* What a chip calls a tool, in the User's words rather than the model's.
|
|
446
|
-
*
|
|
447
|
-
* A tool name is an identifier — `send_to_user`, `user-Github--acme/search_issues`
|
|
448
|
-
* — and the transcript is a conversation, so the chip drops the namespace,
|
|
449
|
-
* un-snakes the rest and capitalises it.
|
|
450
|
-
*/
|
|
451
|
-
function toolChipLabel(tool: WebToolActivity): string {
|
|
452
|
-
const bare = tool.name.split("/").pop() ?? tool.name;
|
|
453
|
-
const words = bare.replace(/[_.-]+/g, " ").trim();
|
|
454
|
-
if (words.length === 0) return tool.name;
|
|
455
|
-
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* Whether a chip is drawn as a failure.
|
|
460
|
-
*
|
|
461
|
-
* A tool call the model recovered from is not a failure the User has anything
|
|
462
|
-
* to do with: a refused call followed by a Turn that went on to finish is the
|
|
463
|
-
* Bot correcting itself, and colouring it red reports a broken Turn that
|
|
464
|
-
* worked. Only a Turn that itself ended badly keeps the failed state.
|
|
465
|
-
*/
|
|
466
|
-
function toolChipState(
|
|
467
|
-
tool: WebToolActivity,
|
|
445
|
+
function activityRingOf(
|
|
468
446
|
message: WebChatMessage,
|
|
469
|
-
):
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/** What a chip says a tool is doing. Its status, in the User's words. */
|
|
477
|
-
function toolChipStatus(
|
|
478
|
-
tool: WebToolActivity,
|
|
479
|
-
message: WebChatMessage,
|
|
480
|
-
): string {
|
|
481
|
-
const state = toolChipState(tool, message);
|
|
482
|
-
if (state === "running") return "running";
|
|
483
|
-
if (state === "retried") return "retried";
|
|
484
|
-
return state === "failed" ? "failed" : "done";
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/** Which tool chips the User has opened. Local, and per chip. */
|
|
488
|
-
const expandedTools = ref(new Set<string>());
|
|
489
|
-
|
|
490
|
-
function toggleTool(toolId: string): void {
|
|
491
|
-
const next = new Set(expandedTools.value);
|
|
492
|
-
if (!next.delete(toolId)) next.add(toolId);
|
|
493
|
-
expandedTools.value = next;
|
|
447
|
+
): ReturnType<typeof activityRingV1> {
|
|
448
|
+
return activityRingV1({
|
|
449
|
+
toolStatuses: message.tools.map((tool) => tool.status),
|
|
450
|
+
status: message.status,
|
|
451
|
+
});
|
|
494
452
|
}
|
|
495
453
|
|
|
496
454
|
/** Which chips the User has opened. Local, and per chip. */
|
|
@@ -928,28 +886,45 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
928
886
|
</p>
|
|
929
887
|
<template v-else-if="message.role === 'assistant'">
|
|
930
888
|
<!--
|
|
931
|
-
The Bot's own avatar
|
|
932
|
-
|
|
933
|
-
|
|
889
|
+
The Bot's own avatar, which appears only while it is working.
|
|
890
|
+
Every line in this transcript is from the same Bot — there are
|
|
891
|
+
no group conversations yet (issue 152) — so a sheep beside a
|
|
892
|
+
settled reply named nobody the reader did not already know. The
|
|
893
|
+
one beside a running Turn carries the ring, which is the whole
|
|
894
|
+
point of drawing it.
|
|
895
|
+
|
|
896
|
+
The art comes from whichever Package owns Bot identity; when no
|
|
897
|
+
Package fills the slot the sparkle tile is the only child and
|
|
898
|
+
shows through.
|
|
934
899
|
-->
|
|
935
|
-
<
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
'bot-avatar-waiting':
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
900
|
+
<Transition name="activity-ring">
|
|
901
|
+
<div
|
|
902
|
+
v-if="activityRingOf(message).active"
|
|
903
|
+
class="bot-avatar bot-avatar-live"
|
|
904
|
+
:class="{ 'bot-avatar-waiting': !message.text }"
|
|
905
|
+
>
|
|
906
|
+
<span class="bot-avatar-fallback" aria-hidden="true"
|
|
907
|
+
><UiIcon name="sparkle" size="sm"
|
|
908
|
+
/></span>
|
|
909
|
+
<k-slot name="frockbot.bot-avatar" />
|
|
910
|
+
<!--
|
|
911
|
+
What the Bot is doing, while it is doing it — a stroke round
|
|
912
|
+
the sheep that pulses and ticks a segment for every step the
|
|
913
|
+
Turn settles. It completes and fades with the avatar when
|
|
914
|
+
the Turn settles, and it never names a tool: the transcript
|
|
915
|
+
stays a conversation.
|
|
916
|
+
-->
|
|
917
|
+
<UiActivityRing
|
|
918
|
+
:progress="activityRingOf(message).progress"
|
|
919
|
+
:running="activityRingOf(message).running"
|
|
920
|
+
:laps="activityRingOf(message).laps"
|
|
921
|
+
/>
|
|
922
|
+
</div>
|
|
923
|
+
</Transition>
|
|
948
924
|
<!--
|
|
949
|
-
Everything the Turn produced stacks in one column
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
side-by-side columns squeezing the reply to a few pixels.
|
|
925
|
+
Everything the Turn produced stacks in one column. While the Bot
|
|
926
|
+
is working the avatar is beside it; once the Turn settles the
|
|
927
|
+
column is the whole row and starts at the transcript's edge.
|
|
953
928
|
-->
|
|
954
929
|
<div class="message-column">
|
|
955
930
|
<div v-if="message.text" class="message-bubble">
|
|
@@ -1024,40 +999,6 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
1024
999
|
/>
|
|
1025
1000
|
</div>
|
|
1026
1001
|
<!--
|
|
1027
|
-
What the Bot did, while it is doing it. The chip is the
|
|
1028
|
-
conversation's whole account of an ordinary tool call: its
|
|
1029
|
-
name, whether it is running, and — when the User opens it —
|
|
1030
|
-
what it returned.
|
|
1031
|
-
-->
|
|
1032
|
-
<div
|
|
1033
|
-
v-if="toolChipsOf(message).length > 0"
|
|
1034
|
-
class="message-tools"
|
|
1035
|
-
>
|
|
1036
|
-
<button
|
|
1037
|
-
v-for="tool in toolChipsOf(message)"
|
|
1038
|
-
:key="tool.id"
|
|
1039
|
-
type="button"
|
|
1040
|
-
class="tool-chip"
|
|
1041
|
-
:class="`tool-chip-${toolChipState(tool, message)}`"
|
|
1042
|
-
:aria-expanded="expandedTools.has(tool.id)"
|
|
1043
|
-
@click="toggleTool(tool.id)"
|
|
1044
|
-
>
|
|
1045
|
-
<span class="tool-chip-name">{{
|
|
1046
|
-
toolChipLabel(tool)
|
|
1047
|
-
}}</span>
|
|
1048
|
-
<span class="tool-chip-status">{{
|
|
1049
|
-
toolChipStatus(tool, message)
|
|
1050
|
-
}}</span>
|
|
1051
|
-
<span
|
|
1052
|
-
v-if="
|
|
1053
|
-
expandedTools.has(tool.id) && tool.text !== undefined
|
|
1054
|
-
"
|
|
1055
|
-
class="tool-chip-result"
|
|
1056
|
-
>{{ tool.text }}</span
|
|
1057
|
-
>
|
|
1058
|
-
</button>
|
|
1059
|
-
</div>
|
|
1060
|
-
<!--
|
|
1061
1002
|
The subagents this Turn dispatched. The child's own Session is
|
|
1062
1003
|
never in this transcript, so the chip is the whole of what the
|
|
1063
1004
|
conversation says about it; opening one shows the summary the
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
ACTIVITY_RING_MAX_LAPS_V1,
|
|
4
|
+
ACTIVITY_RING_SEGMENTS_V1,
|
|
5
|
+
activityRingV1,
|
|
6
|
+
type ActivityStepStatusV1,
|
|
7
|
+
} from "./activity-ring.js";
|
|
8
|
+
|
|
9
|
+
/** `count` settled steps, plus whatever is still in flight. */
|
|
10
|
+
function steps(settled: number, inFlight = 0): ActivityStepStatusV1[] {
|
|
11
|
+
return [
|
|
12
|
+
...Array.from<ActivityStepStatusV1>({ length: settled }).fill("completed"),
|
|
13
|
+
...Array.from<ActivityStepStatusV1>({ length: inFlight }).fill("running"),
|
|
14
|
+
];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("the avatar's activity ring", () => {
|
|
18
|
+
test("a Turn with nothing done yet still shows a ring", () => {
|
|
19
|
+
// Liveness before progress: the ring is the answer to "is it paused?", so
|
|
20
|
+
// it is drawn from the first moment of the Turn, empty and pulsing.
|
|
21
|
+
const ring = activityRingV1({ toolStatuses: [], status: "streaming" });
|
|
22
|
+
expect(ring.active).toBe(true);
|
|
23
|
+
expect(ring.running).toBe(true);
|
|
24
|
+
expect(ring.filled).toBe(0);
|
|
25
|
+
expect(ring.progress).toBe(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a step that is still running has not ticked the ring", () => {
|
|
29
|
+
const ring = activityRingV1({
|
|
30
|
+
toolStatuses: steps(2, 1),
|
|
31
|
+
status: "streaming",
|
|
32
|
+
});
|
|
33
|
+
expect(ring.steps).toBe(2);
|
|
34
|
+
expect(ring.filled).toBe(2);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("a failed step ticks like any other — it is a step that happened", () => {
|
|
38
|
+
const ring = activityRingV1({
|
|
39
|
+
toolStatuses: ["completed", "failed"],
|
|
40
|
+
status: "streaming",
|
|
41
|
+
});
|
|
42
|
+
expect(ring.filled).toBe(2);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("each settled step fills one more segment of the lap", () => {
|
|
46
|
+
for (let settled = 0; settled < ACTIVITY_RING_SEGMENTS_V1; settled += 1) {
|
|
47
|
+
const ring = activityRingV1({
|
|
48
|
+
toolStatuses: steps(settled),
|
|
49
|
+
status: "streaming",
|
|
50
|
+
});
|
|
51
|
+
expect(ring.filled).toBe(settled);
|
|
52
|
+
expect(ring.progress).toBeCloseTo(settled / ACTIVITY_RING_SEGMENTS_V1);
|
|
53
|
+
expect(ring.laps).toBe(0);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a full lap leaves a faint ring behind and starts again", () => {
|
|
58
|
+
const ring = activityRingV1({
|
|
59
|
+
toolStatuses: steps(ACTIVITY_RING_SEGMENTS_V1 + 1),
|
|
60
|
+
status: "streaming",
|
|
61
|
+
});
|
|
62
|
+
expect(ring.laps).toBe(1);
|
|
63
|
+
expect(ring.filled).toBe(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a Turn of forty tool calls never grows a fortieth ring", () => {
|
|
67
|
+
// The bound is the point: the ring reports that work is happening, not how
|
|
68
|
+
// much, so past the last lap it loops inside the strokes it already has.
|
|
69
|
+
const ring = activityRingV1({
|
|
70
|
+
toolStatuses: steps(40),
|
|
71
|
+
status: "streaming",
|
|
72
|
+
});
|
|
73
|
+
expect(ring.laps).toBe(ACTIVITY_RING_MAX_LAPS_V1);
|
|
74
|
+
expect(ring.filled).toBeLessThan(ACTIVITY_RING_SEGMENTS_V1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("a settled Turn keeps no ring, however it ended", () => {
|
|
78
|
+
for (const status of [
|
|
79
|
+
"completed",
|
|
80
|
+
"failed",
|
|
81
|
+
"interrupted",
|
|
82
|
+
"reconciliation-required",
|
|
83
|
+
] as const) {
|
|
84
|
+
const ring = activityRingV1({ toolStatuses: steps(3), status });
|
|
85
|
+
expect(ring.active).toBe(false);
|
|
86
|
+
expect(ring.running).toBe(false);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The activity ring: how much a running Turn has done, without saying so.
|
|
3
|
+
*
|
|
4
|
+
* A Turn can spend a minute making tool calls, and the thread used to answer
|
|
5
|
+
* that with either nothing or a list of tool names. Neither is right — the
|
|
6
|
+
* first reads as a paused app, the second puts the model's plumbing into a
|
|
7
|
+
* conversation. The ring is the third answer: a stroke around the Bot's avatar
|
|
8
|
+
* that pulses while the Turn runs and advances one segment for every step that
|
|
9
|
+
* settles, so a person sees liveness and rough progress and reads no words at
|
|
10
|
+
* all.
|
|
11
|
+
*
|
|
12
|
+
* This module is the whole rule, kept out of the component so it is testable
|
|
13
|
+
* without mounting Vue.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Segments in one lap of the ring. A step fills one. */
|
|
17
|
+
export const ACTIVITY_RING_SEGMENTS_V1 = 8;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How many laps the ring will draw at once. A Turn making forty tool calls
|
|
21
|
+
* must not grow a fortieth ring, so the lap beyond the first is the last one:
|
|
22
|
+
* past it the ring keeps looping inside those two strokes.
|
|
23
|
+
*/
|
|
24
|
+
export const ACTIVITY_RING_MAX_LAPS_V1 = 1;
|
|
25
|
+
|
|
26
|
+
/** A step's state, as the Turn's tool activity reports it. */
|
|
27
|
+
export type ActivityStepStatusV1 = "running" | "completed" | "failed";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A Turn's state, as the thread's message carries it.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately the whole string rather than the union: only `streaming` means
|
|
33
|
+
* the Turn is still going, and every other value — including one a newer Bot
|
|
34
|
+
* invents — is an ending. The ring is drawn off that one positive test, so it
|
|
35
|
+
* cannot be left spinning by a status this file has never heard of.
|
|
36
|
+
*/
|
|
37
|
+
export type ActivityTurnStatusV1 = string;
|
|
38
|
+
|
|
39
|
+
export interface ActivityRingViewV1 {
|
|
40
|
+
/** Whether the ring is drawn at all. A settled Turn keeps none. */
|
|
41
|
+
active: boolean;
|
|
42
|
+
/** Whether the ring pulses. False once the Turn has settled. */
|
|
43
|
+
running: boolean;
|
|
44
|
+
/** Steps that have settled in this Turn. Unbounded, and never rendered. */
|
|
45
|
+
steps: number;
|
|
46
|
+
/** Filled segments of the current lap, `0 … ACTIVITY_RING_SEGMENTS_V1`. */
|
|
47
|
+
filled: number;
|
|
48
|
+
/** Faint laps completed behind the live stroke, capped. */
|
|
49
|
+
laps: number;
|
|
50
|
+
/** The fraction of the circumference the live stroke draws, `0 … 1`. */
|
|
51
|
+
progress: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The ring for one assistant line.
|
|
56
|
+
*
|
|
57
|
+
* A running Turn ticks: every settled tool call advances the stroke one
|
|
58
|
+
* segment, and the lap that fills leaves a faint ring behind it — up to
|
|
59
|
+
* {@link ACTIVITY_RING_MAX_LAPS_V1}, after which the ring simply loops. A Turn
|
|
60
|
+
* that has settled draws no ring; the reply is the answer by then, and the
|
|
61
|
+
* component fades the ring out on its way off screen.
|
|
62
|
+
*/
|
|
63
|
+
export function activityRingV1(input: {
|
|
64
|
+
toolStatuses: readonly ActivityStepStatusV1[];
|
|
65
|
+
status: ActivityTurnStatusV1;
|
|
66
|
+
}): ActivityRingViewV1 {
|
|
67
|
+
const running = input.status === "streaming";
|
|
68
|
+
const steps = input.toolStatuses.filter(
|
|
69
|
+
(status) => status !== "running",
|
|
70
|
+
).length;
|
|
71
|
+
if (!running) {
|
|
72
|
+
return {
|
|
73
|
+
active: false,
|
|
74
|
+
running: false,
|
|
75
|
+
steps,
|
|
76
|
+
filled: 0,
|
|
77
|
+
laps: 0,
|
|
78
|
+
progress: 0,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const filled = steps % ACTIVITY_RING_SEGMENTS_V1;
|
|
82
|
+
const laps = Math.min(
|
|
83
|
+
Math.floor(steps / ACTIVITY_RING_SEGMENTS_V1),
|
|
84
|
+
ACTIVITY_RING_MAX_LAPS_V1,
|
|
85
|
+
);
|
|
86
|
+
return {
|
|
87
|
+
active: true,
|
|
88
|
+
running: true,
|
|
89
|
+
steps,
|
|
90
|
+
filled,
|
|
91
|
+
laps,
|
|
92
|
+
progress: filled / ACTIVITY_RING_SEGMENTS_V1,
|
|
93
|
+
};
|
|
94
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -1228,6 +1228,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1228
1228
|
packageDisplayName: catalogPackage?.displayName,
|
|
1229
1229
|
connectionDisplayName: connection?.displayName,
|
|
1230
1230
|
failure: effective.binding?.failure,
|
|
1231
|
+
fallback: Boolean(effective.fallback),
|
|
1231
1232
|
});
|
|
1232
1233
|
}
|
|
1233
1234
|
|
|
@@ -35,6 +35,20 @@ describe("model runtime presentation", () => {
|
|
|
35
35
|
);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
test("names the stand-in model when the chosen one is unavailable", () => {
|
|
39
|
+
// The Bot is answering, so the line is not a failure — it names the model
|
|
40
|
+
// actually in use and says the User's own choice is not it.
|
|
41
|
+
expect(
|
|
42
|
+
modelRuntimeLabel({
|
|
43
|
+
source: "platform",
|
|
44
|
+
modelDisplayName: "Auto",
|
|
45
|
+
providerModelId: "@flock/auto",
|
|
46
|
+
packageDisplayName: "Flock AI",
|
|
47
|
+
fallback: true,
|
|
48
|
+
}),
|
|
49
|
+
).toBe("Auto · Flock AI · your chosen model is unavailable");
|
|
50
|
+
});
|
|
51
|
+
|
|
38
52
|
test("shows unavailable and backend failure states", () => {
|
|
39
53
|
expect(modelRuntimeLabel({ source: "none" })).toBe(
|
|
40
54
|
"No model available — set one up in Models",
|
|
@@ -11,6 +11,11 @@ export function modelRuntimeLabel(input: {
|
|
|
11
11
|
packageDisplayName?: string;
|
|
12
12
|
connectionDisplayName?: string;
|
|
13
13
|
failure?: string;
|
|
14
|
+
/**
|
|
15
|
+
* The chosen model could not bind — its provider Package is off, or its
|
|
16
|
+
* Connection is gone — and the platform default is answering in its place.
|
|
17
|
+
*/
|
|
18
|
+
fallback?: boolean;
|
|
14
19
|
}): string {
|
|
15
20
|
if (input.failure) return input.failure;
|
|
16
21
|
if (input.source === "none" || !input.providerModelId) {
|
|
@@ -22,5 +27,6 @@ export function modelRuntimeLabel(input: {
|
|
|
22
27
|
const runtime = provider ? `${model} · ${provider}` : model;
|
|
23
28
|
if (input.source === "bot") return `${runtime} · this Bot only`;
|
|
24
29
|
if (input.source === "account") return `${runtime} · Account model`;
|
|
30
|
+
if (input.fallback) return `${runtime} · your chosen model is unavailable`;
|
|
25
31
|
return runtime;
|
|
26
32
|
}
|
package/src/client/styles.css
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
.frockbot-root {
|
|
2
|
+
/*
|
|
3
|
+
* The clear margin a bubble leaves on the side it is not anchored to — about
|
|
4
|
+
* a centimetre and a half. It is what says which side of the conversation a
|
|
5
|
+
* line is on, so it is the same on a phone as on a desktop.
|
|
6
|
+
*/
|
|
7
|
+
--frock-bubble-gutter: 56px;
|
|
2
8
|
position: fixed;
|
|
3
9
|
z-index: 1000;
|
|
4
10
|
inset: 0;
|
|
@@ -324,10 +330,14 @@
|
|
|
324
330
|
}
|
|
325
331
|
|
|
326
332
|
/*
|
|
327
|
-
* An assistant Turn is
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
333
|
+
* An assistant Turn is one column holding everything it produced: bubbles,
|
|
334
|
+
* notices and sends stack inside it, so a one-word reply is a bubble the width
|
|
335
|
+
* of its word rather than a sliver of a shared row.
|
|
336
|
+
*
|
|
337
|
+
* The avatar is beside that column only while the Bot is working. Every reply
|
|
338
|
+
* in this transcript is from the same Bot — there are no group conversations —
|
|
339
|
+
* so a sheep on every settled line said nothing and cost the column its left
|
|
340
|
+
* edge. The one on the running Turn does say something, which is why it stays.
|
|
331
341
|
*/
|
|
332
342
|
.message-assistant {
|
|
333
343
|
flex-direction: row;
|
|
@@ -381,20 +391,22 @@
|
|
|
381
391
|
animation: frock-breathe 2600ms ease-in-out infinite;
|
|
382
392
|
}
|
|
383
393
|
|
|
384
|
-
.bot-avatar-
|
|
385
|
-
|
|
386
|
-
border-radius: 12px;
|
|
387
|
-
animation: frock-halo 2600ms ease-in-out infinite;
|
|
388
|
-
box-shadow: 0 0 0 2px var(--frock-surface-accent);
|
|
389
|
-
content: "";
|
|
390
|
-
inset: -4px;
|
|
391
|
-
opacity: 0;
|
|
392
|
-
pointer-events: none;
|
|
394
|
+
.bot-avatar-waiting {
|
|
395
|
+
animation-duration: 1600ms;
|
|
393
396
|
}
|
|
394
397
|
|
|
395
|
-
|
|
396
|
-
.
|
|
397
|
-
|
|
398
|
+
/*
|
|
399
|
+
* The ring's way off screen. The Turn has settled, the reply is the answer
|
|
400
|
+
* now, and the stroke fades rather than vanishing between two frames.
|
|
401
|
+
*/
|
|
402
|
+
.activity-ring-enter-active,
|
|
403
|
+
.activity-ring-leave-active {
|
|
404
|
+
transition: opacity 420ms ease-out;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.activity-ring-enter-from,
|
|
408
|
+
.activity-ring-leave-to {
|
|
409
|
+
opacity: 0;
|
|
398
410
|
}
|
|
399
411
|
|
|
400
412
|
@keyframes frock-breathe {
|
|
@@ -408,22 +420,16 @@
|
|
|
408
420
|
}
|
|
409
421
|
}
|
|
410
422
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
50% {
|
|
419
|
-
opacity: 1;
|
|
420
|
-
transform: scale(1.06);
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
423
|
+
/*
|
|
424
|
+
* A bubble runs nearly the width of the transcript, leaving one clear margin
|
|
425
|
+
* on the side it is not anchored to: the Bot's at the end, the User's at the
|
|
426
|
+
* start. That margin is the only thing distinguishing the two columns now that
|
|
427
|
+
* neither carries an avatar, so it is a fixed gap rather than a percentage —
|
|
428
|
+
* it has to stay legible at every width.
|
|
429
|
+
*/
|
|
424
430
|
.message-bubble {
|
|
425
431
|
width: max-content;
|
|
426
|
-
max-width:
|
|
432
|
+
max-width: calc(100% - var(--frock-bubble-gutter));
|
|
427
433
|
padding: 10px 14px;
|
|
428
434
|
border: 1px solid var(--frock-border);
|
|
429
435
|
border-radius: 18px 18px 18px 6px;
|
|
@@ -438,7 +444,7 @@
|
|
|
438
444
|
}
|
|
439
445
|
|
|
440
446
|
.message-user .message-bubble {
|
|
441
|
-
max-width:
|
|
447
|
+
max-width: calc(100% - var(--frock-bubble-gutter));
|
|
442
448
|
border-color: var(--frock-action-primary);
|
|
443
449
|
border-radius: 18px 18px 6px 18px;
|
|
444
450
|
color: var(--frock-on-accent);
|
|
@@ -453,59 +459,9 @@
|
|
|
453
459
|
|
|
454
460
|
.message-sends {
|
|
455
461
|
display: flex;
|
|
462
|
+
width: calc(100% - var(--frock-bubble-gutter));
|
|
456
463
|
flex-direction: column;
|
|
457
464
|
gap: 8px;
|
|
458
|
-
width: min(640px, 84%);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/*
|
|
462
|
-
* Tool calls. Quieter than anything the Bot said: the User is watching work
|
|
463
|
-
* happen, not reading a message, so a chip carries the name and its state and
|
|
464
|
-
* opens in place to what the tool returned.
|
|
465
|
-
*/
|
|
466
|
-
|
|
467
|
-
.message-tools {
|
|
468
|
-
display: flex;
|
|
469
|
-
flex-direction: column;
|
|
470
|
-
gap: 6px;
|
|
471
|
-
width: min(640px, 84%);
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
.tool-chip {
|
|
475
|
-
display: flex;
|
|
476
|
-
flex-wrap: wrap;
|
|
477
|
-
gap: 8px;
|
|
478
|
-
align-items: baseline;
|
|
479
|
-
padding: 6px 10px;
|
|
480
|
-
font: inherit;
|
|
481
|
-
color: var(--frock-text-muted);
|
|
482
|
-
text-align: left;
|
|
483
|
-
cursor: pointer;
|
|
484
|
-
background: var(--frock-surface);
|
|
485
|
-
border: 1px solid var(--frock-border);
|
|
486
|
-
border-radius: 10px;
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
.tool-chip-name {
|
|
490
|
-
color: var(--frock-text);
|
|
491
|
-
font-weight: 600;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
.tool-chip-status {
|
|
495
|
-
flex: 1 1 auto;
|
|
496
|
-
font-size: var(--frock-text-xs);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
.tool-chip-failed .tool-chip-status {
|
|
500
|
-
color: var(--frock-danger-text);
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
.tool-chip-result {
|
|
504
|
-
flex: 1 0 100%;
|
|
505
|
-
max-height: 12em;
|
|
506
|
-
overflow: auto;
|
|
507
|
-
white-space: pre-wrap;
|
|
508
|
-
word-break: break-word;
|
|
509
465
|
}
|
|
510
466
|
|
|
511
467
|
/*
|
|
@@ -516,9 +472,9 @@
|
|
|
516
472
|
|
|
517
473
|
.message-tasks {
|
|
518
474
|
display: flex;
|
|
475
|
+
width: calc(100% - var(--frock-bubble-gutter));
|
|
519
476
|
flex-direction: column;
|
|
520
477
|
gap: 6px;
|
|
521
|
-
width: min(640px, 84%);
|
|
522
478
|
}
|
|
523
479
|
|
|
524
480
|
.task-chip {
|
|
@@ -1169,16 +1125,6 @@
|
|
|
1169
1125
|
padding: 20px;
|
|
1170
1126
|
}
|
|
1171
1127
|
|
|
1172
|
-
.message-bubble,
|
|
1173
|
-
.message-user .message-bubble {
|
|
1174
|
-
max-width: 92%;
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
.message-sends,
|
|
1178
|
-
.message-tasks {
|
|
1179
|
-
width: 92%;
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
1128
|
.message-attachments img {
|
|
1183
1129
|
max-width: 100%;
|
|
1184
1130
|
}
|