@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,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;
@@ -1,128 +0,0 @@
1
- // Import the diffStrings function from your module
2
- // import { diffStrings } from './your-module';
3
-
4
- import { Change, diffStrings } from "../utils/diffStrings";
5
-
6
- describe("diffStrings", () => {
7
- test("equal strings", () => {
8
- const oldStr = "This is a test string";
9
- const newStr = "This is a test string";
10
- const expected: Change[] = [
11
- {
12
- type: "equal",
13
- value: "This is a test string"
14
- }
15
- ];
16
- expect(diffStrings(oldStr, newStr)).toEqual(expected);
17
- });
18
-
19
- test("insertions only", () => {
20
- const oldStr = "This is a test string";
21
- const newStr = "This is a new test string";
22
- const expected: Change[] = [
23
- {
24
- type: "equal",
25
- value: "This is a"
26
- },
27
- {
28
- type: "insert",
29
- value: " new"
30
- },
31
- {
32
- type: "equal",
33
- value: " test string"
34
- }
35
- ];
36
- expect(diffStrings(oldStr, newStr)).toEqual(expected);
37
- });
38
-
39
- test("deletions only", () => {
40
- const oldStr = "This is an old test string";
41
- const newStr = "This is a test string";
42
- const expected: Change[] = [
43
- {
44
- type: "equal",
45
- value: "This is a"
46
- },
47
- {
48
- type: "delete",
49
- value: "n old"
50
- },
51
- {
52
- type: "equal",
53
- value: " test string"
54
- }
55
- ];
56
- expect(diffStrings(oldStr, newStr)).toEqual(expected);
57
- });
58
-
59
- test("insertions and deletions", () => {
60
- const oldStr = "This is an old test string";
61
- const newStr = "This is a new modified test string";
62
- // The LCS algorithm finds the longest common substrings correctly
63
- // Even though the output is more granular, it correctly represents the diff
64
- const expected: Change[] = [
65
- {
66
- type: "equal",
67
- value: "This is a"
68
- },
69
- {
70
- type: "insert",
71
- value: " "
72
- },
73
- {
74
- type: "equal",
75
- value: "n"
76
- },
77
- {
78
- type: "insert",
79
- value: "ew"
80
- },
81
- {
82
- type: "equal",
83
- value: " "
84
- },
85
- {
86
- type: "insert",
87
- value: "m"
88
- },
89
- {
90
- type: "equal",
91
- value: "o"
92
- },
93
- {
94
- type: "delete",
95
- value: "l"
96
- },
97
- {
98
- type: "insert",
99
- value: "difie"
100
- },
101
- {
102
- type: "equal",
103
- value: "d test string"
104
- }
105
- ];
106
- expect(diffStrings(oldStr, newStr)).toEqual(expected);
107
- });
108
-
109
- test("completely different strings", () => {
110
- const oldStr = "Old string";
111
- const newStr = "New string";
112
- const expected: Change[] = [
113
- {
114
- type: "delete",
115
- value: "Old"
116
- },
117
- {
118
- type: "insert",
119
- value: "New"
120
- },
121
- {
122
- type: "equal",
123
- value: " string"
124
- }
125
- ];
126
- expect(diffStrings(oldStr, newStr)).toEqual(expected);
127
- });
128
- });
@@ -1,117 +0,0 @@
1
- import { countStringCharacters } from "../utils/strings_counter";
2
- import type { Properties } from "@rebasepro/types";
3
-
4
- describe("countStringCharacters", () => {
5
- it("counts characters in a string property", () => {
6
- const values = { title: "Hello World" };
7
- const properties: Properties = {
8
- title: { name: "title",
9
- type: "string" }
10
- };
11
- expect(countStringCharacters(values, properties)).toBe(11);
12
- });
13
-
14
- it("counts characters in a number property (stringified)", () => {
15
- const values = { price: 12345 };
16
- const properties: Properties = {
17
- price: { name: "price",
18
- type: "number" }
19
- };
20
- expect(countStringCharacters(values, properties)).toBe(5);
21
- });
22
-
23
- it("skips disabled properties", () => {
24
- const values = { title: "Hello",
25
- hidden: "Secret" };
26
- const properties: Properties = {
27
- title: { name: "title",
28
- type: "string" },
29
- hidden: { name: "hidden",
30
- type: "string",
31
- admin: { disabled: true } }
32
- };
33
- expect(countStringCharacters(values, properties)).toBe(5);
34
- });
35
-
36
- it("counts array of strings", () => {
37
- const values = { tags: ["hello", "world", "test"] };
38
- const properties: Properties = {
39
- tags: {
40
- name: "tags",
41
- type: "array",
42
- of: { name: "tag",
43
- type: "string" }
44
- }
45
- };
46
- // "hello" (5) + "world" (5) + "test" (4) = 14
47
- expect(countStringCharacters(values, properties)).toBe(14);
48
- });
49
-
50
- it("recurses into map properties", () => {
51
- const values = {
52
- address: { city: "New York",
53
- country: "USA" }
54
- };
55
- const properties: Properties = {
56
- address: {
57
- name: "address",
58
- type: "map",
59
- properties: {
60
- city: { name: "city",
61
- type: "string" },
62
- country: { name: "country",
63
- type: "string" }
64
- }
65
- }
66
- };
67
- // "New York" (8) + "USA" (3) = 11
68
- expect(countStringCharacters(values, properties)).toBe(11);
69
- });
70
-
71
- it("returns 0 for empty values", () => {
72
- const properties: Properties = {
73
- title: { name: "title",
74
- type: "string" }
75
- };
76
- expect(countStringCharacters({}, properties)).toBe(0);
77
- });
78
-
79
- it("handles mixed property types", () => {
80
- const values = {
81
- title: "Hello",
82
- count: 42,
83
- active: true,
84
- tags: ["a", "bb"]
85
- };
86
- const properties: Properties = {
87
- title: { name: "title",
88
- type: "string" },
89
- count: { name: "count",
90
- type: "number" },
91
- active: { name: "active",
92
- type: "boolean" },
93
- tags: {
94
- name: "tags",
95
- type: "array",
96
- of: { name: "tag",
97
- type: "string" }
98
- }
99
- };
100
- // "Hello" (5) + "42" (2) + "a" (1) + "bb" (2) = 10
101
- expect(countStringCharacters(values, properties)).toBe(10);
102
- });
103
-
104
- it("handles null values in array gracefully", () => {
105
- const values = { tags: [null, "hello", null] };
106
- const properties: Properties = {
107
- tags: {
108
- name: "tags",
109
- type: "array",
110
- of: { name: "tag",
111
- type: "string" }
112
- }
113
- };
114
- // null?.length = 0, "hello" = 5, null?.length = 0
115
- expect(countStringCharacters(values, properties)).toBe(5);
116
- });
117
- });
@@ -1,53 +0,0 @@
1
- import { getAppendableSuggestion } from "../utils/suggestions";
2
-
3
- describe("getAppendableSuggestion", () => {
4
- it("returns the remaining part when suggestion starts with value", () => {
5
- const result = getAppendableSuggestion("Hello World", "Hello");
6
- expect(result).toBe(" World");
7
- });
8
-
9
- it("returns undefined when suggestion does not start with value", () => {
10
- const result = getAppendableSuggestion("Goodbye World", "Hello");
11
- expect(result).toBeUndefined();
12
- });
13
-
14
- it("performs case-insensitive matching", () => {
15
- const result = getAppendableSuggestion("Hello World", "hello");
16
- expect(result).toBe(" World");
17
- });
18
-
19
- it("returns undefined for number suggestion", () => {
20
- const result = getAppendableSuggestion(42, "4");
21
- expect(result).toBeUndefined();
22
- });
23
-
24
- it("returns undefined for undefined suggestion", () => {
25
- const result = getAppendableSuggestion(undefined, "hello");
26
- expect(result).toBeUndefined();
27
- });
28
-
29
- it("returns undefined for non-string value", () => {
30
- const result = getAppendableSuggestion("Hello", 42);
31
- expect(result).toBeUndefined();
32
- });
33
-
34
- it("returns full suggestion when value is empty string", () => {
35
- const result = getAppendableSuggestion("Hello World", "");
36
- expect(result).toBe("Hello World");
37
- });
38
-
39
- it("handles whitespace trimming", () => {
40
- const result = getAppendableSuggestion("Hello World", "Hello");
41
- expect(result).toBe(" World");
42
- });
43
-
44
- it("returns empty string when suggestion equals value", () => {
45
- const result = getAppendableSuggestion("Hello", "Hello");
46
- expect(result).toBe("");
47
- });
48
-
49
- it("returns undefined when value is longer than suggestion", () => {
50
- const result = getAppendableSuggestion("Hi", "Hello World");
51
- expect(result).toBeUndefined();
52
- });
53
- });
@@ -1,70 +0,0 @@
1
- type ChangeType = "equal" | "delete" | "insert";
2
-
3
- export interface Change {
4
- type: ChangeType;
5
- value: string;
6
- }
7
-
8
- export function diffStrings(oldStr: string, newStr: string): Change[] {
9
- // Find the longest common substring
10
- function longestCommonSubstring(s1: string, s2: string): string {
11
- const longest = { start: 0,
12
- length: 0 };
13
- const matrix = new Array(s1.length + 1).fill(null).map(() => new Array(s2.length + 1).fill(0));
14
-
15
- for (let i = 1; i <= s1.length; i++) {
16
- for (let j = 1; j <= s2.length; j++) {
17
- if (s1[i - 1] === s2[j - 1]) {
18
- matrix[i][j] = matrix[i - 1][j - 1] + 1;
19
- if (matrix[i][j] > longest.length) {
20
- longest.start = i - matrix[i][j];
21
- longest.length = matrix[i][j];
22
- }
23
- }
24
- }
25
- }
26
-
27
- return s1.slice(longest.start, longest.start + longest.length);
28
- }
29
-
30
- // Recursively find changes and create Change objects
31
- function findChanges(s1: string, s2: string): Change[] {
32
- if (s1 === s2) return s1.length > 0 ? [{
33
- type: "equal",
34
- value: s1
35
- }] : [];
36
-
37
- const common = longestCommonSubstring(s1, s2);
38
-
39
- if (common.length === 0) {
40
- const changes: Change[] = [];
41
- if (s1.length > 0) {
42
- changes.push({ type: "delete",
43
- value: s1 });
44
- }
45
- if (s2.length > 0) {
46
- changes.push({ type: "insert",
47
- value: s2 });
48
- }
49
- return changes;
50
- }
51
-
52
- const s1Before = s1.slice(0, s1.indexOf(common));
53
- const s1After = s1.slice(s1.indexOf(common) + common.length);
54
- const s2Before = s2.slice(0, s2.indexOf(common));
55
- const s2After = s2.slice(s2.indexOf(common) + common.length);
56
-
57
- const changesBefore = findChanges(s1Before, s2Before);
58
- const changesAfter = findChanges(s1After, s2After);
59
-
60
- return [
61
- ...changesBefore,
62
- { type: "equal",
63
- value: common },
64
- ...changesAfter
65
- ];
66
- }
67
-
68
- return findChanges(oldStr, newStr);
69
- }
70
-
@@ -1,22 +0,0 @@
1
- import { EntityValues, Properties, Property } from "@rebasepro/types";
2
-
3
- export function countStringCharacters(values: EntityValues<any>, properties: Properties) {
4
- let count = 0;
5
-
6
- for (const key in values) {
7
- const value = values[key];
8
- const property: Property = properties[key];
9
-
10
- if (property && !property.admin?.disabled) {
11
- if (property.type === "string" || property.type === "number") {
12
- count += String(value).length;
13
- } else if (property.type === "array" && Array.isArray(value) && property.of && !Array.isArray(property.of) && property.of.type === "string") {
14
- count += (value as string[]).reduce((acc, curr) => acc + (curr?.length ?? 0), 0);
15
- } else if (property.type === "map" && property.properties && typeof value === "object") {
16
- count += countStringCharacters(value, property.properties);
17
- }
18
- }
19
- }
20
-
21
- return count;
22
- }
@@ -1,6 +0,0 @@
1
- export function getAppendableSuggestion(suggestion: string | number | undefined, value: unknown): string | undefined {
2
- const suggestionIncludesValue = typeof suggestion === "string" && typeof value === "string" && suggestion.toLowerCase().trim().startsWith(value.toLowerCase().trim());
3
- return (typeof value === "string" && suggestionIncludesValue)
4
- ? suggestion.substring(suggestion.toLowerCase().trim().indexOf(value.toLowerCase().trim()) + value.trim().length)
5
- : undefined;
6
- }