@k2b/cloud 0.26.0 → 0.27.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 +3 -3
- package/src/_internal/define-app.ts +8 -1
- package/src/_internal/process-identity.ts +7 -1
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/ai/admin.ts +1 -0
- package/src/ai/browser-code-contracts.ts +14 -2
- package/src/ai/browser.ts +8 -1
- package/src/ai/capabilities.ts +82 -22
- package/src/ai/chat/builtin-tools.tsx +31 -23
- package/src/ai/chat/file-tools.tsx +4 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +11 -4
- package/src/ai/chat/messages.ts +8 -2
- package/src/ai/chat/presentation.tsx +29 -9
- package/src/ai/chat/turn-view.tsx +47 -12
- package/src/ai/client/controller.ts +60 -31
- package/src/ai/client/file-source.ts +20 -3
- package/src/ai/client/projection.ts +7 -2
- package/src/ai/code-mode-skill.ts +27 -27
- package/src/ai/code-runtime-tools.ts +10 -1
- package/src/ai/code-source-contracts.ts +54 -4
- package/src/ai/code-source-tools.ts +10 -3
- package/src/ai/credentials.ts +17 -3
- package/src/ai/data-analysis-skill.ts +2 -2
- package/src/ai/default-tools.ts +2 -2
- package/src/ai/executor.ts +148 -84
- package/src/ai/file-context.ts +14 -2
- package/src/ai/file-tools.ts +17 -3
- package/src/ai/files-store.ts +134 -11
- package/src/ai/grids-skill.ts +2 -2
- package/src/ai/index.ts +7 -0
- package/src/ai/memories.ts +14 -0
- package/src/ai/migrate.ts +125 -0
- package/src/ai/model-request-settings.ts +98 -0
- package/src/ai/protocol.ts +6 -0
- package/src/ai/provider.ts +7 -1
- package/src/ai/quota-provider.ts +2 -2
- package/src/ai/request-headers.ts +117 -0
- package/src/ai/routes.ts +34 -6
- package/src/ai/runtime.ts +1 -1
- package/src/ai/settings.ts +19 -2
- package/src/ai/skill-seeds.ts +31 -3
- package/src/ai/skills.ts +26 -0
- package/src/ai/solid.ts +1 -1
- package/src/ai/store.ts +109 -57
- package/src/ai/stream.ts +180 -37
- package/src/ai/structured.ts +20 -5
- package/src/ai/system-prompt.ts +25 -0
- package/src/ai/tool-call-names.ts +45 -0
- package/src/ai/turn-policy.ts +247 -0
- package/src/ai/types.ts +24 -3
- package/src/api/admin-ai-quotas.ts +36 -1
- package/src/api/admin-core-settings.ts +16 -23
- package/src/api/admin-outgoing-mail.ts +62 -0
- package/src/api/index.ts +2 -0
- package/src/cli/admin/ai-quotas.ts +70 -1
- package/src/cli/admin/index.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +118 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/outgoing-mail.ts +77 -0
- package/src/contracts/registry.ts +2 -0
- package/src/services/index.ts +3 -0
- package/src/services/notifications/email.ts +16 -26
- package/src/services/outgoing-mail/index.ts +19 -0
- package/src/services/outgoing-mail/store.ts +286 -0
- package/src/services/outgoing-mail/test-send.ts +40 -0
- package/src/services/outgoing-mail/transport.ts +13 -0
- package/src/services/settings/core-settings.ts +1 -38
- package/src/services/settings/store.ts +5 -1
- package/src/shared/ai-model-request-settings.ts +21 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/ai-request-options.ts +185 -0
- package/src/ssr/admin-navigation.ts +1 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/workspace-navigation.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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": {
|
|
@@ -98,9 +98,9 @@
|
|
|
98
98
|
"@simplewebauthn/server": "14.0.3",
|
|
99
99
|
"@tabler/icons": "3.48.0",
|
|
100
100
|
"@tailwindcss/typography": "0.5.20",
|
|
101
|
-
"@k2b/nessi": "0.
|
|
101
|
+
"@k2b/nessi": "0.15.0",
|
|
102
102
|
"@k2b/ssr": "0.14.0",
|
|
103
|
-
"@k2b/ui": "0.
|
|
103
|
+
"@k2b/ui": "0.17.0",
|
|
104
104
|
"@k2b/stdlib": "0.27.0",
|
|
105
105
|
"@k2b/sync": "7.0.0",
|
|
106
106
|
"@nats-io/jetstream": "3.4.0",
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
CapabilityOriginSchema,
|
|
41
41
|
} from "../contracts/capabilities";
|
|
42
42
|
import { type BoundNotificationMap, bindNotificationDefinitions, type NotificationDefinitionMap } from "../contracts/notification-types";
|
|
43
|
+
import { type PlatformPermission, PlatformPermissionSchema } from "../contracts/outgoing-mail";
|
|
43
44
|
import { isPwaPartId, PWA_AUTH_PATH, PWA_CANVAS_COLORS, PWA_MANIFEST_PATH, PWA_SHELL_APP_ID } from "../contracts/pwa";
|
|
44
45
|
import type { AppRegistryEntry } from "../contracts/registry";
|
|
45
46
|
import type { AppSettingsMap, KindToType } from "../contracts/settings-types";
|
|
@@ -201,6 +202,8 @@ export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefi
|
|
|
201
202
|
* prefix-trie from these strings.
|
|
202
203
|
*/
|
|
203
204
|
routes: readonly string[];
|
|
205
|
+
/** Platform services requested by this application, visible to administrators. */
|
|
206
|
+
platformPermissions?: readonly PlatformPermission[];
|
|
204
207
|
/**
|
|
205
208
|
* Gateway-relative URL at which this app's OpenAPI 3.x JSON spec is
|
|
206
209
|
* served, e.g. `"/api/notebooks/openapi.json"`. Opt-in: only set this
|
|
@@ -471,6 +474,9 @@ export const defineApp = <
|
|
|
471
474
|
// ── 2. Meta ───────────────────────────────────────────────────────────
|
|
472
475
|
const baseMeta: AppMeta = {
|
|
473
476
|
id: opts.id,
|
|
477
|
+
platformPermissions: opts.platformPermissions
|
|
478
|
+
? opts.platformPermissions.map((permission) => PlatformPermissionSchema.parse(permission))
|
|
479
|
+
: undefined,
|
|
474
480
|
name: opts.name,
|
|
475
481
|
icon: opts.icon,
|
|
476
482
|
description: opts.description,
|
|
@@ -528,7 +534,7 @@ export const defineApp = <
|
|
|
528
534
|
}
|
|
529
535
|
})());
|
|
530
536
|
try {
|
|
531
|
-
bindProcessApplicationId(meta.id);
|
|
537
|
+
bindProcessApplicationId(meta.id, meta.platformPermissions);
|
|
532
538
|
cleanup.unshift(clearProcessApplicationId);
|
|
533
539
|
const startedAt = Date.now();
|
|
534
540
|
|
|
@@ -587,6 +593,7 @@ export const defineApp = <
|
|
|
587
593
|
? { href: meta.pwa.href, requiresRoles: meta.pwa.requiresRoles ? [...meta.pwa.requiresRoles] : undefined }
|
|
588
594
|
: undefined,
|
|
589
595
|
widgets: meta.widgets ? meta.widgets.map((w) => ({ ...w })) : undefined,
|
|
596
|
+
platformPermissions: meta.platformPermissions ? [...meta.platformPermissions] : undefined,
|
|
590
597
|
settingKeys: meta.settingKeys ? [...meta.settingKeys] : undefined,
|
|
591
598
|
openapi: advertiseOpenapi ? opts.openapi : undefined,
|
|
592
599
|
cliModules: cliModules.length > 0 ? cliModules : undefined,
|
|
@@ -1,15 +1,21 @@
|
|
|
1
|
+
import type { PlatformPermission } from "../contracts/outgoing-mail";
|
|
2
|
+
|
|
1
3
|
/** Identity of the application started in this process, derived from its declaration. */
|
|
2
4
|
let applicationId: string | undefined;
|
|
5
|
+
let platformPermissions: readonly PlatformPermission[] = [];
|
|
6
|
+
export const getProcessPlatformPermissions = (): readonly PlatformPermission[] => platformPermissions;
|
|
3
7
|
|
|
4
8
|
export const getProcessApplicationId = (): string | undefined => applicationId;
|
|
5
9
|
|
|
6
|
-
export const bindProcessApplicationId = (id: string): void => {
|
|
10
|
+
export const bindProcessApplicationId = (id: string, permissions: readonly PlatformPermission[] = []): void => {
|
|
7
11
|
if (applicationId !== undefined && applicationId !== id) {
|
|
8
12
|
throw new Error(`This process already hosts application "${applicationId}"`);
|
|
9
13
|
}
|
|
10
14
|
applicationId = id;
|
|
15
|
+
platformPermissions = [...permissions];
|
|
11
16
|
};
|
|
12
17
|
|
|
13
18
|
export const clearProcessApplicationId = (): void => {
|
|
14
19
|
applicationId = undefined;
|
|
20
|
+
platformPermissions = [];
|
|
15
21
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CLOUD_CLI_MODULE_NAME } from "../cli/plugin";
|
|
2
|
+
import { PlatformPermissionSchema } from "../contracts/outgoing-mail";
|
|
2
3
|
import type { AppRegistryEntry } from "../contracts/registry";
|
|
3
4
|
|
|
4
5
|
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -118,6 +119,8 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
|
|
|
118
119
|
return invalid("widgets", "an array of valid widgets");
|
|
119
120
|
}
|
|
120
121
|
}
|
|
122
|
+
if (value.platformPermissions !== undefined && !PlatformPermissionSchema.array().safeParse(value.platformPermissions).success)
|
|
123
|
+
return invalid("platformPermissions", "an array of platform permissions");
|
|
121
124
|
if (value.settingKeys !== undefined && !isStringArray(value.settingKeys)) return invalid("settingKeys", "an array of strings");
|
|
122
125
|
if (
|
|
123
126
|
value.cliModules !== undefined &&
|
|
@@ -15,6 +15,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
|
|
|
15
15
|
const searchTags = searchQueries?.flatMap((query) => query.universalSearch!.tags.flatMap((tag) => [tag.tag, ...(tag.aliases ?? [])]));
|
|
16
16
|
return {
|
|
17
17
|
id: e.id,
|
|
18
|
+
platformPermissions: e.platformPermissions ? [...e.platformPermissions] : undefined,
|
|
18
19
|
name: e.name,
|
|
19
20
|
icon: e.icon,
|
|
20
21
|
description: e.description,
|
package/src/ai/admin.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { quotaAdminConfig, quotaReport } from "./quota-report";
|
|
2
2
|
export { AiQuotaError, aiQuotas } from "./quotas";
|
|
3
|
+
export { listAiRequestHeaderNames } from "./request-headers";
|
|
3
4
|
/** Server-only AI administration and usage accounting. */
|
|
4
5
|
|
|
5
6
|
export { type AiModelAccessDraft, type AiModelAccessMap, type AiModelAccessState, aiModelAccess } from "./model-access";
|
|
@@ -23,7 +23,7 @@ export const CodeRunInput = z
|
|
|
23
23
|
resourceId: id
|
|
24
24
|
.optional()
|
|
25
25
|
.describe(
|
|
26
|
-
"Optional data context for one-off code. Requires Manage; uses this resource database and shared files/KV without changing its source.
|
|
26
|
+
"Optional data context for one-off code. Requires Manage; uses this resource database and shared files/KV without changing its source. Test runs use real shared and personal data; writes and capability effects keep normal permissions and approvals.",
|
|
27
27
|
),
|
|
28
28
|
version: z
|
|
29
29
|
.number()
|
|
@@ -71,7 +71,10 @@ export const CodeActionInput = z
|
|
|
71
71
|
.positive()
|
|
72
72
|
.optional()
|
|
73
73
|
.describe("Exact draft revision returned by code_actions({draft:true}); Manage required. Supply this OR publishedVersion."),
|
|
74
|
-
input: z
|
|
74
|
+
input: z
|
|
75
|
+
.record(z.string(), z.json())
|
|
76
|
+
.default({})
|
|
77
|
+
.describe("Action input object matching the discovered inputSchema. Pass the object itself, never JSON text."),
|
|
75
78
|
})
|
|
76
79
|
.strict()
|
|
77
80
|
.refine(
|
|
@@ -162,6 +165,15 @@ export const CodeSecretInput = z
|
|
|
162
165
|
})
|
|
163
166
|
.strict();
|
|
164
167
|
|
|
168
|
+
/**
|
|
169
|
+
* A runtime call that did not complete: rejected input, timeout, lost run or host failure.
|
|
170
|
+
* Hosts return it as data because a client tool result carries no error flag; the managed
|
|
171
|
+
* server tools report it to the model as a tool error. A run snapshot whose code failed is a
|
|
172
|
+
* completed call and keeps its own `status` and `error`.
|
|
173
|
+
*/
|
|
174
|
+
export const CodeToolFailure = z.object({ failed: z.literal(true), error: z.string(), guidance: z.string().optional() });
|
|
175
|
+
export type CodeToolFailure = z.infer<typeof CodeToolFailure>;
|
|
176
|
+
|
|
165
177
|
// Internal bridge envelope. Each model-facing tool receives only its own flat schema.
|
|
166
178
|
export const CodeRuntimeInput = z.discriminatedUnion("operation", [
|
|
167
179
|
CodeRunInput.safeExtend({ operation: z.literal("run") }),
|
package/src/ai/browser.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
CODE_RUNTIME_TOOL_NAMES,
|
|
3
|
+
CodeActionInput,
|
|
4
|
+
CodeResourceId,
|
|
5
|
+
CodeRuntimeInput,
|
|
6
|
+
CodeToolFailure,
|
|
7
|
+
parseCodeToolInput,
|
|
8
|
+
} from "./browser-code-contracts";
|
|
2
9
|
export { type AiTaskRequest, type AiTaskRequestInput, AiTaskRequestSchema } from "./task-contracts";
|
|
3
10
|
export { type AiTodoPlan, parseAiTodoPlan } from "./todo-contracts";
|
|
4
11
|
|
package/src/ai/capabilities.ts
CHANGED
|
@@ -72,6 +72,16 @@ type AiToolCatalogEntry = AiToolCatalogItem & {
|
|
|
72
72
|
|
|
73
73
|
export type AiRememberableCapabilityApprovals = ReadonlyMap<string, string>;
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Why `load_tools` could not make a requested name callable:
|
|
77
|
+
* - `unknown`: no tool has this exact name;
|
|
78
|
+
* - `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 excludes it;
|
|
80
|
+
* - `app_offline`: the app operation is not in the live registry now.
|
|
81
|
+
*/
|
|
82
|
+
export const AI_TOOL_UNAVAILABLE_REASONS = ["unknown", "not_offered_in_turn", "not_allowed", "app_offline"] as const;
|
|
83
|
+
export type AiToolUnavailableReason = (typeof AI_TOOL_UNAVAILABLE_REASONS)[number];
|
|
84
|
+
|
|
75
85
|
const DEFAULT_SEARCH_LIMIT = 10;
|
|
76
86
|
const DEFAULT_APP_LIST_LIMIT = 20;
|
|
77
87
|
const MAX_APP_LIST_LIMIT = 25;
|
|
@@ -367,15 +377,16 @@ export const createAiHelpTools = (help: HelpReaderFactory = createHelpReader, lo
|
|
|
367
377
|
return [search, read];
|
|
368
378
|
};
|
|
369
379
|
|
|
380
|
+
/** `null` when the registry could not be read. */
|
|
370
381
|
const resolveCapabilityRegistry = async (
|
|
371
382
|
listRegistry: () => Promise<CapabilityRegistryEntry[]>,
|
|
372
383
|
onError?: (error: unknown) => void,
|
|
373
|
-
): Promise<CapabilityRegistryEntry[]> => {
|
|
384
|
+
): Promise<CapabilityRegistryEntry[] | null> => {
|
|
374
385
|
try {
|
|
375
386
|
return await listRegistry();
|
|
376
387
|
} catch (error) {
|
|
377
388
|
onError?.(error);
|
|
378
|
-
return
|
|
389
|
+
return null;
|
|
379
390
|
}
|
|
380
391
|
};
|
|
381
392
|
|
|
@@ -453,6 +464,9 @@ const ToolCatalogItemSchema = z
|
|
|
453
464
|
|
|
454
465
|
type ToolStateStore = Pick<AiConversationService, "loadTools">;
|
|
455
466
|
|
|
467
|
+
/** `call` is the name to call the tool by; app operations get a provider-safe name. */
|
|
468
|
+
const LoadedToolSchema = z.object({ name: z.string(), call: z.string() }).strict();
|
|
469
|
+
|
|
456
470
|
export const createAiToolMetaTools = (input: {
|
|
457
471
|
apps: readonly CapabilityRegistryEntry[];
|
|
458
472
|
catalog: readonly AiToolCatalogEntry[];
|
|
@@ -461,6 +475,10 @@ export const createAiToolMetaTools = (input: {
|
|
|
461
475
|
store: ToolStateStore;
|
|
462
476
|
maxLoadedTools?: number;
|
|
463
477
|
unavailableLoadedNames?: readonly string[];
|
|
478
|
+
/** False when this turn offers no app operations at all. */
|
|
479
|
+
appOperationsOffered?: boolean;
|
|
480
|
+
/** Why a requested name that is not in `catalog` cannot be loaded; defaults to `unknown`. */
|
|
481
|
+
unavailableReason?: (name: string) => AiToolUnavailableReason;
|
|
464
482
|
}): AiRuntimeTool[] => {
|
|
465
483
|
const apps = buildAiCapabilityAppCatalog(input.apps);
|
|
466
484
|
const directoryEntries: string[] = [];
|
|
@@ -476,17 +494,17 @@ export const createAiToolMetaTools = (input: {
|
|
|
476
494
|
const liveAppDirectory =
|
|
477
495
|
directoryEntries.length > 0
|
|
478
496
|
? ` Live capability apps: ${directoryEntries.join(", ")}${hiddenAppCount > 0 ? `, and ${hiddenAppCount} more` : ""}.`
|
|
479
|
-
:
|
|
497
|
+
: input.appOperationsOffered === false
|
|
498
|
+
? " App operations are not offered in this turn."
|
|
499
|
+
: " 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.";
|
|
480
500
|
const unavailableLoadedNames = input.unavailableLoadedNames ?? [];
|
|
481
501
|
const unavailableLoadedNotice =
|
|
482
502
|
unavailableLoadedNames.length > 0
|
|
483
|
-
? ` Previously loaded tools
|
|
484
|
-
.slice(-MAX_UNAVAILABLE_LOADED_NAMES)
|
|
485
|
-
.join(", ")}${
|
|
503
|
+
? ` Previously loaded tools that are not available now: ${unavailableLoadedNames.slice(-MAX_UNAVAILABLE_LOADED_NAMES).join(", ")}${
|
|
486
504
|
unavailableLoadedNames.length > MAX_UNAVAILABLE_LOADED_NAMES
|
|
487
505
|
? `, and ${unavailableLoadedNames.length - MAX_UNAVAILABLE_LOADED_NAMES} more`
|
|
488
506
|
: ""
|
|
489
|
-
}.
|
|
507
|
+
}. Do not search for them again in this turn; continue without them or tell the user what is missing.`
|
|
490
508
|
: "";
|
|
491
509
|
const search = defineAiTool({
|
|
492
510
|
name: "search_tools",
|
|
@@ -524,38 +542,52 @@ export const createAiToolMetaTools = (input: {
|
|
|
524
542
|
const load = defineAiTool({
|
|
525
543
|
name: "load_tools",
|
|
526
544
|
description:
|
|
527
|
-
"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.",
|
|
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 excludes 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.",
|
|
528
546
|
inputSchema: z.object({ names: z.array(z.string().trim().min(1)).min(1).max(25) }).strict(),
|
|
529
547
|
outputSchema: z
|
|
530
548
|
.object({
|
|
531
|
-
loaded: z.array(
|
|
532
|
-
alreadyLoaded: z.array(
|
|
533
|
-
|
|
549
|
+
loaded: z.array(LoadedToolSchema),
|
|
550
|
+
alreadyLoaded: z.array(LoadedToolSchema),
|
|
551
|
+
unavailable: z.array(z.object({ name: z.string(), reason: z.enum(AI_TOOL_UNAVAILABLE_REASONS) }).strict()),
|
|
534
552
|
evicted: z.array(z.string()),
|
|
535
553
|
titles: z.record(z.string(), z.string()),
|
|
536
554
|
})
|
|
537
555
|
.strict(),
|
|
538
556
|
approval: "never",
|
|
539
557
|
}).server(async ({ names }) => {
|
|
540
|
-
const
|
|
558
|
+
const catalogByName = new Map(input.catalog.map((entry) => [entry.name, entry]));
|
|
559
|
+
// A provider name the model saw in an earlier call loads its operation, too.
|
|
560
|
+
const byProviderName = new Map(input.catalog.flatMap((entry) => (entry.capability ? [[entry.capability.providerName, entry]] : [])));
|
|
541
561
|
const requested = [...new Set(names)];
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
562
|
+
const found = requested.flatMap((name) => {
|
|
563
|
+
const entry = catalogByName.get(name) ?? byProviderName.get(name);
|
|
564
|
+
return entry ? [entry.name] : [];
|
|
565
|
+
});
|
|
566
|
+
const eager = [...new Set(found.filter((name) => input.eagerNames.has(name)))];
|
|
567
|
+
const valid = [...new Set(found.filter((name) => !input.eagerNames.has(name)))];
|
|
568
|
+
const unavailable = requested
|
|
569
|
+
.filter((name) => !catalogByName.has(name) && !byProviderName.has(name))
|
|
570
|
+
.map((name) => ({ name, reason: input.unavailableReason?.(name) ?? ("unknown" as const) }));
|
|
545
571
|
const updated = await input.store.loadTools({
|
|
546
572
|
conversationId: input.conversationId,
|
|
547
573
|
names: valid,
|
|
548
574
|
maxLoadedTools: input.maxLoadedTools,
|
|
549
575
|
});
|
|
576
|
+
const callable = (name: string) => ({ name, call: catalogByName.get(name)?.capability?.providerName ?? name });
|
|
550
577
|
const alreadyLoaded = [...new Set([...eager, ...updated.alreadyLoaded])];
|
|
551
|
-
const catalogByName = new Map(input.catalog.map((entry) => [entry.name, entry]));
|
|
552
578
|
const titles = Object.fromEntries(
|
|
553
579
|
[...new Set([...updated.loaded, ...alreadyLoaded, ...updated.evicted])].flatMap((name) => {
|
|
554
580
|
const entry = catalogByName.get(name);
|
|
555
581
|
return entry ? [[name, entry.title]] : [];
|
|
556
582
|
}),
|
|
557
583
|
);
|
|
558
|
-
return {
|
|
584
|
+
return {
|
|
585
|
+
loaded: updated.loaded.map(callable),
|
|
586
|
+
alreadyLoaded: alreadyLoaded.map(callable),
|
|
587
|
+
unavailable,
|
|
588
|
+
evicted: updated.evicted,
|
|
589
|
+
titles,
|
|
590
|
+
};
|
|
559
591
|
});
|
|
560
592
|
|
|
561
593
|
return [search, load, listApps];
|
|
@@ -657,6 +689,8 @@ export const createAiToolResolver =
|
|
|
657
689
|
actor: RequestActor;
|
|
658
690
|
staticTools: AiRuntimeTool[];
|
|
659
691
|
allowedTools?: readonly string[] | null;
|
|
692
|
+
/** 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
|
+
unofferedTools?: readonly string[];
|
|
660
694
|
runtimeContext?: Omit<AiToolPreparationContext, "actor" | "conversationId">;
|
|
661
695
|
store: Pick<AiConversationService, "getLoadedTools" | "loadTools">;
|
|
662
696
|
listRegistry?: () => Promise<CapabilityRegistryEntry[]>;
|
|
@@ -675,10 +709,11 @@ export const createAiToolResolver =
|
|
|
675
709
|
}) => void;
|
|
676
710
|
}): ToolResolver =>
|
|
677
711
|
async (): Promise<Tool[]> => {
|
|
678
|
-
const [
|
|
712
|
+
const [liveRegistry, persistedLoadedNames] = await Promise.all([
|
|
679
713
|
input.listRegistry ? resolveCapabilityRegistry(input.listRegistry, input.onCapabilityRegistryError) : [],
|
|
680
714
|
input.store.getLoadedTools({ conversationId: input.conversationId }),
|
|
681
715
|
]);
|
|
716
|
+
const registry = liveRegistry ?? [];
|
|
682
717
|
const configuredLimit = Math.floor(input.maxLoadedTools ?? 0);
|
|
683
718
|
const loadedNames = configuredLimit > 0 ? persistedLoadedNames.slice(-configuredLimit) : persistedLoadedNames;
|
|
684
719
|
if (loadedNames.length !== persistedLoadedNames.length) {
|
|
@@ -689,18 +724,41 @@ export const createAiToolResolver =
|
|
|
689
724
|
});
|
|
690
725
|
}
|
|
691
726
|
const allowed = input.allowedTools == null ? null : new Set(input.allowedTools);
|
|
692
|
-
const
|
|
727
|
+
const fullCapabilityCatalog = buildAiCapabilityCatalog(registry, input.locale);
|
|
728
|
+
const capabilityCatalog = fullCapabilityCatalog.filter((entry) => !allowed || allowed.has(entry.name));
|
|
693
729
|
const helpTools = input.help ? createAiHelpTools(input.help, input.locale) : [];
|
|
694
730
|
const resourceTool =
|
|
695
731
|
input.execute && (capabilityCatalog.length > 0 || input.staticTools.some((tool) => tool.def.name === "code_read"))
|
|
696
732
|
? createAiResourceReaderTool({ apps: registry, catalog: capabilityCatalog, execute: input.execute })
|
|
697
733
|
: null;
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
);
|
|
734
|
+
const allBuiltIns = [...input.staticTools, ...helpTools, ...(resourceTool ? [resourceTool] : [])];
|
|
735
|
+
const builtIns = allBuiltIns.filter((tool) => !allowed || allowed.has(tool.def.name));
|
|
701
736
|
const catalog = buildAiToolCatalog(builtIns, capabilityCatalog);
|
|
702
737
|
const catalogNames = new Set(catalog.map((entry) => entry.name));
|
|
703
738
|
const unavailableLoadedNames = loadedNames.filter((name) => !catalogNames.has(name));
|
|
739
|
+
const outOfScope = new Set([
|
|
740
|
+
...allBuiltIns.map((tool) => tool.def.name),
|
|
741
|
+
...fullCapabilityCatalog.flatMap((entry) => [entry.name, entry.providerName]),
|
|
742
|
+
...(input.unofferedTools ?? []),
|
|
743
|
+
]);
|
|
744
|
+
const unoffered = new Set(input.unofferedTools);
|
|
745
|
+
// A model may name a loaded operation by the provider name it called it by; app IDs contain no dot.
|
|
746
|
+
const loadedByProviderName = new Map(
|
|
747
|
+
persistedLoadedNames.flatMap((id) => {
|
|
748
|
+
const dot = id.indexOf(".");
|
|
749
|
+
if (dot < 1) return [];
|
|
750
|
+
return (["query", "action"] as const).map((kind) => [aiCapabilityToolName(id.slice(0, dot), kind, id.slice(dot + 1)), id] as const);
|
|
751
|
+
}),
|
|
752
|
+
);
|
|
753
|
+
// App operation IDs always contain a dot; built-in names never do.
|
|
754
|
+
const unavailableReason = (requested: string): AiToolUnavailableReason => {
|
|
755
|
+
const name = loadedByProviderName.get(requested) ?? requested;
|
|
756
|
+
if (allowed && outOfScope.has(name) && !allowed.has(name)) return "not_allowed";
|
|
757
|
+
if (unoffered.has(name)) return "not_offered_in_turn";
|
|
758
|
+
if (!name.includes(".")) return "unknown";
|
|
759
|
+
if (!input.listRegistry) return "not_offered_in_turn";
|
|
760
|
+
return liveRegistry === null || persistedLoadedNames.includes(name) ? "app_offline" : "unknown";
|
|
761
|
+
};
|
|
704
762
|
const eagerNames = new Set(builtIns.map((tool) => tool.def.name).filter((name) => !CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES.has(name)));
|
|
705
763
|
const activeBuiltIns = builtIns.filter((tool) => eagerNames.has(tool.def.name) || loadedNames.includes(tool.def.name));
|
|
706
764
|
const runtimeTools = [
|
|
@@ -712,6 +770,8 @@ export const createAiToolResolver =
|
|
|
712
770
|
store: input.store,
|
|
713
771
|
maxLoadedTools: input.maxLoadedTools,
|
|
714
772
|
unavailableLoadedNames,
|
|
773
|
+
appOperationsOffered: Boolean(input.listRegistry),
|
|
774
|
+
unavailableReason,
|
|
715
775
|
}),
|
|
716
776
|
...activeBuiltIns,
|
|
717
777
|
...(input.execute
|
|
@@ -145,6 +145,12 @@ function ListAppsView(props: { block: ToolBlock }) {
|
|
|
145
145
|
);
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/** Entries are `{name}` records; turns stored before #528 hold plain names and a `missing` list. */
|
|
149
|
+
const toolNames = (value: unknown): string[] =>
|
|
150
|
+
Array.isArray(value)
|
|
151
|
+
? value.flatMap((item) => (typeof item === "string" ? [item] : isRecord(item) && typeof item.name === "string" ? [item.name] : []))
|
|
152
|
+
: [];
|
|
153
|
+
|
|
148
154
|
function LoadToolsView(props: { block: ToolBlock }) {
|
|
149
155
|
const result = () => (isRecord(props.block.result) ? props.block.result : {});
|
|
150
156
|
const titles = (): Record<string, unknown> => {
|
|
@@ -152,31 +158,33 @@ function LoadToolsView(props: { block: ToolBlock }) {
|
|
|
152
158
|
return isRecord(value) ? value : {};
|
|
153
159
|
};
|
|
154
160
|
const t = useMessages();
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
161
|
+
const unavailableLabel = (reason: unknown) =>
|
|
162
|
+
reason === "not_offered_in_turn"
|
|
163
|
+
? t().toolNotOffered
|
|
164
|
+
: reason === "not_allowed"
|
|
165
|
+
? t().toolNotAllowed
|
|
166
|
+
: reason === "app_offline"
|
|
167
|
+
? t().toolAppOffline
|
|
168
|
+
: t().toolUnknown;
|
|
169
|
+
const rows = () => [
|
|
170
|
+
...toolNames(result().loaded).map((name) => ({ name, label: t().toolsLoaded })),
|
|
171
|
+
...toolNames(result().alreadyLoaded).map((name) => ({ name, label: t().toolsAlreadyLoaded })),
|
|
172
|
+
...records(result().unavailable).flatMap((item) =>
|
|
173
|
+
typeof item.name === "string" ? [{ name: item.name, label: unavailableLabel(item.reason) }] : [],
|
|
174
|
+
),
|
|
175
|
+
...toolNames(result().missing).map((name) => ({ name, label: t().toolUnknown })),
|
|
176
|
+
...toolNames(result().evicted).map((name) => ({ name, label: t().toolsEvicted })),
|
|
177
|
+
];
|
|
170
178
|
return (
|
|
171
|
-
<CompletedActivity
|
|
172
|
-
|
|
179
|
+
<CompletedActivity
|
|
180
|
+
block={props.block}
|
|
181
|
+
label={t().loadTools}
|
|
182
|
+
description={t().loadedCount({ count: toolNames(result().loaded).length })}
|
|
183
|
+
>
|
|
184
|
+
<Show when={rows().length > 0}>
|
|
173
185
|
<ResultList>
|
|
174
|
-
<For each={
|
|
175
|
-
{(
|
|
176
|
-
<For each={group.names}>
|
|
177
|
-
{(name) => <ResultRow icon={aiToolIcon(name)} title={text(titles()[name]) || name} meta={group.label} />}
|
|
178
|
-
</For>
|
|
179
|
-
)}
|
|
186
|
+
<For each={rows()}>
|
|
187
|
+
{(row) => <ResultRow icon={aiToolIcon(row.name)} title={text(titles()[row.name]) || row.name} meta={row.label} />}
|
|
180
188
|
</For>
|
|
181
189
|
</ResultList>
|
|
182
190
|
</Show>
|
|
@@ -40,7 +40,10 @@ export function PresentToolBlock(props: { block: ToolBlock }) {
|
|
|
40
40
|
</Show>
|
|
41
41
|
}
|
|
42
42
|
>
|
|
43
|
-
<div
|
|
43
|
+
<div
|
|
44
|
+
data-call-id={props.block.callId}
|
|
45
|
+
class="inline-flex min-h-7 max-w-full items-center gap-1.5 py-1 text-xs leading-none text-dimmed"
|
|
46
|
+
>
|
|
44
47
|
<i class={`ti ${icon()} shrink-0 text-base leading-none`} aria-hidden="true" />
|
|
45
48
|
<Show
|
|
46
49
|
when={actions.onOpenFile}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Chat } from "@k2b/ui";
|
|
2
|
-
import { createMemo } from "solid-js";
|
|
2
|
+
import { createMemo, createSignal } from "solid-js";
|
|
3
3
|
import { createStore, reconcile } from "solid-js/store";
|
|
4
4
|
import { render } from "solid-js/web";
|
|
5
5
|
import { emptyProjection, reduceProjection, visibleMessages } from "../client/projection";
|
|
@@ -8,11 +8,14 @@ import { AiChatActionsProvider, createAiChatTimeline } from "./presentation";
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* The live chat timeline for a browser test, folded from wire events like the
|
|
11
|
-
* controller does. `window.emit(event)` applies one event,
|
|
12
|
-
* `window.steer(text)` appends a pending steer as the controller does
|
|
11
|
+
* controller does. `window.emit(event)` applies one event,
|
|
12
|
+
* `window.steer(text)` appends a pending steer as the controller does, and
|
|
13
|
+
* `window.reconnect(on)` sets whether the stream reconnects.
|
|
13
14
|
*/
|
|
14
15
|
const [state, setState] = createStore(emptyProjection());
|
|
16
|
+
const [reconnecting, setReconnecting] = createSignal(false);
|
|
15
17
|
Object.assign(window, {
|
|
18
|
+
reconnect: (on: boolean) => setReconnecting(on),
|
|
16
19
|
emit: (event: AiWireEvent) => setState(reconcile(reduceProjection(state, event), { key: "id", merge: true })),
|
|
17
20
|
steer: (text: string) =>
|
|
18
21
|
setState("activeTurn", "blocks", (blocks): AiTurnBlock[] => [
|
|
@@ -28,7 +31,11 @@ render(
|
|
|
28
31
|
() => (
|
|
29
32
|
<AiChatActionsProvider actions={{}}>
|
|
30
33
|
{(() => {
|
|
31
|
-
const items = createAiChatTimeline({
|
|
34
|
+
const items = createAiChatTimeline({
|
|
35
|
+
messages: createMemo(() => visibleMessages(state)),
|
|
36
|
+
activeTurn: () => state.activeTurn,
|
|
37
|
+
reconnecting,
|
|
38
|
+
});
|
|
32
39
|
return <Chat.Timeline items={items()} />;
|
|
33
40
|
})()}
|
|
34
41
|
</AiChatActionsProvider>
|
package/src/ai/chat/messages.ts
CHANGED
|
@@ -160,7 +160,10 @@ const messages = i18n.define({
|
|
|
160
160
|
loadedCount: ({ count }: { count: number }) => `${count} loaded`,
|
|
161
161
|
toolsLoaded: "Loaded",
|
|
162
162
|
toolsAlreadyLoaded: "Already loaded",
|
|
163
|
-
|
|
163
|
+
toolUnknown: "Not found",
|
|
164
|
+
toolNotOffered: "Not available here",
|
|
165
|
+
toolNotAllowed: "Not allowed",
|
|
166
|
+
toolAppOffline: "App not reachable",
|
|
164
167
|
toolsEvicted: "Evicted",
|
|
165
168
|
loadedSkill: ({ name }: { name: string }) => `Loaded skill ${name}`,
|
|
166
169
|
help: "Help",
|
|
@@ -347,7 +350,10 @@ const messages = i18n.define({
|
|
|
347
350
|
loadedCount: ({ count }) => `${count} geladen`,
|
|
348
351
|
toolsLoaded: "Geladen",
|
|
349
352
|
toolsAlreadyLoaded: "Bereits geladen",
|
|
350
|
-
|
|
353
|
+
toolUnknown: "Nicht gefunden",
|
|
354
|
+
toolNotOffered: "Hier nicht verfügbar",
|
|
355
|
+
toolNotAllowed: "Nicht erlaubt",
|
|
356
|
+
toolAppOffline: "App nicht erreichbar",
|
|
351
357
|
toolsEvicted: "Entladen",
|
|
352
358
|
loadedSkill: ({ name }) => `Skill ${name} geladen`,
|
|
353
359
|
help: "Hilfe",
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
Show,
|
|
14
14
|
} from "solid-js";
|
|
15
15
|
import type { AiActiveTurn } from "../client/projection";
|
|
16
|
-
import { type AiActiveTurnSegment, splitActiveTurnBlocks } from "../protocol";
|
|
16
|
+
import { type AiActiveTurnSegment, type AiTurnBlock, splitActiveTurnBlocks } from "../protocol";
|
|
17
17
|
import { type AiAssistantTimelineItem, buildAiMessageTimeline } from "../timeline";
|
|
18
18
|
import type { AiConversationTimelineEntry, AiStoredMessage } from "../types";
|
|
19
19
|
import { type AiChatActions, AiChatActionsProvider, createAssistantMessageActions, useAiChatActions } from "./message-actions";
|
|
@@ -299,6 +299,7 @@ const activeItems = (
|
|
|
299
299
|
actions: AiChatActions,
|
|
300
300
|
views: TurnViews,
|
|
301
301
|
duration: Accessor<AiTurnDuration | null>,
|
|
302
|
+
reconnecting: boolean,
|
|
302
303
|
): ChatTimelineItem[] => {
|
|
303
304
|
if (!turn) return [];
|
|
304
305
|
const segments = splitActiveTurnBlocks(turn.blocks).flatMap((segment): (AiActiveTurnSegment | SurveySegment)[] =>
|
|
@@ -314,6 +315,14 @@ const activeItems = (
|
|
|
314
315
|
// Until the model takes up the steering message, the segment above it is still the one at work.
|
|
315
316
|
const above = steerTail ? segments.findLastIndex((segment, index) => segment.type === "assistant" && index < lastAssistant) : -1;
|
|
316
317
|
const working = above >= 0 ? above : lastAssistant;
|
|
318
|
+
const phaseOf = (index: number): AiTurnPhase =>
|
|
319
|
+
index !== working ? "completed" : turn.status === "waiting_for_action" ? "waiting" : "running";
|
|
320
|
+
const layoutOf = (blocks: AiTurnBlock[], index: number) =>
|
|
321
|
+
layoutAiTurn(blocks, { phase: phaseOf(index), codePresentations: Boolean(actions.renderCodePresentation) });
|
|
322
|
+
// The work line says in place that the turn reconnects. A turn without one yet says it in a row at its end, where a
|
|
323
|
+
// row that comes and goes moves nothing above it.
|
|
324
|
+
const workingSegment = segments[working];
|
|
325
|
+
const reconnectInLine = reconnecting && workingSegment?.type === "assistant" && layoutOf(workingSegment.blocks, working).showWork;
|
|
317
326
|
const segmentId = createSegmentIds();
|
|
318
327
|
let opener = "start";
|
|
319
328
|
|
|
@@ -339,8 +348,8 @@ const activeItems = (
|
|
|
339
348
|
|
|
340
349
|
const last = index === lastAssistant;
|
|
341
350
|
const live = index === working;
|
|
342
|
-
const phase
|
|
343
|
-
const layout =
|
|
351
|
+
const phase = phaseOf(index);
|
|
352
|
+
const layout = layoutOf(segment.blocks, index);
|
|
344
353
|
// Consecutive steering messages leave empty segments between them; history has none. The last segment stays, since
|
|
345
354
|
// it carries the progress indicator.
|
|
346
355
|
if (!last && isEmptyLayout(layout)) return [];
|
|
@@ -358,7 +367,7 @@ const activeItems = (
|
|
|
358
367
|
layout,
|
|
359
368
|
earlier: !live,
|
|
360
369
|
duration: live ? duration : () => null,
|
|
361
|
-
|
|
370
|
+
reconnect: live && reconnectInLine ? "line" : last && reconnecting && !reconnectInLine ? "row" : undefined,
|
|
362
371
|
}),
|
|
363
372
|
};
|
|
364
373
|
});
|
|
@@ -367,9 +376,10 @@ const activeItems = (
|
|
|
367
376
|
/**
|
|
368
377
|
* Work time of the active turn: wall time minus time spent waiting for the user. A state snapshot seeds it after a
|
|
369
378
|
* reconnect; the clock stands while the turn waits for an approval or an answer and continues from the same value
|
|
370
|
-
* afterwards.
|
|
379
|
+
* afterwards. While `paused`, the shown time stands too and then jumps to the true work time; the clock itself keeps
|
|
380
|
+
* running, so a step that starts right after the pause is timed from the true work time.
|
|
371
381
|
*/
|
|
372
|
-
const createActiveTurnClock = (source: AiChatTimelineSource): Accessor<AiTurnDuration | null> => {
|
|
382
|
+
const createActiveTurnClock = (source: AiChatTimelineSource, paused: Accessor<boolean>): Accessor<AiTurnDuration | null> => {
|
|
373
383
|
const [now, setNow] = createSignal(Date.now());
|
|
374
384
|
const running = createMemo(() => source.activeTurn() !== null);
|
|
375
385
|
createEffect(() => {
|
|
@@ -379,7 +389,7 @@ const createActiveTurnClock = (source: AiChatTimelineSource): Accessor<AiTurnDur
|
|
|
379
389
|
onCleanup(() => window.clearInterval(timer));
|
|
380
390
|
});
|
|
381
391
|
let state: { turnId: string; seed: string; startedAt: number; waitMs: number; waitingSince: number | null } | null = null;
|
|
382
|
-
|
|
392
|
+
const duration = createMemo(() => {
|
|
383
393
|
const turn = source.activeTurn();
|
|
384
394
|
const time = now();
|
|
385
395
|
if (!turn) {
|
|
@@ -411,11 +421,19 @@ const createActiveTurnClock = (source: AiChatTimelineSource): Accessor<AiTurnDur
|
|
|
411
421
|
waitingMs: state.waitingSince === null ? null : Math.max(0, time - state.waitingSince),
|
|
412
422
|
};
|
|
413
423
|
});
|
|
424
|
+
let shown: AiTurnDuration | null = null;
|
|
425
|
+
return createMemo(() => {
|
|
426
|
+
const current = duration();
|
|
427
|
+
if (!paused() || !shown || !current) shown = current;
|
|
428
|
+
return shown;
|
|
429
|
+
});
|
|
414
430
|
};
|
|
415
431
|
|
|
416
432
|
export type AiChatTimelineSource = {
|
|
417
433
|
messages: Accessor<readonly AiStoredMessage[]>;
|
|
418
434
|
activeTurn: Accessor<AiActiveTurn | null>;
|
|
435
|
+
/** The conversation stream lost its connection and reconnects, such as `streamStatus() === "reconnecting"`. */
|
|
436
|
+
reconnecting?: Accessor<boolean>;
|
|
419
437
|
};
|
|
420
438
|
|
|
421
439
|
/**
|
|
@@ -428,9 +446,11 @@ export function createAiChatTimeline(source: AiChatTimelineSource): Accessor<rea
|
|
|
428
446
|
const locale = useLocale();
|
|
429
447
|
const disclosureState = createAiToolDisclosureState();
|
|
430
448
|
const views = createTurnViews(disclosureState);
|
|
431
|
-
|
|
449
|
+
// While the stream reconnects or a model call waits for its retry, the live turn says so and its clock stands.
|
|
450
|
+
const reconnecting = createMemo(() => source.activeTurn()?.providerRetry === true || source.reconnecting?.() === true);
|
|
451
|
+
const clock = createActiveTurnClock(source, reconnecting);
|
|
432
452
|
const stored = createMemo(() => storedItems(source.messages(), actions, views, locale()));
|
|
433
|
-
const active = createMemo(() => activeItems(source.activeTurn(), actions, views, clock));
|
|
453
|
+
const active = createMemo(() => activeItems(source.activeTurn(), actions, views, clock, reconnecting()));
|
|
434
454
|
return createMemo(() => {
|
|
435
455
|
const items = [...stored(), ...active()];
|
|
436
456
|
views.retain(new Set(items.map((item) => item.id)));
|