@rebasepro/plugin-ai 0.17.3 → 0.18.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.
@@ -1,98 +0,0 @@
1
- import { TextEncoder, TextDecoder } from "util";
2
- Object.assign(global, { TextEncoder,
3
- TextDecoder });
4
-
5
- import { renderHook } from "@testing-library/react";
6
- import { useEditorAIController } from "../editor/useEditorAIController";
7
-
8
- /**
9
- * The editor's inline continuation.
10
- *
11
- * Small, but it was the FireCMS-era code that demanded a Firebase ID token and
12
- * threw `"Firebase token is required"` without one — in a Rebase app there is
13
- * no such thing. The property worth pinning is that it now needs no token at
14
- * all, and that its streamed deltas reach the editor in order.
15
- */
16
- jest.mock("@rebasepro/cms", () => ({}));
17
-
18
- function streamingResponse(chunks: string[]): any {
19
- const encoder = new TextEncoder();
20
- let i = 0;
21
- return {
22
- ok: true,
23
- status: 200,
24
- body: {
25
- getReader: () => ({
26
- read: async () => i < chunks.length
27
- ? { done: false,
28
- value: encoder.encode(chunks[i++]) }
29
- : { done: true,
30
- value: undefined }
31
- })
32
- }
33
- };
34
- }
35
-
36
- afterEach(() => jest.restoreAllMocks());
37
-
38
- describe("useEditorAIController", () => {
39
-
40
- it("streams deltas in order and resolves with the whole continuation", async () => {
41
- (global as any).fetch = jest.fn().mockResolvedValue(streamingResponse([
42
- 'event: delta\ndata: {"text":"made of "}\n\n',
43
- 'event: delta\ndata: {"text":"stainless steel"}\n\n',
44
- "event: done\ndata: {}\n\n"
45
- ]));
46
-
47
- const { result } = renderHook(() => useEditorAIController());
48
- const seen: string[] = [];
49
- const text = await result.current.autocomplete("The burrs are ", " Ships worldwide.", d => seen.push(d));
50
-
51
- expect(seen).toEqual(["made of ", "stainless steel"]);
52
- expect(text).toBe("made of stainless steel");
53
- });
54
-
55
- it("needs no auth token — it does not send one, and does not demand one", async () => {
56
- const fetchMock = jest.fn().mockResolvedValue(streamingResponse(["event: done\ndata: {}\n\n"]));
57
- (global as any).fetch = fetchMock;
58
-
59
- const { result } = renderHook(() => useEditorAIController());
60
- await expect(result.current.autocomplete("a", "b", () => undefined)).resolves.toBe("");
61
-
62
- const [, init] = fetchMock.mock.calls[0];
63
- expect(init.headers).toEqual({ "Content-Type": "application/json" });
64
- expect(JSON.stringify(init)).not.toMatch(/Bearer|Basic|token/i);
65
- });
66
-
67
- it("sends the caret context the editor gave it", async () => {
68
- const fetchMock = jest.fn().mockResolvedValue(streamingResponse(["event: done\ndata: {}\n\n"]));
69
- (global as any).fetch = fetchMock;
70
-
71
- const { result } = renderHook(() => useEditorAIController());
72
- await result.current.autocomplete("before", "after", () => undefined);
73
-
74
- expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ textBefore: "before",
75
- textAfter: "after" });
76
- });
77
-
78
- it("honours a custom endpoint, so a self-hoster can proxy it", async () => {
79
- const fetchMock = jest.fn().mockResolvedValue(streamingResponse(["event: done\ndata: {}\n\n"]));
80
- (global as any).fetch = fetchMock;
81
-
82
- const { result } = renderHook(() => useEditorAIController({ endpoint: "https://ai.example.com" }));
83
- await result.current.autocomplete("a", "b", () => undefined);
84
-
85
- expect(fetchMock.mock.calls[0][0]).toBe("https://ai.example.com/autocomplete");
86
- });
87
-
88
- it("surfaces the service's message when it refuses", async () => {
89
- (global as any).fetch = jest.fn().mockResolvedValue({
90
- ok: false,
91
- status: 429,
92
- json: async () => ({ error: { message: "The free AI quota for today has been used up." } })
93
- });
94
-
95
- const { result } = renderHook(() => useEditorAIController());
96
- await expect(result.current.autocomplete("a", "b", () => undefined)).rejects.toThrow(/quota for today/);
97
- });
98
- });
@@ -1,87 +0,0 @@
1
- import { flatMapEntityValues } from "../utils/values";
2
-
3
- describe("flatMapEntityValues", () => {
4
- it("returns flat object unchanged", () => {
5
- const values = { name: "John",
6
- age: 30 };
7
- const result = flatMapEntityValues(values);
8
- expect(result).toEqual({ name: "John",
9
- age: 30 });
10
- });
11
-
12
- it("flattens nested object to dot-notation keys", () => {
13
- const values = {
14
- address: { city: "NYC",
15
- zip: "10001" }
16
- };
17
- const result = flatMapEntityValues(values);
18
- expect(result).toEqual({
19
- "address.city": "NYC",
20
- "address.zip": "10001"
21
- });
22
- });
23
-
24
- it("flattens deeply nested objects", () => {
25
- const values = {
26
- a: { b: { c: { d: "deep" } } }
27
- };
28
- const result = flatMapEntityValues(values);
29
- expect(result).toEqual({ "a.b.c.d": "deep" });
30
- });
31
-
32
- it("returns empty object for empty input", () => {
33
- expect(flatMapEntityValues({})).toEqual({});
34
- });
35
-
36
- it("returns empty object for null input", () => {
37
- expect(flatMapEntityValues(null as unknown as object)).toEqual({});
38
- });
39
-
40
- it("returns empty object for undefined input", () => {
41
- expect(flatMapEntityValues(undefined as unknown as object)).toEqual({});
42
- });
43
-
44
- it("handles mixed nested and flat values", () => {
45
- const values = {
46
- name: "John",
47
- address: { city: "NYC" },
48
- active: true
49
- };
50
- const result = flatMapEntityValues(values);
51
- expect(result).toEqual({
52
- name: "John",
53
- "address.city": "NYC",
54
- active: true
55
- });
56
- });
57
-
58
- it("handles multiple nested objects", () => {
59
- const values = {
60
- home: { city: "NYC" },
61
- work: { city: "SF" }
62
- };
63
- const result = flatMapEntityValues(values);
64
- expect(result).toEqual({
65
- "home.city": "NYC",
66
- "work.city": "SF"
67
- });
68
- });
69
-
70
- it("handles numeric values in nested objects", () => {
71
- const values = {
72
- stats: { visits: 100,
73
- clicks: 50 }
74
- };
75
- const result = flatMapEntityValues(values);
76
- expect(result).toEqual({
77
- "stats.visits": 100,
78
- "stats.clicks": 50
79
- });
80
- });
81
-
82
- it("uses custom path prefix", () => {
83
- const values = { name: "John" };
84
- const result = flatMapEntityValues(values, "user");
85
- expect(result).toEqual({ "user.name": "John" });
86
- });
87
- });
@@ -1,142 +0,0 @@
1
- import { EntityValues } from "@rebasepro/types";
2
- import { EditorAIController } from "@rebasepro/cms";
3
-
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. */
9
- propertyKey?: string;
10
- propertyInstructions?: string;
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. */
44
- instructions?: string;
45
- };
46
-
47
- export type DataEnhancementController = {
48
- /**
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.
56
- */
57
- enabled: boolean;
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
-
83
- getSamplePrompts: (entityName: string, input?: string) => Promise<SamplePromptsResult>;
84
-
85
- editorAIController?: EditorAIController;
86
- };
87
-
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
- };
100
-
101
- export type SamplePrompt = {
102
- prompt: string;
103
- type: "recent" | "sample";
104
- };
105
-
106
- export type SamplePromptsResult = {
107
- prompts: SamplePrompt[];
108
- };
109
-
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 = {
120
- entityName: string;
121
- entityDescription?: string;
122
- values: Record<string, unknown>;
123
- properties: Record<string, InputProperty>;
124
- propertyKey?: string;
125
- propertyInstructions?: string;
126
- instructions?: string;
127
- };
128
-
129
- export type InputProperty = {
130
- name?: string;
131
- description?: string;
132
- type: string;
133
- fieldConfigId: string;
134
- enum?: string[];
135
- disabled?: boolean;
136
- of?: InputProperty;
137
- oneOf?: {
138
- properties: Record<string, InputProperty>;
139
- typeField?: string;
140
- valueField?: string;
141
- };
142
- };
@@ -1,68 +0,0 @@
1
- import React from "react";
2
-
3
- import { CollectionConfig, User } from "@rebasepro/types";
4
- import { RebasePlugin } from "@rebasepro/cms-types";
5
- import { DataEnhancementControllerProvider } from "./components/DataEnhancementControllerProvider";
6
- import { FormEnhanceAction } from "./components/FormEnhanceAction";
7
-
8
- export interface DataEnhancementPluginProps {
9
-
10
- /**
11
- * Use this function to determine if the data enhancement plugin should be enabled for a given path.
12
- * If this function is not provided, the plugin will be enabled for all paths.
13
- * If the function returns false, the plugin will be disabled for the given path.
14
- *
15
- * @param path
16
- * @param collection
17
- */
18
- getConfigForPath?: (props: {
19
- path: string,
20
- collection: CollectionConfig,
21
- user: User | null
22
- }) => boolean;
23
-
24
- /**
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.
34
- */
35
- endpoint?: string;
36
- }
37
-
38
- /**
39
- * Use this hook to initialise the data enhancement plugin.
40
- * This is likely the only hook you will need to use.
41
- * @param props
42
- */
43
- export function useDataEnhancementPlugin(props?: DataEnhancementPluginProps): RebasePlugin {
44
-
45
- const getConfigForPath = props?.getConfigForPath;
46
- const endpoint = props?.endpoint;
47
-
48
- return React.useMemo(() => ({
49
- key: "data_enhancement",
50
- slots: [
51
- {
52
- slot: "form.actions",
53
- Component: FormEnhanceAction,
54
- order: 40
55
- }
56
- ],
57
- providers: [
58
- {
59
- scope: "form" as const,
60
- Component: DataEnhancementControllerProvider as React.ComponentType<any>,
61
- props: {
62
- getConfigForPath,
63
- endpoint
64
- }
65
- }
66
- ]
67
- }), [getConfigForPath, endpoint]);
68
- }
@@ -1,168 +0,0 @@
1
- import { getFieldId } from "@rebasepro/cms";
2
- import { EnumValues, Properties, Property } from "@rebasepro/types";
3
- import { isPropertyBuilder } from "@rebasepro/common";
4
- import { InputProperty } from "../types/data_enhancement_controller";
5
- import { getValueInPath } from "@rebasepro/utils";
6
-
7
- export function getSimplifiedProperties<M extends Record<string, any>>(properties: Properties, values: M, path = ""): Record<string, InputProperty> {
8
- if (!properties) return {};
9
- return Object.entries(properties)
10
- .map(([key, property]) => {
11
- if (isPropertyBuilder(property)) return {};
12
- const fullKey = path ? `${path}.${key}` : key;
13
- const valueInPath = getValueInPath(values, fullKey);
14
- return getSimplifiedProperty(property, fullKey, valueInPath)
15
- })
16
- .reduce((a, b) => ({ ...a,
17
- ...b }), {});
18
- }
19
-
20
- function getSimpleProperty(property: Property): InputProperty {
21
- const fieldId = getFieldId(property);
22
- if (!fieldId) {
23
- console.error("No fieldId found for property", property);
24
- throw new Error("Field id not found");
25
- }
26
- return {
27
- name: property.name,
28
- description: property.description,
29
- type: property.type,
30
- fieldConfigId: fieldId,
31
- enum: "enum" in property && property.enum
32
- ? getSimpleEnumValues(property.enum)
33
- : undefined,
34
- disabled: Boolean(property.admin?.disabled || property.admin?.readOnly)
35
- };
36
- }
37
-
38
- function getSimplifiedProperty(property: Property, path: string, value?: unknown): Record<string, InputProperty> {
39
- if (isPropertyBuilder(property)) return {};
40
- if (property.type === "array") {
41
-
42
- if (property.of && !Array.isArray(property.of) && !isPropertyBuilder(property.of)) {
43
- const arrayParentProperty: InputProperty = {
44
- name: property.name,
45
- description: property.description,
46
- type: property.type,
47
- fieldConfigId: "repeat",
48
- disabled: Boolean(property.admin?.disabled || property.admin?.readOnly),
49
- of: getSimpleProperty(property.of as Property)
50
- };
51
-
52
- const result = { [path]: arrayParentProperty };
53
- // if (Array.isArray(value)) {
54
- // result = {
55
- // ...result,
56
- // ...value
57
- // .map((v, i) => getSimplifiedProperty(property.of, `${path}.${i}`, v))
58
- // .reduce((a, b) => ({ ...a, ...b }), {})
59
- // };
60
- // }
61
- //
62
- // const existingValuesCount = Array.isArray(value) ? value.length : 0;
63
- //
64
- // const newValuesCount = property.of && !isPropertyBuilder<any, any>(property.of) && (property.of as Property).type === "map" ? 1 : 3;
65
- // result = {
66
- // ...result,
67
- // // ...Array.from(Array(newValuesCount))
68
- // // .map((v, i) => getSimplifiedProperty(property.of, `${path}.${i + existingValuesCount}`, v))
69
- // // .reduce((a, b) => ({ ...a, ...b }), {})
70
- // }
71
-
72
- return result;
73
- } else if (property.oneOf) {
74
-
75
- const arrayParentProperty: InputProperty = {
76
- name: property.name,
77
- description: property.description,
78
- type: property.type,
79
- fieldConfigId: "block",
80
- disabled: Boolean(property.admin?.disabled || property.admin?.readOnly),
81
- oneOf: {
82
- typeField: property.oneOf.typeField,
83
- valueField: property.oneOf.valueField,
84
- properties: Object.entries(property.oneOf.properties)
85
- .map(([key, prop]) => ({ [key]: getSimpleProperty(prop) }))
86
- .reduce((a, b) => ({ ...a,
87
- ...b }), {})
88
- }
89
- };
90
-
91
- if (!Array.isArray(value)) {
92
- return { [path]: arrayParentProperty };
93
- }
94
-
95
- return value.map((v, i) => {
96
- if (v == null) return {};
97
- const typeKey = property.oneOf!.typeField ?? "type";
98
- const oneOfType = v[typeKey];
99
- const valueKey = property.oneOf!.valueField ?? "value";
100
- const oneOfValue = v[valueKey];
101
- const childProperty = property.oneOf!.properties[oneOfType];
102
- if (childProperty === undefined) {
103
- console.error(`No property found for type ${oneOfType}`, property.oneOf!.properties);
104
- return {};
105
- }
106
- const simplifiedProperty = getSimplifiedProperty(childProperty, `${path}.${i}.${valueKey}`, oneOfValue);
107
- return {
108
- [`${path}.${i}.${typeKey}`]: oneOfType,
109
- ...simplifiedProperty
110
- };
111
- }).reduce((a, b) => ({ ...a,
112
- ...b }), { [path]: arrayParentProperty });
113
- }
114
- } else if (property.type === "map") {
115
- if (property.properties) {
116
- const mapProperties: Record<string, InputProperty> = Object.entries(property.properties)
117
- .map(([key, childProperty]) => {
118
- const childValue = value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
119
- return getSimplifiedProperty(childProperty, key, childValue);
120
- })
121
- .map(o => attachPathToKeys(o, path))
122
- .reduce((a, b) => ({ ...a,
123
- ...b }), {});
124
-
125
- if (Object.keys(mapProperties).length === 0) return {};
126
- const mapParentProperty: InputProperty = {
127
- name: property.name,
128
- description: property.description,
129
- type: property.type,
130
- fieldConfigId: "group",
131
- disabled: Boolean(property.admin?.disabled || property.admin?.readOnly)
132
- };
133
- return {
134
- [path]: mapParentProperty,
135
- ...mapProperties
136
- } as Record<string, InputProperty>;
137
- }
138
- } else {
139
- const fieldId = getFieldId(property);
140
- if (!fieldId) {
141
- console.warn(`No fieldId found for property ${path} with type ${property.type}`);
142
- return {};
143
- }
144
- return {
145
- [path]: getSimpleProperty(property)
146
- };
147
- }
148
- return {};
149
- }
150
-
151
- // attach a path to every key in an object
152
- function attachPathToKeys(obj: Record<string, InputProperty>, path = ""): Record<string, InputProperty> {
153
- return Object.entries(obj)
154
- .map(([key, value]) => {
155
- const fullKey = path ? `${path}.${key}` : key;
156
- return { [fullKey]: value };
157
- })
158
- .reduce((a, b) => ({ ...a,
159
- ...b }), {});
160
- }
161
-
162
- function getSimpleEnumValues(enumValues: EnumValues): string[] {
163
- if (Array.isArray(enumValues))
164
- return enumValues.map(v => String(v.id));
165
- if (typeof enumValues === "object")
166
- return Object.keys(enumValues);
167
- throw Error("getSimpleEnumValues: Invalid enumValues");
168
- }
@@ -1,72 +0,0 @@
1
- import { InputProperty } from "../types/data_enhancement_controller";
2
-
3
- /**
4
- * Flatten a record onto the dotted paths the property map uses.
5
- *
6
- * The two halves of an autofill request have to be keyed the same way: the
7
- * service decides a field is empty by looking up `values[key]` for every `key`
8
- * in `properties`, so a value filed under a key the property map has never
9
- * heard of is a value the service cannot see.
10
- *
11
- * Only plain objects are containers. This used to recurse into anything
12
- * `typeof value === "object"`, which is both an array and a `Date` — so
13
- * `tags: ["a", "b"]` was sent as `tags.0`/`tags.1` while the property map still
14
- * called it `tags`, and a `Date` disappeared entirely (`Object.entries(date)` is
15
- * `[]`). Both then read as empty on the far side and came back in the review
16
- * pre-ticked to replace a value the record already had. `getSimplifiedProperties`
17
- * names an array by its own path and never descends into one, so neither does
18
- * this.
19
- */
20
- export function flatMapEntityValues<M extends object>(values: M, path = ""): Record<string, unknown> {
21
- if (!values) return {};
22
- return Object.entries(values).flatMap(([key, value]) => {
23
- const currentPath = path ? `${path}.${key}` : key;
24
- if (isPlainObject(value)) {
25
- return flatMapEntityValues(value, currentPath);
26
- } else {
27
- return { [currentPath]: value };
28
- }
29
- }).reduce((acc, curr) => ({ ...acc,
30
- ...curr }), {});
31
- }
32
-
33
- /**
34
- * A container, as opposed to a leaf value.
35
- *
36
- * Arrays, dates, files and every other class instance are values in their own
37
- * right — a map property is the only thing whose children are separate fields.
38
- */
39
- function isPlainObject(value: unknown): value is Record<string, unknown> {
40
- if (value === null || typeof value !== "object") return false;
41
- const proto = Object.getPrototypeOf(value);
42
- return proto === Object.prototype || proto === null;
43
- }
44
-
45
- /**
46
- * Drop the values of properties the panel will not let anyone edit.
47
- *
48
- * A `readOnly` or `disabled` property is already excluded from what the service
49
- * may fill, but the values map was built from the whole record, and the prompt
50
- * includes every value it is given as context. So a field marked read-only
51
- * because a backend hook owns it — an internal note, a customer id — was still
52
- * being transmitted and pasted into the prompt. The collection config lives
53
- * here, so this is the honest place to decide it: disabled means neither
54
- * fillable nor context.
55
- *
56
- * Prefixes match too: a disabled map takes its children with it.
57
- */
58
- export function omitDisabledValues(
59
- values: Record<string, unknown>,
60
- properties: Record<string, InputProperty>
61
- ): Record<string, unknown> {
62
- const disabled = Object.entries(properties ?? {})
63
- // A property map can carry a non-object at a path (see the `oneOf`
64
- // branch of `getSimplifiedProperty`), and `"disabled" in aString` throws.
65
- .filter(([, property]) => property && typeof property === "object" && property.disabled)
66
- .map(([key]) => key);
67
- if (disabled.length === 0) return values;
68
- return Object.fromEntries(
69
- Object.entries(values).filter(([key]) =>
70
- !disabled.some((prefix) => key === prefix || key.startsWith(`${prefix}.`)))
71
- );
72
- }
package/src/vite-env.d.ts DELETED
@@ -1 +0,0 @@
1
- /// <reference types="vite/client" />