@btst/stack 2.3.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.
Files changed (136) hide show
  1. package/dist/packages/stack/src/client/components/compose.cjs +1 -2
  2. package/dist/packages/stack/src/client/components/compose.mjs +1 -2
  3. package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.cjs +71 -0
  4. package/dist/packages/stack/src/plugins/ai-chat/api/page-tools.mjs +68 -0
  5. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.cjs +54 -7
  6. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.mjs +54 -7
  7. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.cjs +2 -2
  8. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.mjs +2 -2
  9. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.cjs +89 -22
  10. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +90 -23
  11. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.cjs +110 -33
  12. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.mjs +112 -35
  13. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.cjs +1 -1
  14. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.mjs +1 -1
  15. package/dist/packages/stack/src/plugins/ai-chat/schemas.cjs +17 -1
  16. package/dist/packages/stack/src/plugins/ai-chat/schemas.mjs +17 -1
  17. package/dist/packages/stack/src/plugins/blog/client/components/forms/post-forms.cjs +15 -2
  18. package/dist/packages/stack/src/plugins/blog/client/components/forms/post-forms.mjs +16 -3
  19. package/dist/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.cjs +24 -1
  20. package/dist/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.mjs +24 -1
  21. package/dist/packages/stack/src/plugins/blog/client/components/pages/fill-blog-form-handler.cjs +26 -0
  22. package/dist/packages/stack/src/plugins/blog/client/components/pages/fill-blog-form-handler.mjs +24 -0
  23. package/dist/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.cjs +30 -1
  24. package/dist/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.mjs +30 -1
  25. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.cjs +18 -0
  26. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.mjs +18 -0
  27. package/dist/packages/stack/src/plugins/cms/api/mutations.cjs +48 -0
  28. package/dist/packages/stack/src/plugins/cms/api/mutations.mjs +46 -0
  29. package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +7 -1
  30. package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +7 -1
  31. package/dist/packages/stack/src/plugins/kanban/api/mutations.cjs +91 -0
  32. package/dist/packages/stack/src/plugins/kanban/api/mutations.mjs +87 -0
  33. package/dist/packages/stack/src/plugins/kanban/api/plugin.cjs +6 -1
  34. package/dist/packages/stack/src/plugins/kanban/api/plugin.mjs +6 -1
  35. package/dist/packages/stack/src/plugins/kanban/client/hooks/kanban-hooks.cjs +7 -3
  36. package/dist/packages/stack/src/plugins/kanban/client/hooks/kanban-hooks.mjs +7 -3
  37. package/dist/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.cjs +89 -0
  38. package/dist/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.mjs +89 -0
  39. package/dist/plugins/ai-chat/api/index.d.cts +1 -1
  40. package/dist/plugins/ai-chat/api/index.d.mts +1 -1
  41. package/dist/plugins/ai-chat/api/index.d.ts +1 -1
  42. package/dist/plugins/ai-chat/client/components/index.d.cts +1 -1
  43. package/dist/plugins/ai-chat/client/components/index.d.mts +1 -1
  44. package/dist/plugins/ai-chat/client/components/index.d.ts +1 -1
  45. package/dist/plugins/ai-chat/client/context/page-ai-context.cjs +92 -0
  46. package/dist/plugins/ai-chat/client/context/page-ai-context.d.cts +84 -0
  47. package/dist/plugins/ai-chat/client/context/page-ai-context.d.mts +84 -0
  48. package/dist/plugins/ai-chat/client/context/page-ai-context.d.ts +84 -0
  49. package/dist/plugins/ai-chat/client/context/page-ai-context.mjs +88 -0
  50. package/dist/plugins/ai-chat/client/hooks/index.d.cts +1 -1
  51. package/dist/plugins/ai-chat/client/hooks/index.d.mts +1 -1
  52. package/dist/plugins/ai-chat/client/hooks/index.d.ts +1 -1
  53. package/dist/plugins/ai-chat/client/index.d.cts +2 -2
  54. package/dist/plugins/ai-chat/client/index.d.mts +2 -2
  55. package/dist/plugins/ai-chat/client/index.d.ts +2 -2
  56. package/dist/plugins/ai-chat/query-keys.d.cts +1 -1
  57. package/dist/plugins/ai-chat/query-keys.d.mts +1 -1
  58. package/dist/plugins/ai-chat/query-keys.d.ts +1 -1
  59. package/dist/plugins/blog/api/index.d.cts +2 -2
  60. package/dist/plugins/blog/api/index.d.mts +2 -2
  61. package/dist/plugins/blog/api/index.d.ts +2 -2
  62. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  63. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  64. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  65. package/dist/plugins/blog/client/index.d.cts +1 -1
  66. package/dist/plugins/blog/client/index.d.mts +1 -1
  67. package/dist/plugins/blog/client/index.d.ts +1 -1
  68. package/dist/plugins/blog/query-keys.d.cts +2 -2
  69. package/dist/plugins/blog/query-keys.d.mts +2 -2
  70. package/dist/plugins/blog/query-keys.d.ts +2 -2
  71. package/dist/plugins/cms/api/index.cjs +2 -0
  72. package/dist/plugins/cms/api/index.d.cts +1 -1
  73. package/dist/plugins/cms/api/index.d.mts +1 -1
  74. package/dist/plugins/cms/api/index.d.ts +1 -1
  75. package/dist/plugins/cms/api/index.mjs +1 -0
  76. package/dist/plugins/cms/query-keys.d.cts +1 -1
  77. package/dist/plugins/cms/query-keys.d.mts +1 -1
  78. package/dist/plugins/cms/query-keys.d.ts +1 -1
  79. package/dist/plugins/form-builder/api/index.d.cts +1 -1
  80. package/dist/plugins/form-builder/api/index.d.mts +1 -1
  81. package/dist/plugins/form-builder/api/index.d.ts +1 -1
  82. package/dist/plugins/form-builder/query-keys.d.cts +1 -1
  83. package/dist/plugins/form-builder/query-keys.d.mts +1 -1
  84. package/dist/plugins/form-builder/query-keys.d.ts +1 -1
  85. package/dist/plugins/kanban/api/index.cjs +4 -0
  86. package/dist/plugins/kanban/api/index.d.cts +1 -1
  87. package/dist/plugins/kanban/api/index.d.mts +1 -1
  88. package/dist/plugins/kanban/api/index.d.ts +1 -1
  89. package/dist/plugins/kanban/api/index.mjs +1 -0
  90. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  91. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  92. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  93. package/dist/shared/{stack.BeSm90va.d.ts → stack.BEn34wW6.d.ts} +60 -2
  94. package/dist/shared/{stack.IdtKDRka.d.cts → stack.BUkC2EsZ.d.cts} +32 -2
  95. package/dist/shared/{stack.DaOcgmrM.d.ts → stack.BV9hnvu4.d.cts} +31 -7
  96. package/dist/shared/{stack.DaOcgmrM.d.cts → stack.BV9hnvu4.d.mts} +31 -7
  97. package/dist/shared/{stack.DaOcgmrM.d.mts → stack.BV9hnvu4.d.ts} +31 -7
  98. package/dist/shared/{stack.rTy7-wQU.d.mts → stack.BepFXT3w.d.mts} +70 -15
  99. package/dist/shared/{stack.BKfolAyK.d.ts → stack.CL8ts1Mu.d.ts} +3 -3
  100. package/dist/shared/{stack.CP68pFEH.d.mts → stack.CczspVn2.d.mts} +32 -2
  101. package/dist/shared/{stack.TIBF2AOx.d.ts → stack.CgWzG5jH.d.ts} +70 -15
  102. package/dist/shared/{stack.BpolpQpf.d.cts → stack.D3GB6wKv.d.cts} +70 -15
  103. package/dist/shared/{stack.B1EeBt1b.d.ts → stack.DASmUVjX.d.ts} +32 -2
  104. package/dist/shared/{stack.Dg09R0oB.d.mts → stack.DTDxgFj8.d.mts} +60 -2
  105. package/dist/shared/{stack.CMh_EdxW.d.cts → stack.DWoCZff7.d.cts} +60 -2
  106. package/dist/shared/{stack.snB1EDP7.d.cts → stack.Dk5r4W1F.d.mts} +3 -3
  107. package/dist/shared/{stack.BIXEI6v_.d.mts → stack.heOA9gzA.d.cts} +3 -3
  108. package/package.json +14 -1
  109. package/src/client/components/compose.tsx +7 -4
  110. package/src/plugins/ai-chat/api/page-tools.ts +111 -0
  111. package/src/plugins/ai-chat/api/plugin.ts +180 -9
  112. package/src/plugins/ai-chat/client/components/chat-input.tsx +2 -2
  113. package/src/plugins/ai-chat/client/components/chat-interface.tsx +154 -58
  114. package/src/plugins/ai-chat/client/components/chat-layout.tsx +166 -32
  115. package/src/plugins/ai-chat/client/components/chat-sidebar.tsx +1 -1
  116. package/src/plugins/ai-chat/client/context/page-ai-context.tsx +240 -0
  117. package/src/plugins/ai-chat/schemas.ts +16 -0
  118. package/src/plugins/blog/client/components/forms/post-forms.tsx +29 -2
  119. package/src/plugins/blog/client/components/pages/edit-post-page.internal.tsx +28 -0
  120. package/src/plugins/blog/client/components/pages/fill-blog-form-handler.ts +38 -0
  121. package/src/plugins/blog/client/components/pages/new-post-page.internal.tsx +33 -1
  122. package/src/plugins/blog/client/components/pages/post-page.internal.tsx +20 -0
  123. package/src/plugins/cms/api/index.ts +4 -0
  124. package/src/plugins/cms/api/mutations.ts +84 -0
  125. package/src/plugins/cms/api/plugin.ts +9 -0
  126. package/src/plugins/kanban/api/index.ts +6 -0
  127. package/src/plugins/kanban/api/mutations.ts +169 -0
  128. package/src/plugins/kanban/api/plugin.ts +12 -0
  129. package/src/plugins/kanban/client/hooks/kanban-hooks.tsx +4 -0
  130. package/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.tsx +132 -0
  131. package/dist/shared/{stack.C5dtIncc.d.mts → stack.B7ONvlD_.d.mts} +1 -1
  132. package/dist/shared/{stack.CBON0dWL.d.cts → stack.BQmuNl5p.d.cts} +2 -2
  133. package/dist/shared/{stack.CBON0dWL.d.mts → stack.BQmuNl5p.d.mts} +2 -2
  134. package/dist/shared/{stack.CBON0dWL.d.ts → stack.BQmuNl5p.d.ts} +2 -2
  135. package/dist/shared/{stack.CIP6QS9l.d.ts → stack.Kq2-QzOC.d.ts} +1 -1
  136. package/dist/shared/{stack.Dw0Ly2TM.d.cts → stack.kcdnD4gA.d.cts} +1 -1
@@ -0,0 +1,240 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useId,
8
+ useEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ } from "react";
13
+
14
+ /**
15
+ * A client-side tool handler — receives the AI's tool call args and returns a result.
16
+ * The result is sent back to the model so it can continue the conversation.
17
+ */
18
+ export type PageAIClientTool = (
19
+ args: any,
20
+ ) => Promise<{ success: boolean; message?: string }>;
21
+
22
+ /**
23
+ * Configuration registered by a page to provide AI context and capabilities.
24
+ * Any component in the tree can call useRegisterPageAIContext with this config.
25
+ */
26
+ export interface PageAIContextConfig {
27
+ /**
28
+ * Identifier for the current route/page (e.g. "blog-post", "ui-builder-edit-page").
29
+ * Shown as a badge in the chat header.
30
+ */
31
+ routeName: string;
32
+
33
+ /**
34
+ * Human-readable description of the current page and its content.
35
+ * Injected into the AI system prompt so it understands what the user is looking at.
36
+ * Capped at 8,000 characters server-side.
37
+ */
38
+ pageDescription: string;
39
+
40
+ /**
41
+ * Optional suggested prompts shown as quick-action chips in the chat empty state.
42
+ * These augment (not replace) any static suggestions configured in plugin overrides.
43
+ */
44
+ suggestions?: string[];
45
+
46
+ /**
47
+ * Client-side tool handlers keyed by tool name.
48
+ * When the AI calls a tool by this name, the handler is invoked with the tool args.
49
+ * The result is sent back to the model via addToolResult.
50
+ *
51
+ * Tool schemas must be registered server-side via enablePageTools + clientToolSchemas
52
+ * in aiChatBackendPlugin (built-in tools like fillBlogForm are pre-registered).
53
+ */
54
+ clientTools?: Record<string, PageAIClientTool>;
55
+ }
56
+
57
+ interface PageAIAPIContextValue {
58
+ register: (id: string, config: PageAIContextConfig) => void;
59
+ unregister: (id: string) => void;
60
+ getActive: () => PageAIContextConfig | null;
61
+ }
62
+
63
+ /**
64
+ * Stable API context — holds register/unregister/getActive.
65
+ * Never changes reference, so useRegisterPageAIContext effects don't re-run
66
+ * simply because the provider re-rendered after a bumpVersion call.
67
+ */
68
+ const PageAIAPIContext = createContext<PageAIAPIContextValue | null>(null);
69
+
70
+ /**
71
+ * Reactive version context — incremented on every register/unregister.
72
+ * Consumers of usePageAIContext subscribe here so they re-render when
73
+ * registrations change and re-call getActive() to pick up the latest config.
74
+ */
75
+ const PageAIVersionContext = createContext<number>(0);
76
+
77
+ /**
78
+ * Provider that enables route-aware AI context across the app.
79
+ *
80
+ * Place this at the root layout — above all StackProviders — so it spans
81
+ * both your main app tree and any chat modals rendered as parallel/intercept routes.
82
+ *
83
+ * @example
84
+ * // app/layout.tsx
85
+ * import { PageAIContextProvider } from "@btst/stack/plugins/ai-chat/client/context"
86
+ *
87
+ * export default function RootLayout({ children }) {
88
+ * return <PageAIContextProvider>{children}</PageAIContextProvider>
89
+ * }
90
+ */
91
+ export function PageAIContextProvider({
92
+ children,
93
+ }: {
94
+ children: React.ReactNode;
95
+ }) {
96
+ // Map from stable registration id → config
97
+ // Using useRef so mutations don't trigger re-renders of the provider itself
98
+ const registrationsRef = useRef<Map<string, PageAIContextConfig>>(new Map());
99
+ // Track insertion order so the last-registered (most specific) wins
100
+ const insertionOrderRef = useRef<string[]>([]);
101
+
102
+ // Version counter — bumped on every register/unregister so consumers re-read
103
+ const [version, setVersion] = useState(0);
104
+ const bumpVersion = useCallback(() => setVersion((v) => v + 1), []);
105
+
106
+ const register = useCallback(
107
+ (id: string, config: PageAIContextConfig) => {
108
+ registrationsRef.current.set(id, config);
109
+ // Move to end to mark as most recent
110
+ insertionOrderRef.current = insertionOrderRef.current.filter(
111
+ (k) => k !== id,
112
+ );
113
+ insertionOrderRef.current.push(id);
114
+ bumpVersion();
115
+ },
116
+ [bumpVersion],
117
+ );
118
+
119
+ const unregister = useCallback(
120
+ (id: string) => {
121
+ registrationsRef.current.delete(id);
122
+ insertionOrderRef.current = insertionOrderRef.current.filter(
123
+ (k) => k !== id,
124
+ );
125
+ bumpVersion();
126
+ },
127
+ [bumpVersion],
128
+ );
129
+
130
+ const getActive = useCallback((): PageAIContextConfig | null => {
131
+ const order = insertionOrderRef.current;
132
+ if (order.length === 0) return null;
133
+ // Last registered wins (most deeply nested / most recently mounted)
134
+ const lastId = order[order.length - 1];
135
+ if (!lastId) return null;
136
+ return registrationsRef.current.get(lastId) ?? null;
137
+ }, []);
138
+
139
+ // Memoize the API object so its reference never changes — this is what
140
+ // breaks the infinite loop: useRegisterPageAIContext has `ctx` (the API)
141
+ // in its effect deps, and a stable reference means the effect won't re-run
142
+ // just because the provider re-rendered after bumpVersion().
143
+ const api = useMemo(
144
+ () => ({ register, unregister, getActive }),
145
+ [register, unregister, getActive],
146
+ );
147
+
148
+ return (
149
+ <PageAIAPIContext.Provider value={api}>
150
+ <PageAIVersionContext.Provider value={version}>
151
+ {children}
152
+ </PageAIVersionContext.Provider>
153
+ </PageAIAPIContext.Provider>
154
+ );
155
+ }
156
+
157
+ /**
158
+ * Register page AI context from any component.
159
+ * The registration is cleaned up automatically when the component unmounts.
160
+ *
161
+ * Pass `null` to conditionally disable context (e.g. while data is loading).
162
+ *
163
+ * @example
164
+ * // Blog post page
165
+ * useRegisterPageAIContext(post ? {
166
+ * routeName: "blog-post",
167
+ * pageDescription: `Blog post: "${post.title}"\n\n${post.content?.slice(0, 16000)}`,
168
+ * suggestions: ["Summarize this post", "What are the key takeaways?"],
169
+ * } : null)
170
+ */
171
+ export function useRegisterPageAIContext(
172
+ config: PageAIContextConfig | null,
173
+ ): void {
174
+ // Use the stable API context — its reference never changes, so adding it
175
+ // to the dependency array below does NOT cause the effect to re-run after
176
+ // bumpVersion() fires. This breaks the register → bumpVersion → re-render
177
+ // → effect re-run → register loop that caused "Maximum update depth exceeded".
178
+ const ctx = useContext(PageAIAPIContext);
179
+ const id = useId();
180
+
181
+ // Always keep the ref current so clientTools handlers are never stale.
182
+ // Updating a ref during render is safe — the value is visible to any effect
183
+ // that runs in the same commit.
184
+ const configRef = useRef<PageAIContextConfig | null>(config);
185
+ configRef.current = config;
186
+
187
+ useEffect(() => {
188
+ if (!ctx || !configRef.current) return;
189
+ // Register a live proxy that always reads from configRef. This ensures
190
+ // clientTools handlers are fresh even when the effect doesn't re-run —
191
+ // for example when a handler's closure captures new state but the
192
+ // serializable fields (routeName, pageDescription, suggestions) are unchanged.
193
+ // JSON.stringify silently strips function values, so clientTools would be
194
+ // invisible to a plain JSON.stringify(config) dependency check.
195
+ ctx.register(id, {
196
+ get routeName() {
197
+ return configRef.current?.routeName ?? "";
198
+ },
199
+ get pageDescription() {
200
+ return configRef.current?.pageDescription ?? "";
201
+ },
202
+ get suggestions() {
203
+ return configRef.current?.suggestions;
204
+ },
205
+ get clientTools() {
206
+ return configRef.current?.clientTools;
207
+ },
208
+ });
209
+ return () => {
210
+ ctx.unregister(id);
211
+ };
212
+ // Track serializable fields individually. JSON.stringify on the whole config
213
+ // would silently strip clientTools (functions), making handler changes invisible.
214
+ // Handler freshness is provided by the ref-proxy above instead.
215
+ // eslint-disable-next-line react-hooks/exhaustive-deps
216
+ }, [
217
+ ctx,
218
+ id,
219
+ config === null,
220
+ config?.routeName,
221
+ config?.pageDescription,
222
+ JSON.stringify(config?.suggestions),
223
+ ]);
224
+ }
225
+
226
+ /**
227
+ * Read the currently active page AI context.
228
+ * Returns null when no page has registered context, or when PageAIContextProvider
229
+ * is not in the tree.
230
+ *
231
+ * Used internally by ChatInterface to inject context into requests.
232
+ */
233
+ export function usePageAIContext(): PageAIContextConfig | null {
234
+ // Subscribe to the version counter so this hook re-runs whenever a page
235
+ // registers or unregisters context, then read the latest active config.
236
+ useContext(PageAIVersionContext);
237
+ const ctx = useContext(PageAIAPIContext);
238
+ if (!ctx) return null;
239
+ return ctx.getActive();
240
+ }
@@ -37,4 +37,20 @@ export const chatRequestSchema = z.object({
37
37
  ),
38
38
  conversationId: z.string().optional(),
39
39
  model: z.string().optional(),
40
+ /**
41
+ * Description of the current page context, injected into the AI system prompt.
42
+ * Sent by ChatInterface when a page has registered context via useRegisterPageAIContext.
43
+ */
44
+ pageContext: z.string().max(16000).optional(),
45
+ /**
46
+ * Names of client-side tools currently available on the page.
47
+ * The server includes matching tool schemas in the streamText call.
48
+ */
49
+ availableTools: z.array(z.string()).optional(),
50
+ /**
51
+ * The routeName registered by the page via useRegisterPageAIContext.
52
+ * Cross-validated server-side against each built-in tool's route allowlist
53
+ * to prevent a page from claiming tools intended for a different route.
54
+ */
55
+ routeName: z.string().optional(),
40
56
  });
@@ -40,7 +40,7 @@ import {
40
40
 
41
41
  import { zodResolver } from "@hookform/resolvers/zod";
42
42
  import { Loader2 } from "lucide-react";
43
- import { lazy, memo, Suspense, useMemo, useState } from "react";
43
+ import { lazy, memo, Suspense, useEffect, useMemo, useState } from "react";
44
44
  import {
45
45
  type FieldPath,
46
46
  type SubmitHandler,
@@ -325,6 +325,10 @@ const CustomPostUpdateSchema = PostUpdateSchema.omit({
325
325
  type AddPostFormProps = {
326
326
  onClose: () => void;
327
327
  onSuccess: (post: { published: boolean }) => void;
328
+ /** Called once with the form instance so parent components can access form state */
329
+ onFormReady?: (
330
+ form: UseFormReturn<z.input<typeof CustomPostCreateSchema>>,
331
+ ) => void;
328
332
  };
329
333
 
330
334
  const addPostFormPropsAreEqual = (
@@ -333,10 +337,15 @@ const addPostFormPropsAreEqual = (
333
337
  ): boolean => {
334
338
  if (prevProps.onClose !== nextProps.onClose) return false;
335
339
  if (prevProps.onSuccess !== nextProps.onSuccess) return false;
340
+ if (prevProps.onFormReady !== nextProps.onFormReady) return false;
336
341
  return true;
337
342
  };
338
343
 
339
- const AddPostFormComponent = ({ onClose, onSuccess }: AddPostFormProps) => {
344
+ const AddPostFormComponent = ({
345
+ onClose,
346
+ onSuccess,
347
+ onFormReady,
348
+ }: AddPostFormProps) => {
340
349
  const [featuredImageUploading, setFeaturedImageUploading] = useState(false);
341
350
  const { localization } = usePluginOverrides<
342
351
  BlogPluginOverrides,
@@ -393,6 +402,12 @@ const AddPostFormComponent = ({ onClose, onSuccess }: AddPostFormProps) => {
393
402
  },
394
403
  });
395
404
 
405
+ // Expose form instance to parent for AI context integration
406
+ useEffect(() => {
407
+ onFormReady?.(form);
408
+ // eslint-disable-next-line react-hooks/exhaustive-deps
409
+ }, []);
410
+
396
411
  return (
397
412
  <PostFormBody
398
413
  form={form}
@@ -417,6 +432,10 @@ type EditPostFormProps = {
417
432
  onClose: () => void;
418
433
  onSuccess: (post: { slug: string; published: boolean }) => void;
419
434
  onDelete?: () => void;
435
+ /** Called once with the form instance so parent components can access form state */
436
+ onFormReady?: (
437
+ form: UseFormReturn<z.input<typeof CustomPostUpdateSchema>>,
438
+ ) => void;
420
439
  };
421
440
 
422
441
  const editPostFormPropsAreEqual = (
@@ -427,6 +446,7 @@ const editPostFormPropsAreEqual = (
427
446
  if (prevProps.onClose !== nextProps.onClose) return false;
428
447
  if (prevProps.onSuccess !== nextProps.onSuccess) return false;
429
448
  if (prevProps.onDelete !== nextProps.onDelete) return false;
449
+ if (prevProps.onFormReady !== nextProps.onFormReady) return false;
430
450
  return true;
431
451
  };
432
452
 
@@ -435,6 +455,7 @@ const EditPostFormComponent = ({
435
455
  onClose,
436
456
  onSuccess,
437
457
  onDelete,
458
+ onFormReady,
438
459
  }: EditPostFormProps) => {
439
460
  const [featuredImageUploading, setFeaturedImageUploading] = useState(false);
440
461
  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -537,6 +558,12 @@ const EditPostFormComponent = ({
537
558
  values: initialData as z.input<typeof schema>,
538
559
  });
539
560
 
561
+ // Expose form instance to parent for AI context integration
562
+ useEffect(() => {
563
+ onFormReady?.(form);
564
+ // eslint-disable-next-line react-hooks/exhaustive-deps
565
+ }, []);
566
+
540
567
  if (!post) {
541
568
  return <EmptyList message={localization.BLOG_PAGE_NOT_FOUND_DESCRIPTION} />;
542
569
  }
@@ -7,6 +7,10 @@ import { PageWrapper } from "../shared/page-wrapper";
7
7
  import { BLOG_LOCALIZATION } from "../../localization";
8
8
  import type { BlogPluginOverrides } from "../../overrides";
9
9
  import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle";
10
+ import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context";
11
+ import { useRef, useCallback } from "react";
12
+ import type { UseFormReturn } from "react-hook-form";
13
+ import { createFillBlogFormHandler } from "./fill-blog-form-handler";
10
14
 
11
15
  // Internal component with actual page content
12
16
  export function EditPostPage({ slug }: { slug: string }) {
@@ -36,6 +40,29 @@ export function EditPostPage({ slug }: { slug: string }) {
36
40
  },
37
41
  });
38
42
 
43
+ // Ref to capture the form instance from EditPostForm via onFormReady callback
44
+ const formRef = useRef<UseFormReturn<any> | null>(null);
45
+ const handleFormReady = useCallback((form: UseFormReturn<any>) => {
46
+ formRef.current = form;
47
+ }, []);
48
+
49
+ // Register AI context so the chat can fill in the edit form
50
+ useRegisterPageAIContext({
51
+ routeName: "blog-edit-post",
52
+ pageDescription: `User is editing a blog post (slug: "${slug}") in the admin editor.`,
53
+ suggestions: [
54
+ "Improve this post's title",
55
+ "Rewrite the intro paragraph",
56
+ "Suggest better tags",
57
+ ],
58
+ clientTools: {
59
+ fillBlogForm: createFillBlogFormHandler(
60
+ formRef,
61
+ "Form updated successfully",
62
+ ),
63
+ },
64
+ });
65
+
39
66
  const handleClose = () => {
40
67
  navigate(`${basePath}/blog`);
41
68
  };
@@ -61,6 +88,7 @@ export function EditPostPage({ slug }: { slug: string }) {
61
88
  onClose={handleClose}
62
89
  onSuccess={handleSuccess}
63
90
  onDelete={handleDelete}
91
+ onFormReady={handleFormReady}
64
92
  />
65
93
  </PageWrapper>
66
94
  );
@@ -0,0 +1,38 @@
1
+ import type { RefObject } from "react";
2
+ import type { UseFormReturn } from "react-hook-form";
3
+
4
+ /**
5
+ * Returns a `fillBlogForm` client tool handler bound to a form ref.
6
+ * Used by both the new-post and edit-post pages so the field-mapping
7
+ * logic stays in one place when the form schema changes.
8
+ */
9
+ export function createFillBlogFormHandler(
10
+ formRef: RefObject<UseFormReturn<any> | null>,
11
+ successMessage: string,
12
+ ) {
13
+ return async ({
14
+ title,
15
+ content,
16
+ excerpt,
17
+ tags,
18
+ }: {
19
+ title?: string;
20
+ content?: string;
21
+ excerpt?: string;
22
+ tags?: string[];
23
+ }) => {
24
+ const form = formRef.current;
25
+ if (!form) return { success: false, message: "Form not ready" };
26
+ if (title !== undefined)
27
+ form.setValue("title", title, { shouldValidate: true });
28
+ if (content !== undefined)
29
+ form.setValue("content", content, { shouldValidate: true });
30
+ if (excerpt !== undefined) form.setValue("excerpt", excerpt);
31
+ if (tags !== undefined)
32
+ form.setValue(
33
+ "tags",
34
+ tags.map((name: string) => ({ name })),
35
+ );
36
+ return { success: true, message: successMessage };
37
+ };
38
+ }
@@ -7,6 +7,10 @@ import { PageWrapper } from "../shared/page-wrapper";
7
7
  import type { BlogPluginOverrides } from "../../overrides";
8
8
  import { BLOG_LOCALIZATION } from "../../localization";
9
9
  import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle";
10
+ import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context";
11
+ import { useRef, useCallback } from "react";
12
+ import type { UseFormReturn } from "react-hook-form";
13
+ import { createFillBlogFormHandler } from "./fill-blog-form-handler";
10
14
 
11
15
  // Internal component with actual page content
12
16
  export function NewPostPage() {
@@ -35,6 +39,30 @@ export function NewPostPage() {
35
39
  },
36
40
  });
37
41
 
42
+ // Ref to capture the form instance from AddPostForm via onFormReady callback
43
+ const formRef = useRef<UseFormReturn<any> | null>(null);
44
+ const handleFormReady = useCallback((form: UseFormReturn<any>) => {
45
+ formRef.current = form;
46
+ }, []);
47
+
48
+ // Register AI context so the chat can fill in the new post form
49
+ useRegisterPageAIContext({
50
+ routeName: "blog-new-post",
51
+ pageDescription:
52
+ "User is creating a new blog post in the admin editor. IMPORTANT: When asked to write, draft, or create a blog post, you MUST call the fillBlogForm tool to populate the form fields directly — do NOT just output the text in your response.",
53
+ suggestions: [
54
+ "Write a post about AI trends",
55
+ "Draft an intro paragraph",
56
+ "Suggest 5 tags for this post",
57
+ ],
58
+ clientTools: {
59
+ fillBlogForm: createFillBlogFormHandler(
60
+ formRef,
61
+ "Form filled successfully",
62
+ ),
63
+ },
64
+ });
65
+
38
66
  const handleClose = () => {
39
67
  navigate(`${basePath}/blog`);
40
68
  };
@@ -54,7 +82,11 @@ export function NewPostPage() {
54
82
  title={localization.BLOG_POST_ADD_TITLE}
55
83
  description={localization.BLOG_POST_ADD_DESCRIPTION}
56
84
  />
57
- <AddPostForm onClose={handleClose} onSuccess={handleSuccess} />
85
+ <AddPostForm
86
+ onClose={handleClose}
87
+ onSuccess={handleSuccess}
88
+ onFormReady={handleFormReady}
89
+ />
58
90
  </PageWrapper>
59
91
  );
60
92
  }
@@ -20,6 +20,7 @@ import { Badge } from "@workspace/ui/components/badge";
20
20
  import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle";
21
21
  import { OnThisPage, OnThisPageSelect } from "../shared/on-this-page";
22
22
  import type { SerializedPost } from "../../../types";
23
+ import { useRegisterPageAIContext } from "@btst/stack/plugins/ai-chat/client/context";
23
24
 
24
25
  // Internal component with actual page content
25
26
  export function PostPage({ slug }: { slug: string }) {
@@ -64,6 +65,25 @@ export function PostPage({ slug }: { slug: string }) {
64
65
  enabled: !!post,
65
66
  });
66
67
 
68
+ // Register page AI context so the chat can summarize and discuss this post
69
+ useRegisterPageAIContext(
70
+ post
71
+ ? {
72
+ routeName: "blog-post",
73
+ pageDescription:
74
+ `Blog post: "${post.title}"\nAuthor: ${post.authorId ?? "Unknown"}\n\n${post.content ?? ""}`.slice(
75
+ 0,
76
+ 16000,
77
+ ),
78
+ suggestions: [
79
+ "Summarize this post",
80
+ "What are the key takeaways?",
81
+ "Explain this in simpler terms",
82
+ ],
83
+ }
84
+ : null,
85
+ );
86
+
67
87
  if (!slug || !post) {
68
88
  return (
69
89
  <PageWrapper>
@@ -12,4 +12,8 @@ export {
12
12
  serializeContentItem,
13
13
  serializeContentItemWithType,
14
14
  } from "./getters";
15
+ export {
16
+ createCMSContentItem,
17
+ type CreateCMSContentItemInput,
18
+ } from "./mutations";
15
19
  export { CMS_QUERY_KEYS } from "./query-key-defs";
@@ -0,0 +1,84 @@
1
+ import type { Adapter } from "@btst/db";
2
+ import type { ContentType, ContentItem } from "../types";
3
+ import { serializeContentItem } from "./getters";
4
+ import type { SerializedContentItem } from "../types";
5
+
6
+ /**
7
+ * Input for creating a new CMS content item.
8
+ */
9
+ export interface CreateCMSContentItemInput {
10
+ /** URL-safe slug for the item */
11
+ slug: string;
12
+ /** Arbitrary data payload — should match the content type schema */
13
+ data: Record<string, unknown>;
14
+ }
15
+
16
+ /**
17
+ * Create a new content item for a content type (looked up by slug).
18
+ *
19
+ * Bypasses Zod schema validation and relation processing — the caller is
20
+ * responsible for providing valid, relation-free data. For relation fields or
21
+ * schema validation, use the HTTP endpoint instead.
22
+ *
23
+ * Throws if the content type is not found or the slug is already taken within
24
+ * that content type.
25
+ *
26
+ * @remarks **Security:** No authorization hooks (`onBeforeCreate`, `onAfterCreate`)
27
+ * are called. The caller is responsible for any access-control checks before
28
+ * invoking this function.
29
+ *
30
+ * @param adapter - The database adapter
31
+ * @param contentTypeSlug - Slug of the target content type
32
+ * @param input - Item slug and data payload
33
+ */
34
+ export async function createCMSContentItem(
35
+ adapter: Adapter,
36
+ contentTypeSlug: string,
37
+ input: CreateCMSContentItemInput,
38
+ ): Promise<SerializedContentItem> {
39
+ const contentType = await adapter.findOne<ContentType>({
40
+ model: "contentType",
41
+ where: [
42
+ {
43
+ field: "slug",
44
+ value: contentTypeSlug,
45
+ operator: "eq" as const,
46
+ },
47
+ ],
48
+ });
49
+
50
+ if (!contentType) {
51
+ throw new Error(`Content type "${contentTypeSlug}" not found`);
52
+ }
53
+
54
+ const existing = await adapter.findOne<ContentItem>({
55
+ model: "contentItem",
56
+ where: [
57
+ {
58
+ field: "contentTypeId",
59
+ value: contentType.id,
60
+ operator: "eq" as const,
61
+ },
62
+ { field: "slug", value: input.slug, operator: "eq" as const },
63
+ ],
64
+ });
65
+
66
+ if (existing) {
67
+ throw new Error(
68
+ `Content item with slug "${input.slug}" already exists in type "${contentTypeSlug}"`,
69
+ );
70
+ }
71
+
72
+ const item = await adapter.create<ContentItem>({
73
+ model: "contentItem",
74
+ data: {
75
+ contentTypeId: contentType.id,
76
+ slug: input.slug,
77
+ data: JSON.stringify(input.data),
78
+ createdAt: new Date(),
79
+ updatedAt: new Date(),
80
+ },
81
+ });
82
+
83
+ return serializeContentItem(item);
84
+ }
@@ -30,6 +30,7 @@ import {
30
30
  serializeContentItem,
31
31
  serializeContentItemWithType,
32
32
  } from "./getters";
33
+ import { createCMSContentItem } from "./mutations";
33
34
  import type { QueryClient } from "@tanstack/react-query";
34
35
  import { CMS_QUERY_KEYS } from "./query-key-defs";
35
36
 
@@ -569,6 +570,14 @@ export const cmsBackendPlugin = (config: CMSBackendConfig) => {
569
570
  return getContentItemById(adapter, id);
570
571
  },
571
572
  prefetchForRoute: createCMSPrefetchForRoute(adapter),
573
+ // Mutations
574
+ createContentItem: async (
575
+ typeSlug: string,
576
+ input: Parameters<typeof createCMSContentItem>[2],
577
+ ) => {
578
+ await ensureSynced(adapter);
579
+ return createCMSContentItem(adapter, typeSlug, input);
580
+ },
572
581
  }),
573
582
 
574
583
  routes: (adapter: Adapter) => {
@@ -6,5 +6,11 @@ export {
6
6
  type KanbanBackendHooks,
7
7
  } from "./plugin";
8
8
  export { getAllBoards, getBoardById, type BoardListResult } from "./getters";
9
+ export {
10
+ createKanbanTask,
11
+ findOrCreateKanbanBoard,
12
+ getKanbanColumnsByBoardId,
13
+ type CreateKanbanTaskInput,
14
+ } from "./mutations";
9
15
  export { serializeBoard, serializeColumn, serializeTask } from "./serializers";
10
16
  export { KANBAN_QUERY_KEYS } from "./query-key-defs";