@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
package/dist/index.es.js CHANGED
@@ -1,143 +1,194 @@
1
- import React, { useCallback, useContext, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
2
- import { AIIcon, useAuthController, useLargeLayout, useSnackbarController } from "@rebasepro/app";
3
- import { getFieldId, useUrlController } from "@rebasepro/admin";
1
+ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
+ import { getFieldId } from "@rebasepro/admin";
4
3
  import { isPropertyBuilder, stripCollectionPath } from "@rebasepro/common";
5
4
  import { getValueInPath } from "@rebasepro/utils";
6
- import { jsx, jsxs } from "react/jsx-runtime";
7
- import { Button, CircularProgress, IconButton, Menu, MenuItem, SendIcon, Separator, TextareaAutosize, XIcon, cls, focusedDisabled, iconSize } from "@rebasepro/ui";
8
- //#region src/utils/values.ts
9
- function flatMapEntityValues(values, path = "") {
10
- if (!values) return {};
11
- return Object.entries(values).flatMap(([key, value]) => {
12
- const currentPath = path ? `${path}.${key}` : key;
13
- if (typeof value === "object") return flatMapEntityValues(value, currentPath);
14
- else return { [currentPath]: value };
15
- }).reduce((acc, curr) => ({
16
- ...acc,
17
- ...curr
18
- }), {});
5
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
+ import { Button, Checkbox, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, SendIcon, Separator, TextareaAutosize, Typography, XIcon, cls, fieldBackgroundMixin, focusedDisabled, iconSize } from "@rebasepro/ui";
7
+ import { AIIcon } from "@rebasepro/app";
8
+ /**
9
+ * ## No credentials cross this boundary
10
+ *
11
+ * The old client sent the tenant's Rebase JWT as `Authorization: Basic <jwt>`
12
+ * plus a hardcoded `fcms-…` key compiled into the published package. Both were
13
+ * wrong in the same way: a self-hosted backend signs its tokens with its own
14
+ * secret, so no external service can verify one — sending it only handed a live
15
+ * credential to a third party that had no use for it.
16
+ *
17
+ * These requests are anonymous. The service bounds cost by rate limit and daily
18
+ * ceiling rather than by identity, and reports through {@link fetchAiStatus}
19
+ * when it can no longer serve — which is what keeps the UI from offering an
20
+ * action that is going to fail.
21
+ */
22
+ function endpointOf(endpoint, path) {
23
+ return (endpoint ?? "https://app.rebase.pro/api/functions/ai").replace(/\/+$/, "") + path;
19
24
  }
20
- //#endregion
21
- //#region src/api.ts
22
- var DEFAULT_SERVER = "https://api.rebase.pro";
23
- async function enhanceDataAPIStream(props) {
24
- const flatValues = flatMapEntityValues(props.values);
25
- const properties = props.properties;
26
- const request = {
27
- inputEntity: {
28
- entityId: props.entityId,
29
- values: flatValues
30
- },
31
- properties,
32
- entityName: props.entityName,
33
- entityDescription: props.entityDescription,
34
- propertyKey: props.propertyKey,
35
- propertyInstructions: props.propertyInstructions,
36
- instructions: props.instructions
37
- };
38
- console.debug("enhanceDataAPIStream", request);
39
- return fetch((props.host ?? DEFAULT_SERVER) + "/data/enhance_stream/", {
40
- method: "POST",
41
- headers: {
42
- "Content-Type": "application/json",
43
- Authorization: `Basic ${props.firebaseToken}`,
44
- "x-de-api-key": `Basic ${props.apiKey}`
45
- },
46
- body: JSON.stringify(request)
47
- }).then(async (res) => {
48
- if (!res.ok) {
49
- console.error("enhanceDataAPIStream error", res);
50
- throw await res.json();
51
- }
52
- const reader = res.body?.getReader();
53
- if (!reader) throw new Error("No reader");
54
- for await (const chunk of readChunks(reader)) {
55
- const str = new TextDecoder().decode(chunk);
56
- try {
57
- str.split("&$# ").forEach((s) => {
58
- if (s && s.length > 0) {
59
- const data = JSON.parse(s.trim());
60
- if (data.type === "suggestion_delta") props.onUpdateDelta(data.data.propertyKey, data.data.partialValue);
61
- else if (data.type === "suggestion") props.onUpdate(data.data);
62
- else if (data.type === "result") props.onEnd(data.data);
63
- }
64
- });
65
- } catch (e) {
66
- console.error("str", str);
67
- console.error("Error parsing stream", e);
68
- props.onError(e instanceof Error ? e : new Error(String(e)));
69
- }
25
+ /** Not global: `exec` must not carry `lastIndex` between buffer reads. */
26
+ var SSE_SEPARATOR = /\r?\n\r?\n/;
27
+ /**
28
+ * Parse an SSE body incrementally.
29
+ *
30
+ * The framing this replaces split each chunk on the literal `"&$# "` and
31
+ * `JSON.parse`d the pieces, which corrupted itself the moment a delimiter
32
+ * straddled two reads — and network reads land wherever they land. Buffering
33
+ * until a blank line is the fix, and it is also just what SSE specifies.
34
+ */
35
+ async function* readServerSentEvents(response) {
36
+ const reader = response.body?.getReader();
37
+ if (!reader) throw new Error("The AI service returned no response body");
38
+ const decoder = new TextDecoder();
39
+ let buffer = "";
40
+ for (;;) {
41
+ const { done, value } = await reader.read();
42
+ if (done) break;
43
+ buffer += decoder.decode(value, { stream: true });
44
+ let match = SSE_SEPARATOR.exec(buffer);
45
+ while (match) {
46
+ const raw = buffer.slice(0, match.index);
47
+ buffer = buffer.slice(match.index + match[0].length);
48
+ const parsed = parseEventBlock(raw);
49
+ if (parsed) yield parsed;
50
+ match = SSE_SEPARATOR.exec(buffer);
70
51
  }
71
- });
52
+ }
72
53
  }
73
- function readChunks(reader) {
74
- return { async *[Symbol.asyncIterator]() {
75
- let readResult = await reader.read();
76
- while (!readResult.done) {
77
- yield readResult.value;
78
- readResult = await reader.read();
79
- }
80
- } };
54
+ function parseEventBlock(block) {
55
+ let event = "message";
56
+ const dataLines = [];
57
+ for (const line of block.split(/\r?\n/)) {
58
+ if (line.startsWith(":")) continue;
59
+ const separator = line.indexOf(":");
60
+ const field = separator === -1 ? line : line.slice(0, separator);
61
+ const rawValue = separator === -1 ? "" : line.slice(separator + 1);
62
+ const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue;
63
+ if (field === "event") event = value;
64
+ else if (field === "data") dataLines.push(value);
65
+ }
66
+ if (dataLines.length === 0) return void 0;
67
+ return {
68
+ event,
69
+ data: dataLines.join("\n")
70
+ };
71
+ }
72
+ /** Pull a message out of the control plane's `{ error: { message } }` envelope. */
73
+ async function errorFrom(response, fallback) {
74
+ try {
75
+ const message = (await response.json())?.error?.message;
76
+ if (typeof message === "string" && message) return new Error(message);
77
+ } catch {}
78
+ return new Error(fallback);
81
79
  }
82
- async function fetchEntityPromptSuggestion(props) {
83
- return fetch((props.host ?? DEFAULT_SERVER) + "/data/prompt_autocomplete/", {
80
+ /**
81
+ * Ask the service whether it can serve a request at all.
82
+ *
83
+ * The plugin gates every affordance on this. A missing provider key, an
84
+ * exhausted daily quota or an unreachable host all resolve to `available:
85
+ * false`, and the Autofill button is simply not rendered — rather than
86
+ * rendered, clicked, and failed.
87
+ */
88
+ async function fetchAiStatus(props) {
89
+ const response = await fetch(endpointOf(props.endpoint, "/status"), {
90
+ method: "GET",
91
+ signal: props.signal
92
+ });
93
+ if (!response.ok) return { available: false };
94
+ const body = await response.json();
95
+ return {
96
+ available: Boolean(body?.available),
97
+ model: typeof body?.model === "string" ? body.model : void 0,
98
+ features: Array.isArray(body?.features) ? body.features : void 0
99
+ };
100
+ }
101
+ /**
102
+ * Fill a record, streaming each field as the service writes it.
103
+ *
104
+ * `onDelta` fires with more text for a field still being written; `onValue`
105
+ * fires once a field is complete and carries its final, correctly typed value.
106
+ * A caller that implements only `onValue` still ends up with the right record —
107
+ * the deltas exist so a long text field fills in visibly instead of appearing
108
+ * all at once.
109
+ */
110
+ async function autofillStream(props) {
111
+ const response = await fetch(endpointOf(props.endpoint, "/autofill"), {
84
112
  method: "POST",
85
- headers: {
86
- "Content-Type": "application/json",
87
- Authorization: `Basic ${props.firebaseToken}`,
88
- "x-de-api-key": `Basic ${props.apiKey}`
89
- },
90
- body: JSON.stringify({
91
- entityName: props.entityName,
92
- input: props.input ?? null
93
- })
94
- }).then(async (res) => {
95
- const data = await res.json();
96
- if (!res.ok) {
97
- console.error("fetchEntityPromptSuggestion", data);
98
- throw Error(data.message);
99
- }
100
- return { prompts: data.data.prompts.map((e) => ({
101
- prompt: e,
102
- type: "sample"
103
- })) };
113
+ headers: { "Content-Type": "application/json" },
114
+ body: JSON.stringify(props.request),
115
+ signal: props.signal
104
116
  });
117
+ if (!response.ok) throw await errorFrom(response, "The AI service could not complete this request.");
118
+ let result = { suggestions: {} };
119
+ for await (const { event, data } of readServerSentEvents(response)) {
120
+ let payload;
121
+ try {
122
+ payload = JSON.parse(data);
123
+ } catch {
124
+ continue;
125
+ }
126
+ if (event === "suggestion_delta") props.onDelta(payload.key, payload.text);
127
+ else if (event === "suggestion") props.onValue(payload.key, payload.value);
128
+ else if (event === "done") result = {
129
+ suggestions: payload.suggestions ?? {},
130
+ usage: payload.usage
131
+ };
132
+ else if (event === "error") throw new Error(payload.message ?? "The AI service reported an error.");
133
+ }
134
+ return result;
105
135
  }
136
+ /** Inline continuation for the rich-text editor. Streams plain text. */
106
137
  async function autocompleteStream(props) {
107
- let result = "";
108
- return fetch((props.host ?? DEFAULT_SERVER) + "/data/autocomplete/", {
138
+ const response = await fetch(endpointOf(props.endpoint, "/autocomplete"), {
109
139
  method: "POST",
110
- headers: {
111
- "Content-Type": "application/json",
112
- Authorization: `Basic ${props.firebaseToken}`
113
- },
140
+ headers: { "Content-Type": "application/json" },
114
141
  body: JSON.stringify({
115
- textBefore: props.textBefore,
116
- textAfter: props.textAfter
117
- })
118
- }).then(async (res) => {
119
- if (!res.ok) {
120
- console.error("enhanceDataAPIStream error", res);
121
- throw await res.json();
142
+ textBefore: props.textBefore ?? "",
143
+ textAfter: props.textAfter ?? ""
144
+ }),
145
+ signal: props.signal
146
+ });
147
+ if (!response.ok) throw await errorFrom(response, "The AI service could not complete this request.");
148
+ let text = "";
149
+ for await (const { event, data } of readServerSentEvents(response)) {
150
+ let payload;
151
+ try {
152
+ payload = JSON.parse(data);
153
+ } catch {
154
+ continue;
122
155
  }
123
- const reader = res.body?.getReader();
124
- if (!reader) throw new Error("No reader");
125
- for await (const chunk of readChunks(reader)) {
126
- const str = new TextDecoder().decode(chunk);
127
- result += str;
128
- console.debug("Autocomplete update:", str);
129
- props.onUpdate(str);
156
+ if (event === "error") throw new Error(payload?.message ?? "The AI service reported an error.");
157
+ if (event === "delta" && typeof payload?.text === "string") {
158
+ text += payload.text;
159
+ props.onDelta(payload.text);
130
160
  }
131
- }).then(() => {
132
- console.debug("Autocomplete result:", result);
133
- return result;
134
- });
161
+ }
162
+ return text;
135
163
  }
136
- //#endregion
137
- //#region src/utils/suggestions.ts
138
- function getAppendableSuggestion(suggestion, value) {
139
- const suggestionIncludesValue = typeof suggestion === "string" && typeof value === "string" && suggestion.toLowerCase().trim().startsWith(value.toLowerCase().trim());
140
- return typeof value === "string" && suggestionIncludesValue ? suggestion.substring(suggestion.toLowerCase().trim().indexOf(value.toLowerCase().trim()) + value.trim().length) : void 0;
164
+ /**
165
+ * Sample prompts for the Autofill menu.
166
+ *
167
+ * Failure is deliberately not thrown: the menu has built-in prompts to fall
168
+ * back on, and an empty suggestion list is a far better outcome than an error
169
+ * toast for something nobody asked for.
170
+ */
171
+ async function fetchPromptSuggestions(props) {
172
+ try {
173
+ const response = await fetch(endpointOf(props.endpoint, "/prompts"), {
174
+ method: "POST",
175
+ headers: { "Content-Type": "application/json" },
176
+ body: JSON.stringify({
177
+ entityName: props.entityName,
178
+ entityDescription: props.entityDescription,
179
+ input: props.input
180
+ }),
181
+ signal: props.signal
182
+ });
183
+ if (!response.ok) return { prompts: [] };
184
+ const body = await response.json();
185
+ return { prompts: (Array.isArray(body?.prompts) ? body.prompts : []).filter((p) => typeof p === "string").map((prompt) => ({
186
+ prompt,
187
+ type: "sample"
188
+ })) };
189
+ } catch {
190
+ return { prompts: [] };
191
+ }
141
192
  }
142
193
  //#endregion
143
194
  //#region src/utils/properties.ts
@@ -262,18 +313,36 @@ function getSimpleEnumValues(enumValues) {
262
313
  throw Error("getSimpleEnumValues: Invalid enumValues");
263
314
  }
264
315
  //#endregion
316
+ //#region src/utils/values.ts
317
+ function flatMapEntityValues(values, path = "") {
318
+ if (!values) return {};
319
+ return Object.entries(values).flatMap(([key, value]) => {
320
+ const currentPath = path ? `${path}.${key}` : key;
321
+ if (typeof value === "object") return flatMapEntityValues(value, currentPath);
322
+ else return { [currentPath]: value };
323
+ }).reduce((acc, curr) => ({
324
+ ...acc,
325
+ ...curr
326
+ }), {});
327
+ }
328
+ //#endregion
265
329
  //#region src/editor/useEditorAIController.tsx
266
- function useEditorAIController({ getAuthToken }) {
267
- const autocomplete = async (textBefore, textAfter, onUpdate) => {
268
- if (!getAuthToken) throw new Error("Firebase token is required");
269
- return autocompleteStream({
270
- firebaseToken: await getAuthToken(),
271
- textBefore,
272
- textAfter,
273
- onUpdate
274
- });
275
- };
276
- return { autocomplete };
330
+ /**
331
+ * Inline continuation for the rich-text editor's slash command.
332
+ *
333
+ * No token is threaded through any more. The previous version demanded a
334
+ * Firebase ID token and threw `"Firebase token is required"` when it could not
335
+ * get one — in a Rebase app there is no such thing, and the token it actually
336
+ * sent was a Rebase JWT the receiving service had no way to verify. The hosted
337
+ * service authenticates nobody; see `src/api.ts`.
338
+ */
339
+ function useEditorAIController({ endpoint } = {}) {
340
+ return React.useMemo(() => ({ autocomplete: (textBefore, textAfter, onUpdate) => autocompleteStream({
341
+ endpoint,
342
+ textBefore,
343
+ textAfter,
344
+ onDelta: onUpdate
345
+ }) }), [endpoint]);
277
346
  }
278
347
  //#endregion
279
348
  //#region src/components/DataEnhancementControllerProvider.tsx
@@ -281,224 +350,208 @@ var DataEnhancementControllerContext = React.createContext(null);
281
350
  var useDataEnhancementController = () => useContext(DataEnhancementControllerContext);
282
351
  function getPropertyFromKey(properties, propertyKey) {
283
352
  if (propertyKey in properties) return properties[propertyKey];
284
- else {
285
- const split = propertyKey.split(".");
286
- if (split.length === 1) return;
287
- return getPropertyFromKey(properties, split.slice(0, split.length - 1).join("."));
353
+ const split = propertyKey.split(".");
354
+ if (split.length === 1) return void 0;
355
+ return getPropertyFromKey(properties, split.slice(0, -1).join("."));
356
+ }
357
+ /**
358
+ * Convert a value off the wire into what the form field expects.
359
+ *
360
+ * Only dates need converting: the service answers ISO-8601 strings because JSON
361
+ * has no date type, and handing a date field a string stores the wrong type
362
+ * without complaining. Everything else — strings, numbers, booleans, arrays of
363
+ * scalars — is already the shape the field wants, which is the point of having
364
+ * the service constrain its answer to a schema derived from these properties.
365
+ */
366
+ function coerceToProperty(value, property) {
367
+ if (property?.type === "date" && typeof value === "string") {
368
+ const date = new Date(value);
369
+ return Number.isNaN(date.getTime()) ? void 0 : date;
288
370
  }
371
+ return value;
289
372
  }
290
- function DataEnhancementControllerProvider({ apiKey, getConfigForPath, children, host, path, collection, formContext }) {
291
- const [enabled, setEnabled] = useState(false);
292
- const [suggestions, setSuggestions] = useState({});
293
- const [loadingSuggestions, setLoadingSuggestions] = useState([]);
294
- const enhancingInProgress = useRef(false);
295
- const authController = useAuthController();
296
- const snackbarController = useSnackbarController();
297
- const properties = useMemo(() => getSimplifiedProperties(collection.properties, formContext?.values ?? {}), [formContext?.values]);
298
- const valuesRef = React.useRef(formContext?.values ?? {});
373
+ function DataEnhancementControllerProvider({ getConfigForPath, children, endpoint, path, collection, formContext }) {
374
+ const [allowedHere, setAllowedHere] = useState(false);
375
+ const [serviceAvailable, setServiceAvailable] = useState(false);
376
+ const [review, setReview] = useState(null);
377
+ const properties = useMemo(() => getSimplifiedProperties(collection.properties, formContext?.values ?? {}), [collection.properties, formContext?.values]);
378
+ /**
379
+ * Read inside the streaming callbacks, which outlive the render that
380
+ * started the run.
381
+ *
382
+ * The operator is free to keep typing while the model works — nothing here
383
+ * writes to the form — so the callbacks must not close over a stale
384
+ * property map from whichever render happened to kick the run off.
385
+ */
386
+ const propertiesRef = useRef(properties);
387
+ propertiesRef.current = properties;
388
+ /** The host app's own opt-out. */
299
389
  useEffect(() => {
300
- if (!enhancingInProgress.current) valuesRef.current = formContext?.values ?? {};
301
- }, [formContext?.values]);
302
- const allowReferenceDataSelection = false;
303
- const updateConfig = useCallback(async () => {
304
- if (!getConfigForPath) return;
305
- if (getConfigForPath({
390
+ if (!getConfigForPath) {
391
+ setAllowedHere(true);
392
+ return;
393
+ }
394
+ setAllowedHere(Boolean(getConfigForPath({
306
395
  path,
307
396
  collection
308
- })) setEnabled(true);
397
+ })));
309
398
  }, [
310
- collection,
311
399
  getConfigForPath,
312
- path
400
+ path,
401
+ collection
313
402
  ]);
403
+ /**
404
+ * The service's own availability.
405
+ *
406
+ * Nothing renders until this comes back true. An unreachable host, an
407
+ * unconfigured provider key or an exhausted daily quota all land here, and
408
+ * all of them mean the same thing to the operator: no Autofill button,
409
+ * rather than a button that fails when clicked.
410
+ */
314
411
  useEffect(() => {
315
- if (!getConfigForPath) setEnabled(true);
316
- else updateConfig();
317
- }, [getConfigForPath, updateConfig]);
318
- const urlController = useUrlController();
319
- const clearSuggestion = useCallback((propertyKey) => {
320
- setSuggestions((prev) => {
321
- const { [propertyKey]: _, ...rest } = prev;
322
- return rest;
412
+ if (!allowedHere) return;
413
+ const abort = new AbortController();
414
+ fetchAiStatus({
415
+ endpoint,
416
+ signal: abort.signal
417
+ }).then((status) => setServiceAvailable(status.available)).catch(() => setServiceAvailable(false));
418
+ return () => abort.abort();
419
+ }, [allowedHere, endpoint]);
420
+ const enabled = allowedHere && serviceAvailable;
421
+ /** Add or update one row in the review, preserving arrival order. */
422
+ const upsertField = useCallback((key, update) => {
423
+ setReview((current) => {
424
+ if (!current) return current;
425
+ const index = current.fields.findIndex((f) => f.key === key);
426
+ const next = update(index === -1 ? void 0 : current.fields[index]);
427
+ const fields = index === -1 ? [...current.fields, next] : current.fields.map((f, i) => i === index ? next : f);
428
+ return {
429
+ ...current,
430
+ fields
431
+ };
323
432
  });
324
433
  }, []);
325
- const appendValueDelta = useCallback((propertyKey, delta) => {
326
- const property = getPropertyFromKey(properties, propertyKey);
327
- if (delta === null || property?.disabled) return;
328
- const value = getValueInPath(valuesRef.current, propertyKey);
329
- const updatedValue = (value ? value + "" : "") + delta;
330
- valuesRef.current = {
331
- ...valuesRef.current,
332
- [propertyKey]: updatedValue
333
- };
334
- formContext?.setFieldValue(propertyKey, updatedValue, false);
335
- setSuggestions((prev) => ({
336
- ...prev,
337
- [propertyKey]: (prev[propertyKey] ?? "") + delta
338
- }));
339
- }, [properties, formContext]);
340
- const updateSuggestedValues = useCallback((currentValues, updatedValues, replaceValues) => {
341
- setLoadingSuggestions((prev) => {
342
- return prev.filter((p) => !Object.keys(updatedValues).includes(p));
434
+ const generate = useCallback(async (params) => {
435
+ const currentProperties = propertiesRef.current;
436
+ const flatValues = flatMapEntityValues(params.values ?? {});
437
+ setReview({
438
+ status: "generating",
439
+ fields: [],
440
+ instructions: params.instructions
343
441
  });
344
- Object.entries(updatedValues).forEach(([propertyKey, suggestion]) => {
345
- const value = getValueInPath(currentValues, propertyKey);
346
- const property = getPropertyFromKey(properties, propertyKey);
347
- if (!property || suggestion === null || property?.disabled) return;
348
- if (typeof suggestion === "number") {
349
- formContext?.setFieldValue(propertyKey, suggestion);
350
- return;
351
- }
352
- if (replaceValues) {
353
- formContext?.setFieldValue(propertyKey, suggestion);
354
- return;
355
- }
356
- const appendableValue = getAppendableSuggestion(suggestion, value);
357
- const currentValue = value ? value + "" : "";
358
- if (appendableValue) formContext?.setFieldValue(propertyKey, suggestion);
359
- else {
360
- const multiline = property?.fieldConfigId === "multiline" || property?.fieldConfigId === "markdown";
361
- const trimmedValue = currentValue.trimEnd();
362
- if (multiline && (trimmedValue.endsWith(".") || trimmedValue.endsWith("?") || trimmedValue.endsWith("!") || trimmedValue.endsWith(":"))) formContext?.setFieldValue(propertyKey, trimmedValue + "\n\n" + suggestion.trimStart());
363
- else formContext?.setFieldValue(propertyKey, trimmedValue + (trimmedValue.length > 0 ? " " : "") + suggestion);
364
- }
365
- });
366
- setSuggestions((prev) => ({
367
- ...prev,
368
- ...Object.keys(updatedValues).reduce((acc, key) => {
369
- const value = getValueInPath(formContext?.values, key);
370
- const suggestion = updatedValues[key];
371
- return {
372
- ...acc,
373
- [key]: getAppendableSuggestion(suggestion, value) ?? suggestion
374
- };
375
- }, {})
376
- }));
377
- }, [properties, formContext]);
378
- const displayNeededSubscriptionSnackbar = useCallback((projectId) => {
379
- snackbarController.open({
380
- type: "warning",
381
- message: "A valid subscription is needed in order to use this function.",
382
- autoHideDuration: 4e3
383
- });
384
- }, [snackbarController]);
385
- const editorAIController = useEditorAIController({ getAuthToken: authController.getAuthToken });
386
- const clearAllSuggestions = useCallback(() => {
387
- setSuggestions({});
388
- }, []);
389
- const enhance = useCallback(async (props) => {
390
- if (!authController.user) {
391
- snackbarController.open({
392
- type: "warning",
393
- message: "You need to be logged in to enhance data"
394
- });
395
- return Promise.reject(/* @__PURE__ */ new Error("Not logged in"));
396
- }
397
- const resolvedPath = urlController.resolveDatabasePathsFrom(path);
398
- const firebaseToken = await authController.getAuthToken();
399
- if (props.propertyKey) clearSuggestion(props.propertyKey);
400
- else clearAllSuggestions();
401
- setLoadingSuggestions((prev) => [...prev, ...props.propertyKey ? [props.propertyKey] : Object.keys(properties)]);
402
- enhancingInProgress.current = true;
403
- const currentValues = valuesRef.current ?? {};
404
- return new Promise((resolve, reject) => {
405
- function onError(e) {
406
- setLoadingSuggestions([]);
407
- const errorObj = e instanceof Error ? e : typeof e === "object" && e !== null ? e : new Error(String(e));
408
- if (errorObj.code === "payment-required") {
409
- const projectId = errorObj.data?.projectId;
410
- displayNeededSubscriptionSnackbar(projectId);
411
- } else console.error("Enhance error", e);
412
- reject(e);
413
- enhancingInProgress.current = false;
414
- }
415
- try {
416
- enhanceDataAPIStream({
417
- ...props,
418
- host,
419
- apiKey,
420
- properties,
421
- path: resolvedPath,
442
+ const labelFor = (key) => currentProperties[key]?.name ?? key;
443
+ try {
444
+ await autofillStream({
445
+ endpoint,
446
+ request: {
422
447
  entityName: collection.singularName ?? collection.name,
423
448
  entityDescription: collection.description,
424
- firebaseToken,
425
- onUpdate: (suggestions) => {
426
- console.debug("de onUpdate", suggestions);
427
- updateSuggestedValues(currentValues, suggestions, props.replaceValues ?? false);
428
- },
429
- onUpdateDelta: (propertyKey, partialValue) => {
430
- appendValueDelta(propertyKey, partialValue);
431
- },
432
- onError,
433
- onEnd: (result) => {
434
- console.debug("de onEnd", result);
435
- if (result.errors) result.errors.forEach((error) => {
436
- snackbarController.open({
437
- type: "warning",
438
- message: error
439
- });
440
- });
441
- if (Object.keys(result.suggestions).length === 0) snackbarController.open({
442
- type: "info",
443
- autoHideDuration: 1800,
444
- message: "No fields were updated"
445
- });
446
- setLoadingSuggestions([]);
447
- resolve(result);
448
- enhancingInProgress.current = false;
449
- }
450
- }).catch(onError);
451
- } catch (e) {
452
- onError(e);
453
- }
454
- });
449
+ values: flatValues,
450
+ properties: currentProperties,
451
+ propertyKey: params.propertyKey,
452
+ propertyInstructions: params.propertyInstructions,
453
+ instructions: params.instructions
454
+ },
455
+ onDelta: (key, text) => {
456
+ upsertField(key, (existing) => existing ? {
457
+ ...existing,
458
+ proposed: String(existing.proposed ?? "") + text
459
+ } : {
460
+ key,
461
+ label: labelFor(key),
462
+ currentValue: getValueInPath(params.values, key),
463
+ proposed: text,
464
+ pending: true,
465
+ selected: true
466
+ });
467
+ },
468
+ onValue: (key, value) => {
469
+ const coerced = coerceToProperty(value, getPropertyFromKey(currentProperties, key));
470
+ upsertField(key, (existing) => ({
471
+ key,
472
+ label: existing?.label ?? labelFor(key),
473
+ currentValue: existing?.currentValue ?? getValueInPath(params.values, key),
474
+ proposed: coerced,
475
+ pending: false,
476
+ selected: existing?.selected ?? true
477
+ }));
478
+ }
479
+ });
480
+ setReview((current) => current && {
481
+ ...current,
482
+ status: "ready",
483
+ fields: current.fields.filter((f) => !f.pending)
484
+ });
485
+ } catch (e) {
486
+ const message = e instanceof Error ? e.message : "Autofill could not be completed";
487
+ setReview((current) => current && {
488
+ ...current,
489
+ status: "failed",
490
+ error: message,
491
+ fields: current.fields.filter((f) => !f.pending)
492
+ });
493
+ }
455
494
  }, [
456
- authController,
457
- urlController,
458
- path,
459
- clearSuggestion,
460
- clearAllSuggestions,
461
- properties,
462
- host,
463
- apiKey,
464
495
  collection,
465
- updateSuggestedValues,
466
- appendValueDelta,
467
- displayNeededSubscriptionSnackbar,
468
- snackbarController
496
+ endpoint,
497
+ upsertField
469
498
  ]);
470
- const getSamplePrompts = useCallback(async (entityName, input) => {
471
- return fetchEntityPromptSuggestion({
472
- host,
473
- entityName,
474
- firebaseToken: await authController.getAuthToken(),
475
- apiKey,
476
- input
499
+ const toggleField = useCallback((key) => {
500
+ setReview((current) => current && {
501
+ ...current,
502
+ fields: current.fields.map((f) => f.key === key ? {
503
+ ...f,
504
+ selected: !f.selected
505
+ } : f)
477
506
  });
478
- }, [
479
- apiKey,
480
- authController.getAuthToken,
481
- host
482
- ]);
507
+ }, []);
508
+ const toggleAll = useCallback((selected) => {
509
+ setReview((current) => current && {
510
+ ...current,
511
+ fields: current.fields.map((f) => ({
512
+ ...f,
513
+ selected
514
+ }))
515
+ });
516
+ }, []);
517
+ const dismissReview = useCallback(() => setReview(null), []);
518
+ const applyReview = useCallback(() => {
519
+ setReview((current) => {
520
+ if (!current) return null;
521
+ for (const field of current.fields) {
522
+ if (!field.selected || field.pending) continue;
523
+ if (field.proposed === void 0 || field.proposed === null) continue;
524
+ formContext?.setFieldValue(field.key, field.proposed);
525
+ }
526
+ return null;
527
+ });
528
+ }, [formContext]);
529
+ const editorAIController = useEditorAIController({ endpoint });
530
+ const getSamplePrompts = useCallback((entityName, input) => fetchPromptSuggestions({
531
+ endpoint,
532
+ entityName,
533
+ entityDescription: collection.description,
534
+ input
535
+ }), [endpoint, collection.description]);
483
536
  const dataEnhancementController = useMemo(() => ({
484
537
  enabled,
485
- suggestions,
486
- clearSuggestion,
487
- enhance,
488
- allowReferenceDataSelection,
489
- clearAllSuggestions,
538
+ review,
539
+ generate,
540
+ toggleField,
541
+ toggleAll,
542
+ applyReview,
543
+ dismissReview,
490
544
  getSamplePrompts,
491
- loadingSuggestions,
492
545
  editorAIController
493
546
  }), [
494
547
  enabled,
495
- suggestions,
496
- clearSuggestion,
497
- enhance,
498
- allowReferenceDataSelection,
499
- clearAllSuggestions,
548
+ review,
549
+ generate,
550
+ toggleField,
551
+ toggleAll,
552
+ applyReview,
553
+ dismissReview,
500
554
  getSamplePrompts,
501
- loadingSuggestions,
502
555
  editorAIController
503
556
  ]);
504
557
  return /* @__PURE__ */ jsx(DataEnhancementControllerContext.Provider, {
@@ -507,15 +560,193 @@ function DataEnhancementControllerProvider({ apiKey, getConfigForPath, children,
507
560
  });
508
561
  }
509
562
  //#endregion
563
+ //#region src/components/AutofillReviewDialog.tsx
564
+ /**
565
+ * The review step.
566
+ *
567
+ * Autofill used to write generated text into the live form as it streamed —
568
+ * fields mutating under the cursor, half-written sentences that looked like
569
+ * bugs, and a pile of heuristics deciding whether each token should append to
570
+ * or replace what the operator had already typed. Getting the old value back
571
+ * meant retyping it.
572
+ *
573
+ * So the generated values land here instead. Streaming still happens, and is
574
+ * still worth having — rows appear and fill in as the model works, so a long
575
+ * run shows progress — but it happens in a surface that owns nothing. The
576
+ * record changes on **Apply**, once, for the rows still ticked.
577
+ */
578
+ function AutofillReviewDialog() {
579
+ const controller = useDataEnhancementController();
580
+ const review = controller?.review;
581
+ if (!review) return null;
582
+ const generating = review.status === "generating";
583
+ const applicable = review.fields.filter((f) => !f.pending && f.selected);
584
+ const allSelected = review.fields.length > 0 && review.fields.every((f) => f.selected);
585
+ return /* @__PURE__ */ jsxs(Dialog, {
586
+ open: true,
587
+ maxWidth: "2xl",
588
+ onOpenChange: (open) => {
589
+ if (!open) controller.dismissReview();
590
+ },
591
+ children: [
592
+ /* @__PURE__ */ jsx(DialogTitle, {
593
+ variant: "subtitle1",
594
+ gutterBottom: false,
595
+ children: "Review autofill"
596
+ }),
597
+ /* @__PURE__ */ jsxs(DialogContent, {
598
+ className: "flex flex-col gap-2",
599
+ children: [
600
+ review.instructions && /* @__PURE__ */ jsxs(Typography, {
601
+ variant: "body2",
602
+ color: "secondary",
603
+ className: "italic",
604
+ children: [
605
+ "“",
606
+ review.instructions,
607
+ "”"
608
+ ]
609
+ }),
610
+ review.fields.length > 1 && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("label", {
611
+ className: "flex items-center gap-3 py-1 cursor-pointer select-none",
612
+ children: [/* @__PURE__ */ jsx(Checkbox, {
613
+ checked: allSelected,
614
+ size: "small",
615
+ onCheckedChange: () => controller.toggleAll(!allSelected)
616
+ }), /* @__PURE__ */ jsx(Typography, {
617
+ variant: "label",
618
+ component: "span",
619
+ color: "secondary",
620
+ children: allSelected ? "Deselect all" : "Select all"
621
+ })]
622
+ }), /* @__PURE__ */ jsx(Separator, {
623
+ orientation: "horizontal",
624
+ className: "my-0"
625
+ })] }),
626
+ /* @__PURE__ */ jsx("div", {
627
+ className: "flex flex-col divide-y divide-surface-accent-100 dark:divide-surface-accent-800",
628
+ children: review.fields.map((field) => /* @__PURE__ */ jsx(ProposedFieldRow, {
629
+ field,
630
+ onToggle: () => controller.toggleField(field.key)
631
+ }, field.key))
632
+ }),
633
+ generating && /* @__PURE__ */ jsxs("div", {
634
+ className: "flex items-center gap-3 py-4 text-text-secondary dark:text-text-secondary-dark",
635
+ children: [/* @__PURE__ */ jsx(CircularProgress, { size: "smallest" }), /* @__PURE__ */ jsx(Typography, {
636
+ variant: "body2",
637
+ color: "secondary",
638
+ children: review.fields.length === 0 ? "Thinking…" : "Writing the remaining fields…"
639
+ })]
640
+ }),
641
+ review.status === "failed" && /* @__PURE__ */ jsxs(Typography, {
642
+ variant: "body2",
643
+ className: "py-2 text-red-600 dark:text-red-400",
644
+ children: [review.error, review.fields.length > 0 && " You can still apply what was written before it stopped."]
645
+ }),
646
+ !generating && review.fields.length === 0 && review.status !== "failed" && /* @__PURE__ */ jsx(Typography, {
647
+ variant: "body2",
648
+ color: "secondary",
649
+ className: "py-4",
650
+ children: "Nothing to fill in — every field either already has a value the model would not improve on, or is not one it can write."
651
+ })
652
+ ]
653
+ }),
654
+ /* @__PURE__ */ jsxs(DialogActions, { children: [/* @__PURE__ */ jsx(Button, {
655
+ variant: "text",
656
+ color: "neutral",
657
+ onClick: controller.dismissReview,
658
+ children: "Discard"
659
+ }), /* @__PURE__ */ jsx(Button, {
660
+ variant: "filled",
661
+ disabled: applicable.length === 0,
662
+ onClick: controller.applyReview,
663
+ children: applicable.length === 1 ? "Apply 1 field" : `Apply ${applicable.length} fields`
664
+ })] })
665
+ ]
666
+ });
667
+ }
668
+ function ProposedFieldRow({ field, onToggle }) {
669
+ const replaces = hasValue(field.currentValue) && !isSameValue(field.currentValue, field.proposed);
670
+ return /* @__PURE__ */ jsxs("label", {
671
+ className: cls("flex items-start gap-3 py-3 cursor-pointer", !field.selected && "opacity-50"),
672
+ children: [/* @__PURE__ */ jsx("div", {
673
+ className: "mt-0.5 shrink-0",
674
+ children: /* @__PURE__ */ jsx(Checkbox, {
675
+ checked: field.selected,
676
+ size: "small",
677
+ onCheckedChange: onToggle
678
+ })
679
+ }), /* @__PURE__ */ jsxs("div", {
680
+ className: "flex flex-col gap-1 min-w-0 grow",
681
+ children: [
682
+ /* @__PURE__ */ jsxs("div", {
683
+ className: "flex items-center gap-2",
684
+ children: [
685
+ /* @__PURE__ */ jsx(Typography, {
686
+ variant: "label",
687
+ component: "span",
688
+ children: field.label
689
+ }),
690
+ replaces && /* @__PURE__ */ jsx(Typography, {
691
+ variant: "caption",
692
+ color: "secondary",
693
+ children: "replaces the current value"
694
+ }),
695
+ field.pending && /* @__PURE__ */ jsx(CircularProgress, { size: "smallest" })
696
+ ]
697
+ }),
698
+ replaces && /* @__PURE__ */ jsx(Typography, {
699
+ variant: "body2",
700
+ color: "secondary",
701
+ className: "line-through whitespace-pre-wrap break-words",
702
+ children: renderValue(field.currentValue)
703
+ }),
704
+ /* @__PURE__ */ jsx(Typography, {
705
+ variant: "body2",
706
+ className: "whitespace-pre-wrap break-words",
707
+ children: renderValue(field.proposed)
708
+ })
709
+ ]
710
+ })]
711
+ });
712
+ }
713
+ function hasValue(value) {
714
+ if (value === null || value === void 0) return false;
715
+ if (typeof value === "string") return value.trim().length > 0;
716
+ if (Array.isArray(value)) return value.length > 0;
717
+ return true;
718
+ }
719
+ function isSameValue(a, b) {
720
+ if (a === b) return true;
721
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
722
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => isSameValue(v, b[i]));
723
+ return false;
724
+ }
725
+ /** Values are shown, never edited here — so a readable string is all that is needed. */
726
+ function renderValue(value) {
727
+ if (value === null || value === void 0) return "";
728
+ if (value instanceof Date) return value.toLocaleString();
729
+ if (Array.isArray(value)) return value.map((v) => renderValue(v)).join(", ");
730
+ if (typeof value === "boolean") return value ? "Yes" : "No";
731
+ if (typeof value === "object") return JSON.stringify(value);
732
+ return String(value);
733
+ }
734
+ //#endregion
510
735
  //#region src/components/FormEnhanceAction.tsx
511
- function FormEnhanceAction({ entityId, path, status, collection, formContext, openEntityMode }) {
512
- const largeLayout = useLargeLayout();
736
+ function FormEnhanceAction({ path, status, collection, formContext }) {
513
737
  const storageKey = createLocalStorageKey(path, status);
514
- const [loading, setLoading] = React.useState(false);
515
738
  const dataEnhancementController = useDataEnhancementController();
516
739
  const [samplePrompts, setSamplePrompts] = React.useState(void 0);
517
740
  const [instructions, setInstructions] = React.useState("");
518
741
  const getSamplePrompts = dataEnhancementController?.getSamplePrompts;
742
+ /**
743
+ * Driven by the controller rather than by local state.
744
+ *
745
+ * There is exactly one run at a time, and the review owns it — a second
746
+ * `loading` flag here could disagree with the dialog about whether the
747
+ * model is still writing.
748
+ */
749
+ const loading = dataEnhancementController?.review?.status === "generating";
519
750
  const loadingPrompts = useRef(false);
520
751
  const updateSuggestedPrompts = useCallback(async function updateSuggestedPrompts(instructions) {
521
752
  if (!getSamplePrompts) return;
@@ -532,7 +763,6 @@ function FormEnhanceAction({ entityId, path, status, collection, formContext, op
532
763
  getSamplePrompts,
533
764
  status
534
765
  ]);
535
- useDeferredValue(formContext?.values);
536
766
  useEffect(() => {
537
767
  if (!dataEnhancementController) return;
538
768
  if (!samplePrompts) {
@@ -551,9 +781,12 @@ function FormEnhanceAction({ entityId, path, status, collection, formContext, op
551
781
  if (!dataEnhancementController) return;
552
782
  updateSuggestedPrompts().then();
553
783
  }, [dataEnhancementController, status]);
554
- const enhance = (prompt) => {
784
+ /**
785
+ * Starts a run and opens the review. Nothing is written to the form here —
786
+ * see {@link AutofillReviewDialog}.
787
+ */
788
+ const generate = (prompt) => {
555
789
  if (!dataEnhancementController || !formContext?.values) return;
556
- setLoading(true);
557
790
  if (prompt) {
558
791
  addRecentPrompt(storageKey, prompt);
559
792
  setSamplePrompts([{
@@ -561,30 +794,22 @@ function FormEnhanceAction({ entityId, path, status, collection, formContext, op
561
794
  type: "recent"
562
795
  }, ...(samplePrompts ?? []).slice(0, 5)]);
563
796
  }
564
- return dataEnhancementController.enhance({
565
- entityId,
797
+ dataEnhancementController.generate({
566
798
  values: formContext.values,
567
- instructions: prompt,
568
- replaceValues: true
569
- }).finally(() => {
570
- setLoading(false);
571
- });
799
+ instructions: prompt
800
+ }).catch(() => void 0);
572
801
  };
573
802
  if (!dataEnhancementController?.enabled) return null;
574
- const suggestions = dataEnhancementController.suggestions;
575
- Object.values(suggestions).filter(Boolean).length;
576
- (samplePrompts ?? []).length > 0 && instructions.length;
577
803
  function submit() {
578
- enhance(instructions);
804
+ generate(instructions);
579
805
  }
580
- return /* @__PURE__ */ jsxs(Menu, {
806
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs(Menu, {
581
807
  align: "end",
582
808
  sideOffset: 8,
583
809
  className: "max-w-[100vw]",
584
810
  trigger: /* @__PURE__ */ jsxs(Button, {
585
811
  variant: "filled",
586
812
  color: "neutral",
587
- fullWidth: largeLayout && openEntityMode === "full_screen",
588
813
  size: "small",
589
814
  disabled: loading,
590
815
  children: [
@@ -597,7 +822,7 @@ function FormEnhanceAction({ entityId, path, status, collection, formContext, op
597
822
  /* @__PURE__ */ jsxs(MenuItem, {
598
823
  className: "py-4",
599
824
  onClick: () => {
600
- enhance();
825
+ generate();
601
826
  },
602
827
  children: [/* @__PURE__ */ jsx(AIIcon, { size: "small" }), "Autofill based on the current content"]
603
828
  }),
@@ -609,7 +834,7 @@ function FormEnhanceAction({ entityId, path, status, collection, formContext, op
609
834
  return /* @__PURE__ */ jsxs(MenuItem, {
610
835
  onClick: () => {
611
836
  setInstructions(samplePrompt.prompt);
612
- enhance(samplePrompt.prompt);
837
+ generate(samplePrompt.prompt);
613
838
  },
614
839
  children: [/* @__PURE__ */ jsx("div", {
615
840
  className: "pl-9 grow text-text-secondary dark:text-text-secondary-dark",
@@ -628,10 +853,11 @@ function FormEnhanceAction({ entityId, path, status, collection, formContext, op
628
853
  }),
629
854
  /* @__PURE__ */ jsx(Separator, { orientation: "horizontal" }),
630
855
  /* @__PURE__ */ jsxs("div", {
631
- className: cls("my-2 w-[500px] max-w-full flex items-start text-surface-700 dark:text-surface-200"),
632
- children: [
633
- /* @__PURE__ */ jsx(TextareaAutosize, {
634
- className: cls("p-4 rounded-lg resize-none bg-surface-100 dark:bg-surface-950 mx-2 w-full grow outline-hidden max-h-[300px] overflow-auto", focusedDisabled),
856
+ className: cls("my-2 px-4 py-2 gap-4 w-[500px] max-w-full flex items-center text-surface-700 dark:text-surface-200"),
857
+ children: [/* @__PURE__ */ jsxs("div", {
858
+ className: "relative w-full grow",
859
+ children: [/* @__PURE__ */ jsx(TextareaAutosize, {
860
+ className: cls("p-3 pr-12 rounded-lg resize-none w-full outline-hidden max-h-[300px] overflow-auto", fieldBackgroundMixin, focusedDisabled),
635
861
  value: instructions,
636
862
  autoFocus: status === "new",
637
863
  disabled: loading,
@@ -649,27 +875,26 @@ function FormEnhanceAction({ entityId, path, status, collection, formContext, op
649
875
  onChange: (e) => {
650
876
  setInstructions(e.target.value);
651
877
  }
652
- }),
653
- /* @__PURE__ */ jsx(IconButton, {
654
- size: "small",
655
- onClick: () => {
656
- setInstructions("");
657
- },
658
- color: !instructions ? "primary" : void 0,
659
- disabled: loading || !instructions,
660
- children: /* @__PURE__ */ jsx(XIcon, { size: iconSize.small })
661
- }),
662
- /* @__PURE__ */ jsxs(IconButton, {
663
- onClick: () => enhance(instructions),
664
- size: "small",
665
- color: !instructions ? "primary" : void 0,
666
- disabled: loading || !instructions,
667
- children: [loading && /* @__PURE__ */ jsx(CircularProgress, { size: "smallest" }), !loading && /* @__PURE__ */ jsx(SendIcon, { color: "primary" })]
668
- })
669
- ]
878
+ }), instructions.length > 0 && !loading && /* @__PURE__ */ jsx("div", {
879
+ className: "flex flex-row justify-center items-center absolute h-full right-0 top-0 mr-2",
880
+ children: /* @__PURE__ */ jsx(IconButton, {
881
+ size: "small",
882
+ onClick: () => {
883
+ setInstructions("");
884
+ },
885
+ children: /* @__PURE__ */ jsx(XIcon, { size: iconSize.small })
886
+ })
887
+ })]
888
+ }), /* @__PURE__ */ jsxs(IconButton, {
889
+ onClick: () => generate(instructions),
890
+ size: "small",
891
+ color: !instructions ? "primary" : void 0,
892
+ disabled: loading || !instructions,
893
+ children: [loading && /* @__PURE__ */ jsx(CircularProgress, { size: "smallest" }), !loading && /* @__PURE__ */ jsx(SendIcon, { size: iconSize.small })]
894
+ })]
670
895
  })
671
896
  ]
672
- });
897
+ }), /* @__PURE__ */ jsx(AutofillReviewDialog, {})] });
673
898
  }
674
899
  function getPromptsForExistingEntities(properties) {
675
900
  const multilineProperties = Object.values(properties).filter((p) => {
@@ -704,15 +929,14 @@ var removeRecentPrompt = (storageKey, prompt) => {
704
929
  };
705
930
  //#endregion
706
931
  //#region src/useDataEnhancementPlugin.tsx
707
- var DEFAULT_API_KEY = "fcms-U9jdDii0xXWSDC34asfrf54lbkFJBfKfRWcEDEwdc4V5wDWEDF";
708
932
  /**
709
933
  * Use this hook to initialise the data enhancement plugin.
710
934
  * This is likely the only hook you will need to use.
711
935
  * @param props
712
936
  */
713
937
  function useDataEnhancementPlugin(props) {
714
- const apiKey = props?.apiKey ?? DEFAULT_API_KEY;
715
938
  const getConfigForPath = props?.getConfigForPath;
939
+ const endpoint = props?.endpoint;
716
940
  return React.useMemo(() => ({
717
941
  key: "data_enhancement",
718
942
  slots: [{
@@ -724,16 +948,11 @@ function useDataEnhancementPlugin(props) {
724
948
  scope: "form",
725
949
  Component: DataEnhancementControllerProvider,
726
950
  props: {
727
- apiKey,
728
951
  getConfigForPath,
729
- host: props?.host
952
+ endpoint
730
953
  }
731
954
  }]
732
- }), [
733
- apiKey,
734
- getConfigForPath,
735
- props?.host
736
- ]);
955
+ }), [getConfigForPath, endpoint]);
737
956
  }
738
957
  //#endregion
739
958
  export { useDataEnhancementPlugin, useEditorAIController };