@k2b/cloud 0.31.0 → 0.33.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 (173) hide show
  1. package/package.json +8 -6
  2. package/src/_internal/define-app.ts +20 -9
  3. package/src/_internal/page-responses.ts +5 -1
  4. package/src/ai/app-skills.ts +1 -7
  5. package/src/ai/audio-tool.ts +2 -1
  6. package/src/ai/browser.ts +109 -0
  7. package/src/ai/capabilities.ts +6 -2
  8. package/src/ai/chart-block.ts +431 -81
  9. package/src/ai/chat/action-sentences.ts +13 -7
  10. package/src/ai/chat/blocks.tsx +11 -7
  11. package/src/ai/chat/builtin-tools.tsx +9 -7
  12. package/src/ai/chat/chart-block.tsx +218 -44
  13. package/src/ai/chat/explorer-block.tsx +276 -0
  14. package/src/ai/chat/file-tools.tsx +40 -1
  15. package/src/ai/chat/message-actions.tsx +6 -0
  16. package/src/ai/chat/message-utils.ts +7 -0
  17. package/src/ai/chat/messages.ts +16 -0
  18. package/src/ai/chat/presentation.tsx +13 -3
  19. package/src/ai/chat/reference-text.tsx +34 -0
  20. package/src/ai/chat/tool-groups.ts +1 -0
  21. package/src/ai/chat/tool-names.ts +14 -0
  22. package/src/ai/chat/turn-view.tsx +17 -7
  23. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  24. package/src/ai/chat/view-block.tsx +417 -0
  25. package/src/ai/chat/visual-tools.tsx +32 -53
  26. package/src/ai/client-tool-results.ts +9 -0
  27. package/src/ai/code-source-contracts.ts +17 -2
  28. package/src/ai/code-source-tools.ts +1 -0
  29. package/src/ai/default-tool-contracts.ts +334 -91
  30. package/src/ai/default-tools.ts +89 -13
  31. package/src/ai/executor.ts +173 -31
  32. package/src/ai/file-error.ts +2 -0
  33. package/src/ai/file-tools.ts +27 -7
  34. package/src/ai/files-store.ts +60 -0
  35. package/src/ai/html-pdf-tool.ts +3 -2
  36. package/src/ai/http.ts +37 -12
  37. package/src/ai/index.ts +90 -0
  38. package/src/ai/local-tool-contracts.ts +244 -0
  39. package/src/ai/malformed-tool-call-repair.ts +144 -0
  40. package/src/ai/markdown-pdf-tool.ts +2 -1
  41. package/src/ai/memories.ts +48 -1
  42. package/src/ai/memory-learning.ts +17 -6
  43. package/src/ai/memory-tool.ts +1 -0
  44. package/src/ai/migrate.ts +15 -0
  45. package/src/ai/openai-api-messages.ts +53 -0
  46. package/src/ai/openai-api-slots.ts +40 -0
  47. package/src/ai/openai-api.ts +383 -0
  48. package/src/ai/protocol.ts +6 -2
  49. package/src/ai/provider.ts +17 -4
  50. package/src/ai/request-context.ts +33 -0
  51. package/src/ai/routes.ts +96 -57
  52. package/src/ai/runtime.ts +48 -12
  53. package/src/ai/settings.ts +5 -0
  54. package/src/ai/skill-catalog.ts +20 -5
  55. package/src/ai/skill-format.ts +17 -7
  56. package/src/ai/skill-tool.ts +15 -5
  57. package/src/ai/skills-routes.ts +20 -6
  58. package/src/ai/skills.ts +26 -7
  59. package/src/ai/store.ts +25 -0
  60. package/src/ai/system-prompt.ts +69 -10
  61. package/src/ai/timeline.ts +3 -1
  62. package/src/ai/tools.ts +3 -0
  63. package/src/ai/types.ts +9 -0
  64. package/src/ai/view-block.ts +941 -0
  65. package/src/ai/vision-tool.ts +2 -1
  66. package/src/api/admin-identity.ts +6 -1
  67. package/src/api/app-approval-actions.ts +36 -0
  68. package/src/api/app-approval.ts +10 -2
  69. package/src/api/approvals-loader.ts +15 -0
  70. package/src/api/approvals.ts +120 -0
  71. package/src/api/index.ts +3 -0
  72. package/src/api/me.ts +12 -3
  73. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  74. package/src/approvals/Approvals.tsx +621 -0
  75. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  76. package/src/approvals/messages.ts +95 -0
  77. package/src/approvals/ui.ts +8 -0
  78. package/src/browser/app-approval.ts +32 -0
  79. package/src/browser/choose-files.tsx +26 -13
  80. package/src/browser/commands.ts +13 -4
  81. package/src/browser/file-provider-error.ts +11 -0
  82. package/src/browser/file-providers.ts +3 -12
  83. package/src/browser/files.tsx +1 -2
  84. package/src/browser/live-connection.ts +2 -3
  85. package/src/browser/navigation-search.ts +4 -1
  86. package/src/browser/provider-list.ts +2 -1
  87. package/src/browser/save-files.tsx +13 -2
  88. package/src/cli/account.ts +21 -2
  89. package/src/cli/commands.ts +4 -2
  90. package/src/contracts/app-approval.ts +120 -0
  91. package/src/contracts/approval-paths.ts +4 -0
  92. package/src/contracts/approvals.ts +81 -0
  93. package/src/contracts/command-path.ts +17 -0
  94. package/src/contracts/commands.ts +5 -15
  95. package/src/contracts/shared.ts +5 -0
  96. package/src/events/live-protocol.ts +2 -1
  97. package/src/events/live-server-message.ts +40 -0
  98. package/src/events/testing.ts +88 -0
  99. package/src/server/index.ts +2 -0
  100. package/src/server/middleware/auth.ts +11 -1
  101. package/src/server/request-timeout.ts +22 -0
  102. package/src/server/services/access.ts +4 -3
  103. package/src/services/app-approval.ts +103 -15
  104. package/src/services/approvals/cursor.ts +15 -0
  105. package/src/services/approvals/device.ts +112 -0
  106. package/src/services/approvals/index.ts +27 -11
  107. package/src/services/approvals/list.ts +120 -0
  108. package/src/services/approvals/migrate.ts +9 -1
  109. package/src/services/identity/models-only-auth.ts +25 -0
  110. package/src/services/notifications/email-frame.ts +16 -3
  111. package/src/services/outbox.ts +13 -6
  112. package/src/services/service-account-credentials.ts +22 -10
  113. package/src/services/settings/core-settings.ts +27 -0
  114. package/src/shared/ai-platform-prompt.ts +29 -3
  115. package/src/shared/email-html.ts +4 -0
  116. package/src/shared/index.ts +6 -1
  117. package/src/shared/markdown/extensions/code.ts +14 -0
  118. package/src/shared/markdown/index.ts +4 -4
  119. package/src/shared/theme.ts +41 -20
  120. package/src/ssr/AnnouncementBanners.tsx +30 -0
  121. package/src/ssr/AppBadge.tsx +4 -4
  122. package/src/ssr/AppLaunchpadPanel.tsx +6 -6
  123. package/src/ssr/CloudShell.island.tsx +91 -0
  124. package/src/ssr/GlobalSearchTrigger.tsx +51 -0
  125. package/src/ssr/Layout.tsx +56 -85
  126. package/src/ssr/LayoutHeader.tsx +31 -77
  127. package/src/ssr/LayoutHelp.tsx +2 -57
  128. package/src/ssr/LayoutHelpTrigger.tsx +51 -0
  129. package/src/ssr/{LayoutPreferences.island.tsx → LayoutPreferences.tsx} +2 -2
  130. package/src/ssr/LayoutRail.tsx +15 -69
  131. package/src/ssr/MinimalLayout.tsx +2 -3
  132. package/src/ssr/MinimalLayoutPreferences.island.tsx +12 -15
  133. package/src/ssr/MobileNavigation.tsx +13 -8
  134. package/src/ssr/MobileProfileActions.tsx +10 -7
  135. package/src/ssr/PageTrailRow.tsx +14 -0
  136. package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +12 -7
  137. package/src/ssr/PwaLayout.tsx +0 -2
  138. package/src/ssr/PwaRuntime.island.tsx +1 -1
  139. package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +2 -2
  140. package/src/ssr/ShellRuntime.island.tsx +170 -0
  141. package/src/ssr/TimezoneCookie.island.tsx +3 -19
  142. package/src/ssr/WorkspaceNavigationProvider.tsx +20 -1
  143. package/src/ssr/announcements-dialog.tsx +66 -0
  144. package/src/ssr/app-badges.ts +50 -6
  145. package/src/ssr/{AppLaunchpad.island.tsx → app-launchpad.tsx} +42 -62
  146. package/src/ssr/busy-action.ts +19 -0
  147. package/src/ssr/islands/index.ts +9 -6
  148. package/src/ssr/launchpad-surface.tsx +15 -0
  149. package/src/ssr/layout-help-registry.tsx +60 -0
  150. package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
  151. package/src/ssr/page-trail.browser-harness.tsx +52 -3
  152. package/src/ssr/preference-controller.ts +30 -21
  153. package/src/ssr/profile-actions.ts +26 -15
  154. package/src/ssr/profile-preferences-messages.ts +8 -4
  155. package/src/ssr/rail-context.ts +8 -12
  156. package/src/ssr/rail-navigation.ts +5 -3
  157. package/src/ssr/runtime.ts +6 -1
  158. package/src/ssr/shell-data.tsx +48 -0
  159. package/src/ssr/timezone-cookie.ts +22 -0
  160. package/src/ssr/workspace-navigation.ts +25 -2
  161. package/src/styles/approvals.css +390 -0
  162. package/src/styles/global.css +22 -7
  163. package/src/styles/utilities-navigation.css +13 -0
  164. package/src/workflows/language/compiler.ts +1 -1
  165. package/src/ssr/AppWorkspaceController.island.tsx +0 -21
  166. package/src/ssr/BrowserPushRegistration.island.tsx +0 -12
  167. package/src/ssr/GlobalAnnouncements.island.tsx +0 -117
  168. package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
  169. package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
  170. package/src/ssr/PageTrailRow.island.tsx +0 -20
  171. package/src/ssr/PageTrailRuntime.island.tsx +0 -26
  172. package/src/ssr/RegisteredHelpDocuments.island.tsx +0 -3
  173. /package/src/ssr/{LayoutBreadcrumbs.island.tsx → LayoutBreadcrumbs.tsx} +0 -0
@@ -0,0 +1,941 @@
1
+ import { computeDomain, extendDomainToNice, niceStep } from "@k2b/stdlib";
2
+ import { z } from "zod";
3
+ import { CloudResourceReferenceSchema } from "../contracts/capabilities";
4
+ import {
5
+ type CloudAiChartRow,
6
+ type CloudAiChartTable,
7
+ chartDataIssue,
8
+ chartDateTime,
9
+ chartDrawingIssue,
10
+ chartSteppingIssue,
11
+ cloudAiNumberFormatters,
12
+ } from "./chart-block";
13
+ import {
14
+ CLOUD_AI_CHART_MAX_SERIES,
15
+ CLOUD_AI_CHART_MAX_VALUES,
16
+ CLOUD_AI_GROUP_MAX_CHARTS,
17
+ CLOUD_AI_VIEW_MAX_MARKS,
18
+ type CloudAiChartInput,
19
+ cloudAiChartKindSchema,
20
+ cloudAiChartSchema,
21
+ cloudAiGroupSchema,
22
+ cloudAiNumberFormatSchema,
23
+ } from "./default-tool-contracts";
24
+
25
+ // Views have their own namespace so model write tools cannot replace computed results.
26
+ export const CLOUD_AI_VIEW_DIRECTORY = "/views";
27
+ // The suffix distinguishes declarative views from ordinary files in the folder.
28
+ export const CLOUD_AI_VIEW_SUFFIX = ".view.json";
29
+ // Readers load the whole file; even an eight-series chart needs only about 100 KB.
30
+ export const CLOUD_AI_VIEW_MAX_BYTES = 1024 * 1024;
31
+ // A table holds no more rows than the largest chart has marks.
32
+ export const CLOUD_AI_TABLE_MAX_ROWS = CLOUD_AI_CHART_MAX_SERIES * CLOUD_AI_CHART_MAX_VALUES;
33
+ // Capability table presentations use the same column bound.
34
+ export const CLOUD_AI_TABLE_MAX_COLUMNS = 100;
35
+
36
+ // StatGrid supports at most six columns.
37
+ export const CLOUD_AI_STATS_MAX_ITEMS = 6;
38
+ // A record shows this many fields before its viewport scrolls; its summary sizes only these.
39
+ export const CLOUD_AI_DETAILS_VIEWPORT_ITEMS = 12;
40
+ // Twelve rows hold about 22 lines; at the widest chat column a line holds about 110 characters, so a longer value fills
41
+ // the viewport at any width and its length counts no further.
42
+ export const CLOUD_AI_DETAILS_MAX_CHARS = 4096;
43
+
44
+ const referenceSchema = () => CloudResourceReferenceSchema.pick({ type: true, id: true, title: true });
45
+ const ReferenceSchema = /* @__PURE__ */ referenceSchema();
46
+ export type CloudAiViewReference = z.output<typeof ReferenceSchema>;
47
+ const cellTypeSchema = () => z.enum(["text", "number", "date", "datetime", "ref"]);
48
+ const cellSchema = () => z.union([z.string(), z.number(), z.null(), ReferenceSchema]);
49
+ const cellIssue = (
50
+ type: z.output<ReturnType<typeof cellTypeSchema>>,
51
+ cell: z.output<ReturnType<typeof cellSchema>>,
52
+ ): string | undefined => {
53
+ if (cell === null) return undefined;
54
+ if (type === "number") return typeof cell === "number" ? undefined : "Expected a number or null.";
55
+ // Text columns have always shown numbers as written, such as ids mixed with names.
56
+ if (type === "text") return typeof cell !== "object" ? undefined : "Expected text, a number or null.";
57
+ if (type === "ref") return typeof cell === "object" ? undefined : "Expected a Cloud reference or null.";
58
+ const pattern = type === "date" ? /^\d{4}-\d{2}-\d{2}$/ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?$/;
59
+ if (typeof cell !== "string" || !pattern.test(cell) || chartDateTime(cell) === undefined)
60
+ return type === "date" ? "Expected a valid YYYY-MM-DD date or null." : "Expected a valid wall-clock datetime or null.";
61
+ return undefined;
62
+ };
63
+ const statsViewSchema = () =>
64
+ z.strictObject({
65
+ view: z.literal("stats"),
66
+ title: z.string().trim().min(1).max(160),
67
+ subtitle: z.string().trim().min(1).max(300).optional(),
68
+ items: z
69
+ .array(
70
+ z
71
+ .strictObject({
72
+ label: z.string().trim().min(1).max(80),
73
+ value: z.union([z.number(), z.string().trim().min(1).max(80)]),
74
+ format: cloudAiNumberFormatSchema().optional(),
75
+ sub: z.string().trim().min(1).max(80).optional(),
76
+ delta: z.number().optional(),
77
+ better: z.enum(["up", "down"]).optional(),
78
+ sparkline: z.array(z.number()).min(2).max(CLOUD_AI_CHART_MAX_VALUES).optional(),
79
+ })
80
+ .superRefine((item, ctx) => {
81
+ if (typeof item.value !== "number") {
82
+ if (item.format !== undefined)
83
+ ctx.addIssue({ code: "custom", path: ["format"], message: "Only number values may have a format." });
84
+ if (item.delta !== undefined)
85
+ ctx.addIssue({ code: "custom", path: ["delta"], message: "Only number values may have a delta." });
86
+ }
87
+ if (item.better !== undefined && item.delta === undefined)
88
+ ctx.addIssue({ code: "custom", path: ["better"], message: "A better direction requires a delta." });
89
+ }),
90
+ )
91
+ .min(1)
92
+ .max(CLOUD_AI_STATS_MAX_ITEMS),
93
+ });
94
+ const detailsViewSchema = () =>
95
+ z.strictObject({
96
+ view: z.literal("details"),
97
+ title: z.string().trim().min(1).max(160),
98
+ subtitle: z.string().trim().min(1).max(300).optional(),
99
+ items: z
100
+ .array(
101
+ z
102
+ .strictObject({
103
+ label: z.string().trim().min(1).max(80),
104
+ type: cellTypeSchema(),
105
+ value: cellSchema(),
106
+ format: cloudAiNumberFormatSchema().optional(),
107
+ })
108
+ .superRefine((item, ctx) => {
109
+ const message = cellIssue(item.type, item.value);
110
+ if (message) ctx.addIssue({ code: "custom", path: ["value"], message });
111
+ if (item.format !== undefined && item.type !== "number")
112
+ ctx.addIssue({ code: "custom", path: ["format"], message: "Only number fields may have a format." });
113
+ }),
114
+ )
115
+ .min(1)
116
+ .max(CLOUD_AI_TABLE_MAX_COLUMNS),
117
+ });
118
+ const chartViewSchema = () => z.object({ view: z.literal("chart") }).and(cloudAiChartSchema());
119
+ const tableRowSchema = () => {
120
+ const cell = cellSchema();
121
+ // z.record drops __proto__; column ids must survive validation without changing the object's prototype.
122
+ const row = z.unknown().transform((value, ctx) => {
123
+ if (!value || typeof value !== "object" || Array.isArray(value) || value instanceof Date) {
124
+ ctx.addIssue({ code: "custom", message: "Expected a row object." });
125
+ return z.NEVER;
126
+ }
127
+ const entries: [string, z.output<typeof cell>][] = [];
128
+ for (const [id, input] of Object.entries(value)) {
129
+ const parsed = cell.safeParse(input);
130
+ if (!parsed.success) {
131
+ for (const issue of parsed.error.issues) ctx.addIssue({ ...issue, path: [id, ...issue.path] });
132
+ } else entries.push([id, parsed.data]);
133
+ }
134
+ return Object.fromEntries(entries);
135
+ });
136
+ return row;
137
+ };
138
+ const tableColumnsSchema = () => {
139
+ return z
140
+ .array(
141
+ z.strictObject({
142
+ id: z.string().trim().min(1).max(80),
143
+ label: z.string().min(1).max(80).optional(),
144
+ type: cellTypeSchema(),
145
+ format: cloudAiNumberFormatSchema().optional(),
146
+ }),
147
+ )
148
+ .min(1)
149
+ .max(CLOUD_AI_TABLE_MAX_COLUMNS);
150
+ };
151
+ const tableViewSchema = () => {
152
+ const row = tableRowSchema();
153
+ return z
154
+ .strictObject({
155
+ view: z.literal("table"),
156
+ title: z.string().trim().min(1).max(160),
157
+ subtitle: z.string().trim().min(1).max(300).optional(),
158
+ columns: tableColumnsSchema(),
159
+ rows: z.array(row).max(CLOUD_AI_TABLE_MAX_ROWS),
160
+ footer: row.optional(),
161
+ sort: z.strictObject({ column: z.string(), direction: z.enum(["asc", "desc"]) }).optional(),
162
+ })
163
+ .superRefine((value, ctx) => {
164
+ const columns = checkTableData(value, ctx);
165
+ if (value.sort && !columns.has(value.sort.column))
166
+ ctx.addIssue({ code: "custom", path: ["sort", "column"], message: "Unknown column id." });
167
+ });
168
+ };
169
+ const checkTableData = (
170
+ value: {
171
+ columns: z.output<ReturnType<typeof tableColumnsSchema>>;
172
+ rows: z.output<ReturnType<typeof tableRowSchema>>[];
173
+ footer?: z.output<ReturnType<typeof tableRowSchema>>;
174
+ },
175
+ ctx: z.RefinementCtx,
176
+ ) => {
177
+ const columns = new Map(value.columns.map((column) => [column.id, column]));
178
+ value.columns.forEach((column, index) => {
179
+ if (value.columns.findIndex((entry) => entry.id === column.id) !== index)
180
+ ctx.addIssue({ code: "custom", path: ["columns", index, "id"], message: "Column ids must be unique." });
181
+ if (column.format && column.type !== "number")
182
+ ctx.addIssue({ code: "custom", path: ["columns", index, "format"], message: "Only number columns may have a format." });
183
+ });
184
+ const checkRow = (cells: z.output<ReturnType<typeof tableRowSchema>>, path: (string | number)[]) => {
185
+ for (const [id, cell] of Object.entries(cells)) {
186
+ const column = columns.get(id);
187
+ const message = column ? cellIssue(column.type, cell) : "Unknown column id.";
188
+ if (message) ctx.addIssue({ code: "custom", path: [...path, id], message });
189
+ }
190
+ };
191
+ value.rows.forEach((cells, index) => checkRow(cells, ["rows", index]));
192
+ if (value.footer) checkRow(value.footer, ["footer"]);
193
+ return columns;
194
+ };
195
+ const groupViewSchema = () => cloudAiGroupSchema().safeExtend({ view: z.literal("group") });
196
+ const GroupViewSchema = /* @__PURE__ */ groupViewSchema();
197
+ const ChartViewSchema = /* @__PURE__ */ chartViewSchema();
198
+ const TableViewSchema = /* @__PURE__ */ tableViewSchema();
199
+ const StatsViewSchema = /* @__PURE__ */ statsViewSchema();
200
+ const DetailsViewSchema = /* @__PURE__ */ detailsViewSchema();
201
+ export type CloudAiChartView = z.output<typeof ChartViewSchema>;
202
+ export type CloudAiTableView = z.output<typeof TableViewSchema>;
203
+ export type CloudAiGroupView = z.output<typeof GroupViewSchema>;
204
+ export type CloudAiStatsView = z.output<typeof StatsViewSchema>;
205
+ export type CloudAiDetailsView = z.output<typeof DetailsViewSchema>;
206
+ export type CloudAiView =
207
+ | CloudAiChartView
208
+ | CloudAiTableView
209
+ | CloudAiGroupView
210
+ | CloudAiStatsView
211
+ | CloudAiDetailsView
212
+ | CloudAiExplorerView;
213
+
214
+ const explorerViewSchema = () => {
215
+ const common = { id: z.string().trim().min(1).max(40), label: z.string().trim().min(1).max(80), x: z.string() };
216
+ const ys = z.union([z.string(), z.array(z.string()).min(1).max(CLOUD_AI_CHART_MAX_SERIES)]);
217
+ const points = { ...common, y: ys, series: z.string().optional() };
218
+ return z.strictObject({
219
+ view: z.literal("explorer"),
220
+ title: z.string().trim().min(1).max(160),
221
+ subtitle: z.string().trim().min(1).max(300).optional(),
222
+ columns: tableColumnsSchema(),
223
+ rows: z.array(tableRowSchema()).max(CLOUD_AI_TABLE_MAX_ROWS),
224
+ views: z
225
+ .array(
226
+ z.discriminatedUnion("kind", [
227
+ z.strictObject({ ...common, kind: z.literal("bar"), y: z.string() }),
228
+ z.strictObject({ ...points, kind: z.literal("line") }),
229
+ z.strictObject({ ...points, kind: z.literal("scatter") }),
230
+ z.strictObject({ ...common, kind: z.literal("heatmap"), y: z.string(), value: z.string() }),
231
+ ]),
232
+ )
233
+ .min(1)
234
+ .max(CLOUD_AI_GROUP_MAX_CHARTS),
235
+ controls: z.strictObject({ filter: z.string().optional(), step: z.string().optional(), compare: z.boolean().optional() }).optional(),
236
+ });
237
+ };
238
+ const ExplorerDataSchema = /* @__PURE__ */ explorerViewSchema();
239
+ export type CloudAiExplorerView = z.output<typeof ExplorerDataSchema>;
240
+ const ExplorerViewSchema = ExplorerDataSchema.superRefine((value, ctx) => {
241
+ const columns = checkTableData(value, ctx);
242
+ const issue = (path: (string | number)[], message: string) => ctx.addIssue({ code: "custom", path, message });
243
+ const reference = (id: string, path: (string | number)[], types?: readonly string[]) => {
244
+ const column = columns.get(id);
245
+ if (!column) issue(path, "Unknown column id; choose an id from columns.");
246
+ // A reference opens an object; it has no value to plot, group or step by.
247
+ else if (column.type === "ref") issue(path, "Reference columns show only in the table; choose a text, number or date column.");
248
+ else if (types && !types.includes(column.type)) issue(path, `Choose a ${types.join(", ")} column.`);
249
+ };
250
+ const nonNull = (id: string, path: (string | number)[], max: number) => {
251
+ reference(id, path);
252
+ const cells = value.rows.map((row) => cloudAiViewScalarCellOf(row, id));
253
+ if (cells.some((cell) => cell == null)) issue(path, "Every row needs a non-null cell in this column; fill missing cells first.");
254
+ if (new Set(cells).size > max) issue(path, `Use at most ${max} distinct values in this column; aggregate or split the dataset.`);
255
+ };
256
+ if (value.controls?.filter !== undefined) nonNull(value.controls.filter, ["controls", "filter"], CLOUD_AI_CHART_MAX_SERIES);
257
+ if (value.controls?.step !== undefined) nonNull(value.controls.step, ["controls", "step"], CLOUD_AI_CHART_MAX_VALUES);
258
+ if (value.controls?.compare && value.controls.step === undefined)
259
+ issue(["controls", "compare"], "Comparison needs controls.step; choose a step column.");
260
+ value.views.forEach((chart, index) => {
261
+ const path = ["views", index];
262
+ if (value.views.findIndex((entry) => entry.id === chart.id) !== index)
263
+ issue([...path, "id"], "View ids must be unique; choose a different id.");
264
+ const point = chart.kind === "line" || chart.kind === "scatter";
265
+ reference(chart.x, [...path, "x"], point ? ["number", "date", "datetime"] : undefined);
266
+ const ys = Array.isArray(chart.y) ? chart.y : [chart.y];
267
+ ys.forEach((id, i) => {
268
+ const yPath = [...path, "y", ...(Array.isArray(chart.y) ? [i] : [])];
269
+ reference(id, yPath, chart.kind === "heatmap" ? undefined : ["number"]);
270
+ if (ys.indexOf(id) !== i) issue(yPath, "Y columns must be unique; remove the repeated column.");
271
+ });
272
+ if (chart.kind === "heatmap") reference(chart.value, [...path, "value"], ["number"]);
273
+ if (point && chart.series !== undefined) {
274
+ if (Array.isArray(chart.y)) issue([...path, "series"], "Series needs a single y column; use y as a column id.");
275
+ nonNull(chart.series, [...path, "series"], CLOUD_AI_CHART_MAX_SERIES);
276
+ }
277
+ if (value.controls?.compare && !point) issue(path, "Comparison needs line or scatter views; change the kind or disable compare.");
278
+ if (value.rows.length * ys.length > CLOUD_AI_VIEW_MAX_MARKS)
279
+ issue(
280
+ path,
281
+ `The view has ${value.rows.length * ys.length} marks; the limit is ${CLOUD_AI_VIEW_MAX_MARKS}. Aggregate or split into several views.`,
282
+ );
283
+ });
284
+ // Projection assumes typed cells and known columns; report those errors before computing axes.
285
+ if (ctx.issues.length) return;
286
+ const format = cloudAiViewCellFormatter(value.columns, "en");
287
+ value.views.forEach((chart, index) => {
288
+ if (chart.kind !== "bar" && chart.kind !== "heatmap") return;
289
+ const seen = new Map<string | number | null, Set<string>>();
290
+ for (const row of value.rows) {
291
+ if (cloudAiViewScalarCellOf(row, chart.kind === "bar" ? chart.y : chart.value) == null) continue;
292
+ const step = value.controls?.step === undefined ? null : cloudAiViewScalarCellOf(row, value.controls.step);
293
+ const labels = chart.kind === "bar" ? [format(chart.x, row)] : [format(chart.x, row), format(chart.y, row)];
294
+ const key = JSON.stringify(labels);
295
+ const keys = seen.get(step) ?? new Set<string>();
296
+ if (keys.has(key)) {
297
+ issue(
298
+ ["views", index],
299
+ chart.kind === "bar"
300
+ ? "Use one row per displayed x label in each step; aggregate rows first."
301
+ : "Use one row per displayed x/y label pair in each step; aggregate rows first.",
302
+ );
303
+ break;
304
+ }
305
+ keys.add(key);
306
+ seen.set(step, keys);
307
+ }
308
+ });
309
+ if (ctx.issues.length) return;
310
+ value.views.forEach((chart, index) => {
311
+ if (chart.kind === "heatmap") return;
312
+ const ys = Array.isArray(chart.y) ? chart.y : [chart.y];
313
+ const counts = new Map<string | number | null, Map<string | number | null, number>>();
314
+ let largest = 0;
315
+ for (const row of value.rows) {
316
+ if (chart.kind !== "bar" && cloudAiViewScalarCellOf(row, chart.x) == null) continue;
317
+ const step = value.controls?.step === undefined ? null : cloudAiViewScalarCellOf(row, value.controls.step);
318
+ for (const id of ys) {
319
+ if (typeof cloudAiViewScalarCellOf(row, id) !== "number") continue;
320
+ const series = chart.kind !== "bar" && chart.series !== undefined ? cloudAiViewScalarCellOf(row, chart.series) : id;
321
+ let state = counts.get(step);
322
+ if (!state) {
323
+ state = new Map();
324
+ counts.set(step, state);
325
+ }
326
+ const count = (state.get(series) ?? 0) + 1;
327
+ state.set(series, count);
328
+ largest = Math.max(largest, count);
329
+ }
330
+ }
331
+ if (largest > CLOUD_AI_CHART_MAX_VALUES)
332
+ issue(
333
+ ["views", index],
334
+ `The view draws ${largest} ${chart.kind === "bar" ? "bars" : "points in one series"} ${value.controls?.step === undefined ? "in total" : "in one step"}; the limit is ${CLOUD_AI_CHART_MAX_VALUES}. Aggregate the rows, keep the top ones, or add a step column.`,
335
+ );
336
+ });
337
+ if (ctx.issues.length) return;
338
+ const whole = cloudAiExplorerState(value, {}, "en");
339
+ // Date axes vary by step. Group once; checking their points needs neither legends nor dataset-wide domains again.
340
+ const stepRows = new Map<string | number | null, typeof value.rows>();
341
+ if (value.controls?.step !== undefined)
342
+ for (const row of value.rows) {
343
+ const key = cloudAiViewScalarCellOf(row, value.controls.step);
344
+ const rows = stepRows.get(key);
345
+ if (rows) rows.push(row);
346
+ else stepRows.set(key, [row]);
347
+ }
348
+ value.views.forEach((chart, index) => {
349
+ const input = whole.charts[chart.id]!.input;
350
+ // Check the whole fixed domain, including values whose other coordinate is null.
351
+ let raw: CloudAiChartInput = input;
352
+ if (input.kind === "bar")
353
+ raw = { ...input, data: [...input.data, ...(input.yAxis?.domain ?? []).map((value) => ({ label: "", value }))], yAxis: undefined };
354
+ if (input.kind === "line" || input.kind === "scatter") {
355
+ const points = input.series.flatMap((series) => series.data);
356
+ const xDomain = input.xAxis?.domain;
357
+ const yDomain = input.yAxis?.domain;
358
+ raw = {
359
+ ...input,
360
+ xAxis: undefined,
361
+ yAxis: undefined,
362
+ series: [
363
+ {
364
+ data: [
365
+ ...points,
366
+ ...(yDomain ?? []).map((y) => ({ x: points[0]?.x ?? xDomain?.[0] ?? 0, y })),
367
+ ...(xDomain ?? []).map((x) => ({ x, y: yDomain?.[0] ?? 0 })),
368
+ ],
369
+ },
370
+ ],
371
+ };
372
+ }
373
+ const domains = [
374
+ ...("xAxis" in input && input.xAxis?.domain ? [input.xAxis.domain] : []),
375
+ ...("yAxis" in input && input.yAxis?.domain ? [input.yAxis.domain] : []),
376
+ ];
377
+ const stepping = domains.some(
378
+ ([min, max]) =>
379
+ min === undefined ||
380
+ max === undefined ||
381
+ !Number.isFinite(min) ||
382
+ !Number.isFinite(max) ||
383
+ min >= max ||
384
+ !Number.isFinite(max - min),
385
+ )
386
+ ? "The values cannot form a finite axis; rescale them first."
387
+ : chartSteppingIssue(raw);
388
+ if (stepping) issue(["views", index], stepping);
389
+ if (chart.kind === "heatmap" && input.kind === "heatmap") {
390
+ const pairs = new Map(
391
+ value.rows.map((row) => {
392
+ const cell = { x: format(chart.x, row), y: format(chart.y, row), value: 0 };
393
+ return [JSON.stringify([cell.x, cell.y]), cell];
394
+ }),
395
+ );
396
+ const fit = chartDataIssue({ kind: "heatmap", title: chart.label, data: [...pairs.values()] });
397
+ if (fit) issue(["views", index], fit);
398
+ }
399
+ if (
400
+ (input.kind === "line" || input.kind === "scatter") &&
401
+ columns.get(chart.x)!.type !== "number" &&
402
+ value.controls?.step !== undefined
403
+ ) {
404
+ const ys = Array.isArray(chart.y) ? chart.y : [chart.y];
405
+ stepRows.forEach((rows) => {
406
+ const data: { x: string; y: number }[] = [];
407
+ for (const row of rows) {
408
+ const x = cloudAiViewScalarCellOf(row, chart.x);
409
+ if (typeof x !== "string") continue;
410
+ for (const id of ys) {
411
+ const y = cloudAiViewScalarCellOf(row, id);
412
+ if (typeof y === "number") data.push({ x, y });
413
+ }
414
+ }
415
+ const error = chartSteppingIssue({ ...input, series: [{ data }] });
416
+ if (error) issue(["views", index], error);
417
+ });
418
+ }
419
+ });
420
+ });
421
+
422
+ const summarySchema = () =>
423
+ z.discriminatedUnion("view", [
424
+ z.object({
425
+ view: z.literal("chart"),
426
+ kind: cloudAiChartKindSchema(),
427
+ title: z.string(),
428
+ subtitle: z.string().optional(),
429
+ }),
430
+ z.object({
431
+ view: z.literal("group"),
432
+ title: z.string(),
433
+ subtitle: z.string().optional(),
434
+ linkX: z.boolean(),
435
+ layout: z.enum(["stack", "grid"]),
436
+ charts: z
437
+ .array(z.object({ kind: cloudAiChartKindSchema(), title: z.string(), subtitle: z.string().optional() }))
438
+ .min(2)
439
+ .max(CLOUD_AI_GROUP_MAX_CHARTS),
440
+ }),
441
+ z.object({
442
+ view: z.literal("explorer"),
443
+ title: z.string(),
444
+ subtitle: z.string().optional(),
445
+ rows: z.number().int().nonnegative().max(CLOUD_AI_TABLE_MAX_ROWS),
446
+ columns: z.number().int().min(1).max(CLOUD_AI_TABLE_MAX_COLUMNS),
447
+ views: z
448
+ .array(
449
+ z.object({
450
+ id: z.string().min(1).max(40),
451
+ label: z.string().min(1).max(80),
452
+ kind: z.enum(["bar", "line", "scatter", "heatmap"]),
453
+ }),
454
+ )
455
+ .min(1)
456
+ .max(CLOUD_AI_GROUP_MAX_CHARTS),
457
+ step: z.boolean(),
458
+ filter: z.boolean(),
459
+ compare: z.boolean(),
460
+ }),
461
+ z.object({
462
+ view: z.literal("table"),
463
+ title: z.string(),
464
+ subtitle: z.string().optional(),
465
+ rows: z.number().int().nonnegative().max(CLOUD_AI_TABLE_MAX_ROWS),
466
+ // The block reserves the totals row from the first frame.
467
+ footer: z.boolean(),
468
+ columns: z.number().int().min(1).max(CLOUD_AI_TABLE_MAX_COLUMNS),
469
+ }),
470
+ z.object({
471
+ view: z.literal("stats"),
472
+ title: z.string(),
473
+ subtitle: z.string().optional(),
474
+ items: z
475
+ .array(z.object({ label: z.string(), sparkline: z.boolean(), sub: z.boolean(), delta: z.boolean() }))
476
+ .min(1)
477
+ .max(CLOUD_AI_STATS_MAX_ITEMS),
478
+ }),
479
+ z.object({
480
+ view: z.literal("details"),
481
+ title: z.string(),
482
+ subtitle: z.string().optional(),
483
+ items: z.number().int().min(1).max(CLOUD_AI_TABLE_MAX_COLUMNS),
484
+ // The fields the viewport shows before it scrolls: label, whether the value is a reference pill, and the length
485
+ // of its text, so the block reserves their wrapped height without their values.
486
+ fields: z
487
+ .array(z.object({ label: z.string(), ref: z.boolean(), chars: z.number().int().nonnegative().max(CLOUD_AI_DETAILS_MAX_CHARS) }))
488
+ .min(1)
489
+ .max(CLOUD_AI_DETAILS_VIEWPORT_ITEMS),
490
+ }),
491
+ ]);
492
+ export const CloudAiViewSummarySchema = /* @__PURE__ */ summarySchema();
493
+ export type CloudAiViewSummary = z.output<typeof CloudAiViewSummarySchema>;
494
+
495
+ const unknownViewSchema = () =>
496
+ z.unknown().transform((_value, ctx) => {
497
+ ctx.addIssue({ code: "custom", path: ["view"], message: "Expected one of chart, group, table, stats, details, explorer." });
498
+ return z.NEVER;
499
+ });
500
+ const UnknownViewSchema = /* @__PURE__ */ unknownViewSchema();
501
+ const parse = (value: unknown) => {
502
+ if (typeof value === "object" && value !== null && "view" in value) {
503
+ switch (value.view) {
504
+ case "chart":
505
+ return ChartViewSchema.safeParse(value);
506
+ case "group":
507
+ return GroupViewSchema.safeParse(value);
508
+ case "table":
509
+ return TableViewSchema.safeParse(value);
510
+ case "stats":
511
+ return StatsViewSchema.safeParse(value);
512
+ case "details":
513
+ return DetailsViewSchema.safeParse(value);
514
+ case "explorer":
515
+ return ExplorerViewSchema.safeParse(value);
516
+ }
517
+ }
518
+ return UnknownViewSchema.safeParse(value);
519
+ };
520
+ export const parseCloudAiView = (value: unknown): CloudAiView | undefined => {
521
+ const parsed = parse(value);
522
+ return parsed.success ? parsed.data : undefined;
523
+ };
524
+ export const checkCloudAiView = (value: unknown): { view: CloudAiView } | { issues: string[] } => {
525
+ const parsed = parse(value);
526
+ if (!parsed.success)
527
+ return {
528
+ issues: parsed.error.issues.map((issue) => {
529
+ const path = issue.path.reduce<string>(
530
+ (text, part) => (typeof part === "number" ? `${text}[${part}]` : `${text}${text ? "." : ""}${String(part)}`),
531
+ "",
532
+ );
533
+ return `${path || "view"}: ${issue.message.replace(/\.?$/, ".")}`;
534
+ }),
535
+ };
536
+ if (parsed.data.view === "chart") {
537
+ const issue = chartDrawingIssue(parsed.data);
538
+ if (issue) return { issues: [`chart: ${issue}`] };
539
+ }
540
+ if (parsed.data.view === "group") {
541
+ const issues = parsed.data.charts.flatMap((chart, index) => {
542
+ const issue = chartDrawingIssue(chart);
543
+ return issue ? [`charts[${index}]: ${issue}`] : [];
544
+ });
545
+ if (issues.length) return { issues };
546
+ }
547
+ return { view: parsed.data };
548
+ };
549
+ export const cloudAiViewSummary = (view: CloudAiView): CloudAiViewSummary => {
550
+ const common = { title: view.title, ...(view.subtitle ? { subtitle: view.subtitle } : {}) };
551
+ if (view.view === "explorer")
552
+ return {
553
+ view: "explorer",
554
+ ...common,
555
+ rows: view.rows.length,
556
+ columns: view.columns.length,
557
+ views: view.views.map(({ id, label, kind }) => ({ id, label, kind })),
558
+ step: view.controls?.step !== undefined,
559
+ filter: view.controls?.filter !== undefined,
560
+ compare: view.controls?.compare ?? false,
561
+ };
562
+ if (view.view === "group")
563
+ return {
564
+ view: "group",
565
+ ...common,
566
+ linkX: view.linkX ?? false,
567
+ layout: view.layout ?? "stack",
568
+ charts: view.charts.map((chart) => ({
569
+ kind: chart.kind,
570
+ title: chart.title,
571
+ ...(chart.subtitle ? { subtitle: chart.subtitle } : {}),
572
+ })),
573
+ };
574
+ if (view.view === "stats")
575
+ return {
576
+ view: "stats",
577
+ ...common,
578
+ items: view.items.map((item) => ({
579
+ label: item.label,
580
+ sparkline: item.sparkline !== undefined,
581
+ sub: item.sub !== undefined,
582
+ delta: item.delta !== undefined,
583
+ })),
584
+ };
585
+ if (view.view === "details")
586
+ return {
587
+ view: "details",
588
+ ...common,
589
+ items: view.items.length,
590
+ // English text has about the length of the viewer's: dates and amounts differ by a character or two.
591
+ fields: cloudAiDetailsRows({ ...view, items: view.items.slice(0, CLOUD_AI_DETAILS_VIEWPORT_ITEMS) }, "en-US").map((row) => ({
592
+ label: row.label,
593
+ ref: row.ref !== undefined,
594
+ chars: Math.min(row.text.length, CLOUD_AI_DETAILS_MAX_CHARS),
595
+ })),
596
+ };
597
+ return view.view === "chart"
598
+ ? { view: "chart", kind: view.kind, ...common }
599
+ : { view: "table", rows: view.rows.length, columns: view.columns.length, footer: view.footer !== undefined, ...common };
600
+ };
601
+ /** Tool arguments become the same canonical views as saved code-run files, without drawing on revalidation. */
602
+ export const parseCloudAiChartToolView = (args: unknown): CloudAiChartView | CloudAiGroupView | undefined => {
603
+ if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
604
+ const value = Object.fromEntries(Object.entries(args));
605
+ if ((value.kind !== undefined) === (value.charts !== undefined)) return undefined;
606
+ if (value.charts !== undefined) {
607
+ const { title, subtitle, charts, linkX, layout } = value;
608
+ const parsed = GroupViewSchema.safeParse({ title, subtitle, charts, linkX, layout, view: "group" });
609
+ return parsed.success ? parsed.data : undefined;
610
+ }
611
+ if (value.linkX !== undefined || value.layout !== undefined) return undefined;
612
+ const parsed = ChartViewSchema.safeParse({ ...value, view: "chart" });
613
+ return parsed.success ? parsed.data : undefined;
614
+ };
615
+ export const isAiViewPath = (path: string) => path === CLOUD_AI_VIEW_DIRECTORY || path.startsWith(`${CLOUD_AI_VIEW_DIRECTORY}/`);
616
+ export const isAiViewFile = (file: { path: string; origin: string }) =>
617
+ file.origin === "assistant" && file.path.startsWith(`${CLOUD_AI_VIEW_DIRECTORY}/`) && file.path.endsWith(CLOUD_AI_VIEW_SUFFIX);
618
+
619
+ const viewDateFormatter = (locale: string) => {
620
+ let dateFormat: Intl.DateTimeFormat | undefined;
621
+ let shortTimeFormat: Intl.DateTimeFormat | undefined;
622
+ let mediumTimeFormat: Intl.DateTimeFormat | undefined;
623
+ return (type: "date" | "datetime", cell: string): string => {
624
+ const options = { timeZone: "UTC", dateStyle: "medium" } as const;
625
+ if (type === "date") {
626
+ dateFormat ??= new Intl.DateTimeFormat(locale, options);
627
+ return dateFormat.format(chartDateTime(cell));
628
+ }
629
+ if (cell.length > 16) {
630
+ mediumTimeFormat ??= new Intl.DateTimeFormat(locale, { ...options, timeStyle: "medium" });
631
+ return mediumTimeFormat.format(chartDateTime(cell));
632
+ }
633
+ shortTimeFormat ??= new Intl.DateTimeFormat(locale, { ...options, timeStyle: "short" });
634
+ return shortTimeFormat.format(chartDateTime(cell));
635
+ };
636
+ };
637
+
638
+ /**
639
+ * The text of every stat tile, the same for the web chat and the terminal. A change carries its sign and a tone the
640
+ * host colors: better or worse only when the view says which direction improves the measure.
641
+ */
642
+ export function cloudAiStatsTiles(
643
+ view: CloudAiStatsView,
644
+ locale: string,
645
+ ): {
646
+ label: string;
647
+ value: string;
648
+ sub?: string;
649
+ sparkline?: number[];
650
+ delta?: { text: string; direction: "up" | "down" | "flat"; tone: "better" | "worse" | "neutral" };
651
+ }[] {
652
+ return view.items.map((item) => {
653
+ const format = cloudAiNumberFormatters(item.format, locale).value;
654
+ const direction = item.delta === undefined || item.delta === 0 ? "flat" : item.delta > 0 ? "up" : "down";
655
+ return {
656
+ label: item.label,
657
+ value: typeof item.value === "number" ? format(item.value) : item.value,
658
+ ...(item.sub !== undefined ? { sub: item.sub } : {}),
659
+ ...(item.sparkline !== undefined ? { sparkline: item.sparkline } : {}),
660
+ ...(item.delta !== undefined
661
+ ? {
662
+ delta: {
663
+ // -0 shows as 0, without a sign.
664
+ text: `${item.delta > 0 ? "+" : ""}${format(item.delta === 0 ? 0 : item.delta)}`,
665
+ direction,
666
+ tone: !item.better || item.delta === 0 ? "neutral" : direction === item.better ? "better" : "worse",
667
+ },
668
+ }
669
+ : {}),
670
+ };
671
+ });
672
+ }
673
+
674
+ /** The label and display text of every field of a record; a reference keeps its object so the host can open it. */
675
+ export function cloudAiDetailsRows(
676
+ view: CloudAiDetailsView,
677
+ locale: string,
678
+ ): { label: string; text: string; ref?: CloudAiViewReference }[] {
679
+ const date = viewDateFormatter(locale);
680
+ return view.items.map((item) => {
681
+ const value = item.value;
682
+ if (value === null) return { label: item.label, text: "" };
683
+ if (typeof value === "object") return { label: item.label, text: value.title ?? value.id, ref: value };
684
+ // A number in a text field stays as written, as in a table's text column.
685
+ const text =
686
+ item.type === "number" && typeof value === "number"
687
+ ? cloudAiNumberFormatters(item.format, locale).value(value)
688
+ : (item.type === "date" || item.type === "datetime") && typeof value === "string"
689
+ ? date(item.type, value)
690
+ : String(value);
691
+ return { label: item.label, text };
692
+ });
693
+ }
694
+
695
+ export type CloudAiViewTable = CloudAiChartTable & { footer?: CloudAiChartRow };
696
+ // Column ids are data; missing cells must not read Object.prototype.
697
+ export const cloudAiViewCellOf = (cells: CloudAiTableView["rows"][number], id: string) => (Object.hasOwn(cells, id) ? cells[id]! : null);
698
+
699
+ /** Explorer encodings reject ref columns before projection; reference cells have no scalar value. */
700
+ export const cloudAiViewScalarCellOf = (cells: CloudAiTableView["rows"][number], id: string): string | number | null => {
701
+ const cell = cloudAiViewCellOf(cells, id);
702
+ return typeof cell === "object" ? null : cell;
703
+ };
704
+
705
+ export function cloudAiViewCellFormatter(columns: CloudAiTableView["columns"], locale: string) {
706
+ const byId = new Map(columns.map((column) => [column.id, column]));
707
+ const numberFormats = new Map(
708
+ columns
709
+ .filter((column) => column.type === "number")
710
+ .map((column) => {
711
+ return [column.id, cloudAiNumberFormatters(column.format, locale).value] as const;
712
+ }),
713
+ );
714
+ const date = viewDateFormatter(locale);
715
+ const formatCell = (
716
+ column: CloudAiTableView["columns"][number],
717
+ cell: Exclude<CloudAiTableView["rows"][number][string], null>,
718
+ ): string => {
719
+ if (typeof cell === "object") return cell.title ?? cell.id;
720
+ if (column.type === "number" && typeof cell === "number") return numberFormats.get(column.id)!(cell);
721
+ if ((column.type === "date" || column.type === "datetime") && typeof cell === "string") return date(column.type, cell);
722
+ return String(cell);
723
+ };
724
+ return (id: string, cells: CloudAiTableView["rows"][number]): string => {
725
+ const cell = cloudAiViewCellOf(cells, id);
726
+ return cell == null ? "" : formatCell(byId.get(id)!, cell);
727
+ };
728
+ }
729
+ /** Row keys are original row indices, so hosts resolve refs through view.rows[Number(row.key)][column.id] even after sorting. */
730
+ export function cloudAiViewTable(view: CloudAiTableView | CloudAiExplorerView, locale: string): CloudAiViewTable {
731
+ const cellOf = cloudAiViewCellOf;
732
+ const formatCell = cloudAiViewCellFormatter(view.columns, locale);
733
+ const row = (cells: CloudAiTableView["rows"][number], key: string): CloudAiChartRow => ({
734
+ key,
735
+ cells: Object.fromEntries(view.columns.map((column) => [column.id, formatCell(column.id, cells)])),
736
+ values: Object.fromEntries(
737
+ view.columns.flatMap((column) => {
738
+ const cell = cellOf(cells, column.id);
739
+ return cell == null ? [] : [[column.id, typeof cell === "object" ? (cell.title ?? cell.id) : cell]];
740
+ }),
741
+ ),
742
+ });
743
+ const rows = view.rows.map((cells, index) => row(cells, String(index)));
744
+ return {
745
+ columns: view.columns.map((column) => ({ id: column.id, label: column.label ?? column.id, numeric: column.type === "number" })),
746
+ // The view's own order, so every reader starts from the same rows.
747
+ rows: "sort" in view && view.sort ? sortCloudAiViewRows(rows, { key: view.sort.column, direction: view.sort.direction }, locale) : rows,
748
+ ...("footer" in view && view.footer ? { footer: row(view.footer, "footer") } : {}),
749
+ };
750
+ }
751
+
752
+ /** Rows of a view table in the given order: numbers by value, text by the locale, empty cells last in both directions. */
753
+ export function sortCloudAiViewRows(
754
+ rows: readonly CloudAiChartRow[],
755
+ sort: { key: string; direction: "asc" | "desc" },
756
+ locale: string,
757
+ ): CloudAiChartRow[] {
758
+ const collator = new Intl.Collator(locale, { numeric: true });
759
+ // Column ids are data; a missing cell must not read an inherited property such as "constructor".
760
+ const value = (row: CloudAiChartRow) => (Object.hasOwn(row.values, sort.key) ? row.values[sort.key] : undefined);
761
+ return [...rows].sort((a, b) => {
762
+ const av = value(a);
763
+ const bv = value(b);
764
+ if (av === undefined) return bv === undefined ? 0 : 1;
765
+ if (bv === undefined) return -1;
766
+ const comparison = typeof av === "number" && typeof bv === "number" ? av - bv : collator.compare(String(av), String(bv));
767
+ return sort.direction === "asc" ? comparison : -comparison;
768
+ });
769
+ }
770
+ export function cloudAiTableTsv(table: CloudAiViewTable): string {
771
+ const cell = (text: string, numeric = false) => {
772
+ const safe = !numeric && /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
773
+ return /[\t\r\n"]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe;
774
+ };
775
+ return [
776
+ table.columns.map((column) => cell(column.label)).join("\t"),
777
+ ...[...table.rows, ...(table.footer ? [table.footer] : [])].map((row) =>
778
+ table.columns.map((column) => cell(row.cells[column.id] ?? "", column.numeric)).join("\t"),
779
+ ),
780
+ ].join("\n");
781
+ }
782
+
783
+ export type CloudAiExplorerRequest = { step?: string; visibleKeys?: readonly string[]; referenceStep?: string };
784
+ type Cell = string | number | null;
785
+ const distinct = (view: CloudAiExplorerView, id: string) => [...new Set(view.rows.map((row) => cloudAiViewScalarCellOf(row, id)))];
786
+
787
+ /** Localized step and filter choices in dataset order, initially showing the last step. */
788
+ export function cloudAiExplorerControls(view: CloudAiExplorerView, locale: string) {
789
+ const format = cloudAiViewCellFormatter(view.columns, locale);
790
+ const entries = (id: string | undefined) =>
791
+ id === undefined ? [] : distinct(view, id).map((cell, position) => ({ key: String(position), label: format(id, { [id]: cell }) }));
792
+ const steps = entries(view.controls?.step);
793
+ const series = entries(view.controls?.filter).map((entry, color) => ({ ...entry, color }));
794
+ const initial: CloudAiExplorerRequest = steps.length ? { step: steps.at(-1)!.key } : {};
795
+ return { steps, series, initial };
796
+ }
797
+
798
+ type ExplorerChartState = {
799
+ input: CloudAiChartInput;
800
+ marks: number[][];
801
+ legend: { label: string; color: number; style?: "solid" | "dashed"; reference?: boolean }[];
802
+ };
803
+
804
+ /** Project current and reference rows into every view, retaining dataset-wide axes and series palette slots. */
805
+ export function cloudAiExplorerState(
806
+ view: CloudAiExplorerView,
807
+ request: CloudAiExplorerRequest,
808
+ locale: string,
809
+ ): {
810
+ rows: number[];
811
+ charts: Record<string, ExplorerChartState>;
812
+ } {
813
+ const format = cloudAiViewCellFormatter(view.columns, locale);
814
+ const columns = new Map(view.columns.map((column) => [column.id, column]));
815
+ const stepId = view.controls?.step;
816
+ const filterId = view.controls?.filter;
817
+ const steps = stepId === undefined ? [] : distinct(view, stepId);
818
+ const filters = filterId === undefined ? [] : distinct(view, filterId);
819
+ const stepValue = (key: string | undefined): Cell | undefined =>
820
+ key === undefined ? undefined : steps.find((_, index) => String(index) === key);
821
+ const current = stepValue(request.step);
822
+ const reference = stepValue(request.referenceStep);
823
+ // A reference on the shown step would only draw every point twice; it compares once the slider moves.
824
+ const comparing = stepId !== undefined && reference !== undefined && request.referenceStep !== request.step;
825
+ const visible = filterId === undefined || request.visibleKeys === undefined ? undefined : new Set(request.visibleKeys);
826
+ const included = (index: number) =>
827
+ !visible || visible.has(String(filters.indexOf(cloudAiViewScalarCellOf(view.rows[index]!, filterId!))));
828
+ const currentRows = view.rows.flatMap((row, index) =>
829
+ included(index) && (stepId === undefined || request.step === undefined || cloudAiViewScalarCellOf(row, stepId) === current)
830
+ ? [index]
831
+ : [],
832
+ );
833
+ const referenceRows = comparing
834
+ ? view.rows.flatMap((row, index) => (included(index) && cloudAiViewScalarCellOf(row, stepId!) === reference ? [index] : []))
835
+ : [];
836
+ const shown = new Set([...currentRows, ...referenceRows]);
837
+ const rows = view.rows.flatMap((_, index) => (shown.has(index) ? [index] : []));
838
+ const numbers = (ids: string[]) =>
839
+ view.rows.flatMap((row) =>
840
+ ids.flatMap((id) => {
841
+ const cell = cloudAiViewScalarCellOf(row, id);
842
+ return typeof cell === "number" ? [cell] : [];
843
+ }),
844
+ );
845
+ const domain = (values: number[], zero = false): [number, number] => {
846
+ const [min, max] = computeDomain(zero ? [...values, 0] : values);
847
+ const step = niceStep(max - min, 5);
848
+ const ends = [Math.floor(min / step) * step, Math.ceil(max / step) * step];
849
+ // Invalid input is inspected before rejection too; never enter the stdlib tick loop for unsafe ranges.
850
+ if (!Number.isFinite(step) || step <= 0 || ends.some((end) => !Number.isFinite(end) || end + step === end || end - step === end))
851
+ return [min, max];
852
+ return extendDomainToNice(min, max, step).domain;
853
+ };
854
+ // A number x axis spans exactly the data, so months 1..12 do not start at a padded 0; value axes round to nice ends.
855
+ const axis = (ids: string[], options: { zero?: boolean; exact?: boolean } = {}) => {
856
+ const column = columns.get(ids[0]!)!;
857
+ const values = column.type === "number" ? numbers(ids) : [];
858
+ const [min, max] = values.length ? [Math.min(...values), Math.max(...values)] : [0, 0];
859
+ return {
860
+ ...(ids.length === 1 ? { label: column.label ?? column.id } : {}),
861
+ ...(column.format ? { format: column.format } : {}),
862
+ ...(column.type === "number" ? { domain: options.exact && min < max ? [min, max] : domain(values, options.zero) } : {}),
863
+ };
864
+ };
865
+ const charts = view.views.map((chart): [string, ExplorerChartState] => {
866
+ const title = chart.label;
867
+ if (chart.kind === "bar") {
868
+ const marks: number[][] = [[]];
869
+ const data = rows.flatMap((index) => {
870
+ const row = view.rows[index]!;
871
+ const value = cloudAiViewScalarCellOf(row, chart.y);
872
+ if (typeof value !== "number") return [];
873
+ marks[0]!.push(index);
874
+ return [{ label: format(chart.x, row), value }];
875
+ });
876
+ return [chart.id, { input: { kind: "bar", title, data, yAxis: axis([chart.y], { zero: true }) }, marks, legend: [] }];
877
+ }
878
+ if (chart.kind === "heatmap") {
879
+ const marks: number[][] = [[]];
880
+ const data = rows.flatMap((index) => {
881
+ const row = view.rows[index]!;
882
+ const value = cloudAiViewScalarCellOf(row, chart.value);
883
+ if (typeof value !== "number") return [];
884
+ marks[0]!.push(index);
885
+ return [{ x: format(chart.x, row), y: format(chart.y, row), value }];
886
+ });
887
+ const [min, max] = computeDomain(numbers([chart.value]));
888
+ return [chart.id, { input: { kind: "heatmap", title, data, min, max, format: columns.get(chart.value)!.format }, marks, legend: [] }];
889
+ }
890
+ const ys = Array.isArray(chart.y) ? chart.y : [chart.y];
891
+ const groups = chart.series === undefined ? undefined : distinct(view, chart.series);
892
+ const names = groups
893
+ ? groups.map((cell) => format(chart.series!, { [chart.series!]: cell }))
894
+ : ys.map((id) => columns.get(id)!.label ?? id);
895
+ const series: { label: string; data: { x: string | number; y: number }[]; role?: "reference" }[] = [];
896
+ const marks: number[][] = [];
897
+ const legend: ExplorerChartState["legend"] = [];
898
+ const stepLabel = (cell: Cell | undefined) => (stepId === undefined || cell === undefined ? "" : format(stepId, { [stepId]: cell }));
899
+ const add = (indexes: number[], offset: number, reference = false) => {
900
+ while (series.length < offset + names.length) {
901
+ series.push({ label: "", data: [] });
902
+ marks.push([]);
903
+ }
904
+ names.forEach((name, slot) => {
905
+ series[offset + slot] = {
906
+ label: comparing ? `${name} · ${stepLabel(reference ? stepValue(request.referenceStep) : current)}` : name,
907
+ data: [],
908
+ ...(reference ? { role: "reference" } : {}),
909
+ };
910
+ });
911
+ for (const index of indexes) {
912
+ const row = view.rows[index]!;
913
+ ys.forEach((id, yIndex) => {
914
+ const x = cloudAiViewScalarCellOf(row, chart.x);
915
+ const y = cloudAiViewScalarCellOf(row, id);
916
+ if (x == null || typeof y !== "number") return;
917
+ const slot = offset + (groups ? groups.indexOf(cloudAiViewScalarCellOf(row, chart.series!)) : yIndex);
918
+ series[slot]!.data.push({ x, y });
919
+ marks[slot]!.push(index);
920
+ });
921
+ }
922
+ names.forEach((_, slot) => {
923
+ const entry = series[offset + slot]!;
924
+ if (entry.data.length)
925
+ legend.push({
926
+ label: entry.label,
927
+ color: slot % 8,
928
+ ...(chart.kind === "line" ? { style: reference ? "dashed" : "solid" } : {}),
929
+ ...(reference ? { reference: true } : {}),
930
+ });
931
+ });
932
+ };
933
+ add(currentRows, 0);
934
+ if (comparing) add(referenceRows, 8, true);
935
+ return [
936
+ chart.id,
937
+ { input: { kind: chart.kind, title, series, xAxis: axis([chart.x], { exact: true }), yAxis: axis(ys) }, marks, legend },
938
+ ];
939
+ });
940
+ return { rows, charts: Object.fromEntries(charts) };
941
+ }