@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.
- package/README.md +62 -18
- package/dist/api.d.ts +58 -30
- 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 +601 -382
- 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 +24 -19
- package/src/api.ts +241 -174
- package/src/components/AutofillReviewDialog.tsx +209 -0
- package/src/components/DataEnhancementControllerProvider.tsx +203 -262
- package/src/components/FormEnhanceAction.tsx +154 -128
- package/src/editor/useEditorAIController.tsx +20 -33
- package/src/tests/AutofillReviewDialog.test.tsx +340 -0
- package/src/tests/api.test.ts +283 -0
- package/src/tests/properties.test.ts +420 -0
- package/src/tests/review.test.tsx +393 -0
- package/src/tests/useDataEnhancementPlugin.test.tsx +36 -15
- package/src/tests/useEditorAIController.test.ts +98 -0
- 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,194 @@
|
|
|
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, 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
|
-
|
|
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
|
+
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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({
|
|
291
|
-
const [
|
|
292
|
-
const [
|
|
293
|
-
const [
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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 (!
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (getConfigForPath({
|
|
390
|
+
if (!getConfigForPath) {
|
|
391
|
+
setAllowedHere(true);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
setAllowedHere(Boolean(getConfigForPath({
|
|
306
395
|
path,
|
|
307
396
|
collection
|
|
308
|
-
}))
|
|
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 (!
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
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));
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
displayNeededSubscriptionSnackbar,
|
|
468
|
-
snackbarController
|
|
496
|
+
endpoint,
|
|
497
|
+
upsertField
|
|
469
498
|
]);
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
565
|
-
entityId,
|
|
797
|
+
dataEnhancementController.generate({
|
|
566
798
|
values: formContext.values,
|
|
567
|
-
instructions: prompt
|
|
568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
632
|
-
children: [
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
})
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
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 };
|