@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/README.md +62 -18
- package/dist/api.d.ts +57 -31
- package/dist/components/AutofillReviewDialog.d.ts +16 -0
- package/dist/components/DataEnhancementControllerProvider.d.ts +2 -3
- package/dist/components/FormEnhanceAction.d.ts +1 -1
- package/dist/editor/useEditorAIController.d.ts +11 -2
- package/dist/index.es.js +582 -361
- package/dist/index.es.js.map +1 -1
- package/dist/types/data_enhancement_controller.d.ts +84 -28
- package/dist/useDataEnhancementPlugin.d.ts +10 -5
- package/package.json +23 -19
- package/src/api.ts +238 -174
- package/src/components/AutofillReviewDialog.tsx +203 -0
- package/src/components/DataEnhancementControllerProvider.tsx +196 -264
- package/src/components/FormEnhanceAction.tsx +128 -128
- package/src/editor/useEditorAIController.tsx +20 -33
- package/src/tests/api.test.ts +283 -0
- package/src/tests/review.test.tsx +260 -0
- package/src/tests/useDataEnhancementPlugin.test.tsx +36 -15
- package/src/types/data_enhancement_controller.tsx +98 -31
- package/src/useDataEnhancementPlugin.tsx +13 -12
- package/dist/utils/diffStrings.d.ts +0 -7
- package/dist/utils/strings_counter.d.ts +0 -2
- package/dist/utils/suggestions.d.ts +0 -1
- package/src/tests/diffStrings.test.ts +0 -128
- package/src/tests/strings_counter.test.ts +0 -117
- package/src/tests/suggestions.test.ts +0 -53
- package/src/utils/diffStrings.ts +0 -70
- package/src/utils/strings_counter.ts +0 -22
- package/src/utils/suggestions.ts +0 -6
package/dist/index.es.js
CHANGED
|
@@ -1,143 +1,193 @@
|
|
|
1
|
-
import React, { useCallback, useContext,
|
|
2
|
-
import {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
if (
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
}
|
|
132
|
-
|
|
133
|
-
return result;
|
|
134
|
-
});
|
|
161
|
+
}
|
|
162
|
+
return text;
|
|
135
163
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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({
|
|
291
|
-
const [
|
|
292
|
-
const [
|
|
293
|
-
const [
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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 (!
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (getConfigForPath({
|
|
389
|
+
if (!getConfigForPath) {
|
|
390
|
+
setAllowedHere(true);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
setAllowedHere(Boolean(getConfigForPath({
|
|
306
394
|
path,
|
|
307
395
|
collection
|
|
308
|
-
}))
|
|
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 (!
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
displayNeededSubscriptionSnackbar,
|
|
468
|
-
snackbarController
|
|
501
|
+
endpoint,
|
|
502
|
+
upsertField
|
|
469
503
|
]);
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
565
|
-
entityId,
|
|
799
|
+
dataEnhancementController.generate({
|
|
566
800
|
values: formContext.values,
|
|
567
|
-
instructions: prompt
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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 };
|