@k2b/cloud 0.23.0 → 0.24.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.
Files changed (45) hide show
  1. package/package.json +2 -2
  2. package/src/_internal/capabilities.ts +230 -75
  3. package/src/_internal/define-app.ts +12 -3
  4. package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
  5. package/src/_internal/page-responses.ts +1 -1
  6. package/src/_internal/registry.ts +78 -3
  7. package/src/ai/chat/blocks.tsx +2 -1
  8. package/src/ai/chat/messages.ts +6 -0
  9. package/src/ai/client/controller.ts +65 -20
  10. package/src/ai/client/transport.ts +5 -5
  11. package/src/ai/code-mode-skill.ts +2 -2
  12. package/src/ai/index.ts +0 -7
  13. package/src/ai/live-events.ts +1 -262
  14. package/src/ai/live.ts +31 -2
  15. package/src/ai/migrate.ts +35 -16
  16. package/src/ai/runtime.ts +1 -6
  17. package/src/ai/solid.ts +0 -5
  18. package/src/api/help.ts +2 -2
  19. package/src/api/pwa-phone.ts +10 -3
  20. package/src/api/search/schemas.ts +34 -0
  21. package/src/api/search.ts +118 -50
  22. package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
  23. package/src/browser/CloudResourceSearch.tsx +208 -80
  24. package/src/browser/resource-search-messages.ts +15 -2
  25. package/src/browser/search-stream.ts +151 -0
  26. package/src/contracts/capabilities.ts +89 -24
  27. package/src/contracts/capability-compatibility.ts +28 -14
  28. package/src/contracts/file-provider.ts +158 -0
  29. package/src/contracts/index.ts +1 -0
  30. package/src/events/live-engine.ts +11 -7
  31. package/src/events/live.ts +32 -32
  32. package/src/server/help.ts +3 -3
  33. package/src/services/index.ts +1 -1
  34. package/src/services/outbox.ts +29 -115
  35. package/src/services/pdf/markdown.ts +22 -2
  36. package/src/services/pwa-devices.ts +74 -8
  37. package/src/shared/markdown/formula.ts +122 -26
  38. package/src/shared/markdown/index.ts +27 -14
  39. package/src/ssr/PwaLayout.tsx +2 -1
  40. package/src/styles/resource-search.css +19 -0
  41. package/src/ai/client/live-connection.ts +0 -141
  42. package/src/ai/live-messages.ts +0 -45
  43. package/src/ai/live-outbox.ts +0 -102
  44. package/src/ai/live-routes.ts +0 -412
  45. package/src/shared/markdown/extensions/info-blocks.ts +0 -79
@@ -475,6 +475,76 @@ const isTruthy = (v: EvalValue): boolean => {
475
475
  return v.length > 0;
476
476
  };
477
477
 
478
+ /** Cell text that is one decimal number and nothing else. Each digit has
479
+ * one place in the pattern, so a long run of digits never backtracks. */
480
+ const NUMBER_CELL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
481
+
482
+ /** A number, boolean, or text whose whole content is one number. */
483
+ const toExactNumber = (v: EvalValue): number | null =>
484
+ typeof v === "string" ? (NUMBER_CELL.test(v.trim()) ? Number(v.trim()) : null) : toNumber(v);
485
+
486
+ /** An ISO date or timestamp: `2026-10-12`, `2026-10-12 09:30`,
487
+ * `2026-10-12T09:30:00.250Z`, or with an offset such as `+02:00`. */
488
+ const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3})\d*)?)?(Z|[+-]\d{2}:?\d{2})?)?$/i;
489
+
490
+ /** An ISO date without a time, such as `2026-10-12`. */
491
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
492
+
493
+ /**
494
+ * The instant an ISO date or timestamp names, or null for anything else,
495
+ * including impossible dates such as `2026-02-30` and offsets such as
496
+ * `+00:60`. Text without an offset is local time, like the values TODAY()
497
+ * and NOW() return.
498
+ */
499
+ const toDateTime = (v: EvalValue): number | null => {
500
+ if (typeof v !== "string") return null;
501
+ const match = ISO_DATE_TIME.exec(v.trim());
502
+ if (!match) return null;
503
+ const year = Number(match[1]);
504
+ const month = Number(match[2]);
505
+ const day = Number(match[3]);
506
+ const hour = Number(match[4] ?? 0);
507
+ const minute = Number(match[5] ?? 0);
508
+ const second = Number(match[6] ?? 0);
509
+ const ms = Number((match[7] ?? "").padEnd(3, "0"));
510
+ const zone = match[8];
511
+ if (hour > 23 || minute > 59 || second > 59) return null;
512
+ const utc = new Date(0);
513
+ utc.setUTCFullYear(year, month - 1, day);
514
+ if (utc.getUTCMonth() !== month - 1 || utc.getUTCDate() !== day) return null;
515
+ if (zone) {
516
+ utc.setUTCHours(hour, minute, second, ms);
517
+ if (zone.toUpperCase() === "Z") return utc.getTime();
518
+ const sign = zone.startsWith("-") ? -1 : 1;
519
+ const digits = zone.slice(1).replace(":", "");
520
+ const offsetHours = Number(digits.slice(0, 2));
521
+ const offsetMinutes = Number(digits.slice(2));
522
+ if (offsetHours > 23 || offsetMinutes > 59) return null;
523
+ return utc.getTime() - sign * (offsetHours * 60 + offsetMinutes) * 60_000;
524
+ }
525
+ const local = new Date(0);
526
+ local.setFullYear(year, month - 1, day);
527
+ local.setHours(hour, minute, second, ms);
528
+ return local.getTime();
529
+ };
530
+
531
+ /**
532
+ * Equality for `==`, `!=`, COUNTIF, and SUMIF: dates by instant; numbers,
533
+ * also `5 €` against 5, by value; everything else, such as `1.2.3` or
534
+ * `3 apples`, by its exact text.
535
+ */
536
+ const isEqual = (l: EvalValue, r: EvalValue): boolean => {
537
+ const ld = toDateTime(l);
538
+ const rd = toDateTime(r);
539
+ if (ld !== null && rd !== null) return ld === rd;
540
+ if (ld === null && rd === null && (toExactNumber(l) !== null || toExactNumber(r) !== null)) {
541
+ const ln = toNumber(l);
542
+ const rn = toNumber(r);
543
+ if (ln !== null && rn !== null) return ln === rn;
544
+ }
545
+ return toString(l) === toString(r);
546
+ };
547
+
478
548
  const lookupColumn = (name: string, ctx: EvalContext): { index: number } | { suggestion?: string } => {
479
549
  const lower = name.toLowerCase();
480
550
  const idx = ctx.headers.findIndex((h) => h.toLowerCase() === lower);
@@ -492,8 +562,9 @@ const isCurrentFormulaCell = (row: number, col: number, ctx: EvalContext): boole
492
562
  /**
493
563
  * Resolve a single cell to its evaluated value. If the cell is itself
494
564
  * a formula, recursively evaluate it (with cycle detection via
495
- * `ctx._visited`). Non-formula cells are coerced to number when
496
- * possible, otherwise returned as-is.
565
+ * `ctx._visited`). A non-formula cell becomes a number only when its
566
+ * whole text is one; anything else, such as the ISO date `2026-10-12`
567
+ * that DATEDIFF needs intact, stays text.
497
568
  *
498
569
  * Used by both the column-reference resolver in `evaluateAst` and by
499
570
  * the column / row aggregate functions, so a `=SUM(total)` aggregating
@@ -503,8 +574,8 @@ const isCurrentFormulaCell = (row: number, col: number, ctx: EvalContext): boole
503
574
  const evaluateCell = (row: number, col: number, ctx: EvalContext): EvalResult => {
504
575
  const cell = cellAt(row, col, ctx);
505
576
  if (!cell.startsWith("=")) {
506
- const n = toNumber(cell);
507
- return n !== null ? ok(n) : ok(cell);
577
+ const text = cell.trim();
578
+ return NUMBER_CELL.test(text) ? ok(Number(text)) : ok(cell);
508
579
  }
509
580
  const key = `${row},${col}`;
510
581
  if (ctx._visited?.has(key)) {
@@ -686,9 +757,8 @@ const FUNCTIONS: Record<string, FuncImpl> = {
686
757
  if ("kind" in col) return col;
687
758
  const valueArg = state.evaluate(args[1]!);
688
759
  if (valueArg.kind === "error") return valueArg;
689
- const target = toString(valueArg.value);
690
760
  let count = 0;
691
- for (const v of col.values) if (v === target) count++;
761
+ for (const v of col.values) if (isEqual(v, valueArg.value)) count++;
692
762
  return ok(count);
693
763
  },
694
764
  SUMIF: (args, state) => {
@@ -717,13 +787,12 @@ const FUNCTIONS: Record<string, FuncImpl> = {
717
787
  }
718
788
  const condValueArg = state.evaluate(args[2]!);
719
789
  if (condValueArg.kind === "error") return condValueArg;
720
- const target = toString(condValueArg.value);
721
790
  let total = 0;
722
791
  for (let r = 0; r < state.ctx.rows.length; r++) {
723
792
  if (isCurrentFormulaCell(r, condLookup.index, state.ctx) || isCurrentFormulaCell(r, sumLookup.index, state.ctx)) continue;
724
793
  const condResult = evaluateCell(r, condLookup.index, state.ctx);
725
794
  if (condResult.kind !== "ok") continue;
726
- if (toString(condResult.value) !== target) continue;
795
+ if (!isEqual(condResult.value, condValueArg.value)) continue;
727
796
  const sumResult = evaluateCell(r, sumLookup.index, state.ctx);
728
797
  if (sumResult.kind !== "ok") continue;
729
798
  const n = toNumber(sumResult.value);
@@ -989,17 +1058,25 @@ const FUNCTIONS: Record<string, FuncImpl> = {
989
1058
  if (d1V.kind === "error") return d1V;
990
1059
  const d2V = state.evaluate(args[1]!);
991
1060
  if (d2V.kind === "error") return d2V;
992
- const d1 = new Date(toString(d1V.value));
993
- const d2 = new Date(toString(d2V.value));
994
- if (Number.isNaN(d1.getTime())) return err("PARSE_ERROR", `DATEDIFF: first argument is not a valid date`);
995
- if (Number.isNaN(d2.getTime())) return err("PARSE_ERROR", `DATEDIFF: second argument is not a valid date`);
1061
+ // ISO values follow the comparison operators, so a date without a
1062
+ // time is local midnight and an impossible one such as 2026-02-30 is
1063
+ // no date; other text keeps the Date parser's reading.
1064
+ const instant = (v: EvalValue): number =>
1065
+ ISO_DATE_TIME.test(toString(v).trim()) ? (toDateTime(v) ?? Number.NaN) : new Date(toString(v)).getTime();
1066
+ const t1 = instant(d1V.value);
1067
+ const t2 = instant(d2V.value);
1068
+ if (Number.isNaN(t1)) return err("PARSE_ERROR", `DATEDIFF: first argument is not a valid date`);
1069
+ if (Number.isNaN(t2)) return err("PARSE_ERROR", `DATEDIFF: second argument is not a valid date`);
996
1070
  let unit = "days";
997
1071
  if (args.length === 3) {
998
1072
  const u = state.evaluate(args[2]!);
999
1073
  if (u.kind === "error") return u;
1000
1074
  unit = toString(u.value).toLowerCase();
1001
1075
  }
1002
- const diffMs = d2.getTime() - d1.getTime();
1076
+ // Two dates are whole days apart, also across a daylight saving change.
1077
+ const dayMs = 1000 * 60 * 60 * 24;
1078
+ const wholeDays = ISO_DATE.test(toString(d1V.value).trim()) && ISO_DATE.test(toString(d2V.value).trim());
1079
+ const diffMs = wholeDays ? Math.round((t2 - t1) / dayMs) * dayMs : t2 - t1;
1003
1080
  switch (unit) {
1004
1081
  case "ms":
1005
1082
  case "milliseconds":
@@ -1030,18 +1107,45 @@ const FUNCTION_NAMES = Object.keys(FUNCTIONS);
1030
1107
 
1031
1108
  // -- Binop application --------------------------------------------------------
1032
1109
 
1110
+ /**
1111
+ * The two numbers the ordering operators compare, or null when the
1112
+ * values have no common order. Two dates compare as instants and never
1113
+ * with a number or other text, so `2026-10-12` is not `2026`. Text with
1114
+ * a unit, such as `5 €`, compares by its leading number, as does text
1115
+ * against text: `5 €` and `5.00 €` are neither smaller nor greater than
1116
+ * each other, even though `==` tells their texts apart.
1117
+ */
1118
+ const orderedPair = (l: EvalValue, r: EvalValue): [number, number] | null => {
1119
+ const ld = toDateTime(l);
1120
+ const rd = toDateTime(r);
1121
+ if (ld !== null || rd !== null) return ld !== null && rd !== null ? [ld, rd] : null;
1122
+ const ln = toNumber(l);
1123
+ const rn = toNumber(r);
1124
+ return ln !== null && rn !== null ? [ln, rn] : null;
1125
+ };
1126
+
1033
1127
  const applyBinop = (op: BinOp, l: EvalValue, r: EvalValue): EvalResult => {
1034
1128
  if (op === "==" || op === "!=") {
1035
- // Equality: if both look numeric, compare as numbers; else as strings.
1036
- const ln = toNumber(l);
1037
- const rn = toNumber(r);
1038
- const equal = ln !== null && rn !== null ? ln === rn : toString(l) === toString(r);
1129
+ const equal = isEqual(l, r);
1039
1130
  return ok((op === "==" ? equal : !equal) ? 1 : 0);
1040
1131
  }
1132
+ if (op === "<" || op === "<=" || op === ">" || op === ">=") {
1133
+ const pair = orderedPair(l, r);
1134
+ if (pair === null) {
1135
+ return toDateTime(l) !== null || toDateTime(r) !== null
1136
+ ? err("TYPE_ERROR", `Cannot compare a date with a value that is not a date using "${op}"`)
1137
+ : err("NON_NUMERIC", `Cannot compare non-numeric values with "${op}"`);
1138
+ }
1139
+ const [a, b] = pair;
1140
+ if (op === "<") return ok(a < b ? 1 : 0);
1141
+ if (op === "<=") return ok(a <= b ? 1 : 0);
1142
+ if (op === ">") return ok(a > b ? 1 : 0);
1143
+ return ok(a >= b ? 1 : 0);
1144
+ }
1041
1145
  const ln = toNumber(l);
1042
1146
  const rn = toNumber(r);
1043
1147
  if (ln === null || rn === null) {
1044
- return err("NON_NUMERIC", `Cannot compare/compute non-numeric values with "${op}"`);
1148
+ return err("NON_NUMERIC", `Cannot compute non-numeric values with "${op}"`);
1045
1149
  }
1046
1150
  switch (op) {
1047
1151
  case "+":
@@ -1053,14 +1157,6 @@ const applyBinop = (op: BinOp, l: EvalValue, r: EvalValue): EvalResult => {
1053
1157
  case "/":
1054
1158
  if (rn === 0) return err("DIV_BY_ZERO", `Division by zero`);
1055
1159
  return ok(ln / rn);
1056
- case "<":
1057
- return ok(ln < rn ? 1 : 0);
1058
- case "<=":
1059
- return ok(ln <= rn ? 1 : 0);
1060
- case ">":
1061
- return ok(ln > rn ? 1 : 0);
1062
- case ">=":
1063
- return ok(ln >= rn ? 1 : 0);
1064
1160
  }
1065
1161
  };
1066
1162
 
@@ -5,13 +5,13 @@
5
5
  * the visual appearance of the CodeMirror editor extensions.
6
6
  */
7
7
 
8
- import { Marked } from "marked";
8
+ import { markdownInfoBlocks } from "@k2b/ui";
9
+ import { Marked, type MarkedExtension } from "marked";
9
10
  import sanitizeHtml from "sanitize-html";
10
11
  import { markdownClient } from "./client";
11
12
  import { codeExtension } from "./extensions/code";
12
13
  import { guidedHelpExtension } from "./extensions/guided-help";
13
14
  import { imagesExtension } from "./extensions/images";
14
- import { infoBlocksExtension } from "./extensions/info-blocks";
15
15
  import { katexExtension } from "./extensions/katex";
16
16
  import { linksExtension } from "./extensions/links";
17
17
  import { markExtension } from "./extensions/mark";
@@ -23,7 +23,7 @@ import { taskListExtension } from "./extensions/task-list";
23
23
  type MarkdownProfile = "content" | "help";
24
24
  type LinkStyle = "widget" | "plain";
25
25
 
26
- const createMarked = (profile: MarkdownProfile = "content", links: LinkStyle = "widget") => {
26
+ const createMarked = (profile: MarkdownProfile, links: LinkStyle, infoBlocks: MarkedExtension) => {
27
27
  const marked = new Marked();
28
28
 
29
29
  marked.use({
@@ -33,7 +33,7 @@ const createMarked = (profile: MarkdownProfile = "content", links: LinkStyle = "
33
33
 
34
34
  // Apply extensions in order
35
35
  // Note: katexExtension must come before codeExtension to handle ```math blocks
36
- marked.use(infoBlocksExtension());
36
+ marked.use(infoBlocks);
37
37
  marked.use(taskListExtension());
38
38
  marked.use(tablesExtension());
39
39
  // Plain links keep marked's own renderer: an anchor around the link text.
@@ -49,17 +49,29 @@ const createMarked = (profile: MarkdownProfile = "content", links: LinkStyle = "
49
49
  return marked;
50
50
  };
51
51
 
52
- const marked = createMarked();
53
- const helpMarked = createMarked("help");
54
- const plainLinksMarked = createMarked("content", "plain");
52
+ // One instance per UI message catalog, not per requested locale: request
53
+ // headers choose the locale, and `markdownInfoBlocks()` returns one extension
54
+ // for every locale that shares a catalog, so the cache stays bounded.
55
+ const instances = new Map<MarkedExtension, Map<string, Marked>>();
56
+ const markedFor = (profile: MarkdownProfile, links: LinkStyle = "widget", locale?: string): Marked => {
57
+ const infoBlocks = markdownInfoBlocks({ locale });
58
+ const byStyle = instances.get(infoBlocks) ?? new Map<string, Marked>();
59
+ instances.set(infoBlocks, byStyle);
60
+ const key = `${profile}:${links}`;
61
+ const instance = byStyle.get(key) ?? createMarked(profile, links, infoBlocks);
62
+ byStyle.set(key, instance);
63
+ return instance;
64
+ };
65
+
66
+ const marked = markedFor("content");
55
67
 
56
68
  export type MarkdownRenderOptions = {
57
69
  /** `"plain"` renders each link as an ordinary anchor around its text, for HTML read outside Cloud such as email. */
58
70
  links?: LinkStyle;
71
+ /** Request locale for the screen-reader names of untitled info blocks. Defaults to English on the server and the page language in the browser. */
72
+ locale?: string;
59
73
  };
60
74
 
61
- const markedFor = ({ links = "widget" }: MarkdownRenderOptions): Marked => (links === "plain" ? plainLinksMarked : marked);
62
-
63
75
  const sanitizeRenderedHtml = (html: string): string =>
64
76
  sanitizeHtml(html, {
65
77
  allowedTags: [
@@ -136,7 +148,8 @@ const sanitizeRenderedHtml = (html: string): string =>
136
148
  *
137
149
  * Supported features:
138
150
  * - GFM (GitHub Flavored Markdown)
139
- * - Notice cards (:::note, :::info, :::success, :::warning, :::danger)
151
+ * - Info blocks (:::note, :::info, :::success, :::warning, :::danger), shared
152
+ * with `MarkdownView` from `@k2b/ui`
140
153
  * - Task lists with checkboxes
141
154
  * - Tables with cell formatting
142
155
  * - Styled links and images
@@ -160,7 +173,7 @@ const sanitizeRenderedHtml = (html: string): string =>
160
173
  export function renderMarkdown(content: string, options: MarkdownRenderOptions = {}): string {
161
174
  if (!content || typeof content !== "string") return "";
162
175
 
163
- const html = markedFor(options).parse(content);
176
+ const html = markedFor("content", options.links, options.locale).parse(content);
164
177
  if (typeof html !== "string") return "";
165
178
 
166
179
  return sanitizeRenderedHtml(html);
@@ -172,7 +185,7 @@ export function renderMarkdown(content: string, options: MarkdownRenderOptions =
172
185
  export function renderMarkdownSync(content: string, options: MarkdownRenderOptions = {}): string {
173
186
  if (!content || typeof content !== "string") return "";
174
187
 
175
- const html = markedFor(options).parse(content);
188
+ const html = markedFor("content", options.links, options.locale).parse(content);
176
189
  if (typeof html !== "string") return "";
177
190
 
178
191
  return sanitizeRenderedHtml(html);
@@ -182,9 +195,9 @@ export function renderMarkdownSync(content: string, options: MarkdownRenderOptio
182
195
  * Render trusted documentation Markdown with guided sections and internal
183
196
  * navigation. All fenced code remains visible source, as in ordinary Markdown.
184
197
  */
185
- export function renderHelpMarkdown(content: string): string {
198
+ export function renderHelpMarkdown(content: string, locale?: string): string {
186
199
  if (!content || typeof content !== "string") return "";
187
- const html = helpMarked.parse(content);
200
+ const html = markedFor("help", "widget", locale).parse(content);
188
201
  return typeof html === "string" ? sanitizeRenderedHtml(html) : "";
189
202
  }
190
203
 
@@ -48,8 +48,9 @@ export default function PwaLayout(props: PwaLayoutProps) {
48
48
  const path = new URL(c.req.raw.url).pathname;
49
49
  const tabs = parts.slice(0, TAB_PARTS);
50
50
  const currentTab = tabs.find((part) => path === part.pwa.href || path.startsWith(`${part.pwa.href}/`));
51
+ // Start is titled with the installation's name; a part's first page carries the part's name.
51
52
  const items: TabBarItem[] = [
52
- { id: "start", label: t.start, icon: "ti ti-home", href: PWA_SCOPE, current: !currentTab },
53
+ { id: "start", label: t.start, title: cloud, icon: "ti ti-home", href: PWA_SCOPE, current: !currentTab },
53
54
  ...tabs.map((part) => ({ id: part.id, label: part.name, icon: part.icon, href: part.pwa.href, current: part === currentTab })),
54
55
  ];
55
56
 
@@ -214,6 +214,24 @@
214
214
  color: var(--k2b-text-muted);
215
215
  font-size: 0.8125rem;
216
216
  }
217
+ /* Apps still searching and apps that did not answer: one calm line each, below the results. */
218
+ .cloud-resource-search__status {
219
+ display: grid;
220
+ gap: 0.125rem;
221
+ padding: 0 1.125rem 1.5rem;
222
+ color: var(--k2b-text-muted);
223
+ font-size: 0.8125rem;
224
+ }
225
+ .cloud-resource-search__status p {
226
+ display: flex;
227
+ flex-wrap: wrap;
228
+ align-items: center;
229
+ gap: 0.375rem;
230
+ min-height: 1.75rem;
231
+ }
232
+ .cloud-resource-search__status i {
233
+ font-size: 0.875rem;
234
+ }
217
235
  .cloud-resource-search__tag-heading {
218
236
  display: flex;
219
237
  align-items: center;
@@ -386,6 +404,7 @@
386
404
  padding-inline: 0;
387
405
  }
388
406
  .cloud-resource-search__hint,
407
+ .cloud-resource-search__status,
389
408
  .cloud-resource-search__idle,
390
409
  .cloud-resource-search__footer,
391
410
  .cloud-resource-search__count,
@@ -1,141 +0,0 @@
1
- import {
2
- createLiveWebSocket,
3
- type LiveWebSocket,
4
- type LiveWebSocketClose,
5
- type LiveWebSocketError,
6
- type LiveWebSocketStatus,
7
- } from "../../browser/live-websocket";
8
- import { AI_LIVE_WS_TYPE, type AiLiveServerMessage, parseAiLiveServerMessage } from "../live-events";
9
- import type { AiConversationStreamTransport, AiStreamConnectionStatus } from "./transport";
10
-
11
- type TurnSubscription = {
12
- conversationId: string;
13
- generation: number;
14
- onEvent: Parameters<AiConversationStreamTransport["subscribe"]>[0]["onEvent"];
15
- onStatus?: Parameters<AiConversationStreamTransport["subscribe"]>[0]["onStatus"];
16
- onError?: Parameters<AiConversationStreamTransport["subscribe"]>[0]["onError"];
17
- };
18
-
19
- export type AiLiveConnection = {
20
- connect: () => void;
21
- markApplied: (cursor: string | null | undefined) => void;
22
- streamTransport: AiConversationStreamTransport;
23
- dispose: () => void;
24
- };
25
-
26
- export type CreateAiLiveConnectionOptions = {
27
- url?: string;
28
- initialCursor: string;
29
- onLiveMessage: (message: AiLiveServerMessage) => void;
30
- onLiveStatus?: (status: LiveWebSocketStatus) => void;
31
- onFatal?: (error: LiveWebSocketError) => void;
32
- };
33
-
34
- const reconnectableAiClose = (close: LiveWebSocketClose): LiveWebSocketError | null =>
35
- close.code === 1008 ? { code: close.reason || "access_denied", message: "Live access changed or expired." } : null;
36
-
37
- const streamStatus = (status: LiveWebSocketStatus): AiStreamConnectionStatus | null => {
38
- if (status === "connecting" || status === "reconnecting") return status;
39
- if (status === "paused") return "reconnecting";
40
- return null;
41
- };
42
-
43
- /** One browser connection for AI invalidations and the currently visible turn. */
44
- export const createAiLiveConnection = (options: CreateAiLiveConnectionOptions): AiLiveConnection => {
45
- let socket: LiveWebSocket;
46
- let turn: TurnSubscription | null = null;
47
- let turnGeneration = 0;
48
- let connected = false;
49
- let disposed = false;
50
- let liveSubscribeCount = 0;
51
-
52
- const sendTurnSubscription = () => {
53
- if (!connected || !turn) return;
54
- if (
55
- !socket.send({
56
- type: AI_LIVE_WS_TYPE.turnSubscribe,
57
- payload: { conversationId: turn.conversationId },
58
- })
59
- ) {
60
- turn.onStatus?.("reconnecting");
61
- }
62
- };
63
-
64
- socket = createLiveWebSocket<AiLiveServerMessage>({
65
- url: options.url ?? "/api/ai/live",
66
- activity: "always",
67
- initialCursor: options.initialCursor,
68
- subscribe: (cursor) => ({
69
- type: AI_LIVE_WS_TYPE.subscribe,
70
- payload: { fromCursor: cursor, recover: liveSubscribeCount++ > 0 },
71
- }),
72
- parse: parseAiLiveServerMessage,
73
- classifyClose: reconnectableAiClose,
74
- onOpen: sendTurnSubscription,
75
- onStatus: (status) => {
76
- connected = status === "open";
77
- options.onLiveStatus?.(status);
78
- const next = streamStatus(status);
79
- if (next) turn?.onStatus?.(next);
80
- },
81
- onMessage: (message) => {
82
- if (message.type === AI_LIVE_WS_TYPE.turnEvent) {
83
- if (!turn || turn.conversationId !== message.payload.conversationId) return;
84
- if (message.payload.event.type === "state") turn.onStatus?.("open");
85
- turn.onEvent(message.payload.event);
86
- return;
87
- }
88
- if (message.type === AI_LIVE_WS_TYPE.turnError) {
89
- if (!turn || turn.conversationId !== message.payload.conversationId) return;
90
- const current = turn;
91
- turn = null;
92
- current.onError?.(new Error(message.payload.message));
93
- return;
94
- }
95
- options.onLiveMessage(message);
96
- },
97
- onFatal: (error) => {
98
- connected = false;
99
- const current = turn;
100
- turn = null;
101
- current?.onError?.(new Error(error.message));
102
- options.onFatal?.(error);
103
- },
104
- });
105
-
106
- const streamTransport: AiConversationStreamTransport = {
107
- subscribe: ({ conversationId, onEvent, onStatus, onError }) => {
108
- const generation = ++turnGeneration;
109
- const subscription: TurnSubscription = { conversationId, generation, onEvent, onStatus, onError };
110
- turn = subscription;
111
- onStatus?.("connecting");
112
- sendTurnSubscription();
113
- return {
114
- close: () => {
115
- if (turn?.generation !== generation) return;
116
- turn = null;
117
- if (connected) {
118
- socket.send({
119
- type: AI_LIVE_WS_TYPE.turnUnsubscribe,
120
- payload: { conversationId },
121
- });
122
- }
123
- },
124
- };
125
- },
126
- };
127
-
128
- return {
129
- connect: () => {
130
- if (!disposed) socket.connect();
131
- },
132
- markApplied: (cursor) => socket.markApplied(cursor),
133
- streamTransport,
134
- dispose: () => {
135
- if (disposed) return;
136
- disposed = true;
137
- turn = null;
138
- socket.dispose();
139
- },
140
- };
141
- };
@@ -1,45 +0,0 @@
1
- import { i18n } from "@k2b/stdlib";
2
-
3
- const catalog = i18n.define({
4
- baseLocale: "en",
5
- messages: {
6
- en: {
7
- loginRequired: "Login required",
8
- conversationAccessChanged: "Conversation access changed or expired",
9
- capacityExceeded: "AI updates exceeded the connection capacity",
10
- authorizationRefreshFailed: "AI live authorization refresh failed",
11
- invalidStreamData: "AI event stream contains invalid data",
12
- eventStreamEnded: "AI event stream ended",
13
- eventStreamFailed: "AI event stream failed",
14
- subscriptionFailed: "AI live subscription failed",
15
- conversationStreamEnded: "AI conversation stream ended",
16
- conversationStreamFailed: "AI conversation stream failed",
17
- liveSubscriptionRequired: "AI live subscription required before a turn subscription",
18
- conversationNotFound: "Conversation not found",
19
- invalidJson: "Invalid JSON payload",
20
- invalidSubscription: "Invalid AI live subscription",
21
- tooManyMessages: "Too many pending AI live messages",
22
- },
23
- de: {
24
- loginRequired: "Anmeldung erforderlich",
25
- conversationAccessChanged: "Der Zugriff auf die Unterhaltung wurde geändert oder ist abgelaufen",
26
- capacityExceeded: "Die AI-Updates haben die Kapazität der Verbindung überschritten",
27
- authorizationRefreshFailed: "Die Zugriffsprüfung für AI Live ist fehlgeschlagen",
28
- invalidStreamData: "Der AI-Ereignisstrom enthält ungültige Daten",
29
- eventStreamEnded: "Der AI-Ereignisstrom wurde beendet",
30
- eventStreamFailed: "Der AI-Ereignisstrom ist fehlgeschlagen",
31
- subscriptionFailed: "Die Anmeldung für AI Live ist fehlgeschlagen",
32
- conversationStreamEnded: "Der AI-Unterhaltungsstrom wurde beendet",
33
- conversationStreamFailed: "Der AI-Unterhaltungsstrom ist fehlgeschlagen",
34
- liveSubscriptionRequired: "Vor dem Abonnieren eines Durchlaufs ist eine Anmeldung für AI Live erforderlich",
35
- conversationNotFound: "Die Unterhaltung wurde nicht gefunden",
36
- invalidJson: "Die JSON-Daten sind ungültig",
37
- invalidSubscription: "Die Anmeldung für AI Live ist ungültig",
38
- tooManyMessages: "Zu viele ausstehende Nachrichten für AI Live",
39
- },
40
- },
41
- });
42
-
43
- export type AiLiveMessages = ReturnType<typeof catalog.resolve>["t"];
44
- export const aiLiveMessages = (locale?: string | null) => catalog.resolve(locale ? [locale] : []).t;
45
- export const checkAiLiveMessages = () => catalog.check();
@@ -1,102 +0,0 @@
1
- import type { sql } from "bun";
2
- import { lazySync } from "../_internal/process-sync";
3
- import { createPgOutbox } from "../services/outbox";
4
- import { toPgTextArray } from "../services/postgres";
5
- import { latestTopicCursor } from "../services/topic-cursor";
6
- import type { AiInvalidation, AiInvalidationDomain } from "./live-events";
7
-
8
- const RETENTION_MS = 24 * 60 * 60 * 1_000;
9
- const RECONCILE_INTERVAL_MS = 1_000;
10
- const MAX_ATTEMPTS = 20;
11
-
12
- type SqlClient = typeof sql;
13
-
14
- export type AiLiveOutboxRow = {
15
- id: string;
16
- change_id: string;
17
- audience_user_id: string;
18
- conversation_short_id: string | null;
19
- project_short_id: string | null;
20
- domains: AiInvalidationDomain[];
21
- attempts: number;
22
- created_at: Date | string;
23
- };
24
-
25
- const invalidationTopic = lazySync((sync) =>
26
- sync.topic<AiInvalidation>({
27
- id: "cloud-ai-invalidations",
28
- owner: "cloud",
29
- retention: { maxAgeMs: RETENTION_MS, maxBytes: 64 * 1024 * 1024 },
30
- maxPayloadBytes: 9_024,
31
- }),
32
- );
33
-
34
- const tenantId = (userId: string): string => userId;
35
-
36
- export const enqueueAiInvalidation = async (
37
- db: SqlClient,
38
- input: {
39
- audienceUserId: string;
40
- domains: AiInvalidationDomain[];
41
- conversationId?: string | null;
42
- projectId?: string | null;
43
- changeId?: string;
44
- },
45
- ): Promise<string> => {
46
- const [row] = await db<{ id: string }[]>`
47
- INSERT INTO ai.live_invalidation_outbox (
48
- change_id, audience_user_id, conversation_short_id, project_short_id, domains
49
- )
50
- VALUES (
51
- ${input.changeId ?? crypto.randomUUID()}::uuid,
52
- ${input.audienceUserId}::uuid,
53
- ${input.conversationId ?? null},
54
- ${input.projectId ?? null},
55
- ${toPgTextArray([...new Set(input.domains)])}::text[]
56
- )
57
- RETURNING id::text
58
- `;
59
- if (!row) throw new Error("AI live invalidation insert returned no id");
60
- return row.id;
61
- };
62
-
63
- const publishAiInvalidation = (row: AiLiveOutboxRow): Promise<unknown> => {
64
- const event: AiInvalidation = {
65
- type: "ai.invalidated",
66
- changeId: row.change_id,
67
- conversationId: row.conversation_short_id,
68
- projectId: row.project_short_id,
69
- domains: row.domains,
70
- at: new Date(row.created_at).toISOString(),
71
- };
72
- return invalidationTopic().publish({
73
- tenantId: tenantId(row.audience_user_id),
74
- orderingKey: row.conversation_short_id ?? row.project_short_id ?? row.audience_user_id,
75
- idempotencyKey: row.change_id,
76
- data: event,
77
- });
78
- };
79
-
80
- const outbox = createPgOutbox<AiLiveOutboxRow>({
81
- table: "ai.live_invalidation_outbox",
82
- name: "ai:live-outbox",
83
- publish: publishAiInvalidation,
84
- reconcileIntervalMs: RECONCILE_INTERVAL_MS,
85
- orderBy: "audience_user_id",
86
- maxAttempts: MAX_ATTEMPTS,
87
- });
88
-
89
- export const claimAiInvalidationBatch = outbox.claim;
90
- export const dispatchAiInvalidation = outbox.dispatch;
91
- export const reconcileAiInvalidations = outbox.reconcile;
92
- export const notifyAiInvalidations = outbox.notify;
93
- export const startAiInvalidationRuntime = outbox.start;
94
- export const stopAiInvalidationRuntime = outbox.stop;
95
-
96
- export const liveAiInvalidations = (input: { userId: string; after?: string | null; signal?: AbortSignal }) =>
97
- invalidationTopic()
98
- .hub({ tenantId: tenantId(input.userId) })
99
- .subscribe({ after: input.after ?? undefined, signal: input.signal });
100
-
101
- export const latestAiInvalidationCursor = (userId: string): Promise<string> =>
102
- latestTopicCursor({ topic: invalidationTopic(), resourceId: "cloud-ai-invalidations", tenantId: tenantId(userId) });