@k2b/cloud 0.29.1 → 0.31.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/app-presentation.ts +22 -1
- package/src/_internal/app-roles.ts +14 -0
- package/src/_internal/capabilities.ts +113 -5
- package/src/_internal/capability-sentences.ts +413 -0
- package/src/_internal/define-app.ts +24 -5
- package/src/_internal/registry-validation.ts +5 -0
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/access/PermissionEditor.tsx +43 -24
- package/src/ai/app-skill-store.ts +298 -0
- package/src/ai/app-skills.ts +105 -0
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +68 -9
- package/src/ai/chart-block.ts +380 -0
- package/src/ai/chat/action-sentences.ts +49 -0
- package/src/ai/chat/blocks.tsx +97 -40
- package/src/ai/chat/chart-block.tsx +79 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +3 -1
- package/src/ai/chat/message-utils.ts +3 -0
- package/src/ai/chat/messages.ts +26 -2
- package/src/ai/chat/tool-groups.ts +66 -12
- package/src/ai/chat/tool-names.ts +6 -2
- package/src/ai/chat/turn-layout.ts +10 -2
- package/src/ai/chat/turn-view.tsx +123 -36
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/code-runtime-tools.ts +109 -61
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/default-tool-contracts.ts +198 -0
- package/src/ai/default-tools.ts +25 -1
- package/src/ai/executor.ts +189 -35
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/index.ts +17 -1
- package/src/ai/migrate.ts +172 -14
- package/src/ai/protocol.ts +22 -1
- package/src/ai/routes.ts +11 -0
- package/src/ai/runtime-tools.ts +1 -0
- package/src/ai/runtime.ts +46 -7
- package/src/ai/skill-content.ts +54 -0
- package/src/ai/skills-routes.ts +22 -13
- package/src/ai/skills.ts +177 -231
- package/src/ai/store.ts +66 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tool-sentences.ts +38 -0
- package/src/ai/tools.ts +91 -3
- package/src/ai/types.ts +47 -1
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/admin-ai-skills.ts +35 -16
- package/src/api/capabilities.ts +3 -2
- package/src/api/fanout.ts +128 -0
- package/src/api/help.ts +8 -3
- package/src/api/mcp.ts +4 -1
- package/src/api/search.ts +31 -106
- package/src/api/widgets.ts +291 -101
- package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/ndjson.ts +34 -0
- package/src/browser/reload.ts +46 -0
- package/src/browser/search-stream.ts +4 -26
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +30 -0
- package/src/capabilities/executions.ts +3 -0
- package/src/capabilities/server.ts +20 -2
- package/src/cli/access.ts +48 -14
- package/src/cli/admin/ai-skills.ts +59 -39
- package/src/cli/commands.ts +20 -0
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/announcement-cookie.ts +62 -0
- package/src/contracts/announcements.ts +14 -52
- package/src/contracts/app.ts +34 -2
- package/src/contracts/capabilities.ts +24 -0
- package/src/contracts/pwa-paths.ts +7 -2
- package/src/contracts/pwa.ts +2 -7
- package/src/contracts/registry.ts +10 -1
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +67 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/index.ts +1 -0
- package/src/server/help.ts +8 -1
- package/src/server/index.ts +1 -0
- package/src/server/widget-request.ts +27 -0
- package/src/services/accounts/users.ts +15 -0
- package/src/services/approvals/index.ts +225 -0
- package/src/services/approvals/migrate.ts +33 -0
- package/src/services/help/index.ts +18 -5
- package/src/services/help/types.ts +3 -2
- package/src/services/identity/constants.ts +2 -0
- package/src/services/identity/key-ring.ts +123 -42
- package/src/services/identity/runtime-config.ts +13 -2
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +43 -8
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +12 -3
- package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
- package/src/ssr/GlobalSearchDialog.tsx +1 -43
- package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/app-navigation.ts +5 -7
- package/src/ssr/global-search-host.tsx +55 -0
- package/src/ssr/help.ts +3 -3
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +216 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/ssr/rail-context.ts +4 -3
- package/src/ssr/runtime.ts +14 -1
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +35 -2
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
- package/src/ai/code-mode-skill.ts +0 -114
- package/src/ai/data-analysis-skill.ts +0 -10
- package/src/ai/grids-skill.ts +0 -158
- package/src/ai/skill-seeds.ts +0 -560
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { dates, i18n } from "@k2b/stdlib";
|
|
2
|
+
import type { CapabilityActionSentences, CapabilityPresentationCatalog } from "../contracts/capabilities";
|
|
3
|
+
import { canonicalLocale, localeFallbackChain, normalizeLocale } from "../shared/locale";
|
|
4
|
+
import { normalizeTimeZone } from "../shared/time";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Action sentences: what an Action will do or did, in the words of its app. One pure renderer serves the
|
|
8
|
+
* server (approval text for the CLI and notifications) and the chat in the browser, so both read the same.
|
|
9
|
+
* It never knows an app: everything comes from the Action's presentation catalog and its schemas.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const CAPABILITY_SENTENCE_KEYS = ["approval", "done", "rejected", "notRun"] as const;
|
|
13
|
+
export type CapabilitySentenceKey = (typeof CAPABILITY_SENTENCE_KEYS)[number];
|
|
14
|
+
|
|
15
|
+
export const CAPABILITY_SENTENCE_MAX_CHARS = 200;
|
|
16
|
+
const MAX_PLACEHOLDERS = 6;
|
|
17
|
+
const MAX_VALUE_CHARS = 60;
|
|
18
|
+
const MAX_LIST_ITEMS = 3;
|
|
19
|
+
const MAX_RENDERED_CHARS = 240;
|
|
20
|
+
const MAX_FALLBACK_FIELDS = 2;
|
|
21
|
+
const MAX_LABELLED_FIELDS = 8;
|
|
22
|
+
const MAX_LABEL_CHARS = 40;
|
|
23
|
+
const MAX_DEPTH = 2;
|
|
24
|
+
|
|
25
|
+
export type CapabilitySentencePlaceholder = { source: "input" | "data"; path: string };
|
|
26
|
+
type Part = string | CapabilitySentencePlaceholder;
|
|
27
|
+
|
|
28
|
+
const PLACEHOLDER = /\{(input|data)\.([A-Za-z_][\w-]*(?:\[\])?(?:\.[A-Za-z_][\w-]*(?:\[\])?)*)\}/g;
|
|
29
|
+
/** Controls, invisible format characters such as direction marks, joiners, and soft hyphens, and line breaks. */
|
|
30
|
+
const UNSAFE_CHARACTER = /[\p{Cc}\p{Cf}\u2028\u2029]/u;
|
|
31
|
+
const UNSAFE_CHARACTERS = new RegExp(UNSAFE_CHARACTER.source, "gu");
|
|
32
|
+
|
|
33
|
+
/** Splits a template into text and placeholders, or names what makes it invalid. */
|
|
34
|
+
export const parseCapabilitySentence = (template: string): { parts: Part[] } | { error: string } => {
|
|
35
|
+
const parts: Part[] = [];
|
|
36
|
+
let index = 0;
|
|
37
|
+
for (const match of template.matchAll(PLACEHOLDER)) {
|
|
38
|
+
parts.push(template.slice(index, match.index));
|
|
39
|
+
parts.push({ source: match[1] as "input" | "data", path: match[2]! });
|
|
40
|
+
index = match.index + match[0].length;
|
|
41
|
+
}
|
|
42
|
+
parts.push(template.slice(index));
|
|
43
|
+
const text = parts.filter((part): part is string => typeof part === "string");
|
|
44
|
+
if (text.some((part) => part.includes("{") || part.includes("}"))) {
|
|
45
|
+
return { error: "uses a placeholder other than {input.path} or {data.path}" };
|
|
46
|
+
}
|
|
47
|
+
if (text.some((part) => UNSAFE_CHARACTER.test(part))) return { error: "contains control or direction characters" };
|
|
48
|
+
if (parts.length - text.length > MAX_PLACEHOLDERS) return { error: `uses more than ${MAX_PLACEHOLDERS} placeholders` };
|
|
49
|
+
return { parts: parts.filter((part) => part !== "") };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Placeholders of one valid template; empty for an invalid one. */
|
|
53
|
+
export const capabilitySentencePlaceholders = (template: string): CapabilitySentencePlaceholder[] => {
|
|
54
|
+
const parsed = parseCapabilitySentence(template);
|
|
55
|
+
return "parts" in parsed ? parsed.parts.filter((part): part is CapabilitySentencePlaceholder => typeof part !== "string") : [];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** How the renderer reads one schema field: its label for the generic sentence and its date semantics. */
|
|
59
|
+
export type CapabilitySentenceField = {
|
|
60
|
+
/** `input.<path>` or `data.<path>`. */
|
|
61
|
+
path: string;
|
|
62
|
+
/** Short field title in the reader's locale; only labelled input fields appear in the generic sentence. */
|
|
63
|
+
label?: string;
|
|
64
|
+
format?: "date" | "date-time";
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Everything a reader needs to word one Action call, resolved for one locale. */
|
|
68
|
+
export type CapabilityActionWording = {
|
|
69
|
+
title: string;
|
|
70
|
+
sentences?: CapabilityActionSentences;
|
|
71
|
+
fields?: readonly CapabilitySentenceField[];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** The reader: locale for words and numbers, IANA time zone for instants (UTC when unknown or invalid). */
|
|
75
|
+
export type CapabilitySentenceContext = { locale: string; timeZone?: string };
|
|
76
|
+
|
|
77
|
+
const words = i18n.define({
|
|
78
|
+
baseLocale: "en",
|
|
79
|
+
messages: {
|
|
80
|
+
en: {
|
|
81
|
+
yes: "yes",
|
|
82
|
+
no: "no",
|
|
83
|
+
more: ({ count }: { count: number }) => `${count} more`,
|
|
84
|
+
why: "Why",
|
|
85
|
+
},
|
|
86
|
+
de: {
|
|
87
|
+
yes: "ja",
|
|
88
|
+
no: "nein",
|
|
89
|
+
more: ({ count }) => `${count} weitere`,
|
|
90
|
+
why: "Warum",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
export const checkCapabilitySentenceMessages = () => words.check();
|
|
96
|
+
|
|
97
|
+
/** The input field a model may add to an Action call that waits for approval: its own reason, never app input. */
|
|
98
|
+
export const CAPABILITY_APPROVAL_REASON_FIELD = "approvalReason";
|
|
99
|
+
export const CAPABILITY_APPROVAL_REASON_MAX_CHARS = 300;
|
|
100
|
+
|
|
101
|
+
/** The model's reason for one call as one bounded line, or `null` when it gave none. */
|
|
102
|
+
export const capabilityApprovalReason = (args: unknown): string | null => {
|
|
103
|
+
const value =
|
|
104
|
+
isRecord(args) && Object.hasOwn(args, CAPABILITY_APPROVAL_REASON_FIELD) ? args[CAPABILITY_APPROVAL_REASON_FIELD] : undefined;
|
|
105
|
+
return typeof value === "string" ? plain(value, CAPABILITY_APPROVAL_REASON_MAX_CHARS) || null : null;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** The label of the model's reason, so a reader never takes it for the app's own words. */
|
|
109
|
+
export const capabilityApprovalReasonLabel = (locale: string): string => words.resolve([locale]).t.why;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The approval as text, for readers without the card such as the CLI: the app and what the call does, the app's live
|
|
113
|
+
* review of this call, which may say what the input cannot, such as a scheduled send, then the model's labelled reason.
|
|
114
|
+
*/
|
|
115
|
+
export const capabilityApprovalText = (input: {
|
|
116
|
+
appName: string;
|
|
117
|
+
subject: string;
|
|
118
|
+
review?: string | null;
|
|
119
|
+
reason?: string | null;
|
|
120
|
+
locale: string;
|
|
121
|
+
}): string =>
|
|
122
|
+
[
|
|
123
|
+
`${input.appName}: ${input.subject}`,
|
|
124
|
+
...(input.review?.trim() ? [input.review.trim()] : []),
|
|
125
|
+
...(input.reason ? [`${capabilityApprovalReasonLabel(input.locale)}: ${input.reason}`] : []),
|
|
126
|
+
].join("\n");
|
|
127
|
+
|
|
128
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
129
|
+
|
|
130
|
+
const own = (value: unknown, key: string): unknown => (isRecord(value) && Object.hasOwn(value, key) ? value[key] : undefined);
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Plain one-line text without control or invisible format characters, cut at a code point boundary. A value keeps
|
|
134
|
+
* its start and its end, so a long path keeps its file name and an address its domain; prose keeps its start.
|
|
135
|
+
*/
|
|
136
|
+
const plain = (value: string, max: number, cut: "end" | "middle" = "end"): string => {
|
|
137
|
+
const text = value.replace(UNSAFE_CHARACTERS, " ").replace(/\s+/g, " ").trim();
|
|
138
|
+
const points = Array.from(text);
|
|
139
|
+
if (points.length <= max) return text;
|
|
140
|
+
if (cut === "end")
|
|
141
|
+
return `${points
|
|
142
|
+
.slice(0, max - 1)
|
|
143
|
+
.join("")
|
|
144
|
+
.trimEnd()}…`;
|
|
145
|
+
const tail = Math.floor((max - 1) / 2);
|
|
146
|
+
return `${points
|
|
147
|
+
.slice(0, max - 1 - tail)
|
|
148
|
+
.join("")
|
|
149
|
+
.trimEnd()}…${points.slice(-tail).join("").trimStart()}`;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/** One inserted value: bounded, and cut in the middle so both of its ends stay readable. */
|
|
153
|
+
const valueText = (value: string): string => plain(value, MAX_VALUE_CHARS, "middle");
|
|
154
|
+
|
|
155
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
156
|
+
const DATE_TIME = /^\d{4}-\d{2}-\d{2}T([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|[+-]([01]\d|2[0-3]):[0-5]\d)$/;
|
|
157
|
+
|
|
158
|
+
/** A real calendar day: JavaScript rolls "2026-02-30" over to March, and year 0 would read as year 1. */
|
|
159
|
+
const isCalendarDate = (value: string): boolean => {
|
|
160
|
+
const date = new Date(`${value}T12:00:00.000Z`);
|
|
161
|
+
return !Number.isNaN(date.getTime()) && date.getUTCFullYear() >= 1 && date.toISOString().slice(0, 10) === value;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* A value of a field whose schema declares `format: "date"` or `"date-time"`: a calendar date stays that date
|
|
166
|
+
* everywhere, an instant shows in the reader's time zone, like review details. Anything that is not a real date
|
|
167
|
+
* or instant stays the text the call carries, so a sentence never names a different value than the one that runs.
|
|
168
|
+
*/
|
|
169
|
+
const formatDate = (value: string, format: "date" | "date-time", context: CapabilitySentenceContext): string | null => {
|
|
170
|
+
if (!isCalendarDate(value.slice(0, 10))) return null;
|
|
171
|
+
if (DATE.test(value)) return dates.formatDate(`${value}T12:00:00.000Z`, { locale: context.locale, timeZone: "UTC" });
|
|
172
|
+
if (!DATE_TIME.test(value) || Number.isNaN(new Date(value).getTime())) return null;
|
|
173
|
+
const options = { locale: context.locale, timeZone: normalizeTimeZone(context.timeZone) };
|
|
174
|
+
return format === "date-time" ? dates.formatDateTime(value, options) : dates.formatDate(value, options);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Keys that name a person or resource. The value an Action acts on comes first: a name from the model's input is
|
|
179
|
+
* not verified, so "Jana Berger" must never stand in for the address a draft actually goes to.
|
|
180
|
+
*/
|
|
181
|
+
const DISPLAY_KEYS = ["email", "address", "path", "displayName", "name", "title", "label"] as const;
|
|
182
|
+
|
|
183
|
+
const formatValue = (
|
|
184
|
+
value: unknown,
|
|
185
|
+
format: CapabilitySentenceField["format"],
|
|
186
|
+
context: CapabilitySentenceContext,
|
|
187
|
+
depth = 0,
|
|
188
|
+
): string | null => {
|
|
189
|
+
if (value === null || value === undefined) return null;
|
|
190
|
+
if (typeof value === "string") {
|
|
191
|
+
const text = value.trim();
|
|
192
|
+
if (!text) return null;
|
|
193
|
+
return (format ? formatDate(text, format, context) : null) ?? (valueText(text) || null);
|
|
194
|
+
}
|
|
195
|
+
if (typeof value === "number") return Number.isFinite(value) ? valueText(new Intl.NumberFormat(context.locale).format(value)) : null;
|
|
196
|
+
if (typeof value === "boolean") {
|
|
197
|
+
const t = words.resolve([context.locale]).t;
|
|
198
|
+
return value ? t.yes : t.no;
|
|
199
|
+
}
|
|
200
|
+
if (depth >= MAX_DEPTH) return null;
|
|
201
|
+
if (Array.isArray(value)) {
|
|
202
|
+
// An entry without a value to show still counts, so a list never reads shorter than the call it describes.
|
|
203
|
+
const items = value.flatMap((item) => {
|
|
204
|
+
const text = formatValue(item, format, context, depth + 1);
|
|
205
|
+
return text ? [text] : [];
|
|
206
|
+
});
|
|
207
|
+
if (items.length === 0) return null;
|
|
208
|
+
const shown = items.slice(0, value.length > MAX_LIST_ITEMS ? MAX_LIST_ITEMS - 1 : MAX_LIST_ITEMS);
|
|
209
|
+
const rest = value.length - shown.length;
|
|
210
|
+
const list = rest > 0 ? [...shown, words.resolve([context.locale]).t.more({ count: rest })] : shown;
|
|
211
|
+
return new Intl.ListFormat(context.locale, { type: "conjunction" }).format(list);
|
|
212
|
+
}
|
|
213
|
+
if (isRecord(value)) {
|
|
214
|
+
for (const key of DISPLAY_KEYS) {
|
|
215
|
+
const text = formatValue(own(value, key), undefined, context, MAX_DEPTH);
|
|
216
|
+
if (text) return text;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Reads a dotted schema path such as `to[].name` from a value without touching inherited properties. Inside a list
|
|
224
|
+
* an entry without the field stays as `undefined`, so `{input.to[].name}` still counts a recipient without a name.
|
|
225
|
+
*/
|
|
226
|
+
const valueAt = (root: unknown, path: string): unknown => {
|
|
227
|
+
let current: unknown[] = [root];
|
|
228
|
+
let list = false;
|
|
229
|
+
for (const segment of path.split(".")) {
|
|
230
|
+
const many = segment.endsWith("[]");
|
|
231
|
+
const key = many ? segment.slice(0, -2) : segment;
|
|
232
|
+
current = current.flatMap((entry) => {
|
|
233
|
+
const next = own(entry, key);
|
|
234
|
+
if (many && Array.isArray(next)) return next;
|
|
235
|
+
return list || (!many && next !== undefined) ? [many ? undefined : next] : [];
|
|
236
|
+
});
|
|
237
|
+
list ||= many;
|
|
238
|
+
}
|
|
239
|
+
return list ? current : current[0];
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Renders one template, or `null` when a placeholder has no value to show or the sentence would not fit: the
|
|
244
|
+
* caller then words the call generically instead of showing a sentence with a gap or without its last words,
|
|
245
|
+
* which in German often carry the verb or a "nicht".
|
|
246
|
+
*/
|
|
247
|
+
export const renderCapabilitySentence = (
|
|
248
|
+
template: string,
|
|
249
|
+
values: { input?: unknown; data?: unknown },
|
|
250
|
+
fields: readonly CapabilitySentenceField[] | undefined,
|
|
251
|
+
context: CapabilitySentenceContext,
|
|
252
|
+
): string | null => {
|
|
253
|
+
const parsed = parseCapabilitySentence(template);
|
|
254
|
+
if ("error" in parsed) return null;
|
|
255
|
+
let text = "";
|
|
256
|
+
for (const part of parsed.parts) {
|
|
257
|
+
if (typeof part === "string") {
|
|
258
|
+
text += part;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const path = `${part.source}.${part.path}`;
|
|
262
|
+
const format = fields?.find((field) => field.path === path)?.format;
|
|
263
|
+
const value = formatValue(valueAt(part.source === "input" ? values.input : values.data, part.path), format, context);
|
|
264
|
+
if (value === null) return null;
|
|
265
|
+
text += value;
|
|
266
|
+
}
|
|
267
|
+
const rendered = plain(text, Number.MAX_SAFE_INTEGER);
|
|
268
|
+
return rendered && Array.from(rendered).length <= MAX_RENDERED_CHARS ? rendered : null;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** The generic sentence: the title with up to two labelled input fields, in schema order. */
|
|
272
|
+
export const capabilitySentenceFallback = (
|
|
273
|
+
wording: CapabilityActionWording,
|
|
274
|
+
input: unknown,
|
|
275
|
+
context: CapabilitySentenceContext,
|
|
276
|
+
): string => {
|
|
277
|
+
const details: string[] = [];
|
|
278
|
+
for (const field of wording.fields ?? []) {
|
|
279
|
+
if (details.length >= MAX_FALLBACK_FIELDS) break;
|
|
280
|
+
if (!field.label || !field.path.startsWith("input.")) continue;
|
|
281
|
+
const value = formatValue(valueAt(input, field.path.slice("input.".length)), field.format, context);
|
|
282
|
+
if (value) details.push(`${field.label}: ${value}`);
|
|
283
|
+
}
|
|
284
|
+
return plain([plain(wording.title, MAX_VALUE_CHARS * 2), ...details].join(" · "), MAX_RENDERED_CHARS);
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
/** What the call does, as one sentence: the app's approval sentence, else the generic sentence. */
|
|
288
|
+
export const capabilityActionSubject = (wording: CapabilityActionWording, input: unknown, context: CapabilitySentenceContext): string =>
|
|
289
|
+
(wording.sentences?.approval ? renderCapabilitySentence(wording.sentences.approval, { input }, wording.fields, context) : null) ??
|
|
290
|
+
capabilitySentenceFallback(wording, input, context);
|
|
291
|
+
|
|
292
|
+
/** The app's own sentence for one outcome, or `null` when it has none that renders for this call. */
|
|
293
|
+
export const capabilityActionOutcome = (
|
|
294
|
+
wording: CapabilityActionWording,
|
|
295
|
+
key: Exclude<CapabilitySentenceKey, "approval">,
|
|
296
|
+
values: { input?: unknown; data?: unknown },
|
|
297
|
+
context: CapabilitySentenceContext,
|
|
298
|
+
): string | null => {
|
|
299
|
+
const template = wording.sentences?.[key];
|
|
300
|
+
return template ? renderCapabilitySentence(template, values, wording.fields, context) : null;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The sentences of one Action in the requested locale: the most specific locale that words the Action
|
|
305
|
+
* provides the whole set, so one receipt never mixes two languages.
|
|
306
|
+
*/
|
|
307
|
+
export const resolveCapabilityActionSentences = (
|
|
308
|
+
localId: string,
|
|
309
|
+
catalog: Pick<CapabilityPresentationCatalog, "baseLocale" | "sentences" | "translations"> | undefined,
|
|
310
|
+
requestedLocale: string,
|
|
311
|
+
): CapabilityActionSentences | undefined => {
|
|
312
|
+
if (!catalog) return undefined;
|
|
313
|
+
const base = normalizeLocale(catalog.baseLocale);
|
|
314
|
+
const byLocale = new Map(
|
|
315
|
+
Object.entries(catalog.translations).flatMap(([locale, translation]) => {
|
|
316
|
+
const canonical = canonicalLocale(locale);
|
|
317
|
+
return canonical ? [[canonical, translation] as const] : [];
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
for (const locale of localeFallbackChain(requestedLocale, base)) {
|
|
321
|
+
const sentences = locale === base ? catalog.sentences?.[localId] : byLocale.get(locale)?.actions?.[localId]?.sentences;
|
|
322
|
+
if (sentences) return sentences;
|
|
323
|
+
}
|
|
324
|
+
return undefined;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
type JsonSchema = Record<string, unknown>;
|
|
328
|
+
|
|
329
|
+
/** The schema of one field path, following properties, array items, and union members. */
|
|
330
|
+
const schemaAt = (schema: unknown, path: string): JsonSchema | undefined => {
|
|
331
|
+
let nodes: unknown[] = [schema];
|
|
332
|
+
const expand = (node: unknown): JsonSchema[] => {
|
|
333
|
+
if (!isRecord(node)) return [];
|
|
334
|
+
const members = (["anyOf", "oneOf", "allOf"] as const).flatMap((keyword) => (Array.isArray(node[keyword]) ? node[keyword] : []));
|
|
335
|
+
return [node, ...members.flatMap(expand)];
|
|
336
|
+
};
|
|
337
|
+
for (const segment of path.split(".")) {
|
|
338
|
+
const many = segment.endsWith("[]");
|
|
339
|
+
const key = many ? segment.slice(0, -2) : segment;
|
|
340
|
+
nodes = nodes.flatMap(expand).flatMap((node) => {
|
|
341
|
+
const property = own(node.properties, key);
|
|
342
|
+
if (!many) return property === undefined ? [] : [property];
|
|
343
|
+
return expand(property).flatMap((candidate) => (candidate.items === undefined ? [] : [candidate.items]));
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return nodes.flatMap(expand)[0];
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const schemaFormat = (schema: JsonSchema | undefined): CapabilitySentenceField["format"] => {
|
|
350
|
+
const nodes = [schema, ...(["anyOf", "oneOf"] as const).flatMap((keyword) => (Array.isArray(schema?.[keyword]) ? schema[keyword] : []))];
|
|
351
|
+
const format = nodes.map((node) => own(node, "format")).find((value) => value === "date" || value === "date-time");
|
|
352
|
+
return format as CapabilitySentenceField["format"];
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const schemaTypes = (schema: JsonSchema): string[] => {
|
|
356
|
+
const nodes = [schema, ...(["anyOf", "oneOf"] as const).flatMap((keyword) => (Array.isArray(schema[keyword]) ? schema[keyword] : []))];
|
|
357
|
+
return nodes.flatMap((node) => {
|
|
358
|
+
const type = own(node, "type");
|
|
359
|
+
return Array.isArray(type)
|
|
360
|
+
? type.filter((entry): entry is string => typeof entry === "string")
|
|
361
|
+
: typeof type === "string"
|
|
362
|
+
? [type]
|
|
363
|
+
: [];
|
|
364
|
+
});
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
/** Identifiers and concurrency tokens say nothing to a person. */
|
|
368
|
+
const OPAQUE_FIELD = /(^id$|Ids?$|Revision$|Hash$|Token$|Cursor$|^idempotencyKey$)/;
|
|
369
|
+
/** Long text such as a message body is not a detail of a one-line sentence. */
|
|
370
|
+
const MAX_DETAIL_TEXT_LENGTH = 1000;
|
|
371
|
+
|
|
372
|
+
/** A field description as a short title: its first clause, if that is short enough to label a value. */
|
|
373
|
+
const shortLabel = (description: unknown): string | undefined => {
|
|
374
|
+
if (typeof description !== "string") return undefined;
|
|
375
|
+
const clause = plain(description, 200)
|
|
376
|
+
.split(/[.;:(]\s|[;:(]|\.$/)[0]!
|
|
377
|
+
.trim();
|
|
378
|
+
return clause && Array.from(clause).length <= MAX_LABEL_CHARS ? clause : undefined;
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The fields a reader needs to word one Action: the formats of the placeholders its sentences use and
|
|
383
|
+
* the labelled top-level input fields that the generic sentence may show, in schema order and bounded.
|
|
384
|
+
* `inputDescriptions` are the reader's localized field descriptions keyed by dotted path.
|
|
385
|
+
*/
|
|
386
|
+
export const capabilitySentenceFields = (input: {
|
|
387
|
+
inputSchema: JsonSchema;
|
|
388
|
+
dataSchema?: JsonSchema;
|
|
389
|
+
sentences?: CapabilityActionSentences;
|
|
390
|
+
inputDescriptions?: Readonly<Record<string, string>>;
|
|
391
|
+
}): CapabilitySentenceField[] => {
|
|
392
|
+
const fields = new Map<string, CapabilitySentenceField>();
|
|
393
|
+
const properties = isRecord(input.inputSchema.properties) ? input.inputSchema.properties : {};
|
|
394
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
395
|
+
if (fields.size >= MAX_LABELLED_FIELDS) break;
|
|
396
|
+
if (!isRecord(value) || OPAQUE_FIELD.test(key) || value.format === "uuid") continue;
|
|
397
|
+
const types = schemaTypes(value);
|
|
398
|
+
if (types.length > 0 && types.every((type) => type === "boolean" || type === "null")) continue;
|
|
399
|
+
if (typeof value.maxLength === "number" && value.maxLength > MAX_DETAIL_TEXT_LENGTH) continue;
|
|
400
|
+
const label = shortLabel(input.inputDescriptions?.[key] ?? value.description);
|
|
401
|
+
if (!label) continue;
|
|
402
|
+
const format = schemaFormat(value);
|
|
403
|
+
fields.set(`input.${key}`, { path: `input.${key}`, label, ...(format ? { format } : {}) });
|
|
404
|
+
}
|
|
405
|
+
const templates = CAPABILITY_SENTENCE_KEYS.flatMap((key) => (input.sentences?.[key] ? [input.sentences[key]] : []));
|
|
406
|
+
for (const placeholder of templates.flatMap(capabilitySentencePlaceholders)) {
|
|
407
|
+
const path = `${placeholder.source}.${placeholder.path}`;
|
|
408
|
+
if (fields.has(path)) continue;
|
|
409
|
+
const format = schemaFormat(schemaAt(placeholder.source === "input" ? input.inputSchema : input.dataSchema, placeholder.path));
|
|
410
|
+
fields.set(path, { path, ...(format ? { format } : {}) });
|
|
411
|
+
}
|
|
412
|
+
return [...fields.values()];
|
|
413
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type AppSkillDefinition, appSkillManifestHash, registerAppSkills, validateAppSkillCatalog } from "../ai/app-skills";
|
|
1
2
|
import { startLiveOutbox } from "../events/live";
|
|
2
3
|
import { registerHelp } from "../services/help";
|
|
3
4
|
import { preloadLayoutHelp } from "../ssr/help";
|
|
@@ -53,6 +54,7 @@ import { matchedRouteTemplate, routeTemplate } from "../server/middleware/route-
|
|
|
53
54
|
import { runtime as runtimeMiddleware } from "../server/middleware/runtime";
|
|
54
55
|
import { preloadLayoutAnnouncements, settings as settingsMiddleware } from "../server/middleware/settings";
|
|
55
56
|
import { validationErrorResponse } from "../server/middleware/validator";
|
|
57
|
+
import { setWidgetRequest } from "../server/widget-request";
|
|
56
58
|
import {
|
|
57
59
|
capabilityInvocationOperation,
|
|
58
60
|
searchInvocationOperation,
|
|
@@ -91,6 +93,7 @@ import { ensureRuntimeWatcher, getCurrentRuntime, stopRuntimeWatcher } from "./r
|
|
|
91
93
|
import { servePublicAsset } from "./static-assets";
|
|
92
94
|
import { createStatusPreservingSsrHandler } from "./status-preserving-ssr";
|
|
93
95
|
import { WEB_VITALS_ASSET_HREF } from "./web-vitals-asset";
|
|
96
|
+
import { compileWidgetDeclarations } from "./widget-declarations";
|
|
94
97
|
|
|
95
98
|
/** Cache-busting version stamp — changes on every server start / rebuild. */
|
|
96
99
|
const v = Date.now();
|
|
@@ -167,6 +170,8 @@ export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefi
|
|
|
167
170
|
settings?: S;
|
|
168
171
|
/** Notification kinds owned by this app, conventionally imported from `src/notifications.ts`. */
|
|
169
172
|
notifications?: N;
|
|
173
|
+
/** Assistant skills owned by this app, conventionally imported from `src/skills.ts`. */
|
|
174
|
+
skills?: readonly AppSkillDefinition[];
|
|
170
175
|
/**
|
|
171
176
|
* Legal/info links contributed by this app — aggregated app-wide via
|
|
172
177
|
* `listLegalLinks()` and rendered in login footer, app Footer, rail more
|
|
@@ -375,6 +380,8 @@ export const defineApp = <
|
|
|
375
380
|
>(
|
|
376
381
|
opts: AppOptions<S, N, AppId>,
|
|
377
382
|
): AppDefinition<S, N, AppId> => {
|
|
383
|
+
const appSkills = opts.skills ? validateAppSkillCatalog(opts.skills) : undefined;
|
|
384
|
+
const skillsManifestHash = appSkills ? appSkillManifestHash(appSkills) : undefined;
|
|
378
385
|
configurePostgresApplicationName(opts.id);
|
|
379
386
|
const isDevelopment = env.IS_DEVELOPMENT;
|
|
380
387
|
const notifications = bindNotificationDefinitions(opts.id, opts.notifications);
|
|
@@ -510,7 +517,7 @@ export const defineApp = <
|
|
|
510
517
|
legalLinks: opts.legalLinks ? [...opts.legalLinks] : undefined,
|
|
511
518
|
searchLinks: opts.searchLinks?.map((link) => ({ ...link, keywords: link.keywords ? [...link.keywords] : undefined })),
|
|
512
519
|
pwa: pwaPart,
|
|
513
|
-
widgets: opts.
|
|
520
|
+
widgets: compileWidgetDeclarations(opts.id, opts.widgets),
|
|
514
521
|
settingKeys: opts.settings ? Object.keys(opts.settings) : undefined,
|
|
515
522
|
openapi: opts.openapi,
|
|
516
523
|
cli: opts.cli,
|
|
@@ -603,12 +610,17 @@ export const defineApp = <
|
|
|
603
610
|
}
|
|
604
611
|
: undefined,
|
|
605
612
|
help: compiledHelp?.summary,
|
|
613
|
+
skills: skillsManifestHash ? { manifestHash: skillsManifestHash } : undefined,
|
|
606
614
|
legalLinks: meta.legalLinks ? meta.legalLinks.map((l) => ({ ...l })) : undefined,
|
|
607
615
|
searchLinks: meta.searchLinks,
|
|
608
616
|
pwa: meta.pwa
|
|
609
617
|
? { href: meta.pwa.href, requiresRoles: meta.pwa.requiresRoles ? [...meta.pwa.requiresRoles] : undefined }
|
|
610
618
|
: undefined,
|
|
611
|
-
widgets: meta.widgets
|
|
619
|
+
widgets: meta.widgets?.map((widget) => ({
|
|
620
|
+
...widget,
|
|
621
|
+
sizes: widget.sizes ? [...widget.sizes] : undefined,
|
|
622
|
+
requiresRoles: widget.requiresRoles ? [...widget.requiresRoles] : undefined,
|
|
623
|
+
})),
|
|
612
624
|
platformPermissions: meta.platformPermissions ? [...meta.platformPermissions] : undefined,
|
|
613
625
|
settingKeys: meta.settingKeys ? [...meta.settingKeys] : undefined,
|
|
614
626
|
openapi: advertiseOpenapi ? opts.openapi : undefined,
|
|
@@ -618,7 +630,10 @@ export const defineApp = <
|
|
|
618
630
|
// Heartbeat
|
|
619
631
|
const heartbeat = createHeartbeat(meta.id, entry, {
|
|
620
632
|
registry: appRegistry(),
|
|
621
|
-
beforeWrite:
|
|
633
|
+
beforeWrite: async () => {
|
|
634
|
+
if (compiledHelp) await registerHelp(compiledHelp.corpus);
|
|
635
|
+
if (appSkills && skillsManifestHash) await registerAppSkills(meta.id, appSkills, skillsManifestHash);
|
|
636
|
+
},
|
|
622
637
|
onError: (error) =>
|
|
623
638
|
log.error("Registry heartbeat failed", {
|
|
624
639
|
appId: meta.id,
|
|
@@ -854,8 +869,12 @@ export const defineApp = <
|
|
|
854
869
|
runtimeMiddleware(),
|
|
855
870
|
settingsMiddleware(),
|
|
856
871
|
async (c) => {
|
|
857
|
-
const
|
|
858
|
-
|
|
872
|
+
const widgetId = c.req.param("widgetId") ?? "";
|
|
873
|
+
const handler = startOpts.widgets?.[widgetId];
|
|
874
|
+
const declaration = meta.widgets?.find((widget) => widget.id === widgetId);
|
|
875
|
+
if (!handler || !declaration) return c.json({ message: "Widget not found" }, 404);
|
|
876
|
+
setWidgetRequest(c, declaration);
|
|
877
|
+
return handler(c, async () => {});
|
|
859
878
|
},
|
|
860
879
|
);
|
|
861
880
|
}
|
|
@@ -166,6 +166,11 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
|
|
|
166
166
|
return invalid("capabilities", "a valid capability summary");
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
|
+
if (
|
|
170
|
+
value.skills !== undefined &&
|
|
171
|
+
(!isRecord(value.skills) || !isString(value.skills.manifestHash) || !/^[a-f0-9]{64}$/.test(value.skills.manifestHash))
|
|
172
|
+
)
|
|
173
|
+
return invalid("skills", "a valid app skill manifest hash");
|
|
169
174
|
if (
|
|
170
175
|
value.help !== undefined &&
|
|
171
176
|
(!isRecord(value.help) ||
|
|
@@ -3,7 +3,14 @@ import type { AppAppearanceColor } from "../contracts/app";
|
|
|
3
3
|
import type { CapabilityManifest, CapabilityPresentationCatalog } from "../contracts/capabilities";
|
|
4
4
|
import { fileProviderIssues } from "../contracts/file-provider";
|
|
5
5
|
import type { AppRegistryEntry, CapabilityRegistryEntry } from "../contracts/registry";
|
|
6
|
-
import type
|
|
6
|
+
import { type Role, RoleSchema } from "../contracts/shared";
|
|
7
|
+
import {
|
|
8
|
+
type DashboardWidgetPresentation,
|
|
9
|
+
type DashboardWidgetSize,
|
|
10
|
+
resolveWidgetSizes,
|
|
11
|
+
WIDGET_DESCRIPTION_MAX_LENGTH,
|
|
12
|
+
WIDGET_TITLE_MAX_LENGTH,
|
|
13
|
+
} from "../contracts/widgets";
|
|
7
14
|
import { resolveAppPresentations } from "../shared/app-presentation";
|
|
8
15
|
import { compileCapabilityPresentation, parseCapabilityManifest } from "./capabilities";
|
|
9
16
|
import { lazySync } from "./process-sync";
|
|
@@ -332,7 +339,9 @@ export const listLegalLinks = async (locale?: string): Promise<Array<{ label: st
|
|
|
332
339
|
* the dashboard app to build the widget grid and for Core to resolve the
|
|
333
340
|
* exact target before proxying a widget request.
|
|
334
341
|
*
|
|
335
|
-
* Order = registration order across apps.
|
|
342
|
+
* Order = registration order across apps. Every optional declaration field is
|
|
343
|
+
* resolved here, so readers never see a registry record from an older app
|
|
344
|
+
* without `title`, `sizes`, or `defaultSize`, nor an unknown size or role.
|
|
336
345
|
*/
|
|
337
346
|
export type DashboardWidget = {
|
|
338
347
|
appId: string;
|
|
@@ -341,23 +350,58 @@ export type DashboardWidget = {
|
|
|
341
350
|
widgetId: string;
|
|
342
351
|
/** Fully-qualified URL — `<baseUrl>/<path>`. */
|
|
343
352
|
url: string;
|
|
353
|
+
/** The declared title in the requested locale, or the app's name. */
|
|
354
|
+
title: string;
|
|
355
|
+
/** The declared description in the requested locale, or the app's description. */
|
|
356
|
+
description: string;
|
|
357
|
+
sizes: DashboardWidgetSize[];
|
|
358
|
+
defaultSize: DashboardWidgetSize;
|
|
359
|
+
suggest: boolean;
|
|
360
|
+
/** Visibility only, like `nav.requiresRoles`; empty means everyone. */
|
|
361
|
+
requiresRoles: Role[];
|
|
362
|
+
/**
|
|
363
|
+
* @deprecated The zone and width the app recommended before widgets had sizes. Only the dashboard reads it, to
|
|
364
|
+
* convert a board saved then into the board the person saw.
|
|
365
|
+
*/
|
|
344
366
|
presentation?: DashboardWidgetPresentation;
|
|
345
367
|
};
|
|
346
368
|
|
|
347
|
-
|
|
348
|
-
|
|
369
|
+
const LEGACY_ZONES = ["focus", "overview", "context"] as const;
|
|
370
|
+
const LEGACY_SPANS = ["standard", "wide"] as const;
|
|
371
|
+
|
|
372
|
+
const legacyPresentation = (value: DashboardWidgetPresentation | undefined): DashboardWidgetPresentation | undefined => {
|
|
373
|
+
const defaultZone = LEGACY_ZONES.find((zone) => zone === value?.defaultZone);
|
|
374
|
+
const defaultSpan = LEGACY_SPANS.find((span) => span === value?.defaultSpan);
|
|
375
|
+
return defaultZone || defaultSpan ? { defaultZone, defaultSpan } : undefined;
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const declaredText = (value: unknown, max: number): string | undefined =>
|
|
379
|
+
typeof value === "string" && value.trim() && value.trim().length <= max ? value.trim() : undefined;
|
|
380
|
+
|
|
381
|
+
/** The widgets `apps` declare, resolved as `listWidgets()` returns them. */
|
|
382
|
+
export const dashboardWidgetsOf = (apps: readonly AppRegistryEntry[], locale?: string): DashboardWidget[] => {
|
|
349
383
|
const out: DashboardWidget[] = [];
|
|
350
|
-
for (const app of apps) {
|
|
384
|
+
for (const app of locale ? resolveAppPresentations(apps, locale) : apps) {
|
|
351
385
|
for (const w of app.widgets ?? []) {
|
|
386
|
+
const { sizes, defaultSize } = resolveWidgetSizes(w);
|
|
352
387
|
out.push({
|
|
353
388
|
appId: app.id,
|
|
354
389
|
appName: app.name,
|
|
355
390
|
appIcon: app.icon,
|
|
356
391
|
widgetId: w.id,
|
|
357
392
|
url: `${app.baseUrl.replace(/\/$/, "")}${w.path.startsWith("/") ? w.path : `/${w.path}`}`,
|
|
358
|
-
|
|
393
|
+
title: declaredText(w.title, WIDGET_TITLE_MAX_LENGTH) ?? app.name,
|
|
394
|
+
description: declaredText(w.description, WIDGET_DESCRIPTION_MAX_LENGTH) ?? app.description,
|
|
395
|
+
sizes,
|
|
396
|
+
defaultSize,
|
|
397
|
+
suggest: w.suggest === true,
|
|
398
|
+
requiresRoles: Array.isArray(w.requiresRoles) ? w.requiresRoles.filter((role) => RoleSchema.safeParse(role).success) : [],
|
|
399
|
+
presentation: legacyPresentation(w.presentation),
|
|
359
400
|
});
|
|
360
401
|
}
|
|
361
402
|
}
|
|
362
403
|
return out;
|
|
363
404
|
};
|
|
405
|
+
|
|
406
|
+
/** Every declared widget, with titles and descriptions in `locale` when given. */
|
|
407
|
+
export const listWidgets = async (locale?: string): Promise<DashboardWidget[]> => dashboardWidgetsOf(await listApps(), locale);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { WidgetEndpoint } from "../contracts/app";
|
|
2
|
+
import { RoleSchema } from "../contracts/shared";
|
|
3
|
+
import {
|
|
4
|
+
DASHBOARD_WIDGET_SIZES,
|
|
5
|
+
isDashboardWidgetSize,
|
|
6
|
+
WIDGET_DESCRIPTION_MAX_LENGTH,
|
|
7
|
+
WIDGET_TITLE_MAX_LENGTH,
|
|
8
|
+
} from "../contracts/widgets";
|
|
9
|
+
|
|
10
|
+
const text = (value: string | undefined, label: string, max: number): string | undefined => {
|
|
11
|
+
if (value === undefined) return undefined;
|
|
12
|
+
const normalized = value.trim();
|
|
13
|
+
if (!normalized || normalized.length > max) throw new Error(`${label} must contain 1 to ${max} characters`);
|
|
14
|
+
return normalized;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validates and copies an app's widget declarations at `defineApp()`, so a typo fails at startup instead of a widget
|
|
19
|
+
* silently missing from the gallery: unique ids without `@`, which separates a widget's key from the size the
|
|
20
|
+
* dashboard asks for, bounded titles and descriptions, known sizes with the default among them, a boolean `suggest`,
|
|
21
|
+
* and known roles.
|
|
22
|
+
*/
|
|
23
|
+
export const compileWidgetDeclarations = (
|
|
24
|
+
appId: string,
|
|
25
|
+
widgets: ReadonlyArray<WidgetEndpoint> | undefined,
|
|
26
|
+
): WidgetEndpoint[] | undefined => {
|
|
27
|
+
if (!widgets) return undefined;
|
|
28
|
+
const ids = new Set<string>();
|
|
29
|
+
return widgets.map((widget) => {
|
|
30
|
+
const label = `Widget ${JSON.stringify(widget.id)} of app ${JSON.stringify(appId)}`;
|
|
31
|
+
if (!widget.id.trim()) throw new Error(`Widgets of app ${JSON.stringify(appId)} need a non-empty id`);
|
|
32
|
+
if (widget.id.includes("@")) throw new Error(`${label} must not contain "@"`);
|
|
33
|
+
if (ids.has(widget.id)) throw new Error(`${label} is declared twice`);
|
|
34
|
+
ids.add(widget.id);
|
|
35
|
+
const sizes = widget.sizes ? [...widget.sizes] : undefined;
|
|
36
|
+
if (sizes && (sizes.length === 0 || sizes.some((size) => !isDashboardWidgetSize(size)) || new Set(sizes).size !== sizes.length)) {
|
|
37
|
+
throw new Error(`${label} must declare sizes as distinct values of ${DASHBOARD_WIDGET_SIZES.join(", ")}`);
|
|
38
|
+
}
|
|
39
|
+
if (widget.defaultSize !== undefined && !(sizes ?? ["large"]).includes(widget.defaultSize)) {
|
|
40
|
+
throw new Error(`${label} must declare a defaultSize it offers in sizes`);
|
|
41
|
+
}
|
|
42
|
+
if (widget.suggest !== undefined && typeof widget.suggest !== "boolean")
|
|
43
|
+
throw new Error(`${label} must declare suggest as true or false`);
|
|
44
|
+
if (widget.requiresRoles?.some((role) => !RoleSchema.safeParse(role).success)) throw new Error(`${label} requires an unknown role`);
|
|
45
|
+
return {
|
|
46
|
+
...widget,
|
|
47
|
+
title: text(widget.title, `${label} title`, WIDGET_TITLE_MAX_LENGTH),
|
|
48
|
+
description: text(widget.description, `${label} description`, WIDGET_DESCRIPTION_MAX_LENGTH),
|
|
49
|
+
sizes: sizes ? DASHBOARD_WIDGET_SIZES.filter((size) => sizes.includes(size)) : undefined,
|
|
50
|
+
requiresRoles: widget.requiresRoles ? [...widget.requiresRoles] : undefined,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
};
|