@k2b/cloud 0.28.0 → 0.29.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/package.json +7 -5
- package/src/_internal/cli-plugins.ts +18 -0
- package/src/_internal/define-app.ts +21 -5
- package/src/_internal/registry-validation.ts +30 -1
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.tsx +11 -4
- package/src/ai/assistant-models.ts +25 -4
- package/src/ai/audio-tool.ts +3 -10
- package/src/ai/capabilities.ts +35 -9
- package/src/ai/chat/blocks.tsx +97 -17
- package/src/ai/chat/builtin-tools.tsx +3 -1
- package/src/ai/chat/composer-adapter.ts +8 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +12 -3
- package/src/ai/chat/message-actions.tsx +1 -1
- package/src/ai/chat/message-utils.ts +28 -6
- package/src/ai/chat/messages.ts +16 -0
- package/src/ai/chat/presentation.tsx +1 -8
- package/src/ai/chat/tool-names.ts +157 -0
- package/src/ai/chat/turn-layout.ts +12 -0
- package/src/ai/chat/turn-view.tsx +15 -7
- package/src/ai/chat-tasks.ts +16 -3
- package/src/ai/code-mode-skill.ts +4 -4
- package/src/ai/compaction.ts +22 -3
- package/src/ai/default-tools.ts +9 -2
- package/src/ai/executor.ts +127 -40
- package/src/ai/file-media-type.ts +4 -0
- package/src/ai/files-store.ts +17 -10
- package/src/ai/http.ts +11 -10
- package/src/ai/index.ts +1 -0
- package/src/ai/memories.ts +2 -0
- package/src/ai/memory-learning.ts +59 -5
- package/src/ai/memory-workflow-evidence.ts +35 -10
- package/src/ai/projects-routes.ts +5 -1
- package/src/ai/projects.ts +5 -1
- package/src/ai/protocol.ts +34 -1
- package/src/ai/provider.ts +16 -1
- package/src/ai/quota-provider.ts +4 -1
- package/src/ai/routes.ts +20 -10
- package/src/ai/settings.ts +54 -17
- package/src/ai/skill-seeds.ts +11 -3
- package/src/ai/store.ts +56 -20
- package/src/ai/structured.ts +2 -1
- package/src/ai/system-prompt.ts +5 -1
- package/src/ai/transcription.ts +58 -13
- package/src/ai/turn-failure.ts +5 -1
- package/src/ai/turn-guidance.ts +505 -0
- package/src/ai/types.ts +1 -0
- package/src/ai/ui.tsx +1 -0
- package/src/api/admin-core-settings.ts +16 -1
- package/src/api/me.ts +42 -1
- package/src/browser/FileChooser.tsx +71 -295
- package/src/browser/FileSaver.tsx +407 -0
- package/src/browser/app-badges.ts +16 -0
- package/src/browser/choose-files.browser-harness.tsx +22 -6
- package/src/browser/choose-files.tsx +23 -36
- package/src/browser/file-chooser-messages.ts +64 -0
- package/src/browser/file-providers.ts +258 -8
- package/src/browser/files.tsx +26 -3
- package/src/browser/notifications.ts +37 -1
- package/src/browser/provider-folder.tsx +301 -0
- package/src/browser/provider-list.ts +39 -0
- package/src/browser/save-files.browser-harness.tsx +35 -0
- package/src/browser/save-files.tsx +107 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/commands.ts +3 -1
- package/src/contracts/notification-group.ts +6 -0
- package/src/contracts/notification-types.ts +6 -0
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/user-notifications.ts +74 -0
- package/src/server/middleware/request-logger.ts +11 -2
- package/src/services/accounts/avatar.ts +5 -2
- package/src/services/image-metadata.ts +438 -0
- package/src/services/index.ts +1 -0
- package/src/services/logging/redaction.ts +50 -0
- package/src/services/mandates/index.ts +4 -2
- package/src/services/mandates/policy.ts +20 -0
- package/src/services/notifications/browser.ts +48 -15
- package/src/services/notifications/dispatcher.ts +31 -1
- package/src/services/notifications/platform.ts +32 -4
- package/src/services/notifications/preview.ts +23 -0
- package/src/services/notifications/quiet.ts +153 -0
- package/src/services/notifications/user.ts +6 -0
- package/src/services/pdf/template-preview.ts +2 -1
- package/src/services/settings/app.ts +3 -0
- package/src/services/settings/store.ts +4 -1
- package/src/services/weather/geo.ts +6 -2
- package/src/shared/ai-platform-prompt.ts +2 -2
- package/src/shared/redirect.ts +2 -1
- package/src/shared/template-rendering.ts +120 -7
- package/src/ssr/AppBadge.tsx +30 -0
- package/src/ssr/AppLaunchpadPanel.tsx +47 -26
- package/src/ssr/IslandError.tsx +33 -0
- package/src/ssr/Layout.tsx +3 -0
- package/src/ssr/LayoutHelp.tsx +4 -2
- package/src/ssr/RailApps.island.tsx +9 -2
- package/src/ssr/app-badges.ts +93 -0
- package/src/ssr/rail-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +2 -0
- package/src/styles/effects.css +23 -0
- package/src/styles/file-chooser.css +25 -0
- package/src/styles/utilities-navigation.css +41 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"./server/*": "./src/server/*",
|
|
31
31
|
"./browser": "./src/server/api-client.ts",
|
|
32
32
|
"./browser/app-approval": "./src/browser/app-approval.ts",
|
|
33
|
+
"./browser/app-badges": "./src/browser/app-badges.ts",
|
|
33
34
|
"./browser/live": "./src/browser/live.ts",
|
|
34
35
|
"./browser/mermaid": "./src/browser/mermaid.ts",
|
|
35
36
|
"./browser/reload": "./src/browser/reload.ts",
|
|
@@ -52,6 +53,7 @@
|
|
|
52
53
|
"./cli/admin": "./src/cli/admin/index.ts",
|
|
53
54
|
"./shared": "./src/shared/index.ts",
|
|
54
55
|
"./services": "./src/services/index.ts",
|
|
56
|
+
"./services/image-metadata": "./src/services/image-metadata.ts",
|
|
55
57
|
"./services/document-extraction": "./src/services/document-extraction.ts",
|
|
56
58
|
"./services/ipa/service-account": null,
|
|
57
59
|
"./services/*": "./src/services/*",
|
|
@@ -98,10 +100,10 @@
|
|
|
98
100
|
"@simplewebauthn/server": "14.0.3",
|
|
99
101
|
"@tabler/icons": "3.48.0",
|
|
100
102
|
"@tailwindcss/typography": "0.5.20",
|
|
101
|
-
"@k2b/nessi": "0.
|
|
102
|
-
"@k2b/ssr": "0.
|
|
103
|
-
"@k2b/ui": "0.
|
|
104
|
-
"@k2b/stdlib": "0.
|
|
103
|
+
"@k2b/nessi": "0.17.0",
|
|
104
|
+
"@k2b/ssr": "0.15.0",
|
|
105
|
+
"@k2b/ui": "0.19.0",
|
|
106
|
+
"@k2b/stdlib": "0.28.0",
|
|
105
107
|
"@k2b/sync": "7.0.0",
|
|
106
108
|
"@nats-io/jetstream": "3.4.0",
|
|
107
109
|
"@nats-io/transport-node": "3.4.0",
|
|
@@ -94,6 +94,24 @@ export const buildCliPlugin = async (params: {
|
|
|
94
94
|
minify: true,
|
|
95
95
|
// Playwright references this optional BiDi adapter lazily; Cloud uses CDP.
|
|
96
96
|
external: ["chromium-bidi/*"],
|
|
97
|
+
plugins: [
|
|
98
|
+
{
|
|
99
|
+
name: "playwright-metadata",
|
|
100
|
+
setup(build) {
|
|
101
|
+
build.onLoad({ filter: /[/\\]playwright-core[/\\]lib[/\\](?:coreBundle|serverRegistry|package)\.js$/ }, async ({ path }) => ({
|
|
102
|
+
// Computed package-relative JSON requires evade Bun's dependency
|
|
103
|
+
// graph and retain the container's absolute path. Static requires
|
|
104
|
+
// carry the version and browser registry in the single-file plugin.
|
|
105
|
+
contents: (await readFile(path, "utf8")).replace(
|
|
106
|
+
/require\(import_path\d*\.default\.join\(packageRoot, "(package|browsers)\.json"\)\)/g,
|
|
107
|
+
(_, file: string) => `require("../${file}.json")`,
|
|
108
|
+
),
|
|
109
|
+
loader: "js",
|
|
110
|
+
resolveDir: dirname(path),
|
|
111
|
+
}));
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
],
|
|
97
115
|
});
|
|
98
116
|
if (!result.success) {
|
|
99
117
|
throw new Error(`CLI module "${name}" failed to bundle:\n${result.logs.map((log) => String(log)).join("\n")}`);
|
|
@@ -86,6 +86,7 @@ import { configurePostgresApplicationName } from "./postgres-application-name";
|
|
|
86
86
|
import { getProcessSync, startProcessSync } from "./process-sync";
|
|
87
87
|
import { APP_READINESS_PATH, appReadinessResponse } from "./readiness";
|
|
88
88
|
import { appRegistry, type CapabilityRegistryRecord, capabilityRegistry } from "./registry";
|
|
89
|
+
import { canonicalRoute, isOwnNavBadge } from "./registry-validation";
|
|
89
90
|
import { ensureRuntimeWatcher, getCurrentRuntime, stopRuntimeWatcher } from "./runtime-watcher";
|
|
90
91
|
import { servePublicAsset } from "./static-assets";
|
|
91
92
|
import { createStatusPreservingSsrHandler } from "./status-preserving-ssr";
|
|
@@ -144,6 +145,13 @@ export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefi
|
|
|
144
145
|
section: "primary" | "more" | "hidden";
|
|
145
146
|
requiresAuth?: boolean;
|
|
146
147
|
requiresRoles?: Role[];
|
|
148
|
+
/**
|
|
149
|
+
* Same-origin path of a `GET` route this app serves below one of its
|
|
150
|
+
* `routes`. After the page loads, the shell reads it with the person's
|
|
151
|
+
* session and shows the returned `AppNavBadge` count on the app's icon in
|
|
152
|
+
* the rail and the app grid. Protect it like any other route.
|
|
153
|
+
*/
|
|
154
|
+
badge?: string;
|
|
147
155
|
};
|
|
148
156
|
/**
|
|
149
157
|
* Settings owned by this app, declared as a map of dotted-key → definition.
|
|
@@ -332,11 +340,6 @@ export type AppDefinition<S extends AppSettingsMap = {}, N extends NotificationD
|
|
|
332
340
|
// ── Implementation ──────────────────────────────────────────────────────────
|
|
333
341
|
|
|
334
342
|
const isPwaPath = (route: string): boolean => route === "/pwa" || route.startsWith("/pwa/");
|
|
335
|
-
/** A route as the gateway reads it: trimmed and without empty segments, so `//pwa/x/` is `/pwa/x`. */
|
|
336
|
-
const canonicalRoute = (route: string): string => {
|
|
337
|
-
const trimmed = route.trim();
|
|
338
|
-
return trimmed.startsWith("/") ? `/${trimmed.split("/").filter(Boolean).join("/")}` : trimmed;
|
|
339
|
-
};
|
|
340
343
|
|
|
341
344
|
/**
|
|
342
345
|
* `/pwa` belongs to the mobile app shell, `/pwa/_auth` to Core, and `/pwa/<id>`
|
|
@@ -357,6 +360,14 @@ const validateAppPwaPart = (opts: Pick<AppOptions, "id" | "routes" | "pwa">): Ap
|
|
|
357
360
|
return { href: `/pwa/${opts.id}`, requiresRoles: opts.pwa.requiresRoles ? [...opts.pwa.requiresRoles] : undefined };
|
|
358
361
|
};
|
|
359
362
|
|
|
363
|
+
/** The badge route is read by the browser through the gateway, so it must be one of this app's own paths. */
|
|
364
|
+
const validateAppNavBadge = (opts: Pick<AppOptions, "id" | "routes" | "nav">): void => {
|
|
365
|
+
const badge = opts.nav?.badge;
|
|
366
|
+
if (badge !== undefined && !isOwnNavBadge(badge, opts.routes)) {
|
|
367
|
+
throw new Error(`App "${opts.id}" declares nav.badge "${badge}"; it must be a same-origin path below one of its routes`);
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
|
|
360
371
|
export const defineApp = <
|
|
361
372
|
const S extends AppSettingsMap = {},
|
|
362
373
|
const N extends NotificationDefinitionMap = {},
|
|
@@ -369,6 +380,7 @@ export const defineApp = <
|
|
|
369
380
|
const notifications = bindNotificationDefinitions(opts.id, opts.notifications);
|
|
370
381
|
const cliModules = validateAppCliModules(opts.id, opts.cli);
|
|
371
382
|
const pwaPart = validateAppPwaPart(opts);
|
|
383
|
+
validateAppNavBadge(opts);
|
|
372
384
|
|
|
373
385
|
// ── 0. Register declared settings into the runtime registry ──────────
|
|
374
386
|
// SETTINGS_MAP is the single source of truth for validation in store.ts
|
|
@@ -387,6 +399,9 @@ export const defineApp = <
|
|
|
387
399
|
rootDir,
|
|
388
400
|
componentRoots: [resolve(env.APP_DIR ?? rootDir, "src"), fileURLToPath(new URL("../", import.meta.url))],
|
|
389
401
|
basePath: opts.basePath,
|
|
402
|
+
// Every island instance already mounts inside its own error boundary; this
|
|
403
|
+
// only gives the fallback Cloud's look and the reader's language.
|
|
404
|
+
errorFallback: fileURLToPath(new URL("../ssr/IslandError.tsx", import.meta.url)),
|
|
390
405
|
template: ({ body, scripts, title, description, theme, lang, performanceRoute, pwa }) => {
|
|
391
406
|
const themeFixed = theme !== undefined;
|
|
392
407
|
// App pages paint the canvas before any stylesheet and give it to the
|
|
@@ -571,6 +586,7 @@ export const defineApp = <
|
|
|
571
586
|
section: meta.nav?.section ?? "hidden",
|
|
572
587
|
requiresAuth: meta.nav?.requiresAuth,
|
|
573
588
|
requiresRoles: meta.nav?.requiresRoles,
|
|
589
|
+
badge: meta.nav?.badge,
|
|
574
590
|
adminHref: meta.adminHref,
|
|
575
591
|
}
|
|
576
592
|
: undefined,
|
|
@@ -7,6 +7,32 @@ const isString = (value: unknown): value is string => typeof value === "string";
|
|
|
7
7
|
const isStringArray = (value: unknown): value is string[] => Array.isArray(value) && value.every(isString);
|
|
8
8
|
const isStringRecord = (value: unknown): value is Record<string, string> => isRecord(value) && Object.values(value).every(isString);
|
|
9
9
|
|
|
10
|
+
/** An absolute path on the current origin: one leading slash, no backslash or whitespace. */
|
|
11
|
+
const SAME_ORIGIN_PATH = /^\/(?![\/\\])[^\\\s]*$/;
|
|
12
|
+
|
|
13
|
+
/** A route as the gateway reads it: trimmed and without empty segments, so `//pwa/x/` is `/pwa/x`. */
|
|
14
|
+
export const canonicalRoute = (route: string): string => {
|
|
15
|
+
const trimmed = route.trim();
|
|
16
|
+
return trimmed.startsWith("/") ? `/${trimmed.split("/").filter(Boolean).join("/")}` : trimmed;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether a `nav.badge` path stays below one of the app's own `routes`. It is
|
|
21
|
+
* checked on the path the browser actually requests, after dot segments, and
|
|
22
|
+
* never starts with `//`, which a URL resolver reads as another host.
|
|
23
|
+
*/
|
|
24
|
+
export const isOwnNavBadge = (badge: string, routes: readonly string[]): boolean => {
|
|
25
|
+
if (!SAME_ORIGIN_PATH.test(badge)) return false;
|
|
26
|
+
const path = new URL(badge, "https://cloud.invalid").pathname;
|
|
27
|
+
return (
|
|
28
|
+
!path.startsWith("//") &&
|
|
29
|
+
routes.some((route) => {
|
|
30
|
+
const prefix = canonicalRoute(route);
|
|
31
|
+
return prefix === "/" || path === prefix || path.startsWith(`${prefix}/`);
|
|
32
|
+
})
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
10
36
|
const invalid = (path: string, expected: string): string => `${path} must be ${expected}`;
|
|
11
37
|
|
|
12
38
|
export const validateAppRegistryEntry = (value: unknown): string | null => {
|
|
@@ -61,6 +87,9 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
|
|
|
61
87
|
if (value.nav.requiresRoles !== undefined && !isStringArray(value.nav.requiresRoles)) {
|
|
62
88
|
return invalid("nav.requiresRoles", "an array of strings");
|
|
63
89
|
}
|
|
90
|
+
if (value.nav.badge !== undefined && (!isString(value.nav.badge) || !isOwnNavBadge(value.nav.badge, value.routes))) {
|
|
91
|
+
return invalid("nav.badge", "a same-origin path below one of the entry's routes");
|
|
92
|
+
}
|
|
64
93
|
}
|
|
65
94
|
if (value.adminNav !== undefined) {
|
|
66
95
|
if (
|
|
@@ -98,7 +127,7 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
|
|
|
98
127
|
!link.label.trim() ||
|
|
99
128
|
(link.description !== undefined && !isString(link.description)) ||
|
|
100
129
|
!isString(link.href) ||
|
|
101
|
-
|
|
130
|
+
!SAME_ORIGIN_PATH.test(link.href) ||
|
|
102
131
|
(link.icon !== undefined && !isString(link.icon)) ||
|
|
103
132
|
(link.keywords !== undefined && !isStringArray(link.keywords)),
|
|
104
133
|
)
|
|
@@ -55,6 +55,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
|
|
|
55
55
|
// Registry stores roles as serialized strings; the source type is
|
|
56
56
|
// Role[] and round-trip is value-preserving.
|
|
57
57
|
requiresRoles: e.nav.requiresRoles as Role[] | undefined,
|
|
58
|
+
badge: e.nav.badge,
|
|
58
59
|
}
|
|
59
60
|
: undefined,
|
|
60
61
|
help: e.help ? { ...e.help } : undefined,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mutation } from "@k2b/stdlib/solid";
|
|
2
2
|
import { IconButton, Placeholder, prompts, SelectChip, Tooltip, useLocale } from "@k2b/ui";
|
|
3
|
-
import { For, Show } from "solid-js";
|
|
3
|
+
import { createMemo, For, Show } from "solid-js";
|
|
4
4
|
import { createStore } from "solid-js/store";
|
|
5
5
|
import { CloudAvatar } from "../account/Avatar";
|
|
6
6
|
import type { AccessEntry, PermissionLevel, Principal, ServiceAccountKind } from "../contracts/shared";
|
|
@@ -52,6 +52,13 @@ type PermissionEditorProps = {
|
|
|
52
52
|
* account), its row can neither be lowered nor removed. */
|
|
53
53
|
revokeAccess: (accessId: string) => Promise<void>;
|
|
54
54
|
|
|
55
|
+
/** The entries that still apply when the resource lets one entry shadow
|
|
56
|
+
* another, such as a `none` that overrides the same principal's `admin`.
|
|
57
|
+
* The editor counts managers only among them, so pass the same function
|
|
58
|
+
* the service applies before `ensureManagerRemains()`. Defaults to every
|
|
59
|
+
* entry. */
|
|
60
|
+
effectiveEntries?: (entries: readonly AccessEntry[]) => readonly AccessEntry[];
|
|
61
|
+
|
|
55
62
|
/** Allow granting `public` access from this editor. */
|
|
56
63
|
allowPublic?: boolean;
|
|
57
64
|
|
|
@@ -218,10 +225,10 @@ export default function PermissionEditor(props: PermissionEditorProps) {
|
|
|
218
225
|
});
|
|
219
226
|
const busy = () => grantMut.loading() || updateMut.loading() || revokeMut.loading();
|
|
220
227
|
// The service refuses to remove the last manager; the row says so up front.
|
|
221
|
-
const lastManagerId = () => {
|
|
222
|
-
const managers = entries.filter(isManagerEntry);
|
|
228
|
+
const lastManagerId = createMemo(() => {
|
|
229
|
+
const managers = (props.effectiveEntries?.(entries) ?? entries).filter(isManagerEntry);
|
|
223
230
|
return managers.length === 1 ? managers[0]!.id : null;
|
|
224
|
-
};
|
|
231
|
+
});
|
|
225
232
|
|
|
226
233
|
return (
|
|
227
234
|
<div class="flex flex-col gap-3">
|
|
@@ -2,8 +2,9 @@ import type { AccessSubject, RequestActor } from "../server";
|
|
|
2
2
|
import { aiModelAccess } from "./model-access";
|
|
3
3
|
import { personalAiModelPolicy } from "./personal-agent";
|
|
4
4
|
import { listAiModels, toPublicAiSettingsState } from "./settings";
|
|
5
|
-
import { resolveAiAudioModel } from "./transcription";
|
|
6
|
-
import type { AiChatTurnRunConfig, AiModelPolicy, AiSettingsError } from "./types";
|
|
5
|
+
import { type AiResolvedAudioModel, AiTranscriptionError, resolveAiAudioModel } from "./transcription";
|
|
6
|
+
import type { AiChatTurnRunConfig, AiDataBoundary, AiModelPolicy, AiSettingsError } from "./types";
|
|
7
|
+
import { isAiSettingsError } from "./validate";
|
|
7
8
|
|
|
8
9
|
export const aiChatAccessSubject = (actor: RequestActor | undefined): AccessSubject | null => {
|
|
9
10
|
if (!actor) return null;
|
|
@@ -20,14 +21,34 @@ export const isAssistantChatTurn = (config: AiChatTurnRunConfig): boolean => con
|
|
|
20
21
|
export const listAssistantAiModels = async (subject: AccessSubject | null, policy: AiModelPolicy = personalAiModelPolicy) =>
|
|
21
22
|
aiModelAccess.filterModels(await listAiModels(policy), subject);
|
|
22
23
|
|
|
24
|
+
/** The one audio check behind the composer microphone, dictation, and offering and running transcribe_audio. */
|
|
25
|
+
export const resolveAssistantAudioModel = async (
|
|
26
|
+
subject: AccessSubject | null,
|
|
27
|
+
allowedDataBoundaries?: AiDataBoundary[],
|
|
28
|
+
): Promise<AiResolvedAudioModel> => {
|
|
29
|
+
const model = await resolveAiAudioModel({ allowedDataBoundaries });
|
|
30
|
+
try {
|
|
31
|
+
await aiModelAccess.assertAllowed(model.profile.id, subject);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (!isAiSettingsError(error) || error.aiError.code !== "model_access_denied") throw error;
|
|
34
|
+
throw Object.assign(
|
|
35
|
+
new AiTranscriptionError(
|
|
36
|
+
"transcription_access_denied",
|
|
37
|
+
"Audio transcription is not available to you: you do not have access to the audio model. An administrator can grant access in the AI settings.",
|
|
38
|
+
),
|
|
39
|
+
{ aiError: error.aiError },
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return model;
|
|
43
|
+
};
|
|
44
|
+
|
|
23
45
|
export const assistantAiSettingsState = async (subject: AccessSubject | null) => {
|
|
24
46
|
const [status, models, audioModelConfigured] = await Promise.all([
|
|
25
47
|
toPublicAiSettingsState(personalAiModelPolicy.allowedDataBoundaries),
|
|
26
48
|
listAssistantAiModels(subject),
|
|
27
49
|
(async () => {
|
|
28
50
|
try {
|
|
29
|
-
|
|
30
|
-
await aiModelAccess.assertAllowed(model.profile.id, subject);
|
|
51
|
+
await resolveAssistantAudioModel(subject, personalAiModelPolicy.allowedDataBoundaries);
|
|
31
52
|
return true;
|
|
32
53
|
} catch {
|
|
33
54
|
return false;
|
package/src/ai/audio-tool.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { aiChatAccessSubject } from "./assistant-models";
|
|
2
|
+
import { aiChatAccessSubject, resolveAssistantAudioModel } from "./assistant-models";
|
|
3
3
|
import { aiProjectFilePathFromMount } from "./file-mount";
|
|
4
4
|
import { aiFileStore, normalizeAiFilePath } from "./files-store";
|
|
5
|
-
import { aiModelAccess } from "./model-access";
|
|
6
5
|
import { defineAiTool } from "./tools";
|
|
7
|
-
import { type AiResolvedAudioModel,
|
|
6
|
+
import { type AiResolvedAudioModel, runAiTranscription } from "./transcription";
|
|
8
7
|
|
|
9
8
|
export const CloudAiTranscribeAudioInputSchema = z.object({
|
|
10
9
|
path: z.string().trim().min(1).describe("Absolute path of the stored audio file. Project files are below /project."),
|
|
@@ -53,13 +52,7 @@ export const createCloudAiTranscribeAudioTool = (options: { resolveModel?: () =>
|
|
|
53
52
|
signal: ctx.signal,
|
|
54
53
|
allowedDataBoundaries: ctx.allowedDataBoundaries,
|
|
55
54
|
attribution: { conversationId: ctx.conversationId, turnId: ctx.turnId },
|
|
56
|
-
resolveModel:
|
|
57
|
-
options.resolveModel ??
|
|
58
|
-
(async () => {
|
|
59
|
-
const resolved = await resolveAiAudioModel({ allowedDataBoundaries: ctx.allowedDataBoundaries });
|
|
60
|
-
await aiModelAccess.assertAllowed(resolved.profile.id, aiChatAccessSubject(ctx.actor));
|
|
61
|
-
return resolved;
|
|
62
|
-
}),
|
|
55
|
+
resolveModel: options.resolveModel ?? (() => resolveAssistantAudioModel(aiChatAccessSubject(ctx.actor), ctx.allowedDataBoundaries)),
|
|
63
56
|
});
|
|
64
57
|
ctx.signal.throwIfAborted();
|
|
65
58
|
const producerCallKey = `${ctx.turnId}:${ctx.callId}`;
|
package/src/ai/capabilities.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import type { CapabilityRegistryEntry } from "../contracts/registry";
|
|
15
15
|
import type { RequestActor } from "../server";
|
|
16
16
|
import { createHelpReader, type HelpReaderFactory } from "../services/help";
|
|
17
|
+
import { type MandatePolicyV1, mandatePolicyCanPermitCapability } from "../services/mandates/policy";
|
|
17
18
|
import { resolveAppIdentityPresentation } from "../shared/app-presentation";
|
|
18
19
|
import { recordRejectedAiCapability } from "./capability-execution";
|
|
19
20
|
import { CODE_SOURCE_TOOLS } from "./code-source-contracts";
|
|
@@ -76,7 +77,7 @@ export type AiRememberableCapabilityApprovals = ReadonlyMap<string, string>;
|
|
|
76
77
|
* Why `load_tools` could not make a requested name callable:
|
|
77
78
|
* - `unknown`: no tool has this exact name;
|
|
78
79
|
* - `not_offered_in_turn`: the tool exists, but this turn's client or task does not provide it;
|
|
79
|
-
* - `not_allowed`: the conversation's fixed tool scope
|
|
80
|
+
* - `not_allowed`: the conversation's fixed tool scope or task grants exclude it;
|
|
80
81
|
* - `app_offline`: the app operation is not in the live registry now.
|
|
81
82
|
*/
|
|
82
83
|
export const AI_TOOL_UNAVAILABLE_REASONS = ["unknown", "not_offered_in_turn", "not_allowed", "app_offline"] as const;
|
|
@@ -475,8 +476,8 @@ export const createAiToolMetaTools = (input: {
|
|
|
475
476
|
store: ToolStateStore;
|
|
476
477
|
maxLoadedTools?: number;
|
|
477
478
|
unavailableLoadedNames?: readonly string[];
|
|
478
|
-
/**
|
|
479
|
-
|
|
479
|
+
/** Whether this turn offers app operations, offers none, or its fixed scope or task grants exclude all of them. */
|
|
480
|
+
appOperations?: "not_offered" | "not_allowed" | "offered";
|
|
480
481
|
/** Why a requested name that is not in `catalog` cannot be loaded; defaults to `unknown`. */
|
|
481
482
|
unavailableReason?: (name: string) => AiToolUnavailableReason;
|
|
482
483
|
}): AiRuntimeTool[] => {
|
|
@@ -494,9 +495,11 @@ export const createAiToolMetaTools = (input: {
|
|
|
494
495
|
const liveAppDirectory =
|
|
495
496
|
directoryEntries.length > 0
|
|
496
497
|
? ` Live capability apps: ${directoryEntries.join(", ")}${hiddenAppCount > 0 ? `, and ${hiddenAppCount} more` : ""}.`
|
|
497
|
-
: input.
|
|
498
|
+
: input.appOperations === "not_offered"
|
|
498
499
|
? " App operations are not offered in this turn."
|
|
499
|
-
:
|
|
500
|
+
: input.appOperations === "not_allowed"
|
|
501
|
+
? " This chat's tool scope or task grants allow no app operations. Do not search for app operations in this turn; continue with the available tools or tell the user which grant is missing."
|
|
502
|
+
: " No Cloud app publishes operations right now. Do not search for app operations again in this turn; tell the user which app is not reachable instead of claiming a permanent product limitation.";
|
|
500
503
|
const unavailableLoadedNames = input.unavailableLoadedNames ?? [];
|
|
501
504
|
const unavailableLoadedNotice =
|
|
502
505
|
unavailableLoadedNames.length > 0
|
|
@@ -542,7 +545,7 @@ export const createAiToolMetaTools = (input: {
|
|
|
542
545
|
const load = defineAiTool({
|
|
543
546
|
name: "load_tools",
|
|
544
547
|
description:
|
|
545
|
-
"Load stable capability ids such as mail.conversation.list, or exact built-in names returned by search_tools, as ordinary tools for the next model turn. Skills may name capability ids directly, so load them without searching first. Call each loaded tool by the `call` name returned for it. `unavailable` gives a reason per name: unknown means no tool has this exact name, so look it up once with search_tools instead of guessing; not_offered_in_turn means this client or task does not provide the tool; not_allowed means this chat's fixed tool scope
|
|
548
|
+
"Load stable capability ids such as mail.conversation.list, or exact built-in names returned by search_tools, as ordinary tools for the next model turn. Skills may name capability ids directly, so load them without searching first. Call each loaded tool by the `call` name returned for it. `unavailable` gives a reason per name: unknown means no tool has this exact name, so look it up once with search_tools instead of guessing; not_offered_in_turn means this client or task does not provide the tool; not_allowed means this chat's fixed tool scope or task grants exclude it; app_offline means its app is not reachable now. For the last three, do not search or load the name again in this turn.",
|
|
546
549
|
inputSchema: z.object({ names: z.array(z.string().trim().min(1)).min(1).max(25) }).strict(),
|
|
547
550
|
outputSchema: z
|
|
548
551
|
.object({
|
|
@@ -689,6 +692,8 @@ export const createAiToolResolver =
|
|
|
689
692
|
actor: RequestActor;
|
|
690
693
|
staticTools: AiRuntimeTool[];
|
|
691
694
|
allowedTools?: readonly string[] | null;
|
|
695
|
+
/** Undefined for interactive runs; null fails closed when a background mandate cannot be loaded. */
|
|
696
|
+
mandatePolicy?: MandatePolicyV1 | null;
|
|
692
697
|
/** Built-in tools that exist but that this turn does not offer, such as client tools its client did not declare or tools outside `allowedTools`. */
|
|
693
698
|
unofferedTools?: readonly string[];
|
|
694
699
|
runtimeContext?: Omit<AiToolPreparationContext, "actor" | "conversationId">;
|
|
@@ -725,7 +730,19 @@ export const createAiToolResolver =
|
|
|
725
730
|
}
|
|
726
731
|
const allowed = input.allowedTools == null ? null : new Set(input.allowedTools);
|
|
727
732
|
const fullCapabilityCatalog = buildAiCapabilityCatalog(registry, input.locale);
|
|
728
|
-
const
|
|
733
|
+
const mandateAllows = (entry: AiCapabilityCatalogEntry) =>
|
|
734
|
+
input.mandatePolicy === undefined ||
|
|
735
|
+
(input.mandatePolicy !== null &&
|
|
736
|
+
mandatePolicyCanPermitCapability(input.mandatePolicy, {
|
|
737
|
+
appId: entry.appId,
|
|
738
|
+
capabilityId: entry.operation.localId,
|
|
739
|
+
kind: entry.kind,
|
|
740
|
+
approval: entry.kind === "action" && "approval" in entry.operation ? entry.operation.approval : undefined,
|
|
741
|
+
}));
|
|
742
|
+
const mandateExcluded = new Set(
|
|
743
|
+
fullCapabilityCatalog.filter((entry) => !mandateAllows(entry)).flatMap((entry) => [entry.name, entry.providerName]),
|
|
744
|
+
);
|
|
745
|
+
const capabilityCatalog = fullCapabilityCatalog.filter((entry) => (!allowed || allowed.has(entry.name)) && mandateAllows(entry));
|
|
729
746
|
const helpTools = input.help ? createAiHelpTools(input.help, input.locale) : [];
|
|
730
747
|
const resourceTool =
|
|
731
748
|
input.execute && (capabilityCatalog.length > 0 || input.staticTools.some((tool) => tool.def.name === "code_read"))
|
|
@@ -753,24 +770,33 @@ export const createAiToolResolver =
|
|
|
753
770
|
// App operation IDs always contain a dot; built-in names never do.
|
|
754
771
|
const unavailableReason = (requested: string): AiToolUnavailableReason => {
|
|
755
772
|
const name = loadedByProviderName.get(requested) ?? requested;
|
|
773
|
+
if (mandateExcluded.has(name)) return "not_allowed";
|
|
756
774
|
if (allowed && outOfScope.has(name) && !allowed.has(name)) return "not_allowed";
|
|
757
775
|
if (unoffered.has(name)) return "not_offered_in_turn";
|
|
758
776
|
if (!name.includes(".")) return "unknown";
|
|
759
777
|
if (!input.listRegistry) return "not_offered_in_turn";
|
|
760
778
|
return liveRegistry === null || persistedLoadedNames.includes(name) ? "app_offline" : "unknown";
|
|
761
779
|
};
|
|
780
|
+
// Independent of which apps are live: the fixed scope or task grants name no app operation at all.
|
|
781
|
+
const scopeExcludesAppOperations =
|
|
782
|
+
input.mandatePolicy === null ||
|
|
783
|
+
(input.mandatePolicy?.grants !== undefined && !input.mandatePolicy.grants.some((grant) => "appId" in grant)) ||
|
|
784
|
+
(allowed !== null && ![...allowed].some((name) => name.includes(".")));
|
|
762
785
|
const eagerNames = new Set(builtIns.map((tool) => tool.def.name).filter((name) => !CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES.has(name)));
|
|
763
786
|
const activeBuiltIns = builtIns.filter((tool) => eagerNames.has(tool.def.name) || loadedNames.includes(tool.def.name));
|
|
764
787
|
const runtimeTools = [
|
|
765
788
|
...createAiToolMetaTools({
|
|
766
|
-
apps:
|
|
789
|
+
apps:
|
|
790
|
+
allowed || input.mandatePolicy !== undefined
|
|
791
|
+
? registry.filter((app) => capabilityCatalog.some((entry) => entry.appId === app.appId))
|
|
792
|
+
: registry,
|
|
767
793
|
catalog,
|
|
768
794
|
eagerNames,
|
|
769
795
|
conversationId: input.conversationId,
|
|
770
796
|
store: input.store,
|
|
771
797
|
maxLoadedTools: input.maxLoadedTools,
|
|
772
798
|
unavailableLoadedNames,
|
|
773
|
-
|
|
799
|
+
appOperations: !input.listRegistry ? "not_offered" : scopeExcludesAppOperations ? "not_allowed" : "offered",
|
|
774
800
|
unavailableReason,
|
|
775
801
|
}),
|
|
776
802
|
...activeBuiltIns,
|
package/src/ai/chat/blocks.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { dates } from "@k2b/stdlib";
|
|
2
2
|
import { mutation } from "@k2b/stdlib/solid";
|
|
3
3
|
import { Button, ButtonLink, Chat, isStructuredDataValue, MarkdownView, SplitButton, StructuredDataPreview, useLocale } from "@k2b/ui";
|
|
4
|
-
import { createSignal, For, type JSX, Match, Show, Switch, untrack } from "solid-js";
|
|
4
|
+
import { createSignal, createUniqueId, For, type JSX, Match, Show, Switch, untrack } from "solid-js";
|
|
5
5
|
import type { CapabilityActionReview } from "../../contracts/capabilities";
|
|
6
6
|
import { markdown } from "../../shared";
|
|
7
7
|
import type { AiTurnBlock } from "../protocol";
|
|
@@ -13,6 +13,7 @@ import { useAiChatActions } from "./message-actions";
|
|
|
13
13
|
import {
|
|
14
14
|
aiToolIcon,
|
|
15
15
|
capabilityErrorDescription,
|
|
16
|
+
codeCheckOutcome,
|
|
16
17
|
displayToolName,
|
|
17
18
|
fetchFileErrorPresentation,
|
|
18
19
|
formatToolDetailText,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
isTextEditorToolName,
|
|
23
24
|
jsonPreview,
|
|
24
25
|
memoryToolPresentation,
|
|
26
|
+
viewedChatImage,
|
|
25
27
|
} from "./message-utils";
|
|
26
28
|
import { aiChatMessages } from "./messages";
|
|
27
29
|
import { AssistantMarkdownBlock } from "./primitives";
|
|
@@ -200,7 +202,7 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock }) {
|
|
|
200
202
|
const submit = (input: { approved: boolean; remember?: "always" }) => {
|
|
201
203
|
if (!actionDisabled() && !approval.loading()) void approval.mutate(input);
|
|
202
204
|
};
|
|
203
|
-
const title = () => props.block.presentation?.title ?? displayToolName(props.block.name);
|
|
205
|
+
const title = () => props.block.presentation?.title ?? displayToolName(props.block.name, locale());
|
|
204
206
|
const ownerName = () => props.block.presentation?.appName ?? t().assistant;
|
|
205
207
|
const description = () => {
|
|
206
208
|
const reviewMessage = props.block.approval?.review?.message.trim();
|
|
@@ -535,7 +537,7 @@ function CapabilityToolView(props: { block: ToolBlock }) {
|
|
|
535
537
|
function RejectedToolView(props: { block: ToolBlock }) {
|
|
536
538
|
const locale = useLocale();
|
|
537
539
|
const presentation = () => props.block.presentation;
|
|
538
|
-
const title = () => presentation()?.title ?? displayToolName(props.block.name);
|
|
540
|
+
const title = () => presentation()?.title ?? displayToolName(props.block.name, locale());
|
|
539
541
|
return (
|
|
540
542
|
<Chat.Activity
|
|
541
543
|
icon={aiToolIcon(props.block.name, presentation()?.appIcon)}
|
|
@@ -664,7 +666,7 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
|
|
|
664
666
|
fallback={
|
|
665
667
|
<ToolResultDisclosure
|
|
666
668
|
blockId={props.block.id}
|
|
667
|
-
name={displayToolName(props.block.name)}
|
|
669
|
+
name={displayToolName(props.block.name, locale())}
|
|
668
670
|
toolName={props.block.name}
|
|
669
671
|
args={props.block.args}
|
|
670
672
|
result={props.block.result}
|
|
@@ -718,11 +720,14 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
|
|
|
718
720
|
<TextEditorToolView turnId={props.turnId} block={props.block} active={props.active} />
|
|
719
721
|
</Match>
|
|
720
722
|
<Match when={status() === "running" || status() === "awaiting_client"}>
|
|
721
|
-
<Chat.Activity label={displayToolName(props.block.name)} icon={aiToolIcon(props.block.name)} busy />
|
|
723
|
+
<Chat.Activity label={displayToolName(props.block.name, locale())} icon={aiToolIcon(props.block.name)} busy />
|
|
722
724
|
</Match>
|
|
723
725
|
</Switch>
|
|
724
726
|
<Show when={hasCapabilityTable(props.block)}>
|
|
725
|
-
<CapabilityTablePreview
|
|
727
|
+
<CapabilityTablePreview
|
|
728
|
+
result={props.block.result}
|
|
729
|
+
label={props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
|
|
730
|
+
/>
|
|
726
731
|
</Show>
|
|
727
732
|
</>
|
|
728
733
|
);
|
|
@@ -751,7 +756,56 @@ export function AiTurnBlockView(props: { block: AiTurnBlock; turnId: string; str
|
|
|
751
756
|
);
|
|
752
757
|
}
|
|
753
758
|
|
|
754
|
-
/**
|
|
759
|
+
/** An alt text is a short description: the first line of the image description, clipped at a word. */
|
|
760
|
+
const altText = (description: string): string => {
|
|
761
|
+
const line =
|
|
762
|
+
description
|
|
763
|
+
.split("\n")
|
|
764
|
+
.find((entry) => entry.trim())
|
|
765
|
+
?.trim() ?? "";
|
|
766
|
+
if (line.length <= 240) return line;
|
|
767
|
+
const clipped = line.slice(0, 240);
|
|
768
|
+
return `${clipped.slice(0, Math.max(clipped.lastIndexOf(" "), 160))}…`;
|
|
769
|
+
};
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* The image a view_image step looked at, as a small fixed-size thumbnail: it loads lazily through the chat's own file
|
|
773
|
+
* route, so it shows only what the viewer may read, and it keeps its box while it loads or fails. A host that opens
|
|
774
|
+
* chat files shows it larger on click; the button is named for that action and described by the image's alt text.
|
|
775
|
+
*/
|
|
776
|
+
function StepImage(props: { path: string; src: string; description: string }) {
|
|
777
|
+
const actions = useAiChatActions();
|
|
778
|
+
const locale = useLocale();
|
|
779
|
+
const imageId = createUniqueId();
|
|
780
|
+
const [failed, setFailed] = createSignal(false);
|
|
781
|
+
const name = () => props.path.slice(props.path.lastIndexOf("/") + 1) || props.path;
|
|
782
|
+
const alt = () => altText(props.description);
|
|
783
|
+
const image = () => (
|
|
784
|
+
<Show when={!failed()} fallback={<i class="ti ti-photo-off text-lg text-dimmed" aria-hidden="true" />}>
|
|
785
|
+
<img id={imageId} src={props.src} alt={alt() || name()} loading="lazy" decoding="async" onError={() => setFailed(true)} />
|
|
786
|
+
</Show>
|
|
787
|
+
);
|
|
788
|
+
return (
|
|
789
|
+
<Show when={actions.onOpenFile} fallback={<div class="ai-step-image">{image()}</div>}>
|
|
790
|
+
{(open) => (
|
|
791
|
+
<button
|
|
792
|
+
type="button"
|
|
793
|
+
class="ai-step-image focus-ui"
|
|
794
|
+
aria-label={aiChatMessages(locale()).openImage({ name: name() })}
|
|
795
|
+
aria-describedby={!failed() && alt() ? imageId : undefined}
|
|
796
|
+
onClick={() => open()(props.path)}
|
|
797
|
+
>
|
|
798
|
+
{image()}
|
|
799
|
+
</button>
|
|
800
|
+
)}
|
|
801
|
+
</Show>
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* One step in the expanded work list: its title, target, state, and outcome, with input and output on demand. An app
|
|
807
|
+
* check names its outcome in words and with its own icon, so a passed and a failed check differ without colour.
|
|
808
|
+
*/
|
|
755
809
|
export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
|
|
756
810
|
const locale = useLocale();
|
|
757
811
|
const t = () => aiChatMessages(locale());
|
|
@@ -760,28 +814,54 @@ export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
|
|
|
760
814
|
const waiting = () => props.block.status === "awaiting_client" || props.block.status === "awaiting_approval";
|
|
761
815
|
const state = () =>
|
|
762
816
|
waiting() ? t().stepWaiting : props.block.status === "rejected" ? t().stepRejected : isFailedTool(props.block) ? t().stepFailed : "";
|
|
817
|
+
const finished = () => props.block.status === "completed" && !props.block.isError;
|
|
818
|
+
const check = () => (finished() ? codeCheckOutcome(props.block.name, props.block.result) : null);
|
|
819
|
+
const outcome = () => {
|
|
820
|
+
const value = check();
|
|
821
|
+
if (!value) return "";
|
|
822
|
+
const result = value.passed ? t().checkPassed : value.errors > 0 ? t().checkFindings({ count: value.errors }) : t().checkNotPassed;
|
|
823
|
+
return value.warnings > 0 ? `${result} · ${t().checkWarnings({ count: value.warnings })}` : result;
|
|
824
|
+
};
|
|
825
|
+
const actions = useAiChatActions();
|
|
826
|
+
/** Only a host that resolves chat file URLs shows the image; without one there is no preview, not an empty box. */
|
|
827
|
+
const image = () => {
|
|
828
|
+
const viewed = finished() ? viewedChatImage(props.block.name, props.block.result) : null;
|
|
829
|
+
const src = viewed ? actions.fileUrl?.(viewed.path) : null;
|
|
830
|
+
return viewed && src ? { ...viewed, src } : null;
|
|
831
|
+
};
|
|
832
|
+
const icon = () => {
|
|
833
|
+
if (waiting()) return "ti ti-clock";
|
|
834
|
+
const value = check();
|
|
835
|
+
if (value) return value.passed ? "ti ti-circle-check" : "ti ti-alert-triangle";
|
|
836
|
+
return aiToolIcon(props.block.name, props.block.presentation?.appIcon);
|
|
837
|
+
};
|
|
763
838
|
return (
|
|
764
839
|
<AiToolActivity
|
|
765
840
|
blockId={props.block.id}
|
|
766
|
-
label={props.block.presentation?.title ?? displayToolName(props.block.name)}
|
|
841
|
+
label={props.block.presentation?.title ?? displayToolName(props.block.name, locale())}
|
|
767
842
|
description={[
|
|
768
843
|
props.block.status === "running" ? (props.block.progress ?? "") : "",
|
|
769
844
|
typeof detail() === "string" ? String(detail()) : "",
|
|
770
845
|
state(),
|
|
846
|
+
outcome(),
|
|
771
847
|
]
|
|
772
848
|
.filter(Boolean)
|
|
773
849
|
.join(" · ")}
|
|
774
|
-
icon={
|
|
850
|
+
icon={icon()}
|
|
775
851
|
busy={props.busy && props.block.status === "running"}
|
|
776
852
|
renderBody={() => (
|
|
777
|
-
<div
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
853
|
+
<div class="flex min-w-0 flex-col gap-2">
|
|
854
|
+
<div
|
|
855
|
+
class="max-h-72 overflow-auto overscroll-contain rounded-md bg-zinc-100 p-3 dark:bg-zinc-950"
|
|
856
|
+
tabIndex={0}
|
|
857
|
+
role="region"
|
|
858
|
+
aria-label={t().toolInputOutput}
|
|
859
|
+
>
|
|
860
|
+
<ToolDetail title={t().input} toolName={props.block.name} value={props.block.args} />
|
|
861
|
+
<ToolDetail title={t().output} toolName={props.block.name} value={props.block.result} />
|
|
862
|
+
</div>
|
|
863
|
+
{/* Below the input and output, so an image that arrives in an open step grows in like its output and moves nothing above. */}
|
|
864
|
+
<Show when={image()}>{(viewed) => <StepImage path={viewed().path} src={viewed().src} description={viewed().description} />}</Show>
|
|
785
865
|
</div>
|
|
786
866
|
)}
|
|
787
867
|
/>
|
|
@@ -106,6 +106,7 @@ function SearchToolsView(props: { block: ToolBlock }) {
|
|
|
106
106
|
const result = () => (isRecord(props.block.result) ? props.block.result : {});
|
|
107
107
|
const tools = () => records(result().tools);
|
|
108
108
|
const t = useMessages();
|
|
109
|
+
const locale = useLocale();
|
|
109
110
|
const query = () => text(args().query) || t().tools;
|
|
110
111
|
return (
|
|
111
112
|
<CompletedActivity block={props.block} label={t().searchTools({ query: query() })} description={t().found({ count: tools().length })}>
|
|
@@ -115,7 +116,8 @@ function SearchToolsView(props: { block: ToolBlock }) {
|
|
|
115
116
|
{(tool) => (
|
|
116
117
|
<ResultRow
|
|
117
118
|
icon={tool.kind === "action" ? "ti-bolt" : tool.kind === "query" ? "ti-search" : aiToolIcon(text(tool.name))}
|
|
118
|
-
|
|
119
|
+
// The catalog titles a built-in tool in English, so the reader's language names it; a capability brings its own title.
|
|
120
|
+
title={tool.kind !== "builtin" && text(tool.title) ? text(tool.title) : displayToolName(text(tool.name), locale())}
|
|
119
121
|
description={text(tool.description)}
|
|
120
122
|
meta={text(tool.appId) || text(tool.kind)}
|
|
121
123
|
/>
|