@k2b/cloud 0.36.0 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/scripts/README.md +1 -0
- package/scripts/cli-pack.ts +123 -0
- package/src/_internal/help-catalog.ts +123 -4
- package/src/_internal/help.ts +27 -6
- package/src/_internal/shell-focus.ts +11 -0
- package/src/access/PermissionEditor.browser-harness.tsx +46 -0
- package/src/access/PermissionEditor.tsx +38 -9
- package/src/access/ui.ts +1 -0
- package/src/account/Avatar.tsx +20 -2
- package/src/account/ui.ts +1 -1
- package/src/ai/browser.ts +1 -0
- package/src/ai/chart-block.ts +44 -13
- package/src/ai/chat/answer-links.ts +8 -0
- package/src/ai/chat/chart-block.tsx +2 -11
- package/src/ai/chat/messages.ts +12 -0
- package/src/ai/chat/primitives.tsx +2 -1
- package/src/ai/chat/web-tools.tsx +23 -6
- package/src/ai/chat-tasks.ts +65 -1
- package/src/ai/default-tool-contracts.ts +30 -13
- package/src/ai/executor.ts +46 -3
- package/src/ai/fetch-file-tool.ts +37 -3
- package/src/ai/migrate.ts +19 -2
- package/src/ai/readable-html.ts +1057 -0
- package/src/ai/types.ts +1 -0
- package/src/api/help.ts +23 -4
- package/src/api/mcp.ts +8 -2
- package/src/browser/FileChooser.tsx +11 -3
- package/src/browser/choose-files.browser-harness.tsx +4 -1
- package/src/browser/file-chooser-messages.ts +4 -0
- package/src/browser/file-providers.ts +14 -1
- package/src/browser/provider-folder.tsx +58 -5
- package/src/browser/resource-picker.tsx +5 -1
- package/src/browser/shell-focus.ts +67 -0
- package/src/cli/admin/workflows.ts +1 -0
- package/src/cli/commands.ts +2 -0
- package/src/cli/plugin.ts +5 -5
- package/src/config/env.ts +14 -0
- package/src/contracts/file-provider.ts +48 -1
- package/src/contracts/outgoing-mail.ts +8 -1
- package/src/server/help.ts +136 -10
- package/src/server/services/geo.ts +21 -4
- package/src/services/allowed-network-fetch.ts +189 -0
- package/src/services/help/index.ts +46 -5
- package/src/services/help/store.ts +26 -5
- package/src/services/help/types.ts +10 -2
- package/src/services/index.ts +2 -0
- package/src/services/network-security.ts +28 -0
- package/src/services/outgoing-mail/dispatcher.ts +7 -2
- package/src/services/outgoing-mail/resolve-host.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +18 -3
- package/src/services/outgoing-mail/transport.ts +24 -13
- package/src/services/settings/index.ts +48 -26
- package/src/shared/help.ts +41 -0
- package/src/shared/index.ts +9 -1
- package/src/shared/markdown/extensions/guided-help.ts +208 -64
- package/src/shared/markdown/formula.ts +8 -8
- package/src/shared/markdown/index.ts +72 -11
- package/src/shared/network-address.ts +9 -0
- package/src/ssr/CloudShell.island.tsx +2 -0
- package/src/ssr/GlobalSearchTrigger.tsx +1 -1
- package/src/ssr/Layout.tsx +28 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
- package/src/ssr/LayoutHelp.tsx +569 -410
- package/src/ssr/LayoutHelpTrigger.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -1
- package/src/ssr/ProfilePreferences.tsx +1 -1
- package/src/ssr/RailApps.tsx +1 -1
- package/src/ssr/ShellFocusToggle.tsx +43 -0
- package/src/ssr/ShellRuntime.island.tsx +53 -26
- package/src/ssr/app-launchpad.tsx +1 -1
- package/src/ssr/help-messages.ts +60 -38
- package/src/ssr/help.ts +3 -0
- package/src/ssr/layout-help-article.ts +41 -0
- package/src/ssr/layout-help-navigation.ts +0 -12
- package/src/ssr/layout-help-registry.tsx +4 -11
- package/src/ssr/layout-help-search.ts +9 -12
- package/src/ssr/layout-help-start.ts +68 -0
- package/src/ssr/platform-messages.ts +14 -0
- package/src/ssr/shell-data.tsx +7 -0
- package/src/ssr/shell-focus.browser-harness.tsx +83 -0
- package/src/ssr/shell-shortcuts.ts +8 -0
- package/src/styles/effects.css +688 -185
- package/src/styles/global.css +155 -26
- package/src/styles/resource-search.css +25 -0
- package/src/styles/utilities-navigation.css +11 -3
- package/src/workflows/store/events.ts +24 -0
- package/src/workflows/store/index.ts +8 -0
- package/src/workflows/store/observability.ts +6 -1
- package/src/workflows/store/retention.ts +157 -0
package/src/ai/chart-block.ts
CHANGED
|
@@ -527,15 +527,38 @@ export type CloudAiChartColumn = { id: string; label: string; numeric: boolean }
|
|
|
527
527
|
export type CloudAiChartRow = { key: string; cells: Record<string, string>; values: Record<string, string | number> };
|
|
528
528
|
export type CloudAiChartTable = { columns: CloudAiChartColumn[]; rows: CloudAiChartRow[] };
|
|
529
529
|
|
|
530
|
+
/** The first text column titles the tooltip; the other columns become rows, matching the table in every host. */
|
|
531
|
+
export function cloudAiChartTooltip(
|
|
532
|
+
columns: CloudAiChartColumn[],
|
|
533
|
+
row: CloudAiChartRow,
|
|
534
|
+
): { title?: string; rows: { label: string; value: string }[] } {
|
|
535
|
+
const [first, ...rest] = columns;
|
|
536
|
+
const named = first && !first.numeric;
|
|
537
|
+
return {
|
|
538
|
+
title: named ? row.cells[first.id] : undefined,
|
|
539
|
+
rows: (named ? rest : columns)
|
|
540
|
+
.filter((column) => column.id in row.cells)
|
|
541
|
+
.map((column) => ({ label: column.label, value: row.cells[column.id] ?? "" })),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
530
545
|
/**
|
|
531
546
|
* Builds the data table mark by mark, in the order the renderer emits them, so the chat block fills it from the same
|
|
532
547
|
* drawing that shows the chart.
|
|
533
548
|
*/
|
|
534
|
-
export function cloudAiChartRows(
|
|
549
|
+
export function cloudAiChartRows(
|
|
550
|
+
input: CloudAiChartInput,
|
|
551
|
+
locale: string,
|
|
552
|
+
options: { formats?: { x?: (v: number) => string; y?: (v: number) => string; value?: (v: number) => string } } = {},
|
|
553
|
+
) {
|
|
535
554
|
const t = labels.resolve([locale]).t;
|
|
536
555
|
const format = formats(input, locale);
|
|
556
|
+
const valueFormat = input.tooltip?.format
|
|
557
|
+
? cloudAiNumberFormatters(input.tooltip.format, locale).value
|
|
558
|
+
: (options.formats?.value ?? (input.kind === "histogram" ? undefined : options.formats?.y) ?? format.value);
|
|
559
|
+
const xFormat = options.formats?.x ?? format.x.value;
|
|
537
560
|
const series = seriesOf(input);
|
|
538
|
-
const countFormat = cloudAiNumberFormatters(undefined, locale).value;
|
|
561
|
+
const countFormat = (input.kind === "histogram" ? options.formats?.y : undefined) ?? cloudAiNumberFormatters(undefined, locale).value;
|
|
539
562
|
const percent = new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 });
|
|
540
563
|
const unit = "unit" in input && input.unit ? ` ${input.unit}` : "";
|
|
541
564
|
const columns = new Map<string, CloudAiChartColumn>();
|
|
@@ -575,26 +598,34 @@ export function cloudAiChartRows(input: CloudAiChartInput, locale: string) {
|
|
|
575
598
|
"x",
|
|
576
599
|
input.kind === "sparkline" || input.kind === "stat" ? "#" : header,
|
|
577
600
|
value,
|
|
578
|
-
input.kind === "sparkline" || input.kind === "stat" ? String(value + 1) :
|
|
601
|
+
input.kind === "sparkline" || input.kind === "stat" ? String(value + 1) : xFormat(value),
|
|
579
602
|
);
|
|
580
603
|
} else if (field.key === "y") {
|
|
581
604
|
const header =
|
|
605
|
+
input.tooltip?.label ??
|
|
582
606
|
(input.kind === "line" || input.kind === "scatter" ? input.yAxis?.label : undefined) ??
|
|
583
607
|
(input.kind === "sparkline" || input.kind === "stat" ? t.value : "Y");
|
|
584
|
-
cell("y", header, value,
|
|
608
|
+
cell("y", header, value, valueFormat(value));
|
|
585
609
|
} else if (field.key === "value")
|
|
586
|
-
cell("value", (input.kind === "bar" ? input.yAxis?.label : undefined) ?? t.value, value, `${format.value(value)}${unit}`);
|
|
587
|
-
else if (field.key === "delta") cell("delta", t.delta, value, `${value > 0 ? "+" : ""}${format.value(value)}`);
|
|
588
|
-
else if (field.key === "q1" || field.key === "q2" || field.key === "q3" || field.key === "whiskerLow" || field.key === "whiskerHigh")
|
|
589
|
-
cell(field.key, t[field.key], value, format.value(value));
|
|
590
|
-
else if (field.key === "percent") cell("percent", t.share, value, percent.format(value / 100));
|
|
591
|
-
else if (field.key === "from" || field.key === "to" || field.key === "count" || field.key === "min" || field.key === "max")
|
|
592
610
|
cell(
|
|
593
|
-
|
|
594
|
-
t
|
|
611
|
+
"value",
|
|
612
|
+
input.tooltip?.label ?? (input.kind === "bar" ? input.yAxis?.label : undefined) ?? t.value,
|
|
595
613
|
value,
|
|
596
|
-
`${
|
|
614
|
+
`${valueFormat(value)}${unit}`,
|
|
597
615
|
);
|
|
616
|
+
else if (field.key === "delta") cell("delta", t.delta, value, `${value > 0 ? "+" : ""}${valueFormat(value)}`);
|
|
617
|
+
else if (field.key === "q1" || field.key === "q2" || field.key === "q3" || field.key === "whiskerLow" || field.key === "whiskerHigh")
|
|
618
|
+
cell(field.key, t[field.key], value, valueFormat(value));
|
|
619
|
+
else if (field.key === "percent") cell("percent", t.share, value, percent.format(value / 100));
|
|
620
|
+
else if (field.key === "from" || field.key === "to" || field.key === "count" || field.key === "min" || field.key === "max") {
|
|
621
|
+
const fieldFormat =
|
|
622
|
+
field.key === "count"
|
|
623
|
+
? countFormat
|
|
624
|
+
: input.kind === "histogram" && (field.key === "from" || field.key === "to") && options.formats?.x
|
|
625
|
+
? xFormat
|
|
626
|
+
: valueFormat;
|
|
627
|
+
cell(field.key, t[field.key], value, `${fieldFormat(value)}${field.key === "min" || field.key === "max" ? unit : ""}`);
|
|
628
|
+
}
|
|
598
629
|
}
|
|
599
630
|
const row = { key: cloudAiChartMarkKey(datum), cells, values };
|
|
600
631
|
rows.push(row);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root scheme-less Cloud resource links in sanitized Assistant answer HTML.
|
|
3
|
+
* Normalized links stay root-relative under /app/ or /open/ and can never change origin.
|
|
4
|
+
*/
|
|
5
|
+
export function rootResourceLinks(html: string): string {
|
|
6
|
+
// sanitize-html emits double-quoted attributes with ", <, and > escaped in values.
|
|
7
|
+
return html.replace(/<a\b[^>]*>/g, (tag) => tag.replace(/(\shref=")((?:app|open)\/)/g, "$1/$2"));
|
|
8
|
+
}
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
} from "@k2b/ui";
|
|
10
10
|
import { createMemo, createUniqueId, Index, type JSX, Show } from "solid-js";
|
|
11
11
|
import {
|
|
12
|
-
type CloudAiChartColumn,
|
|
13
12
|
type CloudAiChartRow,
|
|
14
13
|
type CloudAiChartTable,
|
|
15
14
|
cloudAiChartLegend,
|
|
@@ -17,21 +16,13 @@ import {
|
|
|
17
16
|
cloudAiChartReferenceMark,
|
|
18
17
|
cloudAiChartRenderOptions,
|
|
19
18
|
cloudAiChartRows,
|
|
19
|
+
cloudAiChartTooltip,
|
|
20
20
|
cloudAiChartXValueFormat,
|
|
21
21
|
} from "../chart-block";
|
|
22
22
|
import type { CloudAiChartInput } from "../default-tool-contracts";
|
|
23
23
|
import { type CloudAiGroupView, parseCloudAiChartToolView } from "../view-block";
|
|
24
24
|
import { aiChatMessages } from "./messages";
|
|
25
25
|
|
|
26
|
-
const tooltip = (columns: CloudAiChartColumn[], row: CloudAiChartRow) => {
|
|
27
|
-
const [first, ...rest] = columns;
|
|
28
|
-
const named = first && !first.numeric;
|
|
29
|
-
return {
|
|
30
|
-
title: named ? row.cells[first.id] : undefined,
|
|
31
|
-
rows: (named ? rest : columns).map((column) => ({ label: column.label, value: row.cells[column.id] ?? "" })),
|
|
32
|
-
};
|
|
33
|
-
};
|
|
34
|
-
|
|
35
26
|
type Kind = CloudAiChartInput["kind"];
|
|
36
27
|
type DrawnChart = { input: CloudAiChartInput; table: CloudAiChartTable; snapshot: ChartSnapshot; legend: ChartLegendEntry[] };
|
|
37
28
|
|
|
@@ -44,7 +35,7 @@ const drawChart = (input: CloudAiChartInput, locale: string): DrawnChart | null
|
|
|
44
35
|
key: ({ datum }) => cloudAiChartMarkKey(datum),
|
|
45
36
|
tooltip: ({ datum }) => {
|
|
46
37
|
const row = rows.add(datum);
|
|
47
|
-
return
|
|
38
|
+
return cloudAiChartTooltip(rows.columns(), row);
|
|
48
39
|
},
|
|
49
40
|
reference: ({ datum }) => cloudAiChartReferenceMark(input, datum),
|
|
50
41
|
});
|
package/src/ai/chat/messages.ts
CHANGED
|
@@ -34,6 +34,7 @@ const messages = i18n.define({
|
|
|
34
34
|
turnErrorReason: ({ code, limitMinutes }: AiTurnError) => {
|
|
35
35
|
if (code === "model_unavailable") return "The model service did not answer.";
|
|
36
36
|
if (code === "provider_stopped") return "The model provider ended this answer early.";
|
|
37
|
+
if (code === "tool_failed") return "A step failed, and the model stopped without an answer.";
|
|
37
38
|
if (code === "quota_exhausted") return "Your AI usage limit for this period is reached. You can continue once it resets.";
|
|
38
39
|
if (code === "context_full") return "This chat is too long for the model. Start a new chat to continue; this one stays as it is.";
|
|
39
40
|
if (code === "time_limit")
|
|
@@ -136,6 +137,11 @@ const messages = i18n.define({
|
|
|
136
137
|
revokeFailed: "Could not revoke. Try again.",
|
|
137
138
|
revokeWebsiteFor: ({ host }: { host: string }) => `Revoke the approval for ${host}`,
|
|
138
139
|
websiteRevoked: "Revoked. The next request asks again.",
|
|
140
|
+
fetchingFile: ({ name }: { name: string }) => `Fetching ${name}`,
|
|
141
|
+
fetchedFile: ({ name }: { name: string }) => `Fetched ${name}`,
|
|
142
|
+
fetchSource: "Source",
|
|
143
|
+
fetchSavedAs: "Saved as",
|
|
144
|
+
fetchReadableCopy: "Readable copy",
|
|
139
145
|
details: "Details",
|
|
140
146
|
hideDetails: "Hide details",
|
|
141
147
|
moreOptions: ({ title }: { title: string }) => `More options for ${title}`,
|
|
@@ -336,6 +342,7 @@ const messages = i18n.define({
|
|
|
336
342
|
turnErrorReason: ({ code, limitMinutes }) => {
|
|
337
343
|
if (code === "model_unavailable") return "Der KI-Dienst hat nicht geantwortet.";
|
|
338
344
|
if (code === "provider_stopped") return "Der KI-Anbieter hat diese Antwort vorzeitig beendet.";
|
|
345
|
+
if (code === "tool_failed") return "Ein Schritt ist fehlgeschlagen, und das Modell hat danach nicht geantwortet.";
|
|
339
346
|
if (code === "quota_exhausted")
|
|
340
347
|
return "Dein KI-Kontingent für diesen Zeitraum ist aufgebraucht. Du kannst weiterarbeiten, sobald es sich erneuert.";
|
|
341
348
|
if (code === "context_full")
|
|
@@ -444,6 +451,11 @@ const messages = i18n.define({
|
|
|
444
451
|
revokeFailed: "Konnte nicht entzogen werden. Versuche es erneut.",
|
|
445
452
|
revokeWebsiteFor: ({ host }: { host: string }) => `Freigabe für ${host} entziehen`,
|
|
446
453
|
websiteRevoked: "Entzogen. Der nächste Abruf fragt wieder.",
|
|
454
|
+
fetchingFile: ({ name }) => `Lädt herunter: ${name}`,
|
|
455
|
+
fetchedFile: ({ name }) => `Heruntergeladen: ${name}`,
|
|
456
|
+
fetchSource: "Quelle",
|
|
457
|
+
fetchSavedAs: "Gespeichert als",
|
|
458
|
+
fetchReadableCopy: "Lesbare Kopie",
|
|
447
459
|
details: "Details",
|
|
448
460
|
hideDetails: "Details ausblenden",
|
|
449
461
|
moreOptions: ({ title }) => `Weitere Optionen für ${title}`,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createEffect, onCleanup, onMount } from "solid-js";
|
|
2
|
+
import { rootResourceLinks } from "./answer-links";
|
|
2
3
|
import { useAiChatActions } from "./message-actions";
|
|
3
4
|
|
|
4
5
|
export function AssistantMarkdownBlock(props: { html: string }) {
|
|
@@ -41,5 +42,5 @@ export function AssistantMarkdownBlock(props: { html: string }) {
|
|
|
41
42
|
element.addEventListener("click", handleClick);
|
|
42
43
|
onCleanup(() => element.removeEventListener("click", handleClick));
|
|
43
44
|
});
|
|
44
|
-
return <div ref={element} class="assistant-markdown-block" innerHTML={props.html} />;
|
|
45
|
+
return <div ref={element} class="assistant-markdown-block" innerHTML={rootResourceLinks(props.html)} />;
|
|
45
46
|
}
|
|
@@ -136,15 +136,22 @@ export function WebExtractToolBlock(props: { block: ToolBlock }) {
|
|
|
136
136
|
);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
type FetchFileResult = { path: string; size: number; mediaType: string; url: string };
|
|
139
|
+
type FetchFileResult = { path: string; size: number; mediaType: string; url: string; readablePath?: string };
|
|
140
140
|
|
|
141
|
+
/** Results stored before readable copies existed have no `readable` field and still render. */
|
|
141
142
|
const fetchFileResult = (value: unknown): FetchFileResult | null =>
|
|
142
143
|
isRecord(value) &&
|
|
143
144
|
typeof value.path === "string" &&
|
|
144
145
|
typeof value.size === "number" &&
|
|
145
146
|
typeof value.mediaType === "string" &&
|
|
146
147
|
typeof value.url === "string"
|
|
147
|
-
? {
|
|
148
|
+
? {
|
|
149
|
+
path: value.path,
|
|
150
|
+
size: value.size,
|
|
151
|
+
mediaType: value.mediaType,
|
|
152
|
+
url: value.url,
|
|
153
|
+
...(isRecord(value.readable) && typeof value.readable.path === "string" ? { readablePath: value.readable.path } : {}),
|
|
154
|
+
}
|
|
148
155
|
: null;
|
|
149
156
|
|
|
150
157
|
const filenameOf = (value: string): string => {
|
|
@@ -158,6 +165,8 @@ const filenameOf = (value: string): string => {
|
|
|
158
165
|
|
|
159
166
|
/** Imported web file: source identity, useful metadata, and chat path without raw tool JSON. */
|
|
160
167
|
export function FetchFileToolBlock(props: { block: ToolBlock }) {
|
|
168
|
+
const locale = useLocale();
|
|
169
|
+
const t = () => aiChatMessages(locale());
|
|
161
170
|
const sourceUrl = () => (isRecord(props.block.args) && typeof props.block.args.url === "string" ? props.block.args.url : "");
|
|
162
171
|
const result = () => fetchFileResult(props.block.result);
|
|
163
172
|
const displayName = () => filenameOf(result()?.path ?? sourceUrl()) || "file";
|
|
@@ -172,7 +181,7 @@ export function FetchFileToolBlock(props: { block: ToolBlock }) {
|
|
|
172
181
|
when={props.block.status === "completed" && result()}
|
|
173
182
|
fallback={
|
|
174
183
|
<Chat.Activity
|
|
175
|
-
label={
|
|
184
|
+
label={t().fetchingFile({ name: displayName() })}
|
|
176
185
|
description={domainOf(sourceUrl())}
|
|
177
186
|
leading={<Favicon url={sourceUrl()} fallbackIcon="ti ti-world-download" />}
|
|
178
187
|
busy
|
|
@@ -183,19 +192,27 @@ export function FetchFileToolBlock(props: { block: ToolBlock }) {
|
|
|
183
192
|
<AiToolActivity
|
|
184
193
|
blockId={props.block.id}
|
|
185
194
|
leading={<Favicon url={sourceUrl() || file().url} fallbackIcon="ti ti-world-download" />}
|
|
186
|
-
label={
|
|
195
|
+
label={t().fetchedFile({ name: displayName() })}
|
|
187
196
|
description={description()}
|
|
188
197
|
bodyInset={false}
|
|
189
198
|
>
|
|
190
199
|
<dl class="grid w-full min-w-0 grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-1 rounded-md bg-zinc-100/70 px-2 py-1.5 text-xs [box-shadow:var(--ui-control-recess)] dark:bg-zinc-950/70">
|
|
191
|
-
<dt class="font-medium text-dimmed">
|
|
200
|
+
<dt class="font-medium text-dimmed">{t().fetchSource}</dt>
|
|
192
201
|
<dd class="min-w-0 truncate">
|
|
193
202
|
<a href={file().url} target="_blank" rel="noreferrer noopener" class="text-secondary hover:text-primary">
|
|
194
203
|
{file().url}
|
|
195
204
|
</a>
|
|
196
205
|
</dd>
|
|
197
|
-
<dt class="font-medium text-dimmed">
|
|
206
|
+
<dt class="font-medium text-dimmed">{t().fetchSavedAs}</dt>
|
|
198
207
|
<dd class="min-w-0 truncate text-secondary">{file().path}</dd>
|
|
208
|
+
<Show when={file().readablePath}>
|
|
209
|
+
{(readablePath) => (
|
|
210
|
+
<>
|
|
211
|
+
<dt class="font-medium text-dimmed">{t().fetchReadableCopy}</dt>
|
|
212
|
+
<dd class="min-w-0 truncate text-secondary">{readablePath()}</dd>
|
|
213
|
+
</>
|
|
214
|
+
)}
|
|
215
|
+
</Show>
|
|
199
216
|
</dl>
|
|
200
217
|
</AiToolActivity>
|
|
201
218
|
)}
|
package/src/ai/chat-tasks.ts
CHANGED
|
@@ -16,11 +16,15 @@ import {
|
|
|
16
16
|
updateMandatePolicy,
|
|
17
17
|
validateMandateIssueAuthority,
|
|
18
18
|
} from "../services/mandates";
|
|
19
|
-
import { parsePgJsonValue } from "../services/postgres";
|
|
19
|
+
import { parsePgJsonValue, toPgUuidArray } from "../services/postgres";
|
|
20
20
|
import { withAiShortIdForDb } from "./short-id";
|
|
21
21
|
import { aiConversations } from "./store";
|
|
22
22
|
import type { AiChatTurnRunConfig, AiStoredMessage, AiTurnStatus } from "./types";
|
|
23
23
|
|
|
24
|
+
/** Run history and transcripts are operational evidence; 90 days matches capability execution history.
|
|
25
|
+
* Fixed platform constant, not configurable. */
|
|
26
|
+
export const AI_CHAT_TASK_RUN_RETENTION_DAYS = 90;
|
|
27
|
+
|
|
24
28
|
export type AiChatTaskState = "active" | "paused" | "completed" | "needs_attention";
|
|
25
29
|
export type AiChatTaskSchedule = { kind: "once"; runAt: string } | { kind: "cron"; cron: string };
|
|
26
30
|
export type AiChatTask = {
|
|
@@ -914,6 +918,66 @@ export const aiChatTasks = {
|
|
|
914
918
|
return occurrences;
|
|
915
919
|
},
|
|
916
920
|
|
|
921
|
+
/** Removes expired runs and their evidence, preserving current once slot rows as dedupe guards. */
|
|
922
|
+
pruneExpiredRuns: async (input: { now?: Date; limit?: number } = {}): Promise<number> => {
|
|
923
|
+
const cutoff = new Date((input.now ?? new Date()).getTime() - AI_CHAT_TASK_RUN_RETENTION_DAYS * 86_400_000);
|
|
924
|
+
const limit = Math.min(Math.max(input.limit ?? 100, 1), 500);
|
|
925
|
+
return sql.begin(async (tx) => {
|
|
926
|
+
// Keep the current once slot row as a dedupe guard while its turn and transcript expire.
|
|
927
|
+
const occurrences = await tx<{ id: string; turn_id: string | null; keep: boolean }[]>`
|
|
928
|
+
SELECT occurrence.id, occurrence.turn_id, EXISTS (
|
|
929
|
+
SELECT 1 FROM ai.chat_tasks task WHERE task.id = occurrence.task_id
|
|
930
|
+
AND task.schedule_kind = 'once' AND task.run_at = occurrence.scheduled_for
|
|
931
|
+
) AS keep
|
|
932
|
+
FROM ai.chat_task_occurrences occurrence
|
|
933
|
+
WHERE occurrence.state IN ('completed', 'failed') AND occurrence.completed_at < ${cutoff}
|
|
934
|
+
AND (occurrence.turn_id IS NOT NULL OR NOT EXISTS (
|
|
935
|
+
SELECT 1 FROM ai.chat_tasks task WHERE task.id = occurrence.task_id
|
|
936
|
+
AND task.schedule_kind = 'once' AND task.run_at = occurrence.scheduled_for
|
|
937
|
+
))
|
|
938
|
+
AND NOT EXISTS (
|
|
939
|
+
SELECT 1 FROM ai.turns turn WHERE turn.id = occurrence.turn_id
|
|
940
|
+
AND turn.status NOT IN ('completed', 'failed', 'aborted')
|
|
941
|
+
)
|
|
942
|
+
ORDER BY occurrence.completed_at, occurrence.id
|
|
943
|
+
LIMIT ${limit}
|
|
944
|
+
FOR UPDATE OF occurrence SKIP LOCKED
|
|
945
|
+
`;
|
|
946
|
+
if (occurrences.length > 0) {
|
|
947
|
+
const occurrenceIds = toPgUuidArray(occurrences.filter((occurrence) => !occurrence.keep).map((occurrence) => occurrence.id));
|
|
948
|
+
const turnIds = toPgUuidArray(occurrences.flatMap((occurrence) => (occurrence.turn_id ? [occurrence.turn_id] : [])));
|
|
949
|
+
await tx`DELETE FROM ai.task_messages WHERE loop_id = ANY(${turnIds}::text[])`;
|
|
950
|
+
await tx`
|
|
951
|
+
DELETE FROM ai.turns WHERE id = ANY(${turnIds}::uuid[])
|
|
952
|
+
AND status IN ('completed', 'failed', 'aborted') AND run_config ? 'background'
|
|
953
|
+
`;
|
|
954
|
+
await tx`DELETE FROM ai.chat_task_occurrences WHERE id = ANY(${occurrenceIds}::uuid[])`;
|
|
955
|
+
}
|
|
956
|
+
const remaining = limit - occurrences.length;
|
|
957
|
+
if (remaining === 0) return occurrences.length;
|
|
958
|
+
const orphanedTurns = await tx<{ id: string }[]>`
|
|
959
|
+
SELECT turn.id FROM ai.turns turn
|
|
960
|
+
WHERE turn.run_config ? 'background' AND turn.status IN ('completed', 'failed', 'aborted')
|
|
961
|
+
AND turn.completed_at < ${cutoff}
|
|
962
|
+
AND NOT EXISTS (
|
|
963
|
+
SELECT 1 FROM ai.chat_task_occurrences occurrence WHERE occurrence.turn_id = turn.id
|
|
964
|
+
)
|
|
965
|
+
ORDER BY turn.completed_at, turn.id
|
|
966
|
+
LIMIT ${remaining}
|
|
967
|
+
FOR UPDATE OF turn SKIP LOCKED
|
|
968
|
+
`;
|
|
969
|
+
if (orphanedTurns.length === 0) return occurrences.length;
|
|
970
|
+
const orphanedTurnIds = toPgUuidArray(orphanedTurns.map((turn) => turn.id));
|
|
971
|
+
await tx`DELETE FROM ai.task_messages WHERE loop_id = ANY(${orphanedTurnIds}::text[])`;
|
|
972
|
+
const removedTurns = await tx<{ id: string }[]>`
|
|
973
|
+
DELETE FROM ai.turns WHERE id = ANY(${orphanedTurnIds}::uuid[])
|
|
974
|
+
AND status IN ('completed', 'failed', 'aborted') AND run_config ? 'background'
|
|
975
|
+
RETURNING id
|
|
976
|
+
`;
|
|
977
|
+
return occurrences.length + removedTurns.length;
|
|
978
|
+
});
|
|
979
|
+
},
|
|
980
|
+
|
|
917
981
|
listQueuedOccurrences: async (limit = 100): Promise<Array<{ occurrence: AiChatTaskOccurrence; task: AiChatTask }>> => {
|
|
918
982
|
const rows = await sql<(OccurrenceRow & TaskRow)[]>`
|
|
919
983
|
SELECT occurrence.id, occurrence.short_id, occurrence.task_id, occurrence.scheduled_for, occurrence.trigger,
|
|
@@ -59,6 +59,9 @@ export const cloudAiChartKindSchema = () =>
|
|
|
59
59
|
|
|
60
60
|
const chartParts = (limits = true) => {
|
|
61
61
|
const ChartLabel = limits ? z.string().trim().min(1).max(80) : z.union([z.string(), z.number().transform(String)]);
|
|
62
|
+
const ChartTooltip = z
|
|
63
|
+
.strictObject({ label: ChartLabel.optional(), format: cloudAiNumberFormatSchema().optional() })
|
|
64
|
+
.describe("All kinds: what tooltips and the data table call the value and how they format it; axes keep their own format.");
|
|
62
65
|
// Missing observations stay numeric so the renderer can omit them with its finite-value checks.
|
|
63
66
|
const ChartNumber = limits
|
|
64
67
|
? z.number()
|
|
@@ -96,6 +99,7 @@ const chartParts = (limits = true) => {
|
|
|
96
99
|
);
|
|
97
100
|
return {
|
|
98
101
|
ChartLabel,
|
|
102
|
+
ChartTooltip,
|
|
99
103
|
ChartNumber,
|
|
100
104
|
list,
|
|
101
105
|
ChartReferences,
|
|
@@ -148,6 +152,7 @@ const chartKinds = (limits = true) => {
|
|
|
148
152
|
ChartBins,
|
|
149
153
|
ChartCategory,
|
|
150
154
|
ChartCommon,
|
|
155
|
+
ChartTooltip,
|
|
151
156
|
ChartLabel,
|
|
152
157
|
ChartPoints,
|
|
153
158
|
ChartSeries,
|
|
@@ -159,11 +164,12 @@ const chartKinds = (limits = true) => {
|
|
|
159
164
|
ChartCell,
|
|
160
165
|
ChartBarGauge,
|
|
161
166
|
} = chartParts(limits);
|
|
167
|
+
const ChartKindCommon = { ...ChartCommon, tooltip: ChartTooltip.optional() };
|
|
162
168
|
const numberFormat = cloudAiNumberFormatSchema();
|
|
163
169
|
return z.discriminatedUnion("kind", [
|
|
164
170
|
z.object({
|
|
165
171
|
kind: z.literal("bar"),
|
|
166
|
-
...
|
|
172
|
+
...ChartKindCommon,
|
|
167
173
|
data: list(ChartCategory, 1, CLOUD_AI_CHART_MAX_VALUES),
|
|
168
174
|
yAxis: ChartAxis.optional(),
|
|
169
175
|
colorByBar: z.boolean().optional(),
|
|
@@ -173,7 +179,7 @@ const chartKinds = (limits = true) => {
|
|
|
173
179
|
}),
|
|
174
180
|
z.object({
|
|
175
181
|
kind: z.literal("line"),
|
|
176
|
-
...
|
|
182
|
+
...ChartKindCommon,
|
|
177
183
|
series: list(
|
|
178
184
|
ChartSeries(
|
|
179
185
|
limits
|
|
@@ -192,7 +198,7 @@ const chartKinds = (limits = true) => {
|
|
|
192
198
|
}),
|
|
193
199
|
z.object({
|
|
194
200
|
kind: z.literal("scatter"),
|
|
195
|
-
...
|
|
201
|
+
...ChartKindCommon,
|
|
196
202
|
series: list(ChartSeries(limits ? ChartPoints.min(1) : ChartPoints), 1, CLOUD_AI_CHART_MAX_SERIES),
|
|
197
203
|
xAxis: ChartAxis.optional(),
|
|
198
204
|
yAxis: ChartAxis.optional(),
|
|
@@ -201,7 +207,7 @@ const chartKinds = (limits = true) => {
|
|
|
201
207
|
}),
|
|
202
208
|
z.object({
|
|
203
209
|
kind: z.enum(["pie", "donut"]),
|
|
204
|
-
...
|
|
210
|
+
...ChartKindCommon,
|
|
205
211
|
data: list(ChartSlice, 1, CLOUD_AI_CHART_MAX_VALUES),
|
|
206
212
|
legend: z.boolean().optional(),
|
|
207
213
|
showLabels: z.boolean().optional(),
|
|
@@ -209,14 +215,14 @@ const chartKinds = (limits = true) => {
|
|
|
209
215
|
}),
|
|
210
216
|
z.object({
|
|
211
217
|
kind: z.literal("histogram"),
|
|
212
|
-
...
|
|
218
|
+
...ChartKindCommon,
|
|
213
219
|
data: list(ChartNumber, 1, CLOUD_AI_CHART_MAX_VALUES),
|
|
214
220
|
bins: ChartBins.optional(),
|
|
215
221
|
references: ChartReferences.optional(),
|
|
216
222
|
}),
|
|
217
223
|
z.object({
|
|
218
224
|
kind: z.literal("gauge"),
|
|
219
|
-
...
|
|
225
|
+
...ChartKindCommon,
|
|
220
226
|
value: ChartNumber,
|
|
221
227
|
min: z.number().optional(),
|
|
222
228
|
max: z.number().optional(),
|
|
@@ -227,20 +233,20 @@ const chartKinds = (limits = true) => {
|
|
|
227
233
|
}),
|
|
228
234
|
z.object({
|
|
229
235
|
kind: z.literal("sparkline"),
|
|
230
|
-
...
|
|
236
|
+
...ChartKindCommon,
|
|
231
237
|
data: list(ChartNumber, 2, CLOUD_AI_CHART_MAX_VALUES),
|
|
232
238
|
area: z.boolean().optional(),
|
|
233
239
|
}),
|
|
234
240
|
z.object({
|
|
235
241
|
kind: z.literal("boxplot"),
|
|
236
|
-
...
|
|
242
|
+
...ChartKindCommon,
|
|
237
243
|
data: list(ChartBox, 1, CLOUD_AI_CHART_MAX_VALUES),
|
|
238
244
|
yAxis: ChartAxis.optional(),
|
|
239
245
|
references: ChartReferences.optional(),
|
|
240
246
|
}),
|
|
241
247
|
z.object({
|
|
242
248
|
kind: z.literal("heatmap"),
|
|
243
|
-
...
|
|
249
|
+
...ChartKindCommon,
|
|
244
250
|
data: list(ChartCell, 1, CLOUD_AI_VIEW_MAX_MARKS),
|
|
245
251
|
format: numberFormat.optional(),
|
|
246
252
|
showValues: z.boolean().optional(),
|
|
@@ -249,7 +255,7 @@ const chartKinds = (limits = true) => {
|
|
|
249
255
|
}),
|
|
250
256
|
z.object({
|
|
251
257
|
kind: z.literal("barGauge"),
|
|
252
|
-
...
|
|
258
|
+
...ChartKindCommon,
|
|
253
259
|
// The shared renderer draws 280 units high and a bar gauge row is 28 units, so 8 rows fit without a taller drawing.
|
|
254
260
|
data: list(ChartBarGauge, 1, CLOUD_AI_CHART_MAX_SERIES, `Use at most ${CLOUD_AI_CHART_MAX_SERIES} bar gauge items.`),
|
|
255
261
|
min: z.number().optional(),
|
|
@@ -260,7 +266,7 @@ const chartKinds = (limits = true) => {
|
|
|
260
266
|
}),
|
|
261
267
|
z.object({
|
|
262
268
|
kind: z.literal("stat"),
|
|
263
|
-
...
|
|
269
|
+
...ChartKindCommon,
|
|
264
270
|
value: ChartNumber,
|
|
265
271
|
format: numberFormat.optional(),
|
|
266
272
|
unit: ChartUnit.optional(),
|
|
@@ -336,11 +342,22 @@ export const cloudAiGroupSchema = () => {
|
|
|
336
342
|
};
|
|
337
343
|
|
|
338
344
|
const flatChartSchema = () => {
|
|
339
|
-
const {
|
|
340
|
-
|
|
345
|
+
const {
|
|
346
|
+
ChartAxis,
|
|
347
|
+
ChartBins,
|
|
348
|
+
ChartCommon,
|
|
349
|
+
ChartLabel,
|
|
350
|
+
ChartPoints,
|
|
351
|
+
ChartSeries,
|
|
352
|
+
ChartReferences,
|
|
353
|
+
ChartThresholds,
|
|
354
|
+
ChartUnit,
|
|
355
|
+
ChartTooltip,
|
|
356
|
+
} = chartParts();
|
|
341
357
|
return z.object({
|
|
342
358
|
kind: cloudAiChartKindSchema().optional().describe("Single charts: kind. Omit for groups."),
|
|
343
359
|
...ChartCommon,
|
|
360
|
+
tooltip: ChartTooltip.optional(),
|
|
344
361
|
// One item object preserves every kind's fields before canonical validation; unions would strip overlapping items.
|
|
345
362
|
data: z
|
|
346
363
|
.union([
|
package/src/ai/executor.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { CODE_RUNTIME_TOOL_NAMES } from "./browser-code-contracts";
|
|
|
23
23
|
import { type AiCapabilityCatalogEntry, createAiToolResolver, createRunToolStore } from "./capabilities";
|
|
24
24
|
import { AiCapabilityExecutionError, executeAiCapability, resolveAiCapabilityActor, reviewAiCapability } from "./capability-execution";
|
|
25
25
|
import { aiChatMessages } from "./chat/messages";
|
|
26
|
+
import { isFailedTool } from "./chat/tool-groups";
|
|
26
27
|
import { aiTurnErrorText } from "./chat/turn-error";
|
|
27
28
|
import { AiChatTaskAuthorityError, aiChatTasks } from "./chat-tasks";
|
|
28
29
|
import { createCloudCompactFn } from "./compaction";
|
|
@@ -134,6 +135,17 @@ const toolRoundState = (messages: AiStoredMessage[]): { issued: number; complete
|
|
|
134
135
|
};
|
|
135
136
|
};
|
|
136
137
|
|
|
138
|
+
const lastToolRoundFailed = (messages: AiStoredMessage[], blocks: AiTurnBlock[]): boolean => {
|
|
139
|
+
const latest = messages.findLast(({ message }) => message.role === "assistant")?.message;
|
|
140
|
+
if (latest?.role !== "assistant") return false;
|
|
141
|
+
const callIds = new Set(latest.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : [])));
|
|
142
|
+
// A worker may have stored a malformed call but crashed before its error result; load repairs that result without an event.
|
|
143
|
+
return (
|
|
144
|
+
[...callIds].some(isMalformedToolCallId) ||
|
|
145
|
+
blocks.some((block) => block.kind === "tool" && callIds.has(block.callId) && isFailedTool(block))
|
|
146
|
+
);
|
|
147
|
+
};
|
|
148
|
+
|
|
137
149
|
const indexConversationResources = async (input: Parameters<typeof aiConversations.indexConversationResources>[0]): Promise<void> => {
|
|
138
150
|
try {
|
|
139
151
|
await aiConversations.indexConversationResources(input);
|
|
@@ -1500,6 +1512,7 @@ export class AiTurnExecutor {
|
|
|
1500
1512
|
const outcome = await this.driveChatLoop({
|
|
1501
1513
|
loop,
|
|
1502
1514
|
pipeline,
|
|
1515
|
+
initialRoundFailed: lastToolRoundFailed(turnMessages, turnBlocks),
|
|
1503
1516
|
conversationId,
|
|
1504
1517
|
turnId,
|
|
1505
1518
|
abortController,
|
|
@@ -1572,6 +1585,7 @@ export class AiTurnExecutor {
|
|
|
1572
1585
|
private async driveChatLoop(input: {
|
|
1573
1586
|
loop: NessiLoop;
|
|
1574
1587
|
pipeline: StreamPipeline;
|
|
1588
|
+
initialRoundFailed: boolean;
|
|
1575
1589
|
conversationId: string;
|
|
1576
1590
|
turnId: string;
|
|
1577
1591
|
abortController: AbortController;
|
|
@@ -1613,6 +1627,10 @@ export class AiTurnExecutor {
|
|
|
1613
1627
|
} = input;
|
|
1614
1628
|
const stopHeartbeat = this.startHeartbeat(conversationId, turnId, abortController);
|
|
1615
1629
|
let lastIssueMessage: string | null = null;
|
|
1630
|
+
let lastResponse: AssistantMessage | undefined;
|
|
1631
|
+
let roundFailed = input.initialRoundFailed;
|
|
1632
|
+
let followedFailedRound = false;
|
|
1633
|
+
const malformedCallIds = new Set<string>();
|
|
1616
1634
|
|
|
1617
1635
|
try {
|
|
1618
1636
|
for await (const event of loop) {
|
|
@@ -1672,6 +1690,7 @@ export class AiTurnExecutor {
|
|
|
1672
1690
|
.noteToolCompleted({ turnId, callId: event.callId, isError: event.isError })
|
|
1673
1691
|
.catch(() => log.warn("AI tool audit write failed", { code: "tool_audit_complete_failed", turnId, callId: event.callId }));
|
|
1674
1692
|
const toolBlock = pipeline.blocks.find((block) => block.kind === "tool" && block.callId === event.callId);
|
|
1693
|
+
if (toolBlock?.kind === "tool" && isFailedTool(toolBlock)) roundFailed = true;
|
|
1675
1694
|
// The policy keys calls by the name the model called, as the persisted calls it seeds from.
|
|
1676
1695
|
noteToolCall(
|
|
1677
1696
|
toolBlock?.kind === "tool"
|
|
@@ -1687,20 +1706,44 @@ export class AiTurnExecutor {
|
|
|
1687
1706
|
result: event.result,
|
|
1688
1707
|
isError: event.isError === true,
|
|
1689
1708
|
});
|
|
1690
|
-
} else if (event.type === "turn_end"
|
|
1691
|
-
|
|
1709
|
+
} else if (event.type === "turn_end") {
|
|
1710
|
+
lastResponse = event.message;
|
|
1711
|
+
followedFailedRound = roundFailed;
|
|
1712
|
+
const calls = new Set(event.message.content.flatMap((block) => (block.type === "tool_call" ? [block.id] : [])));
|
|
1713
|
+
// nessi closes a round after executing its tools. Malformed calls have stored failed blocks,
|
|
1714
|
+
// but are added by Cloud's store wrapper, so they are absent from the event's message.
|
|
1715
|
+
roundFailed = pipeline.blocks.some(
|
|
1716
|
+
(block) => block.kind === "tool" && (calls.has(block.callId) || malformedCallIds.has(block.callId)) && isFailedTool(block),
|
|
1717
|
+
);
|
|
1718
|
+
malformedCallIds.clear();
|
|
1719
|
+
if (!abortController.signal.aborted && isToolRound(event.message)) noteToolRound();
|
|
1692
1720
|
} else if (event.type === "issue") {
|
|
1721
|
+
if (event.issue.kind === "malformed_tool_call" && event.issue.callId) malformedCallIds.add(event.issue.callId);
|
|
1693
1722
|
lastIssueMessage = event.issue.message;
|
|
1694
1723
|
log.warn("AI turn issue", { conversationId, turnId, kind: event.issue.kind, message: event.issue.message });
|
|
1695
1724
|
} else if (event.type === "loop_end") {
|
|
1725
|
+
const stoppedAfterFailure =
|
|
1726
|
+
event.reason === "stop" &&
|
|
1727
|
+
!abortController.signal.aborted &&
|
|
1728
|
+
followedFailedRound &&
|
|
1729
|
+
lastResponse !== undefined &&
|
|
1730
|
+
!lastResponse.content.some((block) => block.type === "tool_call" || (block.type === "text" && block.text.trim() !== ""));
|
|
1731
|
+
const doneReason = stoppedAfterFailure ? "error" : event.reason;
|
|
1696
1732
|
const aggregate = await withDurableTurnTiming(turnId, event.aggregate);
|
|
1697
1733
|
if (aggregate.assistantMessageCount > 0) {
|
|
1698
1734
|
await aiConversations
|
|
1699
|
-
.setLatestAssistantLoopAggregate({ conversationId, loopId: turnId, aggregate, doneReason
|
|
1735
|
+
.setLatestAssistantLoopAggregate({ conversationId, loopId: turnId, aggregate, doneReason })
|
|
1700
1736
|
.catch(() => undefined);
|
|
1701
1737
|
}
|
|
1702
1738
|
const timing = aggregate.timing;
|
|
1703
1739
|
if (event.reason === "aborted") return { kind: "finished", status: "aborted", failure: null, timing };
|
|
1740
|
+
if (stoppedAfterFailure)
|
|
1741
|
+
return {
|
|
1742
|
+
kind: "finished",
|
|
1743
|
+
status: "failed",
|
|
1744
|
+
failure: { error: { code: "tool_failed" }, detail: "The model stopped without an answer after a failed tool call." },
|
|
1745
|
+
timing,
|
|
1746
|
+
};
|
|
1704
1747
|
if (event.reason === "stop") return { kind: "finished", status: "completed", failure: null, timing };
|
|
1705
1748
|
const detail = lastIssueMessage ?? `AI turn ended: ${event.reason}`;
|
|
1706
1749
|
const reason: AiTurnFailureReason =
|