@btst/stack 2.2.0 → 2.4.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/dist/packages/stack/src/client/components/compose.cjs +1 -2
- package/dist/packages/stack/src/client/components/compose.mjs +1 -2
- package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.cjs +71 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.mjs +68 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/plugin.cjs +54 -7
- package/dist/packages/stack/src/plugins/ai-chat/api/plugin.mjs +54 -7
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.cjs +2 -2
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.mjs +2 -2
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.cjs +89 -22
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +90 -23
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.cjs +110 -33
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.mjs +112 -35
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.cjs +1 -1
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.mjs +1 -1
- package/dist/packages/stack/src/plugins/ai-chat/schemas.cjs +17 -1
- package/dist/packages/stack/src/plugins/ai-chat/schemas.mjs +17 -1
- package/dist/packages/stack/src/plugins/blog/api/plugin.cjs +52 -1
- package/dist/packages/stack/src/plugins/blog/api/plugin.mjs +52 -1
- package/dist/packages/stack/src/plugins/blog/api/query-key-defs.cjs +18 -0
- package/dist/packages/stack/src/plugins/blog/api/query-key-defs.mjs +15 -0
- package/dist/packages/stack/src/plugins/blog/api/serializers.cjs +21 -0
- package/dist/packages/stack/src/plugins/blog/api/serializers.mjs +18 -0
- package/dist/packages/stack/src/plugins/blog/client/components/forms/post-forms.cjs +15 -2
- package/dist/packages/stack/src/plugins/blog/client/components/forms/post-forms.mjs +16 -3
- package/dist/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.cjs +24 -1
- package/dist/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.mjs +24 -1
- package/dist/packages/stack/src/plugins/blog/client/components/pages/fill-blog-form-handler.cjs +26 -0
- package/dist/packages/stack/src/plugins/blog/client/components/pages/fill-blog-form-handler.mjs +24 -0
- package/dist/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.cjs +30 -1
- package/dist/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.mjs +30 -1
- package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.cjs +18 -0
- package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.mjs +18 -0
- package/dist/packages/stack/src/plugins/blog/client/plugin.cjs +15 -0
- package/dist/packages/stack/src/plugins/blog/client/plugin.mjs +16 -1
- package/dist/packages/stack/src/plugins/cms/api/getters.cjs +10 -0
- package/dist/packages/stack/src/plugins/cms/api/getters.mjs +10 -1
- package/dist/packages/stack/src/plugins/cms/api/mutations.cjs +48 -0
- package/dist/packages/stack/src/plugins/cms/api/mutations.mjs +46 -0
- package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +75 -0
- package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +76 -1
- package/dist/packages/stack/src/plugins/cms/api/query-key-defs.cjs +29 -0
- package/dist/packages/stack/src/plugins/cms/api/query-key-defs.mjs +26 -0
- package/dist/packages/stack/src/plugins/cms/client/plugin.cjs +15 -0
- package/dist/packages/stack/src/plugins/cms/client/plugin.mjs +16 -1
- package/dist/packages/stack/src/plugins/form-builder/api/getters.cjs +9 -0
- package/dist/packages/stack/src/plugins/form-builder/api/getters.mjs +9 -1
- package/dist/packages/stack/src/plugins/form-builder/api/plugin.cjs +62 -1
- package/dist/packages/stack/src/plugins/form-builder/api/plugin.mjs +63 -2
- package/dist/packages/stack/src/plugins/form-builder/api/query-key-defs.cjs +37 -0
- package/dist/packages/stack/src/plugins/form-builder/api/query-key-defs.mjs +33 -0
- package/dist/packages/stack/src/plugins/form-builder/client/plugin.cjs +15 -0
- package/dist/packages/stack/src/plugins/form-builder/client/plugin.mjs +16 -1
- package/dist/packages/stack/src/plugins/kanban/api/mutations.cjs +91 -0
- package/dist/packages/stack/src/plugins/kanban/api/mutations.mjs +87 -0
- package/dist/packages/stack/src/plugins/kanban/api/plugin.cjs +34 -1
- package/dist/packages/stack/src/plugins/kanban/api/plugin.mjs +34 -1
- package/dist/packages/stack/src/plugins/kanban/api/query-key-defs.cjs +26 -0
- package/dist/packages/stack/src/plugins/kanban/api/query-key-defs.mjs +23 -0
- package/dist/packages/stack/src/plugins/kanban/api/serializers.cjs +30 -0
- package/dist/packages/stack/src/plugins/kanban/api/serializers.mjs +26 -0
- package/dist/packages/stack/src/plugins/kanban/client/hooks/kanban-hooks.cjs +7 -3
- package/dist/packages/stack/src/plugins/kanban/client/hooks/kanban-hooks.mjs +7 -3
- package/dist/packages/stack/src/plugins/kanban/client/plugin.cjs +10 -0
- package/dist/packages/stack/src/plugins/kanban/client/plugin.mjs +11 -1
- package/dist/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.cjs +89 -0
- package/dist/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.mjs +89 -0
- package/dist/packages/stack/src/plugins/utils.cjs +6 -0
- package/dist/packages/stack/src/plugins/utils.mjs +6 -1
- package/dist/plugins/ai-chat/api/index.d.cts +1 -1
- package/dist/plugins/ai-chat/api/index.d.mts +1 -1
- package/dist/plugins/ai-chat/api/index.d.ts +1 -1
- package/dist/plugins/ai-chat/client/components/index.d.cts +1 -1
- package/dist/plugins/ai-chat/client/components/index.d.mts +1 -1
- package/dist/plugins/ai-chat/client/components/index.d.ts +1 -1
- package/dist/plugins/ai-chat/client/context/page-ai-context.cjs +92 -0
- package/dist/plugins/ai-chat/client/context/page-ai-context.d.cts +84 -0
- package/dist/plugins/ai-chat/client/context/page-ai-context.d.mts +84 -0
- package/dist/plugins/ai-chat/client/context/page-ai-context.d.ts +84 -0
- package/dist/plugins/ai-chat/client/context/page-ai-context.mjs +88 -0
- package/dist/plugins/ai-chat/client/hooks/index.d.cts +1 -1
- package/dist/plugins/ai-chat/client/hooks/index.d.mts +1 -1
- package/dist/plugins/ai-chat/client/hooks/index.d.ts +1 -1
- package/dist/plugins/ai-chat/client/index.d.cts +2 -2
- package/dist/plugins/ai-chat/client/index.d.mts +2 -2
- package/dist/plugins/ai-chat/client/index.d.ts +2 -2
- package/dist/plugins/ai-chat/query-keys.d.cts +1 -1
- package/dist/plugins/ai-chat/query-keys.d.mts +1 -1
- package/dist/plugins/ai-chat/query-keys.d.ts +1 -1
- package/dist/plugins/blog/api/index.cjs +5 -0
- package/dist/plugins/blog/api/index.d.cts +19 -4
- package/dist/plugins/blog/api/index.d.mts +19 -4
- package/dist/plugins/blog/api/index.d.ts +19 -4
- package/dist/plugins/blog/api/index.mjs +2 -0
- package/dist/plugins/blog/client/hooks/index.d.cts +3 -3
- package/dist/plugins/blog/client/hooks/index.d.mts +3 -3
- package/dist/plugins/blog/client/hooks/index.d.ts +3 -3
- package/dist/plugins/blog/client/index.d.cts +1 -1
- package/dist/plugins/blog/client/index.d.mts +1 -1
- package/dist/plugins/blog/client/index.d.ts +1 -1
- package/dist/plugins/blog/query-keys.cjs +6 -5
- package/dist/plugins/blog/query-keys.d.cts +8 -387
- package/dist/plugins/blog/query-keys.d.mts +8 -387
- package/dist/plugins/blog/query-keys.d.ts +8 -387
- package/dist/plugins/blog/query-keys.mjs +6 -5
- package/dist/plugins/client/index.cjs +1 -0
- package/dist/plugins/client/index.d.cts +8 -1
- package/dist/plugins/client/index.d.mts +8 -1
- package/dist/plugins/client/index.d.ts +8 -1
- package/dist/plugins/client/index.mjs +1 -1
- package/dist/plugins/cms/api/index.cjs +8 -0
- package/dist/plugins/cms/api/index.d.cts +7 -219
- package/dist/plugins/cms/api/index.d.mts +7 -219
- package/dist/plugins/cms/api/index.d.ts +7 -219
- package/dist/plugins/cms/api/index.mjs +3 -1
- package/dist/plugins/cms/client/hooks/index.d.cts +1 -1
- package/dist/plugins/cms/client/hooks/index.d.mts +1 -1
- package/dist/plugins/cms/client/hooks/index.d.ts +1 -1
- package/dist/plugins/cms/query-keys.cjs +2 -1
- package/dist/plugins/cms/query-keys.d.cts +5 -9
- package/dist/plugins/cms/query-keys.d.mts +5 -9
- package/dist/plugins/cms/query-keys.d.ts +5 -9
- package/dist/plugins/cms/query-keys.mjs +2 -1
- package/dist/plugins/form-builder/api/index.cjs +6 -0
- package/dist/plugins/form-builder/api/index.d.cts +7 -211
- package/dist/plugins/form-builder/api/index.d.mts +7 -211
- package/dist/plugins/form-builder/api/index.d.ts +7 -211
- package/dist/plugins/form-builder/api/index.mjs +2 -1
- package/dist/plugins/form-builder/client/components/index.d.cts +1 -1
- package/dist/plugins/form-builder/client/components/index.d.mts +1 -1
- package/dist/plugins/form-builder/client/components/index.d.ts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
- package/dist/plugins/form-builder/query-keys.cjs +3 -2
- package/dist/plugins/form-builder/query-keys.d.cts +6 -6
- package/dist/plugins/form-builder/query-keys.d.mts +6 -6
- package/dist/plugins/form-builder/query-keys.d.ts +6 -6
- package/dist/plugins/form-builder/query-keys.mjs +3 -2
- package/dist/plugins/kanban/api/index.cjs +10 -0
- package/dist/plugins/kanban/api/index.d.cts +17 -392
- package/dist/plugins/kanban/api/index.d.mts +17 -392
- package/dist/plugins/kanban/api/index.d.ts +17 -392
- package/dist/plugins/kanban/api/index.mjs +3 -0
- package/dist/plugins/kanban/client/components/index.d.cts +1 -1
- package/dist/plugins/kanban/client/components/index.d.mts +1 -1
- package/dist/plugins/kanban/client/components/index.d.ts +1 -1
- package/dist/plugins/kanban/client/hooks/index.d.cts +1 -1
- package/dist/plugins/kanban/client/hooks/index.d.mts +1 -1
- package/dist/plugins/kanban/client/hooks/index.d.ts +1 -1
- package/dist/plugins/kanban/client/index.d.cts +1 -1
- package/dist/plugins/kanban/client/index.d.mts +1 -1
- package/dist/plugins/kanban/client/index.d.ts +1 -1
- package/dist/plugins/kanban/query-keys.cjs +2 -9
- package/dist/plugins/kanban/query-keys.d.cts +4 -16
- package/dist/plugins/kanban/query-keys.d.mts +4 -16
- package/dist/plugins/kanban/query-keys.d.ts +4 -16
- package/dist/plugins/kanban/query-keys.mjs +2 -9
- package/dist/plugins/ui-builder/index.d.cts +1 -1
- package/dist/plugins/ui-builder/index.d.mts +1 -1
- package/dist/plugins/ui-builder/index.d.ts +1 -1
- package/dist/shared/stack.B7ONvlD_.d.mts +293 -0
- package/dist/shared/{stack.BeSm90va.d.ts → stack.BEn34wW6.d.ts} +60 -2
- package/dist/shared/stack.BUkC2EsZ.d.cts +327 -0
- package/dist/shared/{stack.DaOcgmrM.d.ts → stack.BV9hnvu4.d.cts} +31 -7
- package/dist/shared/{stack.DaOcgmrM.d.cts → stack.BV9hnvu4.d.mts} +31 -7
- package/dist/shared/{stack.DaOcgmrM.d.mts → stack.BV9hnvu4.d.ts} +31 -7
- package/dist/shared/stack.BepFXT3w.d.mts +500 -0
- package/dist/shared/stack.CL8ts1Mu.d.ts +419 -0
- package/dist/shared/{stack.CXjzTMsb.d.cts → stack.CVDTkMoO.d.cts} +7 -1
- package/dist/shared/{stack.CXjzTMsb.d.mts → stack.CVDTkMoO.d.mts} +7 -1
- package/dist/shared/{stack.CXjzTMsb.d.ts → stack.CVDTkMoO.d.ts} +7 -1
- package/dist/shared/stack.CczspVn2.d.mts +327 -0
- package/dist/shared/stack.CgWzG5jH.d.ts +500 -0
- package/dist/shared/stack.D3GB6wKv.d.cts +500 -0
- package/dist/shared/stack.DASmUVjX.d.ts +327 -0
- package/dist/shared/{stack.QD1y_7NY.d.cts → stack.DJaKVY7v.d.cts} +1 -1
- package/dist/shared/{stack.QD1y_7NY.d.mts → stack.DJaKVY7v.d.mts} +1 -1
- package/dist/shared/{stack.QD1y_7NY.d.ts → stack.DJaKVY7v.d.ts} +1 -1
- package/dist/shared/{stack.Dg09R0oB.d.mts → stack.DTDxgFj8.d.mts} +60 -2
- package/dist/shared/{stack.CMh_EdxW.d.cts → stack.DWoCZff7.d.cts} +60 -2
- package/dist/shared/{stack.CIrIsc-A.d.cts → stack.DdI5W6MB.d.cts} +7 -1
- package/dist/shared/{stack.CIrIsc-A.d.mts → stack.DdI5W6MB.d.mts} +7 -1
- package/dist/shared/{stack.CIrIsc-A.d.ts → stack.DdI5W6MB.d.ts} +7 -1
- package/dist/shared/stack.Dk5r4W1F.d.mts +419 -0
- package/dist/shared/stack.Kq2-QzOC.d.ts +293 -0
- package/dist/shared/stack.heOA9gzA.d.cts +419 -0
- package/dist/shared/stack.kcdnD4gA.d.cts +293 -0
- package/package.json +16 -3
- package/src/client/components/compose.tsx +7 -4
- package/src/plugins/ai-chat/api/page-tools.ts +111 -0
- package/src/plugins/ai-chat/api/plugin.ts +180 -9
- package/src/plugins/ai-chat/client/components/chat-input.tsx +2 -2
- package/src/plugins/ai-chat/client/components/chat-interface.tsx +154 -58
- package/src/plugins/ai-chat/client/components/chat-layout.tsx +166 -32
- package/src/plugins/ai-chat/client/components/chat-sidebar.tsx +1 -1
- package/src/plugins/ai-chat/client/context/page-ai-context.tsx +240 -0
- package/src/plugins/ai-chat/schemas.ts +16 -0
- package/src/plugins/blog/api/index.ts +2 -0
- package/src/plugins/blog/api/plugin.ts +85 -0
- package/src/plugins/blog/api/query-key-defs.ts +46 -0
- package/src/plugins/blog/api/serializers.ts +27 -0
- package/src/plugins/blog/client/components/forms/post-forms.tsx +29 -2
- package/src/plugins/blog/client/components/pages/edit-post-page.internal.tsx +28 -0
- package/src/plugins/blog/client/components/pages/fill-blog-form-handler.ts +38 -0
- package/src/plugins/blog/client/components/pages/new-post-page.internal.tsx +33 -1
- package/src/plugins/blog/client/components/pages/post-page.internal.tsx +20 -0
- package/src/plugins/blog/client/plugin.tsx +19 -0
- package/src/plugins/blog/query-keys.ts +5 -7
- package/src/plugins/client/index.ts +1 -1
- package/src/plugins/cms/api/getters.ts +24 -0
- package/src/plugins/cms/api/index.ts +14 -1
- package/src/plugins/cms/api/mutations.ts +84 -0
- package/src/plugins/cms/api/plugin.ts +114 -0
- package/src/plugins/cms/api/query-key-defs.ts +53 -0
- package/src/plugins/cms/api/serializers.ts +12 -0
- package/src/plugins/cms/client/plugin.tsx +19 -0
- package/src/plugins/cms/query-keys.ts +2 -1
- package/src/plugins/form-builder/api/getters.ts +23 -0
- package/src/plugins/form-builder/api/index.ts +15 -2
- package/src/plugins/form-builder/api/plugin.ts +91 -0
- package/src/plugins/form-builder/api/query-key-defs.ts +79 -0
- package/src/plugins/form-builder/api/serializers.ts +12 -0
- package/src/plugins/form-builder/client/plugin.tsx +19 -0
- package/src/plugins/form-builder/query-keys.ts +6 -2
- package/src/plugins/kanban/api/index.ts +9 -0
- package/src/plugins/kanban/api/mutations.ts +169 -0
- package/src/plugins/kanban/api/plugin.ts +61 -0
- package/src/plugins/kanban/api/query-key-defs.ts +54 -0
- package/src/plugins/kanban/api/serializers.ts +49 -0
- package/src/plugins/kanban/client/hooks/kanban-hooks.tsx +4 -0
- package/src/plugins/kanban/client/plugin.tsx +13 -0
- package/src/plugins/kanban/query-keys.ts +2 -9
- package/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.tsx +132 -0
- package/src/plugins/utils.ts +19 -0
- package/dist/shared/{stack.BkYlUT_8.d.cts → stack.BQmuNl5p.d.cts} +6 -6
- package/dist/shared/{stack.BkYlUT_8.d.mts → stack.BQmuNl5p.d.mts} +6 -6
- package/dist/shared/{stack.BkYlUT_8.d.ts → stack.BQmuNl5p.d.ts} +6 -6
|
@@ -17,6 +17,10 @@ import {
|
|
|
17
17
|
} from "../schemas";
|
|
18
18
|
import type { Conversation, ConversationWithMessages, Message } from "../types";
|
|
19
19
|
import { getAllConversations, getConversationById } from "./getters";
|
|
20
|
+
import {
|
|
21
|
+
BUILT_IN_PAGE_TOOL_ROUTE_ALLOWLIST,
|
|
22
|
+
BUILT_IN_PAGE_TOOL_SCHEMAS,
|
|
23
|
+
} from "./page-tools";
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
26
|
* Context passed to AI Chat API hooks
|
|
@@ -98,6 +102,23 @@ export interface AiChatBackendHooks {
|
|
|
98
102
|
context: ChatApiContext,
|
|
99
103
|
) => Promise<boolean> | boolean;
|
|
100
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Called after the structural routeName/allowlist validation, with the list
|
|
107
|
+
* of tool names that passed. Return a filtered subset to further restrict
|
|
108
|
+
* which tools the LLM sees, or return [] to suppress all page tools.
|
|
109
|
+
* Throw an Error to abort the entire chat request with a 403 response.
|
|
110
|
+
* Not called when no tools passed the structural validation step.
|
|
111
|
+
*
|
|
112
|
+
* @param toolNames - Names that passed the routeName allowlist check
|
|
113
|
+
* @param routeName - routeName claimed by the request (may be undefined)
|
|
114
|
+
* @param context - Full request context (headers, body, etc.)
|
|
115
|
+
*/
|
|
116
|
+
onBeforeToolsActivated?: (
|
|
117
|
+
toolNames: string[],
|
|
118
|
+
routeName: string | undefined,
|
|
119
|
+
context: ChatApiContext,
|
|
120
|
+
) => Promise<string[]> | string[];
|
|
121
|
+
|
|
101
122
|
// ============== Lifecycle Hooks ==============
|
|
102
123
|
|
|
103
124
|
/**
|
|
@@ -232,6 +253,32 @@ export type AiChatMode = "authenticated" | "public";
|
|
|
232
253
|
/**
|
|
233
254
|
* Configuration for AI Chat backend plugin
|
|
234
255
|
*/
|
|
256
|
+
/**
|
|
257
|
+
* Extracts only the literal (non-index-signature) keys from a type.
|
|
258
|
+
* For `Record<string, T>` this resolves to `never`, so collision checks are
|
|
259
|
+
* skipped when the tools map is typed with a broad string index.
|
|
260
|
+
*/
|
|
261
|
+
type KnownKeys<T> = {
|
|
262
|
+
[K in keyof T]: string extends K ? never : K;
|
|
263
|
+
}[keyof T];
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Ensures `TClientTools` has no keys that are also literal keys in `TTools`.
|
|
267
|
+
* Colliding keys are mapped to `never`, which produces a compile-time error
|
|
268
|
+
* at the point of the duplicate key. When `TTools` uses a string index
|
|
269
|
+
* signature the check is skipped to avoid false positives.
|
|
270
|
+
*/
|
|
271
|
+
type NoKeyCollision<
|
|
272
|
+
TTools,
|
|
273
|
+
TClientTools extends Record<string, Tool>,
|
|
274
|
+
> = KnownKeys<TTools> & keyof TClientTools extends never
|
|
275
|
+
? TClientTools
|
|
276
|
+
: {
|
|
277
|
+
[K in keyof TClientTools]: K extends KnownKeys<TTools>
|
|
278
|
+
? never // duplicate of a server-side tool — remove from clientToolSchemas
|
|
279
|
+
: TClientTools[K];
|
|
280
|
+
};
|
|
281
|
+
|
|
235
282
|
export interface AiChatBackendConfig {
|
|
236
283
|
/**
|
|
237
284
|
* The language model to use for chat completions.
|
|
@@ -269,6 +316,31 @@ export interface AiChatBackendConfig {
|
|
|
269
316
|
*/
|
|
270
317
|
tools?: Record<string, Tool>;
|
|
271
318
|
|
|
319
|
+
/**
|
|
320
|
+
* Enable route-aware page tools.
|
|
321
|
+
* When true, the server will include tool schemas for client-side page tools
|
|
322
|
+
* (e.g. fillBlogForm, updatePageLayers) based on the availableTools list
|
|
323
|
+
* sent with each request.
|
|
324
|
+
* @default false
|
|
325
|
+
*/
|
|
326
|
+
enablePageTools?: boolean;
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Custom client-side tool schemas for non-BTST pages.
|
|
330
|
+
* Merged with built-in page tool schemas (fillBlogForm, updatePageLayers).
|
|
331
|
+
* Only included when enablePageTools is true and the tool name appears in
|
|
332
|
+
* the availableTools list sent with the request.
|
|
333
|
+
*
|
|
334
|
+
* @example
|
|
335
|
+
* clientToolSchemas: {
|
|
336
|
+
* addToCart: tool({
|
|
337
|
+
* description: "Add current product to cart",
|
|
338
|
+
* parameters: z.object({ quantity: z.number().int().min(1) }),
|
|
339
|
+
* }),
|
|
340
|
+
* }
|
|
341
|
+
*/
|
|
342
|
+
clientToolSchemas?: Record<string, Tool>;
|
|
343
|
+
|
|
272
344
|
/**
|
|
273
345
|
* Optional hooks for customizing plugin behavior
|
|
274
346
|
*/
|
|
@@ -282,7 +354,15 @@ export interface AiChatBackendConfig {
|
|
|
282
354
|
*
|
|
283
355
|
* @param config - Configuration including model, tools, and optional hooks
|
|
284
356
|
*/
|
|
285
|
-
export const aiChatBackendPlugin =
|
|
357
|
+
export const aiChatBackendPlugin = <
|
|
358
|
+
TTools extends Record<string, Tool> = Record<never, Tool>,
|
|
359
|
+
TClientTools extends Record<string, Tool> = Record<never, Tool>,
|
|
360
|
+
>(
|
|
361
|
+
config: Omit<AiChatBackendConfig, "tools" | "clientToolSchemas"> & {
|
|
362
|
+
tools?: TTools;
|
|
363
|
+
clientToolSchemas?: NoKeyCollision<TTools, TClientTools>;
|
|
364
|
+
},
|
|
365
|
+
) =>
|
|
286
366
|
defineBackendPlugin({
|
|
287
367
|
name: "ai-chat",
|
|
288
368
|
// Always include db schema - in public mode we just don't use it
|
|
@@ -350,7 +430,13 @@ export const aiChatBackendPlugin = (config: AiChatBackendConfig) =>
|
|
|
350
430
|
body: chatRequestSchema,
|
|
351
431
|
},
|
|
352
432
|
async (ctx) => {
|
|
353
|
-
const {
|
|
433
|
+
const {
|
|
434
|
+
messages: rawMessages,
|
|
435
|
+
conversationId,
|
|
436
|
+
pageContext,
|
|
437
|
+
availableTools,
|
|
438
|
+
routeName,
|
|
439
|
+
} = ctx.body;
|
|
354
440
|
const uiMessages = rawMessages as UIMessage[];
|
|
355
441
|
|
|
356
442
|
const context: ChatApiContext = {
|
|
@@ -388,22 +474,107 @@ export const aiChatBackendPlugin = (config: AiChatBackendConfig) =>
|
|
|
388
474
|
// Convert UIMessages to CoreMessages for streamText
|
|
389
475
|
const modelMessages = convertToModelMessages(uiMessages);
|
|
390
476
|
|
|
391
|
-
//
|
|
392
|
-
const
|
|
477
|
+
// Build system prompt: base config + optional page context
|
|
478
|
+
const pageContextContent =
|
|
479
|
+
pageContext && pageContext.trim()
|
|
480
|
+
? `\n\nCurrent page context:\n${pageContext}`
|
|
481
|
+
: "";
|
|
482
|
+
const systemContent = config.systemPrompt
|
|
483
|
+
? `${config.systemPrompt}${pageContextContent}`
|
|
484
|
+
: pageContextContent || undefined;
|
|
485
|
+
|
|
486
|
+
const messagesWithSystem = systemContent
|
|
393
487
|
? [
|
|
394
|
-
{ role: "system" as const, content:
|
|
488
|
+
{ role: "system" as const, content: systemContent },
|
|
395
489
|
...modelMessages,
|
|
396
490
|
]
|
|
397
491
|
: modelMessages;
|
|
398
492
|
|
|
493
|
+
// Merge page tool schemas when enablePageTools is on.
|
|
494
|
+
// Built-in schemas are only included when the request's routeName is in
|
|
495
|
+
// the tool's allowlist — this prevents a page from claiming tools that
|
|
496
|
+
// are intended for a different route (e.g. requesting updatePageLayers
|
|
497
|
+
// from a blog page). Consumer clientToolSchemas are trusted as-is.
|
|
498
|
+
const activePageTools: Record<string, Tool> =
|
|
499
|
+
config.enablePageTools &&
|
|
500
|
+
availableTools &&
|
|
501
|
+
availableTools.length > 0
|
|
502
|
+
? (() => {
|
|
503
|
+
const consumerSchemas: Record<string, Tool> =
|
|
504
|
+
(config.clientToolSchemas as Record<string, Tool>) ?? {};
|
|
505
|
+
return Object.fromEntries(
|
|
506
|
+
availableTools
|
|
507
|
+
.filter((name) => {
|
|
508
|
+
// Built-in tool: require routeName to be in its allowlist
|
|
509
|
+
if (name in BUILT_IN_PAGE_TOOL_SCHEMAS) {
|
|
510
|
+
const allowed =
|
|
511
|
+
BUILT_IN_PAGE_TOOL_ROUTE_ALLOWLIST[name];
|
|
512
|
+
return (
|
|
513
|
+
allowed &&
|
|
514
|
+
routeName &&
|
|
515
|
+
allowed.includes(routeName)
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
// Consumer-defined tool: allow if schema is registered
|
|
519
|
+
return name in consumerSchemas;
|
|
520
|
+
})
|
|
521
|
+
.map((name) => {
|
|
522
|
+
const schema =
|
|
523
|
+
BUILT_IN_PAGE_TOOL_SCHEMAS[name] ??
|
|
524
|
+
consumerSchemas[name]!;
|
|
525
|
+
return [name, schema];
|
|
526
|
+
}),
|
|
527
|
+
);
|
|
528
|
+
})()
|
|
529
|
+
: {};
|
|
530
|
+
|
|
531
|
+
// Consumer hook: user-level tool authorization.
|
|
532
|
+
// Runs after the structural routeName allowlist check.
|
|
533
|
+
// A thrown Error is caught and returned as a 403 response,
|
|
534
|
+
// consistent with how onBeforeChat handles return false → 403.
|
|
535
|
+
if (
|
|
536
|
+
config.hooks?.onBeforeToolsActivated &&
|
|
537
|
+
Object.keys(activePageTools).length > 0
|
|
538
|
+
) {
|
|
539
|
+
try {
|
|
540
|
+
const allowed = await config.hooks.onBeforeToolsActivated(
|
|
541
|
+
Object.keys(activePageTools),
|
|
542
|
+
routeName,
|
|
543
|
+
context,
|
|
544
|
+
);
|
|
545
|
+
const allowedSet = new Set(allowed);
|
|
546
|
+
for (const key of Object.keys(activePageTools)) {
|
|
547
|
+
if (!allowedSet.has(key)) {
|
|
548
|
+
delete activePageTools[key];
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
} catch (hookError) {
|
|
552
|
+
throw ctx.error(403, {
|
|
553
|
+
message:
|
|
554
|
+
hookError instanceof Error
|
|
555
|
+
? hookError.message
|
|
556
|
+
: "Unauthorized: Tool activation denied",
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Page tools are layered under server-side tools so that a
|
|
562
|
+
// clientToolSchemas entry with the same name as a tool in
|
|
563
|
+
// config.tools never silently drops its `execute` function.
|
|
564
|
+
// Server-side tools always win on collision.
|
|
565
|
+
const mergedTools =
|
|
566
|
+
Object.keys(activePageTools).length > 0
|
|
567
|
+
? { ...activePageTools, ...config.tools }
|
|
568
|
+
: config.tools;
|
|
569
|
+
|
|
399
570
|
// PUBLIC MODE: Stream without persistence
|
|
400
571
|
if (isPublicMode) {
|
|
401
572
|
const result = streamText({
|
|
402
573
|
model: config.model,
|
|
403
574
|
messages: messagesWithSystem,
|
|
404
|
-
tools:
|
|
575
|
+
tools: mergedTools,
|
|
405
576
|
// Enable multi-step tool calls if tools are configured
|
|
406
|
-
...(
|
|
577
|
+
...(mergedTools ? { stopWhen: stepCountIs(5) } : {}),
|
|
407
578
|
});
|
|
408
579
|
|
|
409
580
|
return result.toUIMessageStreamResponse({
|
|
@@ -557,9 +728,9 @@ export const aiChatBackendPlugin = (config: AiChatBackendConfig) =>
|
|
|
557
728
|
const result = streamText({
|
|
558
729
|
model: config.model,
|
|
559
730
|
messages: messagesWithSystem,
|
|
560
|
-
tools:
|
|
731
|
+
tools: mergedTools,
|
|
561
732
|
// Enable multi-step tool calls if tools are configured
|
|
562
|
-
...(
|
|
733
|
+
...(mergedTools ? { stopWhen: stepCountIs(5) } : {}),
|
|
563
734
|
onFinish: async (completion: { text: string }) => {
|
|
564
735
|
// Wrap in try-catch since this runs after the response is sent
|
|
565
736
|
// and errors would otherwise become unhandled promise rejections
|
|
@@ -260,14 +260,14 @@ export function ChatInput({
|
|
|
260
260
|
)}
|
|
261
261
|
|
|
262
262
|
{/* Text Input */}
|
|
263
|
-
<div className="relative flex-1">
|
|
263
|
+
<div className="relative flex-1 min-w-0">
|
|
264
264
|
<Textarea
|
|
265
265
|
value={input}
|
|
266
266
|
onChange={handleInputChange}
|
|
267
267
|
onKeyDown={handleKeyDown}
|
|
268
268
|
placeholder={placeholder || localization.CHAT_PLACEHOLDER}
|
|
269
269
|
className={cn(
|
|
270
|
-
"resize-none pr-12",
|
|
270
|
+
"resize-none pr-12 max-w-full",
|
|
271
271
|
isCompact
|
|
272
272
|
? "min-h-[40px] max-h-[120px] py-2"
|
|
273
273
|
: "min-h-[50px] max-h-[200px] py-3",
|
|
@@ -7,7 +7,11 @@ import { ChatMessage } from "./chat-message";
|
|
|
7
7
|
import { ChatInput, type AttachedFile } from "./chat-input";
|
|
8
8
|
import { StackAttribution } from "@workspace/ui/components/stack-attribution";
|
|
9
9
|
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
DefaultChatTransport,
|
|
12
|
+
lastAssistantMessageIsCompleteWithToolCalls,
|
|
13
|
+
type UIMessage,
|
|
14
|
+
} from "ai";
|
|
11
15
|
import { cn } from "@workspace/ui/lib/utils";
|
|
12
16
|
import { usePluginOverrides, useBasePath } from "@btst/stack/context";
|
|
13
17
|
import type { AiChatPluginOverrides } from "../overrides";
|
|
@@ -20,6 +24,7 @@ import {
|
|
|
20
24
|
useConversations,
|
|
21
25
|
type SerializedConversation,
|
|
22
26
|
} from "../hooks/chat-hooks";
|
|
27
|
+
import { usePageAIContext } from "../context/page-ai-context";
|
|
23
28
|
|
|
24
29
|
interface ChatInterfaceProps {
|
|
25
30
|
apiPath?: string;
|
|
@@ -56,6 +61,9 @@ export function ChatInterface({
|
|
|
56
61
|
const basePath = useBasePath();
|
|
57
62
|
const isPublicMode = mode === "public";
|
|
58
63
|
|
|
64
|
+
// Read page AI context registered by the current page
|
|
65
|
+
const pageAIContext = usePageAIContext();
|
|
66
|
+
|
|
59
67
|
const localization = { ...AI_CHAT_LOCALIZATION, ...customLocalization };
|
|
60
68
|
const queryClient = useQueryClient();
|
|
61
69
|
|
|
@@ -126,6 +134,13 @@ export function ChatInterface({
|
|
|
126
134
|
!initialMessages || initialMessages.length === 0,
|
|
127
135
|
);
|
|
128
136
|
|
|
137
|
+
// Ref to always have the latest pageAIContext in the transport callback
|
|
138
|
+
// without recreating the transport on every context change
|
|
139
|
+
const pageAIContextRef = useRef(pageAIContext);
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
pageAIContextRef.current = pageAIContext;
|
|
142
|
+
}, [pageAIContext]);
|
|
143
|
+
|
|
129
144
|
// Memoize the transport to prevent recreation on every render
|
|
130
145
|
const transport = useMemo(
|
|
131
146
|
() =>
|
|
@@ -135,8 +150,21 @@ export function ChatInterface({
|
|
|
135
150
|
body: isPublicMode
|
|
136
151
|
? undefined
|
|
137
152
|
: () => ({ conversationId: conversationIdRef.current }),
|
|
138
|
-
// Handle edit operations
|
|
153
|
+
// Handle edit operations and inject page context
|
|
139
154
|
prepareSendMessagesRequest: ({ messages: hookMessages }) => {
|
|
155
|
+
const currentPageContext = pageAIContextRef.current;
|
|
156
|
+
|
|
157
|
+
// Build page context fields to include in every request
|
|
158
|
+
const pageContextBody = currentPageContext?.pageDescription
|
|
159
|
+
? {
|
|
160
|
+
pageContext: currentPageContext.pageDescription,
|
|
161
|
+
availableTools: Object.keys(
|
|
162
|
+
currentPageContext.clientTools ?? {},
|
|
163
|
+
),
|
|
164
|
+
routeName: currentPageContext.routeName,
|
|
165
|
+
}
|
|
166
|
+
: {};
|
|
167
|
+
|
|
140
168
|
// If we're in an edit operation, use the truncated messages + new user message
|
|
141
169
|
if (editMessagesRef.current !== null) {
|
|
142
170
|
const newUserMessage = hookMessages[hookMessages.length - 1];
|
|
@@ -150,6 +178,7 @@ export function ChatInterface({
|
|
|
150
178
|
body: {
|
|
151
179
|
messages: messagesToSend,
|
|
152
180
|
conversationId: conversationIdRef.current,
|
|
181
|
+
...pageContextBody,
|
|
153
182
|
},
|
|
154
183
|
};
|
|
155
184
|
}
|
|
@@ -158,6 +187,7 @@ export function ChatInterface({
|
|
|
158
187
|
body: {
|
|
159
188
|
messages: hookMessages,
|
|
160
189
|
conversationId: conversationIdRef.current,
|
|
190
|
+
...pageContextBody,
|
|
161
191
|
},
|
|
162
192
|
};
|
|
163
193
|
},
|
|
@@ -165,48 +195,99 @@ export function ChatInterface({
|
|
|
165
195
|
[apiPath, isPublicMode],
|
|
166
196
|
);
|
|
167
197
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
console.error("useChat onError:", err);
|
|
173
|
-
// Reset first-message tracking if the send failed before a conversation was created.
|
|
174
|
-
// Without this, isFirstMessageSentRef stays true and the next successful send
|
|
175
|
-
// skips the "first message" navigation logic, corrupting the conversation flow.
|
|
176
|
-
if (!id && !hasNavigatedRef.current) {
|
|
177
|
-
isFirstMessageSentRef.current = false;
|
|
178
|
-
}
|
|
179
|
-
},
|
|
180
|
-
onFinish: async () => {
|
|
181
|
-
// In public mode, skip all persistence-related operations
|
|
182
|
-
if (isPublicMode) return;
|
|
198
|
+
// Use a ref so addToolOutput is always current inside the onToolCall closure
|
|
199
|
+
const addToolOutputRef = useRef<
|
|
200
|
+
ReturnType<typeof useChat>["addToolOutput"] | null
|
|
201
|
+
>(null);
|
|
183
202
|
|
|
184
|
-
|
|
185
|
-
|
|
203
|
+
const {
|
|
204
|
+
messages,
|
|
205
|
+
sendMessage,
|
|
206
|
+
status,
|
|
207
|
+
error,
|
|
208
|
+
setMessages,
|
|
209
|
+
regenerate,
|
|
210
|
+
addToolOutput,
|
|
211
|
+
} = useChat({
|
|
212
|
+
transport,
|
|
213
|
+
// Automatically resubmit after all client-side tool results are provided
|
|
214
|
+
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
|
|
215
|
+
onToolCall: async ({ toolCall }) => {
|
|
216
|
+
// Dispatch client-side tool calls to the handler registered by the current page.
|
|
217
|
+
// In AI SDK v5, onToolCall returns void — addToolOutput must be called explicitly.
|
|
218
|
+
const toolName = toolCall.toolName;
|
|
219
|
+
const handler = pageAIContextRef.current?.clientTools?.[toolName];
|
|
220
|
+
if (handler) {
|
|
221
|
+
try {
|
|
222
|
+
const result = await handler(toolCall.input);
|
|
223
|
+
// No await — avoids potential deadlocks with sendAutomaticallyWhen
|
|
224
|
+
addToolOutputRef.current?.({
|
|
225
|
+
tool: toolName,
|
|
226
|
+
toolCallId: toolCall.toolCallId,
|
|
227
|
+
output: result,
|
|
228
|
+
});
|
|
229
|
+
} catch (err) {
|
|
230
|
+
addToolOutputRef.current?.({
|
|
231
|
+
tool: toolName,
|
|
232
|
+
toolCallId: toolCall.toolCallId,
|
|
233
|
+
state: "output-error",
|
|
234
|
+
errorText:
|
|
235
|
+
err instanceof Error ? err.message : "Tool execution failed",
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
// No handler found — this happens when the user navigates away while a
|
|
240
|
+
// tool-call response is streaming and the page context changes. Always
|
|
241
|
+
// call addToolOutput so sendAutomaticallyWhen can unblock; without this
|
|
242
|
+
// the conversation gets permanently stuck waiting for a missing output.
|
|
243
|
+
addToolOutputRef.current?.({
|
|
244
|
+
tool: toolName,
|
|
245
|
+
toolCallId: toolCall.toolCallId,
|
|
246
|
+
state: "output-error",
|
|
247
|
+
errorText: `No client-side handler registered for tool "${toolName}". The page context may have changed while the response was streaming.`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
onError: (err) => {
|
|
252
|
+
console.error("useChat onError:", err);
|
|
253
|
+
// Reset first-message tracking if the send failed before a conversation was created.
|
|
254
|
+
// Without this, isFirstMessageSentRef stays true and the next successful send
|
|
255
|
+
// skips the "first message" navigation logic, corrupting the conversation flow.
|
|
256
|
+
if (!id && !hasNavigatedRef.current) {
|
|
257
|
+
isFirstMessageSentRef.current = false;
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
onFinish: async () => {
|
|
261
|
+
// In public mode, skip all persistence-related operations
|
|
262
|
+
if (isPublicMode) return;
|
|
263
|
+
|
|
264
|
+
// Invalidate conversation list to show new/updated conversations
|
|
265
|
+
await queryClient.invalidateQueries({
|
|
266
|
+
queryKey: conversationsListQueryKey,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// If this was the first message on a new chat, update the URL without full navigation
|
|
270
|
+
// This avoids losing the in-memory messages during component remount
|
|
271
|
+
if (isFirstMessageSentRef.current && !id && !hasNavigatedRef.current) {
|
|
272
|
+
hasNavigatedRef.current = true;
|
|
273
|
+
// Wait for the invalidation to complete and refetch conversations
|
|
274
|
+
await queryClient.refetchQueries({
|
|
186
275
|
queryKey: conversationsListQueryKey,
|
|
187
276
|
});
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
// The most recently updated conversation should be the one we just created
|
|
203
|
-
const newConversation = cachedConversations[0];
|
|
204
|
-
if (newConversation) {
|
|
205
|
-
// Update our local state
|
|
206
|
-
setCurrentConversationId(newConversation.id);
|
|
207
|
-
conversationIdRef.current = newConversation.id;
|
|
208
|
-
// Update URL without navigation to preserve in-memory messages
|
|
209
|
-
// Use replaceState to avoid adding to history stack
|
|
277
|
+
// Get the updated conversations from cache
|
|
278
|
+
const cachedConversations = queryClient.getQueryData<
|
|
279
|
+
SerializedConversation[]
|
|
280
|
+
>(conversationsListQueryKey);
|
|
281
|
+
if (cachedConversations && cachedConversations.length > 0) {
|
|
282
|
+
// The most recently updated conversation should be the one we just created
|
|
283
|
+
const newConversation = cachedConversations[0];
|
|
284
|
+
if (newConversation) {
|
|
285
|
+
// Update our local state
|
|
286
|
+
setCurrentConversationId(newConversation.id);
|
|
287
|
+
conversationIdRef.current = newConversation.id;
|
|
288
|
+
// Only update the URL in full-page mode; in widget mode the chat is
|
|
289
|
+
// embedded in another page and clobbering the URL is disruptive.
|
|
290
|
+
if (variant === "full") {
|
|
210
291
|
const newUrl = `${basePath}/chat/${newConversation.id}`;
|
|
211
292
|
if (typeof window !== "undefined") {
|
|
212
293
|
window.history.replaceState(
|
|
@@ -218,8 +299,14 @@ export function ChatInterface({
|
|
|
218
299
|
}
|
|
219
300
|
}
|
|
220
301
|
}
|
|
221
|
-
}
|
|
222
|
-
}
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Keep addToolOutputRef in sync so onToolCall always has the latest reference
|
|
307
|
+
useEffect(() => {
|
|
308
|
+
addToolOutputRef.current = addToolOutput;
|
|
309
|
+
}, [addToolOutput]);
|
|
223
310
|
|
|
224
311
|
// Load existing conversation messages when navigating to a conversation
|
|
225
312
|
useEffect(() => {
|
|
@@ -484,23 +571,32 @@ export function ChatInterface({
|
|
|
484
571
|
>
|
|
485
572
|
{messages.length === 0 ? (
|
|
486
573
|
<div className="flex flex-col h-full min-h-[300px]">
|
|
487
|
-
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
|
574
|
+
<div className="flex-1 flex items-center justify-center text-muted-foreground mb-4">
|
|
488
575
|
<p>{localization.CHAT_EMPTY_STATE}</p>
|
|
489
576
|
</div>
|
|
490
|
-
{
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
577
|
+
{(() => {
|
|
578
|
+
// Merge static suggestions from overrides with dynamic ones from page context.
|
|
579
|
+
// Page context suggestions appear first (most relevant to current page).
|
|
580
|
+
const pageSuggestions = pageAIContext?.suggestions ?? [];
|
|
581
|
+
const allSuggestions = [
|
|
582
|
+
...pageSuggestions,
|
|
583
|
+
...(chatSuggestions ?? []),
|
|
584
|
+
];
|
|
585
|
+
return allSuggestions.length > 0 ? (
|
|
586
|
+
<div className="flex flex-wrap justify-center gap-2 pb-4 max-w-md mx-auto">
|
|
587
|
+
{allSuggestions.map((suggestion, index) => (
|
|
588
|
+
<button
|
|
589
|
+
key={index}
|
|
590
|
+
type="button"
|
|
591
|
+
onClick={() => setInput(suggestion)}
|
|
592
|
+
className="px-3 py-2 text-sm rounded-lg border border-border bg-background hover:bg-accent hover:text-accent-foreground transition-colors text-foreground"
|
|
593
|
+
>
|
|
594
|
+
{suggestion}
|
|
595
|
+
</button>
|
|
596
|
+
))}
|
|
597
|
+
</div>
|
|
598
|
+
) : null;
|
|
599
|
+
})()}
|
|
504
600
|
</div>
|
|
505
601
|
) : (
|
|
506
602
|
messages.map((m, index) => (
|