@k2b/cloud 0.22.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 (117) hide show
  1. package/package.json +4 -3
  2. package/scripts/README.md +1 -1
  3. package/scripts/build-canvas-workers.ts +58 -0
  4. package/scripts/build.ts +4 -5
  5. package/src/_internal/canvas-worker.ts +24 -0
  6. package/src/_internal/capabilities.ts +230 -75
  7. package/src/_internal/define-app.ts +90 -7
  8. package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
  9. package/src/_internal/page-responses.ts +20 -4
  10. package/src/_internal/registry-validation.ts +6 -0
  11. package/src/_internal/registry.ts +78 -3
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/static-assets.ts +18 -3
  14. package/src/access/ResourceApiKeys.tsx +30 -30
  15. package/src/ai/capability-execution.ts +5 -0
  16. package/src/ai/chat/blocks.tsx +2 -1
  17. package/src/ai/chat/messages.ts +6 -0
  18. package/src/ai/client/controller.ts +80 -21
  19. package/src/ai/client/transport.ts +47 -3
  20. package/src/ai/code-mode-skill.ts +2 -2
  21. package/src/ai/index.ts +0 -7
  22. package/src/ai/live-events.ts +1 -262
  23. package/src/ai/live.ts +31 -2
  24. package/src/ai/migrate.ts +35 -16
  25. package/src/ai/pdf-render.ts +2 -22
  26. package/src/ai/routes.ts +5 -4
  27. package/src/ai/runtime.ts +1 -6
  28. package/src/ai/solid.ts +0 -5
  29. package/src/ai/stream.ts +89 -65
  30. package/src/api/app-approval.ts +5 -1
  31. package/src/api/help.ts +2 -2
  32. package/src/api/index.ts +3 -0
  33. package/src/api/me.ts +19 -5
  34. package/src/api/pwa-phone.ts +168 -0
  35. package/src/api/pwa.ts +179 -0
  36. package/src/api/search/schemas.ts +34 -0
  37. package/src/api/search.ts +118 -50
  38. package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
  39. package/src/browser/CloudResourceSearch.tsx +208 -80
  40. package/src/browser/app-session.ts +69 -0
  41. package/src/browser/live-connection.ts +211 -0
  42. package/src/browser/live-websocket.ts +31 -11
  43. package/src/browser/live.ts +2 -0
  44. package/src/browser/resource-search-messages.ts +15 -2
  45. package/src/browser/search-stream.ts +151 -0
  46. package/src/contracts/app.ts +10 -0
  47. package/src/contracts/capabilities.ts +89 -24
  48. package/src/contracts/capability-compatibility.ts +28 -14
  49. package/src/contracts/file-provider.ts +158 -0
  50. package/src/contracts/index.ts +2 -0
  51. package/src/contracts/pwa-paths.ts +6 -0
  52. package/src/contracts/pwa.ts +156 -0
  53. package/src/contracts/registry.ts +7 -0
  54. package/src/contracts/shared.ts +7 -1
  55. package/src/events/index.ts +2 -0
  56. package/src/events/live-engine.ts +830 -0
  57. package/src/events/live-protocol.ts +31 -0
  58. package/src/events/live.ts +298 -0
  59. package/src/server/actor.ts +7 -0
  60. package/src/server/api-client.ts +23 -2
  61. package/src/server/help.ts +3 -3
  62. package/src/server/index.ts +3 -1
  63. package/src/server/middleware/auth.ts +80 -8
  64. package/src/server/middleware/openapi.ts +2 -1
  65. package/src/server/services/access.ts +49 -0
  66. package/src/server/services/index.ts +1 -0
  67. package/src/services/app-approval.ts +6 -25
  68. package/src/services/audit/index.ts +2 -0
  69. package/src/services/branding/app-icon-source.ts +40 -0
  70. package/src/services/branding/app-icons.ts +112 -0
  71. package/src/services/branding/icon-render-worker.ts +69 -0
  72. package/src/services/branding/icon-svg.ts +22 -0
  73. package/src/services/identity/invocation-actor.ts +8 -5
  74. package/src/services/identity/invocation-authority.ts +1 -0
  75. package/src/services/identity/invocation-token.ts +6 -0
  76. package/src/services/index.ts +1 -0
  77. package/src/services/outbox.ts +114 -80
  78. package/src/services/pairing-secret.ts +16 -0
  79. package/src/services/pdf/markdown.ts +22 -2
  80. package/src/services/pwa-devices.ts +640 -0
  81. package/src/services/session/index.ts +212 -57
  82. package/src/services/session/recent.ts +42 -0
  83. package/src/services/session/user.ts +15 -5
  84. package/src/shared/index.ts +1 -0
  85. package/src/{browser → shared}/locale-preference.ts +1 -1
  86. package/src/shared/markdown/formula.ts +122 -26
  87. package/src/shared/markdown/index.ts +27 -23
  88. package/src/ssr/AppLaunchpad.island.tsx +8 -1
  89. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  90. package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
  91. package/src/ssr/Layout.tsx +10 -12
  92. package/src/ssr/LayoutHeader.tsx +14 -2
  93. package/src/ssr/LayoutHelp.tsx +2 -4
  94. package/src/ssr/LayoutRail.tsx +13 -4
  95. package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
  96. package/src/ssr/MobileProfileActions.tsx +19 -13
  97. package/src/ssr/PageError.tsx +23 -1
  98. package/src/ssr/ProfilePreferences.island.tsx +9 -1
  99. package/src/ssr/PwaLayout.tsx +70 -0
  100. package/src/ssr/PwaRuntime.island.tsx +105 -0
  101. package/src/ssr/TimezoneCookie.island.tsx +7 -1
  102. package/src/ssr/app-navigation.ts +25 -10
  103. package/src/ssr/index.ts +8 -1
  104. package/src/ssr/layout-context.ts +1 -1
  105. package/src/ssr/preference-controller.ts +2 -2
  106. package/src/ssr/profile-actions.ts +3 -1
  107. package/src/ssr/profile-preferences-messages.ts +2 -0
  108. package/src/ssr/pwa-messages.ts +24 -0
  109. package/src/styles/global.css +6 -0
  110. package/src/styles/resource-search.css +19 -0
  111. package/src/styles/utilities-feedback.css +2 -2
  112. package/scripts/build-pdf-renderer.ts +0 -39
  113. package/src/ai/client/live-connection.ts +0 -141
  114. package/src/ai/live-messages.ts +0 -45
  115. package/src/ai/live-outbox.ts +0 -102
  116. package/src/ai/live-routes.ts +0 -412
  117. package/src/shared/markdown/extensions/info-blocks.ts +0 -108
@@ -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) });
@@ -1,412 +0,0 @@
1
- import type { ServerWebSocket } from "bun";
2
- import { Hono } from "hono";
3
- import { upgradeWebSocket } from "hono/bun";
4
- import { type AuthContext, auth, getLocale } from "../server";
5
- import { logger } from "../services/logging";
6
- import {
7
- AI_LIVE_WS_TYPE,
8
- type AiInvalidation,
9
- AiInvalidationSchema,
10
- AiLiveClientMessageSchema,
11
- AiLiveCursorSchema,
12
- type AiLiveErrorCode,
13
- type AiLiveServerMessage,
14
- aiTurnEventMessage,
15
- } from "./live-events";
16
- import { type AiLiveMessages, aiLiveMessages } from "./live-messages";
17
- import { latestAiInvalidationCursor, liveAiInvalidations } from "./live-outbox";
18
- import type { AiStreamEvent } from "./protocol";
19
- import { aiConversations } from "./store";
20
- import { streamAiConversationEvents } from "./stream";
21
- import type { AiConversation } from "./types";
22
-
23
- const log = logger("ai:live-routes");
24
- const AUTH_REFRESH_INTERVAL_MS = 5_000;
25
- const MAX_CLIENT_MESSAGE_LENGTH = 8_000;
26
- const MAX_PENDING_MESSAGES = 8;
27
- const MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
28
-
29
- export const isAiLiveClientMessageFrame = (data: unknown): data is string =>
30
- typeof data === "string" && data.length <= MAX_CLIENT_MESSAGE_LENGTH;
31
-
32
- type LiveUser = { id: string };
33
- type WsPhase = "open" | "subscribed" | "closing";
34
-
35
- type WsContext = {
36
- socket: ServerWebSocket<unknown>;
37
- sessionToken: string | null;
38
- messages: AiLiveMessages;
39
- phase: WsPhase;
40
- userId: string | null;
41
- invalidationAbort: AbortController | null;
42
- turnAbort: AbortController | null;
43
- turnConversationId: string | null;
44
- authRefreshTimer: ReturnType<typeof setTimeout> | null;
45
- scopeVersion: string | null;
46
- };
47
-
48
- export type AiLiveRoutesConfig = {
49
- resolveLiveUser?: (sessionToken: string | null) => Promise<LiveUser | null>;
50
- resolveScopeVersion?: (userId: string) => Promise<string>;
51
- resolveConversation?: (conversationId: string, userId: string) => Promise<AiConversation | null>;
52
- streamConversation?: (input: { conversation: AiConversation; signal: AbortSignal }) => AsyncIterable<AiStreamEvent>;
53
- };
54
-
55
- export const resolveAiLiveSessionUser = async (
56
- sessionToken: string | null,
57
- authenticate: typeof auth.session.authenticate = auth.session.authenticate,
58
- ): Promise<LiveUser | null> => {
59
- if (!sessionToken) return null;
60
- const authenticated = await authenticate(sessionToken);
61
- return authenticated ? { id: authenticated.user.id } : null;
62
- };
63
-
64
- const isClosing = (ctx: WsContext): boolean => ctx.phase === "closing";
65
-
66
- export const isAiLiveSubscriptionCurrent = (state: Pick<WsContext, "phase" | "userId">, userId: string, signal: AbortSignal): boolean =>
67
- !signal.aborted && state.phase === "subscribed" && state.userId === userId;
68
-
69
- export const isAiTurnSubscriptionCurrent = (
70
- state: Pick<WsContext, "phase" | "turnConversationId">,
71
- conversationId: string,
72
- signal: AbortSignal,
73
- ): boolean => !signal.aborted && state.phase === "subscribed" && state.turnConversationId === conversationId;
74
-
75
- export const sendAiLiveMessage = (socket: ServerWebSocket<unknown>, message: AiLiveServerMessage): boolean => {
76
- try {
77
- const payload = JSON.stringify(message);
78
- socket.send(payload);
79
- // Bun 1.3 can report 0 after a larger frame already reached the peer.
80
- // The bounded socket queue is the reliable overload signal here.
81
- return socket.getBufferedAmount() <= MAX_BUFFERED_BYTES;
82
- } catch {
83
- return false;
84
- }
85
- };
86
-
87
- const stopInvalidationSubscription = (ctx: WsContext) => {
88
- ctx.invalidationAbort?.abort();
89
- ctx.invalidationAbort = null;
90
- };
91
-
92
- const stopTurnSubscription = (ctx: WsContext) => {
93
- ctx.turnAbort?.abort();
94
- ctx.turnAbort = null;
95
- ctx.turnConversationId = null;
96
- };
97
-
98
- const stopSubscriptions = (ctx: WsContext) => {
99
- stopInvalidationSubscription(ctx);
100
- stopTurnSubscription(ctx);
101
- if (ctx.authRefreshTimer) clearTimeout(ctx.authRefreshTimer);
102
- ctx.authRefreshTimer = null;
103
- ctx.userId = null;
104
- ctx.scopeVersion = null;
105
- };
106
-
107
- const closeWithError = (ctx: WsContext, code: AiLiveErrorCode, message: string, closeCode: number) => {
108
- if (isClosing(ctx)) return;
109
- ctx.phase = "closing";
110
- stopSubscriptions(ctx);
111
- sendAiLiveMessage(ctx.socket, { type: AI_LIVE_WS_TYPE.error, payload: { code, message } });
112
- ctx.socket.close(closeCode, code);
113
- };
114
-
115
- const revoke = (ctx: WsContext, code: "login_required" | "access_denied", message: string) => {
116
- if (isClosing(ctx)) return;
117
- ctx.phase = "closing";
118
- stopSubscriptions(ctx);
119
- sendAiLiveMessage(ctx.socket, { type: AI_LIVE_WS_TYPE.revoked, payload: { code, message } });
120
- ctx.socket.close(1008, code);
121
- };
122
-
123
- export const resolveAiLiveCursor = async (
124
- userId: string,
125
- fromCursor: string | null,
126
- recover: boolean,
127
- latest: (userId: string) => Promise<string> = latestAiInvalidationCursor,
128
- ): Promise<string> => AiLiveCursorSchema.parse(recover || !fromCursor ? await latest(userId) : fromCursor);
129
-
130
- export const parseAiLiveReplayEvent = (item: { cursor: unknown; data: unknown }): { cursor: string; event: AiInvalidation } | null => {
131
- const cursor = AiLiveCursorSchema.safeParse(item.cursor);
132
- const event = AiInvalidationSchema.safeParse(item.data);
133
- return cursor.success && event.success ? { cursor: cursor.data, event: event.data } : null;
134
- };
135
-
136
- const buildAiLiveRoutes = (config: AiLiveRoutesConfig = {}) => {
137
- const resolveLiveUser = config.resolveLiveUser ?? resolveAiLiveSessionUser;
138
- const resolveConversation =
139
- config.resolveConversation ??
140
- ((conversationId: string, userId: string) =>
141
- aiConversations.getConversationByShortId({ shortId: conversationId, ownerUserId: userId }));
142
- const streamConversation = config.streamConversation ?? streamAiConversationEvents;
143
-
144
- const currentUser = async (ctx: WsContext): Promise<LiveUser | null> => {
145
- const user = await resolveLiveUser(ctx.sessionToken);
146
- return user && (!ctx.userId || user.id === ctx.userId) ? user : null;
147
- };
148
-
149
- const startAuthRefresh = (ctx: WsContext, userId: string) => {
150
- if (ctx.authRefreshTimer) clearTimeout(ctx.authRefreshTimer);
151
- ctx.authRefreshTimer = setTimeout(async () => {
152
- if (ctx.phase !== "subscribed" || ctx.userId !== userId) return;
153
- try {
154
- if (!(await currentUser(ctx))) {
155
- revoke(ctx, "login_required", ctx.messages.loginRequired);
156
- return;
157
- }
158
- const activeConversationId = ctx.turnConversationId;
159
- if (activeConversationId && !(await resolveConversation(activeConversationId, userId))) {
160
- if (ctx.turnConversationId !== activeConversationId) {
161
- startAuthRefresh(ctx, userId);
162
- return;
163
- }
164
- const conversationId = activeConversationId;
165
- stopTurnSubscription(ctx);
166
- if (
167
- !sendAiLiveMessage(ctx.socket, {
168
- type: AI_LIVE_WS_TYPE.turnError,
169
- payload: { conversationId, code: "access_denied", message: ctx.messages.conversationAccessChanged },
170
- })
171
- ) {
172
- closeWithError(ctx, "backpressure", ctx.messages.capacityExceeded, 1013);
173
- return;
174
- }
175
- }
176
- if (config.resolveScopeVersion) {
177
- const version = await config.resolveScopeVersion(userId);
178
- if (ctx.phase !== "subscribed" || ctx.userId !== userId) return;
179
- if (ctx.scopeVersion !== null && version !== ctx.scopeVersion) {
180
- if (!sendAiLiveMessage(ctx.socket, { type: AI_LIVE_WS_TYPE.scopeChanged, payload: { at: new Date().toISOString() } })) {
181
- closeWithError(ctx, "backpressure", ctx.messages.capacityExceeded, 1013);
182
- return;
183
- }
184
- }
185
- ctx.scopeVersion = version;
186
- }
187
- startAuthRefresh(ctx, userId);
188
- } catch (error) {
189
- log.error("AI live authorization refresh failed", {
190
- userId,
191
- error: error instanceof Error ? error.message : String(error),
192
- });
193
- closeWithError(ctx, "internal_error", ctx.messages.authorizationRefreshFailed, 1011);
194
- }
195
- }, AUTH_REFRESH_INTERVAL_MS);
196
- };
197
-
198
- const startInvalidationStream = (ctx: WsContext, userId: string, after: string) => {
199
- ctx.invalidationAbort?.abort();
200
- const abort = new AbortController();
201
- ctx.invalidationAbort = abort;
202
- void (async () => {
203
- try {
204
- for await (const item of liveAiInvalidations({ userId, after, signal: abort.signal })) {
205
- if (!isAiLiveSubscriptionCurrent(ctx, userId, abort.signal)) break;
206
- if (!(await currentUser(ctx))) {
207
- revoke(ctx, "login_required", ctx.messages.loginRequired);
208
- return;
209
- }
210
- if (!isAiLiveSubscriptionCurrent(ctx, userId, abort.signal)) break;
211
- const replay = parseAiLiveReplayEvent(item);
212
- if (!replay) {
213
- closeWithError(ctx, "stream_failed", ctx.messages.invalidStreamData, 1011);
214
- return;
215
- }
216
- if (!sendAiLiveMessage(ctx.socket, { type: AI_LIVE_WS_TYPE.event, payload: replay })) {
217
- closeWithError(ctx, "backpressure", ctx.messages.capacityExceeded, 1013);
218
- return;
219
- }
220
- }
221
- if (isAiLiveSubscriptionCurrent(ctx, userId, abort.signal))
222
- closeWithError(ctx, "stream_failed", ctx.messages.eventStreamEnded, 1012);
223
- } catch (error) {
224
- if (abort.signal.aborted || isClosing(ctx)) return;
225
- log.error("AI live event stream failed", {
226
- userId,
227
- error: error instanceof Error ? error.message : String(error),
228
- });
229
- closeWithError(ctx, "stream_failed", ctx.messages.eventStreamFailed, 1012);
230
- } finally {
231
- if (ctx.invalidationAbort === abort) ctx.invalidationAbort = null;
232
- }
233
- })();
234
- };
235
-
236
- const handleLiveSubscribe = async (ctx: WsContext, fromCursor: string | null, recover: boolean) => {
237
- if (isClosing(ctx)) return;
238
- const user = await currentUser(ctx);
239
- if (isClosing(ctx)) return;
240
- if (!user) {
241
- revoke(ctx, "login_required", ctx.messages.loginRequired);
242
- return;
243
- }
244
- let cursor: string;
245
- let scopeVersion: string | null;
246
- try {
247
- cursor = await resolveAiLiveCursor(user.id, fromCursor, recover);
248
- scopeVersion = config.resolveScopeVersion ? await config.resolveScopeVersion(user.id) : null;
249
- } catch (error) {
250
- log.error("AI live subscription setup failed", {
251
- userId: user.id,
252
- error: error instanceof Error ? error.message : String(error),
253
- });
254
- closeWithError(ctx, "stream_failed", ctx.messages.subscriptionFailed, 1012);
255
- return;
256
- }
257
- if (isClosing(ctx)) return;
258
- stopInvalidationSubscription(ctx);
259
- ctx.phase = "subscribed";
260
- ctx.userId = user.id;
261
- ctx.scopeVersion = scopeVersion;
262
- if (!sendAiLiveMessage(ctx.socket, { type: AI_LIVE_WS_TYPE.ready, payload: { cursor, recovered: recover } })) {
263
- closeWithError(ctx, "backpressure", ctx.messages.capacityExceeded, 1013);
264
- return;
265
- }
266
- startInvalidationStream(ctx, user.id, cursor);
267
- startAuthRefresh(ctx, user.id);
268
- };
269
-
270
- const startTurnStream = (ctx: WsContext, conversation: AiConversation) => {
271
- stopTurnSubscription(ctx);
272
- const abort = new AbortController();
273
- const conversationId = conversation.shortId;
274
- ctx.turnAbort = abort;
275
- ctx.turnConversationId = conversationId;
276
- void (async () => {
277
- try {
278
- for await (const event of streamConversation({ conversation, signal: abort.signal })) {
279
- if (!isAiTurnSubscriptionCurrent(ctx, conversationId, abort.signal)) return;
280
- if (!sendAiLiveMessage(ctx.socket, aiTurnEventMessage(conversationId, event))) {
281
- closeWithError(ctx, "backpressure", ctx.messages.capacityExceeded, 1013);
282
- return;
283
- }
284
- }
285
- if (isAiTurnSubscriptionCurrent(ctx, conversationId, abort.signal)) {
286
- closeWithError(ctx, "stream_failed", ctx.messages.conversationStreamEnded, 1012);
287
- }
288
- } catch (error) {
289
- if (abort.signal.aborted || isClosing(ctx)) return;
290
- log.error("AI conversation stream failed", {
291
- conversationId: conversation.id,
292
- error: error instanceof Error ? error.message : String(error),
293
- });
294
- closeWithError(ctx, "stream_failed", ctx.messages.conversationStreamFailed, 1012);
295
- } finally {
296
- if (ctx.turnAbort === abort) {
297
- ctx.turnAbort = null;
298
- ctx.turnConversationId = null;
299
- }
300
- }
301
- })();
302
- };
303
-
304
- const handleTurnSubscribe = async (ctx: WsContext, conversationId: string) => {
305
- if (ctx.phase !== "subscribed" || !ctx.userId) {
306
- closeWithError(ctx, "invalid_message", ctx.messages.liveSubscriptionRequired, 1008);
307
- return;
308
- }
309
- const conversation = await resolveConversation(conversationId, ctx.userId);
310
- if (isClosing(ctx)) return;
311
- if (!conversation) {
312
- stopTurnSubscription(ctx);
313
- if (
314
- !sendAiLiveMessage(ctx.socket, {
315
- type: AI_LIVE_WS_TYPE.turnError,
316
- payload: { conversationId, code: "not_found", message: ctx.messages.conversationNotFound },
317
- })
318
- ) {
319
- closeWithError(ctx, "backpressure", ctx.messages.capacityExceeded, 1013);
320
- }
321
- return;
322
- }
323
- startTurnStream(ctx, conversation);
324
- };
325
-
326
- const handleTurnUnsubscribe = (ctx: WsContext, conversationId: string) => {
327
- if (ctx.turnConversationId === conversationId) stopTurnSubscription(ctx);
328
- };
329
-
330
- const handleMessage = async (ctx: WsContext, raw: string) => {
331
- let parsed: unknown;
332
- try {
333
- parsed = JSON.parse(raw);
334
- } catch {
335
- closeWithError(ctx, "invalid_json", ctx.messages.invalidJson, 1008);
336
- return;
337
- }
338
- const message = AiLiveClientMessageSchema.safeParse(parsed);
339
- if (!message.success) {
340
- closeWithError(ctx, "invalid_message", ctx.messages.invalidSubscription, 1008);
341
- return;
342
- }
343
- if (message.data.type === AI_LIVE_WS_TYPE.subscribe) {
344
- await handleLiveSubscribe(ctx, message.data.payload.fromCursor, message.data.payload.recover);
345
- return;
346
- }
347
- if (message.data.type === AI_LIVE_WS_TYPE.turnSubscribe) {
348
- await handleTurnSubscribe(ctx, message.data.payload.conversationId);
349
- return;
350
- }
351
- handleTurnUnsubscribe(ctx, message.data.payload.conversationId);
352
- };
353
-
354
- return new Hono<AuthContext>().get(
355
- "/",
356
- upgradeWebSocket((c) => {
357
- const sessionToken = auth.session.getToken(c);
358
- const messages = aiLiveMessages(getLocale(c));
359
- let ctx: WsContext | null = null;
360
- let processing: Promise<void> = Promise.resolve();
361
- let pendingMessages = 0;
362
- return {
363
- onOpen(_, ws) {
364
- ctx = {
365
- socket: ws.raw as ServerWebSocket<unknown>,
366
- sessionToken,
367
- messages,
368
- phase: "open",
369
- userId: null,
370
- invalidationAbort: null,
371
- turnAbort: null,
372
- turnConversationId: null,
373
- authRefreshTimer: null,
374
- scopeVersion: null,
375
- };
376
- },
377
- onMessage(event) {
378
- if (!ctx || isClosing(ctx)) return;
379
- if (!isAiLiveClientMessageFrame(event.data)) {
380
- closeWithError(ctx, "invalid_message", ctx.messages.invalidSubscription, 1008);
381
- return;
382
- }
383
- if (pendingMessages >= MAX_PENDING_MESSAGES) {
384
- closeWithError(ctx, "backpressure", ctx.messages.tooManyMessages, 1013);
385
- return;
386
- }
387
- const current = ctx;
388
- const raw = event.data;
389
- pendingMessages++;
390
- processing = processing
391
- .then(() => handleMessage(current, raw))
392
- .catch((error) => {
393
- log.error("AI live message handling failed", { error: error instanceof Error ? error.message : String(error) });
394
- closeWithError(current, "internal_error", current.messages.subscriptionFailed, 1011);
395
- })
396
- .finally(() => {
397
- pendingMessages = Math.max(0, pendingMessages - 1);
398
- });
399
- },
400
- async onClose() {
401
- if (!ctx) return;
402
- ctx.phase = "closing";
403
- stopSubscriptions(ctx);
404
- await processing.catch(() => undefined);
405
- },
406
- };
407
- }),
408
- );
409
- };
410
-
411
- export const aiLiveRoutes = buildAiLiveRoutes();
412
- export type AiLiveRoutes = typeof aiLiveRoutes;
@@ -1,108 +0,0 @@
1
- /**
2
- * Info Blocks Extension for Marked
3
- *
4
- * Renders custom info blocks with syntax:
5
- * :::note
6
- * Content here
7
- * :::
8
- *
9
- * The type follows the colons directly — `::: note` with a space does not
10
- * match. An optional argument after the type overrides the label:
11
- * `:::warning Before deleting`.
12
- *
13
- * Supported types: note, info, success, warning, danger
14
- *
15
- * `notices: "minimal"` renders only the tone colour: no icon and no automatic
16
- * label. The type name stays available to screen readers, and an explicit
17
- * title is still shown.
18
- */
19
-
20
- import { NOTICE_CARD_CLASSES, NOTICE_CARD_ICONS, type NoticeTone } from "@k2b/ui";
21
- import type { MarkedExtension, Tokens } from "marked";
22
- import { escapeHtml } from "../shared";
23
-
24
- type BlockType = "note" | "info" | "success" | "warning" | "danger";
25
-
26
- const blockConfig: Record<BlockType, { label: string; tone: NoticeTone }> = {
27
- note: {
28
- label: "Note",
29
- tone: "neutral",
30
- },
31
- info: {
32
- label: "Info",
33
- tone: "info",
34
- },
35
- success: {
36
- label: "Success",
37
- tone: "success",
38
- },
39
- warning: {
40
- label: "Warning",
41
- tone: "warning",
42
- },
43
- danger: {
44
- label: "Danger",
45
- tone: "danger",
46
- },
47
- };
48
-
49
- const renderInlineContent = (content: string): string => {
50
- return content
51
- .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
52
- .replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>")
53
- .replace(/`([^`]+)`/g, '<code class="bg-black/10 dark:bg-white/10 px-1 py-0.5 rounded text-sm">$1</code>')
54
- .replace(/\n/g, "<br>");
55
- };
56
-
57
- export type NoticeStyle = "card" | "minimal";
58
-
59
- export function infoBlocksExtension(notices: NoticeStyle = "card"): MarkedExtension {
60
- return {
61
- extensions: [
62
- {
63
- name: "infoBlock",
64
- level: "block",
65
- start(src: string) {
66
- return src.match(/^:::/)?.index;
67
- },
68
- tokenizer(src: string) {
69
- const match = src.match(/^:::(\w+)(?:[ \t]+([^\n]+))?\s*\n([\s\S]*?)\n:::/);
70
- if (!match) return undefined;
71
-
72
- const typeStr = match[1]?.toLowerCase() as BlockType;
73
- if (!blockConfig[typeStr]) return undefined;
74
-
75
- return {
76
- type: "infoBlock",
77
- raw: match[0],
78
- blockType: typeStr,
79
- title: match[2]?.trim(),
80
- content: match[3]?.trim() ?? "",
81
- };
82
- },
83
- renderer(token: Tokens.Generic) {
84
- const blockType = token.blockType as BlockType;
85
- const config = blockConfig[blockType];
86
- const content = escapeHtml(token.content as string);
87
- const renderedContent = renderInlineContent(content);
88
-
89
- if (notices === "minimal") {
90
- const title = token.title ? `<p class="${NOTICE_CARD_CLASSES.title}">${escapeHtml(token.title as string)}</p>` : "";
91
- return `<aside class="${NOTICE_CARD_CLASSES.root}" data-tone="${config.tone}" role="note"><span class="sr-only">${config.label}: </span>${title}<div class="${NOTICE_CARD_CLASSES.body}">${renderedContent}</div></aside>`;
92
- }
93
-
94
- const title = escapeHtml((token.title as string | undefined) ?? config.label);
95
- return `<aside class="${NOTICE_CARD_CLASSES.root}" data-tone="${config.tone}">
96
- <div class="${NOTICE_CARD_CLASSES.inner}">
97
- <i class="${NOTICE_CARD_ICONS[config.tone]} ${NOTICE_CARD_CLASSES.icon}" aria-hidden="true"></i>
98
- <div class="${NOTICE_CARD_CLASSES.content}">
99
- <p class="${NOTICE_CARD_CLASSES.title}">${title}</p>
100
- <div class="${NOTICE_CARD_CLASSES.body}">${renderedContent}</div>
101
- </div>
102
- </div>
103
- </aside>`;
104
- },
105
- },
106
- ],
107
- };
108
- }