@k2b/cloud 0.37.0 → 0.39.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 +8 -3
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/define-app.ts +28 -2
- package/src/_internal/help-catalog.ts +123 -4
- package/src/_internal/help.ts +27 -6
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +30 -5
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/account/Avatar.tsx +20 -2
- package/src/account/ui.ts +1 -1
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/browser.ts +11 -0
- package/src/ai/chart-block.ts +44 -13
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/chart-block.tsx +2 -11
- package/src/ai/chat/messages.ts +10 -0
- package/src/ai/chat/turn-view.tsx +35 -3
- package/src/ai/chat/view-block.tsx +7 -2
- package/src/ai/chat/web-tools.tsx +23 -6
- package/src/ai/chat-task-contracts.ts +28 -3
- package/src/ai/chat-task-reasons-migrate.ts +68 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +460 -66
- package/src/ai/default-tool-contracts.ts +30 -13
- package/src/ai/fetch-file-tool.ts +37 -3
- package/src/ai/index.ts +12 -0
- package/src/ai/migrate.ts +46 -3
- package/src/ai/readable-html.ts +1057 -0
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +27 -1
- package/src/ai/runtime.ts +12 -27
- package/src/ai/store.ts +5 -3
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-wording.ts +45 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +12 -1
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-outgoing-mail.ts +4 -2
- package/src/api/app-approval.ts +9 -2
- package/src/api/approvals.ts +1 -1
- package/src/api/help.ts +23 -4
- package/src/api/index.ts +2 -1
- package/src/api/mcp.ts +8 -2
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/FileChooser.tsx +11 -3
- package/src/browser/app-approval.ts +7 -2
- package/src/browser/choose-files.browser-harness.tsx +4 -1
- package/src/browser/composer-catalog.ts +103 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +150 -0
- package/src/browser/file-chooser-messages.ts +4 -0
- package/src/browser/file-providers.ts +14 -1
- package/src/browser/panel-messages.ts +24 -0
- package/src/browser/panel-slot.ts +354 -0
- package/src/browser/panel.ts +28 -0
- package/src/browser/provider-folder.tsx +58 -5
- package/src/browser/resource-picker.tsx +17 -2
- package/src/cli/admin/workflows.ts +1 -0
- package/src/config/env.ts +14 -0
- package/src/contracts/app-approval.ts +49 -9
- package/src/contracts/app.ts +14 -0
- package/src/contracts/file-provider.ts +48 -1
- package/src/contracts/outgoing-mail.ts +8 -1
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/shared.ts +2 -0
- package/src/server/help.ts +136 -10
- package/src/server/services/access.ts +5 -1
- package/src/server/services/geo.ts +21 -4
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/allowed-network-fetch.ts +189 -0
- package/src/services/app-approval.ts +158 -38
- package/src/services/approvals/device.ts +2 -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 +22 -3
- package/src/services/notifications/browser.ts +16 -5
- 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/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +67 -43
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- 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/redirect.ts +12 -0
- package/src/ssr/CloudShell.island.tsx +86 -9
- package/src/ssr/Layout.tsx +62 -11
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
- package/src/ssr/LayoutHelp.tsx +569 -410
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/PanelLayout.tsx +27 -0
- package/src/ssr/PanelRuntime.island.tsx +87 -0
- package/src/ssr/PanelSlot.tsx +108 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +72 -25
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +100 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +15 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +431 -0
- package/src/ssr/help-messages.ts +60 -38
- package/src/ssr/help.ts +3 -0
- package/src/ssr/index.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/panel-slot.browser-harness.tsx +110 -0
- package/src/ssr/platform-messages.ts +34 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +11 -0
- package/src/ssr/shell-shortcuts.ts +19 -0
- package/src/styles/effects.css +689 -185
- package/src/styles/global.css +269 -55
- package/src/styles/panel.css +373 -0
- package/src/styles/resource-search.css +25 -0
- package/src/styles/tokens.css +23 -6
- package/src/styles/utilities-navigation.css +5 -21
- 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);
|
|
@@ -1,15 +1,4 @@
|
|
|
1
1
|
import { createContext, useContext } from "solid-js";
|
|
2
|
-
import {
|
|
3
|
-
type CapabilityActionWording,
|
|
4
|
-
capabilityActionOutcome,
|
|
5
|
-
capabilityActionSubject,
|
|
6
|
-
resolveCapabilityActionSentences,
|
|
7
|
-
} from "../../_internal/capability-sentences";
|
|
8
|
-
import type { AiTurnBlock } from "../protocol";
|
|
9
|
-
import { CLOUD_AI_TOOL_PRESENTATION } from "../tool-sentences";
|
|
10
|
-
import { displayToolName, isRecord } from "./message-utils";
|
|
11
|
-
|
|
12
|
-
type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
|
|
13
2
|
|
|
14
3
|
const ReaderTimeZoneContext = createContext<string>();
|
|
15
4
|
|
|
@@ -20,36 +9,4 @@ export const AiReaderTimeZoneProvider = ReaderTimeZoneContext.Provider;
|
|
|
20
9
|
export const useReaderTimeZone = (): string =>
|
|
21
10
|
useContext(ReaderTimeZoneContext) ?? (typeof window === "undefined" ? "UTC" : Intl.DateTimeFormat().resolvedOptions().timeZone);
|
|
22
11
|
|
|
23
|
-
|
|
24
|
-
* How the chat words one call that acts on something: an app Action through its saved presentation, a
|
|
25
|
-
* Cloud tool through Cloud's own catalog of the same shape. `null` for calls that only read.
|
|
26
|
-
*/
|
|
27
|
-
export const toolWording = (block: ToolBlock, locale: string): CapabilityActionWording | null => {
|
|
28
|
-
const presentation = block.presentation;
|
|
29
|
-
if (presentation?.kind === "capability") {
|
|
30
|
-
if (presentation.capabilityKind !== "action") return null;
|
|
31
|
-
return {
|
|
32
|
-
title: presentation.title,
|
|
33
|
-
...(presentation.sentences ? { sentences: presentation.sentences } : {}),
|
|
34
|
-
...(presentation.fields ? { fields: presentation.fields } : {}),
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
const sentences = resolveCapabilityActionSentences(block.name, CLOUD_AI_TOOL_PRESENTATION, locale);
|
|
38
|
-
return sentences ? { title: displayToolName(block.name, locale), sentences } : null;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
/** What the call does or would do, as one sentence; `null` for calls that only read. */
|
|
42
|
-
export const toolSubject = (block: ToolBlock, locale: string, timeZone: string): string | null => {
|
|
43
|
-
const wording = toolWording(block, locale);
|
|
44
|
-
return wording ? capabilityActionSubject(wording, block.args, { locale, timeZone }) : null;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
/** The sentence for one outcome, or `null` when the tool has none that renders for this call. */
|
|
48
|
-
export const toolOutcome = (block: ToolBlock, key: "done" | "rejected" | "notRun", locale: string, timeZone: string): string | null => {
|
|
49
|
-
const wording = toolWording(block, locale);
|
|
50
|
-
if (!wording) return null;
|
|
51
|
-
const result = isRecord(block.result) ? block.result : undefined;
|
|
52
|
-
// App Actions return their data inside the result envelope; Cloud tools return the data itself.
|
|
53
|
-
const data = block.presentation?.kind === "capability" ? result?.data : block.result;
|
|
54
|
-
return capabilityActionOutcome(wording, key, { input: block.args, data }, { locale, timeZone });
|
|
55
|
-
};
|
|
12
|
+
export { toolOutcome, toolSubject, toolWording } from "../tool-wording";
|
|
@@ -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
|
@@ -137,6 +137,11 @@ const messages = i18n.define({
|
|
|
137
137
|
revokeFailed: "Could not revoke. Try again.",
|
|
138
138
|
revokeWebsiteFor: ({ host }: { host: string }) => `Revoke the approval for ${host}`,
|
|
139
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",
|
|
140
145
|
details: "Details",
|
|
141
146
|
hideDetails: "Hide details",
|
|
142
147
|
moreOptions: ({ title }: { title: string }) => `More options for ${title}`,
|
|
@@ -446,6 +451,11 @@ const messages = i18n.define({
|
|
|
446
451
|
revokeFailed: "Konnte nicht entzogen werden. Versuche es erneut.",
|
|
447
452
|
revokeWebsiteFor: ({ host }: { host: string }) => `Freigabe für ${host} entziehen`,
|
|
448
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",
|
|
449
459
|
details: "Details",
|
|
450
460
|
hideDetails: "Details ausblenden",
|
|
451
461
|
moreOptions: ({ title }) => `Weitere Optionen für ${title}`,
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
untrack,
|
|
17
17
|
} from "solid-js";
|
|
18
18
|
import { markdown } from "../../shared";
|
|
19
|
+
import { type ChatTaskReason, chatTaskReasonSentence, chatTaskReasonState, chatTaskStopSentence } from "../chat-task-reasons";
|
|
19
20
|
import { AI_TURN_LEASE_MS, type AiTurnBlock } from "../protocol";
|
|
20
21
|
import type { AiTurnError } from "../types";
|
|
21
22
|
import { toolOutcome, toolSubject, useReaderTimeZone } from "./action-sentences";
|
|
@@ -65,8 +66,17 @@ export type AiTurnSegment = {
|
|
|
65
66
|
earlier: boolean;
|
|
66
67
|
/** Read reactively, so a ticking clock does not rebuild the segment. */
|
|
67
68
|
duration: () => AiTurnDuration | null;
|
|
68
|
-
/**
|
|
69
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Shown above the work of a delivered scheduled result. `stop` and `reason` add a system line the chat words in
|
|
71
|
+
* the reader's language; with `stop` or `reasonOnly` that line replaces the stored text.
|
|
72
|
+
*/
|
|
73
|
+
scheduledTask?: {
|
|
74
|
+
taskId: string;
|
|
75
|
+
occurrenceId: string;
|
|
76
|
+
reason?: ChatTaskReason;
|
|
77
|
+
reasonOnly?: true;
|
|
78
|
+
stop?: { cause: "paused" | "deleted"; done: string[] };
|
|
79
|
+
} | null;
|
|
70
80
|
/**
|
|
71
81
|
* The live turn cannot reach the model or the server right now: a model call waits for its retry, or the stream
|
|
72
82
|
* reconnects. The working segment's work line says so while its clock stands; a turn without a work line yet says
|
|
@@ -789,6 +799,7 @@ function AiTurnText(props: {
|
|
|
789
799
|
*/
|
|
790
800
|
export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosureState: AiToolDisclosureState }): JSX.Element {
|
|
791
801
|
const locale = useLocale();
|
|
802
|
+
const timeZone = useReaderTimeZone();
|
|
792
803
|
const chatActions = useAiChatActions();
|
|
793
804
|
const layout = () => props.segment().layout;
|
|
794
805
|
const results = createMemo(() => new Map(layout().results.map((result) => [result.id, result])));
|
|
@@ -829,7 +840,17 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
|
|
|
829
840
|
</div>
|
|
830
841
|
</Show>
|
|
831
842
|
<For each={resultIds()}>{(id) => <AiTurnResultView result={() => results().get(id)} receipt={() => actions().has(id)} />}</For>
|
|
832
|
-
<Show when={
|
|
843
|
+
<Show when={props.segment().scheduledTask?.stop}>
|
|
844
|
+
{(stop) => (
|
|
845
|
+
<Chat.Activity
|
|
846
|
+
class="ai-turn__routine-line"
|
|
847
|
+
wrap
|
|
848
|
+
icon="ti ti-player-stop"
|
|
849
|
+
label={chatTaskStopSentence(stop().done, locale())}
|
|
850
|
+
/>
|
|
851
|
+
)}
|
|
852
|
+
</Show>
|
|
853
|
+
<Show when={layout().text.length > 0 && !props.segment().scheduledTask?.stop && !props.segment().scheduledTask?.reasonOnly}>
|
|
833
854
|
<AiTurnText
|
|
834
855
|
blocks={() => layout().text}
|
|
835
856
|
live={live}
|
|
@@ -838,6 +859,17 @@ export function AiTurnView(props: { segment: Accessor<AiTurnSegment>; disclosure
|
|
|
838
859
|
earlier={() => props.segment().earlier}
|
|
839
860
|
/>
|
|
840
861
|
</Show>
|
|
862
|
+
<Show when={props.segment().scheduledTask?.stop ? undefined : props.segment().scheduledTask?.reason}>
|
|
863
|
+
{(reason) => (
|
|
864
|
+
<Chat.Activity
|
|
865
|
+
class="ai-turn__routine-line"
|
|
866
|
+
wrap
|
|
867
|
+
icon={chatTaskReasonState(reason().code) === "paused" ? "ti ti-player-pause" : "ti ti-alert-triangle"}
|
|
868
|
+
tone={chatTaskReasonState(reason().code) === "paused" ? "neutral" : "warning"}
|
|
869
|
+
label={chatTaskReasonSentence(reason(), { locale: locale(), timeZone })}
|
|
870
|
+
/>
|
|
871
|
+
)}
|
|
872
|
+
</Show>
|
|
841
873
|
<For each={actionIds()}>
|
|
842
874
|
{(id) => <AiTurnActionView action={() => actions().get(id)} turnId={props.segment().turnId} phase={props.segment().phase} />}
|
|
843
875
|
</For>
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
useLocale,
|
|
11
11
|
} from "@k2b/ui";
|
|
12
12
|
import { createMemo, createSignal, createUniqueId, Index, type JSX, Match, onCleanup, onMount, Show, Switch } from "solid-js";
|
|
13
|
-
import type
|
|
13
|
+
import { type CloudAiChartRow, cloudAiNumberFormatters } from "../chart-block";
|
|
14
14
|
import {
|
|
15
15
|
CLOUD_AI_DETAILS_VIEWPORT_ITEMS,
|
|
16
16
|
CLOUD_AI_VIEW_MAX_BYTES,
|
|
@@ -244,12 +244,17 @@ function CloudStatsView(props: { summary: StatsSummary; input: CloudAiStatsView
|
|
|
244
244
|
{(value) => (
|
|
245
245
|
<StatGrid columns={columns()}>
|
|
246
246
|
<Index each={value()}>
|
|
247
|
-
{(tile) => (
|
|
247
|
+
{(tile, index) => (
|
|
248
248
|
<StatCell
|
|
249
249
|
label={tile().label}
|
|
250
250
|
value={tile().value}
|
|
251
251
|
title={tile().value}
|
|
252
252
|
trend={tile().sparkline}
|
|
253
|
+
trendTooltip={({ datum }) => {
|
|
254
|
+
const point = tile().sparkline?.[datum.index];
|
|
255
|
+
const format = cloudAiNumberFormatters(props.input?.items[index]?.format, locale()).value;
|
|
256
|
+
return { rows: point === undefined ? [] : [{ label: tile().label, value: format(point) }] };
|
|
257
|
+
}}
|
|
253
258
|
sub={tile().sub}
|
|
254
259
|
accent={deltaAccent(tile().delta)}
|
|
255
260
|
/>
|
|
@@ -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
|
)}
|
|
@@ -6,14 +6,25 @@ import { dates } from "@k2b/stdlib";
|
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { coreSettings } from "../services";
|
|
8
8
|
import { normalizeWorkflowSchedule } from "../workflows/runtime";
|
|
9
|
+
import type { ChatTaskReason } from "./chat-task-reasons";
|
|
9
10
|
import type { AiChatTask, AiChatTaskOccurrence, AiChatTaskOccurrenceState, AiChatTaskSchedule, AiChatTaskState } from "./chat-tasks";
|
|
10
11
|
import { AI_SHORT_ID_PATTERN } from "./short-id";
|
|
11
12
|
|
|
12
|
-
export const ChatTaskIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character
|
|
13
|
-
export const ChatTaskOccurrenceIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character
|
|
13
|
+
export const ChatTaskIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character routine ID.");
|
|
14
|
+
export const ChatTaskOccurrenceIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character routine run ID.");
|
|
14
15
|
export const AiConversationIdSchema = z.string().regex(AI_SHORT_ID_PATTERN).describe("Readable six-character AI conversation ID.");
|
|
15
16
|
|
|
17
|
+
export const chatTaskName = (task: { title?: string | null; chatTitle?: string | null; prompt: string }): string =>
|
|
18
|
+
task.title?.trim() ||
|
|
19
|
+
task.chatTitle?.trim() ||
|
|
20
|
+
Array.from(task.prompt.split(/\n|:\s/u)[0]!.trim())
|
|
21
|
+
.slice(0, 80)
|
|
22
|
+
.join("");
|
|
23
|
+
|
|
16
24
|
export type AiChatTaskView = {
|
|
25
|
+
title: string | null;
|
|
26
|
+
name: string;
|
|
27
|
+
reason: ChatTaskReason | null;
|
|
17
28
|
nextRunAt?: string | null;
|
|
18
29
|
id: string;
|
|
19
30
|
chatId: string;
|
|
@@ -42,6 +53,9 @@ export type AiChatTaskOccurrenceView = {
|
|
|
42
53
|
};
|
|
43
54
|
|
|
44
55
|
export const toAiChatTaskView = (task: AiChatTask): AiChatTaskView => ({
|
|
56
|
+
title: task.title,
|
|
57
|
+
name: task.name,
|
|
58
|
+
reason: task.reasonCode && task.reasonAt ? { code: task.reasonCode, at: task.reasonAt } : null,
|
|
45
59
|
id: task.shortId,
|
|
46
60
|
chatId: task.chatId,
|
|
47
61
|
chatTitle: task.chatTitle,
|
|
@@ -93,12 +107,23 @@ export const getChatTaskTimezone = async (): Promise<string> =>
|
|
|
93
107
|
export const chatTaskCreateFingerprint = (input: {
|
|
94
108
|
chatId: string;
|
|
95
109
|
prompt: string;
|
|
110
|
+
title?: string;
|
|
96
111
|
schedule: z.infer<typeof ChatTaskScheduleInputSchema>;
|
|
97
112
|
timezone?: string;
|
|
98
113
|
grants?: TaskGrant[];
|
|
99
114
|
}): string =>
|
|
100
115
|
new Bun.CryptoHasher("sha256")
|
|
101
|
-
.update(
|
|
116
|
+
.update(
|
|
117
|
+
JSON.stringify([
|
|
118
|
+
input.chatId,
|
|
119
|
+
input.prompt.trim(),
|
|
120
|
+
input.schedule,
|
|
121
|
+
input.timezone ?? null,
|
|
122
|
+
input.grants ?? [],
|
|
123
|
+
// Without a title the hash stays as before titles existed, so earlier idempotency keys still replay.
|
|
124
|
+
...(input.title?.trim() ? [input.title.trim()] : []),
|
|
125
|
+
]),
|
|
126
|
+
)
|
|
102
127
|
.digest("hex");
|
|
103
128
|
|
|
104
129
|
export const normalizeChatTaskSchedule = async (
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { canonicalLocale } from "../shared/locale";
|
|
3
|
+
|
|
4
|
+
/** Called in a transaction; new columns and reason repairs commit together. */
|
|
5
|
+
export const migrateChatTaskReasons = async (sql: SQL): Promise<void> => {
|
|
6
|
+
await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS reason_code TEXT`.simple();
|
|
7
|
+
await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS reason_at TIMESTAMPTZ`.simple();
|
|
8
|
+
await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS title TEXT`.simple();
|
|
9
|
+
const [column] = await sql`SELECT 1 FROM information_schema.columns
|
|
10
|
+
WHERE table_schema = 'ai' AND table_name = 'chat_tasks' AND column_name = 'locale'`;
|
|
11
|
+
if (!column) {
|
|
12
|
+
await sql`ALTER TABLE ai.chat_tasks ADD COLUMN IF NOT EXISTS locale TEXT`.simple();
|
|
13
|
+
// Data-sized work runs only with the new column. A restart never rescans old turns.
|
|
14
|
+
let cursor: string | null = null;
|
|
15
|
+
while (true) {
|
|
16
|
+
const rows: { id: string; locale: string | null }[] = await sql`
|
|
17
|
+
SELECT task.id, COALESCE(
|
|
18
|
+
(SELECT turn.run_config->>'locale' FROM ai.turns turn
|
|
19
|
+
WHERE turn.conversation_id = task.conversation_id AND turn.run_config->>'kind' = 'chat'
|
|
20
|
+
AND NOT (turn.run_config ? 'background') AND turn.created_at <= task.created_at
|
|
21
|
+
ORDER BY turn.created_at DESC, turn.id DESC LIMIT 1),
|
|
22
|
+
(SELECT turn.run_config->>'locale' FROM ai.turns turn
|
|
23
|
+
WHERE turn.conversation_id = task.conversation_id AND turn.run_config->>'kind' = 'chat'
|
|
24
|
+
AND NOT (turn.run_config ? 'background')
|
|
25
|
+
ORDER BY turn.created_at DESC, turn.id DESC LIMIT 1)) AS locale
|
|
26
|
+
FROM ai.chat_tasks task WHERE (${cursor}::uuid IS NULL OR task.id > ${cursor}::uuid)
|
|
27
|
+
ORDER BY task.id LIMIT 500`;
|
|
28
|
+
if (!rows.length) break;
|
|
29
|
+
for (const row of rows) {
|
|
30
|
+
const locale = canonicalLocale(row.locale);
|
|
31
|
+
if (locale) await sql`UPDATE ai.chat_tasks SET locale = ${locale} WHERE id = ${row.id}::uuid`;
|
|
32
|
+
}
|
|
33
|
+
cursor = rows[rows.length - 1]!.id;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
await sql`
|
|
37
|
+
DO $$ BEGIN
|
|
38
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_reason_code_check') THEN
|
|
39
|
+
ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_reason_code_check
|
|
40
|
+
CHECK (reason_code IN ('paused', 'chat_archived', 'owner_inactive', 'failed', 'rights_changed', 'needs_approval'));
|
|
41
|
+
END IF;
|
|
42
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_title_check') THEN
|
|
43
|
+
ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_title_check CHECK (char_length(btrim(title)) BETWEEN 1 AND 80);
|
|
44
|
+
END IF;
|
|
45
|
+
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'ai_chat_tasks_reason_state_check') THEN
|
|
46
|
+
-- Repair legacy and rollback writes, preserving valid reasons and their timestamps.
|
|
47
|
+
UPDATE ai.chat_tasks SET reason_code = CASE
|
|
48
|
+
WHEN state NOT IN ('paused', 'needs_attention') THEN NULL
|
|
49
|
+
WHEN state = 'paused' THEN 'paused'
|
|
50
|
+
WHEN last_error IN ('Scheduled task mandate revision changed', 'Scheduled task mandate or sponsor is unavailable') THEN 'rights_changed'
|
|
51
|
+
ELSE 'failed' END,
|
|
52
|
+
reason_at = CASE WHEN state IN ('paused', 'needs_attention') THEN updated_at ELSE NULL END
|
|
53
|
+
WHERE NOT (
|
|
54
|
+
(state IN ('paused', 'needs_attention')) = (reason_code IS NOT NULL)
|
|
55
|
+
AND (reason_code IS NULL OR
|
|
56
|
+
(state = 'paused' AND reason_code IN ('paused', 'chat_archived', 'owner_inactive')) OR
|
|
57
|
+
(state = 'needs_attention' AND reason_code IN ('failed', 'rights_changed', 'needs_approval')))
|
|
58
|
+
);
|
|
59
|
+
ALTER TABLE ai.chat_tasks ADD CONSTRAINT ai_chat_tasks_reason_state_check CHECK (
|
|
60
|
+
(state IN ('paused', 'needs_attention')) = (reason_code IS NOT NULL)
|
|
61
|
+
AND (reason_code IS NULL OR
|
|
62
|
+
(state = 'paused' AND reason_code IN ('paused', 'chat_archived', 'owner_inactive')) OR
|
|
63
|
+
(state = 'needs_attention' AND reason_code IN ('failed', 'rights_changed', 'needs_approval')))
|
|
64
|
+
);
|
|
65
|
+
END IF;
|
|
66
|
+
END $$
|
|
67
|
+
`.simple();
|
|
68
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { dates, i18n } from "@k2b/stdlib";
|
|
2
|
+
import { canonicalLocale } from "../shared/locale";
|
|
3
|
+
|
|
4
|
+
export const CHAT_TASK_REASON_CODES = ["paused", "chat_archived", "owner_inactive", "failed", "rights_changed", "needs_approval"] as const;
|
|
5
|
+
export type ChatTaskReasonCode = (typeof CHAT_TASK_REASON_CODES)[number];
|
|
6
|
+
export type ChatTaskReason = { code: ChatTaskReasonCode; at: string; action?: string };
|
|
7
|
+
|
|
8
|
+
export const chatTaskReasonState = (code: ChatTaskReasonCode): "paused" | "needs_attention" => {
|
|
9
|
+
switch (code) {
|
|
10
|
+
case "paused":
|
|
11
|
+
case "chat_archived":
|
|
12
|
+
case "owner_inactive":
|
|
13
|
+
return "paused";
|
|
14
|
+
case "failed":
|
|
15
|
+
case "rights_changed":
|
|
16
|
+
case "needs_approval":
|
|
17
|
+
return "needs_attention";
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const messages = i18n.define({
|
|
22
|
+
baseLocale: "en",
|
|
23
|
+
messages: {
|
|
24
|
+
en: {
|
|
25
|
+
paused: ({ time }: { time: string }) => `Paused by you on ${time}. What happens meanwhile is not caught up automatically.`,
|
|
26
|
+
chat_archived: "Paused because the chat is archived.",
|
|
27
|
+
owner_inactive: "Paused because the account is inactive.",
|
|
28
|
+
failed: "The last run failed.",
|
|
29
|
+
rights_changed: "What it may do changed. Please confirm again.",
|
|
30
|
+
needs_approval: ({ action }: { action?: string }) =>
|
|
31
|
+
`${action ? `Tried to ${action}. ` : ""}That needs your approval every time, and routines never ask.`,
|
|
32
|
+
stopped: ({ list }: { list: string }) =>
|
|
33
|
+
list
|
|
34
|
+
? `Stopped while the routine was running. Already done: ${list}. Nothing was undone.`
|
|
35
|
+
: "Stopped while the routine was running. Nothing was undone.",
|
|
36
|
+
},
|
|
37
|
+
de: {
|
|
38
|
+
paused: ({ time }) => `Von dir pausiert am ${time}. Was in der Pause passiert, wird nicht von selbst nachgeholt.`,
|
|
39
|
+
chat_archived: "Pausiert, weil der Chat archiviert ist.",
|
|
40
|
+
owner_inactive: "Pausiert, weil das Konto inaktiv ist.",
|
|
41
|
+
failed: "Der letzte Lauf ist fehlgeschlagen.",
|
|
42
|
+
rights_changed: "Was sie darf, hat sich geändert. Bitte neu bestätigen.",
|
|
43
|
+
needs_approval: ({ action }) => `${action ? `Wollte ${action}. ` : ""}Das macht der Assistant nur mit dir, und Routinen fragen nie.`,
|
|
44
|
+
stopped: ({ list }) =>
|
|
45
|
+
list
|
|
46
|
+
? `Angehalten, während die Routine lief. Schon erledigt: ${list}. Nichts wurde rückgängig gemacht.`
|
|
47
|
+
: "Angehalten, während die Routine lief. Nichts wurde rückgängig gemacht.",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A one-time routine resumes only before its time: a run at that time already happened or was stopped, and what was
|
|
54
|
+
* missed in a pause is not caught up. It needs a new time first.
|
|
55
|
+
*/
|
|
56
|
+
export const chatTaskNeedsNewTime = (
|
|
57
|
+
task: {
|
|
58
|
+
state: "active" | "paused" | "completed" | "needs_attention";
|
|
59
|
+
schedule: { kind: "once"; runAt: string } | { kind: "cron"; cron: string };
|
|
60
|
+
},
|
|
61
|
+
now: Date = new Date(),
|
|
62
|
+
): boolean =>
|
|
63
|
+
task.state !== "active" &&
|
|
64
|
+
task.schedule.kind === "once" &&
|
|
65
|
+
(task.state === "needs_attention" || Date.parse(task.schedule.runAt) <= now.getTime());
|
|
66
|
+
|
|
67
|
+
export const chatTaskLocale = (taskLocale: string | null | undefined, appLocale?: string | null): string =>
|
|
68
|
+
canonicalLocale(taskLocale) ?? canonicalLocale(appLocale) ?? "en";
|
|
69
|
+
|
|
70
|
+
export const chatTaskReasonSentence = (reason: ChatTaskReason, context: { locale: string; timeZone: string }): string => {
|
|
71
|
+
const t = messages.resolve([context.locale]).t;
|
|
72
|
+
// The record is exhaustive: a new reason must have a sentence in both catalogs.
|
|
73
|
+
const sentences: Record<ChatTaskReasonCode, () => string> = {
|
|
74
|
+
paused: () => t.paused({ time: dates.formatDateTime(reason.at, context) }),
|
|
75
|
+
chat_archived: () => t.chat_archived,
|
|
76
|
+
owner_inactive: () => t.owner_inactive,
|
|
77
|
+
failed: () => t.failed,
|
|
78
|
+
rights_changed: () => t.rights_changed,
|
|
79
|
+
needs_approval: () => t.needs_approval({ action: reason.action }),
|
|
80
|
+
};
|
|
81
|
+
return sentences[reason.code]();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// A scan from the end instead of /[.!?]+$/, which backtracks quadratically on long runs of punctuation.
|
|
85
|
+
const withoutClosingPunctuation = (text: string): string => {
|
|
86
|
+
let end = text.length;
|
|
87
|
+
while (end > 0 && ".!?".includes(text[end - 1]!)) end -= 1;
|
|
88
|
+
return text.slice(0, end);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const chatTaskStopSentence = (done: readonly string[], locale: string): string =>
|
|
92
|
+
messages.resolve([locale]).t.stopped({ list: done.map(withoutClosingPunctuation).join("; ") });
|