@rebasepro/plugin-ai 0.12.0 → 0.12.1-canary.g009ed95
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -18
- package/dist/api.d.ts +57 -31
- package/dist/components/AutofillReviewDialog.d.ts +16 -0
- package/dist/components/DataEnhancementControllerProvider.d.ts +2 -3
- package/dist/components/FormEnhanceAction.d.ts +1 -1
- package/dist/editor/useEditorAIController.d.ts +11 -2
- package/dist/index.es.js +582 -361
- package/dist/index.es.js.map +1 -1
- package/dist/types/data_enhancement_controller.d.ts +84 -28
- package/dist/useDataEnhancementPlugin.d.ts +10 -5
- package/package.json +23 -19
- package/src/api.ts +238 -174
- package/src/components/AutofillReviewDialog.tsx +203 -0
- package/src/components/DataEnhancementControllerProvider.tsx +196 -264
- package/src/components/FormEnhanceAction.tsx +128 -128
- package/src/editor/useEditorAIController.tsx +20 -33
- package/src/tests/api.test.ts +283 -0
- package/src/tests/review.test.tsx +260 -0
- package/src/tests/useDataEnhancementPlugin.test.tsx +36 -15
- package/src/types/data_enhancement_controller.tsx +98 -31
- package/src/useDataEnhancementPlugin.tsx +13 -12
- package/dist/utils/diffStrings.d.ts +0 -7
- package/dist/utils/strings_counter.d.ts +0 -2
- package/dist/utils/suggestions.d.ts +0 -1
- package/src/tests/diffStrings.test.ts +0 -128
- package/src/tests/strings_counter.test.ts +0 -117
- package/src/tests/suggestions.test.ts +0 -53
- package/src/utils/diffStrings.ts +0 -70
- package/src/utils/strings_counter.ts +0 -22
- package/src/utils/suggestions.ts +0 -6
|
@@ -1,5 +1,4 @@
|
|
|
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,
|
|
@@ -16,29 +15,24 @@ import {
|
|
|
16
15
|
XIcon
|
|
17
16
|
} from "@rebasepro/ui";
|
|
18
17
|
import {
|
|
19
|
-
AIIcon
|
|
20
|
-
useLargeLayout
|
|
18
|
+
AIIcon
|
|
21
19
|
} from "@rebasepro/app";
|
|
22
20
|
import { EntityStatus, Properties, Property } from "@rebasepro/types";
|
|
23
21
|
import { PluginFormActionProps } from "@rebasepro/admin-types";
|
|
24
22
|
import { isPropertyBuilder, stripCollectionPath } from "@rebasepro/common";
|
|
25
23
|
import { useDataEnhancementController } from "./DataEnhancementControllerProvider";
|
|
24
|
+
import { AutofillReviewDialog } from "./AutofillReviewDialog";
|
|
26
25
|
import { SamplePrompt } from "../types/data_enhancement_controller";
|
|
27
26
|
|
|
28
27
|
export function FormEnhanceAction({
|
|
29
|
-
entityId,
|
|
30
28
|
path,
|
|
31
29
|
status,
|
|
32
30
|
collection,
|
|
33
|
-
formContext
|
|
34
|
-
openEntityMode
|
|
31
|
+
formContext
|
|
35
32
|
}: PluginFormActionProps) {
|
|
36
33
|
|
|
37
|
-
const largeLayout = useLargeLayout();
|
|
38
|
-
|
|
39
34
|
const storageKey = createLocalStorageKey(path, status);
|
|
40
35
|
|
|
41
|
-
const [loading, setLoading] = React.useState(false);
|
|
42
36
|
const dataEnhancementController = useDataEnhancementController();
|
|
43
37
|
|
|
44
38
|
const [samplePrompts, setSamplePrompts] = React.useState<SamplePrompt[] | undefined>(undefined);
|
|
@@ -46,6 +40,15 @@ export function FormEnhanceAction({
|
|
|
46
40
|
|
|
47
41
|
const getSamplePrompts = dataEnhancementController?.getSamplePrompts;
|
|
48
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
|
+
|
|
49
52
|
const loadingPrompts = useRef(false);
|
|
50
53
|
const updateSuggestedPrompts = useCallback(async function updateSuggestedPrompts(instructions?: string) {
|
|
51
54
|
if (!getSamplePrompts) return;
|
|
@@ -62,9 +65,6 @@ export function FormEnhanceAction({
|
|
|
62
65
|
},
|
|
63
66
|
[collection.name, collection.singularName, getSamplePrompts, status]);
|
|
64
67
|
|
|
65
|
-
const deferredValues = useDeferredValue(formContext?.values);
|
|
66
|
-
// const enoughData = countStringCharacters(deferredValues, collection.properties) > 20;
|
|
67
|
-
|
|
68
68
|
useEffect(() => {
|
|
69
69
|
if (!dataEnhancementController) return;
|
|
70
70
|
if (!samplePrompts) {
|
|
@@ -78,9 +78,12 @@ export function FormEnhanceAction({
|
|
|
78
78
|
updateSuggestedPrompts().then();
|
|
79
79
|
}, [dataEnhancementController, status]);
|
|
80
80
|
|
|
81
|
-
|
|
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) => {
|
|
82
86
|
if (!dataEnhancementController || !formContext?.values) return;
|
|
83
|
-
setLoading(true);
|
|
84
87
|
if (prompt) {
|
|
85
88
|
addRecentPrompt(storageKey, prompt);
|
|
86
89
|
setSamplePrompts([{
|
|
@@ -88,136 +91,133 @@ export function FormEnhanceAction({
|
|
|
88
91
|
type: "recent"
|
|
89
92
|
}, ...(samplePrompts ?? []).slice(0, 5)]);
|
|
90
93
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
});
|
|
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);
|
|
99
101
|
};
|
|
100
102
|
|
|
101
103
|
if (!dataEnhancementController?.enabled)
|
|
102
104
|
return null;
|
|
103
105
|
|
|
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
106
|
function submit() {
|
|
113
|
-
|
|
107
|
+
generate(instructions);
|
|
114
108
|
}
|
|
115
109
|
|
|
116
110
|
return (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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}
|
|
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
|
+
trigger={<Button variant={"filled"}
|
|
120
|
+
color={"neutral"}
|
|
121
|
+
size={"small"}
|
|
122
|
+
disabled={loading}>
|
|
123
|
+
{!loading && <AIIcon size={"small"}/>}
|
|
124
|
+
{loading && <CircularProgress size={"small"}/>}
|
|
125
|
+
Autofill
|
|
126
|
+
</Button>}>
|
|
127
|
+
|
|
128
|
+
<MenuItem className={"py-4"}
|
|
144
129
|
onClick={() => {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
130
|
+
generate();
|
|
131
|
+
}}>
|
|
132
|
+
<AIIcon size={"small"}/>
|
|
133
|
+
Autofill based on the current content
|
|
134
|
+
</MenuItem>
|
|
135
|
+
|
|
136
|
+
<Separator orientation={"horizontal"} className={"mt-2"}/>
|
|
137
|
+
|
|
138
|
+
{samplePrompts?.map((samplePrompt, index) => {
|
|
139
|
+
return <MenuItem
|
|
140
|
+
key={index + "_" + samplePrompt.prompt}
|
|
141
|
+
onClick={() => {
|
|
142
|
+
setInstructions(samplePrompt.prompt);
|
|
143
|
+
generate(samplePrompt.prompt);
|
|
159
144
|
}}
|
|
160
|
-
size={"smallest"}
|
|
161
145
|
>
|
|
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();
|
|
146
|
+
<div className={"pl-9 grow text-text-secondary dark:text-text-secondary-dark"}>
|
|
147
|
+
{samplePrompt.prompt}
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{samplePrompt.type === "recent" && <IconButton
|
|
151
|
+
onClick={(e) => {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
e.stopPropagation();
|
|
154
|
+
removeRecentPrompt(storageKey, samplePrompt.prompt);
|
|
155
|
+
setSamplePrompts((samplePrompts ?? []).filter(p => p.prompt !== samplePrompt.prompt));
|
|
156
|
+
}}
|
|
157
|
+
size={"smallest"}
|
|
158
|
+
>
|
|
159
|
+
<XIcon size={iconSize.smallest}/>
|
|
160
|
+
</IconButton>
|
|
189
161
|
}
|
|
162
|
+
</MenuItem>;
|
|
163
|
+
})}
|
|
164
|
+
|
|
165
|
+
<Separator orientation={"horizontal"}/>
|
|
166
|
+
|
|
167
|
+
<div
|
|
168
|
+
className={cls(
|
|
169
|
+
"my-2 w-[500px] max-w-full flex items-start text-surface-700 dark:text-surface-200"
|
|
170
|
+
)}>
|
|
171
|
+
|
|
172
|
+
<TextareaAutosize
|
|
173
|
+
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)}
|
|
174
|
+
value={instructions}
|
|
175
|
+
autoFocus={status === "new"}
|
|
176
|
+
disabled={loading}
|
|
177
|
+
onFocus={(event) => {
|
|
178
|
+
event.stopPropagation();
|
|
179
|
+
}}
|
|
180
|
+
placeholder={"...or provide instructions"}
|
|
181
|
+
onKeyDown={(e) => {
|
|
182
|
+
e.stopPropagation();
|
|
183
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
184
|
+
e.preventDefault();
|
|
185
|
+
submit();
|
|
186
|
+
}
|
|
190
187
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
188
|
+
}}
|
|
189
|
+
onChange={(e) => {
|
|
190
|
+
setInstructions(e.target.value);
|
|
191
|
+
}}
|
|
192
|
+
/>
|
|
196
193
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
194
|
+
<IconButton
|
|
195
|
+
size={"small"}
|
|
196
|
+
onClick={() => {
|
|
197
|
+
setInstructions("");
|
|
198
|
+
}}
|
|
199
|
+
color={!instructions ? "primary" : undefined}
|
|
200
|
+
disabled={loading || !instructions}>
|
|
201
|
+
<XIcon size={iconSize.small}/>
|
|
202
|
+
</IconButton>
|
|
203
|
+
|
|
204
|
+
<IconButton
|
|
205
|
+
onClick={() => generate(instructions)}
|
|
206
|
+
size={"small"}
|
|
207
|
+
color={!instructions ? "primary" : undefined}
|
|
208
|
+
disabled={loading || !instructions}>
|
|
209
|
+
{loading &&
|
|
210
|
+
<CircularProgress size={"smallest"}/>}
|
|
211
|
+
{!loading &&
|
|
212
|
+
<SendIcon color={"primary"}/>}
|
|
213
|
+
</IconButton>
|
|
214
|
+
|
|
215
|
+
</div>
|
|
217
216
|
|
|
218
|
-
</
|
|
217
|
+
</Menu>
|
|
219
218
|
|
|
220
|
-
|
|
219
|
+
<AutofillReviewDialog/>
|
|
220
|
+
</>
|
|
221
221
|
);
|
|
222
222
|
}
|
|
223
223
|
|
|
@@ -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
|
-
// }
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { TextEncoder, TextDecoder } from "util";
|
|
2
|
+
Object.assign(global, { TextEncoder,
|
|
3
|
+
TextDecoder });
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_AI_ENDPOINT,
|
|
7
|
+
autocompleteStream,
|
|
8
|
+
autofillStream,
|
|
9
|
+
fetchAiStatus,
|
|
10
|
+
fetchPromptSuggestions
|
|
11
|
+
} from "../api";
|
|
12
|
+
import { AutofillRequest } from "../types/data_enhancement_controller";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The transport.
|
|
16
|
+
*
|
|
17
|
+
* This exists because of what it replaced. The old client split each network
|
|
18
|
+
* chunk on the literal `"&$# "` and `JSON.parse`d the pieces — so a delimiter
|
|
19
|
+
* landing across two reads corrupted the parse, and reads land wherever the
|
|
20
|
+
* network puts them. The central test below therefore re-delivers the same
|
|
21
|
+
* response one byte at a time and asserts the result is identical to
|
|
22
|
+
* delivering it whole. Anything that only ever feeds a complete body would
|
|
23
|
+
* have passed against the old code too.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** A `Response` whose body yields exactly the chunks given, in order. */
|
|
27
|
+
function streamingResponse(chunks: string[], ok = true): any {
|
|
28
|
+
const encoder = new TextEncoder();
|
|
29
|
+
let i = 0;
|
|
30
|
+
return {
|
|
31
|
+
ok,
|
|
32
|
+
status: ok ? 200 : 500,
|
|
33
|
+
body: {
|
|
34
|
+
getReader: () => ({
|
|
35
|
+
read: async () =>
|
|
36
|
+
i < chunks.length
|
|
37
|
+
? { done: false,
|
|
38
|
+
value: encoder.encode(chunks[i++]) }
|
|
39
|
+
: { done: true,
|
|
40
|
+
value: undefined }
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function jsonResponse(body: unknown, ok = true, status = 200): any {
|
|
47
|
+
return { ok,
|
|
48
|
+
status,
|
|
49
|
+
json: async () => body };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Split a string into fixed-size pieces, to force boundaries anywhere. */
|
|
53
|
+
function chunked(body: string, size: number): string[] {
|
|
54
|
+
const out: string[] = [];
|
|
55
|
+
for (let i = 0; i < body.length; i += size) out.push(body.slice(i, i + size));
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const REQUEST: AutofillRequest = {
|
|
60
|
+
entityName: "Product",
|
|
61
|
+
values: {},
|
|
62
|
+
properties: { title: { type: "string",
|
|
63
|
+
fieldConfigId: "text_field" } }
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const BODY = [
|
|
67
|
+
"event: suggestion_delta",
|
|
68
|
+
'data: {"key":"title","text":"Blue "}',
|
|
69
|
+
"",
|
|
70
|
+
"event: suggestion_delta",
|
|
71
|
+
'data: {"key":"title","text":"widget"}',
|
|
72
|
+
"",
|
|
73
|
+
"event: suggestion",
|
|
74
|
+
'data: {"key":"title","value":"Blue widget"}',
|
|
75
|
+
"",
|
|
76
|
+
"event: suggestion",
|
|
77
|
+
'data: {"key":"stock","value":42}',
|
|
78
|
+
"",
|
|
79
|
+
"event: done",
|
|
80
|
+
'data: {"suggestions":{"title":"Blue widget","stock":42},"usage":{"outputTokens":9}}',
|
|
81
|
+
"",
|
|
82
|
+
""
|
|
83
|
+
].join("\n");
|
|
84
|
+
|
|
85
|
+
async function runAutofill(chunks: string[]) {
|
|
86
|
+
(global as any).fetch = jest.fn().mockResolvedValue(streamingResponse(chunks));
|
|
87
|
+
const deltas: [string, string][] = [];
|
|
88
|
+
const values: [string, unknown][] = [];
|
|
89
|
+
const result = await autofillStream({
|
|
90
|
+
request: REQUEST,
|
|
91
|
+
onDelta: (k, t) => deltas.push([k, t]),
|
|
92
|
+
onValue: (k, v) => values.push([k, v])
|
|
93
|
+
});
|
|
94
|
+
return { deltas,
|
|
95
|
+
values,
|
|
96
|
+
result };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
jest.restoreAllMocks();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("autofillStream", () => {
|
|
104
|
+
it("reads deltas, values and the final result", async () => {
|
|
105
|
+
const { deltas, values, result } = await runAutofill([BODY]);
|
|
106
|
+
expect(deltas).toEqual([
|
|
107
|
+
["title", "Blue "],
|
|
108
|
+
["title", "widget"]
|
|
109
|
+
]);
|
|
110
|
+
expect(values).toEqual([
|
|
111
|
+
["title", "Blue widget"],
|
|
112
|
+
["stock", 42]
|
|
113
|
+
]);
|
|
114
|
+
expect(result.suggestions).toEqual({ title: "Blue widget",
|
|
115
|
+
stock: 42 });
|
|
116
|
+
expect(result.usage).toEqual({ outputTokens: 9 });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("produces identical results at every chunk boundary", async () => {
|
|
120
|
+
const whole = await runAutofill([BODY]);
|
|
121
|
+
for (let size = 1; size <= BODY.length; size++) {
|
|
122
|
+
const split = await runAutofill(chunked(BODY, size));
|
|
123
|
+
expect(split.deltas).toEqual(whole.deltas);
|
|
124
|
+
expect(split.values).toEqual(whole.values);
|
|
125
|
+
expect(split.result).toEqual(whole.result);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("handles CRLF record separators", async () => {
|
|
130
|
+
// Proxies rewrite line endings, and a four-character separator sliced as
|
|
131
|
+
// if it were two leaves a stray newline that eats the next `event:`.
|
|
132
|
+
const { values } = await runAutofill([BODY.replace(/\n/g, "\r\n")]);
|
|
133
|
+
expect(values).toEqual([
|
|
134
|
+
["title", "Blue widget"],
|
|
135
|
+
["stock", 42]
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("ignores keep-alive comments", async () => {
|
|
140
|
+
const withComments = ":keep-alive\n\n" + BODY;
|
|
141
|
+
const { result } = await runAutofill([withComments]);
|
|
142
|
+
expect(result.suggestions).toEqual({ title: "Blue widget",
|
|
143
|
+
stock: 42 });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("joins a multi-line data field", async () => {
|
|
147
|
+
const body = 'event: suggestion\ndata: {"key":"body",\ndata: "value":"two lines"}\n\n';
|
|
148
|
+
const { values } = await runAutofill([body]);
|
|
149
|
+
expect(values).toEqual([["body", "two lines"]]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("keeps going past one malformed record", async () => {
|
|
153
|
+
// A single bad frame must not discard fields that arrived correctly.
|
|
154
|
+
const body = "event: suggestion\ndata: {not json\n\n" + BODY;
|
|
155
|
+
const { values } = await runAutofill([body]);
|
|
156
|
+
expect(values).toEqual([
|
|
157
|
+
["title", "Blue widget"],
|
|
158
|
+
["stock", 42]
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("throws the message carried on an error event", async () => {
|
|
163
|
+
(global as any).fetch = jest.fn().mockResolvedValue(
|
|
164
|
+
streamingResponse(['event: error\ndata: {"message":"quota exhausted"}\n\n'])
|
|
165
|
+
);
|
|
166
|
+
await expect(
|
|
167
|
+
autofillStream({ request: REQUEST,
|
|
168
|
+
onDelta: () => undefined,
|
|
169
|
+
onValue: () => undefined })
|
|
170
|
+
).rejects.toThrow("quota exhausted");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("surfaces the server's error envelope on a non-2xx", async () => {
|
|
174
|
+
// `{ error: { message } }` is the control plane's contract. Reading it
|
|
175
|
+
// is the difference between telling the operator the quota reset time
|
|
176
|
+
// and telling them "Request failed with status 429".
|
|
177
|
+
(global as any).fetch = jest.fn().mockResolvedValue(
|
|
178
|
+
jsonResponse({ error: { message: "The free AI quota for today has been used up.",
|
|
179
|
+
code: "upstream_error" } }, false, 429)
|
|
180
|
+
);
|
|
181
|
+
await expect(
|
|
182
|
+
autofillStream({ request: REQUEST,
|
|
183
|
+
onDelta: () => undefined,
|
|
184
|
+
onValue: () => undefined })
|
|
185
|
+
).rejects.toThrow(/quota for today/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("sends no credentials of any kind", async () => {
|
|
189
|
+
// The FireCMS-era client sent the tenant's JWT as `Authorization: Basic`
|
|
190
|
+
// plus a hardcoded `fcms-…` key. No external service could verify the
|
|
191
|
+
// former, and the latter shipped in the published package.
|
|
192
|
+
const fetchMock = jest.fn().mockResolvedValue(streamingResponse([BODY]));
|
|
193
|
+
(global as any).fetch = fetchMock;
|
|
194
|
+
await autofillStream({ request: REQUEST,
|
|
195
|
+
onDelta: () => undefined,
|
|
196
|
+
onValue: () => undefined });
|
|
197
|
+
const [, init] = fetchMock.mock.calls[0];
|
|
198
|
+
expect(init.headers).toEqual({ "Content-Type": "application/json" });
|
|
199
|
+
expect(JSON.stringify(init)).not.toMatch(/fcms-|Bearer|Basic/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("posts to the hosted endpoint by default and to an override when given", async () => {
|
|
203
|
+
const fetchMock = jest.fn().mockResolvedValue(streamingResponse([BODY]));
|
|
204
|
+
(global as any).fetch = fetchMock;
|
|
205
|
+
|
|
206
|
+
await autofillStream({ request: REQUEST,
|
|
207
|
+
onDelta: () => undefined,
|
|
208
|
+
onValue: () => undefined });
|
|
209
|
+
expect(fetchMock.mock.calls[0][0]).toBe(`${DEFAULT_AI_ENDPOINT}/autofill`);
|
|
210
|
+
|
|
211
|
+
await autofillStream({
|
|
212
|
+
request: REQUEST,
|
|
213
|
+
endpoint: "https://ai.example.com/",
|
|
214
|
+
onDelta: () => undefined,
|
|
215
|
+
onValue: () => undefined
|
|
216
|
+
});
|
|
217
|
+
// Trailing slash trimmed — otherwise the override 404s on `//autofill`.
|
|
218
|
+
expect(fetchMock.mock.calls[1][0]).toBe("https://ai.example.com/autofill");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("autocompleteStream", () => {
|
|
223
|
+
it("concatenates deltas and returns the full continuation", async () => {
|
|
224
|
+
const body = [
|
|
225
|
+
'event: delta\ndata: {"text":"the quick "}',
|
|
226
|
+
'event: delta\ndata: {"text":"brown fox"}',
|
|
227
|
+
"event: done\ndata: {}",
|
|
228
|
+
""
|
|
229
|
+
].join("\n\n");
|
|
230
|
+
(global as any).fetch = jest.fn().mockResolvedValue(streamingResponse(chunked(body, 7)));
|
|
231
|
+
|
|
232
|
+
const seen: string[] = [];
|
|
233
|
+
const text = await autocompleteStream({
|
|
234
|
+
textBefore: "I saw ",
|
|
235
|
+
textAfter: "",
|
|
236
|
+
onDelta: (t) => seen.push(t)
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(seen.join("")).toBe("the quick brown fox");
|
|
240
|
+
expect(text).toBe("the quick brown fox");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("fetchAiStatus", () => {
|
|
245
|
+
it("reports available when the service says so", async () => {
|
|
246
|
+
(global as any).fetch = jest.fn().mockResolvedValue(
|
|
247
|
+
jsonResponse({ available: true,
|
|
248
|
+
model: "claude-opus-5",
|
|
249
|
+
features: ["autofill"] })
|
|
250
|
+
);
|
|
251
|
+
await expect(fetchAiStatus({})).resolves.toEqual({
|
|
252
|
+
available: true,
|
|
253
|
+
model: "claude-opus-5",
|
|
254
|
+
features: ["autofill"]
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("reports unavailable rather than throwing when the service errors", async () => {
|
|
259
|
+
// This value decides whether a button renders. Any doubt must resolve to
|
|
260
|
+
// "no button" — that is the whole fix for the 404-on-click failure.
|
|
261
|
+
(global as any).fetch = jest.fn().mockResolvedValue(jsonResponse({}, false, 503));
|
|
262
|
+
await expect(fetchAiStatus({})).resolves.toEqual({ available: false });
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("fetchPromptSuggestions", () => {
|
|
267
|
+
it("maps the service's prompts", async () => {
|
|
268
|
+
(global as any).fetch = jest.fn().mockResolvedValue(jsonResponse({ prompts: ["A blue widget", "A red one"] }));
|
|
269
|
+
await expect(fetchPromptSuggestions({ entityName: "Product" })).resolves.toEqual({
|
|
270
|
+
prompts: [
|
|
271
|
+
{ prompt: "A blue widget",
|
|
272
|
+
type: "sample" },
|
|
273
|
+
{ prompt: "A red one",
|
|
274
|
+
type: "sample" }
|
|
275
|
+
]
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("degrades to no suggestions instead of failing the menu", async () => {
|
|
280
|
+
(global as any).fetch = jest.fn().mockRejectedValue(new Error("offline"));
|
|
281
|
+
await expect(fetchPromptSuggestions({ entityName: "Product" })).resolves.toEqual({ prompts: [] });
|
|
282
|
+
});
|
|
283
|
+
});
|