@rebasepro/plugin-ai 0.17.3-canary.gdd23447 → 0.18.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.
@@ -1,320 +0,0 @@
1
- import React, { PropsWithChildren, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
-
3
- import {
4
- AutofillReview,
5
- DataEnhancementController,
6
- GenerateParams,
7
- InputProperty,
8
- ProposedField
9
- } from "../types/data_enhancement_controller";
10
- import { CollectionConfig, User } from "@rebasepro/types";
11
- import { PluginFormActionProps } from "@rebasepro/cms-types";
12
- import { useAuthController } from "@rebasepro/app";
13
- import { autofillStream, fetchAiStatusCached, fetchPromptSuggestions } from "../api";
14
- import { getSimplifiedProperties } from "../utils/properties";
15
- import { flatMapEntityValues, omitDisabledValues } from "../utils/values";
16
- import { useEditorAIController } from "../editor/useEditorAIController";
17
- import { getValueInPath } from "@rebasepro/utils";
18
-
19
- const DataEnhancementControllerContext = React.createContext<DataEnhancementController>(null! as DataEnhancementController);
20
-
21
- type DataEnhancementControllerProviderProps = {
22
-
23
- /**
24
- * Kept in step with `DataEnhancementPluginProps.getConfigForPath`, which is
25
- * the signature the host app actually writes against: the plugin hands this
26
- * component through as `ComponentType<any>`, so nothing but agreement here
27
- * makes the two match.
28
- */
29
- getConfigForPath?: (props: {
30
- path: string,
31
- collection: CollectionConfig,
32
- user: User | null
33
- }) => boolean;
34
-
35
- endpoint?: string;
36
- }
37
-
38
- export const useDataEnhancementController = (): DataEnhancementController => useContext(DataEnhancementControllerContext);
39
-
40
- function getPropertyFromKey(properties: Record<string, InputProperty>, propertyKey: string): InputProperty | undefined {
41
- if (propertyKey in properties) {
42
- return properties[propertyKey];
43
- }
44
- const split = propertyKey.split(".");
45
- if (split.length === 1) return undefined;
46
- return getPropertyFromKey(properties, split.slice(0, -1).join("."));
47
- }
48
-
49
- /**
50
- * Convert a value off the wire into what the form field expects.
51
- *
52
- * Only dates need converting: the service answers ISO-8601 strings because JSON
53
- * has no date type, and handing a date field a string stores the wrong type
54
- * without complaining. Everything else — strings, numbers, booleans, arrays of
55
- * scalars — is already the shape the field wants, which is the point of having
56
- * the service constrain its answer to a schema derived from these properties.
57
- */
58
- function coerceToProperty(value: unknown, property: InputProperty | undefined): unknown {
59
- if (property?.type === "date" && typeof value === "string") {
60
- const date = new Date(value);
61
- return Number.isNaN(date.getTime()) ? undefined : date;
62
- }
63
- return value;
64
- }
65
-
66
- export function DataEnhancementControllerProvider({
67
- getConfigForPath,
68
- children,
69
- endpoint,
70
- path,
71
- collection,
72
- formContext
73
- }: PropsWithChildren<DataEnhancementControllerProviderProps & PluginFormActionProps>) {
74
-
75
- const [allowedHere, setAllowedHere] = useState(false);
76
- const [serviceAvailable, setServiceAvailable] = useState(false);
77
- const [review, setReview] = useState<AutofillReview | null>(null);
78
-
79
- const properties = useMemo(
80
- () => getSimplifiedProperties(collection.properties, formContext?.values ?? {}),
81
- [collection.properties, formContext?.values]
82
- );
83
-
84
- /**
85
- * Read inside the streaming callbacks, which outlive the render that
86
- * started the run.
87
- *
88
- * The operator is free to keep typing while the model works — nothing here
89
- * writes to the form — so the callbacks must not close over a stale
90
- * property map from whichever render happened to kick the run off.
91
- */
92
- const propertiesRef = useRef(properties);
93
- propertiesRef.current = properties;
94
-
95
- /**
96
- * The host app's own opt-out.
97
- *
98
- * `user` is part of the documented signature and was never passed, so
99
- * `getConfigForPath: ({ user }) => user?.roles?.includes("editor")` was
100
- * `Boolean(undefined)` for everyone — an access rule that silently decided
101
- * nothing, in whichever direction the host had written it.
102
- */
103
- const authController = useAuthController();
104
- const user: User | null = authController?.user ?? null;
105
-
106
- useEffect(() => {
107
- if (!getConfigForPath) {
108
- setAllowedHere(true);
109
- return;
110
- }
111
- setAllowedHere(Boolean(getConfigForPath({ path,
112
- collection,
113
- user })));
114
- }, [getConfigForPath, path, collection, user]);
115
-
116
- /**
117
- * The service's own availability.
118
- *
119
- * Nothing renders until this comes back true. An unreachable host, an
120
- * unconfigured provider key or an exhausted daily quota all land here, and
121
- * all of them mean the same thing to the operator: no Autofill button,
122
- * rather than a button that fails when clicked.
123
- *
124
- * Asked through the session cache: this provider is form-scoped, so an
125
- * uncached probe is one request to the host per record opened, by an install
126
- * that may never use the feature. The probe is shared rather than aborted on
127
- * unmount — cancelling it would cancel it for whatever else is waiting on the
128
- * same answer — so unmounting only stops this component from reading it.
129
- */
130
- useEffect(() => {
131
- if (!allowedHere) return;
132
- let cancelled = false;
133
- fetchAiStatusCached({ endpoint })
134
- .then((status) => {
135
- if (!cancelled) setServiceAvailable(status.available);
136
- });
137
- return () => {
138
- cancelled = true;
139
- };
140
- }, [allowedHere, endpoint]);
141
-
142
- const enabled = allowedHere && serviceAvailable;
143
-
144
- /** Add or update one row in the review, preserving arrival order. */
145
- const upsertField = useCallback((key: string, update: (existing: ProposedField | undefined) => ProposedField) => {
146
- setReview((current) => {
147
- if (!current) return current;
148
- const index = current.fields.findIndex((f) => f.key === key);
149
- const next = update(index === -1 ? undefined : current.fields[index]);
150
- const fields = index === -1
151
- ? [...current.fields, next]
152
- : current.fields.map((f, i) => (i === index ? next : f));
153
- return { ...current,
154
- fields };
155
- });
156
- }, []);
157
-
158
- const generate = useCallback(async (params: GenerateParams<Record<string, unknown>>): Promise<void> => {
159
-
160
- const currentProperties = propertiesRef.current;
161
- const flatValues = omitDisabledValues(
162
- flatMapEntityValues(params.values ?? {}),
163
- currentProperties
164
- );
165
-
166
- setReview({
167
- status: "generating",
168
- fields: [],
169
- instructions: params.instructions
170
- });
171
-
172
- const labelFor = (key: string) => currentProperties[key]?.name ?? key;
173
-
174
- try {
175
- await autofillStream({
176
- endpoint,
177
- request: {
178
- entityName: collection.singularName ?? collection.name,
179
- entityDescription: collection.description,
180
- // Flattened to dotted paths so the keys line up with the
181
- // property map: the service is told about `seo.title`, so it
182
- // has to be told the value of `seo.title` too, not of `seo`.
183
- // Exactly the same rule in both directions — an array or a
184
- // date is one value under one key here because it is one
185
- // property under one key there. Where they disagreed, the
186
- // service read a filled field as empty and offered to
187
- // rewrite it. Values of properties nobody may edit do not
188
- // travel at all.
189
- values: flatValues,
190
- properties: currentProperties,
191
- propertyKey: params.propertyKey,
192
- propertyInstructions: params.propertyInstructions,
193
- instructions: params.instructions
194
- },
195
- onDelta: (key, text) => {
196
- upsertField(key, (existing) => existing
197
- ? { ...existing,
198
- proposed: String(existing.proposed ?? "") + text }
199
- : {
200
- key,
201
- label: labelFor(key),
202
- currentValue: getValueInPath(params.values, key),
203
- proposed: text,
204
- pending: true,
205
- selected: true
206
- });
207
- },
208
- onValue: (key, value) => {
209
- const coerced = coerceToProperty(value, getPropertyFromKey(currentProperties, key));
210
- upsertField(key, (existing) => ({
211
- key,
212
- label: existing?.label ?? labelFor(key),
213
- currentValue: existing?.currentValue ?? getValueInPath(params.values, key),
214
- proposed: coerced,
215
- pending: false,
216
- // A row the operator already deselected mid-stream stays
217
- // deselected when its final value lands.
218
- selected: existing?.selected ?? true
219
- }));
220
- }
221
- });
222
-
223
- setReview((current) => current && {
224
- ...current,
225
- status: "ready",
226
- // Fields still pending when the run ended never received a final
227
- // value — the model's JSON was cut off mid-string, so all we
228
- // hold is a half-written sentence. Marking them complete would
229
- // make that sentence applicable, which is the exact outcome the
230
- // review exists to prevent. They are dropped instead: the review
231
- // only ever offers what the service actually finished.
232
- fields: current.fields.filter((f) => !f.pending)
233
- });
234
- } catch (e: unknown) {
235
- const message = e instanceof Error ? e.message : "Autofill could not be completed";
236
- // Kept in the review rather than fired into a snackbar: a run that
237
- // produced three good fields and then failed should still let the
238
- // operator apply the three.
239
- setReview((current) => current && {
240
- ...current,
241
- status: "failed",
242
- error: message,
243
- // Same rule as the success path: a field interrupted mid-write
244
- // is not something the operator can be offered.
245
- fields: current.fields.filter((f) => !f.pending)
246
- });
247
- }
248
- }, [collection, endpoint, upsertField]);
249
-
250
- const toggleField = useCallback((key: string) => {
251
- setReview((current) => current && {
252
- ...current,
253
- fields: current.fields.map((f) => (f.key === key ? { ...f,
254
- selected: !f.selected } : f))
255
- });
256
- }, []);
257
-
258
- const toggleAll = useCallback((selected: boolean) => {
259
- setReview((current) => current && {
260
- ...current,
261
- fields: current.fields.map((f) => ({ ...f,
262
- selected }))
263
- });
264
- }, []);
265
-
266
- const dismissReview = useCallback(() => setReview(null), []);
267
-
268
- const applyReview = useCallback(() => {
269
- setReview((current) => {
270
- if (!current) return null;
271
- for (const field of current.fields) {
272
- if (!field.selected || field.pending) continue;
273
- if (field.proposed === undefined || field.proposed === null) continue;
274
- formContext?.setFieldValue(field.key, field.proposed);
275
- }
276
- return null;
277
- });
278
- }, [formContext]);
279
-
280
- const editorAIController = useEditorAIController({ endpoint });
281
-
282
- const getSamplePrompts = useCallback(
283
- (entityName: string, input?: string) => fetchPromptSuggestions({
284
- endpoint,
285
- entityName,
286
- entityDescription: collection.description,
287
- input
288
- }),
289
- [endpoint, collection.description]
290
- );
291
-
292
- const dataEnhancementController: DataEnhancementController = useMemo(() => ({
293
- enabled,
294
- review,
295
- generate,
296
- toggleField,
297
- toggleAll,
298
- applyReview,
299
- dismissReview,
300
- getSamplePrompts,
301
- editorAIController
302
- }), [
303
- enabled,
304
- review,
305
- generate,
306
- toggleField,
307
- toggleAll,
308
- applyReview,
309
- dismissReview,
310
- getSamplePrompts,
311
- editorAIController
312
- ]);
313
-
314
- return (
315
- <DataEnhancementControllerContext.Provider
316
- value={dataEnhancementController}>
317
- {children}
318
- </DataEnhancementControllerContext.Provider>
319
- );
320
- }
@@ -1,307 +0,0 @@
1
- import React, { useCallback, useEffect, useRef } from "react";
2
-
3
- import {
4
- CircularProgress,
5
- cls,
6
- fieldBackgroundMixin,
7
- focusedDisabled,
8
- IconButton,
9
- iconSize,
10
- Menu,
11
- MenuItem,
12
- SendIcon,
13
- Separator,
14
- TextareaAutosize,
15
- XIcon
16
- } from "@rebasepro/ui";
17
- import {
18
- AIIcon
19
- } from "@rebasepro/app";
20
- import { EntityStatus, Properties, Property } from "@rebasepro/types";
21
- import { PluginFormActionProps } from "@rebasepro/cms-types";
22
- import { isPropertyBuilder, stripCollectionPath } from "@rebasepro/common";
23
- import { useDataEnhancementController } from "./DataEnhancementControllerProvider";
24
- import { AutofillReviewDialog } from "./AutofillReviewDialog";
25
- import { SamplePrompt } from "../types/data_enhancement_controller";
26
-
27
- export function FormEnhanceAction({
28
- path,
29
- status,
30
- collection,
31
- formContext
32
- }: PluginFormActionProps) {
33
-
34
- const storageKey = createLocalStorageKey(path, status);
35
-
36
- const dataEnhancementController = useDataEnhancementController();
37
-
38
- const [samplePrompts, setSamplePrompts] = React.useState<SamplePrompt[] | undefined>(undefined);
39
- const [instructions, setInstructions] = React.useState<string>("");
40
-
41
- const getSamplePrompts = dataEnhancementController?.getSamplePrompts;
42
-
43
- /**
44
- * Driven by the controller rather than by local state.
45
- *
46
- * There is exactly one run at a time, and the review owns it — a second
47
- * `loading` flag here could disagree with the dialog about whether the
48
- * model is still writing.
49
- */
50
- const loading = dataEnhancementController?.review?.status === "generating";
51
-
52
- const loadingPrompts = useRef(false);
53
- const updateSuggestedPrompts = useCallback(async function updateSuggestedPrompts(instructions?: string) {
54
- if (!getSamplePrompts) return;
55
- if (loadingPrompts.current) return;
56
- loadingPrompts.current = true;
57
- const prompts = status === "new"
58
- ? (await getSamplePrompts(collection.singularName ?? collection.name, instructions)).prompts
59
- : getPromptsForExistingEntities(collection.properties);
60
-
61
- const recentPromptsFromStorage = getRecentPromptsFromStorage(storageKey);
62
- const recentPrompts = recentPromptsFromStorage.map(prompt => prompt.prompt);
63
- setSamplePrompts([...recentPromptsFromStorage, ...prompts.filter(p => !recentPrompts.includes(p.prompt))].slice(0, 5));
64
- loadingPrompts.current = false;
65
- },
66
- [collection.name, collection.singularName, getSamplePrompts, status]);
67
-
68
- useEffect(() => {
69
- if (!dataEnhancementController) return;
70
- if (!samplePrompts) {
71
- setSamplePrompts(getRecentPromptsFromStorage(storageKey));
72
- updateSuggestedPrompts().then();
73
- }
74
- }, [dataEnhancementController, samplePrompts, storageKey, updateSuggestedPrompts, instructions, status]);
75
-
76
- useEffect(() => {
77
- if (!dataEnhancementController) return;
78
- updateSuggestedPrompts().then();
79
- }, [dataEnhancementController, status]);
80
-
81
- /**
82
- * Starts a run and opens the review. Nothing is written to the form here —
83
- * see {@link AutofillReviewDialog}.
84
- */
85
- const generate = (prompt?: string) => {
86
- if (!dataEnhancementController || !formContext?.values) return;
87
- if (prompt) {
88
- addRecentPrompt(storageKey, prompt);
89
- setSamplePrompts([{
90
- prompt,
91
- type: "recent"
92
- }, ...(samplePrompts ?? []).slice(0, 5)]);
93
- }
94
- // The controller records a failure in the review itself, so there is
95
- // nothing to catch here — but the promise is still explicitly handled
96
- // so a rejection can never surface as an unhandled one.
97
- dataEnhancementController.generate({
98
- values: formContext.values,
99
- instructions: prompt
100
- }).catch(() => undefined);
101
- };
102
-
103
- if (!dataEnhancementController?.enabled)
104
- return null;
105
-
106
- function submit() {
107
- generate(instructions);
108
- }
109
-
110
- return (
111
- <>
112
- <Menu
113
- align={"end"}
114
- sideOffset={8}
115
- className={"max-w-[100vw]"}
116
- // Never full width: this used to stretch to fill the form's
117
- // `w-80 2xl:w-96` side rail in full screen. That rail is gone, and
118
- // in the footer a stretched button reads as the primary action.
119
- // Icon only. The label is carried by `aria-label`/`title` — a
120
- // `Tooltip` here would swallow the menu: both it and
121
- // `DropdownMenu.Trigger` render `asChild`, and `Tooltip` drops
122
- // the props Radix clones onto it, so the menu never opens.
123
- trigger={<IconButton variant={"filled"}
124
- size={"small"}
125
- aria-label={"Autofill"}
126
- title={"Autofill"}
127
- disabled={loading}>
128
- {!loading && <AIIcon size={"small"}/>}
129
- {loading && <CircularProgress size={"small"}/>}
130
- </IconButton>}>
131
-
132
- <MenuItem className={"py-4"}
133
- onClick={() => {
134
- generate();
135
- }}>
136
- <AIIcon size={"small"}/>
137
- Autofill based on the current content
138
- </MenuItem>
139
-
140
- <Separator orientation={"horizontal"} className={"mt-2"}/>
141
-
142
- {samplePrompts?.map((samplePrompt, index) => {
143
- return <MenuItem
144
- key={index + "_" + samplePrompt.prompt}
145
- onClick={() => {
146
- setInstructions(samplePrompt.prompt);
147
- generate(samplePrompt.prompt);
148
- }}
149
- >
150
- <div className={"pl-9 grow text-text-secondary dark:text-text-secondary-dark"}>
151
- {samplePrompt.prompt}
152
- </div>
153
-
154
- {samplePrompt.type === "recent" && <IconButton
155
- onClick={(e) => {
156
- e.preventDefault();
157
- e.stopPropagation();
158
- removeRecentPrompt(storageKey, samplePrompt.prompt);
159
- setSamplePrompts((samplePrompts ?? []).filter(p => p.prompt !== samplePrompt.prompt));
160
- }}
161
- size={"smallest"}
162
- >
163
- <XIcon size={iconSize.smallest}/>
164
- </IconButton>
165
- }
166
- </MenuItem>;
167
- })}
168
-
169
- <Separator orientation={"horizontal"}/>
170
-
171
- {/* `px-4` and `gap-4` are MenuItem's own paddings, so the input
172
- row lines up with the items above it instead of sitting 8px
173
- to their left — which is what `mx-2` on the textarea did. */}
174
- {/* `items-center` so the send button sits on the field's centre
175
- line rather than pinned to its top edge as the textarea grows. */}
176
- <div
177
- className={cls(
178
- "my-2 px-4 py-2 gap-4 w-[500px] max-w-full flex items-center text-surface-700 dark:text-surface-200"
179
- )}>
180
-
181
- <div className={"relative w-full grow"}>
182
- {/* `fieldBackgroundMixin`, the same surface every other input
183
- in the codebase uses. It was `dark:bg-surface-950`, which
184
- theme.css defines as literal `#000000` — a pure black
185
- rectangle inside an already-dark menu. */}
186
- <TextareaAutosize
187
- className={cls("p-3 pr-12 rounded-lg resize-none w-full outline-hidden max-h-[300px] overflow-auto", fieldBackgroundMixin, focusedDisabled)}
188
- value={instructions}
189
- autoFocus={status === "new"}
190
- disabled={loading}
191
- onFocus={(event) => {
192
- event.stopPropagation();
193
- }}
194
- placeholder={"...or provide instructions"}
195
- onKeyDown={(e) => {
196
- e.stopPropagation();
197
- if (e.key === "Enter" && !e.shiftKey) {
198
- e.preventDefault();
199
- submit();
200
- }
201
-
202
- }}
203
- onChange={(e) => {
204
- setInstructions(e.target.value);
205
- }}
206
- />
207
-
208
- {/* Inside the field and only when there is something to
209
- clear — a permanently-visible X on an empty box is a
210
- control that does nothing. Positioned exactly as
211
- `TextFieldBinding` positions its own clearable X, so
212
- it stays centred as the textarea grows. */}
213
- {instructions.length > 0 && !loading && (
214
- <div
215
- className={"flex flex-row justify-center items-center absolute h-full right-0 top-0 mr-2"}>
216
- <IconButton
217
- size={"small"}
218
- onClick={() => {
219
- setInstructions("");
220
- }}>
221
- <XIcon size={iconSize.small}/>
222
- </IconButton>
223
- </div>
224
- )}
225
- </div>
226
-
227
- <IconButton
228
- onClick={() => generate(instructions)}
229
- size={"small"}
230
- color={!instructions ? "primary" : undefined}
231
- disabled={loading || !instructions}>
232
- {loading &&
233
- <CircularProgress size={"smallest"}/>}
234
- {/* Sized, and no `color`. These icons are re-exported
235
- straight from lucide, so `color` lands on the SVG as
236
- a CSS colour — and `"primary"` is not one, which is
237
- why this button rendered empty. Every other icon in
238
- the codebase passes `size` alone and inherits
239
- `currentColor` from the button. */}
240
- {!loading &&
241
- <SendIcon size={iconSize.small}/>}
242
- </IconButton>
243
-
244
- </div>
245
-
246
- </Menu>
247
-
248
- <AutofillReviewDialog/>
249
- </>
250
- );
251
- }
252
-
253
- function getPromptsForExistingEntities(properties: Properties): SamplePrompt[] {
254
-
255
- const multilineProperties = Object.values(properties).filter((p: Property) => {
256
- if (isPropertyBuilder(p)) {
257
- return false;
258
- }
259
- return p.type === "string" && (p.admin?.markdown || p.admin?.multiline);
260
- });
261
-
262
- const multilinePrompt: Property | undefined = multilineProperties.length > 0
263
- ? multilineProperties[Math.floor(Math.random() * multilineProperties.length)] as Property
264
- : undefined;
265
-
266
- const prompts = [
267
- "Fill the missing fields",
268
- "Translate the missing content"
269
- ];
270
- if (multilinePrompt) {
271
- prompts.push(`Add 2 paragraphs to '${multilinePrompt.name}'`);
272
- }
273
- return prompts.map(p => ({
274
- prompt: p,
275
- type: "sample"
276
- }));
277
- }
278
-
279
- const createLocalStorageKey = (path: string, status: EntityStatus) => {
280
- const statusString = status === "new" ? "new" : "existing";
281
- return `data_enhancement::${statusString}::${stripCollectionPath(path)}`;
282
- };
283
-
284
- const getRecentPromptsFromStorage = (storageKey: string): SamplePrompt[] => {
285
- const item = localStorage.getItem(storageKey);
286
- return item ? JSON.parse(item).map((e: string) => ({
287
- prompt: e,
288
- type: "recent"
289
- })) : [];
290
- };
291
-
292
- const addRecentPrompt = (storageKey: string, prompt: string) => {
293
- if (!prompt || prompt.trim().length === 0) {
294
- return;
295
- }
296
- const recentPrompts = getRecentPromptsFromStorage(storageKey);
297
- localStorage.setItem(storageKey, JSON.stringify([prompt, ...recentPrompts
298
- .map(e => e.prompt)
299
- .filter(e => e !== prompt)
300
- .slice(0, 5)]));
301
- };
302
-
303
- const removeRecentPrompt = (storageKey: string, prompt: string) => {
304
- localStorage.setItem(storageKey, JSON.stringify(getRecentPromptsFromStorage(storageKey)
305
- .map(e => e.prompt)
306
- .filter(e => e !== prompt)));
307
- };
@@ -1,24 +0,0 @@
1
- import React from "react";
2
- import { autocompleteStream } from "../api";
3
- import { EditorAIController } from "@rebasepro/cms";
4
-
5
- /**
6
- * Inline continuation for the rich-text editor's slash command.
7
- *
8
- * No token is threaded through any more. The previous version demanded a
9
- * Firebase ID token and threw `"Firebase token is required"` when it could not
10
- * get one — in a Rebase app there is no such thing, and the token it actually
11
- * sent was a Rebase JWT the receiving service had no way to verify. The hosted
12
- * service authenticates nobody; see `src/api.ts`.
13
- */
14
- export function useEditorAIController({ endpoint }: { endpoint?: string } = {}): EditorAIController {
15
- return React.useMemo(() => ({
16
- autocomplete: (textBefore: string, textAfter: string, onUpdate: (delta: string) => void) =>
17
- autocompleteStream({
18
- endpoint,
19
- textBefore,
20
- textAfter,
21
- onDelta: onUpdate
22
- })
23
- }), [endpoint]);
24
- }
package/src/index.ts DELETED
@@ -1,9 +0,0 @@
1
- export {
2
- useDataEnhancementPlugin
3
- } from "./useDataEnhancementPlugin";
4
- export type {
5
- DataEnhancementPluginProps
6
- } from "./useDataEnhancementPlugin";
7
-
8
-
9
- export * from "./editor/useEditorAIController";