@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 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. If that is not acceptable for your data, set `endpoint` and run the
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") props.onDelta(payload.key, payload.text);
127
- else if (event === "suggestion") props.onValue(payload.key, payload.value);
128
- else if (event === "done") result = {
129
- suggestions: payload.suggestions ?? {},
130
- usage: payload.usage
131
- };
132
- else if (event === "error") throw new Error(payload.message ?? "The AI service reported an error.");
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 (typeof value === "object") return flatMapEntityValues(value, currentPath);
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
- /** The host app's own opt-out. */
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
- const abort = new AbortController();
414
- fetchAiStatus({
415
- endpoint,
416
- signal: abort.signal
417
- }).then((status) => setServiceAvailable(status.available)).catch(() => setServiceAvailable(false));
418
- return () => abort.abort();
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(Button, {
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, {