@k2b/cloud 0.32.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/_internal/app-presentation.ts +40 -11
- package/src/_internal/define-app.ts +18 -8
- package/src/_internal/page-responses.ts +5 -1
- package/src/_internal/registry.ts +10 -1
- package/src/_internal/widget-declarations.ts +16 -3
- package/src/_internal/widget-settings.ts +93 -0
- package/src/ai/capabilities.ts +8 -5
- package/src/ai/code-source-contracts.ts +17 -2
- package/src/ai/code-source-tools.ts +1 -0
- package/src/ai/default-tools.ts +4 -4
- package/src/ai/memories.ts +48 -1
- package/src/ai/memory-learning.ts +17 -6
- package/src/ai/memory-tool.ts +1 -0
- package/src/ai/migrate.ts +8 -0
- package/src/ai/provider.ts +4 -3
- package/src/ai/resource-refs.ts +35 -3
- package/src/ai/system-prompt.ts +3 -2
- package/src/api/index.ts +2 -0
- package/src/api/mcp.ts +48 -17
- package/src/api/pwa.ts +7 -2
- package/src/api/resource-links.ts +43 -0
- package/src/api/widgets.ts +163 -82
- package/src/browser/CloudResourceSearch.tsx +6 -6
- package/src/browser/command-shortcuts.ts +2 -11
- package/src/browser/navigation-search.ts +4 -1
- package/src/browser/resource-picker.tsx +1 -0
- package/src/browser/resource-search.ts +7 -2
- package/src/browser/widgets.ts +23 -5
- package/src/capabilities/testing.ts +13 -1
- package/src/cli/capabilities.ts +61 -17
- package/src/cli/index.ts +2 -0
- package/src/cli/resource-links.ts +13 -0
- package/src/contracts/app.ts +7 -1
- package/src/contracts/capabilities.ts +25 -5
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/resource-href.ts +7 -0
- package/src/contracts/widgets.ts +80 -6
- package/src/desktop/index.ts +3 -2
- package/src/desktop/solid.tsx +7 -5
- package/src/server/widget-request.ts +34 -5
- package/src/services/app-approval.ts +4 -2
- package/src/services/approvals/index.ts +40 -11
- package/src/services/approvals/list.ts +1 -0
- package/src/services/approvals/migrate.ts +10 -0
- package/src/services/outbox.ts +13 -6
- package/src/services/pdf/markdown.ts +11 -7
- package/src/services/pwa-devices.ts +68 -15
- package/src/shared/ai-platform-prompt.ts +3 -2
- package/src/shared/app-presentation.ts +49 -1
- package/src/shared/index.ts +1 -0
- package/src/shared/markdown/callout-sanitizer.ts +37 -0
- package/src/shared/markdown/index.ts +8 -2
- package/src/shared/theme.ts +41 -20
- package/src/ssr/AnnouncementBanners.tsx +30 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +91 -0
- package/src/ssr/GlobalSearchTrigger.tsx +51 -0
- package/src/ssr/Layout.tsx +58 -99
- package/src/ssr/LayoutHeader.tsx +31 -77
- package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
- package/src/ssr/LayoutHelp.tsx +319 -238
- package/src/ssr/LayoutHelpTrigger.tsx +49 -0
- package/src/ssr/LayoutPreferences.tsx +31 -0
- package/src/ssr/LayoutRail.tsx +15 -69
- package/src/ssr/MinimalLayout.tsx +2 -3
- package/src/ssr/MinimalLayoutPreferences.island.tsx +6 -15
- package/src/ssr/MobileNavigation.tsx +13 -8
- package/src/ssr/MobileProfileActions.tsx +4 -6
- package/src/ssr/PageTrailRow.tsx +14 -0
- package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +8 -5
- package/src/ssr/PwaLayout.tsx +0 -2
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/ShellRuntime.island.tsx +166 -0
- package/src/ssr/ThemeCycleButton.tsx +46 -0
- package/src/ssr/TimezoneCookie.island.tsx +3 -19
- package/src/ssr/WorkspaceNavigationProvider.tsx +20 -1
- package/src/ssr/announcements-dialog.tsx +66 -0
- package/src/ssr/app-appearance.ts +6 -1
- package/src/ssr/{AppLaunchpad.island.tsx → app-launchpad.tsx} +42 -62
- package/src/ssr/busy-action.ts +19 -0
- package/src/ssr/islands/index.ts +9 -6
- package/src/ssr/launchpad-surface.tsx +15 -0
- package/src/ssr/layout-help-registry.tsx +60 -0
- package/src/ssr/layout-help-url.ts +39 -0
- package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
- package/src/ssr/platform-messages.ts +0 -8
- package/src/ssr/preference-controller.ts +62 -19
- package/src/ssr/profile-actions.ts +12 -5
- package/src/ssr/profile-preferences-messages.ts +10 -4
- package/src/ssr/rail-context.ts +8 -12
- package/src/ssr/rail-navigation.ts +3 -0
- package/src/ssr/shell-data.tsx +48 -0
- package/src/ssr/timezone-cookie.ts +22 -0
- package/src/ssr/workspace-navigation.ts +25 -2
- package/src/styles/effects.css +13 -3
- package/src/styles/global.css +43 -5
- package/src/styles/input.css +0 -8
- package/src/styles/tokens.css +0 -2
- package/src/styles/utilities-navigation.css +10 -0
- package/src/workflows/language/compiler.ts +1 -1
- package/src/ssr/AppWorkspaceController.island.tsx +0 -21
- package/src/ssr/BrowserPushRegistration.island.tsx +0 -12
- package/src/ssr/GlobalAnnouncements.island.tsx +0 -117
- package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
- package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
- package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
- package/src/ssr/LayoutPreferences.island.tsx +0 -21
- package/src/ssr/PageTrailRow.island.tsx +0 -20
- package/src/ssr/PageTrailRuntime.island.tsx +0 -26
- package/src/ssr/RegisteredHelpDocuments.island.tsx +0 -3
- /package/src/ssr/{LayoutBreadcrumbs.island.tsx → LayoutBreadcrumbs.tsx} +0 -0
- /package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k2b/cloud",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.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": {
|
|
@@ -105,10 +105,10 @@
|
|
|
105
105
|
"@simplewebauthn/server": "14.0.3",
|
|
106
106
|
"@tabler/icons": "3.48.0",
|
|
107
107
|
"@tailwindcss/typography": "0.5.20",
|
|
108
|
-
"@k2b/nessi": "0.
|
|
109
|
-
"@k2b/ssr": "0.
|
|
110
|
-
"@k2b/ui": "0.
|
|
111
|
-
"@k2b/stdlib": "0.
|
|
108
|
+
"@k2b/nessi": "0.18.0",
|
|
109
|
+
"@k2b/ssr": "0.16.0",
|
|
110
|
+
"@k2b/ui": "0.25.0",
|
|
111
|
+
"@k2b/stdlib": "0.29.0",
|
|
112
112
|
"@k2b/sync": "7.0.0",
|
|
113
113
|
"@nats-io/jetstream": "3.4.0",
|
|
114
114
|
"@nats-io/transport-node": "3.4.0",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AppMeta, AppPresentationCatalog, AppPresentationTranslation, AppWidgetTranslation } from "../contracts/app";
|
|
1
|
+
import type { AppMeta, AppPresentationCatalog, AppPresentationTranslation, AppWidgetTranslation, WidgetEndpoint } from "../contracts/app";
|
|
2
2
|
import { WIDGET_DESCRIPTION_MAX_LENGTH, WIDGET_TITLE_MAX_LENGTH } from "../contracts/widgets";
|
|
3
3
|
import { canonicalLocale } from "../shared/locale";
|
|
4
4
|
|
|
@@ -14,23 +14,53 @@ const text = (value: string, label: string, max = MAX_SHORT_TEXT): string => {
|
|
|
14
14
|
|
|
15
15
|
const labels = (values: Readonly<Record<string, string>> | undefined, allowed: Set<string>, label: string, max = MAX_SHORT_TEXT) => {
|
|
16
16
|
if (!values) return undefined;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
return Object.fromEntries(
|
|
18
|
+
Object.entries(values).map(([key, value]) => {
|
|
19
|
+
if (!allowed.has(key)) throw new Error(`${label} contains unknown key ${JSON.stringify(key)}`);
|
|
20
|
+
return [key, text(value, `${label}.${key}`, max)];
|
|
21
|
+
}),
|
|
22
|
+
);
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
const widgetTexts = (
|
|
26
26
|
values: Readonly<Record<string, AppWidgetTranslation>>,
|
|
27
|
-
|
|
27
|
+
widgets: readonly WidgetEndpoint[],
|
|
28
28
|
label: string,
|
|
29
29
|
): Record<string, AppWidgetTranslation> => {
|
|
30
30
|
const normalized: Record<string, AppWidgetTranslation> = {};
|
|
31
31
|
for (const [id, value] of Object.entries(values)) {
|
|
32
|
-
|
|
32
|
+
const widget = widgets.find((widget) => widget.id === id);
|
|
33
|
+
if (!widget) throw new Error(`${label} contains unknown key ${JSON.stringify(id)}`);
|
|
34
|
+
const settings: NonNullable<AppWidgetTranslation["settings"]> = Object.fromEntries(
|
|
35
|
+
Object.entries(value.settings ?? {}).map(([name, translation]) => {
|
|
36
|
+
const field = widget.settings?.[name];
|
|
37
|
+
const settingLabel = `${label}.${id}.settings.${name}`;
|
|
38
|
+
if (!field) throw new Error(`${settingLabel} is not declared`);
|
|
39
|
+
return [
|
|
40
|
+
name,
|
|
41
|
+
{
|
|
42
|
+
...(translation.label !== undefined
|
|
43
|
+
? { label: text(translation.label, `${settingLabel}.label`, WIDGET_TITLE_MAX_LENGTH) }
|
|
44
|
+
: {}),
|
|
45
|
+
...(translation.placeholder !== undefined
|
|
46
|
+
? { placeholder: text(translation.placeholder, `${settingLabel}.placeholder`, WIDGET_TITLE_MAX_LENGTH) }
|
|
47
|
+
: {}),
|
|
48
|
+
...(translation.options
|
|
49
|
+
? {
|
|
50
|
+
options: labels(
|
|
51
|
+
translation.options,
|
|
52
|
+
new Set(field.kind === "choice" ? field.options.map((option) => option.value) : []),
|
|
53
|
+
`${settingLabel}.options`,
|
|
54
|
+
WIDGET_TITLE_MAX_LENGTH,
|
|
55
|
+
),
|
|
56
|
+
}
|
|
57
|
+
: {}),
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
33
62
|
normalized[id] = {
|
|
63
|
+
...(value.settings ? { settings } : {}),
|
|
34
64
|
...(value.title !== undefined ? { title: text(value.title, `${label}.${id}.title`, WIDGET_TITLE_MAX_LENGTH) } : {}),
|
|
35
65
|
...(value.description !== undefined
|
|
36
66
|
? { description: text(value.description, `${label}.${id}.description`, WIDGET_DESCRIPTION_MAX_LENGTH) }
|
|
@@ -55,7 +85,6 @@ export const compileAppPresentation = (app: AppMeta, catalog: AppPresentationCat
|
|
|
55
85
|
const adminHrefs = new Set((app.adminNav ?? []).flatMap((group) => group.links.map((link) => link.href)));
|
|
56
86
|
const searchHrefs = new Set((app.searchLinks ?? []).map((link) => link.href));
|
|
57
87
|
const legalHrefs = new Set((app.legalLinks ?? []).map((link) => link.href));
|
|
58
|
-
const widgetIds = new Set((app.widgets ?? []).map((widget) => widget.id));
|
|
59
88
|
const translations: Record<string, AppPresentationTranslation> = {};
|
|
60
89
|
|
|
61
90
|
for (const [locale, translation] of Object.entries(catalog.translations)) {
|
|
@@ -82,7 +111,7 @@ export const compileAppPresentation = (app: AppMeta, catalog: AppPresentationCat
|
|
|
82
111
|
}
|
|
83
112
|
: {}),
|
|
84
113
|
...(translation.legalLinks ? { legalLinks: labels(translation.legalLinks, legalHrefs, `${canonical}.legalLinks`) } : {}),
|
|
85
|
-
...(translation.widgets ? { widgets: widgetTexts(translation.widgets,
|
|
114
|
+
...(translation.widgets ? { widgets: widgetTexts(translation.widgets, app.widgets ?? [], `${canonical}.widgets`) } : {}),
|
|
86
115
|
};
|
|
87
116
|
}
|
|
88
117
|
|
|
@@ -74,7 +74,7 @@ import { createSyncOpsRoutes } from "../services/sync-ops";
|
|
|
74
74
|
import { capabilityMessages } from "../shared/capability-messages";
|
|
75
75
|
import { normalizeLocale } from "../shared/locale";
|
|
76
76
|
import { escapeHtml } from "../shared/markdown/shared";
|
|
77
|
-
import { themeBootstrapScript } from "../shared/theme";
|
|
77
|
+
import { type CloudThemePreference, readThemePreferenceFromCookieHeader, themeBootstrapScript } from "../shared/theme";
|
|
78
78
|
import { appFaviconHref } from "./app-favicon";
|
|
79
79
|
import { compileAppPresentation } from "./app-presentation";
|
|
80
80
|
import { readBoundedJson } from "./bounded-json";
|
|
@@ -102,6 +102,7 @@ const v = Date.now();
|
|
|
102
102
|
export type PageOptions = {
|
|
103
103
|
title?: string;
|
|
104
104
|
description?: string;
|
|
105
|
+
/** Fix public displays such as Pulse, Weather and Venue monitors to one theme; otherwise follow the person's preference. */
|
|
105
106
|
theme?: "light" | "dark";
|
|
106
107
|
/** Framework-resolved BCP 47 document language — always the canonical request locale, never a page override. */
|
|
107
108
|
lang?: string;
|
|
@@ -109,7 +110,7 @@ export type PageOptions = {
|
|
|
109
110
|
pwa?: { name: string };
|
|
110
111
|
};
|
|
111
112
|
|
|
112
|
-
type ResolvedPageOptions = PageOptions & { performanceRoute?: string };
|
|
113
|
+
type ResolvedPageOptions = PageOptions & { performanceRoute?: string; themePreference?: CloudThemePreference };
|
|
113
114
|
|
|
114
115
|
// ── Public types ────────────────────────────────────────────────────────────
|
|
115
116
|
|
|
@@ -411,17 +412,24 @@ export const defineApp = <
|
|
|
411
412
|
// Every island instance already mounts inside its own error boundary; this
|
|
412
413
|
// only gives the fallback Cloud's look and the reader's language.
|
|
413
414
|
errorFallback: fileURLToPath(new URL("../ssr/IslandError.tsx", import.meta.url)),
|
|
414
|
-
template: ({ body, scripts, title, description, theme, lang, performanceRoute, pwa }) => {
|
|
415
|
-
const
|
|
415
|
+
template: ({ body, scripts, title, description, theme, themePreference, lang, performanceRoute, pwa }) => {
|
|
416
|
+
const fixed = theme !== undefined;
|
|
417
|
+
const resolved = theme ?? (themePreference === "light" || themePreference === "dark" ? themePreference : undefined);
|
|
416
418
|
// App pages paint the canvas before any stylesheet and give it to the
|
|
417
419
|
// status bar. No status-bar style: `black-translucent` lays a blur band
|
|
418
420
|
// over the top edge on iOS 26.
|
|
419
|
-
const canvas = PWA_CANVAS_COLORS[
|
|
421
|
+
const canvas = resolved ? PWA_CANVAS_COLORS[resolved] : undefined;
|
|
420
422
|
const head = pwa
|
|
421
423
|
? {
|
|
422
|
-
html: ` style="background-color:${canvas}"
|
|
424
|
+
html: canvas ? ` style="background-color:${canvas}"` : "",
|
|
423
425
|
viewport: "width=device-width, initial-scale=1, viewport-fit=cover",
|
|
424
|
-
app:
|
|
426
|
+
app: `${
|
|
427
|
+
canvas
|
|
428
|
+
? `<meta name="theme-color" content="${canvas}">`
|
|
429
|
+
: `<meta name="theme-color" media="(prefers-color-scheme: light)" content="${PWA_CANVAS_COLORS.light}">
|
|
430
|
+
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="${PWA_CANVAS_COLORS.dark}">
|
|
431
|
+
<style data-cloud-canvas>html{background-color:${PWA_CANVAS_COLORS.light}}@media (prefers-color-scheme: dark){html{background-color:${PWA_CANVAS_COLORS.dark}}}</style>`
|
|
432
|
+
}
|
|
425
433
|
<link rel="manifest" href="${PWA_MANIFEST_PATH}">
|
|
426
434
|
<link rel="apple-touch-icon" href="/branding/apple-touch-icon.png">
|
|
427
435
|
<meta name="apple-mobile-web-app-title" content="${escapeHtml(pwa.name)}">`,
|
|
@@ -442,7 +450,7 @@ export const defineApp = <
|
|
|
442
450
|
// stylesheet declares a layer. Tailwind's `properties` layer resets
|
|
443
451
|
// `--tw-*` in browsers without `@property` and must stay lowest.
|
|
444
452
|
return `<!DOCTYPE html>
|
|
445
|
-
<html lang="${normalizeLocale(lang)}" class="${
|
|
453
|
+
<html lang="${normalizeLocale(lang)}" class="${resolved ?? "light"}"${fixed ? " data-theme-fixed" : ""}${head.html}>
|
|
446
454
|
<head><meta charset="UTF-8"><meta name="viewport" content="${head.viewport}">
|
|
447
455
|
<meta name="view-transition" content="same-origin">
|
|
448
456
|
<title>${escapeHtml(title ?? "Cloud")}</title>
|
|
@@ -480,6 +488,7 @@ export const defineApp = <
|
|
|
480
488
|
const ssr = Object.assign(
|
|
481
489
|
createStatusPreservingSsrHandler<ResolvedPageOptions>(html, async (c) => {
|
|
482
490
|
c.get("page").lang = getLocale(c);
|
|
491
|
+
c.get("page").themePreference = readThemePreferenceFromCookieHeader(c.req.header("Cookie"));
|
|
483
492
|
const user = c.get("user");
|
|
484
493
|
c.get("page").performanceRoute =
|
|
485
494
|
user && c.get("settings")?.observability?.web_vitals?.enabled === true ? (matchedRouteTemplate(c) ?? undefined) : undefined;
|
|
@@ -620,6 +629,7 @@ export const defineApp = <
|
|
|
620
629
|
: undefined,
|
|
621
630
|
widgets: meta.widgets?.map((widget) => ({
|
|
622
631
|
...widget,
|
|
632
|
+
...(widget.settings ? { settings: structuredClone(widget.settings) } : {}),
|
|
623
633
|
sizes: widget.sizes ? [...widget.sizes] : undefined,
|
|
624
634
|
requiresRoles: widget.requiresRoles ? [...widget.requiresRoles] : undefined,
|
|
625
635
|
})),
|
|
@@ -5,6 +5,7 @@ import { PWA_AUTH_PATH, PWA_SCOPE } from "../contracts/pwa";
|
|
|
5
5
|
import { getLocale } from "../server/locale";
|
|
6
6
|
import { preloadLayoutAnnouncements } from "../server/middleware/settings";
|
|
7
7
|
import { createLoginRedirectUrl } from "../shared/redirect";
|
|
8
|
+
import { readThemePreferenceFromCookieHeader } from "../shared/theme";
|
|
8
9
|
import type { PageOptions } from "./define-app";
|
|
9
10
|
|
|
10
11
|
export type PageErrorOptions = {
|
|
@@ -21,7 +22,10 @@ export type PageErrorStatus = ClientErrorStatusCode | ServerErrorStatusCode;
|
|
|
21
22
|
export const createPageResponses = (html: HtmlFn<PageOptions>) => {
|
|
22
23
|
const error = async (c: Context, status: PageErrorStatus, options: PageErrorOptions = {}): Promise<Response> => {
|
|
23
24
|
// Auth middleware may reject before ssr() initializes page metadata.
|
|
24
|
-
c.set("page", {
|
|
25
|
+
c.set("page", {
|
|
26
|
+
lang: getLocale(c),
|
|
27
|
+
themePreference: readThemePreferenceFromCookieHeader(c.req.header("Cookie")),
|
|
28
|
+
});
|
|
25
29
|
c.header("Cache-Control", "private, no-store");
|
|
26
30
|
if (options.layout !== "pwa") await preloadLayoutAnnouncements(c);
|
|
27
31
|
// Load JSX only after the application's SSR plugin has been installed.
|
|
@@ -10,12 +10,14 @@ import {
|
|
|
10
10
|
resolveWidgetSizes,
|
|
11
11
|
WIDGET_DESCRIPTION_MAX_LENGTH,
|
|
12
12
|
WIDGET_TITLE_MAX_LENGTH,
|
|
13
|
+
type WidgetSettingsDeclaration,
|
|
13
14
|
} from "../contracts/widgets";
|
|
14
15
|
import { resolveAppPresentations } from "../shared/app-presentation";
|
|
15
16
|
import { compileCapabilityPresentation, parseCapabilityManifest } from "./capabilities";
|
|
16
17
|
import { lazySync } from "./process-sync";
|
|
17
18
|
import { validateAppRegistryEntry } from "./registry-validation";
|
|
18
19
|
import { watchRegistryChanges } from "./registry-watch";
|
|
20
|
+
import { WidgetSettingsDeclarationSchema } from "./widget-settings";
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* Shared app registry: two @k2b/sync ephemerals on the process Sync
|
|
@@ -356,6 +358,8 @@ export type DashboardWidget = {
|
|
|
356
358
|
description: string;
|
|
357
359
|
sizes: DashboardWidgetSize[];
|
|
358
360
|
defaultSize: DashboardWidgetSize;
|
|
361
|
+
/** Validated setting presentation in the requested locale, absent when none is valid. */
|
|
362
|
+
settings?: WidgetSettingsDeclaration;
|
|
359
363
|
suggest: boolean;
|
|
360
364
|
/** Visibility only, like `nav.requiresRoles`; empty means everyone. */
|
|
361
365
|
requiresRoles: Role[];
|
|
@@ -381,9 +385,13 @@ const declaredText = (value: unknown, max: number): string | undefined =>
|
|
|
381
385
|
/** The widgets `apps` declare, resolved as `listWidgets()` returns them. */
|
|
382
386
|
export const dashboardWidgetsOf = (apps: readonly AppRegistryEntry[], locale?: string): DashboardWidget[] => {
|
|
383
387
|
const out: DashboardWidget[] = [];
|
|
384
|
-
for (const app of locale ? resolveAppPresentations(apps, locale) : apps) {
|
|
388
|
+
for (const [index, app] of (locale ? resolveAppPresentations(apps, locale) : apps).entries()) {
|
|
385
389
|
for (const w of app.widgets ?? []) {
|
|
386
390
|
const { sizes, defaultSize } = resolveWidgetSizes(w);
|
|
391
|
+
const translated = WidgetSettingsDeclarationSchema.safeParse(w.settings);
|
|
392
|
+
const original = translated.success
|
|
393
|
+
? translated
|
|
394
|
+
: WidgetSettingsDeclarationSchema.safeParse(apps[index]?.widgets?.find((widget) => widget.id === w.id)?.settings);
|
|
387
395
|
out.push({
|
|
388
396
|
appId: app.id,
|
|
389
397
|
appName: app.name,
|
|
@@ -394,6 +402,7 @@ export const dashboardWidgetsOf = (apps: readonly AppRegistryEntry[], locale?: s
|
|
|
394
402
|
description: declaredText(w.description, WIDGET_DESCRIPTION_MAX_LENGTH) ?? app.description,
|
|
395
403
|
sizes,
|
|
396
404
|
defaultSize,
|
|
405
|
+
...(original.success ? { settings: original.data } : {}),
|
|
397
406
|
suggest: w.suggest === true,
|
|
398
407
|
requiresRoles: Array.isArray(w.requiresRoles) ? w.requiresRoles.filter((role) => RoleSchema.safeParse(role).success) : [],
|
|
399
408
|
presentation: legacyPresentation(w.presentation),
|
|
@@ -4,8 +4,10 @@ import {
|
|
|
4
4
|
DASHBOARD_WIDGET_SIZES,
|
|
5
5
|
isDashboardWidgetSize,
|
|
6
6
|
WIDGET_DESCRIPTION_MAX_LENGTH,
|
|
7
|
+
WIDGET_ID_MAX_LENGTH,
|
|
7
8
|
WIDGET_TITLE_MAX_LENGTH,
|
|
8
9
|
} from "../contracts/widgets";
|
|
10
|
+
import { WidgetSettingsDeclarationSchema } from "./widget-settings";
|
|
9
11
|
|
|
10
12
|
const text = (value: string | undefined, label: string, max: number): string | undefined => {
|
|
11
13
|
if (value === undefined) return undefined;
|
|
@@ -16,9 +18,9 @@ const text = (value: string | undefined, label: string, max: number): string | u
|
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
20
|
* 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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
21
|
+
* silently missing from the gallery: unique ids that fit Core's `widget.read:<id>` invocation, without `@`, which
|
|
22
|
+
* separates a widget's key from the requested size, bounded titles and descriptions, known sizes with the default
|
|
23
|
+
* among them, a boolean `suggest`, known roles, and a bounded settings declaration.
|
|
22
24
|
*/
|
|
23
25
|
export const compileWidgetDeclarations = (
|
|
24
26
|
appId: string,
|
|
@@ -30,6 +32,11 @@ export const compileWidgetDeclarations = (
|
|
|
30
32
|
const label = `Widget ${JSON.stringify(widget.id)} of app ${JSON.stringify(appId)}`;
|
|
31
33
|
if (!widget.id.trim()) throw new Error(`Widgets of app ${JSON.stringify(appId)} need a non-empty id`);
|
|
32
34
|
if (widget.id.includes("@")) throw new Error(`${label} must not contain "@"`);
|
|
35
|
+
if (!/^[a-z0-9:._-]+$/.test(widget.id) || widget.id.length > WIDGET_ID_MAX_LENGTH) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`${label} id must use 1 to ${WIDGET_ID_MAX_LENGTH} lowercase letters, digits, ".", "_", "-", or ":" because Core invokes it as widget.read:<id>`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
33
40
|
if (ids.has(widget.id)) throw new Error(`${label} is declared twice`);
|
|
34
41
|
ids.add(widget.id);
|
|
35
42
|
const sizes = widget.sizes ? [...widget.sizes] : undefined;
|
|
@@ -42,8 +49,14 @@ export const compileWidgetDeclarations = (
|
|
|
42
49
|
if (widget.suggest !== undefined && typeof widget.suggest !== "boolean")
|
|
43
50
|
throw new Error(`${label} must declare suggest as true or false`);
|
|
44
51
|
if (widget.requiresRoles?.some((role) => !RoleSchema.safeParse(role).success)) throw new Error(`${label} requires an unknown role`);
|
|
52
|
+
const settings = widget.settings === undefined ? undefined : WidgetSettingsDeclarationSchema.safeParse(widget.settings);
|
|
53
|
+
if (settings && !settings.success) {
|
|
54
|
+
const issue = settings.error.issues[0]!;
|
|
55
|
+
throw new Error(`${label} setting ${JSON.stringify(issue.path[0] ?? "settings")}: ${issue.message}`);
|
|
56
|
+
}
|
|
45
57
|
return {
|
|
46
58
|
...widget,
|
|
59
|
+
...(settings?.success ? { settings: settings.data } : {}),
|
|
47
60
|
title: text(widget.title, `${label} title`, WIDGET_TITLE_MAX_LENGTH),
|
|
48
61
|
description: text(widget.description, `${label} description`, WIDGET_DESCRIPTION_MAX_LENGTH),
|
|
49
62
|
sizes: sizes ? DASHBOARD_WIDGET_SIZES.filter((size) => sizes.includes(size)) : undefined,
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CapabilityQualifiedIdSchema, CloudResourceRefSchema } from "../contracts/capabilities";
|
|
3
|
+
import {
|
|
4
|
+
WIDGET_SETTING_MAX_OPTIONS,
|
|
5
|
+
WIDGET_SETTING_NAME_MAX_LENGTH,
|
|
6
|
+
WIDGET_SETTING_TEXT_MAX_LENGTH,
|
|
7
|
+
WIDGET_SETTINGS_MAX_FIELDS,
|
|
8
|
+
WIDGET_TITLE_MAX_LENGTH,
|
|
9
|
+
type WidgetSettings,
|
|
10
|
+
type WidgetSettingsDeclaration,
|
|
11
|
+
type WidgetSettingValue,
|
|
12
|
+
} from "../contracts/widgets";
|
|
13
|
+
|
|
14
|
+
const controlCharacters = /[\u0000-\u001f\u007f-\u009f]/u;
|
|
15
|
+
const settingLabel = z.string().trim().min(1).max(WIDGET_TITLE_MAX_LENGTH);
|
|
16
|
+
/** The single declaration schema used at startup and for tolerant registry reads. */
|
|
17
|
+
export const WidgetSettingsDeclarationSchema = z
|
|
18
|
+
.record(
|
|
19
|
+
z.string().regex(new RegExp(`^[a-z][a-zA-Z0-9]{0,${WIDGET_SETTING_NAME_MAX_LENGTH - 1}}$`)),
|
|
20
|
+
z.discriminatedUnion("kind", [
|
|
21
|
+
z
|
|
22
|
+
.object({
|
|
23
|
+
kind: z.literal("choice"),
|
|
24
|
+
label: settingLabel,
|
|
25
|
+
options: z
|
|
26
|
+
.array(
|
|
27
|
+
z.object({
|
|
28
|
+
label: settingLabel,
|
|
29
|
+
value: z
|
|
30
|
+
.string()
|
|
31
|
+
.min(1)
|
|
32
|
+
.max(100)
|
|
33
|
+
.refine((value) => !controlCharacters.test(value)),
|
|
34
|
+
}),
|
|
35
|
+
)
|
|
36
|
+
.min(1)
|
|
37
|
+
.max(WIDGET_SETTING_MAX_OPTIONS),
|
|
38
|
+
default: z.string().optional(),
|
|
39
|
+
})
|
|
40
|
+
.superRefine((field, ctx) => {
|
|
41
|
+
if (new Set(field.options.map((option) => option.value)).size !== field.options.length)
|
|
42
|
+
ctx.addIssue({ code: "custom", path: ["options"], message: "Choice option values must be distinct" });
|
|
43
|
+
if (field.default !== undefined && !field.options.some((option) => option.value === field.default))
|
|
44
|
+
ctx.addIssue({ code: "custom", path: ["default"], message: "Choice default must be a declared option" });
|
|
45
|
+
}),
|
|
46
|
+
z.object({ kind: z.literal("resource"), label: settingLabel, type: CapabilityQualifiedIdSchema }),
|
|
47
|
+
z.object({ kind: z.literal("toggle"), label: settingLabel, default: z.boolean().optional() }),
|
|
48
|
+
z.object({
|
|
49
|
+
kind: z.literal("text"),
|
|
50
|
+
label: settingLabel,
|
|
51
|
+
placeholder: settingLabel.optional(),
|
|
52
|
+
maxLength: z.number().int().min(1).max(WIDGET_SETTING_TEXT_MAX_LENGTH).optional(),
|
|
53
|
+
}),
|
|
54
|
+
]),
|
|
55
|
+
)
|
|
56
|
+
.superRefine((fields, ctx) => {
|
|
57
|
+
for (const name of Object.keys(fields)) {
|
|
58
|
+
if (name in Object.prototype)
|
|
59
|
+
ctx.addIssue({ code: "custom", path: [name], message: "Setting names must not be Object.prototype members" });
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
.refine((fields) => Object.keys(fields).length <= WIDGET_SETTINGS_MAX_FIELDS, `Declare at most ${WIDGET_SETTINGS_MAX_FIELDS} settings`);
|
|
63
|
+
|
|
64
|
+
/** Drop invalid or undeclared values and fill choice and toggle defaults without reporting input errors. */
|
|
65
|
+
export const parseWidgetSettings = (declaration: WidgetSettingsDeclaration | undefined, raw: unknown): WidgetSettings => {
|
|
66
|
+
const values = z.record(z.string(), z.unknown()).safeParse(raw);
|
|
67
|
+
const settings: Record<string, WidgetSettingValue> = {};
|
|
68
|
+
for (const [name, field] of Object.entries(declaration ?? {})) {
|
|
69
|
+
const value = values.success && Object.hasOwn(values.data, name) ? values.data[name] : undefined;
|
|
70
|
+
switch (field.kind) {
|
|
71
|
+
case "choice":
|
|
72
|
+
if (typeof value === "string" && field.options.some((option) => option.value === value)) settings[name] = value;
|
|
73
|
+
else if (field.default !== undefined) settings[name] = field.default;
|
|
74
|
+
break;
|
|
75
|
+
case "toggle":
|
|
76
|
+
if (typeof value === "boolean") settings[name] = value;
|
|
77
|
+
else if (field.default !== undefined) settings[name] = field.default;
|
|
78
|
+
break;
|
|
79
|
+
case "text": {
|
|
80
|
+
if (typeof value !== "string" || controlCharacters.test(value)) break;
|
|
81
|
+
const text = value.trim();
|
|
82
|
+
if (text && text.length <= (field.maxLength ?? WIDGET_SETTING_TEXT_MAX_LENGTH)) settings[name] = text;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case "resource": {
|
|
86
|
+
const ref = CloudResourceRefSchema.safeParse(value);
|
|
87
|
+
if (ref.success && ref.data.type === field.type) settings[name] = ref.data;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return settings;
|
|
93
|
+
};
|
package/src/ai/capabilities.ts
CHANGED
|
@@ -15,8 +15,8 @@ import {
|
|
|
15
15
|
type CapabilityActionManifest,
|
|
16
16
|
type CapabilityActionReview,
|
|
17
17
|
type CapabilityQueryManifest,
|
|
18
|
-
CloudResourceRefSchema,
|
|
19
18
|
cloudResourceRefAppId,
|
|
19
|
+
LinkedCloudResourceReferenceSchema,
|
|
20
20
|
resolveCapabilityResourceReader,
|
|
21
21
|
} from "../contracts/capabilities";
|
|
22
22
|
import type { CapabilityRegistryEntry } from "../contracts/registry";
|
|
@@ -29,6 +29,7 @@ import { recordRejectedAiCapability } from "./capability-execution";
|
|
|
29
29
|
import { CODE_SOURCE_TOOLS } from "./code-source-contracts";
|
|
30
30
|
import { createCodeSourceTool } from "./code-source-tools";
|
|
31
31
|
import { CLOUD_AI_DEFERRED_BUILTIN_TOOL_NAMES } from "./default-tools";
|
|
32
|
+
import { addCloudResourceHrefs } from "./resource-refs";
|
|
32
33
|
import { type AiToolPreparationContext, defineAiTool, type PreparedAiTools, prepareAiTools } from "./tools";
|
|
33
34
|
import type { AiConversationService, AiRuntimeTool, AiToolPresentation } from "./types";
|
|
34
35
|
|
|
@@ -638,6 +639,7 @@ export const createAiToolMetaTools = (input: {
|
|
|
638
639
|
};
|
|
639
640
|
|
|
640
641
|
export const createLoadedAiCapabilityTools = (input: {
|
|
642
|
+
apps: readonly CapabilityRegistryEntry[];
|
|
641
643
|
catalog: readonly AiCapabilityCatalogEntry[];
|
|
642
644
|
loadedNames: readonly string[];
|
|
643
645
|
actor: RequestActor;
|
|
@@ -686,7 +688,7 @@ export const createLoadedAiCapabilityTools = (input: {
|
|
|
686
688
|
throw new Error("Capability Action was rejected by the user.");
|
|
687
689
|
}
|
|
688
690
|
}
|
|
689
|
-
return input.execute(entry, args, context);
|
|
691
|
+
return addCloudResourceHrefs(await input.execute(entry, args, context), input.apps);
|
|
690
692
|
}),
|
|
691
693
|
];
|
|
692
694
|
});
|
|
@@ -701,14 +703,14 @@ export const createAiResourceReaderTool = (input: {
|
|
|
701
703
|
name: "read_cloud_resource",
|
|
702
704
|
description:
|
|
703
705
|
"Read a Cloud resource from its structured reference using the resource type's current canonical reader. Pass refs returned by search, Projects, or other capabilities unchanged; never substitute an id from another resource type.",
|
|
704
|
-
inputSchema:
|
|
706
|
+
inputSchema: LinkedCloudResourceReferenceSchema,
|
|
705
707
|
outputSchema: z.unknown(),
|
|
706
708
|
approval: "never",
|
|
707
709
|
}).server(async (ref, context) => {
|
|
708
710
|
if (ref.type === "assistant.artifact") {
|
|
709
711
|
const tool = createCodeSourceTool("code_read");
|
|
710
712
|
if (tool.location !== "server") throw new Error("Code reader must execute on the server.");
|
|
711
|
-
return tool.run(CODE_SOURCE_TOOLS.code_read.input.parse({ id: ref.id }), context);
|
|
713
|
+
return addCloudResourceHrefs(await tool.run(CODE_SOURCE_TOOLS.code_read.input.parse({ id: ref.id }), context), input.apps);
|
|
712
714
|
}
|
|
713
715
|
const appId = cloudResourceRefAppId(ref);
|
|
714
716
|
const app = input.apps.find((candidate) => candidate.appId === appId);
|
|
@@ -718,7 +720,7 @@ export const createAiResourceReaderTool = (input: {
|
|
|
718
720
|
(candidate) => candidate.appId === appId && candidate.kind === "query" && candidate.operation.localId === reader.localId,
|
|
719
721
|
);
|
|
720
722
|
if (!entry) throw new Error(`Cloud resource reader ${appId}.${reader.localId} is unavailable.`);
|
|
721
|
-
return input.execute(entry, { id: ref.id }, context);
|
|
723
|
+
return addCloudResourceHrefs(await input.execute(entry, { id: ref.id }, context), input.apps);
|
|
722
724
|
});
|
|
723
725
|
|
|
724
726
|
/** Nessi resolver: one registry/load-state snapshot drives discovery, loading, schemas, and execution per model turn. */
|
|
@@ -860,6 +862,7 @@ export const createAiToolResolver =
|
|
|
860
862
|
...activeBuiltIns,
|
|
861
863
|
...(input.execute
|
|
862
864
|
? createLoadedAiCapabilityTools({
|
|
865
|
+
apps: registry,
|
|
863
866
|
catalog: capabilityCatalog,
|
|
864
867
|
loadedNames,
|
|
865
868
|
actor: input.actor,
|
|
@@ -283,9 +283,24 @@ export const CODE_SOURCE_TOOLS = {
|
|
|
283
283
|
input: Id.extend(ArtifactMetadata.shape),
|
|
284
284
|
},
|
|
285
285
|
code_create: {
|
|
286
|
+
promptHint:
|
|
287
|
+
"build every app, calculator, converter, timer, form or other tool the person asks for as a saved Studio app. In one turn, load_skill assistant-code-mode and load_tools code_create, code_write, code_check and code_open.",
|
|
286
288
|
description:
|
|
287
|
-
"Create a
|
|
288
|
-
input: ArtifactCreate.pick({ title: true, description: true, icon: true })
|
|
289
|
+
"Create a saved Studio app; it appears in the person's Studio Apps and can be opened, reused and shared. Use it for every app, calculator, converter, timer, form or other interactive tool the person asks for, even when it keeps no data. Pass the complete first version as html (a small app is this one file) and steps (its steps.json), so one call creates it; add style.css, app.js or more files with code_write. Without html it starts with a minimal index.html. Returns id, revision and the lines and length of every file; with html or steps also diagnostics, like code_write. Optional icon uses the complete class, e.g. ti ti-calculator; omit it when unsure. For a one-off analysis use code_run instead. Does not run, check or share anything.",
|
|
290
|
+
input: ArtifactCreate.pick({ title: true, description: true, icon: true }).extend({
|
|
291
|
+
html: z
|
|
292
|
+
.string()
|
|
293
|
+
.max(1024 * 1024)
|
|
294
|
+
.optional()
|
|
295
|
+
.describe(
|
|
296
|
+
'Complete index.html. A small app fits in this one file: put its script in <script type="module"> and its CSS in <style>. Omit it to start with a minimal page.',
|
|
297
|
+
),
|
|
298
|
+
steps: z
|
|
299
|
+
.string()
|
|
300
|
+
.max(1024 * 1024)
|
|
301
|
+
.optional()
|
|
302
|
+
.describe("Complete steps.json text for code_check: the main flow, at most 20 steps."),
|
|
303
|
+
}),
|
|
289
304
|
},
|
|
290
305
|
// Source text stays top-level because some providers corrupt strings nested in arrays.
|
|
291
306
|
// Imports carry only references, so they keep an array and one review per batch.
|
|
@@ -50,6 +50,7 @@ export function createCodeSourceTool(name: CodeSourceToolName) {
|
|
|
50
50
|
inputSchema: definition.input,
|
|
51
51
|
outputSchema: z.unknown(),
|
|
52
52
|
approval: "never",
|
|
53
|
+
promptHint: "promptHint" in definition ? definition.promptHint : undefined,
|
|
53
54
|
}).server(async (input, context) => {
|
|
54
55
|
if (!context.conversationId) throw new Error("Code tools require an Assistant conversation.");
|
|
55
56
|
const { actor } = await resolveAiCapabilityActor({
|
package/src/ai/default-tools.ts
CHANGED
|
@@ -99,12 +99,12 @@ export const createCloudAiCardTool = () =>
|
|
|
99
99
|
export const createCloudAiChartTool = () =>
|
|
100
100
|
defineAiTool({
|
|
101
101
|
name: "chart",
|
|
102
|
-
description: `Show one chart in the chat from a few values you already have that did not come from a code run, such as figures from the conversation, a web page or a small capability result: bar, line (area: true fills it), scatter, pie, donut, histogram, gauge, sparkline, boxplot, heatmap, barGauge or stat. Several related measures go into one call as a group of small charts (charts, at most ${CLOUD_AI_GROUP_MAX_CHARTS}); linkX shares the x cursor between line charts with the same kind of x. There is no second y axis: compare measures with different units as a group. Cloud draws it with the renderer of cloud.chart() and gives the reader a data table and Copy data; no code, sandbox or app check is involved. Values computed in code_run never go here: the script shows them itself with cloud.view.chart, cloud.view.group, cloud.view.table, cloud.view.stats, cloud.view.details or cloud.view.explorer, and you present the returned path. Aggregate first. Say what the chart shows in title and put the unit, period or source in subtitle; formats are data ({ style: "percent" } takes fractions). The chart has no state, filters or actions: filters, steps and comparison over computed rows come from cloud.view.explorer in code_run;
|
|
102
|
+
description: `Show one chart in the chat from a few values you already have that did not come from a code run, such as figures from the conversation, a web page or a small capability result: bar, line (area: true fills it), scatter, pie, donut, histogram, gauge, sparkline, boxplot, heatmap, barGauge or stat. Several related measures go into one call as a group of small charts (charts, at most ${CLOUD_AI_GROUP_MAX_CHARTS}); linkX shares the x cursor between line charts with the same kind of x. There is no second y axis: compare measures with different units as a group. Cloud draws it with the renderer of cloud.chart() and gives the reader a data table and Copy data; no code, sandbox or app check is involved. Values computed in code_run never go here: the script shows them itself with cloud.view.chart, cloud.view.group, cloud.view.table, cloud.view.stats, cloud.view.details or cloud.view.explorer, and you present the returned path. Aggregate first. Say what the chart shows in title and put the unit, period or source in subtitle; formats are data ({ style: "percent" } takes fractions). The chart has no state, filters or actions: filters, steps and comparison over computed rows come from cloud.view.explorer in code_run; anything else the person interacts with, such as a calculator, converter or form, is a Studio app built with code_create. After the chart, state the finding instead of repeating its values.`,
|
|
103
103
|
inputSchema: CloudAiChartInputSchema,
|
|
104
104
|
outputSchema: CloudAiChartOutputSchema,
|
|
105
105
|
approval: "never",
|
|
106
106
|
promptHint:
|
|
107
|
-
"show a few values you already have (not from a code run) as a chart in the chat. Code results use cloud.view.* and present; filters, steps and comparison use cloud.view.explorer in code_run;
|
|
107
|
+
"show a few values you already have (not from a code run) as a chart in the chat. Code results use cloud.view.* and present; filters, steps and comparison use cloud.view.explorer in code_run; an app or tool to interact with is a Studio app (code_create).",
|
|
108
108
|
}).clientView();
|
|
109
109
|
|
|
110
110
|
export const createCloudAiSurveyTool = () =>
|
|
@@ -246,7 +246,7 @@ export const createCloudAiCodeTools = () => [
|
|
|
246
246
|
defineAiTool({
|
|
247
247
|
name: "code_open",
|
|
248
248
|
description:
|
|
249
|
-
"Show
|
|
249
|
+
"Show a saved Studio app beside the chat without starting it; HTML apps require a passing code_check for the current files and tables. When the person only wants one calculated result, answer it instead of building an app.",
|
|
250
250
|
inputSchema: CodeOpenInput,
|
|
251
251
|
outputSchema: z.json(),
|
|
252
252
|
approval: "never",
|
|
@@ -262,7 +262,7 @@ export const createCloudAiCodeTools = () => [
|
|
|
262
262
|
defineAiTool({
|
|
263
263
|
name: "code_present",
|
|
264
264
|
description:
|
|
265
|
-
"Show
|
|
265
|
+
"Show a saved Studio app as a card in this chat: pass its id after a passing code_check for its current files and tables; it runs live with its data, and the person starts the card with a click. Every app, calculator, converter, form or tool the person asks for is a Studio app created with code_create; code_open shows it beside the chat instead. files (index.html plus optional style.css and app.js) instead of id show a throwaway card that is not saved and does not appear in Studio: only when the person explicitly asks for a preview they do not want to keep, after a passing code_check for exactly these files. Static problems such as CDN scripts, inline handlers or a missing index.html are rejected before anything is saved.",
|
|
266
266
|
inputSchema: CodePresentInput,
|
|
267
267
|
outputSchema: z.json(),
|
|
268
268
|
approval: "never",
|
package/src/ai/memories.ts
CHANGED
|
@@ -7,6 +7,10 @@ export const AI_MEMORY_CONTENT_MAX_CHARS = 500;
|
|
|
7
7
|
export const AI_MEMORY_HOT_MAX_ITEMS = 20;
|
|
8
8
|
export const AI_MEMORY_HOT_MAX_CHARS = 6_000;
|
|
9
9
|
|
|
10
|
+
// PostgreSQL stores at most 16,383 word positions per tsvector; a parsed token takes at least
|
|
11
|
+
// one character. 12,000 characters keep every position distinct for the stop-word pairing below.
|
|
12
|
+
const AI_MEMORY_RELEVANCE_MAX_CHARS = 12_000;
|
|
13
|
+
|
|
10
14
|
export type AiMemoryKind = "fact" | "preference" | "workflow";
|
|
11
15
|
export type AiMemoryPriority = "normal" | "pinned";
|
|
12
16
|
export type AiMemorySource = "user" | "agent" | "background";
|
|
@@ -131,6 +135,49 @@ const listActive = async (userId: string, limit: number): Promise<AiMemory[]> =>
|
|
|
131
135
|
return rows.map(toMemory);
|
|
132
136
|
};
|
|
133
137
|
|
|
138
|
+
const selectRelevant = async (input: { userId: string; text: string; total: number; limit: number }): Promise<AiMemory[]> => {
|
|
139
|
+
const text = (
|
|
140
|
+
input.text.length > AI_MEMORY_RELEVANCE_MAX_CHARS
|
|
141
|
+
? `${input.text.slice(0, AI_MEMORY_RELEVANCE_MAX_CHARS / 2)}\n${input.text.slice(-AI_MEMORY_RELEVANCE_MAX_CHARS / 2)}`
|
|
142
|
+
: input.text
|
|
143
|
+
).trim();
|
|
144
|
+
if (!text) return [];
|
|
145
|
+
|
|
146
|
+
const rows = await sql<MemoryRow[]>`
|
|
147
|
+
WITH english AS (
|
|
148
|
+
SELECT position, token.lexeme
|
|
149
|
+
FROM unnest(to_tsvector('english', ${text}::text)) AS token, unnest(token.positions) AS position
|
|
150
|
+
), german AS (
|
|
151
|
+
SELECT position, token.lexeme
|
|
152
|
+
FROM unnest(to_tsvector('german', ${text}::text)) AS token, unnest(token.positions) AS position
|
|
153
|
+
), words AS (
|
|
154
|
+
-- Both configurations parse the same tokens, so a position missing in one holds that language's stop word.
|
|
155
|
+
SELECT row_number() OVER () AS word, english, german
|
|
156
|
+
FROM (SELECT DISTINCT english.lexeme AS english, german.lexeme AS german FROM english JOIN german USING (position)) AS pairs
|
|
157
|
+
), lexemes AS (
|
|
158
|
+
SELECT DISTINCT words.word, lexeme FROM words, unnest(ARRAY[words.english, words.german]) AS lexeme
|
|
159
|
+
), matches AS (
|
|
160
|
+
SELECT DISTINCT memory.id, lexemes.word
|
|
161
|
+
FROM ai.memories AS memory
|
|
162
|
+
CROSS JOIN LATERAL unnest(tsvector_to_array(memory.stem_document)) AS document(lexeme)
|
|
163
|
+
JOIN lexemes ON lexemes.lexeme = document.lexeme
|
|
164
|
+
WHERE memory.user_id = ${input.userId}::uuid AND memory.deleted_at IS NULL AND memory.superseded_by_id IS NULL
|
|
165
|
+
), scores AS (
|
|
166
|
+
-- A word found in fewer of the user's memories says more about relevance.
|
|
167
|
+
SELECT id, sum(ln((${input.total} + 1)::float8 / matching)) AS score
|
|
168
|
+
FROM (SELECT id, count(*) OVER (PARTITION BY word) AS matching FROM matches) AS weighted
|
|
169
|
+
GROUP BY id
|
|
170
|
+
)
|
|
171
|
+
SELECT memory.id, memory.short_id, memory.user_id, memory.kind, memory.content, memory.priority, memory.source,
|
|
172
|
+
memory.source_conversation_id, memory.source_message_id, memory.resource_type, memory.resource_id,
|
|
173
|
+
memory.created_at, memory.updated_at
|
|
174
|
+
FROM ai.memories AS memory JOIN scores ON scores.id = memory.id
|
|
175
|
+
ORDER BY scores.score DESC, memory.updated_at DESC, memory.id ASC
|
|
176
|
+
LIMIT ${input.limit}
|
|
177
|
+
`;
|
|
178
|
+
return rows.map(toMemory);
|
|
179
|
+
};
|
|
180
|
+
|
|
134
181
|
const searchRows = async (input: { userId: string; query: string; limit: number; backend: SearchBackend }): Promise<AiMemory[]> => {
|
|
135
182
|
const query = input.query.trim();
|
|
136
183
|
if (!query) return listActive(input.userId, input.limit);
|
|
@@ -609,7 +656,7 @@ export const aiMemories = {
|
|
|
609
656
|
|
|
610
657
|
const [pinned, relevant, recent] = await Promise.all([
|
|
611
658
|
listActive(userId, 50).then((items) => items.filter((item) => item.priority === "pinned")),
|
|
612
|
-
|
|
659
|
+
selectRelevant({ userId, text: query, total: count, limit: 40 }),
|
|
613
660
|
listActive(userId, 40),
|
|
614
661
|
]);
|
|
615
662
|
const seen = new Set<string>();
|