@rebasepro/plugin-ai 0.12.0 → 0.12.1-canary.g009ed95

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,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,250 @@ 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: current.fields.map((f) => ({ ...f,
191
+ pending: false }))
192
+ });
193
+ } catch (e: unknown) {
194
+ const message = e instanceof Error ? e.message : "Autofill could not be completed";
195
+ // Kept in the review rather than fired into a snackbar: a run that
196
+ // produced three good fields and then failed should still let the
197
+ // operator apply the three.
198
+ setReview((current) => current && {
199
+ ...current,
200
+ status: "failed",
201
+ error: message,
202
+ fields: current.fields.map((f) => ({ ...f,
203
+ pending: false }))
204
+ });
228
205
  }
206
+ }, [collection, endpoint, upsertField]);
229
207
 
230
- setLoadingSuggestions((prev) => [...prev, ...(props.propertyKey ? [props.propertyKey] : Object.keys(properties))]);
231
- enhancingInProgress.current = true;
232
-
233
- const currentValues = valuesRef.current ?? {};
208
+ const toggleField = useCallback((key: string) => {
209
+ setReview((current) => current && {
210
+ ...current,
211
+ fields: current.fields.map((f) => (f.key === key ? { ...f,
212
+ selected: !f.selected } : f))
213
+ });
214
+ }, []);
234
215
 
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
- }
216
+ const toggleAll = useCallback((selected: boolean) => {
217
+ setReview((current) => current && {
218
+ ...current,
219
+ fields: current.fields.map((f) => ({ ...f,
220
+ selected }))
221
+ });
222
+ }, []);
250
223
 
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,
224
+ const dismissReview = useCallback(() => setReview(null), []);
260
225
 
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);
226
+ const applyReview = useCallback(() => {
227
+ setReview((current) => {
228
+ if (!current) return null;
229
+ for (const field of current.fields) {
230
+ if (!field.selected || field.pending) continue;
231
+ if (field.proposed === undefined || field.proposed === null) continue;
232
+ formContext?.setFieldValue(field.key, field.proposed);
295
233
  }
234
+ return null;
296
235
  });
297
- }, [
298
- authController, urlController, path, clearSuggestion, clearAllSuggestions,
299
- properties, host, apiKey, collection, updateSuggestedValues, appendValueDelta, displayNeededSubscriptionSnackbar, snackbarController
300
- ]);
236
+ }, [formContext]);
301
237
 
302
- const getSamplePrompts = useCallback(async (entityName: string, input?: string) => {
303
- const firebaseToken = await authController.getAuthToken()
304
- return fetchEntityPromptSuggestion({
305
- host,
306
- entityName,
307
- firebaseToken,
308
- apiKey,
309
- input
310
- });
311
- }, [apiKey, authController.getAuthToken, host]);
238
+ const editorAIController = useEditorAIController({ endpoint });
239
+
240
+ const getSamplePrompts = useCallback(
241
+ (entityName: string, input?: string) => fetchPromptSuggestions({ endpoint,
242
+ entityName,
243
+ input }),
244
+ [endpoint]
245
+ );
312
246
 
313
247
  const dataEnhancementController: DataEnhancementController = useMemo(() => ({
314
248
  enabled,
315
- suggestions,
316
- clearSuggestion,
317
- enhance,
318
- allowReferenceDataSelection,
319
- clearAllSuggestions,
249
+ review,
250
+ generate,
251
+ toggleField,
252
+ toggleAll,
253
+ applyReview,
254
+ dismissReview,
320
255
  getSamplePrompts,
321
- loadingSuggestions,
322
256
  editorAIController
323
257
  }), [
324
258
  enabled,
325
- suggestions,
326
- clearSuggestion,
327
- enhance,
328
- allowReferenceDataSelection,
329
- clearAllSuggestions,
259
+ review,
260
+ generate,
261
+ toggleField,
262
+ toggleAll,
263
+ applyReview,
264
+ dismissReview,
330
265
  getSamplePrompts,
331
- loadingSuggestions,
332
266
  editorAIController
333
267
  ]);
334
268
 
@@ -339,5 +273,3 @@ export function DataEnhancementControllerProvider({
339
273
  </DataEnhancementControllerContext.Provider>
340
274
  );
341
275
  }
342
-
343
-