@k2b/cloud 0.37.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 +4 -2
- package/src/_internal/help-catalog.ts +123 -4
- package/src/_internal/help.ts +27 -6
- 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/chart-block.tsx +2 -11
- package/src/ai/chat/messages.ts +10 -0
- 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/fetch-file-tool.ts +37 -3
- package/src/ai/migrate.ts +19 -2
- package/src/ai/readable-html.ts +1057 -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/cli/admin/workflows.ts +1 -0
- 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 +1 -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/ssr/Layout.tsx +20 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
- package/src/ssr/LayoutHelp.tsx +569 -410
- package/src/ssr/ShellRuntime.island.tsx +17 -25
- 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 +4 -0
- package/src/ssr/shell-data.tsx +7 -0
- package/src/ssr/shell-shortcuts.ts +8 -0
- package/src/styles/effects.css +688 -185
- package/src/styles/resource-search.css +25 -0
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"!src/**/*.test.tsx",
|
|
20
20
|
"!src/**/*.integration.test.ts",
|
|
21
21
|
"!src/**/*.test-fixture.ts",
|
|
22
|
+
"!src/**/*.test-fixture.tsx",
|
|
22
23
|
"!scripts/**/*.test.ts",
|
|
23
24
|
"!scripts/runtime-recovery-*.ts"
|
|
24
25
|
],
|
|
@@ -107,7 +108,7 @@
|
|
|
107
108
|
"@tailwindcss/typography": "0.5.20",
|
|
108
109
|
"@k2b/nessi": "0.18.0",
|
|
109
110
|
"@k2b/ssr": "0.17.0",
|
|
110
|
-
"@k2b/ui": "0.
|
|
111
|
+
"@k2b/ui": "0.28.0",
|
|
111
112
|
"@k2b/stdlib": "0.29.0",
|
|
112
113
|
"@k2b/sync": "7.0.0",
|
|
113
114
|
"@nats-io/jetstream": "3.4.0",
|
|
@@ -115,6 +116,7 @@
|
|
|
115
116
|
"@modelcontextprotocol/sdk": "1.32.1",
|
|
116
117
|
"bun-plugin-tailwind": "0.1.2",
|
|
117
118
|
"hono-openapi": "1.3.3",
|
|
119
|
+
"htmlparser2": "12.0.0",
|
|
118
120
|
"jose": "6.2.12",
|
|
119
121
|
"katex": "0.18.9",
|
|
120
122
|
"liquidjs": "10.29.0",
|
|
@@ -1,5 +1,124 @@
|
|
|
1
|
+
import { i18n } from "@k2b/stdlib";
|
|
2
|
+
import { formatShortcut } from "@k2b/ui";
|
|
3
|
+
import type { Token } from "marked";
|
|
1
4
|
import type { HelpArticle } from "../services/help/types";
|
|
2
|
-
import {
|
|
5
|
+
import { parseHelpReference } from "../shared/help";
|
|
6
|
+
import { lexHelpMarkdown, markdownToPlainText } from "../shared/markdown";
|
|
7
|
+
|
|
8
|
+
const helpAgentMessages = i18n.define({
|
|
9
|
+
baseLocale: "en",
|
|
10
|
+
messages: {
|
|
11
|
+
en: { apple: ({ shortcut }: { shortcut: string }) => `${shortcut} on Mac` },
|
|
12
|
+
de: { apple: ({ shortcut }) => `${shortcut} auf dem Mac` },
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const helpLinkLabel = (source: string, fallback: string): string => {
|
|
17
|
+
// Index backtick runs once so unmatched runs cannot repeatedly scan the remaining source.
|
|
18
|
+
const backticks = new Map<number, { positions: number[]; next: number }>();
|
|
19
|
+
for (const run of source.matchAll(/`+/g)) {
|
|
20
|
+
const length = run[0].length;
|
|
21
|
+
if (!backticks.has(length)) backticks.set(length, { positions: [], next: 0 });
|
|
22
|
+
backticks.get(length)!.positions.push(run.index);
|
|
23
|
+
}
|
|
24
|
+
let depth = 0;
|
|
25
|
+
for (let index = 1; index < source.length; index++) {
|
|
26
|
+
const char = source[index];
|
|
27
|
+
if (char === "\\") index++;
|
|
28
|
+
else if (char === "`") {
|
|
29
|
+
let end = index + 1;
|
|
30
|
+
while (source[end] === "`") end++;
|
|
31
|
+
const length = end - index;
|
|
32
|
+
const runs = backticks.get(length);
|
|
33
|
+
if (runs) {
|
|
34
|
+
while (runs.next < runs.positions.length && runs.positions[runs.next]! <= index) runs.next++;
|
|
35
|
+
}
|
|
36
|
+
const closing = runs?.positions[runs.next];
|
|
37
|
+
index = closing === undefined ? end - 1 : closing + length - 1;
|
|
38
|
+
} else if (char === "[") depth++;
|
|
39
|
+
else if (char === "]") {
|
|
40
|
+
if (depth === 0) return source.slice(1, index);
|
|
41
|
+
depth--;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return fallback;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Portable source for agents and Copy as Markdown; code is kept verbatim. */
|
|
48
|
+
export const helpAgentMarkdown = (
|
|
49
|
+
article: Pick<HelpArticle, "markdown" | "locale" | "appId" | "links">,
|
|
50
|
+
tokens: readonly Token[] = lexHelpMarkdown(article.markdown, article.locale),
|
|
51
|
+
): string => {
|
|
52
|
+
const { t } = helpAgentMessages.resolve([article.locale]);
|
|
53
|
+
const rewriteChildren = (raw: string, children: readonly Token[]): string => {
|
|
54
|
+
let offset = 0;
|
|
55
|
+
let result = "";
|
|
56
|
+
for (const child of children) {
|
|
57
|
+
let start = raw.indexOf(child.raw, offset);
|
|
58
|
+
let source = child.raw;
|
|
59
|
+
if (start < 0 && child.raw.includes("|")) {
|
|
60
|
+
source = child.raw.replaceAll("|", "\\|");
|
|
61
|
+
start = raw.indexOf(source, offset);
|
|
62
|
+
}
|
|
63
|
+
// Marked removes quote markers and list indentation from child block sources.
|
|
64
|
+
if (start < 0 && child.raw.includes("\n")) {
|
|
65
|
+
const pattern = child.raw
|
|
66
|
+
.split("\n")
|
|
67
|
+
.map((line, index) => (index > 0 ? line.replace(/^[ \t]+/, "") : line).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
68
|
+
.join("\\r?\\n[ \\t>]*");
|
|
69
|
+
const match = new RegExp(pattern).exec(raw.slice(offset));
|
|
70
|
+
if (match) {
|
|
71
|
+
start = offset + match.index;
|
|
72
|
+
source = match[0];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (start < 0) break;
|
|
76
|
+
result += raw.slice(offset, start) + rewrite(child, source);
|
|
77
|
+
offset = start + source.length;
|
|
78
|
+
}
|
|
79
|
+
return result + raw.slice(offset);
|
|
80
|
+
};
|
|
81
|
+
const rewrite = (token: Token, source: string): string => {
|
|
82
|
+
if (token.type === "code" || token.type === "codespan") return source;
|
|
83
|
+
if (token.type === "helpKbd") {
|
|
84
|
+
const keys = String(token.keys);
|
|
85
|
+
const other = formatShortcut(keys, { platform: "other", locale: article.locale });
|
|
86
|
+
const apple = formatShortcut(keys, { platform: "apple", locale: article.locale }).label;
|
|
87
|
+
// Key labels such as | or ` stay text in a table cell or a sentence.
|
|
88
|
+
const escape = (text: string) => text.replace(/[\\`*_[\]|<>~]/g, "\\$&");
|
|
89
|
+
// A key without a platform modifier reads by its name: "Enter", not "↵".
|
|
90
|
+
return other.label === apple ? escape(other.name) : `${escape(other.label)} (${t.apple({ shortcut: escape(apple) })})`;
|
|
91
|
+
}
|
|
92
|
+
if (token.type === "helpDetails") {
|
|
93
|
+
const newline = source.includes("\r\n") ? "\r\n" : "\n";
|
|
94
|
+
const bodyStart = source.indexOf("\n") + 1;
|
|
95
|
+
const closing = [...source.matchAll(/^[ \t>]*:::[ \t]*(?:\r?\n|$)/gm)].at(-1)!;
|
|
96
|
+
const body = source.slice(bodyStart, closing.index);
|
|
97
|
+
const prefix = body.match(/^[ \t>]+/)?.[0] ?? "";
|
|
98
|
+
const trailing = source.slice(closing.index + closing[0].length);
|
|
99
|
+
return `**${rewriteChildren(String(token.title), token.titleTokens ?? [])}**${newline}${prefix}${newline}${rewriteChildren(body, token.tokens ?? [])}${trailing}`;
|
|
100
|
+
}
|
|
101
|
+
if (token.type === "helpLabel") return rewriteChildren(source.replace(/\{icon="[a-z0-9-]+"\}$/, ""), token.tokens ?? []);
|
|
102
|
+
if (token.type === "link" && token.href.startsWith("help:")) {
|
|
103
|
+
const ref = parseHelpReference(token.href.slice(5));
|
|
104
|
+
const target = ref && article.links[`${ref.appId ?? article.appId}/${ref.documentId}`];
|
|
105
|
+
const label = rewriteChildren(helpLinkLabel(source, token.text), token.tokens ?? []);
|
|
106
|
+
return target ? `[${label}](${target.url}${ref.fragment})` : label;
|
|
107
|
+
}
|
|
108
|
+
const children =
|
|
109
|
+
token.type === "list"
|
|
110
|
+
? token.items
|
|
111
|
+
: token.type === "table"
|
|
112
|
+
? [...token.header, ...token.rows.flat()].flatMap((cell) => cell.tokens)
|
|
113
|
+
: "tokens" in token
|
|
114
|
+
? (token.tokens ?? [])
|
|
115
|
+
: [];
|
|
116
|
+
const raw = rewriteChildren(source, children);
|
|
117
|
+
return token.type === "heading" ? raw.replace(/[ \t]+\{icon="[a-z0-9-]+"\}(?=[ \t]*\r?\n?$)/i, "") : raw;
|
|
118
|
+
};
|
|
119
|
+
return rewriteChildren(article.markdown, tokens);
|
|
120
|
+
};
|
|
121
|
+
|
|
3
122
|
export const HELP_SEARCH_MAX_LIMIT = 25;
|
|
4
123
|
export const HELP_READ_MAX_CHARS = 7_000;
|
|
5
124
|
const normalizeSearchText = (value: string): string =>
|
|
@@ -33,7 +152,7 @@ const boundedExcerpt = (markdown: string, terms: readonly string[]): string => {
|
|
|
33
152
|
return `${start > 0 ? `${marker}\n\n` : ""}${body}${start + budget < markdown.length ? `\n\n${marker}` : ""}`;
|
|
34
153
|
};
|
|
35
154
|
|
|
36
|
-
export const selectHelpMarkdown = (markdown: string, query?: string): { markdown: string; truncated: boolean } => {
|
|
155
|
+
export const selectHelpMarkdown = (markdown: string, query?: string, locale?: string): { markdown: string; truncated: boolean } => {
|
|
37
156
|
// An exact heading identifies one complete reference section, even in short articles.
|
|
38
157
|
const headingQuery = query?.trim().toLocaleLowerCase();
|
|
39
158
|
const exact =
|
|
@@ -53,7 +172,7 @@ export const selectHelpMarkdown = (markdown: string, query?: string): { markdown
|
|
|
53
172
|
const phrase = normalizeSearchText(query ?? "");
|
|
54
173
|
const ranked = splitSections(markdown)
|
|
55
174
|
.map((section, index) => {
|
|
56
|
-
const text = normalizeSearchText(markdownToPlainText(section));
|
|
175
|
+
const text = normalizeSearchText(markdownToPlainText(section, locale));
|
|
57
176
|
const score = (phrase && text.includes(phrase) ? 10 : 0) + terms.filter((term) => text.includes(term)).length;
|
|
58
177
|
return { section, index, score };
|
|
59
178
|
})
|
|
@@ -79,7 +198,7 @@ export const readHelpArticle = (document: HelpArticle, query?: string) => ({
|
|
|
79
198
|
documentId: document.documentId,
|
|
80
199
|
title: document.title,
|
|
81
200
|
description: document.description,
|
|
82
|
-
...selectHelpMarkdown(document
|
|
201
|
+
...selectHelpMarkdown(helpAgentMarkdown(document), query, document.locale),
|
|
83
202
|
});
|
|
84
203
|
|
|
85
204
|
export const helpResourceUri = (appId: string, documentId: string): string =>
|
package/src/_internal/help.ts
CHANGED
|
@@ -2,28 +2,49 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import type { AppRegistryHelpSummary } from "../contracts/registry";
|
|
3
3
|
import type { HelpDefinition } from "../server/help";
|
|
4
4
|
import type { HelpCorpus, HelpDocument } from "../services/help/types";
|
|
5
|
+
import { parseHelpReference } from "../shared/help";
|
|
6
|
+
import { lexHelpMarkdown, walkHelpTokens } from "../shared/markdown";
|
|
5
7
|
|
|
6
8
|
export const HELP_DOCUMENT_MAX_BYTES = 128 * 1024;
|
|
9
|
+
// defineHelp documents are frozen; retain only their validated targets, not the token trees.
|
|
10
|
+
export const helpLinkTargets = new WeakMap<object, readonly string[]>();
|
|
7
11
|
export type CompiledHelp = { summary: AppRegistryHelpSummary; corpus: HelpCorpus };
|
|
8
12
|
|
|
9
13
|
export const compileHelp = (input: { appId: string; basePath?: string; definition: HelpDefinition }): CompiledHelp => {
|
|
10
|
-
const
|
|
11
|
-
|
|
14
|
+
const baseIds = new Set(input.definition.documents.map((document) => document.id));
|
|
15
|
+
const compileDocuments = (source: HelpDefinition["documents"], locale: string): HelpDocument[] =>
|
|
16
|
+
source.map((document) => {
|
|
17
|
+
const { id, title, icon, description, order, kind, needs, next, related, markdown, searchText } = document;
|
|
12
18
|
if (Buffer.byteLength(markdown) > HELP_DOCUMENT_MAX_BYTES) {
|
|
13
19
|
throw new Error(`Help document "${id}" exceeds the ${HELP_DOCUMENT_MAX_BYTES}-byte limit`);
|
|
14
20
|
}
|
|
15
|
-
|
|
21
|
+
const validate = (reference: string) => {
|
|
22
|
+
const ref = parseHelpReference(reference);
|
|
23
|
+
if (ref?.appId === input.appId && !baseIds.has(ref.documentId))
|
|
24
|
+
throw new Error(`Help locale "${locale}" document "${id}" has unknown reference "${reference}"`);
|
|
25
|
+
};
|
|
26
|
+
for (const reference of related) validate(reference);
|
|
27
|
+
const targets = helpLinkTargets.get(document);
|
|
28
|
+
if (targets) {
|
|
29
|
+
for (const target of targets) validate(target);
|
|
30
|
+
} else {
|
|
31
|
+
walkHelpTokens(lexHelpMarkdown(markdown, locale), (token) => {
|
|
32
|
+
if (token.type === "link" && token.href.startsWith("help:")) validate(token.href.slice(5));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return { id, title, icon, description, order, kind, needs, next, related, markdown, searchText };
|
|
16
36
|
});
|
|
17
37
|
const baseLocale = input.definition.baseLocale ?? "en";
|
|
18
|
-
const documents = compileDocuments(input.definition.documents);
|
|
38
|
+
const documents = compileDocuments(input.definition.documents, baseLocale);
|
|
19
39
|
const documentsByLocale = Object.fromEntries(
|
|
20
40
|
Object.entries(input.definition.documentsByLocale ?? {})
|
|
21
41
|
.filter(([locale]) => locale !== baseLocale)
|
|
22
42
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
23
|
-
.map(([locale, docs]) => [locale, compileDocuments(docs)]),
|
|
43
|
+
.map(([locale, docs]) => [locale, compileDocuments(docs, locale)]),
|
|
24
44
|
);
|
|
45
|
+
// A revision bump republishes every corpus, here to fill the new metadata columns.
|
|
25
46
|
const manifestHash = createHash("sha256")
|
|
26
|
-
.update(JSON.stringify({ revision:
|
|
47
|
+
.update(JSON.stringify({ revision: 2, appId: input.appId, baseLocale, documents, documentsByLocale }))
|
|
27
48
|
.digest("hex");
|
|
28
49
|
return {
|
|
29
50
|
summary: { manifestHash, pageBase: `${input.basePath?.replace(/\/$/, "") ?? ""}/help`, baseLocale },
|
package/src/account/Avatar.tsx
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { Avatar, type AvatarProps } from "@k2b/ui";
|
|
2
2
|
import type { JSX } from "solid-js";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
/** Who an avatar stands for; agents and service accounts are square, people round. */
|
|
5
|
+
export type CloudAvatarKind = "user" | "service_account" | "system";
|
|
6
|
+
|
|
7
|
+
export type CloudAvatarProps = Omit<AvatarProps, "name" | "src" | "shape" | "icon"> & {
|
|
5
8
|
username: string;
|
|
6
9
|
userId?: string | null;
|
|
7
10
|
avatarHash?: string | null;
|
|
11
|
+
kind?: CloudAvatarKind;
|
|
8
12
|
};
|
|
9
13
|
|
|
10
14
|
const avatarSource = (userId?: string | null, avatarHash?: string | null): string | undefined => {
|
|
@@ -12,14 +16,28 @@ const avatarSource = (userId?: string | null, avatarHash?: string | null): strin
|
|
|
12
16
|
return `/api/accounts/users/${encodeURIComponent(userId)}/avatar?rev=${encodeURIComponent(avatarHash)}`;
|
|
13
17
|
};
|
|
14
18
|
|
|
19
|
+
/** The URL of a person's uploaded picture, or undefined when there is none. */
|
|
20
|
+
export const cloudAvatarSource = avatarSource;
|
|
21
|
+
|
|
22
|
+
const KIND_ICON: Record<CloudAvatarKind, string | undefined> = {
|
|
23
|
+
user: undefined,
|
|
24
|
+
service_account: "ti ti-robot",
|
|
25
|
+
system: "ti ti-settings-automation",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** One avatar for every Cloud surface: the uploaded picture when there is one, otherwise initials. */
|
|
15
29
|
export function CloudAvatar(props: CloudAvatarProps): JSX.Element {
|
|
30
|
+
const kind = () => props.kind ?? "user";
|
|
16
31
|
return (
|
|
17
32
|
<Avatar
|
|
18
33
|
name={props.username}
|
|
19
|
-
src={avatarSource(props.userId, props.avatarHash)}
|
|
34
|
+
src={kind() === "user" ? avatarSource(props.userId, props.avatarHash) : undefined}
|
|
35
|
+
icon={KIND_ICON[kind()]}
|
|
36
|
+
shape={kind() === "user" ? "circle" : "square"}
|
|
20
37
|
alt={props.alt}
|
|
21
38
|
fallback={props.fallback}
|
|
22
39
|
size={props.size}
|
|
40
|
+
ring={props.ring}
|
|
23
41
|
loading={props.loading}
|
|
24
42
|
class={props.class}
|
|
25
43
|
style={props.style}
|
package/src/account/ui.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { CloudAvatar, type CloudAvatarProps } from "./Avatar";
|
|
1
|
+
export { CloudAvatar, type CloudAvatarKind, type CloudAvatarProps, cloudAvatarSource } from "./Avatar";
|
|
2
2
|
export { type AvatarUploadDialogOptions, openAvatarUploadDialog } from "./avatar-upload-dialog";
|
|
3
3
|
export { default as EntitySearch, type EntitySearchPrincipal } from "./EntitySearch";
|
package/src/ai/browser.ts
CHANGED
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);
|
|
@@ -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}`,
|
|
@@ -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,
|