@frockbot/plugin-shell 0.1.4 → 0.2.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/frockbot.json +4 -0
- package/package.json +30 -28
- package/src/agent.ts +10 -13
- package/src/backend-authoring.test.ts +356 -2
- package/src/backend-authoring.ts +585 -20
- package/src/backend-composition-input.test.ts +65 -0
- package/src/backend-composition-input.ts +58 -0
- package/src/backend-composition.ts +19 -1
- package/src/backend-configuration.test.ts +166 -0
- package/src/backend-contracts.test.ts +28 -0
- package/src/backend-contracts.ts +3 -1
- package/src/backend-iframe-ui.test.ts +131 -0
- package/src/backend-isolate.test.ts +198 -203
- package/src/backend-isolate.ts +96 -231
- package/src/backend-package-catalog.test.ts +451 -0
- package/src/backend-package-catalog.ts +923 -0
- package/src/backend-runner-iframe.test.ts +74 -0
- package/src/backend-runner.ts +192 -0
- package/src/backend.ts +947 -206
- package/src/client/FrockBotApp.vue +38 -0
- package/src/client/PackageIframeHost.vue +218 -0
- package/src/client/PackageIframeSettings.vue +52 -0
- package/src/client/SendPayloadView.vue +0 -60
- package/src/client/index.test.ts +119 -0
- package/src/client/index.ts +78 -1
- package/src/client/package-iframe-host-message.test.ts +41 -0
- package/src/client/package-iframe-host-message.ts +27 -0
- package/src/composition-views.ts +54 -0
- package/src/shared.ts +10 -0
|
@@ -23,9 +23,11 @@ import {
|
|
|
23
23
|
type WebChatMessage,
|
|
24
24
|
type WebTaskChip,
|
|
25
25
|
type WebToolAttachment,
|
|
26
|
+
type WebToolActivity,
|
|
26
27
|
} from "../shared.js";
|
|
27
28
|
import { ComposerDraftStore } from "./composer-draft.js";
|
|
28
29
|
import SendPayloadView from "./SendPayloadView.vue";
|
|
30
|
+
import PackageIframeHost from "./PackageIframeHost.vue";
|
|
29
31
|
import type { ClientSkillCatalogEntryV1 } from "../skill-protocol.js";
|
|
30
32
|
import {
|
|
31
33
|
nextSkillHighlightV1,
|
|
@@ -189,6 +191,30 @@ function attachmentsOf(message: WebChatMessage): WebToolAttachment[] {
|
|
|
189
191
|
return message.tools.flatMap((tool) => tool.attachments ?? []);
|
|
190
192
|
}
|
|
191
193
|
|
|
194
|
+
function iframeEntriesFor(tool: WebToolActivity) {
|
|
195
|
+
const slot = `frockbot.tool-result:${tool.name}`;
|
|
196
|
+
return (state.value.packageUi?.contributions ?? [])
|
|
197
|
+
.flatMap((contribution) =>
|
|
198
|
+
contribution.mounts
|
|
199
|
+
.filter((mount) => mount.slot === slot)
|
|
200
|
+
.map((mount) => ({ contribution, slot, order: mount.order ?? 0 })),
|
|
201
|
+
)
|
|
202
|
+
.sort(
|
|
203
|
+
(left, right) =>
|
|
204
|
+
left.order - right.order ||
|
|
205
|
+
left.contribution.packageId.localeCompare(right.contribution.packageId),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function toolResultState(tool: WebToolActivity): unknown {
|
|
210
|
+
if (tool.text === undefined) return { status: tool.status };
|
|
211
|
+
try {
|
|
212
|
+
return JSON.parse(tool.text) as unknown;
|
|
213
|
+
} catch {
|
|
214
|
+
return { content: tool.text, isError: tool.status === "failed" };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
192
218
|
/** The Workspace read route for one encoded `WorkspacePathV1`. */
|
|
193
219
|
function workspaceFileUrl(path: string): string {
|
|
194
220
|
const botId = state.value.activeBotId ?? "";
|
|
@@ -251,6 +277,7 @@ function isVisible(message: WebChatMessage): boolean {
|
|
|
251
277
|
// visible act was dispatching a subagent.
|
|
252
278
|
return (
|
|
253
279
|
message.text.length > 0 ||
|
|
280
|
+
message.tools.some((tool) => iframeEntriesFor(tool).length > 0) ||
|
|
254
281
|
message.sends.length > 0 ||
|
|
255
282
|
(message.tasks?.length ?? 0) > 0 ||
|
|
256
283
|
message.status === "streaming"
|
|
@@ -687,6 +714,17 @@ function handleComposerKeydown(event: KeyboardEvent): void {
|
|
|
687
714
|
<div v-if="message.text" class="message-bubble">
|
|
688
715
|
<UiMarkdown :text="message.text" />
|
|
689
716
|
</div>
|
|
717
|
+
<template v-for="tool in message.tools" :key="tool.id">
|
|
718
|
+
<PackageIframeHost
|
|
719
|
+
v-for="entry in iframeEntriesFor(tool)"
|
|
720
|
+
:key="`${tool.id}:${entry.contribution.packageId}`"
|
|
721
|
+
class="message-package-iframe"
|
|
722
|
+
:contribution="entry.contribution"
|
|
723
|
+
:slot="entry.slot"
|
|
724
|
+
:state-name="`tool:${tool.name}`"
|
|
725
|
+
:state-value="toolResultState(tool)"
|
|
726
|
+
/>
|
|
727
|
+
</template>
|
|
690
728
|
<!--
|
|
691
729
|
A binary a tool filed in the Workspace, drawn from the
|
|
692
730
|
Workspace read route. The thread carries the path, never the
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
decodePackageIframePageMessageV1,
|
|
4
|
+
packageIframeToolAllowedV1,
|
|
5
|
+
type PackageIframeContributionViewV1,
|
|
6
|
+
type PackageIframeHostMessageV1,
|
|
7
|
+
} from "@frockbot/kernel-contracts";
|
|
8
|
+
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
9
|
+
import { frockBotWebDataKey } from "../shared.js";
|
|
10
|
+
import { postPackageIframeHostMessage } from "./package-iframe-host-message.js";
|
|
11
|
+
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
contribution: PackageIframeContributionViewV1;
|
|
14
|
+
slot: string;
|
|
15
|
+
stateName: string;
|
|
16
|
+
stateValue: unknown;
|
|
17
|
+
}>();
|
|
18
|
+
const providedWeb = inject(frockBotWebDataKey);
|
|
19
|
+
if (!providedWeb) throw new Error("Package iframe host data was not provided");
|
|
20
|
+
const web = providedWeb;
|
|
21
|
+
const frame = ref<HTMLIFrameElement>();
|
|
22
|
+
const height = ref(240);
|
|
23
|
+
const failure = ref<string>();
|
|
24
|
+
const lastStateWireByName = new Map<string, string>();
|
|
25
|
+
const catalog = computed(() => web.value.packageUi);
|
|
26
|
+
const source = computed(() => {
|
|
27
|
+
const origin = catalog.value?.artifactOrigin;
|
|
28
|
+
return origin
|
|
29
|
+
? `${origin}/packages/${props.contribution.artifact.contentHash}.html`
|
|
30
|
+
: "about:blank";
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const THEME_TOKEN_NAMES = [
|
|
34
|
+
"surface",
|
|
35
|
+
"surface-raised",
|
|
36
|
+
"surface-subtle",
|
|
37
|
+
"text",
|
|
38
|
+
"text-muted",
|
|
39
|
+
"border",
|
|
40
|
+
"accent-surface",
|
|
41
|
+
"accent-text",
|
|
42
|
+
"radius-card",
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
function themeTokens(): Record<string, string> {
|
|
46
|
+
const styles = getComputedStyle(document.documentElement);
|
|
47
|
+
return Object.fromEntries(
|
|
48
|
+
THEME_TOKEN_NAMES.map((name) => [
|
|
49
|
+
name,
|
|
50
|
+
styles.getPropertyValue(`--frock-${name}`).trim(),
|
|
51
|
+
]),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function post(message: PackageIframeHostMessageV1): void {
|
|
56
|
+
const target = frame.value?.contentWindow;
|
|
57
|
+
if (!target) return;
|
|
58
|
+
try {
|
|
59
|
+
postPackageIframeHostMessage(target, message, lastStateWireByName);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
failure.value =
|
|
62
|
+
error instanceof Error ? error.message : "Package page state is invalid";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function initialize(): void {
|
|
67
|
+
const botId = web.value.activeBotId;
|
|
68
|
+
if (!botId) return;
|
|
69
|
+
lastStateWireByName.clear();
|
|
70
|
+
post({
|
|
71
|
+
schemaVersion: 1,
|
|
72
|
+
type: "init",
|
|
73
|
+
themeTokens: themeTokens(),
|
|
74
|
+
packageId: props.contribution.packageId,
|
|
75
|
+
botId,
|
|
76
|
+
slot: props.slot,
|
|
77
|
+
});
|
|
78
|
+
post({
|
|
79
|
+
schemaVersion: 1,
|
|
80
|
+
type: "state",
|
|
81
|
+
name: props.stateName,
|
|
82
|
+
value: props.stateValue,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
watch(
|
|
87
|
+
() => [props.stateName, props.stateValue] as const,
|
|
88
|
+
() =>
|
|
89
|
+
post({
|
|
90
|
+
schemaVersion: 1,
|
|
91
|
+
type: "state",
|
|
92
|
+
name: props.stateName,
|
|
93
|
+
value: props.stateValue,
|
|
94
|
+
}),
|
|
95
|
+
{ deep: true },
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
async function onMessage(event: MessageEvent): Promise<void> {
|
|
99
|
+
if (!frame.value?.contentWindow || event.source !== frame.value.contentWindow)
|
|
100
|
+
return;
|
|
101
|
+
let message;
|
|
102
|
+
try {
|
|
103
|
+
message = decodePackageIframePageMessageV1(event.data);
|
|
104
|
+
} catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (message.type === "resize") {
|
|
108
|
+
height.value = Math.min(1_200, Math.max(96, Math.round(message.height)));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!packageIframeToolAllowedV1(props.contribution, message.name)) {
|
|
112
|
+
failure.value = `This Package did not declare ${message.name}.`;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
failure.value = undefined;
|
|
116
|
+
try {
|
|
117
|
+
const result = await web.value.callPackageUiTool(
|
|
118
|
+
props.contribution,
|
|
119
|
+
message.name,
|
|
120
|
+
message.input,
|
|
121
|
+
);
|
|
122
|
+
post({
|
|
123
|
+
schemaVersion: 1,
|
|
124
|
+
type: "state",
|
|
125
|
+
name: `tool:${message.name}`,
|
|
126
|
+
value: result,
|
|
127
|
+
});
|
|
128
|
+
} catch (error) {
|
|
129
|
+
failure.value = error instanceof Error ? error.message : "Tool call failed";
|
|
130
|
+
post({
|
|
131
|
+
schemaVersion: 1,
|
|
132
|
+
type: "state",
|
|
133
|
+
name: `tool:${message.name}`,
|
|
134
|
+
value: { isError: true, content: failure.value },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
onMounted(() => window.addEventListener("message", onMessage));
|
|
140
|
+
onBeforeUnmount(() => window.removeEventListener("message", onMessage));
|
|
141
|
+
</script>
|
|
142
|
+
|
|
143
|
+
<template>
|
|
144
|
+
<section class="package-iframe-frame">
|
|
145
|
+
<header class="package-iframe-attribution">
|
|
146
|
+
<strong>{{ contribution.displayName }}</strong>
|
|
147
|
+
<span>{{ contribution.provenance }} Package</span>
|
|
148
|
+
</header>
|
|
149
|
+
<!-- Load eagerly because lazy iframes defer the init/resize handshake until the browser decides the frame is near the viewport, which headless Chromium may never do. -->
|
|
150
|
+
<iframe
|
|
151
|
+
ref="frame"
|
|
152
|
+
:title="`${contribution.displayName} Package page`"
|
|
153
|
+
:src="source"
|
|
154
|
+
:style="{ height: `${height}px` }"
|
|
155
|
+
sandbox="allow-scripts"
|
|
156
|
+
credentialless
|
|
157
|
+
referrerpolicy="no-referrer"
|
|
158
|
+
@load="initialize"
|
|
159
|
+
/>
|
|
160
|
+
<p v-if="failure" class="package-iframe-failure" role="alert">
|
|
161
|
+
{{ failure }}
|
|
162
|
+
</p>
|
|
163
|
+
</section>
|
|
164
|
+
</template>
|
|
165
|
+
|
|
166
|
+
<style scoped>
|
|
167
|
+
.package-iframe-frame {
|
|
168
|
+
min-width: 0;
|
|
169
|
+
overflow: hidden;
|
|
170
|
+
border: 1px solid var(--frock-border);
|
|
171
|
+
border-radius: var(--frock-radius-card);
|
|
172
|
+
background: var(--frock-surface);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.package-iframe-attribution {
|
|
176
|
+
position: relative;
|
|
177
|
+
z-index: 1;
|
|
178
|
+
display: flex;
|
|
179
|
+
align-items: baseline;
|
|
180
|
+
justify-content: space-between;
|
|
181
|
+
gap: 12px;
|
|
182
|
+
min-height: 36px;
|
|
183
|
+
padding: 8px 12px;
|
|
184
|
+
border-bottom: 1px solid var(--frock-border);
|
|
185
|
+
color: var(--frock-text);
|
|
186
|
+
background: var(--frock-surface-subtle);
|
|
187
|
+
font-size: var(--frock-text-xs);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.package-iframe-attribution span,
|
|
191
|
+
.package-iframe-failure {
|
|
192
|
+
color: var(--frock-text-muted);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
iframe {
|
|
196
|
+
display: block;
|
|
197
|
+
width: 100%;
|
|
198
|
+
max-width: 100%;
|
|
199
|
+
border: 0;
|
|
200
|
+
background: transparent;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.package-iframe-failure {
|
|
204
|
+
margin: 0;
|
|
205
|
+
padding: 8px 12px;
|
|
206
|
+
border-top: 1px solid var(--frock-danger-border);
|
|
207
|
+
color: var(--frock-danger-text);
|
|
208
|
+
font-size: var(--frock-text-xs);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
@media (max-width: 640px) {
|
|
212
|
+
.package-iframe-attribution {
|
|
213
|
+
align-items: flex-start;
|
|
214
|
+
flex-direction: column;
|
|
215
|
+
gap: 2px;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
</style>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, inject } from "vue";
|
|
3
|
+
import { frockBotWebDataKey } from "../shared.js";
|
|
4
|
+
import PackageIframeHost from "./PackageIframeHost.vue";
|
|
5
|
+
|
|
6
|
+
const providedWeb = inject(frockBotWebDataKey);
|
|
7
|
+
if (!providedWeb)
|
|
8
|
+
throw new Error("Package iframe settings data was not provided");
|
|
9
|
+
const web = providedWeb;
|
|
10
|
+
const slot = "frockbot.bot-settings-sections";
|
|
11
|
+
const contributions = computed(() =>
|
|
12
|
+
(web.value.packageUi?.contributions ?? [])
|
|
13
|
+
.flatMap((contribution) =>
|
|
14
|
+
contribution.mounts
|
|
15
|
+
.filter((mount) => mount.slot === slot)
|
|
16
|
+
.map((mount) => ({ contribution, order: mount.order ?? 0 })),
|
|
17
|
+
)
|
|
18
|
+
.sort(
|
|
19
|
+
(left, right) =>
|
|
20
|
+
left.order - right.order ||
|
|
21
|
+
left.contribution.packageId.localeCompare(right.contribution.packageId),
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
function settingsFor(packageId: string): Record<string, unknown> {
|
|
26
|
+
const installation = web.value.userSettings?.packages.find(
|
|
27
|
+
(candidate) => candidate.packageId === packageId,
|
|
28
|
+
);
|
|
29
|
+
return installation?.values ?? {};
|
|
30
|
+
}
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<div v-if="contributions.length > 0" class="package-iframe-settings">
|
|
35
|
+
<PackageIframeHost
|
|
36
|
+
v-for="entry in contributions"
|
|
37
|
+
:key="entry.contribution.packageId"
|
|
38
|
+
:contribution="entry.contribution"
|
|
39
|
+
:slot="slot"
|
|
40
|
+
state-name="settings"
|
|
41
|
+
:state-value="settingsFor(entry.contribution.packageId)"
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
</template>
|
|
45
|
+
|
|
46
|
+
<style scoped>
|
|
47
|
+
.package-iframe-settings {
|
|
48
|
+
display: grid;
|
|
49
|
+
gap: 16px;
|
|
50
|
+
min-width: 0;
|
|
51
|
+
}
|
|
52
|
+
</style>
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { UiButton, UiMarkdown } from "@frockbot/client-ui";
|
|
21
21
|
import { computed, inject, ref } from "vue";
|
|
22
|
-
import { settingsLinkV1 } from "../settings-links.js";
|
|
23
22
|
import { frockBotWebDataKey, type WebSendPayload } from "../shared.js";
|
|
24
23
|
|
|
25
24
|
const props = defineProps<{ send: WebSendPayload }>();
|
|
@@ -39,17 +38,6 @@ const widget = computed(() =>
|
|
|
39
38
|
const attachment = computed(() =>
|
|
40
39
|
payload.value?.type === "attachment" ? payload.value : undefined,
|
|
41
40
|
);
|
|
42
|
-
/**
|
|
43
|
-
* The connect card. It carries no URL and never will: the Bot recorded a
|
|
44
|
-
* pending decision, and only the User — in Settings, where the host authors
|
|
45
|
-
* the link at the moment they press it — can complete one. So this draws the
|
|
46
|
-
* request and points at the place the decision is made, and is deliberately
|
|
47
|
-
* not a button that authorizes anything.
|
|
48
|
-
*/
|
|
49
|
-
const connectCard = computed(() =>
|
|
50
|
-
payload.value?.type === "connect-card" ? payload.value : undefined,
|
|
51
|
-
);
|
|
52
|
-
const connectionsLink = settingsLinkV1({ anchor: "user-packages" });
|
|
53
41
|
const approval = computed(() =>
|
|
54
42
|
payload.value?.type === "approval" ? payload.value : undefined,
|
|
55
43
|
);
|
|
@@ -115,21 +103,6 @@ const unsupported = computed(
|
|
|
115
103
|
</p>
|
|
116
104
|
</section>
|
|
117
105
|
|
|
118
|
-
<section
|
|
119
|
-
v-else-if="connectCard"
|
|
120
|
-
class="send-connect-card"
|
|
121
|
-
aria-label="Connection request"
|
|
122
|
-
>
|
|
123
|
-
<p class="send-connect-title">{{ connectCard.title }}</p>
|
|
124
|
-
<p v-if="connectCard.body" class="send-connect-body">
|
|
125
|
-
{{ connectCard.body }}
|
|
126
|
-
</p>
|
|
127
|
-
<p class="send-connect-help">
|
|
128
|
-
Open Settings → Plugins to authorize it. Only you can complete this.
|
|
129
|
-
<a :href="connectionsLink">{{ connectionsLink }}</a>
|
|
130
|
-
</p>
|
|
131
|
-
</section>
|
|
132
|
-
|
|
133
106
|
<section
|
|
134
107
|
v-else-if="approval"
|
|
135
108
|
class="send-approval"
|
|
@@ -221,39 +194,6 @@ const unsupported = computed(
|
|
|
221
194
|
list-style: none;
|
|
222
195
|
}
|
|
223
196
|
|
|
224
|
-
.send-connect-card {
|
|
225
|
-
display: flex;
|
|
226
|
-
flex-direction: column;
|
|
227
|
-
gap: 0.5rem;
|
|
228
|
-
border: 1px solid var(--frock-border);
|
|
229
|
-
border-radius: var(--frock-radius-card);
|
|
230
|
-
background: var(--frock-surface-raised);
|
|
231
|
-
padding: 0.75rem 1rem;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
.send-connect-title {
|
|
235
|
-
margin: 0;
|
|
236
|
-
color: var(--frock-text);
|
|
237
|
-
font-weight: 600;
|
|
238
|
-
font-size: var(--frock-text-base);
|
|
239
|
-
line-height: var(--frock-leading-snug);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
.send-connect-body {
|
|
243
|
-
margin: 0;
|
|
244
|
-
color: var(--frock-text);
|
|
245
|
-
font-size: var(--frock-text-sm);
|
|
246
|
-
line-height: var(--frock-leading-normal);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
.send-connect-help {
|
|
250
|
-
margin: 0;
|
|
251
|
-
color: var(--frock-text-muted);
|
|
252
|
-
font-size: var(--frock-text-sm);
|
|
253
|
-
line-height: var(--frock-leading-normal);
|
|
254
|
-
overflow-wrap: anywhere;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
197
|
.send-widget-options li {
|
|
258
198
|
border: 1px solid var(--frock-border);
|
|
259
199
|
border-radius: var(--frock-radius-control);
|
package/src/client/index.test.ts
CHANGED
|
@@ -395,6 +395,125 @@ describe("Bot selection", () => {
|
|
|
395
395
|
expect(provided.value.activeBotId).toBe("new");
|
|
396
396
|
expect(provided.value.botSettings?.botId).toBe("new");
|
|
397
397
|
});
|
|
398
|
+
|
|
399
|
+
test("loads sandboxed Package UI and transports only declared iframe tools", async () => {
|
|
400
|
+
Object.defineProperty(globalThis, "window", {
|
|
401
|
+
configurable: true,
|
|
402
|
+
value: {
|
|
403
|
+
location: { href: "https://app.example/" },
|
|
404
|
+
history: { replaceState: () => undefined },
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
const contribution = {
|
|
408
|
+
packageId: "weather-page",
|
|
409
|
+
displayName: "Sydney Weather",
|
|
410
|
+
provenance: "Bot-authored" as const,
|
|
411
|
+
artifact: {
|
|
412
|
+
contentHash: "a".repeat(64),
|
|
413
|
+
size: 1,
|
|
414
|
+
mediaType: "text/html" as const,
|
|
415
|
+
bundlerVersion: "frockbot-inline-html@1",
|
|
416
|
+
},
|
|
417
|
+
mounts: [{ slot: "frockbot.bot-settings-sections", order: 20 }],
|
|
418
|
+
declaredTools: ["weather_lookup"],
|
|
419
|
+
};
|
|
420
|
+
const requests: Array<{
|
|
421
|
+
path: string;
|
|
422
|
+
method?: string;
|
|
423
|
+
body?: string;
|
|
424
|
+
}> = [];
|
|
425
|
+
const slots: string[] = [];
|
|
426
|
+
let provided: Ref<FrockBotWebData> | undefined;
|
|
427
|
+
await shellClientPlugin({
|
|
428
|
+
transport: {
|
|
429
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
430
|
+
readConfiguration: (query) => {
|
|
431
|
+
if (query.type !== "bot/get")
|
|
432
|
+
throw new Error("unexpected User query");
|
|
433
|
+
return Promise.resolve(initializeBotSettingsV1(query.botId));
|
|
434
|
+
},
|
|
435
|
+
hostedRequest: (path, method, body) => {
|
|
436
|
+
requests.push({ path, method, body });
|
|
437
|
+
if (path.endsWith("/package-ui")) {
|
|
438
|
+
return Promise.resolve({
|
|
439
|
+
schemaVersion: 1,
|
|
440
|
+
botId: "primary",
|
|
441
|
+
generationId: "generation-ui",
|
|
442
|
+
artifactOrigin: "https://ui.app.example",
|
|
443
|
+
contributions: [contribution],
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
if (path.endsWith("/package-ui/tools")) {
|
|
447
|
+
return Promise.resolve({
|
|
448
|
+
schemaVersion: 1,
|
|
449
|
+
runId: "run-iframe-tool",
|
|
450
|
+
text: "Sydney Weather",
|
|
451
|
+
events: [],
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
return Promise.resolve({});
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
slot: (registration) => {
|
|
458
|
+
slots.push(registration.slot);
|
|
459
|
+
return () => {};
|
|
460
|
+
},
|
|
461
|
+
inject: () => {
|
|
462
|
+
throw new Error("unexpected client provider injection");
|
|
463
|
+
},
|
|
464
|
+
provide: (_key, value) => {
|
|
465
|
+
provided = value as Ref<FrockBotWebData>;
|
|
466
|
+
return () => {};
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
if (!provided) throw new Error("shell data was not provided");
|
|
470
|
+
provided.value.userSettings = {
|
|
471
|
+
schemaVersion: 1,
|
|
472
|
+
revision: 1,
|
|
473
|
+
profile: { name: "User" },
|
|
474
|
+
packages: [],
|
|
475
|
+
connections: [],
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
await provided.value.selectBot("primary");
|
|
479
|
+
|
|
480
|
+
expect(slots).toContain("frockbot.bot-settings-sections");
|
|
481
|
+
expect(requests.filter(({ path }) => path.endsWith("/package-ui"))).toEqual(
|
|
482
|
+
[
|
|
483
|
+
{
|
|
484
|
+
path: "/api/bots/primary/package-ui",
|
|
485
|
+
method: undefined,
|
|
486
|
+
body: undefined,
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
);
|
|
490
|
+
expect(provided.value.packageUi?.contributions).toEqual([contribution]);
|
|
491
|
+
|
|
492
|
+
expect(
|
|
493
|
+
await provided.value.callPackageUiTool(contribution, "weather_lookup", {
|
|
494
|
+
city: "Sydney",
|
|
495
|
+
}),
|
|
496
|
+
).toEqual({ content: "Sydney Weather", isError: false });
|
|
497
|
+
const toolRequest = requests.find(({ path }) =>
|
|
498
|
+
path.endsWith("/package-ui/tools"),
|
|
499
|
+
);
|
|
500
|
+
expect(toolRequest?.method).toBe("POST");
|
|
501
|
+
expect(JSON.parse(toolRequest?.body ?? "null")).toEqual({
|
|
502
|
+
schemaVersion: 1,
|
|
503
|
+
commandId: expect.any(String),
|
|
504
|
+
generationId: "generation-ui",
|
|
505
|
+
packageId: "weather-page",
|
|
506
|
+
name: "weather_lookup",
|
|
507
|
+
input: { city: "Sydney" },
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const requestCount = requests.length;
|
|
511
|
+
await expect(
|
|
512
|
+
provided.value.callPackageUiTool(contribution, "package_author", {}),
|
|
513
|
+
).rejects.toThrow("did not declare");
|
|
514
|
+
expect(requests).toHaveLength(requestCount);
|
|
515
|
+
});
|
|
516
|
+
|
|
398
517
|
test("preserves load failures and ignores stale User settings", async () => {
|
|
399
518
|
let provided: Ref<FrockBotWebData> | undefined;
|
|
400
519
|
const older = Promise.withResolvers<UserSettingsViewV1>();
|
package/src/client/index.ts
CHANGED
|
@@ -17,7 +17,12 @@ import type {
|
|
|
17
17
|
ConnectionCommandV1,
|
|
18
18
|
} from "@frockbot/connection-core";
|
|
19
19
|
import { decodeFrockBotManifest } from "@frockbot/kernel-composition";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
decodePackageIframeCatalogV1,
|
|
22
|
+
packageIframeToolAllowedV1,
|
|
23
|
+
decodeSendToUserPayloadV1,
|
|
24
|
+
type PackageIframeContributionViewV1,
|
|
25
|
+
} from "@frockbot/kernel-contracts";
|
|
21
26
|
import { createClientSurfaceRegistry } from "@frockbot/client-ui";
|
|
22
27
|
import type {
|
|
23
28
|
BotNameProvenanceV1,
|
|
@@ -48,6 +53,7 @@ import {
|
|
|
48
53
|
import { MCP_OAUTH_CONNECTION_TYPE_ID } from "@frockbot/plugin-mcp/agent";
|
|
49
54
|
import { decodeStartConnectionResultV1 } from "@frockbot/connection-core";
|
|
50
55
|
import { decodeClientSkillCatalogV1 } from "../skill-protocol.js";
|
|
56
|
+
import { decodeClientTurnV1 } from "../run-protocol.js";
|
|
51
57
|
import {
|
|
52
58
|
decodeApprovalDecisionReceiptV1,
|
|
53
59
|
decodeApprovalListViewV1,
|
|
@@ -69,6 +75,7 @@ import {
|
|
|
69
75
|
type WebToolActivity,
|
|
70
76
|
} from "../shared.js";
|
|
71
77
|
import FrockBotApp from "./FrockBotApp.vue";
|
|
78
|
+
import PackageIframeSettings from "./PackageIframeSettings.vue";
|
|
72
79
|
import { modelRuntimeLabel } from "./model-presentation.js";
|
|
73
80
|
import { showClientNotificationV1 } from "./notify.js";
|
|
74
81
|
import "@frockbot/client-core/fonts.css";
|
|
@@ -1095,6 +1102,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1095
1102
|
skillCatalog: [],
|
|
1096
1103
|
approvals: [],
|
|
1097
1104
|
tasks: [],
|
|
1105
|
+
packageUi: undefined,
|
|
1098
1106
|
async selectBot(botId: string): Promise<void> {
|
|
1099
1107
|
// Re-selecting the open Bot is not a switch: aborting the live Turn and
|
|
1100
1108
|
// clearing the transcript would discard state the User is watching.
|
|
@@ -1113,6 +1121,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1113
1121
|
web.value.skillCatalog = [];
|
|
1114
1122
|
web.value.approvals = [];
|
|
1115
1123
|
web.value.tasks = [];
|
|
1124
|
+
web.value.packageUi = undefined;
|
|
1116
1125
|
const url = URL.parse(window.location.href);
|
|
1117
1126
|
if (url) {
|
|
1118
1127
|
url.searchParams.set("bot", botId);
|
|
@@ -1196,6 +1205,68 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1196
1205
|
web.value.tasks = [];
|
|
1197
1206
|
}
|
|
1198
1207
|
},
|
|
1208
|
+
async loadPackageUi(): Promise<void> {
|
|
1209
|
+
const read = ctx.transport.hostedRequest;
|
|
1210
|
+
const botId = web.value.activeBotId;
|
|
1211
|
+
if (!read || !botId) return;
|
|
1212
|
+
const generation = selectionGeneration;
|
|
1213
|
+
try {
|
|
1214
|
+
const catalog = decodePackageIframeCatalogV1(
|
|
1215
|
+
await read(`/api/bots/${encodeURIComponent(botId)}/package-ui`),
|
|
1216
|
+
);
|
|
1217
|
+
if (
|
|
1218
|
+
generation !== selectionGeneration ||
|
|
1219
|
+
web.value.activeBotId !== botId
|
|
1220
|
+
)
|
|
1221
|
+
return;
|
|
1222
|
+
web.value.packageUi = catalog;
|
|
1223
|
+
} catch {
|
|
1224
|
+
if (
|
|
1225
|
+
generation === selectionGeneration &&
|
|
1226
|
+
web.value.activeBotId === botId
|
|
1227
|
+
) {
|
|
1228
|
+
web.value.packageUi = undefined;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
},
|
|
1232
|
+
async callPackageUiTool(
|
|
1233
|
+
contribution: PackageIframeContributionViewV1,
|
|
1234
|
+
name: string,
|
|
1235
|
+
input: unknown,
|
|
1236
|
+
): Promise<unknown> {
|
|
1237
|
+
const post = ctx.transport.hostedRequest;
|
|
1238
|
+
const botId = web.value.activeBotId;
|
|
1239
|
+
const catalog = web.value.packageUi;
|
|
1240
|
+
if (!post || !botId || !catalog || catalog.botId !== botId) {
|
|
1241
|
+
throw new Error("Package UI is unavailable");
|
|
1242
|
+
}
|
|
1243
|
+
if (!packageIframeToolAllowedV1(contribution, name)) {
|
|
1244
|
+
throw new Error(
|
|
1245
|
+
`Package "${contribution.packageId}" did not declare tool "${name}"`,
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
const turn = decodeClientTurnV1(
|
|
1249
|
+
await post(
|
|
1250
|
+
`/api/bots/${encodeURIComponent(botId)}/package-ui/tools`,
|
|
1251
|
+
"POST",
|
|
1252
|
+
JSON.stringify({
|
|
1253
|
+
schemaVersion: 1,
|
|
1254
|
+
commandId: crypto.randomUUID(),
|
|
1255
|
+
generationId: catalog.generationId,
|
|
1256
|
+
packageId: contribution.packageId,
|
|
1257
|
+
name,
|
|
1258
|
+
input,
|
|
1259
|
+
}),
|
|
1260
|
+
),
|
|
1261
|
+
);
|
|
1262
|
+
await deliverNotifications(botId);
|
|
1263
|
+
const result = turn.events.findLast(
|
|
1264
|
+
(event) => event.type === "tool/result",
|
|
1265
|
+
);
|
|
1266
|
+
return result?.type === "tool/result"
|
|
1267
|
+
? { content: result.content, isError: result.isError === true }
|
|
1268
|
+
: { content: turn.text, isError: false };
|
|
1269
|
+
},
|
|
1199
1270
|
/**
|
|
1200
1271
|
* Explicit, authenticated cancellation of one subagent from the client.
|
|
1201
1272
|
*
|
|
@@ -1288,6 +1359,7 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
1288
1359
|
updateModelLabel();
|
|
1289
1360
|
updateSettingsLoadError("bot");
|
|
1290
1361
|
await deliverNotifications(botId, generation);
|
|
1362
|
+
await web.value.loadPackageUi();
|
|
1291
1363
|
} catch (error) {
|
|
1292
1364
|
if (
|
|
1293
1365
|
generation !== selectionGeneration ||
|
|
@@ -2153,6 +2225,11 @@ export const shellClientPlugin: ClientPlugin = (ctx) => {
|
|
|
2153
2225
|
order: 10_000,
|
|
2154
2226
|
component: FrockBotApp,
|
|
2155
2227
|
}),
|
|
2228
|
+
ctx.slot({
|
|
2229
|
+
slot: "frockbot.bot-settings-sections",
|
|
2230
|
+
order: 10_000,
|
|
2231
|
+
component: PackageIframeSettings,
|
|
2232
|
+
}),
|
|
2156
2233
|
() => {
|
|
2157
2234
|
activeRequest?.abort();
|
|
2158
2235
|
admissionObserver?.abort();
|