@arqel-dev/ai 0.8.0
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 +21 -0
- package/README.md +24 -0
- package/SKILL.md +425 -0
- package/dist/index.d.ts +268 -0
- package/dist/index.js +1133 -0
- package/dist/index.js.map +1 -0
- package/dist/register.d.ts +2 -0
- package/dist/register.js +1342 -0
- package/dist/register.js.map +1 -0
- package/package.json +80 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
import { Label, Button, Card, CardContent, Badge, Alert, AlertDescription, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Textarea } from '@arqel-dev/ui';
|
|
2
|
+
import { useId, useState, useCallback, useMemo } from 'react';
|
|
3
|
+
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
4
|
+
|
|
5
|
+
// src/AiExtractInput.tsx
|
|
6
|
+
function buildExtractUrl(override, resource, field) {
|
|
7
|
+
if (override !== void 0 && override !== "") {
|
|
8
|
+
return override;
|
|
9
|
+
}
|
|
10
|
+
if (resource && field) {
|
|
11
|
+
return `/admin/${resource}/fields/${field}/extract`;
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
function isPlainRecord(input) {
|
|
16
|
+
return input !== null && typeof input === "object" && !Array.isArray(input);
|
|
17
|
+
}
|
|
18
|
+
function coerceSourceText(raw) {
|
|
19
|
+
if (typeof raw === "string") {
|
|
20
|
+
return raw;
|
|
21
|
+
}
|
|
22
|
+
if (raw === null || raw === void 0) {
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
if (typeof raw === "number" || typeof raw === "boolean") {
|
|
26
|
+
return String(raw);
|
|
27
|
+
}
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
function formatPreviewValue(value) {
|
|
31
|
+
if (value === null || value === void 0) {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
if (typeof value === "string") {
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
38
|
+
return String(value);
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
return JSON.stringify(value);
|
|
42
|
+
} catch {
|
|
43
|
+
return String(value);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function Spinner() {
|
|
47
|
+
return /* @__PURE__ */ jsxs(
|
|
48
|
+
"svg",
|
|
49
|
+
{
|
|
50
|
+
width: "14",
|
|
51
|
+
height: "14",
|
|
52
|
+
viewBox: "0 0 24 24",
|
|
53
|
+
fill: "none",
|
|
54
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
55
|
+
"aria-hidden": "true",
|
|
56
|
+
className: "animate-spin",
|
|
57
|
+
children: [
|
|
58
|
+
/* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeOpacity: "0.25", strokeWidth: "4" }),
|
|
59
|
+
/* @__PURE__ */ jsx(
|
|
60
|
+
"path",
|
|
61
|
+
{
|
|
62
|
+
d: "M22 12a10 10 0 0 1-10 10",
|
|
63
|
+
stroke: "currentColor",
|
|
64
|
+
strokeWidth: "4",
|
|
65
|
+
strokeLinecap: "round"
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
function AiExtractInput(props) {
|
|
73
|
+
const {
|
|
74
|
+
name,
|
|
75
|
+
value,
|
|
76
|
+
onChange,
|
|
77
|
+
props: fieldProps,
|
|
78
|
+
resource,
|
|
79
|
+
field,
|
|
80
|
+
formData,
|
|
81
|
+
extractUrl,
|
|
82
|
+
csrfToken,
|
|
83
|
+
onPopulateField
|
|
84
|
+
} = props;
|
|
85
|
+
const sourceField = fieldProps?.sourceField ?? "";
|
|
86
|
+
const targetFields = fieldProps?.targetFields ?? [];
|
|
87
|
+
const buttonLabel = fieldProps?.buttonLabel ?? "Extract with AI";
|
|
88
|
+
const reactId = useId();
|
|
89
|
+
const previewId = `arqel-ai-extract-${reactId}`;
|
|
90
|
+
const [extracted, setExtracted] = useState(value);
|
|
91
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
92
|
+
const [error, setError] = useState(null);
|
|
93
|
+
const applyAll = useCallback(
|
|
94
|
+
(next) => {
|
|
95
|
+
if (onPopulateField !== void 0) {
|
|
96
|
+
for (const key of Object.keys(next)) {
|
|
97
|
+
onPopulateField(key, next[key]);
|
|
98
|
+
}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
onChange?.(next);
|
|
102
|
+
},
|
|
103
|
+
[onChange, onPopulateField]
|
|
104
|
+
);
|
|
105
|
+
const applyOne = useCallback(
|
|
106
|
+
(target, val) => {
|
|
107
|
+
if (onPopulateField !== void 0) {
|
|
108
|
+
onPopulateField(target, val);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
onChange?.({ [target]: val });
|
|
112
|
+
},
|
|
113
|
+
[onChange, onPopulateField]
|
|
114
|
+
);
|
|
115
|
+
const handleExtract = useCallback(async () => {
|
|
116
|
+
const url = buildExtractUrl(extractUrl, resource, field);
|
|
117
|
+
if (url === null) {
|
|
118
|
+
setError("Missing extract URL: provide `extractUrl` or both `resource` and `field`.");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const sourceText = coerceSourceText(
|
|
122
|
+
formData !== void 0 && sourceField !== "" ? formData[sourceField] : ""
|
|
123
|
+
);
|
|
124
|
+
setIsLoading(true);
|
|
125
|
+
setError(null);
|
|
126
|
+
try {
|
|
127
|
+
const response = await fetch(url, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
credentials: "same-origin",
|
|
130
|
+
headers: {
|
|
131
|
+
"Content-Type": "application/json",
|
|
132
|
+
Accept: "application/json",
|
|
133
|
+
"X-CSRF-TOKEN": csrfToken ?? ""
|
|
134
|
+
},
|
|
135
|
+
body: JSON.stringify({ sourceText })
|
|
136
|
+
});
|
|
137
|
+
if (!response.ok) {
|
|
138
|
+
let message = null;
|
|
139
|
+
try {
|
|
140
|
+
const body2 = await response.json();
|
|
141
|
+
if (typeof body2.message === "string" && body2.message !== "") {
|
|
142
|
+
message = body2.message;
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
message = null;
|
|
146
|
+
}
|
|
147
|
+
setError(message ?? `Extraction failed (HTTP ${String(response.status)}).`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const body = await response.json();
|
|
151
|
+
if (!isPlainRecord(body.extracted)) {
|
|
152
|
+
setError("Extraction failed: invalid response body.");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
setExtracted(body.extracted);
|
|
156
|
+
} catch {
|
|
157
|
+
setError("Extraction failed: network error.");
|
|
158
|
+
} finally {
|
|
159
|
+
setIsLoading(false);
|
|
160
|
+
}
|
|
161
|
+
}, [csrfToken, extractUrl, field, formData, resource, sourceField]);
|
|
162
|
+
const hasExtraction = extracted !== null && Object.keys(extracted).length > 0;
|
|
163
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", "data-arqel-field": "aiExtract", "data-field-name": name, children: [
|
|
164
|
+
/* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsxs(Label, { className: "text-muted-foreground", children: [
|
|
165
|
+
"Source: ",
|
|
166
|
+
sourceField
|
|
167
|
+
] }) }),
|
|
168
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
169
|
+
/* @__PURE__ */ jsxs(
|
|
170
|
+
Button,
|
|
171
|
+
{
|
|
172
|
+
variant: "outline",
|
|
173
|
+
size: "sm",
|
|
174
|
+
onClick: () => {
|
|
175
|
+
void handleExtract();
|
|
176
|
+
},
|
|
177
|
+
disabled: isLoading,
|
|
178
|
+
"aria-label": buttonLabel,
|
|
179
|
+
children: [
|
|
180
|
+
isLoading ? /* @__PURE__ */ jsx("span", { role: "status", "aria-label": "Extracting", children: /* @__PURE__ */ jsx(Spinner, {}) }) : null,
|
|
181
|
+
/* @__PURE__ */ jsx("span", { children: buttonLabel })
|
|
182
|
+
]
|
|
183
|
+
}
|
|
184
|
+
),
|
|
185
|
+
hasExtraction && extracted !== null ? /* @__PURE__ */ jsx(
|
|
186
|
+
Button,
|
|
187
|
+
{
|
|
188
|
+
variant: "default",
|
|
189
|
+
size: "sm",
|
|
190
|
+
onClick: () => {
|
|
191
|
+
applyAll(extracted);
|
|
192
|
+
},
|
|
193
|
+
children: "Apply all"
|
|
194
|
+
}
|
|
195
|
+
) : null
|
|
196
|
+
] }),
|
|
197
|
+
hasExtraction && extracted !== null ? /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, { className: "p-4", children: /* @__PURE__ */ jsx("dl", { id: previewId, className: "flex flex-col gap-2", children: (targetFields.length > 0 ? targetFields : Object.keys(extracted)).map((target) => {
|
|
198
|
+
const val = extracted[target];
|
|
199
|
+
return /* @__PURE__ */ jsxs(
|
|
200
|
+
"div",
|
|
201
|
+
{
|
|
202
|
+
className: "flex items-center justify-between gap-2 border-b border-border pb-2 last:border-b-0 last:pb-0",
|
|
203
|
+
children: [
|
|
204
|
+
/* @__PURE__ */ jsx("dt", { children: /* @__PURE__ */ jsx(Badge, { variant: "outline", children: target }) }),
|
|
205
|
+
/* @__PURE__ */ jsxs("dd", { className: "flex items-center gap-2 flex-1 justify-end", children: [
|
|
206
|
+
/* @__PURE__ */ jsx(
|
|
207
|
+
"span",
|
|
208
|
+
{
|
|
209
|
+
className: "text-sm text-foreground truncate",
|
|
210
|
+
"data-testid": `extract-value-${target}`,
|
|
211
|
+
children: formatPreviewValue(val)
|
|
212
|
+
}
|
|
213
|
+
),
|
|
214
|
+
/* @__PURE__ */ jsx(
|
|
215
|
+
Button,
|
|
216
|
+
{
|
|
217
|
+
variant: "ghost",
|
|
218
|
+
size: "sm",
|
|
219
|
+
onClick: () => {
|
|
220
|
+
applyOne(target, val);
|
|
221
|
+
},
|
|
222
|
+
"aria-label": `Apply ${target}`,
|
|
223
|
+
children: "Apply"
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
] })
|
|
227
|
+
]
|
|
228
|
+
},
|
|
229
|
+
target
|
|
230
|
+
);
|
|
231
|
+
}) }) }) }) : /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground italic", children: "No extraction yet \u2014 click button to start." }),
|
|
232
|
+
error !== null ? /* @__PURE__ */ jsx(Alert, { variant: "destructive", children: /* @__PURE__ */ jsx(AlertDescription, { children: error }) }) : null
|
|
233
|
+
] });
|
|
234
|
+
}
|
|
235
|
+
function buildAnalyzeUrl(override, resource, field) {
|
|
236
|
+
if (override !== void 0 && override !== "") {
|
|
237
|
+
return override;
|
|
238
|
+
}
|
|
239
|
+
if (resource && field) {
|
|
240
|
+
return `/admin/${resource}/fields/${field}/analyze-image`;
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
function isStringRecord(input) {
|
|
245
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
for (const value of Object.values(input)) {
|
|
249
|
+
if (typeof value !== "string") {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
function readFileAsDataUrl(file) {
|
|
256
|
+
return new Promise((resolve, reject) => {
|
|
257
|
+
const reader = new FileReader();
|
|
258
|
+
reader.onload = () => {
|
|
259
|
+
const result = reader.result;
|
|
260
|
+
if (typeof result === "string") {
|
|
261
|
+
resolve(result);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
reject(new Error("FileReader returned non-string result."));
|
|
265
|
+
};
|
|
266
|
+
reader.onerror = () => {
|
|
267
|
+
reject(reader.error ?? new Error("FileReader error."));
|
|
268
|
+
};
|
|
269
|
+
reader.readAsDataURL(file);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
function formatBytes(bytes) {
|
|
273
|
+
if (bytes >= 1048576) {
|
|
274
|
+
return `${(bytes / 1048576).toFixed(1)} MB`;
|
|
275
|
+
}
|
|
276
|
+
if (bytes >= 1024) {
|
|
277
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
278
|
+
}
|
|
279
|
+
return `${String(bytes)} B`;
|
|
280
|
+
}
|
|
281
|
+
function Spinner2() {
|
|
282
|
+
return /* @__PURE__ */ jsxs(
|
|
283
|
+
"svg",
|
|
284
|
+
{
|
|
285
|
+
width: "14",
|
|
286
|
+
height: "14",
|
|
287
|
+
viewBox: "0 0 24 24",
|
|
288
|
+
fill: "none",
|
|
289
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
290
|
+
"aria-hidden": "true",
|
|
291
|
+
className: "animate-spin",
|
|
292
|
+
children: [
|
|
293
|
+
/* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeOpacity: "0.25", strokeWidth: "4" }),
|
|
294
|
+
/* @__PURE__ */ jsx(
|
|
295
|
+
"path",
|
|
296
|
+
{
|
|
297
|
+
d: "M22 12a10 10 0 0 1-10 10",
|
|
298
|
+
stroke: "currentColor",
|
|
299
|
+
strokeWidth: "4",
|
|
300
|
+
strokeLinecap: "round"
|
|
301
|
+
}
|
|
302
|
+
)
|
|
303
|
+
]
|
|
304
|
+
}
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
function AiImageInput(props) {
|
|
308
|
+
const {
|
|
309
|
+
name,
|
|
310
|
+
value,
|
|
311
|
+
onChange,
|
|
312
|
+
props: fieldProps,
|
|
313
|
+
resource,
|
|
314
|
+
field,
|
|
315
|
+
analyzeUrl,
|
|
316
|
+
csrfToken,
|
|
317
|
+
onPopulateField
|
|
318
|
+
} = props;
|
|
319
|
+
const analyses = fieldProps?.analyses ?? [];
|
|
320
|
+
const populateFields = fieldProps?.populateFields ?? {};
|
|
321
|
+
const acceptedMimes = fieldProps?.acceptedMimes ?? [];
|
|
322
|
+
const maxFileSize = fieldProps?.maxFileSize ?? 0;
|
|
323
|
+
const buttonLabel = fieldProps?.buttonLabel ?? "Analyze with AI";
|
|
324
|
+
const reactId = useId();
|
|
325
|
+
const inputId = `arqel-ai-image-${reactId}`;
|
|
326
|
+
const [file, setFile] = useState(null);
|
|
327
|
+
const [previewUrl, setPreviewUrl] = useState(null);
|
|
328
|
+
const [results, setResults] = useState(null);
|
|
329
|
+
const [populateMapping, setPopulateMapping] = useState(populateFields);
|
|
330
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
331
|
+
const [error, setError] = useState(null);
|
|
332
|
+
const handleFileChange = useCallback(
|
|
333
|
+
(event) => {
|
|
334
|
+
const next = event.target.files?.[0] ?? null;
|
|
335
|
+
setError(null);
|
|
336
|
+
setResults(null);
|
|
337
|
+
if (next === null) {
|
|
338
|
+
setFile(null);
|
|
339
|
+
setPreviewUrl(null);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (maxFileSize > 0 && next.size > maxFileSize) {
|
|
343
|
+
setError(`File too large: ${formatBytes(next.size)} (max ${formatBytes(maxFileSize)}).`);
|
|
344
|
+
setFile(null);
|
|
345
|
+
setPreviewUrl(null);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
setFile(next);
|
|
349
|
+
try {
|
|
350
|
+
setPreviewUrl(URL.createObjectURL(next));
|
|
351
|
+
} catch {
|
|
352
|
+
setPreviewUrl(null);
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
[maxFileSize]
|
|
356
|
+
);
|
|
357
|
+
const handleAnalyze = useCallback(async () => {
|
|
358
|
+
if (file === null) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const url = buildAnalyzeUrl(analyzeUrl, resource, field);
|
|
362
|
+
if (url === null) {
|
|
363
|
+
setError("Missing analyze URL: provide `analyzeUrl` or both `resource` and `field`.");
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
setIsLoading(true);
|
|
367
|
+
setError(null);
|
|
368
|
+
try {
|
|
369
|
+
const imageBase64 = await readFileAsDataUrl(file);
|
|
370
|
+
const response = await fetch(url, {
|
|
371
|
+
method: "POST",
|
|
372
|
+
credentials: "same-origin",
|
|
373
|
+
headers: {
|
|
374
|
+
"Content-Type": "application/json",
|
|
375
|
+
Accept: "application/json",
|
|
376
|
+
"X-CSRF-TOKEN": csrfToken ?? ""
|
|
377
|
+
},
|
|
378
|
+
body: JSON.stringify({ imageBase64 })
|
|
379
|
+
});
|
|
380
|
+
if (!response.ok) {
|
|
381
|
+
let message = null;
|
|
382
|
+
try {
|
|
383
|
+
const body2 = await response.json();
|
|
384
|
+
if (typeof body2.message === "string" && body2.message !== "") {
|
|
385
|
+
message = body2.message;
|
|
386
|
+
}
|
|
387
|
+
} catch {
|
|
388
|
+
message = null;
|
|
389
|
+
}
|
|
390
|
+
setError(message ?? `Analysis failed (HTTP ${String(response.status)}).`);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const body = await response.json();
|
|
394
|
+
if (!isStringRecord(body.analyses)) {
|
|
395
|
+
setError("Analysis failed: invalid response body.");
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
setResults(body.analyses);
|
|
399
|
+
if (isStringRecord(body.populateMapping)) {
|
|
400
|
+
setPopulateMapping(body.populateMapping);
|
|
401
|
+
}
|
|
402
|
+
if (onChange !== void 0) {
|
|
403
|
+
onChange(imageBase64);
|
|
404
|
+
}
|
|
405
|
+
} catch {
|
|
406
|
+
setError("Analysis failed: network error.");
|
|
407
|
+
} finally {
|
|
408
|
+
setIsLoading(false);
|
|
409
|
+
}
|
|
410
|
+
}, [analyzeUrl, csrfToken, field, file, onChange, resource]);
|
|
411
|
+
const applyOne = useCallback(
|
|
412
|
+
(analysisKey, val) => {
|
|
413
|
+
const target = populateMapping[analysisKey];
|
|
414
|
+
if (target === void 0 || target === "") {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
onPopulateField?.(target, val);
|
|
418
|
+
},
|
|
419
|
+
[onPopulateField, populateMapping]
|
|
420
|
+
);
|
|
421
|
+
const applyAll = useCallback(
|
|
422
|
+
(entries) => {
|
|
423
|
+
for (const key of Object.keys(entries)) {
|
|
424
|
+
const target = populateMapping[key];
|
|
425
|
+
const val = entries[key];
|
|
426
|
+
if (target === void 0 || target === "" || val === void 0) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
onPopulateField?.(target, val);
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
[onPopulateField, populateMapping]
|
|
433
|
+
);
|
|
434
|
+
const accept = acceptedMimes.join(",");
|
|
435
|
+
const hasResults = results !== null && Object.keys(results).length > 0;
|
|
436
|
+
const canAnalyze = file !== null && !isLoading;
|
|
437
|
+
return /* @__PURE__ */ jsxs(
|
|
438
|
+
"div",
|
|
439
|
+
{
|
|
440
|
+
className: "flex flex-col gap-2",
|
|
441
|
+
"data-arqel-field": "aiImage",
|
|
442
|
+
"data-field-name": name,
|
|
443
|
+
"data-has-value": value !== null && value !== "" ? "true" : "false",
|
|
444
|
+
children: [
|
|
445
|
+
/* @__PURE__ */ jsxs(
|
|
446
|
+
"label",
|
|
447
|
+
{
|
|
448
|
+
htmlFor: inputId,
|
|
449
|
+
className: "flex items-center justify-center w-full min-h-[6rem] rounded-sm border-2 border-dashed border-border bg-muted/40 px-4 py-6 text-sm text-muted-foreground hover:bg-muted cursor-pointer transition-colors",
|
|
450
|
+
children: [
|
|
451
|
+
/* @__PURE__ */ jsx("span", { children: file !== null ? file.name : `Click or drop image (${accept !== "" ? accept : "any"})` }),
|
|
452
|
+
/* @__PURE__ */ jsx(
|
|
453
|
+
"input",
|
|
454
|
+
{
|
|
455
|
+
id: inputId,
|
|
456
|
+
type: "file",
|
|
457
|
+
accept,
|
|
458
|
+
onChange: handleFileChange,
|
|
459
|
+
className: "sr-only",
|
|
460
|
+
"aria-label": "Image file"
|
|
461
|
+
}
|
|
462
|
+
)
|
|
463
|
+
]
|
|
464
|
+
}
|
|
465
|
+
),
|
|
466
|
+
previewUrl !== null ? /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, { className: "p-2 flex items-center justify-center", children: /* @__PURE__ */ jsx(
|
|
467
|
+
"img",
|
|
468
|
+
{
|
|
469
|
+
src: previewUrl,
|
|
470
|
+
alt: "Selected preview",
|
|
471
|
+
"data-testid": "image-preview",
|
|
472
|
+
className: "max-h-64 max-w-full rounded-sm"
|
|
473
|
+
}
|
|
474
|
+
) }) }) : null,
|
|
475
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
476
|
+
/* @__PURE__ */ jsxs(
|
|
477
|
+
Button,
|
|
478
|
+
{
|
|
479
|
+
variant: "outline",
|
|
480
|
+
size: "sm",
|
|
481
|
+
onClick: () => {
|
|
482
|
+
void handleAnalyze();
|
|
483
|
+
},
|
|
484
|
+
disabled: !canAnalyze,
|
|
485
|
+
"aria-label": buttonLabel,
|
|
486
|
+
children: [
|
|
487
|
+
isLoading ? /* @__PURE__ */ jsx("span", { role: "status", "aria-label": "Analyzing", children: /* @__PURE__ */ jsx(Spinner2, {}) }) : null,
|
|
488
|
+
/* @__PURE__ */ jsx("span", { children: buttonLabel })
|
|
489
|
+
]
|
|
490
|
+
}
|
|
491
|
+
),
|
|
492
|
+
hasResults && results !== null ? /* @__PURE__ */ jsx(
|
|
493
|
+
Button,
|
|
494
|
+
{
|
|
495
|
+
variant: "default",
|
|
496
|
+
size: "sm",
|
|
497
|
+
onClick: () => {
|
|
498
|
+
applyAll(results);
|
|
499
|
+
},
|
|
500
|
+
children: "Apply all"
|
|
501
|
+
}
|
|
502
|
+
) : null
|
|
503
|
+
] }),
|
|
504
|
+
hasResults && results !== null ? /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, { className: "p-4", children: /* @__PURE__ */ jsx("dl", { className: "flex flex-col gap-2", children: (analyses.length > 0 ? analyses : Object.keys(results)).map((key) => {
|
|
505
|
+
const val = results[key];
|
|
506
|
+
if (val === void 0) {
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
const target = populateMapping[key];
|
|
510
|
+
const hasTarget = target !== void 0 && target !== "";
|
|
511
|
+
return /* @__PURE__ */ jsxs(
|
|
512
|
+
"div",
|
|
513
|
+
{
|
|
514
|
+
className: "flex items-center justify-between gap-2 border-b border-border pb-2 last:border-b-0 last:pb-0",
|
|
515
|
+
children: [
|
|
516
|
+
/* @__PURE__ */ jsx("dt", { children: /* @__PURE__ */ jsx(Badge, { variant: "outline", children: key }) }),
|
|
517
|
+
/* @__PURE__ */ jsxs("dd", { className: "flex items-center gap-2 flex-1 justify-end", children: [
|
|
518
|
+
/* @__PURE__ */ jsx(
|
|
519
|
+
"span",
|
|
520
|
+
{
|
|
521
|
+
className: "text-sm text-foreground truncate",
|
|
522
|
+
"data-testid": `analysis-value-${key}`,
|
|
523
|
+
children: val
|
|
524
|
+
}
|
|
525
|
+
),
|
|
526
|
+
hasTarget ? /* @__PURE__ */ jsx(
|
|
527
|
+
Button,
|
|
528
|
+
{
|
|
529
|
+
variant: "ghost",
|
|
530
|
+
size: "sm",
|
|
531
|
+
onClick: () => {
|
|
532
|
+
applyOne(key, val);
|
|
533
|
+
},
|
|
534
|
+
"aria-label": `Apply ${key}`,
|
|
535
|
+
children: "Apply"
|
|
536
|
+
}
|
|
537
|
+
) : null
|
|
538
|
+
] })
|
|
539
|
+
]
|
|
540
|
+
},
|
|
541
|
+
key
|
|
542
|
+
);
|
|
543
|
+
}) }) }) }) : null,
|
|
544
|
+
error !== null ? /* @__PURE__ */ jsx(Alert, { variant: "destructive", children: /* @__PURE__ */ jsx(AlertDescription, { children: error }) }) : null
|
|
545
|
+
]
|
|
546
|
+
}
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
var DEFAULT_BUTTON_LABEL = "Classify with AI";
|
|
550
|
+
var NO_CONTEXT_TOOLTIP = "No context fields configured. Add `classifyFromFields` to enable AI classification.";
|
|
551
|
+
function buildClassifyUrl(override, resource, field) {
|
|
552
|
+
if (override !== void 0 && override !== "") {
|
|
553
|
+
return override;
|
|
554
|
+
}
|
|
555
|
+
if (resource && field) {
|
|
556
|
+
return `/admin/${resource}/fields/${field}/classify`;
|
|
557
|
+
}
|
|
558
|
+
return null;
|
|
559
|
+
}
|
|
560
|
+
function Spinner3() {
|
|
561
|
+
return /* @__PURE__ */ jsxs(
|
|
562
|
+
"svg",
|
|
563
|
+
{
|
|
564
|
+
width: "14",
|
|
565
|
+
height: "14",
|
|
566
|
+
viewBox: "0 0 24 24",
|
|
567
|
+
fill: "none",
|
|
568
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
569
|
+
"aria-hidden": "true",
|
|
570
|
+
className: "animate-spin",
|
|
571
|
+
children: [
|
|
572
|
+
/* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeOpacity: "0.25", strokeWidth: "4" }),
|
|
573
|
+
/* @__PURE__ */ jsx(
|
|
574
|
+
"path",
|
|
575
|
+
{
|
|
576
|
+
d: "M22 12a10 10 0 0 1-10 10",
|
|
577
|
+
stroke: "currentColor",
|
|
578
|
+
strokeWidth: "4",
|
|
579
|
+
strokeLinecap: "round"
|
|
580
|
+
}
|
|
581
|
+
)
|
|
582
|
+
]
|
|
583
|
+
}
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
function AiSelectInput(props) {
|
|
587
|
+
const {
|
|
588
|
+
name,
|
|
589
|
+
value,
|
|
590
|
+
onChange,
|
|
591
|
+
props: fieldProps,
|
|
592
|
+
resource,
|
|
593
|
+
field,
|
|
594
|
+
formData,
|
|
595
|
+
classifyUrl,
|
|
596
|
+
csrfToken
|
|
597
|
+
} = props;
|
|
598
|
+
const options = fieldProps?.options ?? {};
|
|
599
|
+
const fallbackOption = fieldProps?.fallbackOption ?? null;
|
|
600
|
+
const hasContextFields = fieldProps?.hasContextFields ?? false;
|
|
601
|
+
const reactId = useId();
|
|
602
|
+
const selectId = `arqel-ai-select-${reactId}`;
|
|
603
|
+
const [internalValue, setInternalValue] = useState(value);
|
|
604
|
+
const isControlled = onChange !== void 0;
|
|
605
|
+
const currentValue = isControlled ? value : internalValue;
|
|
606
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
607
|
+
const [error, setError] = useState(null);
|
|
608
|
+
const [suggestion, setSuggestion] = useState(null);
|
|
609
|
+
const applyValue = useCallback(
|
|
610
|
+
(next) => {
|
|
611
|
+
if (isControlled && onChange) {
|
|
612
|
+
onChange(next);
|
|
613
|
+
} else {
|
|
614
|
+
setInternalValue(next);
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
[isControlled, onChange]
|
|
618
|
+
);
|
|
619
|
+
const handleSelectChange = useCallback(
|
|
620
|
+
(next) => {
|
|
621
|
+
applyValue(next === "" ? null : next);
|
|
622
|
+
setSuggestion(null);
|
|
623
|
+
},
|
|
624
|
+
[applyValue]
|
|
625
|
+
);
|
|
626
|
+
const handleClassify = useCallback(async () => {
|
|
627
|
+
const url = buildClassifyUrl(classifyUrl, resource, field);
|
|
628
|
+
if (url === null) {
|
|
629
|
+
setError("Missing classify URL: provide `classifyUrl` or both `resource` and `field`.");
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
setIsLoading(true);
|
|
633
|
+
setError(null);
|
|
634
|
+
setSuggestion(null);
|
|
635
|
+
try {
|
|
636
|
+
const response = await fetch(url, {
|
|
637
|
+
method: "POST",
|
|
638
|
+
credentials: "same-origin",
|
|
639
|
+
headers: {
|
|
640
|
+
"Content-Type": "application/json",
|
|
641
|
+
Accept: "application/json",
|
|
642
|
+
"X-CSRF-TOKEN": csrfToken ?? ""
|
|
643
|
+
},
|
|
644
|
+
body: JSON.stringify({ formData: formData ?? {} })
|
|
645
|
+
});
|
|
646
|
+
if (!response.ok) {
|
|
647
|
+
setError(`Classification failed (HTTP ${String(response.status)}).`);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const body = await response.json();
|
|
651
|
+
const key = typeof body.key === "string" ? body.key : null;
|
|
652
|
+
if (key !== null) {
|
|
653
|
+
applyValue(key);
|
|
654
|
+
setSuggestion("ai");
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (fallbackOption !== null && fallbackOption !== "") {
|
|
658
|
+
applyValue(fallbackOption);
|
|
659
|
+
setSuggestion("fallback");
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
setError("Could not classify.");
|
|
663
|
+
} catch {
|
|
664
|
+
setError("Classification failed: network error.");
|
|
665
|
+
} finally {
|
|
666
|
+
setIsLoading(false);
|
|
667
|
+
}
|
|
668
|
+
}, [applyValue, classifyUrl, csrfToken, fallbackOption, field, formData, resource]);
|
|
669
|
+
const buttonDisabled = isLoading || !hasContextFields;
|
|
670
|
+
const buttonTitle = !hasContextFields ? NO_CONTEXT_TOOLTIP : void 0;
|
|
671
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", "data-arqel-field": "aiSelect", "data-field-name": name, children: [
|
|
672
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
673
|
+
/* @__PURE__ */ jsxs(Select, { value: currentValue ?? "", onValueChange: handleSelectChange, name, children: [
|
|
674
|
+
/* @__PURE__ */ jsx(SelectTrigger, { id: selectId, className: "flex-1", children: /* @__PURE__ */ jsx(SelectValue, { placeholder: "Select..." }) }),
|
|
675
|
+
/* @__PURE__ */ jsx(SelectContent, { children: Object.entries(options).map(([key, label]) => /* @__PURE__ */ jsx(SelectItem, { value: key, children: label }, key)) })
|
|
676
|
+
] }),
|
|
677
|
+
/* @__PURE__ */ jsxs(
|
|
678
|
+
Button,
|
|
679
|
+
{
|
|
680
|
+
variant: "outline",
|
|
681
|
+
size: "sm",
|
|
682
|
+
onClick: () => {
|
|
683
|
+
void handleClassify();
|
|
684
|
+
},
|
|
685
|
+
disabled: buttonDisabled,
|
|
686
|
+
"aria-label": DEFAULT_BUTTON_LABEL,
|
|
687
|
+
...buttonTitle !== void 0 ? { title: buttonTitle } : {},
|
|
688
|
+
children: [
|
|
689
|
+
isLoading ? /* @__PURE__ */ jsx("span", { role: "status", "aria-label": "Classifying", children: /* @__PURE__ */ jsx(Spinner3, {}) }) : null,
|
|
690
|
+
/* @__PURE__ */ jsx("span", { children: DEFAULT_BUTTON_LABEL })
|
|
691
|
+
]
|
|
692
|
+
}
|
|
693
|
+
)
|
|
694
|
+
] }),
|
|
695
|
+
suggestion !== null ? /* @__PURE__ */ jsxs("div", { role: "status", className: "flex items-center gap-2", children: [
|
|
696
|
+
/* @__PURE__ */ jsx(
|
|
697
|
+
Badge,
|
|
698
|
+
{
|
|
699
|
+
variant: "secondary",
|
|
700
|
+
className: suggestion === "ai" ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
|
701
|
+
children: suggestion === "ai" ? "Suggested by AI" : "Used fallback"
|
|
702
|
+
}
|
|
703
|
+
),
|
|
704
|
+
/* @__PURE__ */ jsx(
|
|
705
|
+
Button,
|
|
706
|
+
{
|
|
707
|
+
variant: "ghost",
|
|
708
|
+
size: "sm",
|
|
709
|
+
onClick: () => {
|
|
710
|
+
setSuggestion(null);
|
|
711
|
+
},
|
|
712
|
+
children: "Accept"
|
|
713
|
+
}
|
|
714
|
+
),
|
|
715
|
+
/* @__PURE__ */ jsx(
|
|
716
|
+
Button,
|
|
717
|
+
{
|
|
718
|
+
variant: "ghost",
|
|
719
|
+
size: "sm",
|
|
720
|
+
onClick: () => {
|
|
721
|
+
setSuggestion(null);
|
|
722
|
+
},
|
|
723
|
+
children: "Pick another"
|
|
724
|
+
}
|
|
725
|
+
)
|
|
726
|
+
] }) : null,
|
|
727
|
+
error !== null ? /* @__PURE__ */ jsx(Alert, { variant: "destructive", children: /* @__PURE__ */ jsx(AlertDescription, { children: error }) }) : null
|
|
728
|
+
] });
|
|
729
|
+
}
|
|
730
|
+
var DEFAULT_BUTTON_LABEL2 = "Generate with AI";
|
|
731
|
+
var REGENERATE_LABEL = "Regenerate";
|
|
732
|
+
function buildGenerateUrl(override, resource, field) {
|
|
733
|
+
if (override !== void 0 && override !== "") {
|
|
734
|
+
return override;
|
|
735
|
+
}
|
|
736
|
+
if (resource && field) {
|
|
737
|
+
return `/admin/${resource}/fields/${field}/generate`;
|
|
738
|
+
}
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
function Spinner4() {
|
|
742
|
+
return /* @__PURE__ */ jsxs(
|
|
743
|
+
"svg",
|
|
744
|
+
{
|
|
745
|
+
width: "14",
|
|
746
|
+
height: "14",
|
|
747
|
+
viewBox: "0 0 24 24",
|
|
748
|
+
fill: "none",
|
|
749
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
750
|
+
"aria-hidden": "true",
|
|
751
|
+
className: "animate-spin",
|
|
752
|
+
children: [
|
|
753
|
+
/* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeOpacity: "0.25", strokeWidth: "4" }),
|
|
754
|
+
/* @__PURE__ */ jsx(
|
|
755
|
+
"path",
|
|
756
|
+
{
|
|
757
|
+
d: "M22 12a10 10 0 0 1-10 10",
|
|
758
|
+
stroke: "currentColor",
|
|
759
|
+
strokeWidth: "4",
|
|
760
|
+
strokeLinecap: "round"
|
|
761
|
+
}
|
|
762
|
+
)
|
|
763
|
+
]
|
|
764
|
+
}
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
function AiTextInput(props) {
|
|
768
|
+
const {
|
|
769
|
+
name,
|
|
770
|
+
value,
|
|
771
|
+
onChange,
|
|
772
|
+
props: fieldProps,
|
|
773
|
+
resource,
|
|
774
|
+
field,
|
|
775
|
+
formData,
|
|
776
|
+
generateUrl,
|
|
777
|
+
csrfToken
|
|
778
|
+
} = props;
|
|
779
|
+
const buttonLabel = fieldProps?.buttonLabel ?? DEFAULT_BUTTON_LABEL2;
|
|
780
|
+
const maxLength = fieldProps?.maxLength ?? null;
|
|
781
|
+
const reactId = useId();
|
|
782
|
+
const textareaId = `arqel-ai-text-${reactId}`;
|
|
783
|
+
const [internalValue, setInternalValue] = useState(value);
|
|
784
|
+
const isControlled = onChange !== void 0;
|
|
785
|
+
const currentValue = isControlled ? value : internalValue;
|
|
786
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
787
|
+
const [error, setError] = useState(null);
|
|
788
|
+
const [hasGenerated, setHasGenerated] = useState(false);
|
|
789
|
+
const handleTextareaChange = useCallback(
|
|
790
|
+
(event) => {
|
|
791
|
+
const next = event.target.value;
|
|
792
|
+
if (isControlled && onChange) {
|
|
793
|
+
onChange(next);
|
|
794
|
+
} else {
|
|
795
|
+
setInternalValue(next);
|
|
796
|
+
}
|
|
797
|
+
},
|
|
798
|
+
[isControlled, onChange]
|
|
799
|
+
);
|
|
800
|
+
const applyGeneratedText = useCallback(
|
|
801
|
+
(next) => {
|
|
802
|
+
if (isControlled && onChange) {
|
|
803
|
+
onChange(next);
|
|
804
|
+
} else {
|
|
805
|
+
setInternalValue(next);
|
|
806
|
+
}
|
|
807
|
+
},
|
|
808
|
+
[isControlled, onChange]
|
|
809
|
+
);
|
|
810
|
+
const handleGenerate = useCallback(async () => {
|
|
811
|
+
const url = buildGenerateUrl(generateUrl, resource, field);
|
|
812
|
+
if (url === null) {
|
|
813
|
+
setError("Missing generate URL: provide `generateUrl` or both `resource` and `field`.");
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
setIsLoading(true);
|
|
817
|
+
setError(null);
|
|
818
|
+
try {
|
|
819
|
+
const response = await fetch(url, {
|
|
820
|
+
method: "POST",
|
|
821
|
+
credentials: "same-origin",
|
|
822
|
+
headers: {
|
|
823
|
+
"Content-Type": "application/json",
|
|
824
|
+
Accept: "application/json",
|
|
825
|
+
"X-CSRF-TOKEN": csrfToken ?? ""
|
|
826
|
+
},
|
|
827
|
+
body: JSON.stringify({ formData: formData ?? {} })
|
|
828
|
+
});
|
|
829
|
+
if (!response.ok) {
|
|
830
|
+
setError(`Generation failed (HTTP ${String(response.status)}).`);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const body = await response.json();
|
|
834
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
835
|
+
applyGeneratedText(text);
|
|
836
|
+
setHasGenerated(true);
|
|
837
|
+
} catch {
|
|
838
|
+
setError("Generation failed: network error.");
|
|
839
|
+
} finally {
|
|
840
|
+
setIsLoading(false);
|
|
841
|
+
}
|
|
842
|
+
}, [applyGeneratedText, csrfToken, field, formData, generateUrl, resource]);
|
|
843
|
+
const triggerLabel = hasGenerated ? REGENERATE_LABEL : buttonLabel;
|
|
844
|
+
const charCount = currentValue.length;
|
|
845
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", "data-arqel-field": "aiText", "data-field-name": name, children: [
|
|
846
|
+
/* @__PURE__ */ jsx(
|
|
847
|
+
Textarea,
|
|
848
|
+
{
|
|
849
|
+
id: textareaId,
|
|
850
|
+
name,
|
|
851
|
+
value: currentValue,
|
|
852
|
+
onChange: handleTextareaChange,
|
|
853
|
+
...maxLength !== null ? { maxLength } : {},
|
|
854
|
+
rows: 6
|
|
855
|
+
}
|
|
856
|
+
),
|
|
857
|
+
maxLength !== null ? /* @__PURE__ */ jsxs("div", { className: "text-xs text-muted-foreground self-end", "aria-live": "polite", children: [
|
|
858
|
+
charCount,
|
|
859
|
+
" / ",
|
|
860
|
+
maxLength
|
|
861
|
+
] }) : null,
|
|
862
|
+
/* @__PURE__ */ jsx("div", { className: "flex items-center gap-2", children: /* @__PURE__ */ jsxs(
|
|
863
|
+
Button,
|
|
864
|
+
{
|
|
865
|
+
variant: "outline",
|
|
866
|
+
size: "sm",
|
|
867
|
+
onClick: () => {
|
|
868
|
+
void handleGenerate();
|
|
869
|
+
},
|
|
870
|
+
disabled: isLoading,
|
|
871
|
+
"aria-label": triggerLabel,
|
|
872
|
+
children: [
|
|
873
|
+
isLoading ? /* @__PURE__ */ jsx("span", { role: "status", "aria-label": "Generating", children: /* @__PURE__ */ jsx(Spinner4, {}) }) : null,
|
|
874
|
+
/* @__PURE__ */ jsx("span", { children: triggerLabel })
|
|
875
|
+
]
|
|
876
|
+
}
|
|
877
|
+
) }),
|
|
878
|
+
error !== null ? /* @__PURE__ */ jsx(Alert, { variant: "destructive", children: /* @__PURE__ */ jsx(AlertDescription, { children: error }) }) : null
|
|
879
|
+
] });
|
|
880
|
+
}
|
|
881
|
+
var AiTextInput_default = AiTextInput;
|
|
882
|
+
function buildTranslateUrl(override, resource, field) {
|
|
883
|
+
if (override !== void 0 && override !== "") {
|
|
884
|
+
return override;
|
|
885
|
+
}
|
|
886
|
+
if (resource && field) {
|
|
887
|
+
return `/admin/${resource}/fields/${field}/translate`;
|
|
888
|
+
}
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
891
|
+
function isStringRecord2(input) {
|
|
892
|
+
if (input === null || typeof input !== "object") {
|
|
893
|
+
return false;
|
|
894
|
+
}
|
|
895
|
+
for (const v of Object.values(input)) {
|
|
896
|
+
if (typeof v !== "string") {
|
|
897
|
+
return false;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return true;
|
|
901
|
+
}
|
|
902
|
+
function isMissing(translations, lang) {
|
|
903
|
+
const v = translations[lang];
|
|
904
|
+
return v === void 0 || v === null || v === "";
|
|
905
|
+
}
|
|
906
|
+
function Spinner5() {
|
|
907
|
+
return /* @__PURE__ */ jsxs(
|
|
908
|
+
"svg",
|
|
909
|
+
{
|
|
910
|
+
width: "14",
|
|
911
|
+
height: "14",
|
|
912
|
+
viewBox: "0 0 24 24",
|
|
913
|
+
fill: "none",
|
|
914
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
915
|
+
"aria-hidden": "true",
|
|
916
|
+
className: "animate-spin",
|
|
917
|
+
children: [
|
|
918
|
+
/* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeOpacity: "0.25", strokeWidth: "4" }),
|
|
919
|
+
/* @__PURE__ */ jsx(
|
|
920
|
+
"path",
|
|
921
|
+
{
|
|
922
|
+
d: "M22 12a10 10 0 0 1-10 10",
|
|
923
|
+
stroke: "currentColor",
|
|
924
|
+
strokeWidth: "4",
|
|
925
|
+
strokeLinecap: "round"
|
|
926
|
+
}
|
|
927
|
+
)
|
|
928
|
+
]
|
|
929
|
+
}
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
function AiTranslateInput(props) {
|
|
933
|
+
const {
|
|
934
|
+
name,
|
|
935
|
+
value,
|
|
936
|
+
onChange,
|
|
937
|
+
props: fieldProps,
|
|
938
|
+
resource,
|
|
939
|
+
field,
|
|
940
|
+
translateUrl,
|
|
941
|
+
csrfToken
|
|
942
|
+
} = props;
|
|
943
|
+
const languages = useMemo(() => fieldProps?.languages ?? [], [fieldProps?.languages]);
|
|
944
|
+
const defaultLanguage = fieldProps?.defaultLanguage ?? languages[0] ?? "";
|
|
945
|
+
const reactId = useId();
|
|
946
|
+
const tablistId = `arqel-ai-translate-${reactId}`;
|
|
947
|
+
const [internalValue, setInternalValue] = useState(value ?? {});
|
|
948
|
+
const isControlled = onChange !== void 0;
|
|
949
|
+
const currentValue = isControlled ? value ?? {} : internalValue;
|
|
950
|
+
const initialActive = languages.includes(defaultLanguage) ? defaultLanguage : languages[0] ?? defaultLanguage;
|
|
951
|
+
const [activeLang, setActiveLang] = useState(initialActive);
|
|
952
|
+
const [loadingLangs, setLoadingLangs] = useState(() => /* @__PURE__ */ new Set());
|
|
953
|
+
const [error, setError] = useState(null);
|
|
954
|
+
const applyValue = useCallback(
|
|
955
|
+
(next) => {
|
|
956
|
+
if (isControlled && onChange) {
|
|
957
|
+
onChange(next);
|
|
958
|
+
} else {
|
|
959
|
+
setInternalValue(next);
|
|
960
|
+
}
|
|
961
|
+
},
|
|
962
|
+
[isControlled, onChange]
|
|
963
|
+
);
|
|
964
|
+
const handleTextareaChange = useCallback(
|
|
965
|
+
(event) => {
|
|
966
|
+
const next = { ...currentValue, [activeLang]: event.target.value };
|
|
967
|
+
applyValue(next);
|
|
968
|
+
},
|
|
969
|
+
[activeLang, applyValue, currentValue]
|
|
970
|
+
);
|
|
971
|
+
const setLoadingFor = useCallback((langs, on) => {
|
|
972
|
+
setLoadingLangs((prev) => {
|
|
973
|
+
const next = new Set(prev);
|
|
974
|
+
for (const l of langs) {
|
|
975
|
+
if (on) {
|
|
976
|
+
next.add(l);
|
|
977
|
+
} else {
|
|
978
|
+
next.delete(l);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
return next;
|
|
982
|
+
});
|
|
983
|
+
}, []);
|
|
984
|
+
const performTranslate = useCallback(
|
|
985
|
+
async (targetLanguages) => {
|
|
986
|
+
if (targetLanguages.length === 0) {
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
const url = buildTranslateUrl(translateUrl, resource, field);
|
|
990
|
+
if (url === null) {
|
|
991
|
+
setError("Missing translate URL: provide `translateUrl` or both `resource` and `field`.");
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
setError(null);
|
|
995
|
+
setLoadingFor(targetLanguages, true);
|
|
996
|
+
try {
|
|
997
|
+
const response = await fetch(url, {
|
|
998
|
+
method: "POST",
|
|
999
|
+
credentials: "same-origin",
|
|
1000
|
+
headers: {
|
|
1001
|
+
"Content-Type": "application/json",
|
|
1002
|
+
Accept: "application/json",
|
|
1003
|
+
"X-CSRF-TOKEN": csrfToken ?? ""
|
|
1004
|
+
},
|
|
1005
|
+
body: JSON.stringify({
|
|
1006
|
+
sourceLanguage: defaultLanguage,
|
|
1007
|
+
targetLanguages,
|
|
1008
|
+
sourceText: currentValue[defaultLanguage] ?? ""
|
|
1009
|
+
})
|
|
1010
|
+
});
|
|
1011
|
+
if (!response.ok) {
|
|
1012
|
+
setError(`Translation failed (HTTP ${String(response.status)}).`);
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
const body = await response.json();
|
|
1016
|
+
const incoming = body.translations;
|
|
1017
|
+
if (!isStringRecord2(incoming)) {
|
|
1018
|
+
setError("Translation failed: invalid response body.");
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
const merged = { ...currentValue, ...incoming };
|
|
1022
|
+
applyValue(merged);
|
|
1023
|
+
} catch {
|
|
1024
|
+
setError("Translation failed: network error.");
|
|
1025
|
+
} finally {
|
|
1026
|
+
setLoadingFor(targetLanguages, false);
|
|
1027
|
+
}
|
|
1028
|
+
},
|
|
1029
|
+
[
|
|
1030
|
+
applyValue,
|
|
1031
|
+
csrfToken,
|
|
1032
|
+
currentValue,
|
|
1033
|
+
defaultLanguage,
|
|
1034
|
+
field,
|
|
1035
|
+
resource,
|
|
1036
|
+
setLoadingFor,
|
|
1037
|
+
translateUrl
|
|
1038
|
+
]
|
|
1039
|
+
);
|
|
1040
|
+
const handleTranslateOne = useCallback(
|
|
1041
|
+
(lang) => {
|
|
1042
|
+
void performTranslate([lang]);
|
|
1043
|
+
},
|
|
1044
|
+
[performTranslate]
|
|
1045
|
+
);
|
|
1046
|
+
const handleTranslateAllMissing = useCallback(() => {
|
|
1047
|
+
const missing = languages.filter(
|
|
1048
|
+
(lang) => lang !== defaultLanguage && isMissing(currentValue, lang)
|
|
1049
|
+
);
|
|
1050
|
+
void performTranslate(missing);
|
|
1051
|
+
}, [currentValue, defaultLanguage, languages, performTranslate]);
|
|
1052
|
+
const missingCount = languages.filter(
|
|
1053
|
+
(lang) => lang !== defaultLanguage && isMissing(currentValue, lang)
|
|
1054
|
+
).length;
|
|
1055
|
+
const activeText = currentValue[activeLang] ?? "";
|
|
1056
|
+
const activeIsLoading = loadingLangs.has(activeLang);
|
|
1057
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", "data-arqel-field": "aiTranslate", "data-field-name": name, children: [
|
|
1058
|
+
/* @__PURE__ */ jsx("div", { className: "flex items-center justify-end", children: /* @__PURE__ */ jsx(
|
|
1059
|
+
Button,
|
|
1060
|
+
{
|
|
1061
|
+
variant: "outline",
|
|
1062
|
+
size: "sm",
|
|
1063
|
+
onClick: handleTranslateAllMissing,
|
|
1064
|
+
disabled: missingCount === 0 || loadingLangs.size > 0,
|
|
1065
|
+
children: "Translate all missing"
|
|
1066
|
+
}
|
|
1067
|
+
) }),
|
|
1068
|
+
/* @__PURE__ */ jsx("div", { role: "tablist", id: tablistId, className: "flex items-center gap-1 border-b border-border", children: languages.map((lang) => {
|
|
1069
|
+
const selected = lang === activeLang;
|
|
1070
|
+
const missing = isMissing(currentValue, lang);
|
|
1071
|
+
return /* @__PURE__ */ jsxs(
|
|
1072
|
+
"button",
|
|
1073
|
+
{
|
|
1074
|
+
type: "button",
|
|
1075
|
+
role: "tab",
|
|
1076
|
+
"aria-selected": selected,
|
|
1077
|
+
"aria-controls": `${tablistId}-panel-${lang}`,
|
|
1078
|
+
"data-missing": missing ? "true" : "false",
|
|
1079
|
+
onClick: () => setActiveLang(lang),
|
|
1080
|
+
className: "inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium border-b-2 -mb-px transition-colors " + (selected ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"),
|
|
1081
|
+
children: [
|
|
1082
|
+
/* @__PURE__ */ jsx("span", { children: lang }),
|
|
1083
|
+
missing ? /* @__PURE__ */ jsx(
|
|
1084
|
+
Badge,
|
|
1085
|
+
{
|
|
1086
|
+
variant: "secondary",
|
|
1087
|
+
role: "img",
|
|
1088
|
+
"aria-label": "Missing translation",
|
|
1089
|
+
"data-testid": `missing-dot-${lang}`,
|
|
1090
|
+
className: "px-1 py-0",
|
|
1091
|
+
children: "\u2022"
|
|
1092
|
+
}
|
|
1093
|
+
) : null
|
|
1094
|
+
]
|
|
1095
|
+
},
|
|
1096
|
+
lang
|
|
1097
|
+
);
|
|
1098
|
+
}) }),
|
|
1099
|
+
/* @__PURE__ */ jsxs("div", { role: "tabpanel", id: `${tablistId}-panel-${activeLang}`, className: "flex flex-col gap-2", children: [
|
|
1100
|
+
/* @__PURE__ */ jsx(
|
|
1101
|
+
Textarea,
|
|
1102
|
+
{
|
|
1103
|
+
name: `${name}[${activeLang}]`,
|
|
1104
|
+
value: activeText,
|
|
1105
|
+
onChange: handleTextareaChange,
|
|
1106
|
+
rows: 6,
|
|
1107
|
+
"aria-label": `Translation in ${activeLang}`
|
|
1108
|
+
}
|
|
1109
|
+
),
|
|
1110
|
+
activeLang !== defaultLanguage ? /* @__PURE__ */ jsx("div", { className: "flex items-center", children: /* @__PURE__ */ jsxs(
|
|
1111
|
+
Button,
|
|
1112
|
+
{
|
|
1113
|
+
variant: "outline",
|
|
1114
|
+
size: "sm",
|
|
1115
|
+
onClick: () => handleTranslateOne(activeLang),
|
|
1116
|
+
disabled: activeIsLoading,
|
|
1117
|
+
children: [
|
|
1118
|
+
activeIsLoading ? /* @__PURE__ */ jsx("span", { role: "status", "aria-label": "Translating", children: /* @__PURE__ */ jsx(Spinner5, {}) }) : null,
|
|
1119
|
+
/* @__PURE__ */ jsxs("span", { children: [
|
|
1120
|
+
"Translate from ",
|
|
1121
|
+
defaultLanguage
|
|
1122
|
+
] })
|
|
1123
|
+
]
|
|
1124
|
+
}
|
|
1125
|
+
) }) : null
|
|
1126
|
+
] }),
|
|
1127
|
+
error !== null ? /* @__PURE__ */ jsx(Alert, { variant: "destructive", children: /* @__PURE__ */ jsx(AlertDescription, { children: error }) }) : null
|
|
1128
|
+
] });
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
export { AiExtractInput, AiImageInput, AiSelectInput, AiTextInput, AiTranslateInput, AiTextInput_default as default };
|
|
1132
|
+
//# sourceMappingURL=index.js.map
|
|
1133
|
+
//# sourceMappingURL=index.js.map
|