@rebasepro/plugin-ai 0.17.3 → 0.18.1
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/LICENSE +0 -1
- package/README.md +4 -0
- package/dist/api.d.ts +8 -0
- package/dist/index.es.js.map +1 -1
- package/package.json +35 -21
- package/src/api.ts +0 -320
- package/src/components/AutofillReviewDialog.tsx +0 -209
- package/src/components/DataEnhancementControllerProvider.tsx +0 -320
- package/src/components/FormEnhanceAction.tsx +0 -307
- package/src/editor/useEditorAIController.tsx +0 -24
- package/src/index.ts +0 -9
- package/src/tests/AutofillReviewDialog.test.tsx +0 -340
- package/src/tests/api.test.ts +0 -382
- package/src/tests/properties.test.ts +0 -420
- package/src/tests/request_agreement.test.ts +0 -240
- package/src/tests/review.test.tsx +0 -596
- package/src/tests/useDataEnhancementPlugin.test.tsx +0 -72
- package/src/tests/useEditorAIController.test.ts +0 -98
- package/src/tests/values.test.ts +0 -87
- package/src/types/data_enhancement_controller.tsx +0 -142
- package/src/useDataEnhancementPlugin.tsx +0 -68
- package/src/utils/properties.ts +0 -168
- package/src/utils/values.ts +0 -72
- package/src/vite-env.d.ts +0 -1
package/src/api.ts
DELETED
|
@@ -1,320 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
AutofillRequest,
|
|
3
|
-
AutofillResult,
|
|
4
|
-
AiStatus,
|
|
5
|
-
SamplePromptsResult
|
|
6
|
-
} from "./types/data_enhancement_controller";
|
|
7
|
-
|
|
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
|
-
}
|
|
36
|
-
|
|
37
|
-
/** One `event:`/`data:` pair off the wire. */
|
|
38
|
-
type ServerSentEvent = { event: string; data: string };
|
|
39
|
-
|
|
40
|
-
/** Not global: `exec` must not carry `lastIndex` between buffer reads. */
|
|
41
|
-
const SSE_SEPARATOR = /\r?\n\r?\n/;
|
|
42
|
-
|
|
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");
|
|
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
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
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();
|
|
123
|
-
return {
|
|
124
|
-
available: Boolean(body?.available),
|
|
125
|
-
model: typeof body?.model === "string" ? body.model : undefined,
|
|
126
|
-
features: Array.isArray(body?.features) ? body.features : undefined
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** One in-flight or settled probe per endpoint, for the life of the page. */
|
|
131
|
-
const statusProbes = new Map<string, Promise<AiStatus>>();
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* {@link fetchAiStatus}, asked once per endpoint per session.
|
|
135
|
-
*
|
|
136
|
-
* The provider is form-scoped, so the uncached call meant one request to the
|
|
137
|
-
* host every time any record was opened — a beacon on an install that may never
|
|
138
|
-
* click Autofill, and enough traffic from one NAT'd office to spend the host's
|
|
139
|
-
* per-IP rate limit on nothing, which reads back as `available: false` and makes
|
|
140
|
-
* the button flicker in and out for everyone behind it.
|
|
141
|
-
*
|
|
142
|
-
* Availability changes on the order of a deploy or a daily quota reset, not of a
|
|
143
|
-
* form open, so a session-long answer is the right resolution. Failures resolve
|
|
144
|
-
* to `available: false` and are cached like any other answer — retrying per form
|
|
145
|
-
* open is the behaviour this replaces.
|
|
146
|
-
*/
|
|
147
|
-
export function fetchAiStatusCached(props: { endpoint?: string }): Promise<AiStatus> {
|
|
148
|
-
const key = endpointOf(props.endpoint, "/status");
|
|
149
|
-
const existing = statusProbes.get(key);
|
|
150
|
-
if (existing) return existing;
|
|
151
|
-
const probe = fetchAiStatus({ endpoint: props.endpoint })
|
|
152
|
-
.catch(() => ({ available: false }) as AiStatus);
|
|
153
|
-
statusProbes.set(key, probe);
|
|
154
|
-
return probe;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** Forget every cached probe, so the next caller asks again. */
|
|
158
|
-
export function clearAiStatusCache(): void {
|
|
159
|
-
statusProbes.clear();
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Fill a record, streaming each field as the service writes it.
|
|
164
|
-
*
|
|
165
|
-
* `onDelta` fires with more text for a field still being written; `onValue`
|
|
166
|
-
* fires once a field is complete and carries its final, correctly typed value.
|
|
167
|
-
* A caller that implements only `onValue` still ends up with the right record —
|
|
168
|
-
* the deltas exist so a long text field fills in visibly instead of appearing
|
|
169
|
-
* all at once.
|
|
170
|
-
*/
|
|
171
|
-
export async function autofillStream(props: {
|
|
172
|
-
request: AutofillRequest;
|
|
173
|
-
endpoint?: string;
|
|
174
|
-
signal?: AbortSignal;
|
|
175
|
-
onDelta: (key: string, text: string) => void;
|
|
176
|
-
onValue: (key: string, value: unknown) => void;
|
|
177
|
-
}): Promise<AutofillResult> {
|
|
178
|
-
const response = await fetch(endpointOf(props.endpoint, "/autofill"), {
|
|
179
|
-
method: "POST",
|
|
180
|
-
headers: { "Content-Type": "application/json" },
|
|
181
|
-
body: JSON.stringify(props.request),
|
|
182
|
-
signal: props.signal
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
if (!response.ok) {
|
|
186
|
-
throw await errorFrom(response, "The AI service could not complete this request.");
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
let result: AutofillResult = { suggestions: {} };
|
|
190
|
-
let done = false;
|
|
191
|
-
let delivered = 0;
|
|
192
|
-
let discarded = 0;
|
|
193
|
-
|
|
194
|
-
for await (const { event, data } of readServerSentEvents(response)) {
|
|
195
|
-
let payload: any;
|
|
196
|
-
try {
|
|
197
|
-
payload = JSON.parse(data);
|
|
198
|
-
} catch {
|
|
199
|
-
// One malformed record must not abort a stream that is otherwise
|
|
200
|
-
// delivering good fields — but it is counted, because a run that
|
|
201
|
-
// delivered nothing *but* malformed records is a failure, not an
|
|
202
|
-
// answer of "there was nothing to fill in".
|
|
203
|
-
discarded++;
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (event === "suggestion_delta") {
|
|
208
|
-
delivered++;
|
|
209
|
-
props.onDelta(payload.key, payload.text);
|
|
210
|
-
} else if (event === "suggestion") {
|
|
211
|
-
delivered++;
|
|
212
|
-
props.onValue(payload.key, payload.value);
|
|
213
|
-
} else if (event === "done") {
|
|
214
|
-
done = true;
|
|
215
|
-
result = {
|
|
216
|
-
suggestions: payload.suggestions ?? {},
|
|
217
|
-
usage: payload.usage
|
|
218
|
-
};
|
|
219
|
-
} else if (event === "error") {
|
|
220
|
-
throw new Error(payload.message ?? "The AI service reported an error.");
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// The service closes every run it finished with a `done` record — including
|
|
225
|
-
// the run that had nothing to fill, which is an empty `done` and not an
|
|
226
|
-
// empty body. So a body that simply stops is a truncation: a rolled pod, a
|
|
227
|
-
// proxy timeout, a dropped connection. Reported as one, because the caller's
|
|
228
|
-
// only other reading of an empty result is "nothing needed filling", and
|
|
229
|
-
// telling an operator that their empty fields are fields the model would not
|
|
230
|
-
// improve on is a confident, wrong answer they have no way to question.
|
|
231
|
-
if (!done) {
|
|
232
|
-
throw new Error("The connection to the AI service ended before it finished.");
|
|
233
|
-
}
|
|
234
|
-
if (discarded > 0 && delivered === 0) {
|
|
235
|
-
throw new Error("The AI service's response could not be read.");
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
return result;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** Inline continuation for the rich-text editor. Streams plain text. */
|
|
242
|
-
export async function autocompleteStream(props: {
|
|
243
|
-
textBefore?: string;
|
|
244
|
-
textAfter?: string;
|
|
245
|
-
endpoint?: string;
|
|
246
|
-
signal?: AbortSignal;
|
|
247
|
-
onDelta: (text: string) => void;
|
|
248
|
-
}): Promise<string> {
|
|
249
|
-
const response = await fetch(endpointOf(props.endpoint, "/autocomplete"), {
|
|
250
|
-
method: "POST",
|
|
251
|
-
headers: { "Content-Type": "application/json" },
|
|
252
|
-
body: JSON.stringify({
|
|
253
|
-
textBefore: props.textBefore ?? "",
|
|
254
|
-
textAfter: props.textAfter ?? ""
|
|
255
|
-
}),
|
|
256
|
-
signal: props.signal
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
if (!response.ok) {
|
|
260
|
-
throw await errorFrom(response, "The AI service could not complete this request.");
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
let text = "";
|
|
264
|
-
for await (const { event, data } of readServerSentEvents(response)) {
|
|
265
|
-
let payload: any;
|
|
266
|
-
try {
|
|
267
|
-
payload = JSON.parse(data);
|
|
268
|
-
} catch {
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
if (event === "error") {
|
|
272
|
-
throw new Error(payload?.message ?? "The AI service reported an error.");
|
|
273
|
-
}
|
|
274
|
-
if (event === "delta" && typeof payload?.text === "string") {
|
|
275
|
-
text += payload.text;
|
|
276
|
-
props.onDelta(payload.text);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
return text;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* Sample prompts for the Autofill menu.
|
|
284
|
-
*
|
|
285
|
-
* Failure is deliberately not thrown: the menu has built-in prompts to fall
|
|
286
|
-
* back on, and an empty suggestion list is a far better outcome than an error
|
|
287
|
-
* toast for something nobody asked for.
|
|
288
|
-
*/
|
|
289
|
-
export async function fetchPromptSuggestions(props: {
|
|
290
|
-
entityName: string;
|
|
291
|
-
/** Ties suggestions to the domain — see the note on the service's side. */
|
|
292
|
-
entityDescription?: string;
|
|
293
|
-
input?: string;
|
|
294
|
-
endpoint?: string;
|
|
295
|
-
signal?: AbortSignal;
|
|
296
|
-
}): Promise<SamplePromptsResult> {
|
|
297
|
-
try {
|
|
298
|
-
const response = await fetch(endpointOf(props.endpoint, "/prompts"), {
|
|
299
|
-
method: "POST",
|
|
300
|
-
headers: { "Content-Type": "application/json" },
|
|
301
|
-
body: JSON.stringify({
|
|
302
|
-
entityName: props.entityName,
|
|
303
|
-
entityDescription: props.entityDescription,
|
|
304
|
-
input: props.input
|
|
305
|
-
}),
|
|
306
|
-
signal: props.signal
|
|
307
|
-
});
|
|
308
|
-
if (!response.ok) return { prompts: [] };
|
|
309
|
-
const body = await response.json();
|
|
310
|
-
const prompts: string[] = Array.isArray(body?.prompts) ? body.prompts : [];
|
|
311
|
-
return {
|
|
312
|
-
prompts: prompts
|
|
313
|
-
.filter((p): p is string => typeof p === "string")
|
|
314
|
-
.map((prompt) => ({ prompt,
|
|
315
|
-
type: "sample" as const }))
|
|
316
|
-
};
|
|
317
|
-
} catch {
|
|
318
|
-
return { prompts: [] };
|
|
319
|
-
}
|
|
320
|
-
}
|
|
@@ -1,209 +0,0 @@
|
|
|
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
|
-
{/* `component="span"`: the Typography `label`
|
|
73
|
-
variant renders a <label> element, and this sits
|
|
74
|
-
inside the row's own <label>. Nested labels are
|
|
75
|
-
invalid HTML and stop the text toggling the
|
|
76
|
-
checkbox — clicking "Select all" did nothing. */}
|
|
77
|
-
<Typography variant={"label"} component={"span"} color={"secondary"}>
|
|
78
|
-
{allSelected ? "Deselect all" : "Select all"}
|
|
79
|
-
</Typography>
|
|
80
|
-
</label>
|
|
81
|
-
<Separator orientation={"horizontal"} className={"my-0"}/>
|
|
82
|
-
</>
|
|
83
|
-
)}
|
|
84
|
-
|
|
85
|
-
<div className={"flex flex-col divide-y divide-surface-accent-100 dark:divide-surface-accent-800"}>
|
|
86
|
-
{review.fields.map((field) => (
|
|
87
|
-
<ProposedFieldRow
|
|
88
|
-
key={field.key}
|
|
89
|
-
field={field}
|
|
90
|
-
onToggle={() => controller.toggleField(field.key)}
|
|
91
|
-
/>
|
|
92
|
-
))}
|
|
93
|
-
</div>
|
|
94
|
-
|
|
95
|
-
{generating && (
|
|
96
|
-
<div className={"flex items-center gap-3 py-4 text-text-secondary dark:text-text-secondary-dark"}>
|
|
97
|
-
<CircularProgress size={"smallest"}/>
|
|
98
|
-
<Typography variant={"body2"} color={"secondary"}>
|
|
99
|
-
{review.fields.length === 0 ? "Thinking…" : "Writing the remaining fields…"}
|
|
100
|
-
</Typography>
|
|
101
|
-
</div>
|
|
102
|
-
)}
|
|
103
|
-
|
|
104
|
-
{review.status === "failed" && (
|
|
105
|
-
<Typography variant={"body2"} className={"py-2 text-red-600 dark:text-red-400"}>
|
|
106
|
-
{review.error}
|
|
107
|
-
{review.fields.length > 0 && " You can still apply what was written before it stopped."}
|
|
108
|
-
</Typography>
|
|
109
|
-
)}
|
|
110
|
-
|
|
111
|
-
{!generating && review.fields.length === 0 && review.status !== "failed" && (
|
|
112
|
-
<Typography variant={"body2"} color={"secondary"} className={"py-4"}>
|
|
113
|
-
Nothing to fill in — every field either already has a value the model would not
|
|
114
|
-
improve on, or is not one it can write.
|
|
115
|
-
</Typography>
|
|
116
|
-
)}
|
|
117
|
-
|
|
118
|
-
</DialogContent>
|
|
119
|
-
|
|
120
|
-
<DialogActions>
|
|
121
|
-
<Button variant={"text"}
|
|
122
|
-
color={"neutral"}
|
|
123
|
-
onClick={controller.dismissReview}>
|
|
124
|
-
{/* Named for what it does to the record, not to the dialog:
|
|
125
|
-
nothing has been written, so there is nothing to undo. */}
|
|
126
|
-
Discard
|
|
127
|
-
</Button>
|
|
128
|
-
<Button variant={"filled"}
|
|
129
|
-
disabled={applicable.length === 0}
|
|
130
|
-
onClick={controller.applyReview}>
|
|
131
|
-
{applicable.length === 1 ? "Apply 1 field" : `Apply ${applicable.length} fields`}
|
|
132
|
-
</Button>
|
|
133
|
-
</DialogActions>
|
|
134
|
-
|
|
135
|
-
</Dialog>
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function ProposedFieldRow({ field, onToggle }: { field: ProposedField, onToggle: () => void }) {
|
|
140
|
-
|
|
141
|
-
const replaces = hasValue(field.currentValue) && !isSameValue(field.currentValue, field.proposed);
|
|
142
|
-
|
|
143
|
-
return (
|
|
144
|
-
<label className={cls(
|
|
145
|
-
"flex items-start gap-3 py-3 cursor-pointer",
|
|
146
|
-
!field.selected && "opacity-50"
|
|
147
|
-
)}>
|
|
148
|
-
<div className={"mt-0.5 shrink-0"}>
|
|
149
|
-
<Checkbox
|
|
150
|
-
checked={field.selected}
|
|
151
|
-
size={"small"}
|
|
152
|
-
onCheckedChange={onToggle}
|
|
153
|
-
/>
|
|
154
|
-
</div>
|
|
155
|
-
|
|
156
|
-
<div className={"flex flex-col gap-1 min-w-0 grow"}>
|
|
157
|
-
<div className={"flex items-center gap-2"}>
|
|
158
|
-
{/* See the note above: never a bare `label` variant inside a <label>. */}
|
|
159
|
-
<Typography variant={"label"} component={"span"}>{field.label}</Typography>
|
|
160
|
-
{replaces && (
|
|
161
|
-
<Typography variant={"caption"} color={"secondary"}>
|
|
162
|
-
replaces the current value
|
|
163
|
-
</Typography>
|
|
164
|
-
)}
|
|
165
|
-
{field.pending && <CircularProgress size={"smallest"}/>}
|
|
166
|
-
</div>
|
|
167
|
-
|
|
168
|
-
{replaces && (
|
|
169
|
-
<Typography
|
|
170
|
-
variant={"body2"}
|
|
171
|
-
color={"secondary"}
|
|
172
|
-
className={"line-through whitespace-pre-wrap break-words"}>
|
|
173
|
-
{renderValue(field.currentValue)}
|
|
174
|
-
</Typography>
|
|
175
|
-
)}
|
|
176
|
-
|
|
177
|
-
<Typography variant={"body2"} className={"whitespace-pre-wrap break-words"}>
|
|
178
|
-
{renderValue(field.proposed)}
|
|
179
|
-
</Typography>
|
|
180
|
-
</div>
|
|
181
|
-
</label>
|
|
182
|
-
);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function hasValue(value: unknown): boolean {
|
|
186
|
-
if (value === null || value === undefined) return false;
|
|
187
|
-
if (typeof value === "string") return value.trim().length > 0;
|
|
188
|
-
if (Array.isArray(value)) return value.length > 0;
|
|
189
|
-
return true;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function isSameValue(a: unknown, b: unknown): boolean {
|
|
193
|
-
if (a === b) return true;
|
|
194
|
-
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
195
|
-
if (Array.isArray(a) && Array.isArray(b)) {
|
|
196
|
-
return a.length === b.length && a.every((v, i) => isSameValue(v, b[i]));
|
|
197
|
-
}
|
|
198
|
-
return false;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** Values are shown, never edited here — so a readable string is all that is needed. */
|
|
202
|
-
function renderValue(value: unknown): string {
|
|
203
|
-
if (value === null || value === undefined) return "";
|
|
204
|
-
if (value instanceof Date) return value.toLocaleString();
|
|
205
|
-
if (Array.isArray(value)) return value.map((v) => renderValue(v)).join(", ");
|
|
206
|
-
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
207
|
-
if (typeof value === "object") return JSON.stringify(value);
|
|
208
|
-
return String(value);
|
|
209
|
-
}
|