@btst/stack 2.3.0 → 2.5.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 (208) 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 +87 -54
  6. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.mjs +87 -54
  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/client/plugin.cjs +14 -21
  16. package/dist/packages/stack/src/plugins/ai-chat/client/plugin.mjs +15 -22
  17. package/dist/packages/stack/src/plugins/ai-chat/schemas.cjs +17 -1
  18. package/dist/packages/stack/src/plugins/ai-chat/schemas.mjs +17 -1
  19. package/dist/packages/stack/src/plugins/blog/api/plugin.cjs +28 -45
  20. package/dist/packages/stack/src/plugins/blog/api/plugin.mjs +22 -39
  21. package/dist/packages/stack/src/plugins/blog/client/components/forms/post-forms.cjs +15 -2
  22. package/dist/packages/stack/src/plugins/blog/client/components/forms/post-forms.mjs +16 -3
  23. package/dist/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.cjs +24 -1
  24. package/dist/packages/stack/src/plugins/blog/client/components/pages/edit-post-page.internal.mjs +24 -1
  25. package/dist/packages/stack/src/plugins/blog/client/components/pages/fill-blog-form-handler.cjs +26 -0
  26. package/dist/packages/stack/src/plugins/blog/client/components/pages/fill-blog-form-handler.mjs +24 -0
  27. package/dist/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.cjs +30 -1
  28. package/dist/packages/stack/src/plugins/blog/client/components/pages/new-post-page.internal.mjs +30 -1
  29. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.cjs +18 -0
  30. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.mjs +18 -0
  31. package/dist/packages/stack/src/plugins/blog/client/plugin.cjs +23 -27
  32. package/dist/packages/stack/src/plugins/blog/client/plugin.mjs +24 -28
  33. package/dist/packages/stack/src/plugins/cms/api/mutations.cjs +48 -0
  34. package/dist/packages/stack/src/plugins/cms/api/mutations.mjs +46 -0
  35. package/dist/packages/stack/src/plugins/cms/api/plugin.cjs +21 -18
  36. package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +21 -18
  37. package/dist/packages/stack/src/plugins/cms/client/plugin.cjs +11 -15
  38. package/dist/packages/stack/src/plugins/cms/client/plugin.mjs +12 -16
  39. package/dist/packages/stack/src/plugins/form-builder/api/plugin.cjs +58 -62
  40. package/dist/packages/stack/src/plugins/form-builder/api/plugin.mjs +58 -62
  41. package/dist/packages/stack/src/plugins/form-builder/client/plugin.cjs +12 -12
  42. package/dist/packages/stack/src/plugins/form-builder/client/plugin.mjs +13 -13
  43. package/dist/packages/stack/src/plugins/kanban/api/mutations.cjs +91 -0
  44. package/dist/packages/stack/src/plugins/kanban/api/mutations.mjs +87 -0
  45. package/dist/packages/stack/src/plugins/kanban/api/plugin.cjs +92 -118
  46. package/dist/packages/stack/src/plugins/kanban/api/plugin.mjs +89 -115
  47. package/dist/packages/stack/src/plugins/kanban/client/hooks/kanban-hooks.cjs +7 -3
  48. package/dist/packages/stack/src/plugins/kanban/client/hooks/kanban-hooks.mjs +7 -3
  49. package/dist/packages/stack/src/plugins/kanban/client/plugin.cjs +22 -29
  50. package/dist/packages/stack/src/plugins/kanban/client/plugin.mjs +23 -30
  51. package/dist/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.cjs +89 -0
  52. package/dist/packages/stack/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.mjs +89 -0
  53. package/dist/packages/stack/src/plugins/ui-builder/client/plugin.cjs +8 -8
  54. package/dist/packages/stack/src/plugins/ui-builder/client/plugin.mjs +9 -9
  55. package/dist/packages/stack/src/plugins/utils.cjs +42 -0
  56. package/dist/packages/stack/src/plugins/utils.mjs +41 -1
  57. package/dist/plugins/ai-chat/api/index.d.cts +1 -1
  58. package/dist/plugins/ai-chat/api/index.d.mts +1 -1
  59. package/dist/plugins/ai-chat/api/index.d.ts +1 -1
  60. package/dist/plugins/ai-chat/client/components/index.d.cts +1 -1
  61. package/dist/plugins/ai-chat/client/components/index.d.mts +1 -1
  62. package/dist/plugins/ai-chat/client/components/index.d.ts +1 -1
  63. package/dist/plugins/ai-chat/client/context/page-ai-context.cjs +92 -0
  64. package/dist/plugins/ai-chat/client/context/page-ai-context.d.cts +84 -0
  65. package/dist/plugins/ai-chat/client/context/page-ai-context.d.mts +84 -0
  66. package/dist/plugins/ai-chat/client/context/page-ai-context.d.ts +84 -0
  67. package/dist/plugins/ai-chat/client/context/page-ai-context.mjs +88 -0
  68. package/dist/plugins/ai-chat/client/hooks/index.d.cts +1 -1
  69. package/dist/plugins/ai-chat/client/hooks/index.d.mts +1 -1
  70. package/dist/plugins/ai-chat/client/hooks/index.d.ts +1 -1
  71. package/dist/plugins/ai-chat/client/index.d.cts +10 -10
  72. package/dist/plugins/ai-chat/client/index.d.mts +10 -10
  73. package/dist/plugins/ai-chat/client/index.d.ts +10 -10
  74. package/dist/plugins/ai-chat/query-keys.d.cts +1 -1
  75. package/dist/plugins/ai-chat/query-keys.d.mts +1 -1
  76. package/dist/plugins/ai-chat/query-keys.d.ts +1 -1
  77. package/dist/plugins/blog/api/index.d.cts +2 -2
  78. package/dist/plugins/blog/api/index.d.mts +2 -2
  79. package/dist/plugins/blog/api/index.d.ts +2 -2
  80. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  81. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  82. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  83. package/dist/plugins/blog/client/index.d.cts +13 -13
  84. package/dist/plugins/blog/client/index.d.mts +13 -13
  85. package/dist/plugins/blog/client/index.d.ts +13 -13
  86. package/dist/plugins/blog/query-keys.d.cts +2 -2
  87. package/dist/plugins/blog/query-keys.d.mts +2 -2
  88. package/dist/plugins/blog/query-keys.d.ts +2 -2
  89. package/dist/plugins/client/index.cjs +1 -0
  90. package/dist/plugins/client/index.d.cts +8 -1
  91. package/dist/plugins/client/index.d.mts +8 -1
  92. package/dist/plugins/client/index.d.ts +8 -1
  93. package/dist/plugins/client/index.mjs +1 -1
  94. package/dist/plugins/cms/api/index.cjs +2 -0
  95. package/dist/plugins/cms/api/index.d.cts +2 -2
  96. package/dist/plugins/cms/api/index.d.mts +2 -2
  97. package/dist/plugins/cms/api/index.d.ts +2 -2
  98. package/dist/plugins/cms/api/index.mjs +1 -0
  99. package/dist/plugins/cms/client/hooks/index.d.cts +1 -1
  100. package/dist/plugins/cms/client/hooks/index.d.mts +1 -1
  101. package/dist/plugins/cms/client/hooks/index.d.ts +1 -1
  102. package/dist/plugins/cms/client/index.d.cts +6 -6
  103. package/dist/plugins/cms/client/index.d.mts +6 -6
  104. package/dist/plugins/cms/client/index.d.ts +6 -6
  105. package/dist/plugins/cms/query-keys.d.cts +2 -2
  106. package/dist/plugins/cms/query-keys.d.mts +2 -2
  107. package/dist/plugins/cms/query-keys.d.ts +2 -2
  108. package/dist/plugins/form-builder/api/index.d.cts +2 -2
  109. package/dist/plugins/form-builder/api/index.d.mts +2 -2
  110. package/dist/plugins/form-builder/api/index.d.ts +2 -2
  111. package/dist/plugins/form-builder/client/components/index.d.cts +1 -1
  112. package/dist/plugins/form-builder/client/components/index.d.mts +1 -1
  113. package/dist/plugins/form-builder/client/components/index.d.ts +1 -1
  114. package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
  115. package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
  116. package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
  117. package/dist/plugins/form-builder/client/index.d.cts +6 -6
  118. package/dist/plugins/form-builder/client/index.d.mts +6 -6
  119. package/dist/plugins/form-builder/client/index.d.ts +6 -6
  120. package/dist/plugins/form-builder/query-keys.d.cts +2 -2
  121. package/dist/plugins/form-builder/query-keys.d.mts +2 -2
  122. package/dist/plugins/form-builder/query-keys.d.ts +2 -2
  123. package/dist/plugins/kanban/api/index.cjs +4 -0
  124. package/dist/plugins/kanban/api/index.d.cts +1 -1
  125. package/dist/plugins/kanban/api/index.d.mts +1 -1
  126. package/dist/plugins/kanban/api/index.d.ts +1 -1
  127. package/dist/plugins/kanban/api/index.mjs +1 -0
  128. package/dist/plugins/kanban/client/index.d.cts +12 -12
  129. package/dist/plugins/kanban/client/index.d.mts +12 -12
  130. package/dist/plugins/kanban/client/index.d.ts +12 -12
  131. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  132. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  133. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  134. package/dist/plugins/ui-builder/client/hooks/index.d.cts +1 -1
  135. package/dist/plugins/ui-builder/client/hooks/index.d.mts +1 -1
  136. package/dist/plugins/ui-builder/client/hooks/index.d.ts +1 -1
  137. package/dist/plugins/ui-builder/client/index.d.cts +3 -3
  138. package/dist/plugins/ui-builder/client/index.d.mts +3 -3
  139. package/dist/plugins/ui-builder/client/index.d.ts +3 -3
  140. package/dist/plugins/ui-builder/index.d.cts +2 -2
  141. package/dist/plugins/ui-builder/index.d.mts +2 -2
  142. package/dist/plugins/ui-builder/index.d.ts +2 -2
  143. package/dist/shared/{stack.C-WUPMT6.d.cts → stack.B2xZTSiO.d.cts} +4 -4
  144. package/dist/shared/{stack.B1EeBt1b.d.ts → stack.B58oHdqm.d.mts} +33 -3
  145. package/dist/shared/{stack.CVDTkMoO.d.mts → stack.B8QD11QU.d.cts} +7 -7
  146. package/dist/shared/{stack.CVDTkMoO.d.cts → stack.B8QD11QU.d.mts} +7 -7
  147. package/dist/shared/{stack.CVDTkMoO.d.ts → stack.B8QD11QU.d.ts} +7 -7
  148. package/dist/shared/{stack.CIP6QS9l.d.ts → stack.BDVEpue1.d.ts} +1 -1
  149. package/dist/shared/{stack.C5dtIncc.d.mts → stack.BTvbxZvw.d.cts} +1 -1
  150. package/dist/shared/{stack.DaOcgmrM.d.ts → stack.BV9hnvu4.d.cts} +31 -7
  151. package/dist/shared/{stack.DaOcgmrM.d.cts → stack.BV9hnvu4.d.mts} +31 -7
  152. package/dist/shared/{stack.DaOcgmrM.d.mts → stack.BV9hnvu4.d.ts} +31 -7
  153. package/dist/shared/{stack.DdI5W6MB.d.mts → stack.BozPgbrZ.d.cts} +19 -19
  154. package/dist/shared/{stack.DdI5W6MB.d.ts → stack.BozPgbrZ.d.mts} +19 -19
  155. package/dist/shared/{stack.DdI5W6MB.d.cts → stack.BozPgbrZ.d.ts} +19 -19
  156. package/dist/shared/{stack.CP68pFEH.d.mts → stack.C9Mg2Q46.d.cts} +33 -3
  157. package/dist/shared/{stack.BeSm90va.d.ts → stack.CTDVxbrA.d.ts} +72 -14
  158. package/dist/shared/{stack.C-Ptrz8s.d.ts → stack.Cj_zKww4.d.ts} +4 -4
  159. package/dist/shared/{stack.TIBF2AOx.d.ts → stack.CxaFNQCV.d.mts} +89 -34
  160. package/dist/shared/{stack.CMh_EdxW.d.cts → stack.D-b5zbPm.d.cts} +72 -14
  161. package/dist/shared/{stack.Dw0Ly2TM.d.cts → stack.DTtmJPQO.d.mts} +1 -1
  162. package/dist/shared/{stack.BKfolAyK.d.ts → stack.DXnclTG7.d.ts} +11 -11
  163. package/dist/shared/{stack.snB1EDP7.d.cts → stack.DaZM10cp.d.cts} +11 -11
  164. package/dist/shared/{stack.Dg09R0oB.d.mts → stack.FVWf2JhZ.d.mts} +72 -14
  165. package/dist/shared/{stack.BIXEI6v_.d.mts → stack.cfCkioTe.d.mts} +11 -11
  166. package/dist/shared/{stack.6fUOjLs9.d.mts → stack.dH7u-TJH.d.mts} +4 -4
  167. package/dist/shared/{stack.BpolpQpf.d.cts → stack.j75TpKh2.d.ts} +89 -34
  168. package/dist/shared/{stack.rTy7-wQU.d.mts → stack.n1_i1p2B.d.cts} +89 -34
  169. package/dist/shared/{stack.IdtKDRka.d.cts → stack.sO33ZDhK.d.ts} +33 -3
  170. package/package.json +14 -1
  171. package/src/client/components/compose.tsx +7 -4
  172. package/src/plugins/ai-chat/api/page-tools.ts +111 -0
  173. package/src/plugins/ai-chat/api/plugin.ts +228 -72
  174. package/src/plugins/ai-chat/client/components/chat-input.tsx +2 -2
  175. package/src/plugins/ai-chat/client/components/chat-interface.tsx +154 -58
  176. package/src/plugins/ai-chat/client/components/chat-layout.tsx +166 -32
  177. package/src/plugins/ai-chat/client/components/chat-sidebar.tsx +1 -1
  178. package/src/plugins/ai-chat/client/context/page-ai-context.tsx +240 -0
  179. package/src/plugins/ai-chat/client/plugin.tsx +23 -31
  180. package/src/plugins/ai-chat/schemas.ts +16 -0
  181. package/src/plugins/blog/api/plugin.ts +31 -47
  182. package/src/plugins/blog/client/components/forms/post-forms.tsx +29 -2
  183. package/src/plugins/blog/client/components/pages/edit-post-page.internal.tsx +28 -0
  184. package/src/plugins/blog/client/components/pages/fill-blog-form-handler.ts +38 -0
  185. package/src/plugins/blog/client/components/pages/new-post-page.internal.tsx +33 -1
  186. package/src/plugins/blog/client/components/pages/post-page.internal.tsx +20 -0
  187. package/src/plugins/blog/client/plugin.tsx +36 -39
  188. package/src/plugins/client/index.ts +5 -1
  189. package/src/plugins/cms/api/index.ts +4 -0
  190. package/src/plugins/cms/api/mutations.ts +84 -0
  191. package/src/plugins/cms/api/plugin.ts +23 -17
  192. package/src/plugins/cms/client/plugin.tsx +18 -21
  193. package/src/plugins/cms/types.ts +7 -7
  194. package/src/plugins/form-builder/api/plugin.ts +64 -64
  195. package/src/plugins/form-builder/client/plugin.tsx +19 -18
  196. package/src/plugins/form-builder/types.ts +19 -24
  197. package/src/plugins/kanban/api/index.ts +6 -0
  198. package/src/plugins/kanban/api/mutations.ts +169 -0
  199. package/src/plugins/kanban/api/plugin.ts +123 -136
  200. package/src/plugins/kanban/client/hooks/kanban-hooks.tsx +4 -0
  201. package/src/plugins/kanban/client/plugin.tsx +35 -41
  202. package/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.tsx +132 -0
  203. package/src/plugins/ui-builder/client/plugin.tsx +11 -10
  204. package/src/plugins/ui-builder/types.ts +4 -4
  205. package/src/plugins/utils.ts +92 -1
  206. package/dist/shared/{stack.CBON0dWL.d.mts → stack.BQmuNl5p.d.cts} +2 -2
  207. package/dist/shared/{stack.CBON0dWL.d.ts → stack.BQmuNl5p.d.mts} +2 -2
  208. package/dist/shared/{stack.CBON0dWL.d.cts → stack.BQmuNl5p.d.ts} +2 -2
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  defineClientPlugin,
3
3
  createApiClient,
4
+ runClientHookWithShim,
4
5
  } from "@btst/stack/plugins/client";
5
6
  import { createRoute } from "@btst/yar";
6
7
  import type { QueryClient } from "@tanstack/react-query";
@@ -92,35 +93,33 @@ export interface AiChatClientConfig {
92
93
  */
93
94
  export interface AiChatClientHooks {
94
95
  /**
95
- * Called before loading conversations list. Return false to cancel loading.
96
+ * Called before loading conversations list. Throw an error to cancel loading.
96
97
  * @param context - Loader context with path, params, etc.
97
98
  */
98
- beforeLoadConversations?: (
99
- context: LoaderContext,
100
- ) => Promise<boolean> | boolean;
99
+ beforeLoadConversations?: (context: LoaderContext) => Promise<void> | void;
101
100
 
102
101
  /**
103
- * Called after conversations are loaded. Return false to cancel further processing.
102
+ * Called after conversations are loaded. Throw an error to cancel further processing.
104
103
  * @param conversations - Array of loaded conversations or null
105
104
  * @param context - Loader context
106
105
  */
107
106
  afterLoadConversations?: (
108
107
  conversations: SerializedConversation[] | null,
109
108
  context: LoaderContext,
110
- ) => Promise<boolean> | boolean;
109
+ ) => Promise<void> | void;
111
110
 
112
111
  /**
113
- * Called before loading a single conversation. Return false to cancel loading.
112
+ * Called before loading a single conversation. Throw an error to cancel loading.
114
113
  * @param id - Conversation ID being loaded
115
114
  * @param context - Loader context
116
115
  */
117
116
  beforeLoadConversation?: (
118
117
  id: string,
119
118
  context: LoaderContext,
120
- ) => Promise<boolean> | boolean;
119
+ ) => Promise<void> | void;
121
120
 
122
121
  /**
123
- * Called after a conversation is loaded. Return false to cancel further processing.
122
+ * Called after a conversation is loaded. Throw an error to cancel further processing.
124
123
  * @param conversation - Loaded conversation or null if not found
125
124
  * @param id - Conversation ID that was requested
126
125
  * @param context - Loader context
@@ -131,7 +130,7 @@ export interface AiChatClientHooks {
131
130
  | null,
132
131
  id: string,
133
132
  context: LoaderContext,
134
- ) => Promise<boolean> | boolean;
133
+ ) => Promise<void> | void;
135
134
 
136
135
  /**
137
136
  * Called when a loading error occurs
@@ -163,10 +162,10 @@ function createConversationsLoader(config: AiChatClientConfig) {
163
162
  try {
164
163
  // Before hook
165
164
  if (hooks?.beforeLoadConversations) {
166
- const canLoad = await hooks.beforeLoadConversations(context);
167
- if (!canLoad) {
168
- throw new Error("Load prevented by beforeLoadConversations hook");
169
- }
165
+ await runClientHookWithShim(
166
+ () => hooks.beforeLoadConversations!(context),
167
+ "Load prevented by beforeLoadConversations hook",
168
+ );
170
169
  }
171
170
 
172
171
  const client = createApiClient<AiChatApiRouter>({
@@ -185,13 +184,10 @@ function createConversationsLoader(config: AiChatClientConfig) {
185
184
  queryClient.getQueryData<SerializedConversation[]>(
186
185
  listQuery.queryKey,
187
186
  ) || null;
188
- const canContinue = await hooks.afterLoadConversations(
189
- conversations,
190
- context,
187
+ await runClientHookWithShim(
188
+ () => hooks.afterLoadConversations!(conversations, context),
189
+ "Load prevented by afterLoadConversations hook",
191
190
  );
192
- if (canContinue === false) {
193
- throw new Error("Load prevented by afterLoadConversations hook");
194
- }
195
191
  }
196
192
 
197
193
  // Check for errors
@@ -230,10 +226,10 @@ function createConversationLoader(id: string, config: AiChatClientConfig) {
230
226
  try {
231
227
  // Before hook
232
228
  if (hooks?.beforeLoadConversation) {
233
- const canLoad = await hooks.beforeLoadConversation(id, context);
234
- if (!canLoad) {
235
- throw new Error("Load prevented by beforeLoadConversation hook");
236
- }
229
+ await runClientHookWithShim(
230
+ () => hooks.beforeLoadConversation!(id, context),
231
+ "Load prevented by beforeLoadConversation hook",
232
+ );
237
233
  }
238
234
 
239
235
  const client = createApiClient<AiChatApiRouter>({
@@ -258,14 +254,10 @@ function createConversationLoader(id: string, config: AiChatClientConfig) {
258
254
  queryClient.getQueryData<
259
255
  SerializedConversation & { messages: SerializedMessage[] }
260
256
  >(conversationQuery.queryKey) || null;
261
- const canContinue = await hooks.afterLoadConversation(
262
- conversation,
263
- id,
264
- context,
257
+ await runClientHookWithShim(
258
+ () => hooks.afterLoadConversation!(conversation, id, context),
259
+ "Load prevented by afterLoadConversation hook",
265
260
  );
266
- if (canContinue === false) {
267
- throw new Error("Load prevented by afterLoadConversation hook");
268
- }
269
261
  }
270
262
 
271
263
  // Check for errors
@@ -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
  });
@@ -10,6 +10,7 @@ import { getAllPosts, getPostBySlug, getAllTags } from "./getters";
10
10
  import { BLOG_QUERY_KEYS } from "./query-key-defs";
11
11
  import { serializePost, serializeTag } from "./serializers";
12
12
  import type { QueryClient } from "@tanstack/react-query";
13
+ import { runHookWithShim } from "../../utils";
13
14
 
14
15
  /**
15
16
  * Route keys for the blog plugin — matches the keys returned by
@@ -131,25 +132,25 @@ export interface BlogApiContext<TBody = any, TParams = any, TQuery = any> {
131
132
  */
132
133
  export interface BlogBackendHooks {
133
134
  /**
134
- * Called before listing posts. Return false to deny access.
135
+ * Called before listing posts. Throw an error to deny access.
135
136
  * @param filter - Query parameters for filtering posts
136
137
  * @param context - Request context with headers, etc.
137
138
  */
138
139
  onBeforeListPosts?: (
139
140
  filter: z.infer<typeof PostListQuerySchema>,
140
141
  context: BlogApiContext,
141
- ) => Promise<boolean> | boolean;
142
+ ) => Promise<void> | void;
142
143
  /**
143
- * Called before creating a post. Return false to deny access.
144
+ * Called before creating a post. Throw an error to deny access.
144
145
  * @param data - Post data being created
145
146
  * @param context - Request context with headers, etc.
146
147
  */
147
148
  onBeforeCreatePost?: (
148
149
  data: z.infer<typeof createPostSchema>,
149
150
  context: BlogApiContext,
150
- ) => Promise<boolean> | boolean;
151
+ ) => Promise<void> | void;
151
152
  /**
152
- * Called before updating a post. Return false to deny access.
153
+ * Called before updating a post. Throw an error to deny access.
153
154
  * @param postId - ID of the post being updated
154
155
  * @param data - Updated post data
155
156
  * @param context - Request context with headers, etc.
@@ -158,16 +159,16 @@ export interface BlogBackendHooks {
158
159
  postId: string,
159
160
  data: z.infer<typeof updatePostSchema>,
160
161
  context: BlogApiContext,
161
- ) => Promise<boolean> | boolean;
162
+ ) => Promise<void> | void;
162
163
  /**
163
- * Called before deleting a post. Return false to deny access.
164
+ * Called before deleting a post. Throw an error to deny access.
164
165
  * @param postId - ID of the post being deleted
165
166
  * @param context - Request context with headers, etc.
166
167
  */
167
168
  onBeforeDeletePost?: (
168
169
  postId: string,
169
170
  context: BlogApiContext,
170
- ) => Promise<boolean> | boolean;
171
+ ) => Promise<void> | void;
171
172
 
172
173
  /**
173
174
  * Called after posts are read successfully
@@ -350,12 +351,11 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
350
351
 
351
352
  try {
352
353
  if (hooks?.onBeforeListPosts) {
353
- const canList = await hooks.onBeforeListPosts(query, context);
354
- if (!canList) {
355
- throw ctx.error(403, {
356
- message: "Unauthorized: Cannot list posts",
357
- });
358
- }
354
+ await runHookWithShim(
355
+ () => hooks.onBeforeListPosts!(query, context),
356
+ ctx.error,
357
+ "Unauthorized: Cannot list posts",
358
+ );
359
359
  }
360
360
 
361
361
  const result = await getAllPosts(adapter, query);
@@ -387,15 +387,11 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
387
387
 
388
388
  try {
389
389
  if (hooks?.onBeforeCreatePost) {
390
- const canCreate = await hooks.onBeforeCreatePost(
391
- ctx.body,
392
- context,
390
+ await runHookWithShim(
391
+ () => hooks.onBeforeCreatePost!(ctx.body, context),
392
+ ctx.error,
393
+ "Unauthorized: Cannot create post",
393
394
  );
394
- if (!canCreate) {
395
- throw ctx.error(403, {
396
- message: "Unauthorized: Cannot create post",
397
- });
398
- }
399
395
  }
400
396
 
401
397
  const { tags, ...postData } = ctx.body;
@@ -471,16 +467,12 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
471
467
 
472
468
  try {
473
469
  if (hooks?.onBeforeUpdatePost) {
474
- const canUpdate = await hooks.onBeforeUpdatePost(
475
- ctx.params.id,
476
- ctx.body,
477
- context,
470
+ await runHookWithShim(
471
+ () =>
472
+ hooks.onBeforeUpdatePost!(ctx.params.id, ctx.body, context),
473
+ ctx.error,
474
+ "Unauthorized: Cannot update post",
478
475
  );
479
- if (!canUpdate) {
480
- throw ctx.error(403, {
481
- message: "Unauthorized: Cannot update post",
482
- });
483
- }
484
476
  }
485
477
 
486
478
  const { tags, slug: rawSlug, ...restPostData } = ctx.body;
@@ -598,15 +590,11 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
598
590
  try {
599
591
  // Authorization hook
600
592
  if (hooks?.onBeforeDeletePost) {
601
- const canDelete = await hooks.onBeforeDeletePost(
602
- ctx.params.id,
603
- context,
593
+ await runHookWithShim(
594
+ () => hooks.onBeforeDeletePost!(ctx.params.id, context),
595
+ ctx.error,
596
+ "Unauthorized: Cannot delete post",
604
597
  );
605
- if (!canDelete) {
606
- throw ctx.error(403, {
607
- message: "Unauthorized: Cannot delete post",
608
- });
609
- }
610
598
  }
611
599
 
612
600
  await adapter.delete<Post>({
@@ -642,15 +630,11 @@ export const blogBackendPlugin = (hooks?: BlogBackendHooks) =>
642
630
 
643
631
  try {
644
632
  if (hooks?.onBeforeListPosts) {
645
- const canList = await hooks.onBeforeListPosts(
646
- { published: true },
647
- context,
633
+ await runHookWithShim(
634
+ () => hooks.onBeforeListPosts!({ published: true }, context),
635
+ ctx.error,
636
+ "Unauthorized: Cannot list posts",
648
637
  );
649
- if (!canList) {
650
- throw ctx.error(403, {
651
- message: "Unauthorized: Cannot list posts",
652
- });
653
- }
654
638
  }
655
639
 
656
640
  const date = query.date;
@@ -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
  );