@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
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { TextEncoder, TextDecoder } from "util";
|
|
2
|
+
Object.assign(global, { TextEncoder,
|
|
3
|
+
TextDecoder });
|
|
4
|
+
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
DataEnhancementControllerProvider,
|
|
10
|
+
useDataEnhancementController
|
|
11
|
+
} from "../components/DataEnhancementControllerProvider";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The propose → review → apply contract.
|
|
15
|
+
*
|
|
16
|
+
* The design this replaced streamed generated text straight into the live form
|
|
17
|
+
* fields. Its failure modes were all the same failure: the record changed
|
|
18
|
+
* before anyone agreed to it. Half-written sentences appeared mid-generation,
|
|
19
|
+
* heuristics guessed whether each token should append to or replace what the
|
|
20
|
+
* operator had typed, and getting the old value back meant retyping it.
|
|
21
|
+
*
|
|
22
|
+
* So the load-bearing assertion in this file is a negative one — `setFieldValue`
|
|
23
|
+
* is not called — and it is checked during streaming, after streaming, after a
|
|
24
|
+
* failure, and after a discard. Everything else here is detail; that is the
|
|
25
|
+
* promise.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
jest.mock("@rebasepro/admin", () => ({
|
|
29
|
+
getFieldId: (property: { type?: string }) => (property?.type === "string" ? "text_field" : "number_field")
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const COLLECTION = {
|
|
33
|
+
name: "Products",
|
|
34
|
+
singularName: "Product",
|
|
35
|
+
properties: {
|
|
36
|
+
title: { type: "string",
|
|
37
|
+
name: "Title" },
|
|
38
|
+
subtitle: { type: "string",
|
|
39
|
+
name: "Subtitle" },
|
|
40
|
+
stock: { type: "number",
|
|
41
|
+
name: "Stock" }
|
|
42
|
+
}
|
|
43
|
+
} as any;
|
|
44
|
+
|
|
45
|
+
/** A `Response` whose body yields the given chunks, in order. */
|
|
46
|
+
function streamingResponse(chunks: string[]): any {
|
|
47
|
+
const encoder = new TextEncoder();
|
|
48
|
+
let i = 0;
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
status: 200,
|
|
52
|
+
body: {
|
|
53
|
+
getReader: () => ({
|
|
54
|
+
read: async () =>
|
|
55
|
+
i < chunks.length
|
|
56
|
+
? { done: false,
|
|
57
|
+
value: encoder.encode(chunks[i++]) }
|
|
58
|
+
: { done: true,
|
|
59
|
+
value: undefined }
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const AUTOFILL_BODY = [
|
|
66
|
+
'event: suggestion_delta\ndata: {"key":"title","text":"Blue "}',
|
|
67
|
+
'event: suggestion_delta\ndata: {"key":"title","text":"widget"}',
|
|
68
|
+
'event: suggestion\ndata: {"key":"title","value":"Blue widget"}',
|
|
69
|
+
'event: suggestion\ndata: {"key":"stock","value":42}',
|
|
70
|
+
'event: done\ndata: {"suggestions":{"title":"Blue widget","stock":42}}',
|
|
71
|
+
""
|
|
72
|
+
].join("\n\n");
|
|
73
|
+
|
|
74
|
+
/** Route `/status` to an availability answer and `/autofill` to a stream. */
|
|
75
|
+
function mockService(autofill: () => any, available = true) {
|
|
76
|
+
(global as any).fetch = jest.fn((url: string) => {
|
|
77
|
+
if (String(url).endsWith("/status")) {
|
|
78
|
+
return Promise.resolve({ ok: true,
|
|
79
|
+
status: 200,
|
|
80
|
+
json: async () => ({ available,
|
|
81
|
+
model: "gemini-3.6-flash" }) });
|
|
82
|
+
}
|
|
83
|
+
if (String(url).endsWith("/autofill")) return Promise.resolve(autofill());
|
|
84
|
+
return Promise.resolve({ ok: true,
|
|
85
|
+
status: 200,
|
|
86
|
+
json: async () => ({ prompts: [] }) });
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function mountController(formValues: Record<string, unknown> = {}) {
|
|
91
|
+
const setFieldValue = jest.fn();
|
|
92
|
+
const formContext = { values: formValues,
|
|
93
|
+
setFieldValue } as any;
|
|
94
|
+
|
|
95
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
96
|
+
<DataEnhancementControllerProvider
|
|
97
|
+
path={"products"}
|
|
98
|
+
collection={COLLECTION}
|
|
99
|
+
formContext={formContext}
|
|
100
|
+
{...({} as any)}>
|
|
101
|
+
{children}
|
|
102
|
+
</DataEnhancementControllerProvider>
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const rendered = renderHook(() => useDataEnhancementController(), { wrapper });
|
|
106
|
+
return { ...rendered,
|
|
107
|
+
setFieldValue };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
jest.restoreAllMocks();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("autofill review", () => {
|
|
115
|
+
|
|
116
|
+
it("never writes to the form while generating or after it finishes", async () => {
|
|
117
|
+
mockService(() => streamingResponse([AUTOFILL_BODY]));
|
|
118
|
+
const { result, setFieldValue } = await mountController({ title: "" });
|
|
119
|
+
await waitFor(() => expect(result.current.enabled).toBe(true));
|
|
120
|
+
|
|
121
|
+
await act(async () => {
|
|
122
|
+
await result.current.generate({ values: { title: "" } });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(result.current.review?.status).toBe("ready");
|
|
126
|
+
expect(result.current.review?.fields.length).toBe(2);
|
|
127
|
+
expect(setFieldValue).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("accumulates streamed text into the proposal, not the field", async () => {
|
|
131
|
+
mockService(() => streamingResponse([AUTOFILL_BODY]));
|
|
132
|
+
const { result, setFieldValue } = await mountController();
|
|
133
|
+
await waitFor(() => expect(result.current.enabled).toBe(true));
|
|
134
|
+
|
|
135
|
+
await act(async () => {
|
|
136
|
+
await result.current.generate({ values: {} });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const title = result.current.review?.fields.find(f => f.key === "title");
|
|
140
|
+
expect(title?.proposed).toBe("Blue widget");
|
|
141
|
+
expect(title?.pending).toBe(false);
|
|
142
|
+
expect(title?.label).toBe("Title");
|
|
143
|
+
expect(setFieldValue).not.toHaveBeenCalled();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("writes only the selected fields, and only on apply", async () => {
|
|
147
|
+
mockService(() => streamingResponse([AUTOFILL_BODY]));
|
|
148
|
+
const { result, setFieldValue } = await mountController();
|
|
149
|
+
await waitFor(() => expect(result.current.enabled).toBe(true));
|
|
150
|
+
|
|
151
|
+
await act(async () => {
|
|
152
|
+
await result.current.generate({ values: {} });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
act(() => result.current.toggleField("stock"));
|
|
156
|
+
expect(setFieldValue).not.toHaveBeenCalled();
|
|
157
|
+
|
|
158
|
+
act(() => result.current.applyReview());
|
|
159
|
+
|
|
160
|
+
expect(setFieldValue).toHaveBeenCalledTimes(1);
|
|
161
|
+
expect(setFieldValue).toHaveBeenCalledWith("title", "Blue widget");
|
|
162
|
+
// The review closes on apply — there is nothing left to decide.
|
|
163
|
+
expect(result.current.review).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("writes nothing when the review is discarded", async () => {
|
|
167
|
+
mockService(() => streamingResponse([AUTOFILL_BODY]));
|
|
168
|
+
const { result, setFieldValue } = await mountController({ title: "Existing" });
|
|
169
|
+
await waitFor(() => expect(result.current.enabled).toBe(true));
|
|
170
|
+
|
|
171
|
+
await act(async () => {
|
|
172
|
+
await result.current.generate({ values: { title: "Existing" } });
|
|
173
|
+
});
|
|
174
|
+
act(() => result.current.dismissReview());
|
|
175
|
+
|
|
176
|
+
expect(setFieldValue).not.toHaveBeenCalled();
|
|
177
|
+
expect(result.current.review).toBeNull();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("records what each proposal would overwrite", async () => {
|
|
181
|
+
// The row shows the current value struck through, which is only possible
|
|
182
|
+
// because the form was never touched — it still holds the original.
|
|
183
|
+
mockService(() => streamingResponse([AUTOFILL_BODY]));
|
|
184
|
+
const { result } = await mountController({ title: "Old title" });
|
|
185
|
+
await waitFor(() => expect(result.current.enabled).toBe(true));
|
|
186
|
+
|
|
187
|
+
await act(async () => {
|
|
188
|
+
await result.current.generate({ values: { title: "Old title" } });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const title = result.current.review?.fields.find(f => f.key === "title");
|
|
192
|
+
expect(title?.currentValue).toBe("Old title");
|
|
193
|
+
expect(title?.proposed).toBe("Blue widget");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("toggles every field at once", async () => {
|
|
197
|
+
mockService(() => streamingResponse([AUTOFILL_BODY]));
|
|
198
|
+
const { result, setFieldValue } = await mountController();
|
|
199
|
+
await waitFor(() => expect(result.current.enabled).toBe(true));
|
|
200
|
+
|
|
201
|
+
await act(async () => {
|
|
202
|
+
await result.current.generate({ values: {} });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
act(() => result.current.toggleAll(false));
|
|
206
|
+
act(() => result.current.applyReview());
|
|
207
|
+
expect(setFieldValue).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("keeps the fields that arrived before a mid-stream failure", async () => {
|
|
211
|
+
// A run that produced two good fields and then broke should still let
|
|
212
|
+
// the operator take the two, rather than discarding the work.
|
|
213
|
+
const truncated = [
|
|
214
|
+
'event: suggestion\ndata: {"key":"title","value":"Blue widget"}',
|
|
215
|
+
'event: error\ndata: {"message":"quota exhausted"}',
|
|
216
|
+
""
|
|
217
|
+
].join("\n\n");
|
|
218
|
+
mockService(() => streamingResponse([truncated]));
|
|
219
|
+
const { result, setFieldValue } = await mountController();
|
|
220
|
+
await waitFor(() => expect(result.current.enabled).toBe(true));
|
|
221
|
+
|
|
222
|
+
await act(async () => {
|
|
223
|
+
await result.current.generate({ values: {} });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(result.current.review?.status).toBe("failed");
|
|
227
|
+
expect(result.current.review?.error).toMatch(/quota exhausted/);
|
|
228
|
+
expect(result.current.review?.fields.map(f => f.key)).toEqual(["title"]);
|
|
229
|
+
expect(setFieldValue).not.toHaveBeenCalled();
|
|
230
|
+
|
|
231
|
+
act(() => result.current.applyReview());
|
|
232
|
+
expect(setFieldValue).toHaveBeenCalledWith("title", "Blue widget");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("sends values flattened onto the same dotted paths as the properties", async () => {
|
|
236
|
+
// The service is told about `seo.title`, so it has to be told the value
|
|
237
|
+
// of `seo.title` — not of `seo`. Sending the nested object instead makes
|
|
238
|
+
// every existing value invisible to the model, which shows up as
|
|
239
|
+
// autofill cheerfully overwriting things the operator already wrote.
|
|
240
|
+
mockService(() => streamingResponse([AUTOFILL_BODY]));
|
|
241
|
+
const { result } = await mountController();
|
|
242
|
+
await waitFor(() => expect(result.current.enabled).toBe(true));
|
|
243
|
+
|
|
244
|
+
await act(async () => {
|
|
245
|
+
await result.current.generate({ values: { seo: { title: "Nested" } } });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const call = ((global as any).fetch as jest.Mock).mock.calls
|
|
249
|
+
.find(([url]: [string]) => String(url).endsWith("/autofill"));
|
|
250
|
+
expect(JSON.parse(call[1].body).values).toEqual({ "seo.title": "Nested" });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("stays disabled when the service reports unavailable", async () => {
|
|
254
|
+
mockService(() => streamingResponse([AUTOFILL_BODY]), false);
|
|
255
|
+
const { result } = await mountController();
|
|
256
|
+
await waitFor(() => expect((global as any).fetch).toHaveBeenCalled());
|
|
257
|
+
expect(result.current.enabled).toBe(false);
|
|
258
|
+
expect(result.current.review).toBeNull();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -19,6 +19,7 @@ if (typeof window !== "undefined") {
|
|
|
19
19
|
|
|
20
20
|
import { renderHook } from "@testing-library/react";
|
|
21
21
|
import { useDataEnhancementPlugin } from "../useDataEnhancementPlugin";
|
|
22
|
+
import { DEFAULT_AI_ENDPOINT } from "../api";
|
|
22
23
|
|
|
23
24
|
jest.mock("@rebasepro/admin", () => ({
|
|
24
25
|
useUrlController: () => ({})
|
|
@@ -29,23 +30,43 @@ describe("useDataEnhancementPlugin hook", () => {
|
|
|
29
30
|
const { result } = renderHook(() => useDataEnhancementPlugin());
|
|
30
31
|
const plugin = result.current;
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
expect(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
// Matched as one shape rather than indexed field by field. `slots` and
|
|
34
|
+
// `providers` are optional on `Plugin`, and a preceding
|
|
35
|
+
// `expect(...).toBeDefined()` does not narrow them for TypeScript — so
|
|
36
|
+
// the indexed form only compiled because nothing type-checked this file.
|
|
37
|
+
// `toMatchObject` needs no narrowing and pins the fields together, which
|
|
38
|
+
// is what "correct metadata" actually means.
|
|
39
|
+
expect(plugin).toMatchObject({
|
|
40
|
+
key: "data_enhancement",
|
|
41
|
+
slots: [{ slot: "form.actions" }],
|
|
42
|
+
providers: [{ scope: "form" }]
|
|
43
|
+
});
|
|
38
44
|
});
|
|
39
45
|
|
|
40
|
-
it("
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const { result } = renderHook(() => useDataEnhancementPlugin(
|
|
46
|
-
const
|
|
46
|
+
it("ships no credentials", () => {
|
|
47
|
+
// The FireCMS-era plugin exported a hardcoded `fcms-…` key as
|
|
48
|
+
// `DEFAULT_API_KEY` and handed it to the provider. Anyone who installed
|
|
49
|
+
// the package got a copy. Nothing key-shaped may reach the provider
|
|
50
|
+
// props again — the hosted service authenticates nobody by design.
|
|
51
|
+
const { result } = renderHook(() => useDataEnhancementPlugin());
|
|
52
|
+
const props = result.current.providers?.[0]?.props ?? {};
|
|
53
|
+
expect(Object.keys(props)).toEqual(expect.not.arrayContaining(["apiKey", "firebaseToken", "token"]));
|
|
54
|
+
expect(JSON.stringify(props)).not.toMatch(/fcms-/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("forwards a custom endpoint so a self-hoster can proxy the service", () => {
|
|
58
|
+
const { result } = renderHook(() => useDataEnhancementPlugin({ endpoint: "https://ai.example.com" }));
|
|
47
59
|
|
|
48
|
-
expect(
|
|
49
|
-
|
|
60
|
+
expect(result.current).toMatchObject({
|
|
61
|
+
providers: [{ props: { endpoint: "https://ai.example.com" } }]
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("leaves the endpoint unset so the client falls back to the hosted default", () => {
|
|
66
|
+
// Compared against the exported constant rather than a copy of the URL:
|
|
67
|
+
// a copy pins the string, this pins the wiring.
|
|
68
|
+
const { result } = renderHook(() => useDataEnhancementPlugin());
|
|
69
|
+
expect(result.current.providers?.[0]?.props?.endpoint).toBeUndefined();
|
|
70
|
+
expect(DEFAULT_AI_ENDPOINT).toMatch(/^https:\/\//);
|
|
50
71
|
});
|
|
51
72
|
});
|
|
@@ -1,64 +1,131 @@
|
|
|
1
1
|
import { EntityValues } from "@rebasepro/types";
|
|
2
2
|
import { EditorAIController } from "@rebasepro/admin";
|
|
3
3
|
|
|
4
|
-
export type
|
|
5
|
-
|
|
4
|
+
export type GenerateParams<M extends Record<string, unknown>> = {
|
|
5
|
+
values: EntityValues<M>;
|
|
6
|
+
/** Free-text instruction from the operator, if they gave one. */
|
|
7
|
+
instructions?: string;
|
|
8
|
+
/** Restrict the run to one field. */
|
|
6
9
|
propertyKey?: string;
|
|
7
10
|
propertyInstructions?: string;
|
|
8
|
-
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One field the model wants to write, awaiting the operator's decision.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here has touched the form. That is the entire point of the type: the
|
|
17
|
+
* previous design streamed generated text straight into the live fields, which
|
|
18
|
+
* meant a half-written sentence was indistinguishable from a bug, the
|
|
19
|
+
* operator's own words were overwritten by heuristics that tried to guess
|
|
20
|
+
* whether to append or replace, and there was no way back other than retyping.
|
|
21
|
+
*/
|
|
22
|
+
export type ProposedField = {
|
|
23
|
+
/** Dotted property path, e.g. `seo.title`. */
|
|
24
|
+
key: string;
|
|
25
|
+
/** The property's display name, falling back to its key. */
|
|
26
|
+
label: string;
|
|
27
|
+
/** What is in the form right now — shown so an overwrite is visible. */
|
|
28
|
+
currentValue: unknown;
|
|
29
|
+
/** What the model proposes. Grows while `pending`. */
|
|
30
|
+
proposed: unknown;
|
|
31
|
+
/** Still streaming. */
|
|
32
|
+
pending: boolean;
|
|
33
|
+
/** Whether Apply will write this one. */
|
|
34
|
+
selected: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type AutofillReview = {
|
|
38
|
+
status: "generating" | "ready" | "failed";
|
|
39
|
+
/** Set when `status` is `failed`. */
|
|
40
|
+
error?: string;
|
|
41
|
+
/** In arrival order, so the list reads as the model works. */
|
|
42
|
+
fields: ProposedField[];
|
|
43
|
+
/** What was asked for, shown back to the operator while they review. */
|
|
9
44
|
instructions?: string;
|
|
10
|
-
replaceValues: boolean;
|
|
11
45
|
};
|
|
12
46
|
|
|
13
47
|
export type DataEnhancementController = {
|
|
14
48
|
/**
|
|
15
|
-
* Whether
|
|
49
|
+
* Whether autofill can actually be used right now.
|
|
50
|
+
*
|
|
51
|
+
* The conjunction of two separate things: the host app allows it for this
|
|
52
|
+
* collection ({@link DataEnhancementPluginProps.getConfigForPath}), *and*
|
|
53
|
+
* the service reported itself available. The second half is what the
|
|
54
|
+
* FireCMS-era plugin lacked — it rendered its button unconditionally
|
|
55
|
+
* against a host that no longer existed, so every click 404'd.
|
|
16
56
|
*/
|
|
17
57
|
enabled: boolean;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
58
|
+
|
|
59
|
+
/** The run in flight or awaiting review; `null` when there is neither. */
|
|
60
|
+
review: AutofillReview | null;
|
|
61
|
+
|
|
62
|
+
/** Start a run. Opens {@link review}; never writes to the form. */
|
|
63
|
+
generate: <M extends Record<string, unknown>>(params: GenerateParams<M>) => Promise<void>;
|
|
64
|
+
|
|
65
|
+
/** Include or exclude one field from what Apply will write. */
|
|
66
|
+
toggleField: (key: string) => void;
|
|
67
|
+
|
|
68
|
+
/** Select or deselect every field at once. */
|
|
69
|
+
toggleAll: (selected: boolean) => void;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Write the selected fields to the form and close the review.
|
|
73
|
+
*
|
|
74
|
+
* The only path by which this plugin mutates a record, and it runs once per
|
|
75
|
+
* run rather than once per token — so it is a single undo step and a single
|
|
76
|
+
* dirty transition, not hundreds.
|
|
77
|
+
*/
|
|
78
|
+
applyReview: () => void;
|
|
79
|
+
|
|
80
|
+
/** Close the review, writing nothing. The record is untouched. */
|
|
81
|
+
dismissReview: () => void;
|
|
82
|
+
|
|
23
83
|
getSamplePrompts: (entityName: string, input?: string) => Promise<SamplePromptsResult>;
|
|
24
|
-
|
|
84
|
+
|
|
25
85
|
editorAIController?: EditorAIController;
|
|
26
|
-
}
|
|
86
|
+
};
|
|
27
87
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
88
|
+
/** What `GET /status` answers. Everything else is gated on `available`. */
|
|
89
|
+
export type AiStatus = {
|
|
90
|
+
available: boolean;
|
|
91
|
+
model?: string;
|
|
92
|
+
features?: string[];
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type AutofillResult = {
|
|
96
|
+
/** Every field the service completed, keyed by property path. */
|
|
97
|
+
suggestions: Record<string, unknown>;
|
|
98
|
+
usage?: { inputTokens?: number; outputTokens?: number };
|
|
99
|
+
};
|
|
36
100
|
|
|
37
101
|
export type SamplePrompt = {
|
|
38
102
|
prompt: string;
|
|
39
103
|
type: "recent" | "sample";
|
|
40
|
-
}
|
|
104
|
+
};
|
|
41
105
|
|
|
42
106
|
export type SamplePromptsResult = {
|
|
43
107
|
prompts: SamplePrompt[];
|
|
44
|
-
host?: string;
|
|
45
108
|
};
|
|
46
109
|
|
|
47
|
-
|
|
110
|
+
/**
|
|
111
|
+
* The autofill request body.
|
|
112
|
+
*
|
|
113
|
+
* The property schema travels with every request because the service has no
|
|
114
|
+
* access to the caller's collections — it is a hosted endpoint reachable from
|
|
115
|
+
* any self-hosted admin panel. That is the cost of not putting an LLM
|
|
116
|
+
* dependency in `@rebasepro/server`; had the route lived in the backend it
|
|
117
|
+
* could have read the collection registry and this would be three fields.
|
|
118
|
+
*/
|
|
119
|
+
export type AutofillRequest = {
|
|
48
120
|
entityName: string;
|
|
49
121
|
entityDescription?: string;
|
|
50
|
-
|
|
122
|
+
values: Record<string, unknown>;
|
|
51
123
|
properties: Record<string, InputProperty>;
|
|
52
124
|
propertyKey?: string;
|
|
53
|
-
propertyInstructions?: string
|
|
125
|
+
propertyInstructions?: string;
|
|
54
126
|
instructions?: string;
|
|
55
127
|
};
|
|
56
128
|
|
|
57
|
-
export type InputEntity = {
|
|
58
|
-
entityId?: string | number;
|
|
59
|
-
values: Record<string, any>;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
129
|
export type InputProperty = {
|
|
63
130
|
name?: string;
|
|
64
131
|
description?: string;
|
|
@@ -72,4 +139,4 @@ export type InputProperty = {
|
|
|
72
139
|
typeField?: string;
|
|
73
140
|
valueField?: string;
|
|
74
141
|
};
|
|
75
|
-
}
|
|
142
|
+
};
|
|
@@ -5,17 +5,12 @@ import { RebasePlugin } from "@rebasepro/admin-types";
|
|
|
5
5
|
import { DataEnhancementControllerProvider } from "./components/DataEnhancementControllerProvider";
|
|
6
6
|
import { FormEnhanceAction } from "./components/FormEnhanceAction";
|
|
7
7
|
|
|
8
|
-
const DEFAULT_API_KEY = "fcms-U9jdDii0xXWSDC34asfrf54lbkFJBfKfRWcEDEwdc4V5wDWEDF";
|
|
9
|
-
|
|
10
8
|
export interface DataEnhancementPluginProps {
|
|
11
9
|
|
|
12
|
-
apiKey?: string;
|
|
13
|
-
|
|
14
10
|
/**
|
|
15
11
|
* Use this function to determine if the data enhancement plugin should be enabled for a given path.
|
|
16
12
|
* If this function is not provided, the plugin will be enabled for all paths.
|
|
17
13
|
* If the function returns false, the plugin will be disabled for the given path.
|
|
18
|
-
* You can also return a configuration object to override the default configuration.
|
|
19
14
|
*
|
|
20
15
|
* @param path
|
|
21
16
|
* @param collection
|
|
@@ -27,10 +22,17 @@ export interface DataEnhancementPluginProps {
|
|
|
27
22
|
}) => boolean;
|
|
28
23
|
|
|
29
24
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
25
|
+
* Base URL of the AI service.
|
|
26
|
+
*
|
|
27
|
+
* Defaults to the one Rebase hosts, which is free to use and needs no
|
|
28
|
+
* configuration. Point it at your own deployment to keep generation inside
|
|
29
|
+
* your infrastructure — the wire format is documented in `src/api.ts`, and
|
|
30
|
+
* the reference implementation is `saas/backend/functions/ai.ts`.
|
|
31
|
+
*
|
|
32
|
+
* Whatever it points at, the plugin renders nothing until that host's
|
|
33
|
+
* `GET /status` reports itself available.
|
|
32
34
|
*/
|
|
33
|
-
|
|
35
|
+
endpoint?: string;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
/**
|
|
@@ -40,8 +42,8 @@ export interface DataEnhancementPluginProps {
|
|
|
40
42
|
*/
|
|
41
43
|
export function useDataEnhancementPlugin(props?: DataEnhancementPluginProps): RebasePlugin {
|
|
42
44
|
|
|
43
|
-
const apiKey = props?.apiKey ?? DEFAULT_API_KEY;
|
|
44
45
|
const getConfigForPath = props?.getConfigForPath;
|
|
46
|
+
const endpoint = props?.endpoint;
|
|
45
47
|
|
|
46
48
|
return React.useMemo(() => ({
|
|
47
49
|
key: "data_enhancement",
|
|
@@ -57,11 +59,10 @@ export function useDataEnhancementPlugin(props?: DataEnhancementPluginProps): Re
|
|
|
57
59
|
scope: "form" as const,
|
|
58
60
|
Component: DataEnhancementControllerProvider as React.ComponentType<any>,
|
|
59
61
|
props: {
|
|
60
|
-
apiKey,
|
|
61
62
|
getConfigForPath,
|
|
62
|
-
|
|
63
|
+
endpoint
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
]
|
|
66
|
-
}), [
|
|
67
|
+
}), [getConfigForPath, endpoint]);
|
|
67
68
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export declare function getAppendableSuggestion(suggestion: string | number | undefined, value: unknown): string | undefined;
|