@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.
@@ -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
- expect(plugin.key).toBe("data_enhancement");
33
- expect(plugin.slots).toBeDefined();
34
- expect(plugin.slots[0].slot).toBe("form.actions");
35
- expect(plugin.providers).toBeDefined();
36
- expect(plugin.providers[0].scope).toBe("form");
37
- expect(plugin.providers[0].props.apiKey).toBe("fcms-U9jdDii0xXWSDC34asfrf54lbkFJBfKfRWcEDEwdc4V5wDWEDF");
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("accepts and forwards custom apiKey and host props", () => {
41
- const customProps = {
42
- apiKey: "custom-key",
43
- host: "https://custom-host.com"
44
- };
45
- const { result } = renderHook(() => useDataEnhancementPlugin(customProps));
46
- const plugin = result.current;
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(plugin.providers[0].props.apiKey).toBe("custom-key");
49
- expect(plugin.providers[0].props.host).toBe("https://custom-host.com");
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 EnhanceParams<M extends Record<string, unknown>> = {
5
- entityId?: string | number;
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
- values: EntityValues<M>;
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 the data enhancement is enabled for the current path
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
- suggestions: Record<string, string | number>;
19
- enhance: <M extends Record<string, unknown>>(props: EnhanceParams<M>) => Promise<EnhancedDataResult | null>;
20
- clearSuggestion: (key: string, suggestion: string | number) => void;
21
- allowReferenceDataSelection: boolean;
22
- clearAllSuggestions: () => void;
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
- loadingSuggestions: string[],
84
+
25
85
  editorAIController?: EditorAIController;
26
- }
86
+ };
27
87
 
28
- export type EnhancedDataResult = {
29
- entityId?: string | number;
30
- suggestions: {
31
- [key: string]: string[];
32
- };
33
- errors: string[];
34
- usage: { promptTokens?: number, completionTokens?: number, totalTokens?: number }
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
- export type DataEnhancementRequest = {
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
- inputEntity: InputEntity;
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
- * Host to use for the data enhancement API.
31
- * This prop is only use in development mode.
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
- host?: string;
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
- host: props?.host
63
+ endpoint
63
64
  }
64
65
  }
65
66
  ]
66
- }), [apiKey, getConfigForPath, props?.host]);
67
+ }), [getConfigForPath, endpoint]);
67
68
  }
@@ -1,7 +0,0 @@
1
- type ChangeType = "equal" | "delete" | "insert";
2
- export interface Change {
3
- type: ChangeType;
4
- value: string;
5
- }
6
- export declare function diffStrings(oldStr: string, newStr: string): Change[];
7
- export {};
@@ -1,2 +0,0 @@
1
- import { EntityValues, Properties } from "@rebasepro/types";
2
- export declare function countStringCharacters(values: EntityValues<any>, properties: Properties): number;
@@ -1 +0,0 @@
1
- export declare function getAppendableSuggestion(suggestion: string | number | undefined, value: unknown): string | undefined;