@k2b/cloud 0.32.0 → 0.33.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/define-app.ts +17 -8
- package/src/_internal/page-responses.ts +5 -1
- 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/browser/navigation-search.ts +4 -1
- package/src/services/outbox.ts +13 -6
- package/src/shared/ai-platform-prompt.ts +3 -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 +56 -85
- package/src/ssr/LayoutHeader.tsx +31 -77
- package/src/ssr/LayoutHelp.tsx +2 -57
- package/src/ssr/LayoutHelpTrigger.tsx +51 -0
- package/src/ssr/{LayoutPreferences.island.tsx → LayoutPreferences.tsx} +2 -2
- package/src/ssr/LayoutRail.tsx +15 -69
- package/src/ssr/MinimalLayout.tsx +2 -3
- package/src/ssr/MinimalLayoutPreferences.island.tsx +12 -15
- package/src/ssr/MobileNavigation.tsx +13 -8
- package/src/ssr/MobileProfileActions.tsx +10 -7
- package/src/ssr/PageTrailRow.tsx +14 -0
- package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +12 -7
- package/src/ssr/PwaLayout.tsx +0 -2
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/ShellRuntime.island.tsx +170 -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/{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/mobile-navigation.browser-harness.tsx +20 -0
- package/src/ssr/preference-controller.ts +30 -21
- package/src/ssr/profile-actions.ts +26 -15
- package/src/ssr/profile-preferences-messages.ts +8 -4
- package/src/ssr/rail-context.ts +8 -12
- 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/global.css +17 -5
- package/src/styles/utilities-navigation.css +13 -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/GlobalSearchTrigger.island.tsx +0 -75
- package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
- 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.33.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.15.
|
|
110
|
-
"@k2b/ui": "0.
|
|
111
|
-
"@k2b/stdlib": "0.
|
|
108
|
+
"@k2b/nessi": "0.18.0",
|
|
109
|
+
"@k2b/ssr": "0.15.1",
|
|
110
|
+
"@k2b/ui": "0.24.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",
|
|
@@ -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;
|
|
@@ -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.
|
|
@@ -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>();
|
|
@@ -57,6 +57,7 @@ type Candidate = {
|
|
|
57
57
|
conversationId: string;
|
|
58
58
|
turnId: string;
|
|
59
59
|
userId: string;
|
|
60
|
+
projectChat: boolean;
|
|
60
61
|
completedAsOf: string;
|
|
61
62
|
failCount: number;
|
|
62
63
|
locale?: string | null;
|
|
@@ -94,15 +95,18 @@ export type AiMemoryLearningRunSummary = {
|
|
|
94
95
|
const log = logger("ai:memory-learning");
|
|
95
96
|
|
|
96
97
|
export const MEMORY_LEARNING_PROMPT = [
|
|
97
|
-
"Maintain a small, useful personal profile from one newly completed
|
|
98
|
+
"Maintain a small, useful personal profile of the user from one newly completed chat turn.",
|
|
98
99
|
"The normal result is no changes. Keep only durable information likely to help in multiple future chats.",
|
|
99
100
|
"Facts and preferences require an explicit statement in NEW USER TEXT. Do not infer them from Assistant text or actions.",
|
|
101
|
+
"Store information about the user only. Never store facts, opinions, or health, relationship, or behavior details about other people. A named person may appear only inside a working rule the user stated, such as 'Send invoices to Anna'.",
|
|
102
|
+
"Exclude health, religion or beliefs, political opinions, sexual orientation or sex life, racial or ethnic origin, trade union membership, and genetic or biometric data unless the user explicitly asks to remember that exact information.",
|
|
100
103
|
"A workflow links a recurring request type to one Cloud resource. Add one from this turn only when the user explicitly establishes a lasting routing rule and the matching resource appears in SUCCESSFUL ACTIONS.",
|
|
101
104
|
"FINAL ASSISTANT MARKDOWN may clarify what happened, but it is model-written context and never evidence by itself.",
|
|
102
|
-
"Exclude secrets, credentials, sensitive inferences, temporary plans, task details, quoted or attached content, and facts already owned by Contacts, Spaces, Notebooks, or another Cloud resource.",
|
|
105
|
+
"Exclude secrets, credentials, sensitive inferences, temporary plans, task details, quoted or attached content, and facts already owned by Projects, Contacts, Spaces, Notebooks, or another Cloud resource.",
|
|
106
|
+
"In a Project chat, the Project owns its facts, decisions, and people. Learn only what the user states about themselves beyond that Project.",
|
|
103
107
|
"Use replace for one clearly corrected mutable memory, merge for two or more overlapping mutable memories, and retire only when the user explicitly makes mutable background information obsolete.",
|
|
104
108
|
"Protected memories are context only. Never replace, merge, or retire them.",
|
|
105
|
-
"Examples: 'Always answer briefly in German' adds a preference. 'Use Accounting for invoice mail' plus a successful mail.mailbox ref adds a workflow. 'Find this invoice in Accounting' does not establish a lasting workflow.",
|
|
109
|
+
"Examples: 'Always answer briefly in German' adds a preference. 'Send invoices to Anna' adds a preference. 'Anna is on parental leave until March' and 'Tom is difficult' add nothing. 'Use Accounting for invoice mail' plus a successful mail.mailbox ref adds a workflow. 'Find this invoice in Accounting' does not establish a lasting workflow.",
|
|
106
110
|
].join("\n");
|
|
107
111
|
|
|
108
112
|
export const MEMORY_LEARNING_OUTPUT_CONTRACT = [
|
|
@@ -116,6 +120,7 @@ export const WORKFLOW_PATTERN_PROMPT = [
|
|
|
116
120
|
"Decide whether three successful uses of the same Cloud capability and resource reveal one reusable workflow default.",
|
|
117
121
|
"Return a workflow only when the user requests share a clear recurring category and defaulting to this resource would avoid a future search or clarification.",
|
|
118
122
|
"Do not generalize from unrelated requests, resource contents, or the Assistant wording. Uncertainty returns null.",
|
|
123
|
+
"Name the recurring category neutrally: never put details about other people or health, religion or beliefs, political opinions, sexual orientation or sex life, racial or ethnic origin, trade union membership, or genetic or biometric data in the rule. When examples come from Project chats, return a workflow only if it clearly applies beyond that Project; otherwise return null.",
|
|
119
124
|
"If an existing mutable workflow is corrected, return its id. Return multiple ids only when one new workflow genuinely consolidates them. Protected memories cannot be changed.",
|
|
120
125
|
].join("\n");
|
|
121
126
|
|
|
@@ -141,6 +146,7 @@ export const listAiMemoryLearningCandidates = async (limit: number, monthlyToken
|
|
|
141
146
|
conversation.id AS conversation_id,
|
|
142
147
|
turn.id AS turn_id,
|
|
143
148
|
conversation.created_by_user_id AS user_id,
|
|
149
|
+
conversation.project_id IS NOT NULL AS project_chat,
|
|
144
150
|
turn.completed_at,
|
|
145
151
|
turn.memory_learn_fail_count,
|
|
146
152
|
turn.run_config->>'locale' AS locale,
|
|
@@ -168,6 +174,7 @@ export const listAiMemoryLearningCandidates = async (limit: number, monthlyToken
|
|
|
168
174
|
conversation_id AS "conversationId",
|
|
169
175
|
turn_id AS "turnId",
|
|
170
176
|
user_id AS "userId",
|
|
177
|
+
project_chat AS "projectChat",
|
|
171
178
|
completed_at::text AS "completedAsOf",
|
|
172
179
|
memory_learn_fail_count AS "failCount",
|
|
173
180
|
locale
|
|
@@ -292,6 +299,8 @@ const actionLines = (actions: AiMemoryWorkflowEvidence[]): string =>
|
|
|
292
299
|
|
|
293
300
|
const learningInput = (evidence: TurnEvidence, memories: Awaited<ReturnType<typeof aiMemories.list>>): string =>
|
|
294
301
|
[
|
|
302
|
+
evidence.candidate.projectChat ? "CHAT: Project chat" : "CHAT: chat outside Projects",
|
|
303
|
+
"",
|
|
295
304
|
"NEW USER TEXT:",
|
|
296
305
|
evidence.userText || "(none)",
|
|
297
306
|
"",
|
|
@@ -361,8 +370,9 @@ const workflowPatternInput = async (
|
|
|
361
370
|
if (!(await isAiWorkflowPatternEligible(pattern))) return { input: "", source: null, mutableMemoryIds: new Set() };
|
|
362
371
|
const sources = await Promise.all(
|
|
363
372
|
pattern.turnIds.map(async (turnId) => {
|
|
364
|
-
const [row] = await sql<{ conversation_id: string; completed_as_of: string; fail_count: number }[]>`
|
|
365
|
-
SELECT turn.conversation_id,
|
|
373
|
+
const [row] = await sql<{ conversation_id: string; project_chat: boolean; completed_as_of: string; fail_count: number }[]>`
|
|
374
|
+
SELECT turn.conversation_id, conversation.project_id IS NOT NULL AS project_chat,
|
|
375
|
+
turn.completed_at::text AS completed_as_of, turn.memory_learn_fail_count AS fail_count
|
|
366
376
|
FROM ai.turns turn
|
|
367
377
|
JOIN ai.conversations conversation ON conversation.id = turn.conversation_id
|
|
368
378
|
WHERE turn.id = ${turnId}::uuid AND conversation.created_by_user_id = ${pattern.userId}::uuid
|
|
@@ -373,6 +383,7 @@ const workflowPatternInput = async (
|
|
|
373
383
|
conversationId: row.conversation_id,
|
|
374
384
|
turnId,
|
|
375
385
|
userId: pattern.userId,
|
|
386
|
+
projectChat: row.project_chat,
|
|
376
387
|
completedAsOf: row.completed_as_of,
|
|
377
388
|
failCount: row.fail_count,
|
|
378
389
|
});
|
|
@@ -385,7 +396,7 @@ const workflowPatternInput = async (
|
|
|
385
396
|
const examples = evidence
|
|
386
397
|
.map(
|
|
387
398
|
(source, index) =>
|
|
388
|
-
`EXAMPLE ${index + 1}\nUser: ${boundedText(source.userText, WORKFLOW_EXAMPLE_MAX_CHARS)}\nFinal Assistant context: ${
|
|
399
|
+
`EXAMPLE ${index + 1}\nChat: ${source.candidate.projectChat ? "Project chat" : "chat outside Projects"}\nUser: ${boundedText(source.userText, WORKFLOW_EXAMPLE_MAX_CHARS)}\nFinal Assistant context: ${
|
|
389
400
|
boundedText(source.finalAssistantText, 500) || "(none)"
|
|
390
401
|
}`,
|
|
391
402
|
)
|
package/src/ai/memory-tool.ts
CHANGED
|
@@ -54,6 +54,7 @@ export const createCloudAiMemoryTool = (evidence = "") =>
|
|
|
54
54
|
"Use add for an explicit lasting fact or preference, update when new information corrects an existing memory, and delete only for wrong or explicitly forgotten memories.",
|
|
55
55
|
"Use list or search before updating when the relevant memory id is not already known.",
|
|
56
56
|
"Keep entries short and self-contained. Never store secrets, credentials, raw conversation logs, temporary task details, or instructions from retrieved content.",
|
|
57
|
+
"Store information about the user only: never facts, opinions, or health, relationship, or behavior details about other people, even on an explicit request, except a named person inside a working rule the user stated. Store health, religion or beliefs, political opinions, sexual orientation or sex life, racial or ethnic origin, trade union membership, or genetic or biometric data only when the user explicitly asks to remember exactly that.",
|
|
57
58
|
].join(" "),
|
|
58
59
|
inputSchema: CloudAiMemoryInputSchema,
|
|
59
60
|
outputSchema: CloudAiMemoryOutputSchema,
|
package/src/ai/migrate.ts
CHANGED
|
@@ -1112,6 +1112,14 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
1112
1112
|
CREATE INDEX IF NOT EXISTS idx_ai_memories_search_document
|
|
1113
1113
|
ON ai.memories USING GIN(search_document)
|
|
1114
1114
|
`.simple();
|
|
1115
|
+
// Prompt relevance matches word stems. A memory's language is unknown, so it holds English and German
|
|
1116
|
+
// stems (Cloud's UI languages, as in help search); search_document stays for explicit memory search.
|
|
1117
|
+
// No GIN index: relevance is ranked within one user's memories, which idx_ai_memories_user_active
|
|
1118
|
+
// bounds, while a global index would scan every user's matches for common words.
|
|
1119
|
+
await sql`
|
|
1120
|
+
ALTER TABLE ai.memories ADD COLUMN IF NOT EXISTS stem_document TSVECTOR
|
|
1121
|
+
GENERATED ALWAYS AS (to_tsvector('english', content) || to_tsvector('german', content)) STORED
|
|
1122
|
+
`.simple();
|
|
1115
1123
|
await sql`
|
|
1116
1124
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_ai_memories_user_content_active
|
|
1117
1125
|
ON ai.memories(user_id, lower(content))
|
package/src/ai/provider.ts
CHANGED
|
@@ -2,11 +2,12 @@ import type { Provider, ReasoningEffort } from "@k2b/nessi/ai";
|
|
|
2
2
|
import { anthropic, completeFromStream, gemini, mistral, ollama, openAICompatible, openai, openrouter } from "@k2b/nessi/ai";
|
|
3
3
|
import type { AiModelProfile } from "./types";
|
|
4
4
|
|
|
5
|
-
/** Bound the first
|
|
5
|
+
/** Bound the first response chunk and every gap between chunks to 60s, for chat and complete() calls. */
|
|
6
6
|
export const PROVIDER_TIMEOUTS = { firstByteMs: 60_000, idleMs: 60_000 } as const;
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
//
|
|
8
|
+
// Native complete() receives headers only after the whole answer, so until then Nessi can bound it only by total time
|
|
9
|
+
// (totalMs), which cuts off slow healthy answers or detects a stall late. Streaming bounds the first response chunk and
|
|
10
|
+
// every gap between chunks, as for chat. Any body chunk counts, including SSE keep-alive comments.
|
|
10
11
|
export const completeThroughStream = (provider: Provider): Provider => ({
|
|
11
12
|
...provider,
|
|
12
13
|
complete: (request) => completeFromStream(provider, request),
|
|
@@ -5,7 +5,10 @@ import type { AppMeta } from "../contracts/app";
|
|
|
5
5
|
/** UI-only rows. The synthetic ref is a row key, never a readable resource. */
|
|
6
6
|
export type NavigationSearchItem = SearchItem & { keywords?: readonly string[] };
|
|
7
7
|
|
|
8
|
-
export const navigationSearchItems = (
|
|
8
|
+
export const navigationSearchItems = (
|
|
9
|
+
apps: readonly Pick<AppMeta, "id" | "name" | "icon" | "searchLinks">[],
|
|
10
|
+
visibleAppIds: readonly string[],
|
|
11
|
+
): NavigationSearchItem[] => {
|
|
9
12
|
const visible = new Set(visibleAppIds);
|
|
10
13
|
return apps
|
|
11
14
|
.filter((app) => visible.has(app.id))
|
package/src/services/outbox.ts
CHANGED
|
@@ -14,9 +14,10 @@ import { logger } from "./logging";
|
|
|
14
14
|
* the dispatcher publishes keys concurrently and the rows of one key in order,
|
|
15
15
|
* then completes the batch in one statement. A batch starts no publish after
|
|
16
16
|
* its claim has ended, because the next claim takes its rows. A row that never
|
|
17
|
-
* failed is due at once. Claims stay proportional to the batch
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* failed is due at once. Claims stay proportional to the batch, whatever the
|
|
18
|
+
* table's statistics say, with an index on the `where` columns and `sequence`,
|
|
19
|
+
* and one on the `where` columns and `orderBy` limited to
|
|
20
|
+
* `claimed_until IS NOT NULL OR attempts > 0`.
|
|
20
21
|
*
|
|
21
22
|
* Required columns: `id uuid`, `attempts int`, `next_attempt_at timestamptz`,
|
|
22
23
|
* `claimed_until timestamptz`, `last_error text`, and the `orderBy` and
|
|
@@ -97,13 +98,20 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
|
|
|
97
98
|
const cap = Math.min(Math.max(limit, 1), batchSize);
|
|
98
99
|
// Claims take turns: a concurrent claim would skip this one's locked rows and take later rows of the same keys.
|
|
99
100
|
// A claim that outlives its claim period is worthless, so neither a slow statement nor a vanished client holds the turn longer.
|
|
101
|
+
// Without sequential and bitmap scans, statistics of an empty or claimed outbox cannot make a claim scan and sort
|
|
102
|
+
// the whole backlog instead of walking the sequence index up to the batch. A disabled scan still counts as very
|
|
103
|
+
// expensive, which would compile every claim (JIT, ~0.4 s), so JIT stays off.
|
|
100
104
|
await tx`
|
|
101
105
|
SELECT set_config('statement_timeout', ${String(claimMs)}, true),
|
|
102
106
|
set_config('idle_in_transaction_session_timeout', ${String(claimMs)}, true),
|
|
107
|
+
set_config('enable_seqscan', 'off', true),
|
|
108
|
+
set_config('enable_bitmapscan', 'off', true),
|
|
109
|
+
set_config('jit', 'off', true),
|
|
103
110
|
pg_advisory_xact_lock(hashtextextended(${claimLock}, 0))
|
|
104
111
|
`;
|
|
105
112
|
// The busy probe runs per candidate (OFFSET 0 keeps the planner from turning it into a join, which
|
|
106
|
-
// stale statistics can make quadratic), against an index of claimed and retried rows per key.
|
|
113
|
+
// stale statistics can make quadratic), against an index of claimed and retried rows per key. The update looks
|
|
114
|
+
// its rows up by id for the same reason: as a join, it scanned the whole table per claim.
|
|
107
115
|
return tx<Row[]>`
|
|
108
116
|
WITH candidates AS MATERIALIZED (
|
|
109
117
|
SELECT current.id
|
|
@@ -125,8 +133,7 @@ export const createPgOutbox = <Row extends OutboxRow>(config: PgOutboxConfig<Row
|
|
|
125
133
|
), claimed AS (
|
|
126
134
|
UPDATE ${table} outbox
|
|
127
135
|
SET claimed_until = now() + (${claimMs} * interval '1 millisecond')
|
|
128
|
-
FROM candidates
|
|
129
|
-
WHERE outbox.id = candidates.id
|
|
136
|
+
WHERE outbox.id = ANY(ARRAY(SELECT id FROM candidates))
|
|
130
137
|
RETURNING outbox.*
|
|
131
138
|
)
|
|
132
139
|
SELECT * FROM claimed ORDER BY ${sequence}
|
|
@@ -105,10 +105,11 @@ Use the conversation file tools for this chat's files: the user's uploads are re
|
|
|
105
105
|
{%- if memoryEnabled %}
|
|
106
106
|
|
|
107
107
|
# Personalization rules
|
|
108
|
-
|
|
108
|
+
Apply the dated facts, preferences, and workflow defaults at the end only when one changes the answer, and judge how current it is. Do not mention or recite them unless the user asks or one is directly relevant. The current message wins over a conflicting memory. A workflow default may select a likely Cloud resource, but never grants access; resolve it through the current authorized capability before use.
|
|
109
109
|
{%- if memoryToolEnabled %}
|
|
110
110
|
- When the user explicitly asks you to remember or forget something, or clearly frames a lasting preference with phrases such as "from now on", "always", or "never", call memory before replying.
|
|
111
|
-
-
|
|
111
|
+
- These limits apply to explicit requests too: never save facts, opinions, or health, relationship, or behavior details about other people, except a named person inside a working rule the user stated. Say what you did not save and offer Contacts or the Project instead.
|
|
112
|
+
- Without a direct request, save only a fact or preference the user clearly stated about themselves that is durable and likely useful in future conversations.
|
|
112
113
|
- Search before correcting an entry whose id is unknown, update contradictions instead of adding duplicates, and delete wrong or explicitly forgotten memories.
|
|
113
114
|
- Say you remembered, noted, or forgot something only after the corresponding memory call succeeded.
|
|
114
115
|
{%- endif %}
|
package/src/shared/theme.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
export type CloudTheme = "light" | "dark";
|
|
2
|
+
export type CloudThemePreference = CloudTheme | "system";
|
|
2
3
|
|
|
3
4
|
const THEME_COOKIE = "theme";
|
|
4
5
|
const COOKIE_MAX_AGE_SECONDS = 31536000;
|
|
5
6
|
|
|
6
7
|
const isCloudTheme = (value: string): value is CloudTheme => value === "light" || value === "dark";
|
|
7
8
|
|
|
9
|
+
const isCloudThemePreference = (value: string): value is CloudThemePreference => isCloudTheme(value) || value === "system";
|
|
10
|
+
|
|
8
11
|
const decodeCookieValue = (value: string): string => {
|
|
9
12
|
try {
|
|
10
13
|
return decodeURIComponent(value);
|
|
@@ -13,28 +16,44 @@ const decodeCookieValue = (value: string): string => {
|
|
|
13
16
|
}
|
|
14
17
|
};
|
|
15
18
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
export const readThemeFromCookieHeader = (cookieHeader: string | null | undefined): CloudTheme => {
|
|
24
|
-
let resolved: CloudTheme = "light";
|
|
19
|
+
/** More specific legacy paths arrive first; the root choice is the last valid entry. */
|
|
20
|
+
const readCookieValue = <T extends string>(
|
|
21
|
+
cookieHeader: string | null | undefined,
|
|
22
|
+
name: string,
|
|
23
|
+
valid: (value: string) => value is T,
|
|
24
|
+
): T | undefined => {
|
|
25
|
+
let result: T | undefined;
|
|
25
26
|
for (const part of (cookieHeader ?? "").split(";")) {
|
|
26
27
|
const trimmed = part.trim();
|
|
27
28
|
const separatorIndex = trimmed.indexOf("=");
|
|
28
|
-
if (separatorIndex <= 0) continue;
|
|
29
|
-
if (trimmed.slice(0, separatorIndex) !== THEME_COOKIE) continue;
|
|
30
|
-
|
|
29
|
+
if (separatorIndex <= 0 || trimmed.slice(0, separatorIndex) !== name) continue;
|
|
31
30
|
const value = decodeCookieValue(trimmed.slice(separatorIndex + 1));
|
|
32
|
-
if (
|
|
31
|
+
if (valid(value)) result = value;
|
|
33
32
|
}
|
|
34
|
-
return
|
|
33
|
+
return result;
|
|
35
34
|
};
|
|
36
35
|
|
|
37
|
-
|
|
36
|
+
/** The person's choice; a missing or invalid cookie means Automatic (`system`). */
|
|
37
|
+
export const readThemePreferenceFromCookieHeader = (cookieHeader: string | null | undefined): CloudThemePreference =>
|
|
38
|
+
readCookieValue(cookieHeader, THEME_COOKIE, isCloudThemePreference) ?? "system";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The resolved theme for server work such as the AI code runtime. For
|
|
42
|
+
* Automatic it is the scheme the browser last reported; browser code reads the
|
|
43
|
+
* `<html>` class instead.
|
|
44
|
+
*/
|
|
45
|
+
export const readThemeFromCookieHeader = (cookieHeader: string | null | undefined): CloudTheme => {
|
|
46
|
+
const preference = readThemePreferenceFromCookieHeader(cookieHeader);
|
|
47
|
+
return preference === "system" ? (readCookieValue(cookieHeader, "color-scheme", isCloudTheme) ?? "light") : preference;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves the `<html>` class before first paint, because only the browser
|
|
52
|
+
* knows the device scheme, and follows scheme changes live. It also reports
|
|
53
|
+
* the scheme in the `color-scheme` cookie for the server. A constant, so a CSP
|
|
54
|
+
* hash of it stays valid.
|
|
55
|
+
*/
|
|
56
|
+
export const themeBootstrapScript = `!function(){var e=document.documentElement;if(e.hasAttribute("data-theme-fixed"))return;var m=matchMedia("(prefers-color-scheme: dark)");function r(n,s){var v;document.cookie.split(";").forEach(function(p){p=p.trim();var i=p.indexOf("=");if(i>0&&p.slice(0,i)===n){var t=p.slice(i+1);try{t=decodeURIComponent(t)}catch(e){}if(t==="light"||t==="dark"||s&&t==="system")v=t}});return v}function a(){var s=m.matches?"dark":"light";if(r("color-scheme")!==s)document.cookie="color-scheme="+s+"; path=/; max-age=31536000; SameSite=Lax"+(location.protocol==="https:"?"; Secure":"");var p=r("theme",true)||"system";e.classList.remove("light","dark");e.classList.add(p==="system"?s:p)}a();m.addEventListener("change",function(){a();window.dispatchEvent(new Event("cloud:theme-preference"))})}();`;
|
|
38
57
|
|
|
39
58
|
const legacyThemeCookiePaths = (pathname: string): string[] => {
|
|
40
59
|
const paths = new Set(["/", "/me", "/app", "/admin"]);
|
|
@@ -48,11 +67,13 @@ const legacyThemeCookiePaths = (pathname: string): string[] => {
|
|
|
48
67
|
return [...paths];
|
|
49
68
|
};
|
|
50
69
|
|
|
51
|
-
export const setThemePreference = (mode:
|
|
70
|
+
export const setThemePreference = (mode: CloudThemePreference): CloudThemePreference => {
|
|
52
71
|
if (typeof document === "undefined") return mode;
|
|
53
72
|
|
|
54
73
|
document.documentElement.classList.remove("light", "dark");
|
|
55
|
-
document.documentElement.classList.add(
|
|
74
|
+
document.documentElement.classList.add(
|
|
75
|
+
mode === "system" ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") : mode,
|
|
76
|
+
);
|
|
56
77
|
|
|
57
78
|
const secure = location.protocol === "https:" ? "; Secure" : "";
|
|
58
79
|
for (const path of legacyThemeCookiePaths(location.pathname)) {
|
|
@@ -62,7 +83,7 @@ export const setThemePreference = (mode: CloudTheme): CloudTheme => {
|
|
|
62
83
|
return mode;
|
|
63
84
|
};
|
|
64
85
|
|
|
65
|
-
export const getCurrentThemePreference = ():
|
|
66
|
-
if (typeof document === "undefined") return "
|
|
67
|
-
return document.
|
|
86
|
+
export const getCurrentThemePreference = (): CloudThemePreference => {
|
|
87
|
+
if (typeof document === "undefined") return "system";
|
|
88
|
+
return readThemePreferenceFromCookieHeader(document.cookie);
|
|
68
89
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { IconButton, MarkdownView, NoticeCard, useLocale } from "@k2b/ui";
|
|
2
|
+
import { For } from "solid-js";
|
|
3
|
+
import type { AnnouncementDisplayEntry } from "../contracts/announcements";
|
|
4
|
+
import { platformMessages } from "./platform-messages";
|
|
5
|
+
|
|
6
|
+
export default function AnnouncementBanners(props: { banners: AnnouncementDisplayEntry[] }) {
|
|
7
|
+
const locale = useLocale();
|
|
8
|
+
const t = () => platformMessages.resolve([locale()]).t;
|
|
9
|
+
if (props.banners.length === 0) return null;
|
|
10
|
+
return (
|
|
11
|
+
<div class="flex shrink-0 flex-col gap-1">
|
|
12
|
+
<For each={props.banners}>
|
|
13
|
+
{(banner) => (
|
|
14
|
+
<div class="cloud-announcement">
|
|
15
|
+
<NoticeCard tone={banner.tone} title={banner.title}>
|
|
16
|
+
<MarkdownView
|
|
17
|
+
trustedHtml={banner.bodyHtml}
|
|
18
|
+
headingScale="compact"
|
|
19
|
+
class="-mx-2 max-h-36 overflow-y-auto overscroll-contain pl-2 pr-3 [&_p]:my-0"
|
|
20
|
+
/>
|
|
21
|
+
</NoticeCard>
|
|
22
|
+
<IconButton size="sm" label={t().dismissBanner} data-cloud-announcement-dismiss={banner.version}>
|
|
23
|
+
<i class="ti ti-x" aria-hidden="true" />
|
|
24
|
+
</IconButton>
|
|
25
|
+
</div>
|
|
26
|
+
)}
|
|
27
|
+
</For>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|