@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.
Files changed (77) hide show
  1. package/package.json +3 -2
  2. package/src/_internal/app-roles.ts +14 -0
  3. package/src/_internal/capabilities.ts +111 -3
  4. package/src/_internal/capability-sentences.ts +413 -0
  5. package/src/_internal/define-app.ts +10 -1
  6. package/src/_internal/registry-validation.ts +5 -0
  7. package/src/access/PermissionEditor.tsx +43 -24
  8. package/src/ai/app-skill-store.ts +298 -0
  9. package/src/ai/app-skills.ts +105 -0
  10. package/src/ai/browser.ts +10 -0
  11. package/src/ai/capabilities.ts +68 -9
  12. package/src/ai/chart-block.ts +380 -0
  13. package/src/ai/chat/action-sentences.ts +49 -0
  14. package/src/ai/chat/blocks.tsx +45 -9
  15. package/src/ai/chat/chart-block.tsx +79 -0
  16. package/src/ai/chat/message-utils.ts +3 -0
  17. package/src/ai/chat/messages.ts +8 -2
  18. package/src/ai/chat/tool-names.ts +2 -0
  19. package/src/ai/chat/turn-layout.ts +2 -2
  20. package/src/ai/chat/turn-view.tsx +29 -11
  21. package/src/ai/code-runtime-tools.ts +59 -44
  22. package/src/ai/default-tool-contracts.ts +198 -0
  23. package/src/ai/default-tools.ts +25 -1
  24. package/src/ai/executor.ts +19 -21
  25. package/src/ai/index.ts +9 -1
  26. package/src/ai/migrate.ts +59 -12
  27. package/src/ai/runtime-tools.ts +1 -0
  28. package/src/ai/skill-content.ts +54 -0
  29. package/src/ai/skills-routes.ts +22 -13
  30. package/src/ai/skills.ts +177 -231
  31. package/src/ai/tool-sentences.ts +38 -0
  32. package/src/ai/types.ts +8 -1
  33. package/src/api/admin-ai-skills.ts +35 -16
  34. package/src/api/fanout.ts +128 -0
  35. package/src/api/help.ts +8 -3
  36. package/src/api/mcp.ts +4 -1
  37. package/src/api/search.ts +31 -106
  38. package/src/api/widgets.ts +259 -100
  39. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  40. package/src/browser/ndjson.ts +34 -0
  41. package/src/browser/reload.ts +46 -0
  42. package/src/browser/search-stream.ts +4 -26
  43. package/src/browser/widgets.ts +28 -0
  44. package/src/capabilities/server.ts +20 -2
  45. package/src/cli/access.ts +48 -14
  46. package/src/cli/admin/ai-skills.ts +59 -39
  47. package/src/contracts/announcement-cookie.ts +62 -0
  48. package/src/contracts/announcements.ts +14 -52
  49. package/src/contracts/capabilities.ts +24 -0
  50. package/src/contracts/pwa-paths.ts +7 -2
  51. package/src/contracts/pwa.ts +2 -7
  52. package/src/contracts/registry.ts +1 -0
  53. package/src/contracts/shared.ts +5 -0
  54. package/src/contracts/widgets.ts +20 -0
  55. package/src/index.ts +1 -0
  56. package/src/server/help.ts +8 -1
  57. package/src/services/accounts/users.ts +15 -0
  58. package/src/services/help/index.ts +18 -5
  59. package/src/services/help/types.ts +3 -2
  60. package/src/services/identity/constants.ts +2 -0
  61. package/src/services/identity/key-ring.ts +123 -42
  62. package/src/services/identity/runtime-config.ts +13 -2
  63. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  64. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  65. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  66. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  67. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  68. package/src/ssr/PwaRuntime.island.tsx +1 -1
  69. package/src/ssr/app-navigation.ts +5 -7
  70. package/src/ssr/global-search-host.tsx +55 -0
  71. package/src/ssr/help.ts +3 -3
  72. package/src/ssr/rail-context.ts +4 -3
  73. package/src/ssr/runtime.ts +14 -1
  74. package/src/ai/code-mode-skill.ts +0 -114
  75. package/src/ai/data-analysis-skill.ts +0 -10
  76. package/src/ai/grids-skill.ts +0 -158
  77. package/src/ai/skill-seeds.ts +0 -560
@@ -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 { createHelpReader, type HelpReaderFactory } from "../services/help";
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 = (help: HelpReaderFactory = createHelpReader, locale = "en"): AiRuntimeTool[] => {
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(entry.operation.inputSchema),
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 (args, context) => {
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
- const message = review?.message ?? `${entry.display.appName}: ${entry.display.title}`;
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
- const helpTools = input.help ? createAiHelpTools(input.help, input.locale) : [];
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(/&quot;/g, '"')
267
+ .replace(/&apos;|&#39;/g, "'")
268
+ .replace(/&lt;/g, "<")
269
+ .replace(/&gt;/g, ">")
270
+ .replace(/&amp;/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
+ };