@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
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
import React, { useCallback, useDeferredValue, useEffect, useRef } from "react";
|
|
1
|
+
import React, { useCallback, useEffect, useRef } from "react";
|
|
3
2
|
|
|
4
3
|
import {
|
|
5
4
|
Button,
|
|
6
5
|
CircularProgress,
|
|
7
6
|
cls,
|
|
7
|
+
fieldBackgroundMixin,
|
|
8
8
|
focusedDisabled,
|
|
9
9
|
IconButton,
|
|
10
10
|
iconSize,
|
|
@@ -16,29 +16,24 @@ import {
|
|
|
16
16
|
XIcon
|
|
17
17
|
} from "@rebasepro/ui";
|
|
18
18
|
import {
|
|
19
|
-
AIIcon
|
|
20
|
-
useLargeLayout
|
|
19
|
+
AIIcon
|
|
21
20
|
} from "@rebasepro/app";
|
|
22
21
|
import { EntityStatus, Properties, Property } from "@rebasepro/types";
|
|
23
22
|
import { PluginFormActionProps } from "@rebasepro/admin-types";
|
|
24
23
|
import { isPropertyBuilder, stripCollectionPath } from "@rebasepro/common";
|
|
25
24
|
import { useDataEnhancementController } from "./DataEnhancementControllerProvider";
|
|
25
|
+
import { AutofillReviewDialog } from "./AutofillReviewDialog";
|
|
26
26
|
import { SamplePrompt } from "../types/data_enhancement_controller";
|
|
27
27
|
|
|
28
28
|
export function FormEnhanceAction({
|
|
29
|
-
entityId,
|
|
30
29
|
path,
|
|
31
30
|
status,
|
|
32
31
|
collection,
|
|
33
|
-
formContext
|
|
34
|
-
openEntityMode
|
|
32
|
+
formContext
|
|
35
33
|
}: PluginFormActionProps) {
|
|
36
34
|
|
|
37
|
-
const largeLayout = useLargeLayout();
|
|
38
|
-
|
|
39
35
|
const storageKey = createLocalStorageKey(path, status);
|
|
40
36
|
|
|
41
|
-
const [loading, setLoading] = React.useState(false);
|
|
42
37
|
const dataEnhancementController = useDataEnhancementController();
|
|
43
38
|
|
|
44
39
|
const [samplePrompts, setSamplePrompts] = React.useState<SamplePrompt[] | undefined>(undefined);
|
|
@@ -46,6 +41,15 @@ export function FormEnhanceAction({
|
|
|
46
41
|
|
|
47
42
|
const getSamplePrompts = dataEnhancementController?.getSamplePrompts;
|
|
48
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Driven by the controller rather than by local state.
|
|
46
|
+
*
|
|
47
|
+
* There is exactly one run at a time, and the review owns it — a second
|
|
48
|
+
* `loading` flag here could disagree with the dialog about whether the
|
|
49
|
+
* model is still writing.
|
|
50
|
+
*/
|
|
51
|
+
const loading = dataEnhancementController?.review?.status === "generating";
|
|
52
|
+
|
|
49
53
|
const loadingPrompts = useRef(false);
|
|
50
54
|
const updateSuggestedPrompts = useCallback(async function updateSuggestedPrompts(instructions?: string) {
|
|
51
55
|
if (!getSamplePrompts) return;
|
|
@@ -62,9 +66,6 @@ export function FormEnhanceAction({
|
|
|
62
66
|
},
|
|
63
67
|
[collection.name, collection.singularName, getSamplePrompts, status]);
|
|
64
68
|
|
|
65
|
-
const deferredValues = useDeferredValue(formContext?.values);
|
|
66
|
-
// const enoughData = countStringCharacters(deferredValues, collection.properties) > 20;
|
|
67
|
-
|
|
68
69
|
useEffect(() => {
|
|
69
70
|
if (!dataEnhancementController) return;
|
|
70
71
|
if (!samplePrompts) {
|
|
@@ -78,9 +79,12 @@ export function FormEnhanceAction({
|
|
|
78
79
|
updateSuggestedPrompts().then();
|
|
79
80
|
}, [dataEnhancementController, status]);
|
|
80
81
|
|
|
81
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Starts a run and opens the review. Nothing is written to the form here —
|
|
84
|
+
* see {@link AutofillReviewDialog}.
|
|
85
|
+
*/
|
|
86
|
+
const generate = (prompt?: string) => {
|
|
82
87
|
if (!dataEnhancementController || !formContext?.values) return;
|
|
83
|
-
setLoading(true);
|
|
84
88
|
if (prompt) {
|
|
85
89
|
addRecentPrompt(storageKey, prompt);
|
|
86
90
|
setSamplePrompts([{
|
|
@@ -88,136 +92,158 @@ export function FormEnhanceAction({
|
|
|
88
92
|
type: "recent"
|
|
89
93
|
}, ...(samplePrompts ?? []).slice(0, 5)]);
|
|
90
94
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
});
|
|
95
|
+
// The controller records a failure in the review itself, so there is
|
|
96
|
+
// nothing to catch here — but the promise is still explicitly handled
|
|
97
|
+
// so a rejection can never surface as an unhandled one.
|
|
98
|
+
dataEnhancementController.generate({
|
|
99
|
+
values: formContext.values,
|
|
100
|
+
instructions: prompt
|
|
101
|
+
}).catch(() => undefined);
|
|
99
102
|
};
|
|
100
103
|
|
|
101
104
|
if (!dataEnhancementController?.enabled)
|
|
102
105
|
return null;
|
|
103
106
|
|
|
104
|
-
const suggestions = dataEnhancementController.suggestions;
|
|
105
|
-
const hasSuggestions = Object.values(suggestions).filter(Boolean).length > 0;
|
|
106
|
-
|
|
107
|
-
const disabledSuggestionActions = !hasSuggestions;
|
|
108
|
-
const promptSuggestionsEnabled = (samplePrompts ?? []).length > 0 && instructions.length === 0;
|
|
109
|
-
|
|
110
|
-
// const noIdSet = !formContext?.entityId;
|
|
111
|
-
|
|
112
107
|
function submit() {
|
|
113
|
-
|
|
108
|
+
generate(instructions);
|
|
114
109
|
}
|
|
115
110
|
|
|
116
111
|
return (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
enhance();
|
|
134
|
-
}}>
|
|
135
|
-
<AIIcon size={"small"}/>
|
|
136
|
-
Autofill based on the current content
|
|
137
|
-
</MenuItem>
|
|
138
|
-
|
|
139
|
-
<Separator orientation={"horizontal"} className={"mt-2"}/>
|
|
140
|
-
|
|
141
|
-
{samplePrompts?.map((samplePrompt, index) => {
|
|
142
|
-
return <MenuItem
|
|
143
|
-
key={index + "_" + samplePrompt.prompt}
|
|
144
|
-
onClick={() => {
|
|
145
|
-
setInstructions(samplePrompt.prompt);
|
|
146
|
-
enhance(samplePrompt.prompt);
|
|
147
|
-
}}
|
|
148
|
-
>
|
|
149
|
-
<div className={"pl-9 grow text-text-secondary dark:text-text-secondary-dark"}>
|
|
150
|
-
{samplePrompt.prompt}
|
|
151
|
-
</div>
|
|
112
|
+
<>
|
|
113
|
+
<Menu
|
|
114
|
+
align={"end"}
|
|
115
|
+
sideOffset={8}
|
|
116
|
+
className={"max-w-[100vw]"}
|
|
117
|
+
// Never full width: this used to stretch to fill the form's
|
|
118
|
+
// `w-80 2xl:w-96` side rail in full screen. That rail is gone, and
|
|
119
|
+
// in the footer a stretched button reads as the primary action.
|
|
120
|
+
trigger={<Button variant={"filled"}
|
|
121
|
+
color={"neutral"}
|
|
122
|
+
size={"small"}
|
|
123
|
+
disabled={loading}>
|
|
124
|
+
{!loading && <AIIcon size={"small"}/>}
|
|
125
|
+
{loading && <CircularProgress size={"small"}/>}
|
|
126
|
+
Autofill
|
|
127
|
+
</Button>}>
|
|
152
128
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
129
|
+
<MenuItem className={"py-4"}
|
|
130
|
+
onClick={() => {
|
|
131
|
+
generate();
|
|
132
|
+
}}>
|
|
133
|
+
<AIIcon size={"small"}/>
|
|
134
|
+
Autofill based on the current content
|
|
135
|
+
</MenuItem>
|
|
136
|
+
|
|
137
|
+
<Separator orientation={"horizontal"} className={"mt-2"}/>
|
|
138
|
+
|
|
139
|
+
{samplePrompts?.map((samplePrompt, index) => {
|
|
140
|
+
return <MenuItem
|
|
141
|
+
key={index + "_" + samplePrompt.prompt}
|
|
142
|
+
onClick={() => {
|
|
143
|
+
setInstructions(samplePrompt.prompt);
|
|
144
|
+
generate(samplePrompt.prompt);
|
|
159
145
|
}}
|
|
160
|
-
size={"smallest"}
|
|
161
146
|
>
|
|
162
|
-
<
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
value={instructions}
|
|
178
|
-
autoFocus={status === "new"}
|
|
179
|
-
disabled={loading}
|
|
180
|
-
onFocus={(event) => {
|
|
181
|
-
event.stopPropagation();
|
|
182
|
-
}}
|
|
183
|
-
placeholder={"...or provide instructions"}
|
|
184
|
-
onKeyDown={(e) => {
|
|
185
|
-
e.stopPropagation();
|
|
186
|
-
if (e.key === "Enter" && !e.shiftKey) {
|
|
187
|
-
e.preventDefault();
|
|
188
|
-
submit();
|
|
147
|
+
<div className={"pl-9 grow text-text-secondary dark:text-text-secondary-dark"}>
|
|
148
|
+
{samplePrompt.prompt}
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
{samplePrompt.type === "recent" && <IconButton
|
|
152
|
+
onClick={(e) => {
|
|
153
|
+
e.preventDefault();
|
|
154
|
+
e.stopPropagation();
|
|
155
|
+
removeRecentPrompt(storageKey, samplePrompt.prompt);
|
|
156
|
+
setSamplePrompts((samplePrompts ?? []).filter(p => p.prompt !== samplePrompt.prompt));
|
|
157
|
+
}}
|
|
158
|
+
size={"smallest"}
|
|
159
|
+
>
|
|
160
|
+
<XIcon size={iconSize.smallest}/>
|
|
161
|
+
</IconButton>
|
|
189
162
|
}
|
|
163
|
+
</MenuItem>;
|
|
164
|
+
})}
|
|
165
|
+
|
|
166
|
+
<Separator orientation={"horizontal"}/>
|
|
167
|
+
|
|
168
|
+
{/* `px-4` and `gap-4` are MenuItem's own paddings, so the input
|
|
169
|
+
row lines up with the items above it instead of sitting 8px
|
|
170
|
+
to their left — which is what `mx-2` on the textarea did. */}
|
|
171
|
+
{/* `items-center` so the send button sits on the field's centre
|
|
172
|
+
line rather than pinned to its top edge as the textarea grows. */}
|
|
173
|
+
<div
|
|
174
|
+
className={cls(
|
|
175
|
+
"my-2 px-4 py-2 gap-4 w-[500px] max-w-full flex items-center text-surface-700 dark:text-surface-200"
|
|
176
|
+
)}>
|
|
177
|
+
|
|
178
|
+
<div className={"relative w-full grow"}>
|
|
179
|
+
{/* `fieldBackgroundMixin`, the same surface every other input
|
|
180
|
+
in the codebase uses. It was `dark:bg-surface-950`, which
|
|
181
|
+
theme.css defines as literal `#000000` — a pure black
|
|
182
|
+
rectangle inside an already-dark menu. */}
|
|
183
|
+
<TextareaAutosize
|
|
184
|
+
className={cls("p-3 pr-12 rounded-lg resize-none w-full outline-hidden max-h-[300px] overflow-auto", fieldBackgroundMixin, focusedDisabled)}
|
|
185
|
+
value={instructions}
|
|
186
|
+
autoFocus={status === "new"}
|
|
187
|
+
disabled={loading}
|
|
188
|
+
onFocus={(event) => {
|
|
189
|
+
event.stopPropagation();
|
|
190
|
+
}}
|
|
191
|
+
placeholder={"...or provide instructions"}
|
|
192
|
+
onKeyDown={(e) => {
|
|
193
|
+
e.stopPropagation();
|
|
194
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
195
|
+
e.preventDefault();
|
|
196
|
+
submit();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
}}
|
|
200
|
+
onChange={(e) => {
|
|
201
|
+
setInstructions(e.target.value);
|
|
202
|
+
}}
|
|
203
|
+
/>
|
|
204
|
+
|
|
205
|
+
{/* Inside the field and only when there is something to
|
|
206
|
+
clear — a permanently-visible X on an empty box is a
|
|
207
|
+
control that does nothing. Positioned exactly as
|
|
208
|
+
`TextFieldBinding` positions its own clearable X, so
|
|
209
|
+
it stays centred as the textarea grows. */}
|
|
210
|
+
{instructions.length > 0 && !loading && (
|
|
211
|
+
<div
|
|
212
|
+
className={"flex flex-row justify-center items-center absolute h-full right-0 top-0 mr-2"}>
|
|
213
|
+
<IconButton
|
|
214
|
+
size={"small"}
|
|
215
|
+
onClick={() => {
|
|
216
|
+
setInstructions("");
|
|
217
|
+
}}>
|
|
218
|
+
<XIcon size={iconSize.small}/>
|
|
219
|
+
</IconButton>
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
</div>
|
|
190
223
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
224
|
+
<IconButton
|
|
225
|
+
onClick={() => generate(instructions)}
|
|
226
|
+
size={"small"}
|
|
227
|
+
color={!instructions ? "primary" : undefined}
|
|
228
|
+
disabled={loading || !instructions}>
|
|
229
|
+
{loading &&
|
|
230
|
+
<CircularProgress size={"smallest"}/>}
|
|
231
|
+
{/* Sized, and no `color`. These icons are re-exported
|
|
232
|
+
straight from lucide, so `color` lands on the SVG as
|
|
233
|
+
a CSS colour — and `"primary"` is not one, which is
|
|
234
|
+
why this button rendered empty. Every other icon in
|
|
235
|
+
the codebase passes `size` alone and inherits
|
|
236
|
+
`currentColor` from the button. */}
|
|
237
|
+
{!loading &&
|
|
238
|
+
<SendIcon size={iconSize.small}/>}
|
|
239
|
+
</IconButton>
|
|
196
240
|
|
|
197
|
-
|
|
198
|
-
size={"small"}
|
|
199
|
-
onClick={() => {
|
|
200
|
-
setInstructions("");
|
|
201
|
-
}}
|
|
202
|
-
color={!instructions ? "primary" : undefined}
|
|
203
|
-
disabled={loading || !instructions}>
|
|
204
|
-
<XIcon size={iconSize.small}/>
|
|
205
|
-
</IconButton>
|
|
206
|
-
|
|
207
|
-
<IconButton
|
|
208
|
-
onClick={() => enhance(instructions)}
|
|
209
|
-
size={"small"}
|
|
210
|
-
color={!instructions ? "primary" : undefined}
|
|
211
|
-
disabled={loading || !instructions}>
|
|
212
|
-
{loading &&
|
|
213
|
-
<CircularProgress size={"smallest"}/>}
|
|
214
|
-
{!loading &&
|
|
215
|
-
<SendIcon color={"primary"}/>}
|
|
216
|
-
</IconButton>
|
|
241
|
+
</div>
|
|
217
242
|
|
|
218
|
-
</
|
|
243
|
+
</Menu>
|
|
219
244
|
|
|
220
|
-
|
|
245
|
+
<AutofillReviewDialog/>
|
|
246
|
+
</>
|
|
221
247
|
);
|
|
222
248
|
}
|
|
223
249
|
|
|
@@ -1,37 +1,24 @@
|
|
|
1
|
+
import React from "react";
|
|
1
2
|
import { autocompleteStream } from "../api";
|
|
2
3
|
import { EditorAIController } from "@rebasepro/admin";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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]);
|
|
21
24
|
}
|
|
22
|
-
|
|
23
|
-
// async function * generateLoremIpsum(): AsyncGenerator<string> {
|
|
24
|
-
// const loremIpsum = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.\n# Heading\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.";
|
|
25
|
-
//
|
|
26
|
-
// const words = loremIpsum.split(" ");
|
|
27
|
-
//
|
|
28
|
-
// for (const word of words) {
|
|
29
|
-
// yield word;
|
|
30
|
-
// await new Promise(resolve => setTimeout(resolve, 100));
|
|
31
|
-
// }
|
|
32
|
-
// }
|
|
33
|
-
//
|
|
34
|
-
// const generator = generateLoremIpsum();
|
|
35
|
-
// for await (const word of generator) {
|
|
36
|
-
//
|
|
37
|
-
// }
|