@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.
package/dist/index.es.js CHANGED
@@ -1,143 +1,193 @@
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, 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
+ input: props.input
179
+ }),
180
+ signal: props.signal
181
+ });
182
+ if (!response.ok) return { prompts: [] };
183
+ const body = await response.json();
184
+ return { prompts: (Array.isArray(body?.prompts) ? body.prompts : []).filter((p) => typeof p === "string").map((prompt) => ({
185
+ prompt,
186
+ type: "sample"
187
+ })) };
188
+ } catch {
189
+ return { prompts: [] };
190
+ }
141
191
  }
142
192
  //#endregion
143
193
  //#region src/utils/properties.ts
@@ -262,18 +312,36 @@ function getSimpleEnumValues(enumValues) {
262
312
  throw Error("getSimpleEnumValues: Invalid enumValues");
263
313
  }
264
314
  //#endregion
315
+ //#region src/utils/values.ts
316
+ function flatMapEntityValues(values, path = "") {
317
+ if (!values) return {};
318
+ return Object.entries(values).flatMap(([key, value]) => {
319
+ const currentPath = path ? `${path}.${key}` : key;
320
+ if (typeof value === "object") return flatMapEntityValues(value, currentPath);
321
+ else return { [currentPath]: value };
322
+ }).reduce((acc, curr) => ({
323
+ ...acc,
324
+ ...curr
325
+ }), {});
326
+ }
327
+ //#endregion
265
328
  //#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 };
329
+ /**
330
+ * Inline continuation for the rich-text editor's slash command.
331
+ *
332
+ * No token is threaded through any more. The previous version demanded a
333
+ * Firebase ID token and threw `"Firebase token is required"` when it could not
334
+ * get one — in a Rebase app there is no such thing, and the token it actually
335
+ * sent was a Rebase JWT the receiving service had no way to verify. The hosted
336
+ * service authenticates nobody; see `src/api.ts`.
337
+ */
338
+ function useEditorAIController({ endpoint } = {}) {
339
+ return React.useMemo(() => ({ autocomplete: (textBefore, textAfter, onUpdate) => autocompleteStream({
340
+ endpoint,
341
+ textBefore,
342
+ textAfter,
343
+ onDelta: onUpdate
344
+ }) }), [endpoint]);
277
345
  }
278
346
  //#endregion
279
347
  //#region src/components/DataEnhancementControllerProvider.tsx
@@ -281,224 +349,213 @@ var DataEnhancementControllerContext = React.createContext(null);
281
349
  var useDataEnhancementController = () => useContext(DataEnhancementControllerContext);
282
350
  function getPropertyFromKey(properties, propertyKey) {
283
351
  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("."));
352
+ const split = propertyKey.split(".");
353
+ if (split.length === 1) return void 0;
354
+ return getPropertyFromKey(properties, split.slice(0, -1).join("."));
355
+ }
356
+ /**
357
+ * Convert a value off the wire into what the form field expects.
358
+ *
359
+ * Only dates need converting: the service answers ISO-8601 strings because JSON
360
+ * has no date type, and handing a date field a string stores the wrong type
361
+ * without complaining. Everything else — strings, numbers, booleans, arrays of
362
+ * scalars — is already the shape the field wants, which is the point of having
363
+ * the service constrain its answer to a schema derived from these properties.
364
+ */
365
+ function coerceToProperty(value, property) {
366
+ if (property?.type === "date" && typeof value === "string") {
367
+ const date = new Date(value);
368
+ return Number.isNaN(date.getTime()) ? void 0 : date;
288
369
  }
370
+ return value;
289
371
  }
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 ?? {});
372
+ function DataEnhancementControllerProvider({ getConfigForPath, children, endpoint, path, collection, formContext }) {
373
+ const [allowedHere, setAllowedHere] = useState(false);
374
+ const [serviceAvailable, setServiceAvailable] = useState(false);
375
+ const [review, setReview] = useState(null);
376
+ const properties = useMemo(() => getSimplifiedProperties(collection.properties, formContext?.values ?? {}), [collection.properties, formContext?.values]);
377
+ /**
378
+ * Read inside the streaming callbacks, which outlive the render that
379
+ * started the run.
380
+ *
381
+ * The operator is free to keep typing while the model works — nothing here
382
+ * writes to the form — so the callbacks must not close over a stale
383
+ * property map from whichever render happened to kick the run off.
384
+ */
385
+ const propertiesRef = useRef(properties);
386
+ propertiesRef.current = properties;
387
+ /** The host app's own opt-out. */
299
388
  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({
389
+ if (!getConfigForPath) {
390
+ setAllowedHere(true);
391
+ return;
392
+ }
393
+ setAllowedHere(Boolean(getConfigForPath({
306
394
  path,
307
395
  collection
308
- })) setEnabled(true);
396
+ })));
309
397
  }, [
310
- collection,
311
398
  getConfigForPath,
312
- path
399
+ path,
400
+ collection
313
401
  ]);
402
+ /**
403
+ * The service's own availability.
404
+ *
405
+ * Nothing renders until this comes back true. An unreachable host, an
406
+ * unconfigured provider key or an exhausted daily quota all land here, and
407
+ * all of them mean the same thing to the operator: no Autofill button,
408
+ * rather than a button that fails when clicked.
409
+ */
314
410
  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;
411
+ if (!allowedHere) return;
412
+ const abort = new AbortController();
413
+ fetchAiStatus({
414
+ endpoint,
415
+ signal: abort.signal
416
+ }).then((status) => setServiceAvailable(status.available)).catch(() => setServiceAvailable(false));
417
+ return () => abort.abort();
418
+ }, [allowedHere, endpoint]);
419
+ const enabled = allowedHere && serviceAvailable;
420
+ /** Add or update one row in the review, preserving arrival order. */
421
+ const upsertField = useCallback((key, update) => {
422
+ setReview((current) => {
423
+ if (!current) return current;
424
+ const index = current.fields.findIndex((f) => f.key === key);
425
+ const next = update(index === -1 ? void 0 : current.fields[index]);
426
+ const fields = index === -1 ? [...current.fields, next] : current.fields.map((f, i) => i === index ? next : f);
427
+ return {
428
+ ...current,
429
+ fields
430
+ };
323
431
  });
324
432
  }, []);
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));
343
- });
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
433
+ const generate = useCallback(async (params) => {
434
+ const currentProperties = propertiesRef.current;
435
+ const flatValues = flatMapEntityValues(params.values ?? {});
436
+ setReview({
437
+ status: "generating",
438
+ fields: [],
439
+ instructions: params.instructions
383
440
  });
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,
441
+ const labelFor = (key) => currentProperties[key]?.name ?? key;
442
+ try {
443
+ await autofillStream({
444
+ endpoint,
445
+ request: {
422
446
  entityName: collection.singularName ?? collection.name,
423
447
  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
- });
448
+ values: flatValues,
449
+ properties: currentProperties,
450
+ propertyKey: params.propertyKey,
451
+ propertyInstructions: params.propertyInstructions,
452
+ instructions: params.instructions
453
+ },
454
+ onDelta: (key, text) => {
455
+ upsertField(key, (existing) => existing ? {
456
+ ...existing,
457
+ proposed: String(existing.proposed ?? "") + text
458
+ } : {
459
+ key,
460
+ label: labelFor(key),
461
+ currentValue: getValueInPath(params.values, key),
462
+ proposed: text,
463
+ pending: true,
464
+ selected: true
465
+ });
466
+ },
467
+ onValue: (key, value) => {
468
+ const coerced = coerceToProperty(value, getPropertyFromKey(currentProperties, key));
469
+ upsertField(key, (existing) => ({
470
+ key,
471
+ label: existing?.label ?? labelFor(key),
472
+ currentValue: existing?.currentValue ?? getValueInPath(params.values, key),
473
+ proposed: coerced,
474
+ pending: false,
475
+ selected: existing?.selected ?? true
476
+ }));
477
+ }
478
+ });
479
+ setReview((current) => current && {
480
+ ...current,
481
+ status: "ready",
482
+ fields: current.fields.map((f) => ({
483
+ ...f,
484
+ pending: false
485
+ }))
486
+ });
487
+ } catch (e) {
488
+ const message = e instanceof Error ? e.message : "Autofill could not be completed";
489
+ setReview((current) => current && {
490
+ ...current,
491
+ status: "failed",
492
+ error: message,
493
+ fields: current.fields.map((f) => ({
494
+ ...f,
495
+ pending: false
496
+ }))
497
+ });
498
+ }
455
499
  }, [
456
- authController,
457
- urlController,
458
- path,
459
- clearSuggestion,
460
- clearAllSuggestions,
461
- properties,
462
- host,
463
- apiKey,
464
500
  collection,
465
- updateSuggestedValues,
466
- appendValueDelta,
467
- displayNeededSubscriptionSnackbar,
468
- snackbarController
501
+ endpoint,
502
+ upsertField
469
503
  ]);
470
- const getSamplePrompts = useCallback(async (entityName, input) => {
471
- return fetchEntityPromptSuggestion({
472
- host,
473
- entityName,
474
- firebaseToken: await authController.getAuthToken(),
475
- apiKey,
476
- input
504
+ const toggleField = useCallback((key) => {
505
+ setReview((current) => current && {
506
+ ...current,
507
+ fields: current.fields.map((f) => f.key === key ? {
508
+ ...f,
509
+ selected: !f.selected
510
+ } : f)
477
511
  });
478
- }, [
479
- apiKey,
480
- authController.getAuthToken,
481
- host
482
- ]);
512
+ }, []);
513
+ const toggleAll = useCallback((selected) => {
514
+ setReview((current) => current && {
515
+ ...current,
516
+ fields: current.fields.map((f) => ({
517
+ ...f,
518
+ selected
519
+ }))
520
+ });
521
+ }, []);
522
+ const dismissReview = useCallback(() => setReview(null), []);
523
+ const applyReview = useCallback(() => {
524
+ setReview((current) => {
525
+ if (!current) return null;
526
+ for (const field of current.fields) {
527
+ if (!field.selected || field.pending) continue;
528
+ if (field.proposed === void 0 || field.proposed === null) continue;
529
+ formContext?.setFieldValue(field.key, field.proposed);
530
+ }
531
+ return null;
532
+ });
533
+ }, [formContext]);
534
+ const editorAIController = useEditorAIController({ endpoint });
535
+ const getSamplePrompts = useCallback((entityName, input) => fetchPromptSuggestions({
536
+ endpoint,
537
+ entityName,
538
+ input
539
+ }), [endpoint]);
483
540
  const dataEnhancementController = useMemo(() => ({
484
541
  enabled,
485
- suggestions,
486
- clearSuggestion,
487
- enhance,
488
- allowReferenceDataSelection,
489
- clearAllSuggestions,
542
+ review,
543
+ generate,
544
+ toggleField,
545
+ toggleAll,
546
+ applyReview,
547
+ dismissReview,
490
548
  getSamplePrompts,
491
- loadingSuggestions,
492
549
  editorAIController
493
550
  }), [
494
551
  enabled,
495
- suggestions,
496
- clearSuggestion,
497
- enhance,
498
- allowReferenceDataSelection,
499
- clearAllSuggestions,
552
+ review,
553
+ generate,
554
+ toggleField,
555
+ toggleAll,
556
+ applyReview,
557
+ dismissReview,
500
558
  getSamplePrompts,
501
- loadingSuggestions,
502
559
  editorAIController
503
560
  ]);
504
561
  return /* @__PURE__ */ jsx(DataEnhancementControllerContext.Provider, {
@@ -507,15 +564,191 @@ function DataEnhancementControllerProvider({ apiKey, getConfigForPath, children,
507
564
  });
508
565
  }
509
566
  //#endregion
567
+ //#region src/components/AutofillReviewDialog.tsx
568
+ /**
569
+ * The review step.
570
+ *
571
+ * Autofill used to write generated text into the live form as it streamed —
572
+ * fields mutating under the cursor, half-written sentences that looked like
573
+ * bugs, and a pile of heuristics deciding whether each token should append to
574
+ * or replace what the operator had already typed. Getting the old value back
575
+ * meant retyping it.
576
+ *
577
+ * So the generated values land here instead. Streaming still happens, and is
578
+ * still worth having — rows appear and fill in as the model works, so a long
579
+ * run shows progress — but it happens in a surface that owns nothing. The
580
+ * record changes on **Apply**, once, for the rows still ticked.
581
+ */
582
+ function AutofillReviewDialog() {
583
+ const controller = useDataEnhancementController();
584
+ const review = controller?.review;
585
+ if (!review) return null;
586
+ const generating = review.status === "generating";
587
+ const applicable = review.fields.filter((f) => !f.pending && f.selected);
588
+ const allSelected = review.fields.length > 0 && review.fields.every((f) => f.selected);
589
+ return /* @__PURE__ */ jsxs(Dialog, {
590
+ open: true,
591
+ maxWidth: "2xl",
592
+ onOpenChange: (open) => {
593
+ if (!open) controller.dismissReview();
594
+ },
595
+ children: [
596
+ /* @__PURE__ */ jsx(DialogTitle, {
597
+ variant: "subtitle1",
598
+ gutterBottom: false,
599
+ children: "Review autofill"
600
+ }),
601
+ /* @__PURE__ */ jsxs(DialogContent, {
602
+ className: "flex flex-col gap-2",
603
+ children: [
604
+ review.instructions && /* @__PURE__ */ jsxs(Typography, {
605
+ variant: "body2",
606
+ color: "secondary",
607
+ className: "italic",
608
+ children: [
609
+ "“",
610
+ review.instructions,
611
+ "”"
612
+ ]
613
+ }),
614
+ review.fields.length > 1 && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("label", {
615
+ className: "flex items-center gap-3 py-1 cursor-pointer select-none",
616
+ children: [/* @__PURE__ */ jsx(Checkbox, {
617
+ checked: allSelected,
618
+ size: "small",
619
+ onCheckedChange: () => controller.toggleAll(!allSelected)
620
+ }), /* @__PURE__ */ jsx(Typography, {
621
+ variant: "label",
622
+ color: "secondary",
623
+ children: allSelected ? "Deselect all" : "Select all"
624
+ })]
625
+ }), /* @__PURE__ */ jsx(Separator, {
626
+ orientation: "horizontal",
627
+ className: "my-0"
628
+ })] }),
629
+ /* @__PURE__ */ jsx("div", {
630
+ className: "flex flex-col divide-y divide-surface-accent-100 dark:divide-surface-accent-800",
631
+ children: review.fields.map((field) => /* @__PURE__ */ jsx(ProposedFieldRow, {
632
+ field,
633
+ onToggle: () => controller.toggleField(field.key)
634
+ }, field.key))
635
+ }),
636
+ generating && /* @__PURE__ */ jsxs("div", {
637
+ className: "flex items-center gap-3 py-4 text-text-secondary dark:text-text-secondary-dark",
638
+ children: [/* @__PURE__ */ jsx(CircularProgress, { size: "smallest" }), /* @__PURE__ */ jsx(Typography, {
639
+ variant: "body2",
640
+ color: "secondary",
641
+ children: review.fields.length === 0 ? "Thinking…" : "Writing the remaining fields…"
642
+ })]
643
+ }),
644
+ review.status === "failed" && /* @__PURE__ */ jsxs(Typography, {
645
+ variant: "body2",
646
+ className: "py-2 text-red-600 dark:text-red-400",
647
+ children: [review.error, review.fields.length > 0 && " You can still apply what was written before it stopped."]
648
+ }),
649
+ !generating && review.fields.length === 0 && review.status !== "failed" && /* @__PURE__ */ jsx(Typography, {
650
+ variant: "body2",
651
+ color: "secondary",
652
+ className: "py-4",
653
+ 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."
654
+ })
655
+ ]
656
+ }),
657
+ /* @__PURE__ */ jsxs(DialogActions, { children: [/* @__PURE__ */ jsx(Button, {
658
+ variant: "text",
659
+ color: "neutral",
660
+ onClick: controller.dismissReview,
661
+ children: "Discard"
662
+ }), /* @__PURE__ */ jsx(Button, {
663
+ variant: "filled",
664
+ disabled: applicable.length === 0,
665
+ onClick: controller.applyReview,
666
+ children: applicable.length === 1 ? "Apply 1 field" : `Apply ${applicable.length} fields`
667
+ })] })
668
+ ]
669
+ });
670
+ }
671
+ function ProposedFieldRow({ field, onToggle }) {
672
+ const replaces = hasValue(field.currentValue) && !isSameValue(field.currentValue, field.proposed);
673
+ return /* @__PURE__ */ jsxs("label", {
674
+ className: cls("flex items-start gap-3 py-3 cursor-pointer", !field.selected && "opacity-50"),
675
+ children: [/* @__PURE__ */ jsx("div", {
676
+ className: "mt-0.5 shrink-0",
677
+ children: /* @__PURE__ */ jsx(Checkbox, {
678
+ checked: field.selected,
679
+ size: "small",
680
+ onCheckedChange: onToggle
681
+ })
682
+ }), /* @__PURE__ */ jsxs("div", {
683
+ className: "flex flex-col gap-1 min-w-0 grow",
684
+ children: [
685
+ /* @__PURE__ */ jsxs("div", {
686
+ className: "flex items-center gap-2",
687
+ children: [
688
+ /* @__PURE__ */ jsx(Typography, {
689
+ variant: "label",
690
+ children: field.label
691
+ }),
692
+ replaces && /* @__PURE__ */ jsx(Typography, {
693
+ variant: "caption",
694
+ color: "secondary",
695
+ children: "replaces the current value"
696
+ }),
697
+ field.pending && /* @__PURE__ */ jsx(CircularProgress, { size: "smallest" })
698
+ ]
699
+ }),
700
+ replaces && /* @__PURE__ */ jsx(Typography, {
701
+ variant: "body2",
702
+ color: "secondary",
703
+ className: "line-through whitespace-pre-wrap break-words",
704
+ children: renderValue(field.currentValue)
705
+ }),
706
+ /* @__PURE__ */ jsx(Typography, {
707
+ variant: "body2",
708
+ className: "whitespace-pre-wrap break-words",
709
+ children: renderValue(field.proposed)
710
+ })
711
+ ]
712
+ })]
713
+ });
714
+ }
715
+ function hasValue(value) {
716
+ if (value === null || value === void 0) return false;
717
+ if (typeof value === "string") return value.trim().length > 0;
718
+ if (Array.isArray(value)) return value.length > 0;
719
+ return true;
720
+ }
721
+ function isSameValue(a, b) {
722
+ if (a === b) return true;
723
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
724
+ if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => isSameValue(v, b[i]));
725
+ return false;
726
+ }
727
+ /** Values are shown, never edited here — so a readable string is all that is needed. */
728
+ function renderValue(value) {
729
+ if (value === null || value === void 0) return "";
730
+ if (value instanceof Date) return value.toLocaleString();
731
+ if (Array.isArray(value)) return value.map((v) => renderValue(v)).join(", ");
732
+ if (typeof value === "boolean") return value ? "Yes" : "No";
733
+ if (typeof value === "object") return JSON.stringify(value);
734
+ return String(value);
735
+ }
736
+ //#endregion
510
737
  //#region src/components/FormEnhanceAction.tsx
511
- function FormEnhanceAction({ entityId, path, status, collection, formContext, openEntityMode }) {
512
- const largeLayout = useLargeLayout();
738
+ function FormEnhanceAction({ path, status, collection, formContext }) {
513
739
  const storageKey = createLocalStorageKey(path, status);
514
- const [loading, setLoading] = React.useState(false);
515
740
  const dataEnhancementController = useDataEnhancementController();
516
741
  const [samplePrompts, setSamplePrompts] = React.useState(void 0);
517
742
  const [instructions, setInstructions] = React.useState("");
518
743
  const getSamplePrompts = dataEnhancementController?.getSamplePrompts;
744
+ /**
745
+ * Driven by the controller rather than by local state.
746
+ *
747
+ * There is exactly one run at a time, and the review owns it — a second
748
+ * `loading` flag here could disagree with the dialog about whether the
749
+ * model is still writing.
750
+ */
751
+ const loading = dataEnhancementController?.review?.status === "generating";
519
752
  const loadingPrompts = useRef(false);
520
753
  const updateSuggestedPrompts = useCallback(async function updateSuggestedPrompts(instructions) {
521
754
  if (!getSamplePrompts) return;
@@ -532,7 +765,6 @@ function FormEnhanceAction({ entityId, path, status, collection, formContext, op
532
765
  getSamplePrompts,
533
766
  status
534
767
  ]);
535
- useDeferredValue(formContext?.values);
536
768
  useEffect(() => {
537
769
  if (!dataEnhancementController) return;
538
770
  if (!samplePrompts) {
@@ -551,9 +783,12 @@ function FormEnhanceAction({ entityId, path, status, collection, formContext, op
551
783
  if (!dataEnhancementController) return;
552
784
  updateSuggestedPrompts().then();
553
785
  }, [dataEnhancementController, status]);
554
- const enhance = (prompt) => {
786
+ /**
787
+ * Starts a run and opens the review. Nothing is written to the form here —
788
+ * see {@link AutofillReviewDialog}.
789
+ */
790
+ const generate = (prompt) => {
555
791
  if (!dataEnhancementController || !formContext?.values) return;
556
- setLoading(true);
557
792
  if (prompt) {
558
793
  addRecentPrompt(storageKey, prompt);
559
794
  setSamplePrompts([{
@@ -561,30 +796,22 @@ function FormEnhanceAction({ entityId, path, status, collection, formContext, op
561
796
  type: "recent"
562
797
  }, ...(samplePrompts ?? []).slice(0, 5)]);
563
798
  }
564
- return dataEnhancementController.enhance({
565
- entityId,
799
+ dataEnhancementController.generate({
566
800
  values: formContext.values,
567
- instructions: prompt,
568
- replaceValues: true
569
- }).finally(() => {
570
- setLoading(false);
571
- });
801
+ instructions: prompt
802
+ }).catch(() => void 0);
572
803
  };
573
804
  if (!dataEnhancementController?.enabled) return null;
574
- const suggestions = dataEnhancementController.suggestions;
575
- Object.values(suggestions).filter(Boolean).length;
576
- (samplePrompts ?? []).length > 0 && instructions.length;
577
805
  function submit() {
578
- enhance(instructions);
806
+ generate(instructions);
579
807
  }
580
- return /* @__PURE__ */ jsxs(Menu, {
808
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs(Menu, {
581
809
  align: "end",
582
810
  sideOffset: 8,
583
811
  className: "max-w-[100vw]",
584
812
  trigger: /* @__PURE__ */ jsxs(Button, {
585
813
  variant: "filled",
586
814
  color: "neutral",
587
- fullWidth: largeLayout && openEntityMode === "full_screen",
588
815
  size: "small",
589
816
  disabled: loading,
590
817
  children: [
@@ -597,7 +824,7 @@ function FormEnhanceAction({ entityId, path, status, collection, formContext, op
597
824
  /* @__PURE__ */ jsxs(MenuItem, {
598
825
  className: "py-4",
599
826
  onClick: () => {
600
- enhance();
827
+ generate();
601
828
  },
602
829
  children: [/* @__PURE__ */ jsx(AIIcon, { size: "small" }), "Autofill based on the current content"]
603
830
  }),
@@ -609,7 +836,7 @@ function FormEnhanceAction({ entityId, path, status, collection, formContext, op
609
836
  return /* @__PURE__ */ jsxs(MenuItem, {
610
837
  onClick: () => {
611
838
  setInstructions(samplePrompt.prompt);
612
- enhance(samplePrompt.prompt);
839
+ generate(samplePrompt.prompt);
613
840
  },
614
841
  children: [/* @__PURE__ */ jsx("div", {
615
842
  className: "pl-9 grow text-text-secondary dark:text-text-secondary-dark",
@@ -660,7 +887,7 @@ function FormEnhanceAction({ entityId, path, status, collection, formContext, op
660
887
  children: /* @__PURE__ */ jsx(XIcon, { size: iconSize.small })
661
888
  }),
662
889
  /* @__PURE__ */ jsxs(IconButton, {
663
- onClick: () => enhance(instructions),
890
+ onClick: () => generate(instructions),
664
891
  size: "small",
665
892
  color: !instructions ? "primary" : void 0,
666
893
  disabled: loading || !instructions,
@@ -669,7 +896,7 @@ function FormEnhanceAction({ entityId, path, status, collection, formContext, op
669
896
  ]
670
897
  })
671
898
  ]
672
- });
899
+ }), /* @__PURE__ */ jsx(AutofillReviewDialog, {})] });
673
900
  }
674
901
  function getPromptsForExistingEntities(properties) {
675
902
  const multilineProperties = Object.values(properties).filter((p) => {
@@ -704,15 +931,14 @@ var removeRecentPrompt = (storageKey, prompt) => {
704
931
  };
705
932
  //#endregion
706
933
  //#region src/useDataEnhancementPlugin.tsx
707
- var DEFAULT_API_KEY = "fcms-U9jdDii0xXWSDC34asfrf54lbkFJBfKfRWcEDEwdc4V5wDWEDF";
708
934
  /**
709
935
  * Use this hook to initialise the data enhancement plugin.
710
936
  * This is likely the only hook you will need to use.
711
937
  * @param props
712
938
  */
713
939
  function useDataEnhancementPlugin(props) {
714
- const apiKey = props?.apiKey ?? DEFAULT_API_KEY;
715
940
  const getConfigForPath = props?.getConfigForPath;
941
+ const endpoint = props?.endpoint;
716
942
  return React.useMemo(() => ({
717
943
  key: "data_enhancement",
718
944
  slots: [{
@@ -724,16 +950,11 @@ function useDataEnhancementPlugin(props) {
724
950
  scope: "form",
725
951
  Component: DataEnhancementControllerProvider,
726
952
  props: {
727
- apiKey,
728
953
  getConfigForPath,
729
- host: props?.host
954
+ endpoint
730
955
  }
731
956
  }]
732
- }), [
733
- apiKey,
734
- getConfigForPath,
735
- props?.host
736
- ]);
957
+ }), [getConfigForPath, endpoint]);
737
958
  }
738
959
  //#endregion
739
960
  export { useDataEnhancementPlugin, useEditorAIController };