@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/README.md +62 -18
- package/dist/api.d.ts +57 -31
- package/dist/components/AutofillReviewDialog.d.ts +16 -0
- package/dist/components/DataEnhancementControllerProvider.d.ts +2 -3
- package/dist/components/FormEnhanceAction.d.ts +1 -1
- package/dist/editor/useEditorAIController.d.ts +11 -2
- package/dist/index.es.js +582 -361
- package/dist/index.es.js.map +1 -1
- package/dist/types/data_enhancement_controller.d.ts +84 -28
- package/dist/useDataEnhancementPlugin.d.ts +10 -5
- package/package.json +23 -19
- package/src/api.ts +238 -174
- package/src/components/AutofillReviewDialog.tsx +203 -0
- package/src/components/DataEnhancementControllerProvider.tsx +196 -264
- package/src/components/FormEnhanceAction.tsx +128 -128
- package/src/editor/useEditorAIController.tsx +20 -33
- package/src/tests/api.test.ts +283 -0
- package/src/tests/review.test.tsx +260 -0
- package/src/tests/useDataEnhancementPlugin.test.tsx +36 -15
- package/src/types/data_enhancement_controller.tsx +98 -31
- package/src/useDataEnhancementPlugin.tsx +13 -12
- package/dist/utils/diffStrings.d.ts +0 -7
- package/dist/utils/strings_counter.d.ts +0 -2
- package/dist/utils/suggestions.d.ts +0 -1
- package/src/tests/diffStrings.test.ts +0 -128
- package/src/tests/strings_counter.test.ts +0 -117
- package/src/tests/suggestions.test.ts +0 -53
- package/src/utils/diffStrings.ts +0 -70
- package/src/utils/strings_counter.ts +0 -22
- package/src/utils/suggestions.ts +0 -6
package/src/api.ts
CHANGED
|
@@ -1,198 +1,262 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
37
|
+
/** One `event:`/`data:` pair off the wire. */
|
|
38
|
+
type ServerSentEvent = { event: string; data: string };
|
|
55
39
|
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
{
|
|
164
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
+
}
|