@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.
Files changed (33) hide show
  1. package/README.md +62 -18
  2. package/dist/api.d.ts +58 -30
  3. package/dist/components/AutofillReviewDialog.d.ts +16 -0
  4. package/dist/components/DataEnhancementControllerProvider.d.ts +2 -3
  5. package/dist/components/FormEnhanceAction.d.ts +1 -1
  6. package/dist/editor/useEditorAIController.d.ts +11 -2
  7. package/dist/index.es.js +601 -382
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/types/data_enhancement_controller.d.ts +84 -28
  10. package/dist/useDataEnhancementPlugin.d.ts +10 -5
  11. package/package.json +24 -19
  12. package/src/api.ts +241 -174
  13. package/src/components/AutofillReviewDialog.tsx +209 -0
  14. package/src/components/DataEnhancementControllerProvider.tsx +203 -262
  15. package/src/components/FormEnhanceAction.tsx +154 -128
  16. package/src/editor/useEditorAIController.tsx +20 -33
  17. package/src/tests/AutofillReviewDialog.test.tsx +340 -0
  18. package/src/tests/api.test.ts +283 -0
  19. package/src/tests/properties.test.ts +420 -0
  20. package/src/tests/review.test.tsx +393 -0
  21. package/src/tests/useDataEnhancementPlugin.test.tsx +36 -15
  22. package/src/tests/useEditorAIController.test.ts +98 -0
  23. package/src/types/data_enhancement_controller.tsx +98 -31
  24. package/src/useDataEnhancementPlugin.tsx +13 -12
  25. package/dist/utils/diffStrings.d.ts +0 -7
  26. package/dist/utils/strings_counter.d.ts +0 -2
  27. package/dist/utils/suggestions.d.ts +0 -1
  28. package/src/tests/diffStrings.test.ts +0 -128
  29. package/src/tests/strings_counter.test.ts +0 -117
  30. package/src/tests/suggestions.test.ts +0 -53
  31. package/src/utils/diffStrings.ts +0 -70
  32. package/src/utils/strings_counter.ts +0 -22
  33. 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
- const enhance = (prompt?: string) => {
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
- return dataEnhancementController.enhance({
92
- entityId,
93
- values: formContext!.values,
94
- instructions: prompt,
95
- replaceValues: true
96
- }).finally(() => {
97
- setLoading(false);
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
- enhance(instructions);
108
+ generate(instructions);
114
109
  }
115
110
 
116
111
  return (
117
- <Menu
118
- align={"end"}
119
- sideOffset={8}
120
- className={"max-w-[100vw]"}
121
- trigger={<Button variant={"filled"}
122
- color={"neutral"}
123
- fullWidth={largeLayout && openEntityMode === "full_screen"}
124
- size={"small"}
125
- disabled={loading}>
126
- {!loading && <AIIcon size={"small"}/>}
127
- {loading && <CircularProgress size={"small"}/>}
128
- Autofill
129
- </Button>}>
130
-
131
- <MenuItem className={"py-4"}
132
- onClick={() => {
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
- {samplePrompt.type === "recent" && <IconButton
154
- onClick={(e) => {
155
- e.preventDefault();
156
- e.stopPropagation();
157
- removeRecentPrompt(storageKey, samplePrompt.prompt);
158
- setSamplePrompts((samplePrompts ?? []).filter(p => p.prompt !== samplePrompt.prompt));
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
- <XIcon size={iconSize.smallest}/>
163
- </IconButton>
164
- }
165
- </MenuItem>;
166
- })}
167
-
168
- <Separator orientation={"horizontal"}/>
169
-
170
- <div
171
- className={cls(
172
- "my-2 w-[500px] max-w-full flex items-start text-surface-700 dark:text-surface-200"
173
- )}>
174
-
175
- <TextareaAutosize
176
- className={cls("p-4 rounded-lg resize-none bg-surface-100 dark:bg-surface-950 mx-2 w-full grow outline-hidden max-h-[300px] overflow-auto", focusedDisabled)}
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
- onChange={(e) => {
193
- setInstructions(e.target.value);
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
- <IconButton
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
- </div>
243
+ </Menu>
219
244
 
220
- </Menu>
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
- export function useEditorAIController({ getAuthToken }: { getAuthToken?: () => Promise<string> }): EditorAIController {
5
- const autocomplete = async (textBefore: string, textAfter: string, onUpdate: (delta: string) => void) => {
6
- if (!getAuthToken) {
7
- throw new Error("Firebase token is required");
8
- }
9
- const firebaseToken = await getAuthToken();
10
- return autocompleteStream({
11
- firebaseToken,
12
- textBefore,
13
- textAfter,
14
- onUpdate
15
- });
16
- }
17
-
18
- return {
19
- autocomplete
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
- // }