@rebasepro/plugin-ai 0.13.0 → 0.13.1-canary.g06dbe5b
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 +6 -2
- package/dist/api.d.ts +19 -0
- package/dist/components/DataEnhancementControllerProvider.d.ts +8 -1
- package/dist/index.es.js +114 -26
- package/dist/index.es.js.map +1 -1
- package/dist/utils/values.d.ts +33 -1
- package/package.json +8 -8
- package/src/api.ts +56 -1
- package/src/components/DataEnhancementControllerProvider.tsx +50 -14
- package/src/components/FormEnhanceAction.tsx +8 -5
- package/src/tests/api.test.ts +101 -2
- package/src/tests/request_agreement.test.ts +240 -0
- package/src/tests/review.test.tsx +211 -8
- package/src/utils/values.ts +63 -3
package/README.md
CHANGED
|
@@ -46,13 +46,17 @@ Two consequences worth knowing:
|
|
|
46
46
|
for it. Cost is bounded by rate limits and a daily ceiling instead.
|
|
47
47
|
- **Your collection schema and the record's current values are sent** with each
|
|
48
48
|
autofill request, because the service has no other way to know the shape of what
|
|
49
|
-
it is filling.
|
|
49
|
+
it is filling. Values of properties marked `admin: { readOnly: true }` or
|
|
50
|
+
`admin: { disabled: true }` are the exception — they are neither filled nor sent.
|
|
51
|
+
If the rest is not acceptable for your data, set `endpoint` and run the
|
|
50
52
|
service yourself — the reference implementation is `saas/backend/functions/ai.ts`
|
|
51
53
|
in the Rebase repository, and the wire format is documented in `src/api.ts`.
|
|
52
54
|
|
|
53
55
|
The plugin renders nothing until the service's `GET /status` reports itself
|
|
54
56
|
available, so an unreachable host or an exhausted daily quota means no Autofill
|
|
55
|
-
button — never a button that fails when clicked.
|
|
57
|
+
button — never a button that fails when clicked. That check is one request per
|
|
58
|
+
session, not one per record opened; return `false` from `getConfigForPath` to
|
|
59
|
+
keep a collection from being asked about at all.
|
|
56
60
|
|
|
57
61
|
## Key Exports
|
|
58
62
|
|
package/dist/api.d.ts
CHANGED
|
@@ -21,6 +21,25 @@ export declare function fetchAiStatus(props: {
|
|
|
21
21
|
endpoint?: string;
|
|
22
22
|
signal?: AbortSignal;
|
|
23
23
|
}): Promise<AiStatus>;
|
|
24
|
+
/**
|
|
25
|
+
* {@link fetchAiStatus}, asked once per endpoint per session.
|
|
26
|
+
*
|
|
27
|
+
* The provider is form-scoped, so the uncached call meant one request to the
|
|
28
|
+
* host every time any record was opened — a beacon on an install that may never
|
|
29
|
+
* click Autofill, and enough traffic from one NAT'd office to spend the host's
|
|
30
|
+
* per-IP rate limit on nothing, which reads back as `available: false` and makes
|
|
31
|
+
* the button flicker in and out for everyone behind it.
|
|
32
|
+
*
|
|
33
|
+
* Availability changes on the order of a deploy or a daily quota reset, not of a
|
|
34
|
+
* form open, so a session-long answer is the right resolution. Failures resolve
|
|
35
|
+
* to `available: false` and are cached like any other answer — retrying per form
|
|
36
|
+
* open is the behaviour this replaces.
|
|
37
|
+
*/
|
|
38
|
+
export declare function fetchAiStatusCached(props: {
|
|
39
|
+
endpoint?: string;
|
|
40
|
+
}): Promise<AiStatus>;
|
|
41
|
+
/** Forget every cached probe, so the next caller asks again. */
|
|
42
|
+
export declare function clearAiStatusCache(): void;
|
|
24
43
|
/**
|
|
25
44
|
* Fill a record, streaming each field as the service writes it.
|
|
26
45
|
*
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import React, { PropsWithChildren } from "react";
|
|
2
2
|
import { DataEnhancementController } from "../types/data_enhancement_controller";
|
|
3
|
-
import { CollectionConfig } from "@rebasepro/types";
|
|
3
|
+
import { CollectionConfig, User } from "@rebasepro/types";
|
|
4
4
|
import { PluginFormActionProps } from "@rebasepro/admin-types";
|
|
5
5
|
type DataEnhancementControllerProviderProps = {
|
|
6
|
+
/**
|
|
7
|
+
* Kept in step with `DataEnhancementPluginProps.getConfigForPath`, which is
|
|
8
|
+
* the signature the host app actually writes against: the plugin hands this
|
|
9
|
+
* component through as `ComponentType<any>`, so nothing but agreement here
|
|
10
|
+
* makes the two match.
|
|
11
|
+
*/
|
|
6
12
|
getConfigForPath?: (props: {
|
|
7
13
|
path: string;
|
|
8
14
|
collection: CollectionConfig;
|
|
15
|
+
user: User | null;
|
|
9
16
|
}) => boolean;
|
|
10
17
|
endpoint?: string;
|
|
11
18
|
};
|
package/dist/index.es.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { AIIcon, useAuthController } from "@rebasepro/app";
|
|
2
3
|
import { getFieldId } from "@rebasepro/admin";
|
|
3
4
|
import { isPropertyBuilder, stripCollectionPath } from "@rebasepro/common";
|
|
4
5
|
import { getValueInPath } from "@rebasepro/utils";
|
|
5
6
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
6
7
|
import { Button, Checkbox, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, SendIcon, Separator, TextareaAutosize, Typography, XIcon, cls, fieldBackgroundMixin, focusedDisabled, iconSize } from "@rebasepro/ui";
|
|
7
|
-
import { AIIcon } from "@rebasepro/app";
|
|
8
8
|
/**
|
|
9
9
|
* ## No credentials cross this boundary
|
|
10
10
|
*
|
|
@@ -98,6 +98,30 @@ async function fetchAiStatus(props) {
|
|
|
98
98
|
features: Array.isArray(body?.features) ? body.features : void 0
|
|
99
99
|
};
|
|
100
100
|
}
|
|
101
|
+
/** One in-flight or settled probe per endpoint, for the life of the page. */
|
|
102
|
+
var statusProbes = /* @__PURE__ */ new Map();
|
|
103
|
+
/**
|
|
104
|
+
* {@link fetchAiStatus}, asked once per endpoint per session.
|
|
105
|
+
*
|
|
106
|
+
* The provider is form-scoped, so the uncached call meant one request to the
|
|
107
|
+
* host every time any record was opened — a beacon on an install that may never
|
|
108
|
+
* click Autofill, and enough traffic from one NAT'd office to spend the host's
|
|
109
|
+
* per-IP rate limit on nothing, which reads back as `available: false` and makes
|
|
110
|
+
* the button flicker in and out for everyone behind it.
|
|
111
|
+
*
|
|
112
|
+
* Availability changes on the order of a deploy or a daily quota reset, not of a
|
|
113
|
+
* form open, so a session-long answer is the right resolution. Failures resolve
|
|
114
|
+
* to `available: false` and are cached like any other answer — retrying per form
|
|
115
|
+
* open is the behaviour this replaces.
|
|
116
|
+
*/
|
|
117
|
+
function fetchAiStatusCached(props) {
|
|
118
|
+
const key = endpointOf(props.endpoint, "/status");
|
|
119
|
+
const existing = statusProbes.get(key);
|
|
120
|
+
if (existing) return existing;
|
|
121
|
+
const probe = fetchAiStatus({ endpoint: props.endpoint }).catch(() => ({ available: false }));
|
|
122
|
+
statusProbes.set(key, probe);
|
|
123
|
+
return probe;
|
|
124
|
+
}
|
|
101
125
|
/**
|
|
102
126
|
* Fill a record, streaming each field as the service writes it.
|
|
103
127
|
*
|
|
@@ -116,21 +140,33 @@ async function autofillStream(props) {
|
|
|
116
140
|
});
|
|
117
141
|
if (!response.ok) throw await errorFrom(response, "The AI service could not complete this request.");
|
|
118
142
|
let result = { suggestions: {} };
|
|
143
|
+
let done = false;
|
|
144
|
+
let delivered = 0;
|
|
145
|
+
let discarded = 0;
|
|
119
146
|
for await (const { event, data } of readServerSentEvents(response)) {
|
|
120
147
|
let payload;
|
|
121
148
|
try {
|
|
122
149
|
payload = JSON.parse(data);
|
|
123
150
|
} catch {
|
|
151
|
+
discarded++;
|
|
124
152
|
continue;
|
|
125
153
|
}
|
|
126
|
-
if (event === "suggestion_delta")
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
else if (event === "
|
|
154
|
+
if (event === "suggestion_delta") {
|
|
155
|
+
delivered++;
|
|
156
|
+
props.onDelta(payload.key, payload.text);
|
|
157
|
+
} else if (event === "suggestion") {
|
|
158
|
+
delivered++;
|
|
159
|
+
props.onValue(payload.key, payload.value);
|
|
160
|
+
} else if (event === "done") {
|
|
161
|
+
done = true;
|
|
162
|
+
result = {
|
|
163
|
+
suggestions: payload.suggestions ?? {},
|
|
164
|
+
usage: payload.usage
|
|
165
|
+
};
|
|
166
|
+
} else if (event === "error") throw new Error(payload.message ?? "The AI service reported an error.");
|
|
133
167
|
}
|
|
168
|
+
if (!done) throw new Error("The connection to the AI service ended before it finished.");
|
|
169
|
+
if (discarded > 0 && delivered === 0) throw new Error("The AI service's response could not be read.");
|
|
134
170
|
return result;
|
|
135
171
|
}
|
|
136
172
|
/** Inline continuation for the rich-text editor. Streams plain text. */
|
|
@@ -314,17 +350,63 @@ function getSimpleEnumValues(enumValues) {
|
|
|
314
350
|
}
|
|
315
351
|
//#endregion
|
|
316
352
|
//#region src/utils/values.ts
|
|
353
|
+
/**
|
|
354
|
+
* Flatten a record onto the dotted paths the property map uses.
|
|
355
|
+
*
|
|
356
|
+
* The two halves of an autofill request have to be keyed the same way: the
|
|
357
|
+
* service decides a field is empty by looking up `values[key]` for every `key`
|
|
358
|
+
* in `properties`, so a value filed under a key the property map has never
|
|
359
|
+
* heard of is a value the service cannot see.
|
|
360
|
+
*
|
|
361
|
+
* Only plain objects are containers. This used to recurse into anything
|
|
362
|
+
* `typeof value === "object"`, which is both an array and a `Date` — so
|
|
363
|
+
* `tags: ["a", "b"]` was sent as `tags.0`/`tags.1` while the property map still
|
|
364
|
+
* called it `tags`, and a `Date` disappeared entirely (`Object.entries(date)` is
|
|
365
|
+
* `[]`). Both then read as empty on the far side and came back in the review
|
|
366
|
+
* pre-ticked to replace a value the record already had. `getSimplifiedProperties`
|
|
367
|
+
* names an array by its own path and never descends into one, so neither does
|
|
368
|
+
* this.
|
|
369
|
+
*/
|
|
317
370
|
function flatMapEntityValues(values, path = "") {
|
|
318
371
|
if (!values) return {};
|
|
319
372
|
return Object.entries(values).flatMap(([key, value]) => {
|
|
320
373
|
const currentPath = path ? `${path}.${key}` : key;
|
|
321
|
-
if (
|
|
374
|
+
if (isPlainObject(value)) return flatMapEntityValues(value, currentPath);
|
|
322
375
|
else return { [currentPath]: value };
|
|
323
376
|
}).reduce((acc, curr) => ({
|
|
324
377
|
...acc,
|
|
325
378
|
...curr
|
|
326
379
|
}), {});
|
|
327
380
|
}
|
|
381
|
+
/**
|
|
382
|
+
* A container, as opposed to a leaf value.
|
|
383
|
+
*
|
|
384
|
+
* Arrays, dates, files and every other class instance are values in their own
|
|
385
|
+
* right — a map property is the only thing whose children are separate fields.
|
|
386
|
+
*/
|
|
387
|
+
function isPlainObject(value) {
|
|
388
|
+
if (value === null || typeof value !== "object") return false;
|
|
389
|
+
const proto = Object.getPrototypeOf(value);
|
|
390
|
+
return proto === Object.prototype || proto === null;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Drop the values of properties the panel will not let anyone edit.
|
|
394
|
+
*
|
|
395
|
+
* A `readOnly` or `disabled` property is already excluded from what the service
|
|
396
|
+
* may fill, but the values map was built from the whole record, and the prompt
|
|
397
|
+
* includes every value it is given as context. So a field marked read-only
|
|
398
|
+
* because a backend hook owns it — an internal note, a customer id — was still
|
|
399
|
+
* being transmitted and pasted into the prompt. The collection config lives
|
|
400
|
+
* here, so this is the honest place to decide it: disabled means neither
|
|
401
|
+
* fillable nor context.
|
|
402
|
+
*
|
|
403
|
+
* Prefixes match too: a disabled map takes its children with it.
|
|
404
|
+
*/
|
|
405
|
+
function omitDisabledValues(values, properties) {
|
|
406
|
+
const disabled = Object.entries(properties ?? {}).filter(([, property]) => property && typeof property === "object" && property.disabled).map(([key]) => key);
|
|
407
|
+
if (disabled.length === 0) return values;
|
|
408
|
+
return Object.fromEntries(Object.entries(values).filter(([key]) => !disabled.some((prefix) => key === prefix || key.startsWith(`${prefix}.`))));
|
|
409
|
+
}
|
|
328
410
|
//#endregion
|
|
329
411
|
//#region src/editor/useEditorAIController.tsx
|
|
330
412
|
/**
|
|
@@ -385,7 +467,7 @@ function DataEnhancementControllerProvider({ getConfigForPath, children, endpoin
|
|
|
385
467
|
*/
|
|
386
468
|
const propertiesRef = useRef(properties);
|
|
387
469
|
propertiesRef.current = properties;
|
|
388
|
-
|
|
470
|
+
const user = useAuthController()?.user ?? null;
|
|
389
471
|
useEffect(() => {
|
|
390
472
|
if (!getConfigForPath) {
|
|
391
473
|
setAllowedHere(true);
|
|
@@ -393,12 +475,14 @@ function DataEnhancementControllerProvider({ getConfigForPath, children, endpoin
|
|
|
393
475
|
}
|
|
394
476
|
setAllowedHere(Boolean(getConfigForPath({
|
|
395
477
|
path,
|
|
396
|
-
collection
|
|
478
|
+
collection,
|
|
479
|
+
user
|
|
397
480
|
})));
|
|
398
481
|
}, [
|
|
399
482
|
getConfigForPath,
|
|
400
483
|
path,
|
|
401
|
-
collection
|
|
484
|
+
collection,
|
|
485
|
+
user
|
|
402
486
|
]);
|
|
403
487
|
/**
|
|
404
488
|
* The service's own availability.
|
|
@@ -407,15 +491,22 @@ function DataEnhancementControllerProvider({ getConfigForPath, children, endpoin
|
|
|
407
491
|
* unconfigured provider key or an exhausted daily quota all land here, and
|
|
408
492
|
* all of them mean the same thing to the operator: no Autofill button,
|
|
409
493
|
* rather than a button that fails when clicked.
|
|
494
|
+
*
|
|
495
|
+
* Asked through the session cache: this provider is form-scoped, so an
|
|
496
|
+
* uncached probe is one request to the host per record opened, by an install
|
|
497
|
+
* that may never use the feature. The probe is shared rather than aborted on
|
|
498
|
+
* unmount — cancelling it would cancel it for whatever else is waiting on the
|
|
499
|
+
* same answer — so unmounting only stops this component from reading it.
|
|
410
500
|
*/
|
|
411
501
|
useEffect(() => {
|
|
412
502
|
if (!allowedHere) return;
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
503
|
+
let cancelled = false;
|
|
504
|
+
fetchAiStatusCached({ endpoint }).then((status) => {
|
|
505
|
+
if (!cancelled) setServiceAvailable(status.available);
|
|
506
|
+
});
|
|
507
|
+
return () => {
|
|
508
|
+
cancelled = true;
|
|
509
|
+
};
|
|
419
510
|
}, [allowedHere, endpoint]);
|
|
420
511
|
const enabled = allowedHere && serviceAvailable;
|
|
421
512
|
/** Add or update one row in the review, preserving arrival order. */
|
|
@@ -433,7 +524,7 @@ function DataEnhancementControllerProvider({ getConfigForPath, children, endpoin
|
|
|
433
524
|
}, []);
|
|
434
525
|
const generate = useCallback(async (params) => {
|
|
435
526
|
const currentProperties = propertiesRef.current;
|
|
436
|
-
const flatValues = flatMapEntityValues(params.values ?? {});
|
|
527
|
+
const flatValues = omitDisabledValues(flatMapEntityValues(params.values ?? {}), currentProperties);
|
|
437
528
|
setReview({
|
|
438
529
|
status: "generating",
|
|
439
530
|
fields: [],
|
|
@@ -807,16 +898,13 @@ function FormEnhanceAction({ path, status, collection, formContext }) {
|
|
|
807
898
|
align: "end",
|
|
808
899
|
sideOffset: 8,
|
|
809
900
|
className: "max-w-[100vw]",
|
|
810
|
-
trigger: /* @__PURE__ */ jsxs(
|
|
901
|
+
trigger: /* @__PURE__ */ jsxs(IconButton, {
|
|
811
902
|
variant: "filled",
|
|
812
|
-
color: "neutral",
|
|
813
903
|
size: "small",
|
|
904
|
+
"aria-label": "Autofill",
|
|
905
|
+
title: "Autofill",
|
|
814
906
|
disabled: loading,
|
|
815
|
-
children: [
|
|
816
|
-
!loading && /* @__PURE__ */ jsx(AIIcon, { size: "small" }),
|
|
817
|
-
loading && /* @__PURE__ */ jsx(CircularProgress, { size: "small" }),
|
|
818
|
-
"Autofill"
|
|
819
|
-
]
|
|
907
|
+
children: [!loading && /* @__PURE__ */ jsx(AIIcon, { size: "small" }), loading && /* @__PURE__ */ jsx(CircularProgress, { size: "small" })]
|
|
820
908
|
}),
|
|
821
909
|
children: [
|
|
822
910
|
/* @__PURE__ */ jsxs(MenuItem, {
|