@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.
package/src/api.ts CHANGED
@@ -1,198 +1,262 @@
1
1
  import {
2
- DataEnhancementRequest,
3
- EnhancedDataResult,
4
- InputEntity,
5
- InputProperty,
2
+ AutofillRequest,
3
+ AutofillResult,
4
+ AiStatus,
6
5
  SamplePromptsResult
7
6
  } from "./types/data_enhancement_controller";
8
- import { EntityValues } from "@rebasepro/types";
9
- import { flatMapEntityValues } from "./utils/values";
10
-
11
- // const DEFAULT_SERVER = "http://localhost:5001/rebase-dev-2da42/europe-west3/api"; // Local
12
-
13
- const DEFAULT_SERVER = "https://api.rebase.pro";
14
-
15
- export async function enhanceDataAPIStream<M extends Record<string, unknown>>(props: {
16
- apiKey: string,
17
- entityId?: string | number,
18
- entityName: string,
19
- entityDescription?: string,
20
- propertyKey?: string,
21
- propertyInstructions?: string;
22
- values: EntityValues<M>,
23
- path: string,
24
- properties: Record<string, InputProperty>,
25
-
26
- instructions?: string,
27
- firebaseToken: string,
28
- onUpdate: (suggestions: Record<string, string | number>) => void;
29
- onUpdateDelta: (propertyKey: string, partialValue: string) => void;
30
- onError: (error: Error) => void;
31
- onEnd: (result: EnhancedDataResult) => void;
32
- host?: string;
33
- }) {
34
-
35
- const flatValues = flatMapEntityValues(props.values);
36
-
37
- const properties = props.properties;
38
-
39
- const inputEntity: InputEntity = {
40
- entityId: props.entityId,
41
- values: flatValues
42
- }
43
7
 
44
- const request: DataEnhancementRequest = {
45
- inputEntity,
46
- properties,
47
- entityName: props.entityName,
48
- entityDescription: props.entityDescription,
49
- propertyKey: props.propertyKey,
50
- propertyInstructions: props.propertyInstructions,
51
- instructions: props.instructions
52
- };
8
+ /**
9
+ * The hosted service Rebase runs for this plugin.
10
+ *
11
+ * The previous value here was `https://api.rebase.pro`, a FireCMS-era host that
12
+ * resolves but serves nothing — every path 404s — so Autofill had never worked
13
+ * in a Rebase install. This one is served by the control plane
14
+ * (`saas/backend/functions/ai.ts`). Point `endpoint` somewhere else to run your
15
+ * own; the wire format below is the whole contract.
16
+ */
17
+ export const DEFAULT_AI_ENDPOINT = "https://app.rebase.pro/api/functions/ai";
18
+
19
+ /**
20
+ * ## No credentials cross this boundary
21
+ *
22
+ * The old client sent the tenant's Rebase JWT as `Authorization: Basic <jwt>`
23
+ * plus a hardcoded `fcms-…` key compiled into the published package. Both were
24
+ * wrong in the same way: a self-hosted backend signs its tokens with its own
25
+ * secret, so no external service can verify one — sending it only handed a live
26
+ * credential to a third party that had no use for it.
27
+ *
28
+ * These requests are anonymous. The service bounds cost by rate limit and daily
29
+ * ceiling rather than by identity, and reports through {@link fetchAiStatus}
30
+ * when it can no longer serve — which is what keeps the UI from offering an
31
+ * action that is going to fail.
32
+ */
33
+ function endpointOf(endpoint: string | undefined, path: string): string {
34
+ return (endpoint ?? DEFAULT_AI_ENDPOINT).replace(/\/+$/, "") + path;
35
+ }
53
36
 
54
- console.debug("enhanceDataAPIStream", request);
37
+ /** One `event:`/`data:` pair off the wire. */
38
+ type ServerSentEvent = { event: string; data: string };
55
39
 
56
- return fetch((props.host ?? DEFAULT_SERVER) + "/data/enhance_stream/",
57
- {
58
- // mode: "no-cors",
59
- method: "POST",
60
- headers: {
61
- "Content-Type": "application/json",
62
- Authorization: `Basic ${props.firebaseToken}`,
63
- "x-de-api-key": `Basic ${props.apiKey}`
64
- // "x-de-version": version
65
- },
66
- body: JSON.stringify(request)
67
- })
68
- .then(async (res) => {
69
- if (!res.ok) {
70
- console.error("enhanceDataAPIStream error", res)
71
- throw await res.json();
72
- }
73
- const reader = res.body?.getReader();
74
- if (!reader) {
75
- throw new Error("No reader");
76
- }
77
-
78
- for await (const chunk of readChunks(reader)) {
79
- const str = new TextDecoder().decode(chunk);
80
- try {
81
- str.split("&$# ").forEach((s) => {
82
- if (s && s.length > 0) {
83
- const data = JSON.parse(s.trim());
84
- if (data.type === "suggestion_delta")
85
- props.onUpdateDelta(data.data.propertyKey, data.data.partialValue);
86
- else if (data.type === "suggestion")
87
- props.onUpdate(data.data);
88
- else if (data.type === "result")
89
- props.onEnd(data.data);
90
- }
91
- });
92
- } catch (e: unknown) {
93
- console.error("str", str);
94
- console.error("Error parsing stream", e);
95
- props.onError(e instanceof Error ? e : new Error(String(e)));
96
- }
97
- }
40
+ /** Not global: `exec` must not carry `lastIndex` between buffer reads. */
41
+ const SSE_SEPARATOR = /\r?\n\r?\n/;
98
42
 
99
- });
43
+ /**
44
+ * Parse an SSE body incrementally.
45
+ *
46
+ * The framing this replaces split each chunk on the literal `"&$# "` and
47
+ * `JSON.parse`d the pieces, which corrupted itself the moment a delimiter
48
+ * straddled two reads — and network reads land wherever they land. Buffering
49
+ * until a blank line is the fix, and it is also just what SSE specifies.
50
+ */
51
+ async function* readServerSentEvents(response: Response): AsyncGenerator<ServerSentEvent> {
52
+ const reader = response.body?.getReader();
53
+ if (!reader) throw new Error("The AI service returned no response body");
100
54
 
55
+ const decoder = new TextDecoder();
56
+ let buffer = "";
57
+
58
+ for (;;) {
59
+ const { done, value } = await reader.read();
60
+ if (done) break;
61
+ buffer += decoder.decode(value, { stream: true });
62
+
63
+ // A record ends at a blank line. `\r\n` is tolerated because proxies
64
+ // rewrite line endings. The separator is located with `exec` rather
65
+ // than `search` so its actual length is known — a `\r\n\r\n` boundary
66
+ // is four characters, not two, and slicing by the wrong count leaves a
67
+ // stray newline that swallows the next record's `event:` field.
68
+ let match = SSE_SEPARATOR.exec(buffer);
69
+ while (match) {
70
+ const raw = buffer.slice(0, match.index);
71
+ buffer = buffer.slice(match.index + match[0].length);
72
+ const parsed = parseEventBlock(raw);
73
+ if (parsed) yield parsed;
74
+ match = SSE_SEPARATOR.exec(buffer);
75
+ }
76
+ }
101
77
  }
102
78
 
103
- function readChunks(reader: ReadableStreamDefaultReader) {
79
+ function parseEventBlock(block: string): ServerSentEvent | undefined {
80
+ let event = "message";
81
+ const dataLines: string[] = [];
82
+ for (const line of block.split(/\r?\n/)) {
83
+ if (line.startsWith(":")) continue; // comment / keep-alive
84
+ const separator = line.indexOf(":");
85
+ const field = separator === -1 ? line : line.slice(0, separator);
86
+ const rawValue = separator === -1 ? "" : line.slice(separator + 1);
87
+ const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue;
88
+ if (field === "event") event = value;
89
+ else if (field === "data") dataLines.push(value);
90
+ }
91
+ if (dataLines.length === 0) return undefined;
92
+ return { event,
93
+ data: dataLines.join("\n") };
94
+ }
95
+
96
+ /** Pull a message out of the control plane's `{ error: { message } }` envelope. */
97
+ async function errorFrom(response: Response, fallback: string): Promise<Error> {
98
+ try {
99
+ const body = await response.json();
100
+ const message = body?.error?.message;
101
+ if (typeof message === "string" && message) return new Error(message);
102
+ } catch {
103
+ /* not JSON — fall through */
104
+ }
105
+ return new Error(fallback);
106
+ }
107
+
108
+ /**
109
+ * Ask the service whether it can serve a request at all.
110
+ *
111
+ * The plugin gates every affordance on this. A missing provider key, an
112
+ * exhausted daily quota or an unreachable host all resolve to `available:
113
+ * false`, and the Autofill button is simply not rendered — rather than
114
+ * rendered, clicked, and failed.
115
+ */
116
+ export async function fetchAiStatus(props: { endpoint?: string; signal?: AbortSignal }): Promise<AiStatus> {
117
+ const response = await fetch(endpointOf(props.endpoint, "/status"), {
118
+ method: "GET",
119
+ signal: props.signal
120
+ });
121
+ if (!response.ok) return { available: false };
122
+ const body = await response.json();
104
123
  return {
105
- async *[Symbol.asyncIterator]() {
106
- let readResult = await reader.read();
107
- while (!readResult.done) {
108
- yield readResult.value;
109
- readResult = await reader.read();
110
- }
111
- }
124
+ available: Boolean(body?.available),
125
+ model: typeof body?.model === "string" ? body.model : undefined,
126
+ features: Array.isArray(body?.features) ? body.features : undefined
112
127
  };
113
128
  }
114
129
 
115
- export async function fetchEntityPromptSuggestion<M extends object>(props: {
116
- input?: string,
117
- entityName: string,
118
- firebaseToken: string,
119
- apiKey: string,
120
- host?: string
121
- }): Promise<SamplePromptsResult> {
130
+ /**
131
+ * Fill a record, streaming each field as the service writes it.
132
+ *
133
+ * `onDelta` fires with more text for a field still being written; `onValue`
134
+ * fires once a field is complete and carries its final, correctly typed value.
135
+ * A caller that implements only `onValue` still ends up with the right record —
136
+ * the deltas exist so a long text field fills in visibly instead of appearing
137
+ * all at once.
138
+ */
139
+ export async function autofillStream(props: {
140
+ request: AutofillRequest;
141
+ endpoint?: string;
142
+ signal?: AbortSignal;
143
+ onDelta: (key: string, text: string) => void;
144
+ onValue: (key: string, value: unknown) => void;
145
+ }): Promise<AutofillResult> {
146
+ const response = await fetch(endpointOf(props.endpoint, "/autofill"), {
147
+ method: "POST",
148
+ headers: { "Content-Type": "application/json" },
149
+ body: JSON.stringify(props.request),
150
+ signal: props.signal
151
+ });
122
152
 
123
- return fetch((props.host ?? DEFAULT_SERVER) + "/data/prompt_autocomplete/",
124
- {
125
- // mode: "no-cors",
126
- method: "POST",
127
- headers: {
128
- "Content-Type": "application/json",
129
- Authorization: `Basic ${props.firebaseToken}`,
130
- "x-de-api-key": `Basic ${props.apiKey}`
131
- },
132
- body: JSON.stringify({
133
- entityName: props.entityName,
134
- input: props.input ?? null
135
- })
136
- })
137
- .then(async (res) => {
138
- const data = await res.json();
139
- if (!res.ok) {
140
- console.error("fetchEntityPromptSuggestion", data);
141
- throw Error(data.message);
142
- }
143
- return {
144
- prompts: data.data.prompts.map((e: string) => ({
145
- prompt: e,
146
- type: "sample"
147
- }))
153
+ if (!response.ok) {
154
+ throw await errorFrom(response, "The AI service could not complete this request.");
155
+ }
156
+
157
+ let result: AutofillResult = { suggestions: {} };
158
+
159
+ for await (const { event, data } of readServerSentEvents(response)) {
160
+ let payload: any;
161
+ try {
162
+ payload = JSON.parse(data);
163
+ } catch {
164
+ // One malformed record must not abort a stream that is otherwise
165
+ // delivering good fields.
166
+ continue;
167
+ }
168
+
169
+ if (event === "suggestion_delta") {
170
+ props.onDelta(payload.key, payload.text);
171
+ } else if (event === "suggestion") {
172
+ props.onValue(payload.key, payload.value);
173
+ } else if (event === "done") {
174
+ result = {
175
+ suggestions: payload.suggestions ?? {},
176
+ usage: payload.usage
148
177
  };
149
- });
178
+ } else if (event === "error") {
179
+ throw new Error(payload.message ?? "The AI service reported an error.");
180
+ }
181
+ }
150
182
 
183
+ return result;
151
184
  }
152
185
 
186
+ /** Inline continuation for the rich-text editor. Streams plain text. */
153
187
  export async function autocompleteStream(props: {
154
- firebaseToken: string,
155
- textBefore?: string,
156
- textAfter: string,
157
- host?: string;
158
- onUpdate: (delta: string) => void;
159
- }) {
160
-
161
- let result = "";
162
- return fetch((props.host ?? DEFAULT_SERVER) + "/data/autocomplete/",
163
- {
164
- // mode: "no-cors",
188
+ textBefore?: string;
189
+ textAfter?: string;
190
+ endpoint?: string;
191
+ signal?: AbortSignal;
192
+ onDelta: (text: string) => void;
193
+ }): Promise<string> {
194
+ const response = await fetch(endpointOf(props.endpoint, "/autocomplete"), {
195
+ method: "POST",
196
+ headers: { "Content-Type": "application/json" },
197
+ body: JSON.stringify({
198
+ textBefore: props.textBefore ?? "",
199
+ textAfter: props.textAfter ?? ""
200
+ }),
201
+ signal: props.signal
202
+ });
203
+
204
+ if (!response.ok) {
205
+ throw await errorFrom(response, "The AI service could not complete this request.");
206
+ }
207
+
208
+ let text = "";
209
+ for await (const { event, data } of readServerSentEvents(response)) {
210
+ let payload: any;
211
+ try {
212
+ payload = JSON.parse(data);
213
+ } catch {
214
+ continue;
215
+ }
216
+ if (event === "error") {
217
+ throw new Error(payload?.message ?? "The AI service reported an error.");
218
+ }
219
+ if (event === "delta" && typeof payload?.text === "string") {
220
+ text += payload.text;
221
+ props.onDelta(payload.text);
222
+ }
223
+ }
224
+ return text;
225
+ }
226
+
227
+ /**
228
+ * Sample prompts for the Autofill menu.
229
+ *
230
+ * Failure is deliberately not thrown: the menu has built-in prompts to fall
231
+ * back on, and an empty suggestion list is a far better outcome than an error
232
+ * toast for something nobody asked for.
233
+ */
234
+ export async function fetchPromptSuggestions(props: {
235
+ entityName: string;
236
+ input?: string;
237
+ endpoint?: string;
238
+ signal?: AbortSignal;
239
+ }): Promise<SamplePromptsResult> {
240
+ try {
241
+ const response = await fetch(endpointOf(props.endpoint, "/prompts"), {
165
242
  method: "POST",
166
- headers: {
167
- "Content-Type": "application/json",
168
- Authorization: `Basic ${props.firebaseToken}`
169
- // "x-de-version": version
170
- },
243
+ headers: { "Content-Type": "application/json" },
171
244
  body: JSON.stringify({
172
- textBefore: props.textBefore,
173
- textAfter: props.textAfter
174
- })
175
- })
176
- .then(async (res) => {
177
- if (!res.ok) {
178
- console.error("enhanceDataAPIStream error", res)
179
- throw await res.json();
180
- }
181
- const reader = res.body?.getReader();
182
- if (!reader) {
183
- throw new Error("No reader");
184
- }
185
-
186
- for await (const chunk of readChunks(reader)) {
187
- const str = new TextDecoder().decode(chunk);
188
- result += str;
189
- console.debug("Autocomplete update:", str);
190
- props.onUpdate(str);
191
- }
192
-
193
- }).then(() => {
194
- console.debug("Autocomplete result:", result);
195
- return result;
245
+ entityName: props.entityName,
246
+ input: props.input
247
+ }),
248
+ signal: props.signal
196
249
  });
197
-
250
+ if (!response.ok) return { prompts: [] };
251
+ const body = await response.json();
252
+ const prompts: string[] = Array.isArray(body?.prompts) ? body.prompts : [];
253
+ return {
254
+ prompts: prompts
255
+ .filter((p): p is string => typeof p === "string")
256
+ .map((prompt) => ({ prompt,
257
+ type: "sample" as const }))
258
+ };
259
+ } catch {
260
+ return { prompts: [] };
261
+ }
198
262
  }
@@ -0,0 +1,203 @@
1
+ import React from "react";
2
+
3
+ import {
4
+ Button,
5
+ Checkbox,
6
+ CircularProgress,
7
+ cls,
8
+ Dialog,
9
+ DialogActions,
10
+ DialogContent,
11
+ DialogTitle,
12
+ Separator,
13
+ Typography
14
+ } from "@rebasepro/ui";
15
+
16
+ import { ProposedField } from "../types/data_enhancement_controller";
17
+ import { useDataEnhancementController } from "./DataEnhancementControllerProvider";
18
+
19
+ /**
20
+ * The review step.
21
+ *
22
+ * Autofill used to write generated text into the live form as it streamed —
23
+ * fields mutating under the cursor, half-written sentences that looked like
24
+ * bugs, and a pile of heuristics deciding whether each token should append to
25
+ * or replace what the operator had already typed. Getting the old value back
26
+ * meant retyping it.
27
+ *
28
+ * So the generated values land here instead. Streaming still happens, and is
29
+ * still worth having — rows appear and fill in as the model works, so a long
30
+ * run shows progress — but it happens in a surface that owns nothing. The
31
+ * record changes on **Apply**, once, for the rows still ticked.
32
+ */
33
+ export function AutofillReviewDialog() {
34
+
35
+ const controller = useDataEnhancementController();
36
+ const review = controller?.review;
37
+
38
+ if (!review) return null;
39
+
40
+ const generating = review.status === "generating";
41
+ const applicable = review.fields.filter((f) => !f.pending && f.selected);
42
+ const allSelected = review.fields.length > 0 && review.fields.every((f) => f.selected);
43
+
44
+ return (
45
+ <Dialog
46
+ open={true}
47
+ maxWidth={"2xl"}
48
+ onOpenChange={(open) => {
49
+ if (!open) controller.dismissReview();
50
+ }}>
51
+
52
+ <DialogTitle variant={"subtitle1"} gutterBottom={false}>
53
+ Review autofill
54
+ </DialogTitle>
55
+
56
+ <DialogContent className={"flex flex-col gap-2"}>
57
+
58
+ {review.instructions && (
59
+ <Typography variant={"body2"} color={"secondary"} className={"italic"}>
60
+ “{review.instructions}”
61
+ </Typography>
62
+ )}
63
+
64
+ {review.fields.length > 1 && (
65
+ <>
66
+ <label className={"flex items-center gap-3 py-1 cursor-pointer select-none"}>
67
+ <Checkbox
68
+ checked={allSelected}
69
+ size={"small"}
70
+ onCheckedChange={() => controller.toggleAll(!allSelected)}
71
+ />
72
+ <Typography variant={"label"} color={"secondary"}>
73
+ {allSelected ? "Deselect all" : "Select all"}
74
+ </Typography>
75
+ </label>
76
+ <Separator orientation={"horizontal"} className={"my-0"}/>
77
+ </>
78
+ )}
79
+
80
+ <div className={"flex flex-col divide-y divide-surface-accent-100 dark:divide-surface-accent-800"}>
81
+ {review.fields.map((field) => (
82
+ <ProposedFieldRow
83
+ key={field.key}
84
+ field={field}
85
+ onToggle={() => controller.toggleField(field.key)}
86
+ />
87
+ ))}
88
+ </div>
89
+
90
+ {generating && (
91
+ <div className={"flex items-center gap-3 py-4 text-text-secondary dark:text-text-secondary-dark"}>
92
+ <CircularProgress size={"smallest"}/>
93
+ <Typography variant={"body2"} color={"secondary"}>
94
+ {review.fields.length === 0 ? "Thinking…" : "Writing the remaining fields…"}
95
+ </Typography>
96
+ </div>
97
+ )}
98
+
99
+ {review.status === "failed" && (
100
+ <Typography variant={"body2"} className={"py-2 text-red-600 dark:text-red-400"}>
101
+ {review.error}
102
+ {review.fields.length > 0 && " You can still apply what was written before it stopped."}
103
+ </Typography>
104
+ )}
105
+
106
+ {!generating && review.fields.length === 0 && review.status !== "failed" && (
107
+ <Typography variant={"body2"} color={"secondary"} className={"py-4"}>
108
+ Nothing to fill in — every field either already has a value the model would not
109
+ improve on, or is not one it can write.
110
+ </Typography>
111
+ )}
112
+
113
+ </DialogContent>
114
+
115
+ <DialogActions>
116
+ <Button variant={"text"}
117
+ color={"neutral"}
118
+ onClick={controller.dismissReview}>
119
+ {/* Named for what it does to the record, not to the dialog:
120
+ nothing has been written, so there is nothing to undo. */}
121
+ Discard
122
+ </Button>
123
+ <Button variant={"filled"}
124
+ disabled={applicable.length === 0}
125
+ onClick={controller.applyReview}>
126
+ {applicable.length === 1 ? "Apply 1 field" : `Apply ${applicable.length} fields`}
127
+ </Button>
128
+ </DialogActions>
129
+
130
+ </Dialog>
131
+ );
132
+ }
133
+
134
+ function ProposedFieldRow({ field, onToggle }: { field: ProposedField, onToggle: () => void }) {
135
+
136
+ const replaces = hasValue(field.currentValue) && !isSameValue(field.currentValue, field.proposed);
137
+
138
+ return (
139
+ <label className={cls(
140
+ "flex items-start gap-3 py-3 cursor-pointer",
141
+ !field.selected && "opacity-50"
142
+ )}>
143
+ <div className={"mt-0.5 shrink-0"}>
144
+ <Checkbox
145
+ checked={field.selected}
146
+ size={"small"}
147
+ onCheckedChange={onToggle}
148
+ />
149
+ </div>
150
+
151
+ <div className={"flex flex-col gap-1 min-w-0 grow"}>
152
+ <div className={"flex items-center gap-2"}>
153
+ <Typography variant={"label"}>{field.label}</Typography>
154
+ {replaces && (
155
+ <Typography variant={"caption"} color={"secondary"}>
156
+ replaces the current value
157
+ </Typography>
158
+ )}
159
+ {field.pending && <CircularProgress size={"smallest"}/>}
160
+ </div>
161
+
162
+ {replaces && (
163
+ <Typography
164
+ variant={"body2"}
165
+ color={"secondary"}
166
+ className={"line-through whitespace-pre-wrap break-words"}>
167
+ {renderValue(field.currentValue)}
168
+ </Typography>
169
+ )}
170
+
171
+ <Typography variant={"body2"} className={"whitespace-pre-wrap break-words"}>
172
+ {renderValue(field.proposed)}
173
+ </Typography>
174
+ </div>
175
+ </label>
176
+ );
177
+ }
178
+
179
+ function hasValue(value: unknown): boolean {
180
+ if (value === null || value === undefined) return false;
181
+ if (typeof value === "string") return value.trim().length > 0;
182
+ if (Array.isArray(value)) return value.length > 0;
183
+ return true;
184
+ }
185
+
186
+ function isSameValue(a: unknown, b: unknown): boolean {
187
+ if (a === b) return true;
188
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
189
+ if (Array.isArray(a) && Array.isArray(b)) {
190
+ return a.length === b.length && a.every((v, i) => isSameValue(v, b[i]));
191
+ }
192
+ return false;
193
+ }
194
+
195
+ /** Values are shown, never edited here — so a readable string is all that is needed. */
196
+ function renderValue(value: unknown): string {
197
+ if (value === null || value === undefined) return "";
198
+ if (value instanceof Date) return value.toLocaleString();
199
+ if (Array.isArray(value)) return value.map((v) => renderValue(v)).join(", ");
200
+ if (typeof value === "boolean") return value ? "Yes" : "No";
201
+ if (typeof value === "object") return JSON.stringify(value);
202
+ return String(value);
203
+ }