@rebasepro/plugin-ai 0.17.3-canary.gdd23447 → 0.18.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/LICENSE +0 -1
- package/README.md +4 -0
- package/dist/api.d.ts +8 -0
- package/dist/index.es.js.map +1 -1
- package/package.json +35 -21
- package/src/api.ts +0 -320
- package/src/components/AutofillReviewDialog.tsx +0 -209
- package/src/components/DataEnhancementControllerProvider.tsx +0 -320
- package/src/components/FormEnhanceAction.tsx +0 -307
- package/src/editor/useEditorAIController.tsx +0 -24
- package/src/index.ts +0 -9
- package/src/tests/AutofillReviewDialog.test.tsx +0 -340
- package/src/tests/api.test.ts +0 -382
- package/src/tests/properties.test.ts +0 -420
- package/src/tests/request_agreement.test.ts +0 -240
- package/src/tests/review.test.tsx +0 -596
- package/src/tests/useDataEnhancementPlugin.test.tsx +0 -72
- package/src/tests/useEditorAIController.test.ts +0 -98
- package/src/tests/values.test.ts +0 -87
- package/src/types/data_enhancement_controller.tsx +0 -142
- package/src/useDataEnhancementPlugin.tsx +0 -68
- package/src/utils/properties.ts +0 -168
- package/src/utils/values.ts +0 -72
- package/src/vite-env.d.ts +0 -1
|
@@ -1,320 +0,0 @@
|
|
|
1
|
-
import React, { PropsWithChildren, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
AutofillReview,
|
|
5
|
-
DataEnhancementController,
|
|
6
|
-
GenerateParams,
|
|
7
|
-
InputProperty,
|
|
8
|
-
ProposedField
|
|
9
|
-
} from "../types/data_enhancement_controller";
|
|
10
|
-
import { CollectionConfig, User } from "@rebasepro/types";
|
|
11
|
-
import { PluginFormActionProps } from "@rebasepro/cms-types";
|
|
12
|
-
import { useAuthController } from "@rebasepro/app";
|
|
13
|
-
import { autofillStream, fetchAiStatusCached, fetchPromptSuggestions } from "../api";
|
|
14
|
-
import { getSimplifiedProperties } from "../utils/properties";
|
|
15
|
-
import { flatMapEntityValues, omitDisabledValues } from "../utils/values";
|
|
16
|
-
import { useEditorAIController } from "../editor/useEditorAIController";
|
|
17
|
-
import { getValueInPath } from "@rebasepro/utils";
|
|
18
|
-
|
|
19
|
-
const DataEnhancementControllerContext = React.createContext<DataEnhancementController>(null! as DataEnhancementController);
|
|
20
|
-
|
|
21
|
-
type DataEnhancementControllerProviderProps = {
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Kept in step with `DataEnhancementPluginProps.getConfigForPath`, which is
|
|
25
|
-
* the signature the host app actually writes against: the plugin hands this
|
|
26
|
-
* component through as `ComponentType<any>`, so nothing but agreement here
|
|
27
|
-
* makes the two match.
|
|
28
|
-
*/
|
|
29
|
-
getConfigForPath?: (props: {
|
|
30
|
-
path: string,
|
|
31
|
-
collection: CollectionConfig,
|
|
32
|
-
user: User | null
|
|
33
|
-
}) => boolean;
|
|
34
|
-
|
|
35
|
-
endpoint?: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export const useDataEnhancementController = (): DataEnhancementController => useContext(DataEnhancementControllerContext);
|
|
39
|
-
|
|
40
|
-
function getPropertyFromKey(properties: Record<string, InputProperty>, propertyKey: string): InputProperty | undefined {
|
|
41
|
-
if (propertyKey in properties) {
|
|
42
|
-
return properties[propertyKey];
|
|
43
|
-
}
|
|
44
|
-
const split = propertyKey.split(".");
|
|
45
|
-
if (split.length === 1) return undefined;
|
|
46
|
-
return getPropertyFromKey(properties, split.slice(0, -1).join("."));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Convert a value off the wire into what the form field expects.
|
|
51
|
-
*
|
|
52
|
-
* Only dates need converting: the service answers ISO-8601 strings because JSON
|
|
53
|
-
* has no date type, and handing a date field a string stores the wrong type
|
|
54
|
-
* without complaining. Everything else — strings, numbers, booleans, arrays of
|
|
55
|
-
* scalars — is already the shape the field wants, which is the point of having
|
|
56
|
-
* the service constrain its answer to a schema derived from these properties.
|
|
57
|
-
*/
|
|
58
|
-
function coerceToProperty(value: unknown, property: InputProperty | undefined): unknown {
|
|
59
|
-
if (property?.type === "date" && typeof value === "string") {
|
|
60
|
-
const date = new Date(value);
|
|
61
|
-
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
62
|
-
}
|
|
63
|
-
return value;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function DataEnhancementControllerProvider({
|
|
67
|
-
getConfigForPath,
|
|
68
|
-
children,
|
|
69
|
-
endpoint,
|
|
70
|
-
path,
|
|
71
|
-
collection,
|
|
72
|
-
formContext
|
|
73
|
-
}: PropsWithChildren<DataEnhancementControllerProviderProps & PluginFormActionProps>) {
|
|
74
|
-
|
|
75
|
-
const [allowedHere, setAllowedHere] = useState(false);
|
|
76
|
-
const [serviceAvailable, setServiceAvailable] = useState(false);
|
|
77
|
-
const [review, setReview] = useState<AutofillReview | null>(null);
|
|
78
|
-
|
|
79
|
-
const properties = useMemo(
|
|
80
|
-
() => getSimplifiedProperties(collection.properties, formContext?.values ?? {}),
|
|
81
|
-
[collection.properties, formContext?.values]
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Read inside the streaming callbacks, which outlive the render that
|
|
86
|
-
* started the run.
|
|
87
|
-
*
|
|
88
|
-
* The operator is free to keep typing while the model works — nothing here
|
|
89
|
-
* writes to the form — so the callbacks must not close over a stale
|
|
90
|
-
* property map from whichever render happened to kick the run off.
|
|
91
|
-
*/
|
|
92
|
-
const propertiesRef = useRef(properties);
|
|
93
|
-
propertiesRef.current = properties;
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* The host app's own opt-out.
|
|
97
|
-
*
|
|
98
|
-
* `user` is part of the documented signature and was never passed, so
|
|
99
|
-
* `getConfigForPath: ({ user }) => user?.roles?.includes("editor")` was
|
|
100
|
-
* `Boolean(undefined)` for everyone — an access rule that silently decided
|
|
101
|
-
* nothing, in whichever direction the host had written it.
|
|
102
|
-
*/
|
|
103
|
-
const authController = useAuthController();
|
|
104
|
-
const user: User | null = authController?.user ?? null;
|
|
105
|
-
|
|
106
|
-
useEffect(() => {
|
|
107
|
-
if (!getConfigForPath) {
|
|
108
|
-
setAllowedHere(true);
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
setAllowedHere(Boolean(getConfigForPath({ path,
|
|
112
|
-
collection,
|
|
113
|
-
user })));
|
|
114
|
-
}, [getConfigForPath, path, collection, user]);
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* The service's own availability.
|
|
118
|
-
*
|
|
119
|
-
* Nothing renders until this comes back true. An unreachable host, an
|
|
120
|
-
* unconfigured provider key or an exhausted daily quota all land here, and
|
|
121
|
-
* all of them mean the same thing to the operator: no Autofill button,
|
|
122
|
-
* rather than a button that fails when clicked.
|
|
123
|
-
*
|
|
124
|
-
* Asked through the session cache: this provider is form-scoped, so an
|
|
125
|
-
* uncached probe is one request to the host per record opened, by an install
|
|
126
|
-
* that may never use the feature. The probe is shared rather than aborted on
|
|
127
|
-
* unmount — cancelling it would cancel it for whatever else is waiting on the
|
|
128
|
-
* same answer — so unmounting only stops this component from reading it.
|
|
129
|
-
*/
|
|
130
|
-
useEffect(() => {
|
|
131
|
-
if (!allowedHere) return;
|
|
132
|
-
let cancelled = false;
|
|
133
|
-
fetchAiStatusCached({ endpoint })
|
|
134
|
-
.then((status) => {
|
|
135
|
-
if (!cancelled) setServiceAvailable(status.available);
|
|
136
|
-
});
|
|
137
|
-
return () => {
|
|
138
|
-
cancelled = true;
|
|
139
|
-
};
|
|
140
|
-
}, [allowedHere, endpoint]);
|
|
141
|
-
|
|
142
|
-
const enabled = allowedHere && serviceAvailable;
|
|
143
|
-
|
|
144
|
-
/** Add or update one row in the review, preserving arrival order. */
|
|
145
|
-
const upsertField = useCallback((key: string, update: (existing: ProposedField | undefined) => ProposedField) => {
|
|
146
|
-
setReview((current) => {
|
|
147
|
-
if (!current) return current;
|
|
148
|
-
const index = current.fields.findIndex((f) => f.key === key);
|
|
149
|
-
const next = update(index === -1 ? undefined : current.fields[index]);
|
|
150
|
-
const fields = index === -1
|
|
151
|
-
? [...current.fields, next]
|
|
152
|
-
: current.fields.map((f, i) => (i === index ? next : f));
|
|
153
|
-
return { ...current,
|
|
154
|
-
fields };
|
|
155
|
-
});
|
|
156
|
-
}, []);
|
|
157
|
-
|
|
158
|
-
const generate = useCallback(async (params: GenerateParams<Record<string, unknown>>): Promise<void> => {
|
|
159
|
-
|
|
160
|
-
const currentProperties = propertiesRef.current;
|
|
161
|
-
const flatValues = omitDisabledValues(
|
|
162
|
-
flatMapEntityValues(params.values ?? {}),
|
|
163
|
-
currentProperties
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
setReview({
|
|
167
|
-
status: "generating",
|
|
168
|
-
fields: [],
|
|
169
|
-
instructions: params.instructions
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
const labelFor = (key: string) => currentProperties[key]?.name ?? key;
|
|
173
|
-
|
|
174
|
-
try {
|
|
175
|
-
await autofillStream({
|
|
176
|
-
endpoint,
|
|
177
|
-
request: {
|
|
178
|
-
entityName: collection.singularName ?? collection.name,
|
|
179
|
-
entityDescription: collection.description,
|
|
180
|
-
// Flattened to dotted paths so the keys line up with the
|
|
181
|
-
// property map: the service is told about `seo.title`, so it
|
|
182
|
-
// has to be told the value of `seo.title` too, not of `seo`.
|
|
183
|
-
// Exactly the same rule in both directions — an array or a
|
|
184
|
-
// date is one value under one key here because it is one
|
|
185
|
-
// property under one key there. Where they disagreed, the
|
|
186
|
-
// service read a filled field as empty and offered to
|
|
187
|
-
// rewrite it. Values of properties nobody may edit do not
|
|
188
|
-
// travel at all.
|
|
189
|
-
values: flatValues,
|
|
190
|
-
properties: currentProperties,
|
|
191
|
-
propertyKey: params.propertyKey,
|
|
192
|
-
propertyInstructions: params.propertyInstructions,
|
|
193
|
-
instructions: params.instructions
|
|
194
|
-
},
|
|
195
|
-
onDelta: (key, text) => {
|
|
196
|
-
upsertField(key, (existing) => existing
|
|
197
|
-
? { ...existing,
|
|
198
|
-
proposed: String(existing.proposed ?? "") + text }
|
|
199
|
-
: {
|
|
200
|
-
key,
|
|
201
|
-
label: labelFor(key),
|
|
202
|
-
currentValue: getValueInPath(params.values, key),
|
|
203
|
-
proposed: text,
|
|
204
|
-
pending: true,
|
|
205
|
-
selected: true
|
|
206
|
-
});
|
|
207
|
-
},
|
|
208
|
-
onValue: (key, value) => {
|
|
209
|
-
const coerced = coerceToProperty(value, getPropertyFromKey(currentProperties, key));
|
|
210
|
-
upsertField(key, (existing) => ({
|
|
211
|
-
key,
|
|
212
|
-
label: existing?.label ?? labelFor(key),
|
|
213
|
-
currentValue: existing?.currentValue ?? getValueInPath(params.values, key),
|
|
214
|
-
proposed: coerced,
|
|
215
|
-
pending: false,
|
|
216
|
-
// A row the operator already deselected mid-stream stays
|
|
217
|
-
// deselected when its final value lands.
|
|
218
|
-
selected: existing?.selected ?? true
|
|
219
|
-
}));
|
|
220
|
-
}
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
setReview((current) => current && {
|
|
224
|
-
...current,
|
|
225
|
-
status: "ready",
|
|
226
|
-
// Fields still pending when the run ended never received a final
|
|
227
|
-
// value — the model's JSON was cut off mid-string, so all we
|
|
228
|
-
// hold is a half-written sentence. Marking them complete would
|
|
229
|
-
// make that sentence applicable, which is the exact outcome the
|
|
230
|
-
// review exists to prevent. They are dropped instead: the review
|
|
231
|
-
// only ever offers what the service actually finished.
|
|
232
|
-
fields: current.fields.filter((f) => !f.pending)
|
|
233
|
-
});
|
|
234
|
-
} catch (e: unknown) {
|
|
235
|
-
const message = e instanceof Error ? e.message : "Autofill could not be completed";
|
|
236
|
-
// Kept in the review rather than fired into a snackbar: a run that
|
|
237
|
-
// produced three good fields and then failed should still let the
|
|
238
|
-
// operator apply the three.
|
|
239
|
-
setReview((current) => current && {
|
|
240
|
-
...current,
|
|
241
|
-
status: "failed",
|
|
242
|
-
error: message,
|
|
243
|
-
// Same rule as the success path: a field interrupted mid-write
|
|
244
|
-
// is not something the operator can be offered.
|
|
245
|
-
fields: current.fields.filter((f) => !f.pending)
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
}, [collection, endpoint, upsertField]);
|
|
249
|
-
|
|
250
|
-
const toggleField = useCallback((key: string) => {
|
|
251
|
-
setReview((current) => current && {
|
|
252
|
-
...current,
|
|
253
|
-
fields: current.fields.map((f) => (f.key === key ? { ...f,
|
|
254
|
-
selected: !f.selected } : f))
|
|
255
|
-
});
|
|
256
|
-
}, []);
|
|
257
|
-
|
|
258
|
-
const toggleAll = useCallback((selected: boolean) => {
|
|
259
|
-
setReview((current) => current && {
|
|
260
|
-
...current,
|
|
261
|
-
fields: current.fields.map((f) => ({ ...f,
|
|
262
|
-
selected }))
|
|
263
|
-
});
|
|
264
|
-
}, []);
|
|
265
|
-
|
|
266
|
-
const dismissReview = useCallback(() => setReview(null), []);
|
|
267
|
-
|
|
268
|
-
const applyReview = useCallback(() => {
|
|
269
|
-
setReview((current) => {
|
|
270
|
-
if (!current) return null;
|
|
271
|
-
for (const field of current.fields) {
|
|
272
|
-
if (!field.selected || field.pending) continue;
|
|
273
|
-
if (field.proposed === undefined || field.proposed === null) continue;
|
|
274
|
-
formContext?.setFieldValue(field.key, field.proposed);
|
|
275
|
-
}
|
|
276
|
-
return null;
|
|
277
|
-
});
|
|
278
|
-
}, [formContext]);
|
|
279
|
-
|
|
280
|
-
const editorAIController = useEditorAIController({ endpoint });
|
|
281
|
-
|
|
282
|
-
const getSamplePrompts = useCallback(
|
|
283
|
-
(entityName: string, input?: string) => fetchPromptSuggestions({
|
|
284
|
-
endpoint,
|
|
285
|
-
entityName,
|
|
286
|
-
entityDescription: collection.description,
|
|
287
|
-
input
|
|
288
|
-
}),
|
|
289
|
-
[endpoint, collection.description]
|
|
290
|
-
);
|
|
291
|
-
|
|
292
|
-
const dataEnhancementController: DataEnhancementController = useMemo(() => ({
|
|
293
|
-
enabled,
|
|
294
|
-
review,
|
|
295
|
-
generate,
|
|
296
|
-
toggleField,
|
|
297
|
-
toggleAll,
|
|
298
|
-
applyReview,
|
|
299
|
-
dismissReview,
|
|
300
|
-
getSamplePrompts,
|
|
301
|
-
editorAIController
|
|
302
|
-
}), [
|
|
303
|
-
enabled,
|
|
304
|
-
review,
|
|
305
|
-
generate,
|
|
306
|
-
toggleField,
|
|
307
|
-
toggleAll,
|
|
308
|
-
applyReview,
|
|
309
|
-
dismissReview,
|
|
310
|
-
getSamplePrompts,
|
|
311
|
-
editorAIController
|
|
312
|
-
]);
|
|
313
|
-
|
|
314
|
-
return (
|
|
315
|
-
<DataEnhancementControllerContext.Provider
|
|
316
|
-
value={dataEnhancementController}>
|
|
317
|
-
{children}
|
|
318
|
-
</DataEnhancementControllerContext.Provider>
|
|
319
|
-
);
|
|
320
|
-
}
|
|
@@ -1,307 +0,0 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useRef } from "react";
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
CircularProgress,
|
|
5
|
-
cls,
|
|
6
|
-
fieldBackgroundMixin,
|
|
7
|
-
focusedDisabled,
|
|
8
|
-
IconButton,
|
|
9
|
-
iconSize,
|
|
10
|
-
Menu,
|
|
11
|
-
MenuItem,
|
|
12
|
-
SendIcon,
|
|
13
|
-
Separator,
|
|
14
|
-
TextareaAutosize,
|
|
15
|
-
XIcon
|
|
16
|
-
} from "@rebasepro/ui";
|
|
17
|
-
import {
|
|
18
|
-
AIIcon
|
|
19
|
-
} from "@rebasepro/app";
|
|
20
|
-
import { EntityStatus, Properties, Property } from "@rebasepro/types";
|
|
21
|
-
import { PluginFormActionProps } from "@rebasepro/cms-types";
|
|
22
|
-
import { isPropertyBuilder, stripCollectionPath } from "@rebasepro/common";
|
|
23
|
-
import { useDataEnhancementController } from "./DataEnhancementControllerProvider";
|
|
24
|
-
import { AutofillReviewDialog } from "./AutofillReviewDialog";
|
|
25
|
-
import { SamplePrompt } from "../types/data_enhancement_controller";
|
|
26
|
-
|
|
27
|
-
export function FormEnhanceAction({
|
|
28
|
-
path,
|
|
29
|
-
status,
|
|
30
|
-
collection,
|
|
31
|
-
formContext
|
|
32
|
-
}: PluginFormActionProps) {
|
|
33
|
-
|
|
34
|
-
const storageKey = createLocalStorageKey(path, status);
|
|
35
|
-
|
|
36
|
-
const dataEnhancementController = useDataEnhancementController();
|
|
37
|
-
|
|
38
|
-
const [samplePrompts, setSamplePrompts] = React.useState<SamplePrompt[] | undefined>(undefined);
|
|
39
|
-
const [instructions, setInstructions] = React.useState<string>("");
|
|
40
|
-
|
|
41
|
-
const getSamplePrompts = dataEnhancementController?.getSamplePrompts;
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Driven by the controller rather than by local state.
|
|
45
|
-
*
|
|
46
|
-
* There is exactly one run at a time, and the review owns it — a second
|
|
47
|
-
* `loading` flag here could disagree with the dialog about whether the
|
|
48
|
-
* model is still writing.
|
|
49
|
-
*/
|
|
50
|
-
const loading = dataEnhancementController?.review?.status === "generating";
|
|
51
|
-
|
|
52
|
-
const loadingPrompts = useRef(false);
|
|
53
|
-
const updateSuggestedPrompts = useCallback(async function updateSuggestedPrompts(instructions?: string) {
|
|
54
|
-
if (!getSamplePrompts) return;
|
|
55
|
-
if (loadingPrompts.current) return;
|
|
56
|
-
loadingPrompts.current = true;
|
|
57
|
-
const prompts = status === "new"
|
|
58
|
-
? (await getSamplePrompts(collection.singularName ?? collection.name, instructions)).prompts
|
|
59
|
-
: getPromptsForExistingEntities(collection.properties);
|
|
60
|
-
|
|
61
|
-
const recentPromptsFromStorage = getRecentPromptsFromStorage(storageKey);
|
|
62
|
-
const recentPrompts = recentPromptsFromStorage.map(prompt => prompt.prompt);
|
|
63
|
-
setSamplePrompts([...recentPromptsFromStorage, ...prompts.filter(p => !recentPrompts.includes(p.prompt))].slice(0, 5));
|
|
64
|
-
loadingPrompts.current = false;
|
|
65
|
-
},
|
|
66
|
-
[collection.name, collection.singularName, getSamplePrompts, status]);
|
|
67
|
-
|
|
68
|
-
useEffect(() => {
|
|
69
|
-
if (!dataEnhancementController) return;
|
|
70
|
-
if (!samplePrompts) {
|
|
71
|
-
setSamplePrompts(getRecentPromptsFromStorage(storageKey));
|
|
72
|
-
updateSuggestedPrompts().then();
|
|
73
|
-
}
|
|
74
|
-
}, [dataEnhancementController, samplePrompts, storageKey, updateSuggestedPrompts, instructions, status]);
|
|
75
|
-
|
|
76
|
-
useEffect(() => {
|
|
77
|
-
if (!dataEnhancementController) return;
|
|
78
|
-
updateSuggestedPrompts().then();
|
|
79
|
-
}, [dataEnhancementController, status]);
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Starts a run and opens the review. Nothing is written to the form here —
|
|
83
|
-
* see {@link AutofillReviewDialog}.
|
|
84
|
-
*/
|
|
85
|
-
const generate = (prompt?: string) => {
|
|
86
|
-
if (!dataEnhancementController || !formContext?.values) return;
|
|
87
|
-
if (prompt) {
|
|
88
|
-
addRecentPrompt(storageKey, prompt);
|
|
89
|
-
setSamplePrompts([{
|
|
90
|
-
prompt,
|
|
91
|
-
type: "recent"
|
|
92
|
-
}, ...(samplePrompts ?? []).slice(0, 5)]);
|
|
93
|
-
}
|
|
94
|
-
// The controller records a failure in the review itself, so there is
|
|
95
|
-
// nothing to catch here — but the promise is still explicitly handled
|
|
96
|
-
// so a rejection can never surface as an unhandled one.
|
|
97
|
-
dataEnhancementController.generate({
|
|
98
|
-
values: formContext.values,
|
|
99
|
-
instructions: prompt
|
|
100
|
-
}).catch(() => undefined);
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
if (!dataEnhancementController?.enabled)
|
|
104
|
-
return null;
|
|
105
|
-
|
|
106
|
-
function submit() {
|
|
107
|
-
generate(instructions);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return (
|
|
111
|
-
<>
|
|
112
|
-
<Menu
|
|
113
|
-
align={"end"}
|
|
114
|
-
sideOffset={8}
|
|
115
|
-
className={"max-w-[100vw]"}
|
|
116
|
-
// Never full width: this used to stretch to fill the form's
|
|
117
|
-
// `w-80 2xl:w-96` side rail in full screen. That rail is gone, and
|
|
118
|
-
// in the footer a stretched button reads as the primary action.
|
|
119
|
-
// Icon only. The label is carried by `aria-label`/`title` — a
|
|
120
|
-
// `Tooltip` here would swallow the menu: both it and
|
|
121
|
-
// `DropdownMenu.Trigger` render `asChild`, and `Tooltip` drops
|
|
122
|
-
// the props Radix clones onto it, so the menu never opens.
|
|
123
|
-
trigger={<IconButton variant={"filled"}
|
|
124
|
-
size={"small"}
|
|
125
|
-
aria-label={"Autofill"}
|
|
126
|
-
title={"Autofill"}
|
|
127
|
-
disabled={loading}>
|
|
128
|
-
{!loading && <AIIcon size={"small"}/>}
|
|
129
|
-
{loading && <CircularProgress size={"small"}/>}
|
|
130
|
-
</IconButton>}>
|
|
131
|
-
|
|
132
|
-
<MenuItem className={"py-4"}
|
|
133
|
-
onClick={() => {
|
|
134
|
-
generate();
|
|
135
|
-
}}>
|
|
136
|
-
<AIIcon size={"small"}/>
|
|
137
|
-
Autofill based on the current content
|
|
138
|
-
</MenuItem>
|
|
139
|
-
|
|
140
|
-
<Separator orientation={"horizontal"} className={"mt-2"}/>
|
|
141
|
-
|
|
142
|
-
{samplePrompts?.map((samplePrompt, index) => {
|
|
143
|
-
return <MenuItem
|
|
144
|
-
key={index + "_" + samplePrompt.prompt}
|
|
145
|
-
onClick={() => {
|
|
146
|
-
setInstructions(samplePrompt.prompt);
|
|
147
|
-
generate(samplePrompt.prompt);
|
|
148
|
-
}}
|
|
149
|
-
>
|
|
150
|
-
<div className={"pl-9 grow text-text-secondary dark:text-text-secondary-dark"}>
|
|
151
|
-
{samplePrompt.prompt}
|
|
152
|
-
</div>
|
|
153
|
-
|
|
154
|
-
{samplePrompt.type === "recent" && <IconButton
|
|
155
|
-
onClick={(e) => {
|
|
156
|
-
e.preventDefault();
|
|
157
|
-
e.stopPropagation();
|
|
158
|
-
removeRecentPrompt(storageKey, samplePrompt.prompt);
|
|
159
|
-
setSamplePrompts((samplePrompts ?? []).filter(p => p.prompt !== samplePrompt.prompt));
|
|
160
|
-
}}
|
|
161
|
-
size={"smallest"}
|
|
162
|
-
>
|
|
163
|
-
<XIcon size={iconSize.smallest}/>
|
|
164
|
-
</IconButton>
|
|
165
|
-
}
|
|
166
|
-
</MenuItem>;
|
|
167
|
-
})}
|
|
168
|
-
|
|
169
|
-
<Separator orientation={"horizontal"}/>
|
|
170
|
-
|
|
171
|
-
{/* `px-4` and `gap-4` are MenuItem's own paddings, so the input
|
|
172
|
-
row lines up with the items above it instead of sitting 8px
|
|
173
|
-
to their left — which is what `mx-2` on the textarea did. */}
|
|
174
|
-
{/* `items-center` so the send button sits on the field's centre
|
|
175
|
-
line rather than pinned to its top edge as the textarea grows. */}
|
|
176
|
-
<div
|
|
177
|
-
className={cls(
|
|
178
|
-
"my-2 px-4 py-2 gap-4 w-[500px] max-w-full flex items-center text-surface-700 dark:text-surface-200"
|
|
179
|
-
)}>
|
|
180
|
-
|
|
181
|
-
<div className={"relative w-full grow"}>
|
|
182
|
-
{/* `fieldBackgroundMixin`, the same surface every other input
|
|
183
|
-
in the codebase uses. It was `dark:bg-surface-950`, which
|
|
184
|
-
theme.css defines as literal `#000000` — a pure black
|
|
185
|
-
rectangle inside an already-dark menu. */}
|
|
186
|
-
<TextareaAutosize
|
|
187
|
-
className={cls("p-3 pr-12 rounded-lg resize-none w-full outline-hidden max-h-[300px] overflow-auto", fieldBackgroundMixin, focusedDisabled)}
|
|
188
|
-
value={instructions}
|
|
189
|
-
autoFocus={status === "new"}
|
|
190
|
-
disabled={loading}
|
|
191
|
-
onFocus={(event) => {
|
|
192
|
-
event.stopPropagation();
|
|
193
|
-
}}
|
|
194
|
-
placeholder={"...or provide instructions"}
|
|
195
|
-
onKeyDown={(e) => {
|
|
196
|
-
e.stopPropagation();
|
|
197
|
-
if (e.key === "Enter" && !e.shiftKey) {
|
|
198
|
-
e.preventDefault();
|
|
199
|
-
submit();
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
}}
|
|
203
|
-
onChange={(e) => {
|
|
204
|
-
setInstructions(e.target.value);
|
|
205
|
-
}}
|
|
206
|
-
/>
|
|
207
|
-
|
|
208
|
-
{/* Inside the field and only when there is something to
|
|
209
|
-
clear — a permanently-visible X on an empty box is a
|
|
210
|
-
control that does nothing. Positioned exactly as
|
|
211
|
-
`TextFieldBinding` positions its own clearable X, so
|
|
212
|
-
it stays centred as the textarea grows. */}
|
|
213
|
-
{instructions.length > 0 && !loading && (
|
|
214
|
-
<div
|
|
215
|
-
className={"flex flex-row justify-center items-center absolute h-full right-0 top-0 mr-2"}>
|
|
216
|
-
<IconButton
|
|
217
|
-
size={"small"}
|
|
218
|
-
onClick={() => {
|
|
219
|
-
setInstructions("");
|
|
220
|
-
}}>
|
|
221
|
-
<XIcon size={iconSize.small}/>
|
|
222
|
-
</IconButton>
|
|
223
|
-
</div>
|
|
224
|
-
)}
|
|
225
|
-
</div>
|
|
226
|
-
|
|
227
|
-
<IconButton
|
|
228
|
-
onClick={() => generate(instructions)}
|
|
229
|
-
size={"small"}
|
|
230
|
-
color={!instructions ? "primary" : undefined}
|
|
231
|
-
disabled={loading || !instructions}>
|
|
232
|
-
{loading &&
|
|
233
|
-
<CircularProgress size={"smallest"}/>}
|
|
234
|
-
{/* Sized, and no `color`. These icons are re-exported
|
|
235
|
-
straight from lucide, so `color` lands on the SVG as
|
|
236
|
-
a CSS colour — and `"primary"` is not one, which is
|
|
237
|
-
why this button rendered empty. Every other icon in
|
|
238
|
-
the codebase passes `size` alone and inherits
|
|
239
|
-
`currentColor` from the button. */}
|
|
240
|
-
{!loading &&
|
|
241
|
-
<SendIcon size={iconSize.small}/>}
|
|
242
|
-
</IconButton>
|
|
243
|
-
|
|
244
|
-
</div>
|
|
245
|
-
|
|
246
|
-
</Menu>
|
|
247
|
-
|
|
248
|
-
<AutofillReviewDialog/>
|
|
249
|
-
</>
|
|
250
|
-
);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function getPromptsForExistingEntities(properties: Properties): SamplePrompt[] {
|
|
254
|
-
|
|
255
|
-
const multilineProperties = Object.values(properties).filter((p: Property) => {
|
|
256
|
-
if (isPropertyBuilder(p)) {
|
|
257
|
-
return false;
|
|
258
|
-
}
|
|
259
|
-
return p.type === "string" && (p.admin?.markdown || p.admin?.multiline);
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
const multilinePrompt: Property | undefined = multilineProperties.length > 0
|
|
263
|
-
? multilineProperties[Math.floor(Math.random() * multilineProperties.length)] as Property
|
|
264
|
-
: undefined;
|
|
265
|
-
|
|
266
|
-
const prompts = [
|
|
267
|
-
"Fill the missing fields",
|
|
268
|
-
"Translate the missing content"
|
|
269
|
-
];
|
|
270
|
-
if (multilinePrompt) {
|
|
271
|
-
prompts.push(`Add 2 paragraphs to '${multilinePrompt.name}'`);
|
|
272
|
-
}
|
|
273
|
-
return prompts.map(p => ({
|
|
274
|
-
prompt: p,
|
|
275
|
-
type: "sample"
|
|
276
|
-
}));
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const createLocalStorageKey = (path: string, status: EntityStatus) => {
|
|
280
|
-
const statusString = status === "new" ? "new" : "existing";
|
|
281
|
-
return `data_enhancement::${statusString}::${stripCollectionPath(path)}`;
|
|
282
|
-
};
|
|
283
|
-
|
|
284
|
-
const getRecentPromptsFromStorage = (storageKey: string): SamplePrompt[] => {
|
|
285
|
-
const item = localStorage.getItem(storageKey);
|
|
286
|
-
return item ? JSON.parse(item).map((e: string) => ({
|
|
287
|
-
prompt: e,
|
|
288
|
-
type: "recent"
|
|
289
|
-
})) : [];
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
const addRecentPrompt = (storageKey: string, prompt: string) => {
|
|
293
|
-
if (!prompt || prompt.trim().length === 0) {
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
const recentPrompts = getRecentPromptsFromStorage(storageKey);
|
|
297
|
-
localStorage.setItem(storageKey, JSON.stringify([prompt, ...recentPrompts
|
|
298
|
-
.map(e => e.prompt)
|
|
299
|
-
.filter(e => e !== prompt)
|
|
300
|
-
.slice(0, 5)]));
|
|
301
|
-
};
|
|
302
|
-
|
|
303
|
-
const removeRecentPrompt = (storageKey: string, prompt: string) => {
|
|
304
|
-
localStorage.setItem(storageKey, JSON.stringify(getRecentPromptsFromStorage(storageKey)
|
|
305
|
-
.map(e => e.prompt)
|
|
306
|
-
.filter(e => e !== prompt)));
|
|
307
|
-
};
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { autocompleteStream } from "../api";
|
|
3
|
-
import { EditorAIController } from "@rebasepro/cms";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Inline continuation for the rich-text editor's slash command.
|
|
7
|
-
*
|
|
8
|
-
* No token is threaded through any more. The previous version demanded a
|
|
9
|
-
* Firebase ID token and threw `"Firebase token is required"` when it could not
|
|
10
|
-
* get one — in a Rebase app there is no such thing, and the token it actually
|
|
11
|
-
* sent was a Rebase JWT the receiving service had no way to verify. The hosted
|
|
12
|
-
* service authenticates nobody; see `src/api.ts`.
|
|
13
|
-
*/
|
|
14
|
-
export function useEditorAIController({ endpoint }: { endpoint?: string } = {}): EditorAIController {
|
|
15
|
-
return React.useMemo(() => ({
|
|
16
|
-
autocomplete: (textBefore: string, textAfter: string, onUpdate: (delta: string) => void) =>
|
|
17
|
-
autocompleteStream({
|
|
18
|
-
endpoint,
|
|
19
|
-
textBefore,
|
|
20
|
-
textAfter,
|
|
21
|
-
onDelta: onUpdate
|
|
22
|
-
})
|
|
23
|
-
}), [endpoint]);
|
|
24
|
-
}
|