@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.
- 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/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/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 +7 -1
- package/dist/packages/stack/src/plugins/cms/api/plugin.mjs +7 -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 +6 -1
- package/dist/packages/stack/src/plugins/kanban/api/plugin.mjs +6 -1
- 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/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/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.d.cts +2 -2
- package/dist/plugins/blog/api/index.d.mts +2 -2
- package/dist/plugins/blog/api/index.d.ts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
- 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.d.cts +2 -2
- package/dist/plugins/blog/query-keys.d.mts +2 -2
- package/dist/plugins/blog/query-keys.d.ts +2 -2
- package/dist/plugins/cms/api/index.cjs +2 -0
- package/dist/plugins/cms/api/index.d.cts +1 -1
- package/dist/plugins/cms/api/index.d.mts +1 -1
- package/dist/plugins/cms/api/index.d.ts +1 -1
- package/dist/plugins/cms/api/index.mjs +1 -0
- package/dist/plugins/cms/query-keys.d.cts +1 -1
- package/dist/plugins/cms/query-keys.d.mts +1 -1
- package/dist/plugins/cms/query-keys.d.ts +1 -1
- package/dist/plugins/form-builder/api/index.d.cts +1 -1
- package/dist/plugins/form-builder/api/index.d.mts +1 -1
- package/dist/plugins/form-builder/api/index.d.ts +1 -1
- package/dist/plugins/form-builder/query-keys.d.cts +1 -1
- package/dist/plugins/form-builder/query-keys.d.mts +1 -1
- package/dist/plugins/form-builder/query-keys.d.ts +1 -1
- package/dist/plugins/kanban/api/index.cjs +4 -0
- package/dist/plugins/kanban/api/index.d.cts +1 -1
- package/dist/plugins/kanban/api/index.d.mts +1 -1
- package/dist/plugins/kanban/api/index.d.ts +1 -1
- package/dist/plugins/kanban/api/index.mjs +1 -0
- package/dist/plugins/kanban/query-keys.d.cts +1 -1
- package/dist/plugins/kanban/query-keys.d.mts +1 -1
- package/dist/plugins/kanban/query-keys.d.ts +1 -1
- package/dist/shared/{stack.BeSm90va.d.ts → stack.BEn34wW6.d.ts} +60 -2
- package/dist/shared/{stack.IdtKDRka.d.cts → stack.BUkC2EsZ.d.cts} +32 -2
- 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.rTy7-wQU.d.mts → stack.BepFXT3w.d.mts} +70 -15
- package/dist/shared/{stack.BKfolAyK.d.ts → stack.CL8ts1Mu.d.ts} +3 -3
- package/dist/shared/{stack.CP68pFEH.d.mts → stack.CczspVn2.d.mts} +32 -2
- package/dist/shared/{stack.TIBF2AOx.d.ts → stack.CgWzG5jH.d.ts} +70 -15
- package/dist/shared/{stack.BpolpQpf.d.cts → stack.D3GB6wKv.d.cts} +70 -15
- package/dist/shared/{stack.B1EeBt1b.d.ts → stack.DASmUVjX.d.ts} +32 -2
- 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.snB1EDP7.d.cts → stack.Dk5r4W1F.d.mts} +3 -3
- package/dist/shared/{stack.BIXEI6v_.d.mts → stack.heOA9gzA.d.cts} +3 -3
- package/package.json +14 -1
- 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/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/cms/api/index.ts +4 -0
- package/src/plugins/cms/api/mutations.ts +84 -0
- package/src/plugins/cms/api/plugin.ts +9 -0
- package/src/plugins/kanban/api/index.ts +6 -0
- package/src/plugins/kanban/api/mutations.ts +169 -0
- package/src/plugins/kanban/api/plugin.ts +12 -0
- package/src/plugins/kanban/client/hooks/kanban-hooks.tsx +4 -0
- package/src/plugins/ui-builder/client/components/pages/page-builder-page.internal.tsx +132 -0
- package/dist/shared/{stack.C5dtIncc.d.mts → stack.B7ONvlD_.d.mts} +1 -1
- package/dist/shared/{stack.CBON0dWL.d.cts → stack.BQmuNl5p.d.cts} +2 -2
- package/dist/shared/{stack.CBON0dWL.d.mts → stack.BQmuNl5p.d.mts} +2 -2
- package/dist/shared/{stack.CBON0dWL.d.ts → stack.BQmuNl5p.d.ts} +2 -2
- package/dist/shared/{stack.CIP6QS9l.d.ts → stack.Kq2-QzOC.d.ts} +1 -1
- 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 = ({
|
|
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
|
|
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>
|
|
@@ -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";
|