@rebasepro/plugin-ai 0.12.1-canary.gf5f1d39 → 0.13.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 (33) hide show
  1. package/README.md +62 -18
  2. package/dist/api.d.ts +58 -30
  3. package/dist/components/AutofillReviewDialog.d.ts +16 -0
  4. package/dist/components/DataEnhancementControllerProvider.d.ts +2 -3
  5. package/dist/components/FormEnhanceAction.d.ts +1 -1
  6. package/dist/editor/useEditorAIController.d.ts +11 -2
  7. package/dist/index.es.js +601 -382
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/types/data_enhancement_controller.d.ts +84 -28
  10. package/dist/useDataEnhancementPlugin.d.ts +10 -5
  11. package/package.json +24 -19
  12. package/src/api.ts +241 -174
  13. package/src/components/AutofillReviewDialog.tsx +209 -0
  14. package/src/components/DataEnhancementControllerProvider.tsx +203 -262
  15. package/src/components/FormEnhanceAction.tsx +154 -128
  16. package/src/editor/useEditorAIController.tsx +20 -33
  17. package/src/tests/AutofillReviewDialog.test.tsx +340 -0
  18. package/src/tests/api.test.ts +283 -0
  19. package/src/tests/properties.test.ts +420 -0
  20. package/src/tests/review.test.tsx +393 -0
  21. package/src/tests/useDataEnhancementPlugin.test.tsx +36 -15
  22. package/src/tests/useEditorAIController.test.ts +98 -0
  23. package/src/types/data_enhancement_controller.tsx +98 -31
  24. package/src/useDataEnhancementPlugin.tsx +13 -12
  25. package/dist/utils/diffStrings.d.ts +0 -7
  26. package/dist/utils/strings_counter.d.ts +0 -2
  27. package/dist/utils/suggestions.d.ts +0 -1
  28. package/src/tests/diffStrings.test.ts +0 -128
  29. package/src/tests/strings_counter.test.ts +0 -117
  30. package/src/tests/suggestions.test.ts +0 -53
  31. package/src/utils/diffStrings.ts +0 -70
  32. package/src/utils/strings_counter.ts +0 -22
  33. package/src/utils/suggestions.ts +0 -6
@@ -1,22 +1,17 @@
1
1
  import React, { PropsWithChildren, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
2
 
3
3
  import {
4
+ AutofillReview,
4
5
  DataEnhancementController,
5
- EnhancedDataResult,
6
- EnhanceParams,
7
- InputProperty
6
+ GenerateParams,
7
+ InputProperty,
8
+ ProposedField
8
9
  } from "../types/data_enhancement_controller";
9
- import {
10
- useAuthController,
11
- useCustomizationController,
12
- useSnackbarController
13
- } from "@rebasepro/app";
14
- import { useUrlController } from "@rebasepro/admin";
15
- import { DataDriver, Entity, CollectionConfig } from "@rebasepro/types";
10
+ import { CollectionConfig } from "@rebasepro/types";
16
11
  import { PluginFormActionProps } from "@rebasepro/admin-types";
17
- import { enhanceDataAPIStream, fetchEntityPromptSuggestion } from "../api";
18
- import { getAppendableSuggestion } from "../utils/suggestions";
12
+ import { autofillStream, fetchAiStatus, fetchPromptSuggestions } from "../api";
19
13
  import { getSimplifiedProperties } from "../utils/properties";
14
+ import { flatMapEntityValues } from "../utils/values";
20
15
  import { useEditorAIController } from "../editor/useEditorAIController";
21
16
  import { getValueInPath } from "@rebasepro/utils";
22
17
 
@@ -24,311 +19,259 @@ const DataEnhancementControllerContext = React.createContext<DataEnhancementCont
24
19
 
25
20
  type DataEnhancementControllerProviderProps = {
26
21
 
27
- apiKey: string;
28
-
29
22
  getConfigForPath?: (props: {
30
23
  path: string,
31
24
  collection: CollectionConfig
32
25
  }) => boolean;
33
26
 
34
- host?: string;
27
+ endpoint?: string;
35
28
  }
36
29
 
37
30
  export const useDataEnhancementController = (): DataEnhancementController => useContext(DataEnhancementControllerContext);
38
31
 
39
- function getPropertyFromKey(properties: Record<string, InputProperty>, propertyKey: string) {
32
+ function getPropertyFromKey(properties: Record<string, InputProperty>, propertyKey: string): InputProperty | undefined {
40
33
  if (propertyKey in properties) {
41
34
  return properties[propertyKey];
42
- } else {
43
- //split the property key
44
- const split = propertyKey.split(".");
45
- if (split.length === 1) {
46
- return undefined;
47
- }
48
- const parentKey = split.slice(0, split.length - 1).join(".");
49
- return getPropertyFromKey(properties, parentKey);
35
+ }
36
+ const split = propertyKey.split(".");
37
+ if (split.length === 1) return undefined;
38
+ return getPropertyFromKey(properties, split.slice(0, -1).join("."));
39
+ }
50
40
 
41
+ /**
42
+ * Convert a value off the wire into what the form field expects.
43
+ *
44
+ * Only dates need converting: the service answers ISO-8601 strings because JSON
45
+ * has no date type, and handing a date field a string stores the wrong type
46
+ * without complaining. Everything else — strings, numbers, booleans, arrays of
47
+ * scalars — is already the shape the field wants, which is the point of having
48
+ * the service constrain its answer to a schema derived from these properties.
49
+ */
50
+ function coerceToProperty(value: unknown, property: InputProperty | undefined): unknown {
51
+ if (property?.type === "date" && typeof value === "string") {
52
+ const date = new Date(value);
53
+ return Number.isNaN(date.getTime()) ? undefined : date;
51
54
  }
55
+ return value;
52
56
  }
53
57
 
54
58
  export function DataEnhancementControllerProvider({
55
- apiKey,
56
59
  getConfigForPath,
57
60
  children,
58
- host,
61
+ endpoint,
59
62
  path,
60
63
  collection,
61
64
  formContext
62
65
  }: PropsWithChildren<DataEnhancementControllerProviderProps & PluginFormActionProps>) {
63
66
 
64
- const [enabled, setEnabled] = useState(false);
65
- const [suggestions, setSuggestions] = useState<Record<string, string | number>>({});
66
- const [loadingSuggestions, setLoadingSuggestions] = useState<string[]>([]);
67
-
68
- const enhancingInProgress = useRef(false);
69
-
70
- const authController = useAuthController();
71
- const snackbarController = useSnackbarController();
72
-
67
+ const [allowedHere, setAllowedHere] = useState(false);
68
+ const [serviceAvailable, setServiceAvailable] = useState(false);
69
+ const [review, setReview] = useState<AutofillReview | null>(null);
73
70
 
74
- const properties = useMemo(() => getSimplifiedProperties(collection.properties, formContext?.values ?? {}), [formContext?.values]);
75
- // const preEnhanceValuesRef = React.useRef(formContext?.values ?? {});
76
- const valuesRef = React.useRef(formContext?.values ?? {});
77
- useEffect(() => {
78
- if (!enhancingInProgress.current)
79
- valuesRef.current = formContext?.values ?? {};
80
- }, [formContext?.values]);
81
-
82
- const allowReferenceDataSelection = false;
83
-
84
- const updateConfig = useCallback(async () => {
85
- if (!getConfigForPath) return;
86
- const config = getConfigForPath({
87
- path,
88
- collection
89
- });
90
- if (config) {
91
- setEnabled(true);
92
- }
93
- }, [collection, getConfigForPath, path]);
71
+ const properties = useMemo(
72
+ () => getSimplifiedProperties(collection.properties, formContext?.values ?? {}),
73
+ [collection.properties, formContext?.values]
74
+ );
94
75
 
76
+ /**
77
+ * Read inside the streaming callbacks, which outlive the render that
78
+ * started the run.
79
+ *
80
+ * The operator is free to keep typing while the model works — nothing here
81
+ * writes to the form — so the callbacks must not close over a stale
82
+ * property map from whichever render happened to kick the run off.
83
+ */
84
+ const propertiesRef = useRef(properties);
85
+ propertiesRef.current = properties;
86
+
87
+ /** The host app's own opt-out. */
95
88
  useEffect(() => {
96
89
  if (!getConfigForPath) {
97
- setEnabled(true);
98
- } else {
99
- updateConfig();
90
+ setAllowedHere(true);
91
+ return;
100
92
  }
101
-
102
- }, [getConfigForPath, updateConfig]);
103
-
104
-
105
- const urlController = useUrlController();
106
-
107
- const clearSuggestion = useCallback((propertyKey: string) => {
108
- setSuggestions((prev) => {
109
- //remove propertyKey from prev
110
- const {
111
- [propertyKey]: _,
112
- ...rest
113
- } = prev;
114
- return rest;
93
+ setAllowedHere(Boolean(getConfigForPath({ path,
94
+ collection })));
95
+ }, [getConfigForPath, path, collection]);
96
+
97
+ /**
98
+ * The service's own availability.
99
+ *
100
+ * Nothing renders until this comes back true. An unreachable host, an
101
+ * unconfigured provider key or an exhausted daily quota all land here, and
102
+ * all of them mean the same thing to the operator: no Autofill button,
103
+ * rather than a button that fails when clicked.
104
+ */
105
+ useEffect(() => {
106
+ if (!allowedHere) return;
107
+ const abort = new AbortController();
108
+ fetchAiStatus({ endpoint,
109
+ signal: abort.signal })
110
+ .then((status) => setServiceAvailable(status.available))
111
+ .catch(() => setServiceAvailable(false));
112
+ return () => abort.abort();
113
+ }, [allowedHere, endpoint]);
114
+
115
+ const enabled = allowedHere && serviceAvailable;
116
+
117
+ /** Add or update one row in the review, preserving arrival order. */
118
+ const upsertField = useCallback((key: string, update: (existing: ProposedField | undefined) => ProposedField) => {
119
+ setReview((current) => {
120
+ if (!current) return current;
121
+ const index = current.fields.findIndex((f) => f.key === key);
122
+ const next = update(index === -1 ? undefined : current.fields[index]);
123
+ const fields = index === -1
124
+ ? [...current.fields, next]
125
+ : current.fields.map((f, i) => (i === index ? next : f));
126
+ return { ...current,
127
+ fields };
115
128
  });
116
129
  }, []);
117
130
 
118
- const appendValueDelta = useCallback((propertyKey: string, delta: string) => {
119
-
120
- const property = getPropertyFromKey(properties, propertyKey);
121
- if (delta === null || property?.disabled) {
122
- return;
123
- }
124
-
125
- // clearSuggestion(propertyKey);
126
- const value = getValueInPath(valuesRef.current, propertyKey);
131
+ const generate = useCallback(async (params: GenerateParams<Record<string, unknown>>): Promise<void> => {
127
132
 
128
- const currentValue = value ? (value as string) + "" : "";
129
- const updatedValue = currentValue + delta;
130
- // if (currentValue.length === 0) updatedValue = updatedValue.trimStart();
131
- valuesRef.current = {
132
- ...valuesRef.current,
133
- [propertyKey]: updatedValue
134
- };
135
- formContext?.setFieldValue(propertyKey, updatedValue, false);
136
- setSuggestions(prev => ({
137
- ...prev,
138
- [propertyKey]: (prev[propertyKey] ?? "") + delta
139
- }));
140
- }, [properties, formContext]);
133
+ const currentProperties = propertiesRef.current;
134
+ const flatValues = flatMapEntityValues(params.values ?? {}) as Record<string, unknown>;
141
135
 
142
- const updateSuggestedValues = useCallback((currentValues: object, updatedValues: Record<string, string | number>, replaceValues: boolean) => {
143
-
144
- setLoadingSuggestions((prev) => {
145
- return prev.filter(p => !Object.keys(updatedValues).includes(p));
136
+ setReview({
137
+ status: "generating",
138
+ fields: [],
139
+ instructions: params.instructions
146
140
  });
147
141
 
148
- Object.entries(updatedValues).forEach(([propertyKey, suggestion]) => {
149
-
150
- const value = getValueInPath(currentValues, propertyKey);
151
- const property = getPropertyFromKey(properties, propertyKey);
152
-
153
- if (!property || suggestion === null || property?.disabled) {
154
- return;
155
- }
156
-
157
- if (typeof suggestion === "number") {
158
- formContext?.setFieldValue(propertyKey, suggestion);
159
- return;
160
- }
161
-
162
- if (replaceValues) {
163
- formContext?.setFieldValue(propertyKey, suggestion);
164
- return;
165
- }
166
-
167
- const appendableValue = getAppendableSuggestion(suggestion, value);
142
+ const labelFor = (key: string) => currentProperties[key]?.name ?? key;
168
143
 
169
- const currentValue = value ? (value as string) + "" : "";
170
- if (appendableValue) {
171
- formContext?.setFieldValue(propertyKey, suggestion);
172
- } else {
173
- const multiline = property?.fieldConfigId === "multiline" || property?.fieldConfigId === "markdown";
174
- const trimmedValue = currentValue.trimEnd();
175
- if (multiline && (trimmedValue.endsWith(".") || trimmedValue.endsWith("?") || trimmedValue.endsWith("!") || trimmedValue.endsWith(":"))) {
176
- formContext?.setFieldValue(propertyKey, trimmedValue + "\n\n" + (suggestion as string).trimStart());
177
- } else {
178
- formContext?.setFieldValue(propertyKey, trimmedValue + (trimmedValue.length > 0 ? " " : "") + (suggestion as string));
144
+ try {
145
+ await autofillStream({
146
+ endpoint,
147
+ request: {
148
+ entityName: collection.singularName ?? collection.name,
149
+ entityDescription: collection.description,
150
+ // Flattened to dotted paths so the keys line up with the
151
+ // property map: the service is told about `seo.title`, so it
152
+ // has to be told the value of `seo.title` too, not of `seo`.
153
+ values: flatValues,
154
+ properties: currentProperties,
155
+ propertyKey: params.propertyKey,
156
+ propertyInstructions: params.propertyInstructions,
157
+ instructions: params.instructions
158
+ },
159
+ onDelta: (key, text) => {
160
+ upsertField(key, (existing) => existing
161
+ ? { ...existing,
162
+ proposed: String(existing.proposed ?? "") + text }
163
+ : {
164
+ key,
165
+ label: labelFor(key),
166
+ currentValue: getValueInPath(params.values, key),
167
+ proposed: text,
168
+ pending: true,
169
+ selected: true
170
+ });
171
+ },
172
+ onValue: (key, value) => {
173
+ const coerced = coerceToProperty(value, getPropertyFromKey(currentProperties, key));
174
+ upsertField(key, (existing) => ({
175
+ key,
176
+ label: existing?.label ?? labelFor(key),
177
+ currentValue: existing?.currentValue ?? getValueInPath(params.values, key),
178
+ proposed: coerced,
179
+ pending: false,
180
+ // A row the operator already deselected mid-stream stays
181
+ // deselected when its final value lands.
182
+ selected: existing?.selected ?? true
183
+ }));
179
184
  }
180
- }
181
- });
182
-
183
- setSuggestions(prev => ({
184
- ...prev,
185
- ...Object.keys(updatedValues)
186
- .reduce((acc, key) => {
187
- const value = getValueInPath(formContext?.values, key);
188
- const suggestion = updatedValues[key];
189
- return {
190
- ...acc,
191
- [key]: getAppendableSuggestion(suggestion, value) ?? suggestion
192
- };
193
- }, {})
194
- }));
195
- }, [properties, formContext]);
196
-
197
- const displayNeededSubscriptionSnackbar = useCallback((projectId: unknown) => {
198
- snackbarController.open({
199
- type: "warning",
200
- message: "A valid subscription is needed in order to use this function.",
201
- autoHideDuration: 4000
202
- });
203
- }, [snackbarController]);
204
-
205
- const editorAIController = useEditorAIController({ getAuthToken: authController.getAuthToken });
206
-
207
- const clearAllSuggestions = useCallback(() => {
208
- setSuggestions({});
209
- }, []);
210
-
211
- const enhance = useCallback(async (props: EnhanceParams<Record<string, unknown>>): Promise<EnhancedDataResult | null> => {
212
-
213
- if (!authController.user) {
214
- snackbarController.open({
215
- type: "warning",
216
- message: "You need to be logged in to enhance data"
217
185
  });
218
- return Promise.reject(new Error("Not logged in"));
219
- }
220
186
 
221
- const resolvedPath = urlController.resolveDatabasePathsFrom(path);
222
- const firebaseToken = await authController.getAuthToken();
223
-
224
- if (props.propertyKey) {
225
- clearSuggestion(props.propertyKey)
226
- } else {
227
- clearAllSuggestions();
187
+ setReview((current) => current && {
188
+ ...current,
189
+ status: "ready",
190
+ // Fields still pending when the run ended never received a final
191
+ // value — the model's JSON was cut off mid-string, so all we
192
+ // hold is a half-written sentence. Marking them complete would
193
+ // make that sentence applicable, which is the exact outcome the
194
+ // review exists to prevent. They are dropped instead: the review
195
+ // only ever offers what the service actually finished.
196
+ fields: current.fields.filter((f) => !f.pending)
197
+ });
198
+ } catch (e: unknown) {
199
+ const message = e instanceof Error ? e.message : "Autofill could not be completed";
200
+ // Kept in the review rather than fired into a snackbar: a run that
201
+ // produced three good fields and then failed should still let the
202
+ // operator apply the three.
203
+ setReview((current) => current && {
204
+ ...current,
205
+ status: "failed",
206
+ error: message,
207
+ // Same rule as the success path: a field interrupted mid-write
208
+ // is not something the operator can be offered.
209
+ fields: current.fields.filter((f) => !f.pending)
210
+ });
228
211
  }
212
+ }, [collection, endpoint, upsertField]);
229
213
 
230
- setLoadingSuggestions((prev) => [...prev, ...(props.propertyKey ? [props.propertyKey] : Object.keys(properties))]);
231
- enhancingInProgress.current = true;
232
-
233
- const currentValues = valuesRef.current ?? {};
214
+ const toggleField = useCallback((key: string) => {
215
+ setReview((current) => current && {
216
+ ...current,
217
+ fields: current.fields.map((f) => (f.key === key ? { ...f,
218
+ selected: !f.selected } : f))
219
+ });
220
+ }, []);
234
221
 
235
- return new Promise((resolve, reject) => {
236
- function onError(e: unknown) {
237
- setLoadingSuggestions([]);
238
- const err = e instanceof Error ? e : typeof e === "object" && e !== null ? e : new Error(String(e));
239
- const errorObj = err as Record<string, unknown>;
240
- if (errorObj.code === "payment-required") {
241
- const data = errorObj.data as Record<string, unknown> | undefined;
242
- const projectId = data?.projectId;
243
- displayNeededSubscriptionSnackbar(projectId);
244
- } else {
245
- console.error("Enhance error", e);
246
- }
247
- reject(e);
248
- enhancingInProgress.current = false;
249
- }
222
+ const toggleAll = useCallback((selected: boolean) => {
223
+ setReview((current) => current && {
224
+ ...current,
225
+ fields: current.fields.map((f) => ({ ...f,
226
+ selected }))
227
+ });
228
+ }, []);
250
229
 
251
- try {
252
- enhanceDataAPIStream({
253
- ...props,
254
- host,
255
- apiKey,
256
- properties,
257
- path: resolvedPath,
258
- entityName: collection.singularName ?? collection.name,
259
- entityDescription: collection.description,
230
+ const dismissReview = useCallback(() => setReview(null), []);
260
231
 
261
- firebaseToken,
262
- onUpdate: (suggestions) => {
263
- console.debug("de onUpdate", suggestions);
264
- updateSuggestedValues(currentValues, suggestions, props.replaceValues ?? false);
265
- },
266
- onUpdateDelta: (propertyKey: string, partialValue: string) => {
267
- // console.debug("de delta", propertyKey, partialValue);
268
- appendValueDelta(propertyKey, partialValue);
269
- },
270
- onError,
271
- onEnd: (result) => {
272
- console.debug("de onEnd", result);
273
- if (result.errors) {
274
- result.errors.forEach((error) => {
275
- snackbarController.open({
276
- type: "warning",
277
- message: error
278
- })
279
- });
280
- }
281
- if (Object.keys(result.suggestions).length === 0) {
282
- snackbarController.open({
283
- type: "info",
284
- autoHideDuration: 1800,
285
- message: "No fields were updated"
286
- })
287
- }
288
- setLoadingSuggestions([]);
289
- resolve(result);
290
- enhancingInProgress.current = false;
291
- }
292
- }).catch(onError);
293
- } catch (e: unknown) {
294
- onError(e);
232
+ const applyReview = useCallback(() => {
233
+ setReview((current) => {
234
+ if (!current) return null;
235
+ for (const field of current.fields) {
236
+ if (!field.selected || field.pending) continue;
237
+ if (field.proposed === undefined || field.proposed === null) continue;
238
+ formContext?.setFieldValue(field.key, field.proposed);
295
239
  }
240
+ return null;
296
241
  });
297
- }, [
298
- authController, urlController, path, clearSuggestion, clearAllSuggestions,
299
- properties, host, apiKey, collection, updateSuggestedValues, appendValueDelta, displayNeededSubscriptionSnackbar, snackbarController
300
- ]);
242
+ }, [formContext]);
243
+
244
+ const editorAIController = useEditorAIController({ endpoint });
301
245
 
302
- const getSamplePrompts = useCallback(async (entityName: string, input?: string) => {
303
- const firebaseToken = await authController.getAuthToken()
304
- return fetchEntityPromptSuggestion({
305
- host,
246
+ const getSamplePrompts = useCallback(
247
+ (entityName: string, input?: string) => fetchPromptSuggestions({
248
+ endpoint,
306
249
  entityName,
307
- firebaseToken,
308
- apiKey,
250
+ entityDescription: collection.description,
309
251
  input
310
- });
311
- }, [apiKey, authController.getAuthToken, host]);
252
+ }),
253
+ [endpoint, collection.description]
254
+ );
312
255
 
313
256
  const dataEnhancementController: DataEnhancementController = useMemo(() => ({
314
257
  enabled,
315
- suggestions,
316
- clearSuggestion,
317
- enhance,
318
- allowReferenceDataSelection,
319
- clearAllSuggestions,
258
+ review,
259
+ generate,
260
+ toggleField,
261
+ toggleAll,
262
+ applyReview,
263
+ dismissReview,
320
264
  getSamplePrompts,
321
- loadingSuggestions,
322
265
  editorAIController
323
266
  }), [
324
267
  enabled,
325
- suggestions,
326
- clearSuggestion,
327
- enhance,
328
- allowReferenceDataSelection,
329
- clearAllSuggestions,
268
+ review,
269
+ generate,
270
+ toggleField,
271
+ toggleAll,
272
+ applyReview,
273
+ dismissReview,
330
274
  getSamplePrompts,
331
- loadingSuggestions,
332
275
  editorAIController
333
276
  ]);
334
277
 
@@ -339,5 +282,3 @@ export function DataEnhancementControllerProvider({
339
282
  </DataEnhancementControllerContext.Provider>
340
283
  );
341
284
  }
342
-
343
-