@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
@@ -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(async (c) => {
357
- const sessionToken = await auth.session.resolveToken(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,79 +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
- * Every block renders as the calm NoticeCard: the tone tints the surface, the
16
- * text stays neutral, and there is no icon. An explicit title is the visible
17
- * heading and names the block, as on a NoticeCard; without one the type name
18
- * is announced to screen readers only. Email drops the visually hidden class,
19
- * so a titled block must not carry the type name as well.
20
- */
21
-
22
- import { NOTICE_CARD_CLASSES, type NoticeTone } from "@k2b/ui";
23
- import type { MarkedExtension, Tokens } from "marked";
24
- import { escapeHtml } from "../shared";
25
-
26
- type BlockType = "note" | "info" | "success" | "warning" | "danger";
27
-
28
- const blockConfig: Record<BlockType, { label: string; tone: NoticeTone }> = {
29
- note: { label: "Note", tone: "neutral" },
30
- info: { label: "Info", tone: "info" },
31
- success: { label: "Success", tone: "success" },
32
- warning: { label: "Warning", tone: "warning" },
33
- danger: { label: "Danger", tone: "danger" },
34
- };
35
-
36
- const renderInlineContent = (content: string): string => {
37
- return content
38
- .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
39
- .replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "<em>$1</em>")
40
- .replace(/`([^`]+)`/g, '<code class="bg-black/10 dark:bg-white/10 px-1 py-0.5 rounded text-sm">$1</code>')
41
- .replace(/\n/g, "<br>");
42
- };
43
-
44
- export function infoBlocksExtension(): MarkedExtension {
45
- return {
46
- extensions: [
47
- {
48
- name: "infoBlock",
49
- level: "block",
50
- start(src: string) {
51
- return src.match(/^:::/)?.index;
52
- },
53
- tokenizer(src: string) {
54
- const match = src.match(/^:::(\w+)(?:[ \t]+([^\n]+))?\s*\n([\s\S]*?)\n:::/);
55
- if (!match) return undefined;
56
-
57
- const typeStr = match[1]?.toLowerCase() as BlockType;
58
- if (!blockConfig[typeStr]) return undefined;
59
-
60
- return {
61
- type: "infoBlock",
62
- raw: match[0],
63
- blockType: typeStr,
64
- title: match[2]?.trim(),
65
- content: match[3]?.trim() ?? "",
66
- };
67
- },
68
- renderer(token: Tokens.Generic) {
69
- const config = blockConfig[token.blockType as BlockType];
70
- const body = renderInlineContent(escapeHtml(token.content as string));
71
- const name = token.title
72
- ? `<p class="${NOTICE_CARD_CLASSES.title}">${escapeHtml(token.title as string)}</p>`
73
- : `<span class="sr-only">${config.label}: </span>`;
74
- return `<aside class="${NOTICE_CARD_CLASSES.root}" data-tone="${config.tone}" role="note">${name}<div class="${NOTICE_CARD_CLASSES.body}">${body}</div></aside>`;
75
- },
76
- },
77
- ],
78
- };
79
- }