@k2b/cloud 0.29.1 → 0.30.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 -2
- package/src/_internal/app-roles.ts +14 -0
- package/src/_internal/capabilities.ts +111 -3
- package/src/_internal/capability-sentences.ts +413 -0
- package/src/_internal/define-app.ts +10 -1
- package/src/_internal/registry-validation.ts +5 -0
- package/src/access/PermissionEditor.tsx +43 -24
- package/src/ai/app-skill-store.ts +298 -0
- package/src/ai/app-skills.ts +105 -0
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +68 -9
- package/src/ai/chart-block.ts +380 -0
- package/src/ai/chat/action-sentences.ts +49 -0
- package/src/ai/chat/blocks.tsx +45 -9
- package/src/ai/chat/chart-block.tsx +79 -0
- package/src/ai/chat/message-utils.ts +3 -0
- package/src/ai/chat/messages.ts +8 -2
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +2 -2
- package/src/ai/chat/turn-view.tsx +29 -11
- package/src/ai/code-runtime-tools.ts +59 -44
- package/src/ai/default-tool-contracts.ts +198 -0
- package/src/ai/default-tools.ts +25 -1
- package/src/ai/executor.ts +19 -21
- package/src/ai/index.ts +9 -1
- package/src/ai/migrate.ts +59 -12
- package/src/ai/runtime-tools.ts +1 -0
- package/src/ai/skill-content.ts +54 -0
- package/src/ai/skills-routes.ts +22 -13
- package/src/ai/skills.ts +177 -231
- package/src/ai/tool-sentences.ts +38 -0
- package/src/ai/types.ts +8 -1
- package/src/api/admin-ai-skills.ts +35 -16
- package/src/api/fanout.ts +128 -0
- package/src/api/help.ts +8 -3
- package/src/api/mcp.ts +4 -1
- package/src/api/search.ts +31 -106
- package/src/api/widgets.ts +259 -100
- package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
- package/src/browser/ndjson.ts +34 -0
- package/src/browser/reload.ts +46 -0
- package/src/browser/search-stream.ts +4 -26
- package/src/browser/widgets.ts +28 -0
- package/src/capabilities/server.ts +20 -2
- package/src/cli/access.ts +48 -14
- package/src/cli/admin/ai-skills.ts +59 -39
- package/src/contracts/announcement-cookie.ts +62 -0
- package/src/contracts/announcements.ts +14 -52
- package/src/contracts/capabilities.ts +24 -0
- package/src/contracts/pwa-paths.ts +7 -2
- package/src/contracts/pwa.ts +2 -7
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +20 -0
- package/src/index.ts +1 -0
- package/src/server/help.ts +8 -1
- package/src/services/accounts/users.ts +15 -0
- package/src/services/help/index.ts +18 -5
- package/src/services/help/types.ts +3 -2
- package/src/services/identity/constants.ts +2 -0
- package/src/services/identity/key-ring.ts +123 -42
- package/src/services/identity/runtime-config.ts +13 -2
- package/src/ssr/AppLaunchpadPanel.tsx +12 -3
- package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
- package/src/ssr/GlobalSearchDialog.tsx +1 -43
- package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
- package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/app-navigation.ts +5 -7
- package/src/ssr/global-search-host.tsx +55 -0
- package/src/ssr/help.ts +3 -3
- package/src/ssr/rail-context.ts +4 -3
- package/src/ssr/runtime.ts +14 -1
- package/src/ai/code-mode-skill.ts +0 -114
- package/src/ai/data-analysis-skill.ts +0 -10
- package/src/ai/grids-skill.ts +0 -158
- package/src/ai/skill-seeds.ts +0 -560
package/src/ai/capabilities.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { Tool, ToolContext, ToolResolver } from "@k2b/nessi";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { resolveCapabilityOperationTitle } from "../_internal/capabilities";
|
|
4
|
+
import { resolveCapabilityActionWording, resolveCapabilityOperationTitle } from "../_internal/capabilities";
|
|
5
|
+
import {
|
|
6
|
+
CAPABILITY_APPROVAL_REASON_FIELD,
|
|
7
|
+
CAPABILITY_APPROVAL_REASON_MAX_CHARS,
|
|
8
|
+
type CapabilityActionWording,
|
|
9
|
+
capabilityActionSubject,
|
|
10
|
+
capabilityApprovalReason,
|
|
11
|
+
capabilityApprovalText,
|
|
12
|
+
} from "../_internal/capability-sentences";
|
|
5
13
|
import { HELP_READ_MAX_CHARS, HELP_SEARCH_MAX_LIMIT, readHelpArticle } from "../_internal/help-catalog";
|
|
6
14
|
import {
|
|
7
15
|
type CapabilityActionManifest,
|
|
@@ -13,9 +21,10 @@ import {
|
|
|
13
21
|
} from "../contracts/capabilities";
|
|
14
22
|
import type { CapabilityRegistryEntry } from "../contracts/registry";
|
|
15
23
|
import type { RequestActor } from "../server";
|
|
16
|
-
import {
|
|
24
|
+
import type { HelpReader, HelpReaderFactory } from "../services/help";
|
|
17
25
|
import { type MandatePolicyV1, mandatePolicyCanPermitCapability } from "../services/mandates/policy";
|
|
18
26
|
import { resolveAppIdentityPresentation } from "../shared/app-presentation";
|
|
27
|
+
import { DEFAULT_LOCALE } from "../shared/locale";
|
|
19
28
|
import { recordRejectedAiCapability } from "./capability-execution";
|
|
20
29
|
import { CODE_SOURCE_TOOLS } from "./code-source-contracts";
|
|
21
30
|
import { createCodeSourceTool } from "./code-source-tools";
|
|
@@ -337,9 +346,7 @@ const AiHelpDocumentSchema = AiHelpCatalogItemSchema.extend({
|
|
|
337
346
|
}).strict();
|
|
338
347
|
|
|
339
348
|
/** Search and read published Help without loading one tool per article. */
|
|
340
|
-
export const createAiHelpTools = (
|
|
341
|
-
const reader = help(locale);
|
|
342
|
-
|
|
349
|
+
export const createAiHelpTools = (reader: HelpReader): AiRuntimeTool[] => {
|
|
343
350
|
const search = defineAiTool({
|
|
344
351
|
name: "search_help",
|
|
345
352
|
description:
|
|
@@ -453,6 +460,39 @@ export const reduceAiCapabilityInputSchema = (schema: Record<string, unknown>):
|
|
|
453
460
|
export const aiCapabilityInputSchema = (schema: Record<string, unknown>): z.ZodType =>
|
|
454
461
|
z.fromJSONSchema(reduceAiCapabilityInputSchema(schema));
|
|
455
462
|
|
|
463
|
+
/** How people read calls of one loaded Action in the turn's locale; derived from the live catalog only. */
|
|
464
|
+
export const aiCapabilityActionWording = (entry: AiCapabilityCatalogEntry, locale: string | undefined): CapabilityActionWording =>
|
|
465
|
+
resolveCapabilityActionWording(entry.operation as CapabilityActionManifest, entry.app.presentation, locale ?? DEFAULT_LOCALE);
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* An Action that waits for approval lets the model say why it wants it. The field exists only for the model:
|
|
469
|
+
* Cloud removes it before review, authorization, and execution. An app field of the same name wins.
|
|
470
|
+
*/
|
|
471
|
+
const offersApprovalReason = (entry: AiCapabilityCatalogEntry): boolean => {
|
|
472
|
+
if (entry.kind !== "action" || (entry.operation as CapabilityActionManifest).approval === "none") return false;
|
|
473
|
+
const properties = entry.operation.inputSchema.properties;
|
|
474
|
+
return Boolean(properties && typeof properties === "object" && !Object.hasOwn(properties, CAPABILITY_APPROVAL_REASON_FIELD));
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const withApprovalReason = (schema: Record<string, unknown>): Record<string, unknown> => ({
|
|
478
|
+
...schema,
|
|
479
|
+
properties: {
|
|
480
|
+
...(schema.properties as Record<string, unknown>),
|
|
481
|
+
[CAPABILITY_APPROVAL_REASON_FIELD]: {
|
|
482
|
+
type: "string",
|
|
483
|
+
maxLength: CAPABILITY_APPROVAL_REASON_MAX_CHARS,
|
|
484
|
+
description:
|
|
485
|
+
"Optional: one short sentence in the user's language on why this call is needed now. Shown as your reason beside the approval; never put Action input here.",
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const withoutApprovalReason = (entry: AiCapabilityCatalogEntry, args: unknown): unknown => {
|
|
491
|
+
if (!offersApprovalReason(entry) || !args || typeof args !== "object" || Array.isArray(args)) return args;
|
|
492
|
+
const { [CAPABILITY_APPROVAL_REASON_FIELD]: _reason, ...appArgs } = args as Record<string, unknown>;
|
|
493
|
+
return appArgs;
|
|
494
|
+
};
|
|
495
|
+
|
|
456
496
|
const ToolCatalogItemSchema = z
|
|
457
497
|
.object({
|
|
458
498
|
name: z.string(),
|
|
@@ -600,6 +640,8 @@ export const createLoadedAiCapabilityTools = (input: {
|
|
|
600
640
|
catalog: readonly AiCapabilityCatalogEntry[];
|
|
601
641
|
loadedNames: readonly string[];
|
|
602
642
|
actor: RequestActor;
|
|
643
|
+
/** Locale of the turn; the approval text a person reads is worded in it. */
|
|
644
|
+
locale?: string;
|
|
603
645
|
review?: (entry: AiCapabilityCatalogEntry, args: unknown, context: ToolContext) => Promise<CapabilityActionReview | null>;
|
|
604
646
|
authorizeBackground?: (entry: AiCapabilityCatalogEntry, args: unknown) => Promise<void>;
|
|
605
647
|
onReview?: (callId: string, review: CapabilityActionReview) => void;
|
|
@@ -614,17 +656,28 @@ export const createLoadedAiCapabilityTools = (input: {
|
|
|
614
656
|
name: entry.providerName,
|
|
615
657
|
canonicalName: entry.name,
|
|
616
658
|
description: `${entry.title}. ${entry.description} Never retry ACTION_OUTCOME_UNKNOWN. Do not retry unchanged after INTERNAL or INVALID_APP_RESPONSE; report the provider error.`,
|
|
617
|
-
inputSchema: aiCapabilityInputSchema(
|
|
659
|
+
inputSchema: aiCapabilityInputSchema(
|
|
660
|
+
offersApprovalReason(entry) ? withApprovalReason(entry.operation.inputSchema) : entry.operation.inputSchema,
|
|
661
|
+
),
|
|
618
662
|
outputSchema: z.unknown(),
|
|
619
663
|
// Capability Actions request a custom approval after their optional
|
|
620
664
|
// live review has resolved. The review may supply an app-owned scope.
|
|
621
665
|
approval: "never",
|
|
622
|
-
}).server(async (
|
|
666
|
+
}).server(async (modelArgs, context) => {
|
|
667
|
+
const args = withoutApprovalReason(entry, modelArgs);
|
|
623
668
|
await input.authorizeBackground?.(entry, args);
|
|
624
669
|
if (!input.authorizeBackground && entry.kind === "action" && (entry.operation as CapabilityActionManifest).approval !== "none") {
|
|
625
670
|
const review = (await input.review?.(entry, args, context)) ?? null;
|
|
626
671
|
if (review && context.callId) input.onReview?.(context.callId, review);
|
|
627
|
-
|
|
672
|
+
// Text-only readers such as the CLI read what the card shows: the app's sentence, its review, and the model's reason.
|
|
673
|
+
const locale = input.locale ?? DEFAULT_LOCALE;
|
|
674
|
+
const message = capabilityApprovalText({
|
|
675
|
+
appName: entry.display.appName,
|
|
676
|
+
subject: capabilityActionSubject(aiCapabilityActionWording(entry, locale), args, { locale, timeZone: context.timeZone }),
|
|
677
|
+
review: review?.message,
|
|
678
|
+
reason: offersApprovalReason(entry) ? capabilityApprovalReason(modelArgs) : null,
|
|
679
|
+
locale,
|
|
680
|
+
});
|
|
628
681
|
if (!(await context.requestApproval(message))) {
|
|
629
682
|
if (input.actor.kind === "user") {
|
|
630
683
|
await recordRejectedAiCapability({ entry, actor: input.actor, args }).catch(() => undefined);
|
|
@@ -743,7 +796,8 @@ export const createAiToolResolver =
|
|
|
743
796
|
fullCapabilityCatalog.filter((entry) => !mandateAllows(entry)).flatMap((entry) => [entry.name, entry.providerName]),
|
|
744
797
|
);
|
|
745
798
|
const capabilityCatalog = fullCapabilityCatalog.filter((entry) => (!allowed || allowed.has(entry.name)) && mandateAllows(entry));
|
|
746
|
-
|
|
799
|
+
// The turn's actor decides which apps' Help exists for the model, like every other Help surface.
|
|
800
|
+
const helpTools = input.help ? createAiHelpTools(input.help(input.locale ?? "en", input.actor)) : [];
|
|
747
801
|
const resourceTool =
|
|
748
802
|
input.execute && (capabilityCatalog.length > 0 || input.staticTools.some((tool) => tool.def.name === "code_read"))
|
|
749
803
|
? createAiResourceReaderTool({ apps: registry, catalog: capabilityCatalog, execute: input.execute })
|
|
@@ -805,6 +859,7 @@ export const createAiToolResolver =
|
|
|
805
859
|
catalog: capabilityCatalog,
|
|
806
860
|
loadedNames,
|
|
807
861
|
actor: input.actor,
|
|
862
|
+
locale: input.locale,
|
|
808
863
|
review: input.review,
|
|
809
864
|
onReview: input.onReview,
|
|
810
865
|
authorizeBackground: input.authorizeBackground,
|
|
@@ -824,6 +879,7 @@ export const createAiToolResolver =
|
|
|
824
879
|
for (const name of loadedNames) {
|
|
825
880
|
const entry = catalogByName.get(name);
|
|
826
881
|
if (!entry) continue;
|
|
882
|
+
const wording = entry.kind === "action" ? aiCapabilityActionWording(entry, input.locale) : null;
|
|
827
883
|
presentations.set(entry.providerName, {
|
|
828
884
|
kind: "capability",
|
|
829
885
|
appId: entry.appId,
|
|
@@ -832,6 +888,9 @@ export const createAiToolResolver =
|
|
|
832
888
|
appAccent: entry.app.appAccent,
|
|
833
889
|
title: entry.display.title,
|
|
834
890
|
capabilityKind: entry.kind,
|
|
891
|
+
...(wording?.sentences ? { sentences: wording.sentences } : {}),
|
|
892
|
+
...(wording?.fields?.length ? { fields: [...wording.fields] } : {}),
|
|
893
|
+
...(offersApprovalReason(entry) ? { approvalReason: true as const } : {}),
|
|
835
894
|
});
|
|
836
895
|
// Rememberable scopes are resolved by the owning app for each concrete
|
|
837
896
|
// call and attached when its live review completes.
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { type ChartDatum, charts, computeDomain, i18n, niceStep } from "@k2b/stdlib";
|
|
2
|
+
import type { ChartRenderOptions } from "@k2b/ui";
|
|
3
|
+
import type { CloudAiChartInput } from "./default-tool-contracts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The chart chat block: the plain-data `cloud.chart()` options of the `chart` tool become the options of the shared
|
|
7
|
+
* renderer (`@k2b/ui` Chart and `stdlib.charts`) and a data table with the same rows the chart marks. Pure, so the
|
|
8
|
+
* schema, the browser, and the CLI read a chart the same way.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Marked pure so a bundle that does not read charts, such as a Studio app frame, can leave the labels out.
|
|
12
|
+
const labels = /* @__PURE__ */ i18n.define({
|
|
13
|
+
baseLocale: "en",
|
|
14
|
+
messages: {
|
|
15
|
+
en: {
|
|
16
|
+
category: "Category",
|
|
17
|
+
series: "Series",
|
|
18
|
+
value: "Value",
|
|
19
|
+
share: "Share",
|
|
20
|
+
from: "From",
|
|
21
|
+
to: "To",
|
|
22
|
+
count: "Count",
|
|
23
|
+
min: "Minimum",
|
|
24
|
+
max: "Maximum",
|
|
25
|
+
},
|
|
26
|
+
de: {
|
|
27
|
+
category: "Kategorie",
|
|
28
|
+
series: "Datenreihe",
|
|
29
|
+
value: "Wert",
|
|
30
|
+
share: "Anteil",
|
|
31
|
+
from: "Von",
|
|
32
|
+
to: "Bis",
|
|
33
|
+
count: "Anzahl",
|
|
34
|
+
min: "Minimum",
|
|
35
|
+
max: "Maximum",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const checkCloudAiChartLabels = () => labels.check();
|
|
41
|
+
|
|
42
|
+
/** Logical drawing size of the shared renderer; CSS fits it to the block. */
|
|
43
|
+
const WIDTH = 480;
|
|
44
|
+
const HEIGHT = 280;
|
|
45
|
+
/** Tick labels are 11px IBM Plex Mono, about 6.7px per character. */
|
|
46
|
+
const CHAR = 6.7;
|
|
47
|
+
/**
|
|
48
|
+
* Text keeps its pixel size while the drawing stretches to the chat column. The drawing is never measured or redrawn,
|
|
49
|
+
* so label room is reserved for the narrowest column, about 296px of 480 units on a 320px phone; wide columns show it
|
|
50
|
+
* as a little more space left of the axis.
|
|
51
|
+
*/
|
|
52
|
+
const NARROW = 480 / 296;
|
|
53
|
+
/** The renderer's tick count when an axis sets none. */
|
|
54
|
+
const TICKS = 5;
|
|
55
|
+
/** Below this radius, about 30px on a 320px phone, a pie no longer reads as one. */
|
|
56
|
+
const MIN_PIE_RADIUS = 48;
|
|
57
|
+
|
|
58
|
+
const DATE = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?$/;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A chart date as UTC milliseconds, so calendar days and wall-clock times stay as written on every device. Impossible
|
|
62
|
+
* dates such as February 30 are undefined: engines would move them into March or refuse them, each in its own way.
|
|
63
|
+
*/
|
|
64
|
+
export const chartDateTime = (value: string): number | undefined => {
|
|
65
|
+
const match = DATE.exec(value);
|
|
66
|
+
if (!match) return undefined;
|
|
67
|
+
const [, year, month, day, hour = "00", minute = "00", second = "00"] = match;
|
|
68
|
+
const date = new Date(0);
|
|
69
|
+
date.setUTCFullYear(Number(year), Number(month) - 1, Number(day));
|
|
70
|
+
date.setUTCHours(Number(hour), Number(minute), Number(second));
|
|
71
|
+
// Parts out of range roll over, so a date that does not read back as written does not exist.
|
|
72
|
+
return date.toISOString().startsWith(`${year}-${month}-${day}T${hour}:${minute}:${second}`) ? date.getTime() : undefined;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const dateTime = (value: number | string) => (typeof value === "number" ? value : (chartDateTime(value) ?? Number.NaN));
|
|
76
|
+
|
|
77
|
+
const seriesOf = (input: CloudAiChartInput) => (input.kind === "line" || input.kind === "scatter" ? input.series : []);
|
|
78
|
+
|
|
79
|
+
type ChartAxisInput = { label?: string; domain?: number[]; ticks?: number; scale?: "linear" | "log" };
|
|
80
|
+
|
|
81
|
+
const bounds = (domain: number[] | undefined): [number, number] | undefined => {
|
|
82
|
+
const [min, max] = domain ?? [];
|
|
83
|
+
return min === undefined || max === undefined ? undefined : [min, max];
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const axis = ({ domain, ...value }: ChartAxisInput = {}, format: (value: number) => string) => ({
|
|
87
|
+
...value,
|
|
88
|
+
...(domain ? { domain: bounds(domain) } : {}),
|
|
89
|
+
format,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/** The value axes of a chart with what they show: the data checks and the label room read them alike. */
|
|
93
|
+
const valueAxes = (input: CloudAiChartInput): { values: number[]; axis?: ChartAxisInput; zero?: boolean }[] => {
|
|
94
|
+
if (input.kind === "bar") return [{ values: input.data.map((item) => item.value), axis: input.yAxis, zero: true }];
|
|
95
|
+
if (input.kind === "histogram") return [{ values: input.data }];
|
|
96
|
+
if (input.kind !== "line" && input.kind !== "scatter") return [];
|
|
97
|
+
const points = input.series.flatMap((series) => series.data);
|
|
98
|
+
return [
|
|
99
|
+
{ values: points.map((point) => dateTime(point.x)), axis: input.xAxis },
|
|
100
|
+
{ values: points.map((point) => point.y), axis: input.yAxis },
|
|
101
|
+
];
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether the renderer can step from the lowest to the highest tick. Values that differ by less than the float
|
|
106
|
+
* precision of their size, such as 1e17 and 1e17 + 16, would make it count forever.
|
|
107
|
+
*/
|
|
108
|
+
const steppable = (values: number[], ticks = TICKS) => {
|
|
109
|
+
const [min, max] = computeDomain(values);
|
|
110
|
+
const step = niceStep(max - min, ticks);
|
|
111
|
+
return [Math.floor(min / step) * step, Math.ceil(max / step) * step].every((value) => value + step !== value && value - step !== value);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The values whose labels are the widest on a value axis: its ends and the ticks next to them. The renderer puts ticks
|
|
116
|
+
* on multiples of a nice step, or on powers of ten on a log axis, and adds the bounds of a fixed domain.
|
|
117
|
+
*/
|
|
118
|
+
const tickCandidates = (values: number[], value?: ChartAxisInput): number[] => {
|
|
119
|
+
const domain = bounds(value?.domain);
|
|
120
|
+
if (value?.scale === "log") {
|
|
121
|
+
const positive = values.filter((entry) => entry > 0);
|
|
122
|
+
const [min, max] = domain ?? (positive.length ? [Math.min(...positive), Math.max(...positive)] : [1, 10]);
|
|
123
|
+
return [min, max, 10 ** Math.floor(Math.log10(min)), 10 ** Math.ceil(Math.log10(max))];
|
|
124
|
+
}
|
|
125
|
+
const [min, max] = domain ?? computeDomain(values);
|
|
126
|
+
const step = niceStep(max - min, value?.ticks ?? TICKS);
|
|
127
|
+
const low = Math.floor(min / step) * step;
|
|
128
|
+
const high = Math.ceil(max / step) * step;
|
|
129
|
+
return [...(domain ?? [low, high]), low + step, high - step];
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
type Formats = {
|
|
133
|
+
tick: (value: number) => string;
|
|
134
|
+
value: (value: number) => string;
|
|
135
|
+
x: { tick: (value: number) => string; value: (value: number) => string };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const formats = (input: CloudAiChartInput, locale: string): Formats => {
|
|
139
|
+
// Ticks are round numbers already; fifteen significant digits show them exactly (0.005, 1.25M) without float noise.
|
|
140
|
+
const tick = new Intl.NumberFormat(locale, { notation: "compact", maximumSignificantDigits: 15 });
|
|
141
|
+
// An x axis often counts years or steps, which compact notation would turn into 2.02K.
|
|
142
|
+
const xTick = new Intl.NumberFormat(locale, { maximumSignificantDigits: 15, useGrouping: "min2" });
|
|
143
|
+
// The table, tooltips and Copy data show the value as given.
|
|
144
|
+
const value = new Intl.NumberFormat(locale, { maximumSignificantDigits: 15 });
|
|
145
|
+
const points = seriesOf(input).flatMap((series) => series.data);
|
|
146
|
+
const written = points.flatMap((point) => (typeof point.x === "string" ? [point.x] : []));
|
|
147
|
+
if (written.length === 0)
|
|
148
|
+
return {
|
|
149
|
+
tick: (v) => tick.format(v),
|
|
150
|
+
value: (v) => value.format(v),
|
|
151
|
+
x: { tick: (v) => xTick.format(v), value: (v) => value.format(v) },
|
|
152
|
+
};
|
|
153
|
+
const times = written.some((date) => date.length > 10);
|
|
154
|
+
const seconds = written.some((date) => date.length > 16);
|
|
155
|
+
const clock: Intl.DateTimeFormatOptions = times ? { hour: "2-digit", minute: "2-digit", ...(seconds ? { second: "2-digit" } : {}) } : {};
|
|
156
|
+
const dateTick = new Intl.DateTimeFormat(locale, { timeZone: "UTC", day: "numeric", month: "short", ...clock });
|
|
157
|
+
const dateValue = new Intl.DateTimeFormat(locale, {
|
|
158
|
+
timeZone: "UTC",
|
|
159
|
+
dateStyle: "medium",
|
|
160
|
+
...(times ? { timeStyle: seconds ? "medium" : "short" } : {}),
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
tick: (v) => tick.format(v),
|
|
164
|
+
value: (v) => value.format(v),
|
|
165
|
+
x: { tick: (v) => dateTick.format(v), value: (v) => dateValue.format(v) },
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/** Room for the longest y tick label, so values like 1.2M never run past the left edge on a phone. */
|
|
170
|
+
const leftPadding = (values: number[], value: ChartAxisInput | undefined, tick: (value: number) => string) => {
|
|
171
|
+
// A tick between the ends can carry one more digit than they do.
|
|
172
|
+
const longest = Math.max(...tickCandidates(values, value).map((entry) => tick(entry).length)) + 1;
|
|
173
|
+
return Math.max(40, Math.ceil((longest * CHAR + 8 + (value?.label ? 16 : 0)) * NARROW));
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Category labels never shorten: when they would overlap in the narrowest column, every n-th label shows in full and
|
|
178
|
+
* the others stay empty, as in `cloud.chart()`. Table and tooltips keep every label.
|
|
179
|
+
*/
|
|
180
|
+
const thinned = <T extends { label: string }>(data: T[], left: number): T[] => {
|
|
181
|
+
const longest = Math.max(1, ...data.map((item) => item.label.length));
|
|
182
|
+
const plot = Math.max(WIDTH - left - 16, 1) / NARROW;
|
|
183
|
+
const step = Math.max(1, Math.ceil(((longest + 1) * CHAR * data.length) / plot));
|
|
184
|
+
return step === 1 ? data : data.map((item, index) => (index % step === 0 ? item : { ...item, label: "" }));
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** Options for `@k2b/ui` Chart and `prepareChartSnapshot`; the chat block draws the title itself. */
|
|
188
|
+
export function cloudAiChartRenderOptions(input: CloudAiChartInput, locale: string): ChartRenderOptions {
|
|
189
|
+
const format = formats(input, locale);
|
|
190
|
+
switch (input.kind) {
|
|
191
|
+
case "bar": {
|
|
192
|
+
const values = input.data.map((item) => item.value);
|
|
193
|
+
const left = leftPadding(input.yAxis?.scale === "log" ? values : [...values, 0], input.yAxis, format.tick);
|
|
194
|
+
return {
|
|
195
|
+
kind: "bar",
|
|
196
|
+
data: input.colorByBar && input.legend ? input.data : thinned(input.data, left),
|
|
197
|
+
yAxis: axis(input.yAxis, format.tick),
|
|
198
|
+
colorByBar: input.colorByBar,
|
|
199
|
+
showValues: input.showValues,
|
|
200
|
+
legend: input.legend,
|
|
201
|
+
padding: { left },
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
case "line":
|
|
205
|
+
case "scatter": {
|
|
206
|
+
const t = labels.resolve([locale]).t;
|
|
207
|
+
const series = input.series.map((entry, index) => ({
|
|
208
|
+
// The renderer names unnamed series in English; the legend speaks the reader's language like the table.
|
|
209
|
+
label: entry.label ?? `${t.series} ${index + 1}`,
|
|
210
|
+
data: entry.data.map((point) => ({ x: dateTime(point.x), y: point.y })),
|
|
211
|
+
}));
|
|
212
|
+
const points = series.flatMap((entry) => entry.data);
|
|
213
|
+
const lastTick = Math.max(
|
|
214
|
+
...tickCandidates(
|
|
215
|
+
points.map((point) => point.x),
|
|
216
|
+
input.xAxis,
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
const common = {
|
|
220
|
+
series,
|
|
221
|
+
xAxis: axis(input.xAxis, format.x.tick),
|
|
222
|
+
yAxis: axis(input.yAxis, format.tick),
|
|
223
|
+
legend: input.legend ?? input.series.length > 1,
|
|
224
|
+
padding: {
|
|
225
|
+
left: leftPadding(
|
|
226
|
+
points.map((point) => point.y),
|
|
227
|
+
input.yAxis,
|
|
228
|
+
format.tick,
|
|
229
|
+
),
|
|
230
|
+
// Labels center on their tick: room for half of the last one right of the plot.
|
|
231
|
+
right: Math.max(16, Math.ceil(((format.x.tick(lastTick).length * CHAR) / 2 + 4) * NARROW)),
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
return input.kind === "line" ? { kind: "line", ...common, area: input.area, smooth: input.smooth } : { kind: "scatter", ...common };
|
|
235
|
+
}
|
|
236
|
+
case "pie":
|
|
237
|
+
case "donut":
|
|
238
|
+
return { kind: input.kind, data: input.data, legend: input.legend ?? !input.showLabels, showLabels: input.showLabels };
|
|
239
|
+
case "histogram":
|
|
240
|
+
return {
|
|
241
|
+
kind: "histogram",
|
|
242
|
+
data: input.data,
|
|
243
|
+
bins: input.bins,
|
|
244
|
+
xAxis: { format: format.tick },
|
|
245
|
+
yAxis: { format: format.tick },
|
|
246
|
+
// No bin counts more than every observation.
|
|
247
|
+
padding: { left: leftPadding([0, input.data.length], undefined, format.tick) },
|
|
248
|
+
};
|
|
249
|
+
case "gauge":
|
|
250
|
+
return {
|
|
251
|
+
kind: "gauge",
|
|
252
|
+
value: input.value,
|
|
253
|
+
min: input.min,
|
|
254
|
+
max: input.max,
|
|
255
|
+
label: input.label,
|
|
256
|
+
unit: input.unit,
|
|
257
|
+
format: format.value,
|
|
258
|
+
};
|
|
259
|
+
case "sparkline":
|
|
260
|
+
return { kind: "sparkline", data: input.data, area: input.area };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const decode = (value: string) =>
|
|
265
|
+
value
|
|
266
|
+
.replace(/"/g, '"')
|
|
267
|
+
.replace(/'|'/g, "'")
|
|
268
|
+
.replace(/</g, "<")
|
|
269
|
+
.replace(/>/g, ">")
|
|
270
|
+
.replace(/&/g, "&");
|
|
271
|
+
|
|
272
|
+
/** Draws the options with inspection metadata; throws what the renderer would throw in the chat. */
|
|
273
|
+
const draw = (options: ChartRenderOptions): string => {
|
|
274
|
+
const { kind, ...rest } = options;
|
|
275
|
+
return (charts[kind] as (value: object) => string)({ ...rest, width: WIDTH, height: HEIGHT, inspect: true });
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const marks = (svg: string): ChartDatum[] =>
|
|
279
|
+
[...svg.matchAll(/data-chart-datum="([^"]*)"/g)].map((match) => JSON.parse(decode(match[1]!)) as ChartDatum);
|
|
280
|
+
|
|
281
|
+
/** The outer radius of a drawn pie or donut: the first arc of its first slice. */
|
|
282
|
+
const pieRadius = (svg: string) => Number(/class="stdlib-chart-slice[^"]*" d="[^"A]*A ([\d.]+)/.exec(svg)?.[1] ?? 0);
|
|
283
|
+
|
|
284
|
+
/** One mark, as `prepareChartSnapshot` keys it: series and position in the input. */
|
|
285
|
+
export const cloudAiChartMarkKey = (datum: Pick<ChartDatum, "index" | "seriesIndex">) => `${datum.seriesIndex ?? 0}:${datum.index}`;
|
|
286
|
+
|
|
287
|
+
export type CloudAiChartColumn = { id: string; label: string; numeric: boolean };
|
|
288
|
+
export type CloudAiChartRow = { key: string; cells: Record<string, string>; values: Record<string, string | number> };
|
|
289
|
+
export type CloudAiChartTable = { columns: CloudAiChartColumn[]; rows: CloudAiChartRow[] };
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Builds the data table mark by mark, in the order the renderer emits them, so the chat block fills it from the same
|
|
293
|
+
* drawing that shows the chart.
|
|
294
|
+
*/
|
|
295
|
+
export function cloudAiChartRows(input: CloudAiChartInput, locale: string) {
|
|
296
|
+
const t = labels.resolve([locale]).t;
|
|
297
|
+
const format = formats(input, locale);
|
|
298
|
+
const series = seriesOf(input);
|
|
299
|
+
const percent = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 });
|
|
300
|
+
const unit = input.kind === "gauge" && input.unit ? ` ${input.unit}` : "";
|
|
301
|
+
const columns = new Map<string, CloudAiChartColumn>();
|
|
302
|
+
const rows: CloudAiChartRow[] = [];
|
|
303
|
+
const named = series.some((entry) => entry.label);
|
|
304
|
+
const add = (datum: ChartDatum): CloudAiChartRow => {
|
|
305
|
+
const cells: Record<string, string> = {};
|
|
306
|
+
const values: Record<string, string | number> = {};
|
|
307
|
+
const cell = (id: string, label: string, value: string | number, text: string) => {
|
|
308
|
+
if (!columns.has(id)) columns.set(id, { id, label, numeric: typeof value === "number" });
|
|
309
|
+
cells[id] = text;
|
|
310
|
+
values[id] = value;
|
|
311
|
+
};
|
|
312
|
+
if (input.kind === "line" || input.kind === "scatter") {
|
|
313
|
+
if (named || series.length > 1) {
|
|
314
|
+
const name = series[datum.seriesIndex ?? 0]?.label ?? `${t.series} ${(datum.seriesIndex ?? 0) + 1}`;
|
|
315
|
+
cell("series", t.series, name, name);
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
// Bars may hide some axis labels; the table names every category.
|
|
319
|
+
const label = input.kind === "bar" || input.kind === "pie" || input.kind === "donut" ? input.data[datum.index]?.label : datum.label;
|
|
320
|
+
if (label) cell("label", t.category, label, label);
|
|
321
|
+
}
|
|
322
|
+
for (const field of datum.values) {
|
|
323
|
+
const value = field.value;
|
|
324
|
+
if (typeof value !== "number" || field.key === "total") continue;
|
|
325
|
+
if (field.key === "x") {
|
|
326
|
+
const header = (input.kind === "line" || input.kind === "scatter" ? input.xAxis?.label : undefined) ?? "X";
|
|
327
|
+
cell("x", input.kind === "sparkline" ? "#" : header, value, input.kind === "sparkline" ? String(value + 1) : format.x.value(value));
|
|
328
|
+
} else if (field.key === "y") {
|
|
329
|
+
const header =
|
|
330
|
+
(input.kind === "line" || input.kind === "scatter" ? input.yAxis?.label : undefined) ??
|
|
331
|
+
(input.kind === "sparkline" ? t.value : "Y");
|
|
332
|
+
cell("y", header, value, format.value(value));
|
|
333
|
+
} else if (field.key === "value")
|
|
334
|
+
cell("value", (input.kind === "bar" ? input.yAxis?.label : undefined) ?? t.value, value, `${format.value(value)}${unit}`);
|
|
335
|
+
else if (field.key === "percent") cell("percent", t.share, value, percent.format(value / 100));
|
|
336
|
+
else if (field.key === "from" || field.key === "to" || field.key === "count" || field.key === "min" || field.key === "max")
|
|
337
|
+
cell(field.key, t[field.key], value, `${format.value(value)}${field.key === "min" || field.key === "max" ? unit : ""}`);
|
|
338
|
+
}
|
|
339
|
+
const row = { key: cloudAiChartMarkKey(datum), cells, values };
|
|
340
|
+
rows.push(row);
|
|
341
|
+
return row;
|
|
342
|
+
};
|
|
343
|
+
return { add, columns: () => [...columns.values()], table: (): CloudAiChartTable => ({ columns: [...columns.values()], rows }) };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The data behind every mark of the chart as a table: the accessible alternative and what Copy data copies. */
|
|
347
|
+
export function cloudAiChartTable(input: CloudAiChartInput, locale: string): CloudAiChartTable {
|
|
348
|
+
const rows = cloudAiChartRows(input, locale);
|
|
349
|
+
for (const datum of marks(draw(cloudAiChartRenderOptions(input, locale)))) rows.add(datum);
|
|
350
|
+
return rows.table();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Why chart options would not draw, found without drawing them; undefined when nothing is wrong. */
|
|
354
|
+
export function chartDataIssue(input: CloudAiChartInput): string | undefined {
|
|
355
|
+
if (new Set(seriesOf(input).flatMap((series) => series.data.map((point) => typeof point.x))).size > 1)
|
|
356
|
+
return "Use either numbers or dates for x in all series, not both.";
|
|
357
|
+
if ((input.kind === "pie" || input.kind === "donut") && !input.data.some((slice) => slice.value > 0))
|
|
358
|
+
return "Pie and donut slices must add up to more than zero.";
|
|
359
|
+
for (const { values, axis, zero } of valueAxes(input)) {
|
|
360
|
+
if (axis?.scale === "log") {
|
|
361
|
+
if (values.some((value) => value <= 0))
|
|
362
|
+
return "A log axis shows only values above zero; use a linear axis or leave out the values at or below zero.";
|
|
363
|
+
} else if (!axis?.domain && !steppable(zero ? [...values, 0] : values, axis?.ticks))
|
|
364
|
+
return "The values differ too little for their size to label an axis; subtract a common offset first.";
|
|
365
|
+
}
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Why chart options that pass the data checks still would not draw well; draws them once. */
|
|
370
|
+
export function chartDrawingIssue(input: CloudAiChartInput): string | undefined {
|
|
371
|
+
try {
|
|
372
|
+
const svg = draw(cloudAiChartRenderOptions(input, "en"));
|
|
373
|
+
if (marks(svg).length === 0) return "None of the values can be drawn.";
|
|
374
|
+
if ((input.kind === "pie" || input.kind === "donut") && pieRadius(svg) < MIN_PIE_RADIUS)
|
|
375
|
+
return "The legend leaves too little room for the pie: combine small slices into one, shorten the labels, or use a bar chart.";
|
|
376
|
+
return undefined;
|
|
377
|
+
} catch (error) {
|
|
378
|
+
return `The chart cannot be drawn: ${error instanceof Error ? error.message : String(error)}`;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CapabilityActionWording,
|
|
3
|
+
capabilityActionOutcome,
|
|
4
|
+
capabilityActionSubject,
|
|
5
|
+
resolveCapabilityActionSentences,
|
|
6
|
+
} from "../../_internal/capability-sentences";
|
|
7
|
+
import type { AiTurnBlock } from "../protocol";
|
|
8
|
+
import { CLOUD_AI_TOOL_PRESENTATION } from "../tool-sentences";
|
|
9
|
+
import { displayToolName, isRecord } from "./message-utils";
|
|
10
|
+
|
|
11
|
+
type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
|
|
12
|
+
|
|
13
|
+
/** Instants show in the reader's own time zone; outside a browser they stay in UTC. */
|
|
14
|
+
const readerTimeZone = (): string | undefined =>
|
|
15
|
+
typeof window === "undefined" ? undefined : Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* How the chat words one call that acts on something: an app Action through its saved presentation, a
|
|
19
|
+
* Cloud tool through Cloud's own catalog of the same shape. `null` for calls that only read.
|
|
20
|
+
*/
|
|
21
|
+
export const toolWording = (block: ToolBlock, locale: string): CapabilityActionWording | null => {
|
|
22
|
+
const presentation = block.presentation;
|
|
23
|
+
if (presentation?.kind === "capability") {
|
|
24
|
+
if (presentation.capabilityKind !== "action") return null;
|
|
25
|
+
return {
|
|
26
|
+
title: presentation.title,
|
|
27
|
+
...(presentation.sentences ? { sentences: presentation.sentences } : {}),
|
|
28
|
+
...(presentation.fields ? { fields: presentation.fields } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const sentences = resolveCapabilityActionSentences(block.name, CLOUD_AI_TOOL_PRESENTATION, locale);
|
|
32
|
+
return sentences ? { title: displayToolName(block.name, locale), sentences } : null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** What the call does or would do, as one sentence; `null` for calls that only read. */
|
|
36
|
+
export const toolSubject = (block: ToolBlock, locale: string): string | null => {
|
|
37
|
+
const wording = toolWording(block, locale);
|
|
38
|
+
return wording ? capabilityActionSubject(wording, block.args, { locale, timeZone: readerTimeZone() }) : null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** The sentence for one outcome, or `null` when the tool has none that renders for this call. */
|
|
42
|
+
export const toolOutcome = (block: ToolBlock, key: "done" | "rejected" | "notRun", locale: string): string | null => {
|
|
43
|
+
const wording = toolWording(block, locale);
|
|
44
|
+
if (!wording) return null;
|
|
45
|
+
const result = isRecord(block.result) ? block.result : undefined;
|
|
46
|
+
// App Actions return their data inside the result envelope; Cloud tools return the data itself.
|
|
47
|
+
const data = block.presentation?.kind === "capability" ? result?.data : block.result;
|
|
48
|
+
return capabilityActionOutcome(wording, key, { input: block.args, data }, { locale, timeZone: readerTimeZone() });
|
|
49
|
+
};
|