@k2b/cloud 0.31.0 → 0.32.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 (119) hide show
  1. package/package.json +6 -4
  2. package/src/_internal/define-app.ts +3 -1
  3. package/src/ai/app-skills.ts +1 -7
  4. package/src/ai/audio-tool.ts +2 -1
  5. package/src/ai/browser.ts +109 -0
  6. package/src/ai/capabilities.ts +6 -2
  7. package/src/ai/chart-block.ts +431 -81
  8. package/src/ai/chat/action-sentences.ts +13 -7
  9. package/src/ai/chat/blocks.tsx +11 -7
  10. package/src/ai/chat/builtin-tools.tsx +9 -7
  11. package/src/ai/chat/chart-block.tsx +218 -44
  12. package/src/ai/chat/explorer-block.tsx +276 -0
  13. package/src/ai/chat/file-tools.tsx +40 -1
  14. package/src/ai/chat/message-actions.tsx +6 -0
  15. package/src/ai/chat/message-utils.ts +7 -0
  16. package/src/ai/chat/messages.ts +16 -0
  17. package/src/ai/chat/presentation.tsx +13 -3
  18. package/src/ai/chat/reference-text.tsx +34 -0
  19. package/src/ai/chat/tool-groups.ts +1 -0
  20. package/src/ai/chat/tool-names.ts +14 -0
  21. package/src/ai/chat/turn-view.tsx +17 -7
  22. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  23. package/src/ai/chat/view-block.tsx +417 -0
  24. package/src/ai/chat/visual-tools.tsx +32 -53
  25. package/src/ai/client-tool-results.ts +9 -0
  26. package/src/ai/default-tool-contracts.ts +334 -91
  27. package/src/ai/default-tools.ts +87 -11
  28. package/src/ai/executor.ts +173 -31
  29. package/src/ai/file-error.ts +2 -0
  30. package/src/ai/file-tools.ts +27 -7
  31. package/src/ai/files-store.ts +60 -0
  32. package/src/ai/html-pdf-tool.ts +3 -2
  33. package/src/ai/http.ts +37 -12
  34. package/src/ai/index.ts +90 -0
  35. package/src/ai/local-tool-contracts.ts +244 -0
  36. package/src/ai/malformed-tool-call-repair.ts +144 -0
  37. package/src/ai/markdown-pdf-tool.ts +2 -1
  38. package/src/ai/migrate.ts +7 -0
  39. package/src/ai/openai-api-messages.ts +53 -0
  40. package/src/ai/openai-api-slots.ts +40 -0
  41. package/src/ai/openai-api.ts +383 -0
  42. package/src/ai/protocol.ts +6 -2
  43. package/src/ai/provider.ts +16 -4
  44. package/src/ai/request-context.ts +33 -0
  45. package/src/ai/routes.ts +96 -57
  46. package/src/ai/runtime.ts +48 -12
  47. package/src/ai/settings.ts +5 -0
  48. package/src/ai/skill-catalog.ts +20 -5
  49. package/src/ai/skill-format.ts +17 -7
  50. package/src/ai/skill-tool.ts +15 -5
  51. package/src/ai/skills-routes.ts +20 -6
  52. package/src/ai/skills.ts +26 -7
  53. package/src/ai/store.ts +25 -0
  54. package/src/ai/system-prompt.ts +69 -10
  55. package/src/ai/timeline.ts +3 -1
  56. package/src/ai/tools.ts +3 -0
  57. package/src/ai/types.ts +9 -0
  58. package/src/ai/view-block.ts +941 -0
  59. package/src/ai/vision-tool.ts +2 -1
  60. package/src/api/admin-identity.ts +6 -1
  61. package/src/api/app-approval-actions.ts +36 -0
  62. package/src/api/app-approval.ts +10 -2
  63. package/src/api/approvals-loader.ts +15 -0
  64. package/src/api/approvals.ts +120 -0
  65. package/src/api/index.ts +3 -0
  66. package/src/api/me.ts +12 -3
  67. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  68. package/src/approvals/Approvals.tsx +621 -0
  69. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  70. package/src/approvals/messages.ts +95 -0
  71. package/src/approvals/ui.ts +8 -0
  72. package/src/browser/app-approval.ts +32 -0
  73. package/src/browser/choose-files.tsx +26 -13
  74. package/src/browser/commands.ts +13 -4
  75. package/src/browser/file-provider-error.ts +11 -0
  76. package/src/browser/file-providers.ts +3 -12
  77. package/src/browser/files.tsx +1 -2
  78. package/src/browser/live-connection.ts +2 -3
  79. package/src/browser/provider-list.ts +2 -1
  80. package/src/browser/save-files.tsx +13 -2
  81. package/src/cli/account.ts +21 -2
  82. package/src/cli/commands.ts +4 -2
  83. package/src/contracts/app-approval.ts +120 -0
  84. package/src/contracts/approval-paths.ts +4 -0
  85. package/src/contracts/approvals.ts +81 -0
  86. package/src/contracts/command-path.ts +17 -0
  87. package/src/contracts/commands.ts +5 -15
  88. package/src/contracts/shared.ts +5 -0
  89. package/src/events/live-protocol.ts +2 -1
  90. package/src/events/live-server-message.ts +40 -0
  91. package/src/events/testing.ts +88 -0
  92. package/src/server/index.ts +2 -0
  93. package/src/server/middleware/auth.ts +11 -1
  94. package/src/server/request-timeout.ts +22 -0
  95. package/src/server/services/access.ts +4 -3
  96. package/src/services/app-approval.ts +103 -15
  97. package/src/services/approvals/cursor.ts +15 -0
  98. package/src/services/approvals/device.ts +112 -0
  99. package/src/services/approvals/index.ts +27 -11
  100. package/src/services/approvals/list.ts +120 -0
  101. package/src/services/approvals/migrate.ts +9 -1
  102. package/src/services/identity/models-only-auth.ts +25 -0
  103. package/src/services/notifications/email-frame.ts +16 -3
  104. package/src/services/service-account-credentials.ts +22 -10
  105. package/src/services/settings/core-settings.ts +27 -0
  106. package/src/shared/ai-platform-prompt.ts +26 -1
  107. package/src/shared/email-html.ts +4 -0
  108. package/src/shared/index.ts +6 -1
  109. package/src/shared/markdown/extensions/code.ts +14 -0
  110. package/src/shared/markdown/index.ts +4 -4
  111. package/src/ssr/AppBadge.tsx +4 -4
  112. package/src/ssr/AppLaunchpadPanel.tsx +4 -4
  113. package/src/ssr/RailApps.island.tsx +2 -2
  114. package/src/ssr/app-badges.ts +50 -6
  115. package/src/ssr/page-trail.browser-harness.tsx +52 -3
  116. package/src/ssr/rail-navigation.ts +5 -3
  117. package/src/ssr/runtime.ts +6 -1
  118. package/src/styles/approvals.css +390 -0
  119. package/src/styles/global.css +5 -2
@@ -1,3 +1,4 @@
1
+ import { createContext, useContext } from "solid-js";
1
2
  import {
2
3
  type CapabilityActionWording,
3
4
  capabilityActionOutcome,
@@ -10,9 +11,14 @@ import { displayToolName, isRecord } from "./message-utils";
10
11
 
11
12
  type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
12
13
 
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;
14
+ const ReaderTimeZoneContext = createContext<string>();
15
+
16
+ /** Gives the chat the reader's time zone from the request, so server and browser render the same times. */
17
+ export const AiReaderTimeZoneProvider = ReaderTimeZoneContext.Provider;
18
+
19
+ /** Instants show in the provided time zone, else the browser's own; outside a browser they stay in UTC. */
20
+ export const useReaderTimeZone = (): string =>
21
+ useContext(ReaderTimeZoneContext) ?? (typeof window === "undefined" ? "UTC" : Intl.DateTimeFormat().resolvedOptions().timeZone);
16
22
 
17
23
  /**
18
24
  * How the chat words one call that acts on something: an app Action through its saved presentation, a
@@ -33,17 +39,17 @@ export const toolWording = (block: ToolBlock, locale: string): CapabilityActionW
33
39
  };
34
40
 
35
41
  /** 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 => {
42
+ export const toolSubject = (block: ToolBlock, locale: string, timeZone: string): string | null => {
37
43
  const wording = toolWording(block, locale);
38
- return wording ? capabilityActionSubject(wording, block.args, { locale, timeZone: readerTimeZone() }) : null;
44
+ return wording ? capabilityActionSubject(wording, block.args, { locale, timeZone }) : null;
39
45
  };
40
46
 
41
47
  /** 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 => {
48
+ export const toolOutcome = (block: ToolBlock, key: "done" | "rejected" | "notRun", locale: string, timeZone: string): string | null => {
43
49
  const wording = toolWording(block, locale);
44
50
  if (!wording) return null;
45
51
  const result = isRecord(block.result) ? block.result : undefined;
46
52
  // App Actions return their data inside the result envelope; Cloud tools return the data itself.
47
53
  const data = block.presentation?.kind === "capability" ? result?.data : block.result;
48
- return capabilityActionOutcome(wording, key, { input: block.args, data }, { locale, timeZone: readerTimeZone() });
54
+ return capabilityActionOutcome(wording, key, { input: block.args, data }, { locale, timeZone });
49
55
  };
@@ -6,7 +6,7 @@ import { capabilityApprovalReason, capabilityApprovalReasonLabel } from "../../_
6
6
  import type { CapabilityActionReview } from "../../contracts/capabilities";
7
7
  import { markdown } from "../../shared";
8
8
  import type { AiTurnBlock } from "../protocol";
9
- import { toolOutcome, toolSubject } from "./action-sentences";
9
+ import { toolOutcome, toolSubject, useReaderTimeZone } from "./action-sentences";
10
10
  import { hasSpecializedBuiltinToolView, SpecializedBuiltinToolBlock } from "./builtin-tools";
11
11
  import { hasCapabilityTable } from "./capability-result";
12
12
  import { CapabilityTablePreview } from "./capability-table";
@@ -197,6 +197,7 @@ export type AiApprovalDecision = { callId: string; approved: boolean };
197
197
  export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onDecided?: (decision: AiApprovalDecision) => void }) {
198
198
  const actions = useAiChatActions();
199
199
  const locale = useLocale();
200
+ const timeZone = useReaderTimeZone();
200
201
  const t = () => aiChatMessages(locale());
201
202
  const request = () => ({ turnId: props.turnId, callId: props.block.callId, name: props.block.name });
202
203
  const pending = () => props.block.status === "awaiting_approval";
@@ -221,7 +222,8 @@ export function ApprovalBlockView(props: { turnId: string; block: ToolBlock; onD
221
222
  void approval.mutate(input);
222
223
  };
223
224
  // The heading says what will happen in the owning app's words; the app or the assistant name stays beside it.
224
- const title = () => toolSubject(props.block, locale()) ?? props.block.presentation?.title ?? displayToolName(props.block.name, locale());
225
+ const title = () =>
226
+ toolSubject(props.block, locale(), timeZone) ?? props.block.presentation?.title ?? displayToolName(props.block.name, locale());
225
227
  const ownerName = () => props.block.presentation?.appName ?? t().assistant;
226
228
  const capability = () => props.block.presentation?.kind === "capability";
227
229
  // The model's own reason, labelled as such, only where Cloud offered it; it adds to the app's sentence and never replaces it.
@@ -545,12 +547,13 @@ function CapabilityToolView(props: { block: ToolBlock }) {
545
547
 
546
548
  function RejectedToolView(props: { block: ToolBlock }) {
547
549
  const locale = useLocale();
550
+ const timeZone = useReaderTimeZone();
548
551
  const presentation = () => props.block.presentation;
549
- const title = () => toolSubject(props.block, locale()) ?? presentation()?.title ?? displayToolName(props.block.name, locale());
552
+ const title = () => toolSubject(props.block, locale(), timeZone) ?? presentation()?.title ?? displayToolName(props.block.name, locale());
550
553
  return (
551
554
  <Chat.Activity
552
555
  icon={aiToolIcon(props.block.name, presentation()?.appIcon)}
553
- label={toolOutcome(props.block, "rejected", locale()) ?? aiChatMessages(locale()).toolRejected({ title: title() })}
556
+ label={toolOutcome(props.block, "rejected", locale(), timeZone) ?? aiChatMessages(locale()).toolRejected({ title: title() })}
554
557
  accent={presentation()?.appAccent}
555
558
  />
556
559
  );
@@ -823,6 +826,7 @@ function StepImage(props: { path: string; src: string; description: string }) {
823
826
  */
824
827
  export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
825
828
  const locale = useLocale();
829
+ const timeZone = useReaderTimeZone();
826
830
  const t = () => aiChatMessages(locale());
827
831
  const args = () => (isRecord(props.block.args) ? props.block.args : {});
828
832
  /**
@@ -833,11 +837,11 @@ export function CompactToolRow(props: { block: ToolBlock; busy?: boolean }) {
833
837
  const status = props.block.status;
834
838
  const outcome =
835
839
  status === "completed" && !props.block.isError
836
- ? toolOutcome(props.block, "done", locale())
840
+ ? toolOutcome(props.block, "done", locale(), timeZone)
837
841
  : status === "rejected"
838
- ? toolOutcome(props.block, "rejected", locale())
842
+ ? toolOutcome(props.block, "rejected", locale(), timeZone)
839
843
  : null;
840
- return outcome ?? toolSubject(props.block, locale());
844
+ return outcome ?? toolSubject(props.block, locale(), timeZone);
841
845
  };
842
846
  const detail = () =>
843
847
  sentence() ? undefined : [args().path, args().name, args().query, args().title].find((value) => typeof value === "string");
@@ -161,13 +161,15 @@ function LoadToolsView(props: { block: ToolBlock }) {
161
161
  };
162
162
  const t = useMessages();
163
163
  const unavailableLabel = (reason: unknown) =>
164
- reason === "not_offered_in_turn"
165
- ? t().toolNotOffered
166
- : reason === "not_allowed"
167
- ? t().toolNotAllowed
168
- : reason === "app_offline"
169
- ? t().toolAppOffline
170
- : t().toolUnknown;
164
+ reason === "skill"
165
+ ? t().toolIsSkill
166
+ : reason === "not_offered_in_turn"
167
+ ? t().toolNotOffered
168
+ : reason === "not_allowed"
169
+ ? t().toolNotAllowed
170
+ : reason === "app_offline"
171
+ ? t().toolAppOffline
172
+ : t().toolUnknown;
171
173
  const rows = () => [
172
174
  ...toolNames(result().loaded).map((name) => ({ name, label: t().toolsLoaded })),
173
175
  ...toolNames(result().alreadyLoaded).map((name) => ({ name, label: t().toolsAlreadyLoaded })),
@@ -1,13 +1,26 @@
1
- import { ChartExplorer, type ChartExplorerColumn, prepareChartSnapshot, useLocale } from "@k2b/ui";
2
- import { createMemo, Show } from "solid-js";
1
+ import {
2
+ ChartExplorer,
3
+ type ChartExplorerColumn,
4
+ type ChartLegendEntry,
5
+ type ChartSnapshot,
6
+ createChartCursor,
7
+ prepareChartSnapshot,
8
+ useLocale,
9
+ } from "@k2b/ui";
10
+ import { createMemo, createUniqueId, Index, type JSX, Show } from "solid-js";
3
11
  import {
4
12
  type CloudAiChartColumn,
5
13
  type CloudAiChartRow,
14
+ type CloudAiChartTable,
15
+ cloudAiChartLegend,
6
16
  cloudAiChartMarkKey,
17
+ cloudAiChartReferenceMark,
7
18
  cloudAiChartRenderOptions,
8
19
  cloudAiChartRows,
20
+ cloudAiChartXValueFormat,
9
21
  } from "../chart-block";
10
- import { parseCloudAiChartInput } from "../default-tool-contracts";
22
+ import type { CloudAiChartInput } from "../default-tool-contracts";
23
+ import { type CloudAiGroupView, parseCloudAiChartToolView } from "../view-block";
11
24
  import { aiChatMessages } from "./messages";
12
25
 
13
26
  const tooltip = (columns: CloudAiChartColumn[], row: CloudAiChartRow) => {
@@ -19,61 +32,222 @@ const tooltip = (columns: CloudAiChartColumn[], row: CloudAiChartRow) => {
19
32
  };
20
33
  };
21
34
 
35
+ type Kind = CloudAiChartInput["kind"];
36
+ type DrawnChart = { input: CloudAiChartInput; table: CloudAiChartTable; snapshot: ChartSnapshot; legend: ChartLegendEntry[] };
37
+
38
+ /** Draws a chart for display and fills its table from that drawing; null when nothing can be drawn. */
39
+ const drawChart = (input: CloudAiChartInput, locale: string): DrawnChart | null => {
40
+ try {
41
+ const rows = cloudAiChartRows(input, locale);
42
+ // The chat lays the legend out in HTML: the renderer's single SVG row runs out of a phone-wide column.
43
+ const snapshot = prepareChartSnapshot(cloudAiChartRenderOptions(input, locale, { legend: "outside" }), {
44
+ key: ({ datum }) => cloudAiChartMarkKey(datum),
45
+ tooltip: ({ datum }) => {
46
+ const row = rows.add(datum);
47
+ return tooltip(rows.columns(), row);
48
+ },
49
+ reference: ({ datum }) => cloudAiChartReferenceMark(input, datum),
50
+ });
51
+ return snapshot.marks.length > 0 ? { input, table: rows.table(), snapshot, legend: cloudAiChartLegend(input, locale) } : null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ };
56
+
22
57
  /**
23
- * One `chart` tool call: the shared chart with its data table and Copy data. The block takes its full size with its
24
- * first frame and keeps it; switching to the table scrolls inside the same height. A call whose arguments are not a
25
- * valid chart shows nothing while it runs, because the model gets the validation error and the call fails.
58
+ * Single values and trends need less room than a plot with axes. Bar gauges hold at most eight 28-unit rows of the
59
+ * 280-unit drawing; at 12rem a row is still taller than its text.
60
+ */
61
+ const chartHeight = (kind: Kind) => (kind === "sparkline" ? "8rem" : kind === "stat" ? "10rem" : kind === "barGauge" ? "12rem" : "18rem");
62
+
63
+ function ChartFrame(props: {
64
+ title: string;
65
+ subtitle?: string;
66
+ kind: Kind;
67
+ chart: DrawnChart | null;
68
+ empty?: JSX.Element;
69
+ cursor?: ReturnType<typeof createChartCursor>;
70
+ /** A chart of a titled group: its title sits one heading level and one size below the group's. */
71
+ nested?: boolean;
72
+ }) {
73
+ const locale = useLocale();
74
+ const columns = () =>
75
+ props.chart
76
+ ? props.chart.table.columns.map(
77
+ (column): ChartExplorerColumn<CloudAiChartRow> => ({
78
+ id: column.id,
79
+ label: column.label,
80
+ value: (row) => row.cells[column.id] ?? "",
81
+ sortValue: (row) => row.values[column.id] ?? null,
82
+ align: column.numeric ? "right" : "left",
83
+ }),
84
+ )
85
+ : // A frame without a drawing keeps the explorer's header and height; its one column is never shown.
86
+ [{ id: "value", label: aiChatMessages(locale()).chartUnavailable, value: () => "" }];
87
+ return (
88
+ <ChartExplorer
89
+ title={props.title}
90
+ headingLevel={props.nested ? 4 : 3}
91
+ description={props.subtitle ? <span class="text-sm text-secondary">{props.subtitle}</span> : undefined}
92
+ height={chartHeight(props.kind)}
93
+ data={props.chart ? { chart: props.chart.snapshot, rows: props.chart.table.rows } : { chart: emptySnapshot(props.kind), rows: [] }}
94
+ columns={columns()}
95
+ legend={props.chart?.legend.length ? props.chart.legend : undefined}
96
+ cursor={props.cursor}
97
+ empty={props.empty}
98
+ renderDetails={false}
99
+ class={props.nested ? "[&_h4]:text-sm!" : undefined}
100
+ />
101
+ );
102
+ }
103
+
104
+ const emptySnapshot = (kind: Kind): ChartSnapshot => ({
105
+ kind,
106
+ svg: "",
107
+ width: 480,
108
+ height: 280,
109
+ stretch: true,
110
+ marks: [],
111
+ });
112
+
113
+ type GroupSummary = {
114
+ title: string;
115
+ subtitle?: string;
116
+ linkX: boolean;
117
+ layout: "stack" | "grid";
118
+ charts: { kind: Kind; title: string; subtitle?: string }[];
119
+ };
120
+
121
+ /**
122
+ * A group of small charts under one heading. Every chart frame stands with the title and height of its summary from
123
+ * the first frame, while the group loads (`group` undefined) and when it cannot be drawn (`group` null); a chart that
124
+ * cannot be drawn alone shows unavailable in its own frame. With `linkX`, one cursor follows the pointer or a tap
125
+ * across every chart. A grid puts two charts side by side once the chat column has room for both.
126
+ */
127
+ function ChartGroupFrame(props: { summary: GroupSummary; group: CloudAiGroupView | null | undefined; empty?: JSX.Element }) {
128
+ const locale = useLocale();
129
+ const t = () => aiChatMessages(locale());
130
+ const id = `cloud-chart-group-${createUniqueId()}`;
131
+ const drawn = createMemo(() => props.group?.charts.map((input) => drawChart(input, locale())) ?? []);
132
+ // One cursor per group, owned by the component; the first chart's x format titles the shared tooltip.
133
+ const cursor = createChartCursor({
134
+ formatX: (x) => {
135
+ const first = props.group?.charts[0];
136
+ return first ? cloudAiChartXValueFormat(first, locale())(x) : String(x);
137
+ },
138
+ });
139
+ return (
140
+ <section class="@container grid min-w-0 gap-3" aria-labelledby={id}>
141
+ <header class="min-w-0">
142
+ <h3 id={id} class="m-0 text-base font-semibold">
143
+ {props.group?.title ?? props.summary.title}
144
+ </h3>
145
+ <Show when={props.group ? props.group.subtitle : props.summary.subtitle}>
146
+ {(subtitle) => <span class="text-sm text-secondary">{subtitle()}</span>}
147
+ </Show>
148
+ </header>
149
+ <div class={`grid min-w-0 gap-6 ${props.summary.layout === "grid" ? "@2xl:grid-cols-2" : ""}`}>
150
+ <Index each={props.summary.charts}>
151
+ {(chart, index) => (
152
+ <ChartFrame
153
+ title={props.group?.charts[index]?.title ?? chart().title}
154
+ subtitle={props.group ? props.group.charts[index]?.subtitle : chart().subtitle}
155
+ kind={chart().kind}
156
+ chart={drawn()[index] ?? null}
157
+ cursor={props.summary.linkX ? cursor : undefined}
158
+ nested
159
+ empty={props.empty ?? <span role="status">{t().chartUnavailable}</span>}
160
+ />
161
+ )}
162
+ </Index>
163
+ </div>
164
+ </section>
165
+ );
166
+ }
167
+
168
+ const groupSummary = (group: CloudAiGroupView): GroupSummary => ({
169
+ title: group.title,
170
+ subtitle: group.subtitle,
171
+ linkX: group.linkX ?? false,
172
+ layout: group.layout ?? "stack",
173
+ charts: group.charts.map((chart) => ({ kind: chart.kind, title: chart.title, subtitle: chart.subtitle })),
174
+ });
175
+
176
+ /**
177
+ * One `chart` tool call: the shared chart with its data table and Copy data, or a group of them. The block takes its
178
+ * full size with its first frame and keeps it; switching to the table scrolls inside the same height. A call whose
179
+ * arguments are not a valid chart shows nothing while it runs, because the model gets the validation error and the
180
+ * call fails.
26
181
  */
27
182
  export function CloudChartBlock(props: { args: unknown; completed: boolean }) {
28
183
  const locale = useLocale();
29
184
  // Live updates replace the block with equal arguments; only a real change redraws.
30
185
  const source = createMemo(() => JSON.stringify(props.args ?? null));
186
+ const view = createMemo(() => parseCloudAiChartToolView(JSON.parse(source())));
187
+ // The tool schema drew the chart once to accept it; here it is drawn once more, for display, and the table is
188
+ // filled from that drawing.
31
189
  const chart = createMemo(() => {
32
- // The tool schema drew the chart once to accept it; here it is drawn once more, for display, and the table is
33
- // filled from that drawing.
34
- const input = parseCloudAiChartInput(JSON.parse(source()));
35
- if (!input) return null;
36
- try {
37
- const rows = cloudAiChartRows(input, locale());
38
- const snapshot = prepareChartSnapshot(cloudAiChartRenderOptions(input, locale()), {
39
- key: ({ datum }) => cloudAiChartMarkKey(datum),
40
- tooltip: ({ datum }) => {
41
- const row = rows.add(datum);
42
- return tooltip(rows.columns(), row);
43
- },
44
- });
45
- return snapshot.marks.length > 0 ? { input, table: rows.table(), snapshot } : null;
46
- } catch {
47
- return null;
48
- }
190
+ const value = view();
191
+ return value?.view === "chart" ? drawChart(value, locale()) : null;
192
+ });
193
+ const group = createMemo(() => {
194
+ const value = view();
195
+ return value?.view === "group" ? value : undefined;
49
196
  });
50
197
  return (
51
198
  <Show
52
- when={chart()}
199
+ when={group()}
53
200
  fallback={
54
- <Show when={props.completed}>
55
- <p class="text-sm text-secondary">{aiChatMessages(locale()).chartUnavailable}</p>
201
+ <Show
202
+ when={chart()}
203
+ fallback={
204
+ <Show when={props.completed}>
205
+ <p class="text-sm text-secondary">{aiChatMessages(locale()).chartUnavailable}</p>
206
+ </Show>
207
+ }
208
+ >
209
+ {(value) => (
210
+ <ChartFrame title={value().input.title} subtitle={value().input.subtitle} kind={value().input.kind} chart={value()} />
211
+ )}
56
212
  </Show>
57
213
  }
58
214
  >
59
- {(value) => (
60
- <ChartExplorer
61
- title={value().input.title}
62
- description={value().input.subtitle ? <span class="text-sm text-secondary">{value().input.subtitle}</span> : undefined}
63
- height={value().input.kind === "sparkline" ? "8rem" : "18rem"}
64
- data={{ chart: value().snapshot, rows: value().table.rows }}
65
- columns={value().table.columns.map(
66
- (column): ChartExplorerColumn<CloudAiChartRow> => ({
67
- id: column.id,
68
- label: column.label,
69
- value: (row) => row.cells[column.id] ?? "",
70
- sortValue: (row) => row.values[column.id] ?? null,
71
- align: column.numeric ? "right" : "left",
72
- }),
73
- )}
74
- renderDetails={false}
75
- />
76
- )}
215
+ {(value) => <ChartGroupFrame summary={groupSummary(value())} group={value()} />}
77
216
  </Show>
78
217
  );
79
218
  }
219
+
220
+ /**
221
+ * A chart view file: the frame stands with the title, actions and height of its `present` summary from the first frame,
222
+ * while the file loads (`input` undefined) and when it cannot be drawn (`input` null).
223
+ */
224
+ export function CloudChartView(props: {
225
+ summary: { title: string; subtitle?: string; kind: Kind };
226
+ input: CloudAiChartInput | null | undefined;
227
+ }) {
228
+ const locale = useLocale();
229
+ const t = () => aiChatMessages(locale());
230
+ const chart = createMemo(() => (props.input ? drawChart(props.input, locale()) : null));
231
+ return (
232
+ <ChartFrame
233
+ title={chart()?.input.title ?? props.summary.title}
234
+ subtitle={chart() ? chart()?.input.subtitle : props.summary.subtitle}
235
+ kind={props.summary.kind}
236
+ chart={chart()}
237
+ empty={<span role="status">{props.input === undefined ? t().viewLoading : t().viewUnavailable}</span>}
238
+ />
239
+ );
240
+ }
241
+
242
+ /** A group view file, sized by its `present` summary like a chart view. */
243
+ export function CloudChartGroupView(props: { summary: GroupSummary; group: CloudAiGroupView | null | undefined }) {
244
+ const locale = useLocale();
245
+ const t = () => aiChatMessages(locale());
246
+ return (
247
+ <ChartGroupFrame
248
+ summary={props.summary}
249
+ group={props.group}
250
+ empty={props.group ? undefined : <span role="status">{props.group === undefined ? t().viewLoading : t().viewUnavailable}</span>}
251
+ />
252
+ );
253
+ }