@agent-native/core 0.84.58 → 0.84.60
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/corpus/core/CHANGELOG.md +17 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/action.ts +76 -2
- package/corpus/core/src/server/ssr-handler.ts +14 -16
- package/corpus/templates/clips/app/i18n/ar-SA.ts +7 -0
- package/corpus/templates/clips/app/i18n/de-DE.ts +8 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +7 -0
- package/corpus/templates/clips/app/i18n/es-ES.ts +7 -0
- package/corpus/templates/clips/app/i18n/fr-FR.ts +7 -0
- package/corpus/templates/clips/app/i18n/hi-IN.ts +6 -0
- package/corpus/templates/clips/app/i18n/ja-JP.ts +8 -0
- package/corpus/templates/clips/app/i18n/ko-KR.ts +7 -0
- package/corpus/templates/clips/app/i18n/pt-BR.ts +7 -0
- package/corpus/templates/clips/app/i18n/zh-CN.ts +5 -0
- package/corpus/templates/clips/app/i18n/zh-TW.ts +5 -0
- package/corpus/templates/clips/app/routes/_app.settings._index.tsx +174 -28
- package/corpus/templates/clips/app/routes/record.tsx +74 -6
- package/dist/action.js +76 -2
- package/dist/action.js.map +1 -1
- package/dist/collab/routes.d.ts +2 -2
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/ssr-handler.d.ts.map +1 -1
- package/dist/server/ssr-handler.js +14 -16
- package/dist/server/ssr-handler.js.map +1 -1
- package/package.json +1 -1
|
@@ -399,19 +399,23 @@ export default function SettingsIndexRoute() {
|
|
|
399
399
|
const [transcriptCleanupEnabled, setTranscriptCleanupEnabled] =
|
|
400
400
|
useState(true);
|
|
401
401
|
const [s3Values, setS3Values] = useState<Record<string, string>>({});
|
|
402
|
+
const [s3Errors, setS3Errors] = useState<Record<string, string>>({});
|
|
403
|
+
const [clearingS3, setClearingS3] = useState(false);
|
|
402
404
|
const [s3Expanded, setS3Expanded] = useState(false);
|
|
403
405
|
const [apiKeysExpanded, setApiKeysExpanded] = useState(false);
|
|
404
406
|
const [apiKeyValues, setApiKeyValues] = useState<Record<string, string>>({});
|
|
405
407
|
const [apiKeyStatus, setApiKeyStatus] = useState<Record<string, boolean>>({});
|
|
408
|
+
const [secretLast4, setSecretLast4] = useState<Record<string, string>>({});
|
|
406
409
|
const [apiKeyStatusLoading, setApiKeyStatusLoading] = useState(true);
|
|
407
410
|
const [savingApiKey, setSavingApiKey] = useState<string | null>(null);
|
|
408
411
|
|
|
409
412
|
const refreshApiKeyStatus = useCallback(async () => {
|
|
410
413
|
setApiKeyStatusLoading(true);
|
|
411
414
|
try {
|
|
412
|
-
const [envRes, secretsRes] = await Promise.all([
|
|
415
|
+
const [envRes, secretsRes, adhocRes] = await Promise.all([
|
|
413
416
|
fetch(agentNativePath("/_agent-native/env-status")),
|
|
414
417
|
fetch(agentNativePath("/_agent-native/secrets")),
|
|
418
|
+
fetch(agentNativePath("/_agent-native/secrets/adhoc")),
|
|
415
419
|
]);
|
|
416
420
|
const envData = envRes.ok
|
|
417
421
|
? ((await envRes.json()) as Array<{
|
|
@@ -425,12 +429,24 @@ export default function SettingsIndexRoute() {
|
|
|
425
429
|
status?: string;
|
|
426
430
|
}>)
|
|
427
431
|
: [];
|
|
432
|
+
const adhocData = adhocRes.ok
|
|
433
|
+
? ((await adhocRes.json()) as Array<{
|
|
434
|
+
name: string;
|
|
435
|
+
last4?: string;
|
|
436
|
+
}>)
|
|
437
|
+
: [];
|
|
428
438
|
const next = Object.fromEntries(
|
|
429
439
|
envData.map((entry) => [entry.key, Boolean(entry.configured)]),
|
|
430
440
|
);
|
|
431
441
|
for (const entry of secretsData) {
|
|
432
442
|
next[entry.key] = entry.status === "set";
|
|
433
443
|
}
|
|
444
|
+
const nextLast4: Record<string, string> = {};
|
|
445
|
+
for (const entry of adhocData) {
|
|
446
|
+
next[entry.name] = true;
|
|
447
|
+
if (entry.last4) nextLast4[entry.name] = entry.last4;
|
|
448
|
+
}
|
|
449
|
+
setSecretLast4(nextLast4);
|
|
434
450
|
setApiKeyStatus(next);
|
|
435
451
|
} catch {
|
|
436
452
|
setApiKeyStatus({});
|
|
@@ -477,7 +493,36 @@ export default function SettingsIndexRoute() {
|
|
|
477
493
|
}
|
|
478
494
|
}
|
|
479
495
|
|
|
496
|
+
function validateS3Values(
|
|
497
|
+
values: Record<string, string>,
|
|
498
|
+
): Record<string, string> {
|
|
499
|
+
const errors: Record<string, string> = {};
|
|
500
|
+
const urlFields = ["S3_ENDPOINT", "S3_PUBLIC_BASE_URL"];
|
|
501
|
+
for (const key of urlFields) {
|
|
502
|
+
const val = (values[key] ?? "").trim();
|
|
503
|
+
if (!val) continue;
|
|
504
|
+
try {
|
|
505
|
+
const parsed = new URL(val);
|
|
506
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
507
|
+
errors[key] = t("settings.s3UrlInvalid");
|
|
508
|
+
}
|
|
509
|
+
} catch {
|
|
510
|
+
errors[key] = t("settings.s3UrlInvalid");
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const bucket = (values["S3_BUCKET"] ?? "").trim();
|
|
514
|
+
if (bucket && !/^[a-z0-9][a-z0-9\-.]{1,61}[a-z0-9]$/.test(bucket)) {
|
|
515
|
+
errors["S3_BUCKET"] = t("settings.s3BucketInvalid");
|
|
516
|
+
}
|
|
517
|
+
return errors;
|
|
518
|
+
}
|
|
519
|
+
|
|
480
520
|
async function handleSaveS3Storage() {
|
|
521
|
+
const validationErrors = validateS3Values(s3Values);
|
|
522
|
+
if (Object.keys(validationErrors).length > 0) {
|
|
523
|
+
setS3Errors(validationErrors);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
481
526
|
const s3Configured = storageStatus.data?.activeProvider?.id === "s3";
|
|
482
527
|
const missing = s3Configured
|
|
483
528
|
? []
|
|
@@ -499,7 +544,7 @@ export default function SettingsIndexRoute() {
|
|
|
499
544
|
...current,
|
|
500
545
|
S3_SECRET_ACCESS_KEY: "",
|
|
501
546
|
}));
|
|
502
|
-
await storageStatus.refetch();
|
|
547
|
+
await Promise.all([storageStatus.refetch(), refreshApiKeyStatus()]);
|
|
503
548
|
toast.success(t("settings.storageSaved"));
|
|
504
549
|
} catch (err) {
|
|
505
550
|
toast.error(
|
|
@@ -510,6 +555,51 @@ export default function SettingsIndexRoute() {
|
|
|
510
555
|
}
|
|
511
556
|
}
|
|
512
557
|
|
|
558
|
+
async function handleClearAllS3() {
|
|
559
|
+
setClearingS3(true);
|
|
560
|
+
try {
|
|
561
|
+
const results = await Promise.all(
|
|
562
|
+
S3_STORAGE_FIELDS.filter((field) => apiKeyStatus[field.key]).map(
|
|
563
|
+
async (field) => {
|
|
564
|
+
const res = await fetch(
|
|
565
|
+
agentNativePath(
|
|
566
|
+
`/_agent-native/secrets/adhoc/${encodeURIComponent(field.key)}`,
|
|
567
|
+
),
|
|
568
|
+
{ method: "DELETE" },
|
|
569
|
+
);
|
|
570
|
+
if (!res.ok) {
|
|
571
|
+
const body = (await res.json().catch(() => null)) as {
|
|
572
|
+
error?: string;
|
|
573
|
+
} | null;
|
|
574
|
+
throw new Error(
|
|
575
|
+
body?.error ?? `Failed to clear ${field.key} (${res.status})`,
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
const body = (await res.json().catch(() => null)) as {
|
|
579
|
+
removed?: boolean;
|
|
580
|
+
} | null;
|
|
581
|
+
return { key: field.key, removed: body?.removed !== false };
|
|
582
|
+
},
|
|
583
|
+
),
|
|
584
|
+
);
|
|
585
|
+
const failed = results.filter((r) => !r.removed).map((r) => r.key);
|
|
586
|
+
if (failed.length > 0) {
|
|
587
|
+
throw new Error(
|
|
588
|
+
`Could not remove: ${failed.join(", ")}. You may not have permission.`,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
setS3Values({});
|
|
592
|
+
await Promise.all([refreshApiKeyStatus(), storageStatus.refetch()]);
|
|
593
|
+
toast.success(t("settings.keyCleared"));
|
|
594
|
+
} catch (err) {
|
|
595
|
+
toast.error(
|
|
596
|
+
err instanceof Error ? err.message : t("settings.saveFailed"),
|
|
597
|
+
);
|
|
598
|
+
} finally {
|
|
599
|
+
setClearingS3(false);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
513
603
|
async function handleSaveApiKey(key: string) {
|
|
514
604
|
const value = (apiKeyValues[key] ?? "").trim();
|
|
515
605
|
if (!value) {
|
|
@@ -785,34 +875,90 @@ export default function SettingsIndexRoute() {
|
|
|
785
875
|
<CollapsibleContent>
|
|
786
876
|
<div className="space-y-4 border-t border-border px-3 py-4">
|
|
787
877
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
788
|
-
{S3_STORAGE_FIELDS.map((field) =>
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
878
|
+
{S3_STORAGE_FIELDS.map((field) => {
|
|
879
|
+
const configured = Boolean(
|
|
880
|
+
apiKeyStatus[field.key],
|
|
881
|
+
);
|
|
882
|
+
const last4 = secretLast4[field.key];
|
|
883
|
+
return (
|
|
884
|
+
<div key={field.key} className="space-y-1.5">
|
|
885
|
+
<div className="flex items-center justify-between gap-2">
|
|
886
|
+
<Label htmlFor={field.key}>
|
|
887
|
+
{t(field.labelKey)}
|
|
888
|
+
</Label>
|
|
889
|
+
{configured ? (
|
|
890
|
+
<span className="flex items-center gap-1 text-[10px] font-medium text-primary">
|
|
891
|
+
<IconCheck className="h-3 w-3" />
|
|
892
|
+
{last4
|
|
893
|
+
? `••••${last4}`
|
|
894
|
+
: t("settings.keySet")}
|
|
895
|
+
</span>
|
|
896
|
+
) : null}
|
|
897
|
+
</div>
|
|
898
|
+
<Input
|
|
899
|
+
id={field.key}
|
|
900
|
+
type={
|
|
901
|
+
"secret" in field && field.secret
|
|
902
|
+
? "password"
|
|
903
|
+
: "text"
|
|
904
|
+
}
|
|
905
|
+
value={s3Values[field.key] ?? ""}
|
|
906
|
+
onChange={(event) => {
|
|
907
|
+
setS3Values((current) => ({
|
|
908
|
+
...current,
|
|
909
|
+
[field.key]: event.target.value,
|
|
910
|
+
}));
|
|
911
|
+
if (s3Errors[field.key]) {
|
|
912
|
+
setS3Errors((current) => {
|
|
913
|
+
const next = { ...current };
|
|
914
|
+
delete next[field.key];
|
|
915
|
+
return next;
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
}}
|
|
919
|
+
placeholder={
|
|
920
|
+
configured
|
|
921
|
+
? t("settings.replaceKey")
|
|
922
|
+
: field.placeholder
|
|
923
|
+
}
|
|
924
|
+
autoComplete="off"
|
|
925
|
+
disabled={savingStorage}
|
|
926
|
+
className={
|
|
927
|
+
s3Errors[field.key]
|
|
928
|
+
? "border-destructive"
|
|
929
|
+
: undefined
|
|
930
|
+
}
|
|
931
|
+
/>
|
|
932
|
+
{s3Errors[field.key] ? (
|
|
933
|
+
<p className="text-[11px] text-destructive">
|
|
934
|
+
{s3Errors[field.key]}
|
|
935
|
+
</p>
|
|
936
|
+
) : null}
|
|
937
|
+
</div>
|
|
938
|
+
);
|
|
939
|
+
})}
|
|
813
940
|
</div>
|
|
814
941
|
|
|
815
|
-
<div className="flex justify-end">
|
|
942
|
+
<div className="flex items-center justify-end gap-2">
|
|
943
|
+
{S3_STORAGE_FIELDS.some(
|
|
944
|
+
(field) => apiKeyStatus[field.key],
|
|
945
|
+
) ? (
|
|
946
|
+
<Button
|
|
947
|
+
type="button"
|
|
948
|
+
variant="ghost"
|
|
949
|
+
size="sm"
|
|
950
|
+
onClick={handleClearAllS3}
|
|
951
|
+
disabled={clearingS3 || savingStorage}
|
|
952
|
+
className="text-muted-foreground hover:text-destructive"
|
|
953
|
+
>
|
|
954
|
+
{clearingS3 ? (
|
|
955
|
+
<IconLoader2 className="h-4 w-4 animate-spin" />
|
|
956
|
+
) : (
|
|
957
|
+
<IconTrash className="h-4 w-4" />
|
|
958
|
+
)}
|
|
959
|
+
{t("settings.clearAllS3")}
|
|
960
|
+
</Button>
|
|
961
|
+
) : null}
|
|
816
962
|
<Button
|
|
817
963
|
onClick={handleSaveS3Storage}
|
|
818
964
|
disabled={
|
|
@@ -462,6 +462,16 @@ function uploadTooLargeMessage(size: number, detail?: string): string {
|
|
|
462
462
|
)}) after automatic compression. Trim or export a shorter copy and upload again.`;
|
|
463
463
|
}
|
|
464
464
|
|
|
465
|
+
/** Pre-upload size rejection for a picked file — no compression has been
|
|
466
|
+
* attempted yet, so the message must not imply it has. */
|
|
467
|
+
function fileTooLargeMessage(size: number): string {
|
|
468
|
+
return `This file is too large to upload (${formatMb(
|
|
469
|
+
size,
|
|
470
|
+
)}, limit is ${formatMb(
|
|
471
|
+
MAX_UPLOAD_BYTES,
|
|
472
|
+
)}). Trim it or export a shorter copy and try again.`;
|
|
473
|
+
}
|
|
474
|
+
|
|
465
475
|
function isUploadFailureError(error: string): boolean {
|
|
466
476
|
return (
|
|
467
477
|
isUploadSizeError(error) ||
|
|
@@ -471,7 +481,7 @@ function isUploadFailureError(error: string): boolean {
|
|
|
471
481
|
|
|
472
482
|
function friendlyRecordingErrorMessage(error: string): string {
|
|
473
483
|
if (isUploadSizeError(error)) {
|
|
474
|
-
return `This video is too large for Clips
|
|
484
|
+
return `This video is too large for Clips. Trim or export a shorter copy under ${formatMb(
|
|
475
485
|
MAX_UPLOAD_BYTES,
|
|
476
486
|
)} and upload again.`;
|
|
477
487
|
}
|
|
@@ -835,6 +845,12 @@ export default function RecordRoute() {
|
|
|
835
845
|
const [compressionProgress, setCompressionProgress] = useState<number | null>(
|
|
836
846
|
null,
|
|
837
847
|
);
|
|
848
|
+
// Fraction (0-1) of upload chunks confirmed sent so far. Chunks are fixed-size
|
|
849
|
+
// slices of the already-recorded blob, so chunksSent / totalChunks is a
|
|
850
|
+
// truthful proxy for bytes uploaded — not simulated. Null means the total
|
|
851
|
+
// chunk count isn't known yet (e.g. the brief live-streaming remainder
|
|
852
|
+
// upload), so the overlay falls back to an indeterminate spinner.
|
|
853
|
+
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
|
838
854
|
|
|
839
855
|
const queryClient = useQueryClient();
|
|
840
856
|
const { isDesktopApp } = useDesktopPromo();
|
|
@@ -1103,19 +1119,29 @@ export default function RecordRoute() {
|
|
|
1103
1119
|
// upload — applies whether or not we just came from
|
|
1104
1120
|
// compressing.
|
|
1105
1121
|
setCompressionProgress(null);
|
|
1122
|
+
// Reset upload progress at the start of each upload attempt so
|
|
1123
|
+
// a retry doesn't briefly show the previous attempt's percent.
|
|
1124
|
+
setUploadProgress(null);
|
|
1106
1125
|
// Always sync the UI back to "uploading"; if we were already
|
|
1107
1126
|
// there from doStop's pre-stop transition, this is a no-op.
|
|
1108
1127
|
setUiState("uploading");
|
|
1109
1128
|
}
|
|
1110
1129
|
},
|
|
1111
|
-
onChunk: ({ index,
|
|
1130
|
+
onChunk: ({ index, total }) => {
|
|
1131
|
+
// `total` is only known once the full recording is sliced into
|
|
1132
|
+
// fixed-size chunks after stop(); the live per-chunk uploads
|
|
1133
|
+
// during recording report `total: null` and don't drive this bar.
|
|
1134
|
+
const fraction = total ? (index + 1) / total : null;
|
|
1135
|
+
setUploadProgress(fraction);
|
|
1112
1136
|
const recordingId = pendingRef.current?.id;
|
|
1113
1137
|
if (!recordingId) return;
|
|
1138
|
+
// Only expose a percentage here — this state is agent-visible, and
|
|
1139
|
+
// chunk/byte counts are an internal transport detail, not
|
|
1140
|
+
// something to surface to the user.
|
|
1114
1141
|
void writeAppState(`recording-upload-${recordingId}`, {
|
|
1115
1142
|
recordingId,
|
|
1116
1143
|
status: "uploading",
|
|
1117
|
-
|
|
1118
|
-
lastChunkBytes: bytes,
|
|
1144
|
+
progress: fraction !== null ? Math.round(fraction * 100) : null,
|
|
1119
1145
|
updatedAt: new Date().toISOString(),
|
|
1120
1146
|
}).catch(() => {});
|
|
1121
1147
|
},
|
|
@@ -1378,6 +1404,7 @@ export default function RecordRoute() {
|
|
|
1378
1404
|
setError(null);
|
|
1379
1405
|
setUiState("uploading");
|
|
1380
1406
|
setCompressionProgress(null);
|
|
1407
|
+
setUploadProgress(null);
|
|
1381
1408
|
|
|
1382
1409
|
const acceptedMime = new Set([
|
|
1383
1410
|
"video/mp4",
|
|
@@ -1406,6 +1433,22 @@ export default function RecordRoute() {
|
|
|
1406
1433
|
return;
|
|
1407
1434
|
}
|
|
1408
1435
|
|
|
1436
|
+
// Fail fast on oversized files before we probe metadata, attempt
|
|
1437
|
+
// compression, or open the upload session — no point spending time or
|
|
1438
|
+
// chunking bytes for a file the server will reject anyway. Uses the
|
|
1439
|
+
// same MAX_UPLOAD_BYTES ceiling as the (currently compression-gated)
|
|
1440
|
+
// post-compression check below and the server chunk/finalize routes.
|
|
1441
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
1442
|
+
const message = fileTooLargeMessage(file.size);
|
|
1443
|
+
if (fileUploadAbortRef.current === abort) {
|
|
1444
|
+
fileUploadAbortRef.current = null;
|
|
1445
|
+
}
|
|
1446
|
+
setError(message);
|
|
1447
|
+
setUiState("error");
|
|
1448
|
+
toast.error(message);
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1409
1452
|
let createdId: string | null = null;
|
|
1410
1453
|
try {
|
|
1411
1454
|
const status = await fetchVideoStorageStatus();
|
|
@@ -1628,6 +1671,7 @@ export default function RecordRoute() {
|
|
|
1628
1671
|
unknown
|
|
1629
1672
|
> | null) ?? null;
|
|
1630
1673
|
}
|
|
1674
|
+
setUploadProgress((i + 1) / totalChunks);
|
|
1631
1675
|
}
|
|
1632
1676
|
|
|
1633
1677
|
setUiState("complete");
|
|
@@ -1699,6 +1743,7 @@ export default function RecordRoute() {
|
|
|
1699
1743
|
fileUploadAbortRef.current = null;
|
|
1700
1744
|
}
|
|
1701
1745
|
setCompressionProgress(null);
|
|
1746
|
+
setUploadProgress(null);
|
|
1702
1747
|
}
|
|
1703
1748
|
},
|
|
1704
1749
|
[markStorageConfigured, navigate, probeVideoMetadata],
|
|
@@ -1863,6 +1908,7 @@ export default function RecordRoute() {
|
|
|
1863
1908
|
setCameraStream(null);
|
|
1864
1909
|
setPreviewStream(null);
|
|
1865
1910
|
setCompressionProgress(null);
|
|
1911
|
+
setUploadProgress(null);
|
|
1866
1912
|
setUiState("complete");
|
|
1867
1913
|
if (result.waitingForStorage) {
|
|
1868
1914
|
toast.info(t("recordRoute.recordingReadyToUpload"), {
|
|
@@ -2006,6 +2052,7 @@ export default function RecordRoute() {
|
|
|
2006
2052
|
|
|
2007
2053
|
setError(null);
|
|
2008
2054
|
setCompressionProgress(null);
|
|
2055
|
+
setUploadProgress(null);
|
|
2009
2056
|
setUiState("uploading");
|
|
2010
2057
|
try {
|
|
2011
2058
|
const retryResult = await engine.retryUpload();
|
|
@@ -2022,6 +2069,7 @@ export default function RecordRoute() {
|
|
|
2022
2069
|
body: JSON.stringify({ reason: message }),
|
|
2023
2070
|
}).catch(() => {});
|
|
2024
2071
|
setCompressionProgress(null);
|
|
2072
|
+
setUploadProgress(null);
|
|
2025
2073
|
setError(message);
|
|
2026
2074
|
setUiState("error");
|
|
2027
2075
|
toast.error(t("recordRoute.uploadFailed"), {
|
|
@@ -2085,6 +2133,7 @@ export default function RecordRoute() {
|
|
|
2085
2133
|
setPreviewStream(null);
|
|
2086
2134
|
setIsPaused(false);
|
|
2087
2135
|
setUiState("idle");
|
|
2136
|
+
setUploadProgress(null);
|
|
2088
2137
|
pendingRef.current = null;
|
|
2089
2138
|
engineRef.current = null;
|
|
2090
2139
|
}, [extensionCapture, liveTranscription]);
|
|
@@ -2427,7 +2476,9 @@ export default function RecordRoute() {
|
|
|
2427
2476
|
users wonder if the app froze). */}
|
|
2428
2477
|
{(uiState === "uploading" || uiState === "compressing") && (
|
|
2429
2478
|
<div className="fixed inset-0 z-[120] flex flex-col items-center justify-center gap-3 bg-black/70 text-white backdrop-blur">
|
|
2430
|
-
|
|
2479
|
+
{!(uiState === "uploading" && uploadProgress !== null) && (
|
|
2480
|
+
<Spinner className="h-10 w-10 text-white/70" />
|
|
2481
|
+
)}
|
|
2431
2482
|
{uiState === "compressing" ? (
|
|
2432
2483
|
<>
|
|
2433
2484
|
<div className="text-sm">
|
|
@@ -2441,7 +2492,24 @@ export default function RecordRoute() {
|
|
|
2441
2492
|
</div>
|
|
2442
2493
|
</>
|
|
2443
2494
|
) : (
|
|
2444
|
-
|
|
2495
|
+
<>
|
|
2496
|
+
<div className="text-sm">{t("recordRoute.savingRecording")}</div>
|
|
2497
|
+
{uploadProgress !== null && (
|
|
2498
|
+
<div className="flex w-48 flex-col items-center gap-1">
|
|
2499
|
+
<div className="h-1.5 w-full overflow-hidden rounded-full bg-white/20">
|
|
2500
|
+
<div
|
|
2501
|
+
className="h-full rounded-full bg-white transition-all"
|
|
2502
|
+
style={{
|
|
2503
|
+
width: `${Math.min(100, Math.max(0, Math.round(uploadProgress * 100)))}%`,
|
|
2504
|
+
}}
|
|
2505
|
+
/>
|
|
2506
|
+
</div>
|
|
2507
|
+
<div className="text-[11px] text-white/50">
|
|
2508
|
+
{Math.round(uploadProgress * 100)}%
|
|
2509
|
+
</div>
|
|
2510
|
+
</div>
|
|
2511
|
+
)}
|
|
2512
|
+
</>
|
|
2445
2513
|
)}
|
|
2446
2514
|
<button
|
|
2447
2515
|
onClick={doCancel}
|
package/dist/action.js
CHANGED
|
@@ -210,6 +210,80 @@ function wrapRunWithAudit(run, auditConfig) {
|
|
|
210
210
|
// ---------------------------------------------------------------------------
|
|
211
211
|
// Schema → JSON Schema conversion
|
|
212
212
|
// ---------------------------------------------------------------------------
|
|
213
|
+
// Keywords whose value is a single subschema.
|
|
214
|
+
const SUBSCHEMA_VALUE_KEYS = [
|
|
215
|
+
"items",
|
|
216
|
+
"additionalItems",
|
|
217
|
+
"contains",
|
|
218
|
+
"additionalProperties",
|
|
219
|
+
"not",
|
|
220
|
+
"if",
|
|
221
|
+
"then",
|
|
222
|
+
"else",
|
|
223
|
+
];
|
|
224
|
+
// Keywords whose value is an array of subschemas.
|
|
225
|
+
const SUBSCHEMA_ARRAY_KEYS = [
|
|
226
|
+
"allOf",
|
|
227
|
+
"anyOf",
|
|
228
|
+
"oneOf",
|
|
229
|
+
"prefixItems",
|
|
230
|
+
];
|
|
231
|
+
// Keywords whose value is a map of name → subschema.
|
|
232
|
+
const SUBSCHEMA_MAP_KEYS = [
|
|
233
|
+
"properties",
|
|
234
|
+
"patternProperties",
|
|
235
|
+
"$defs",
|
|
236
|
+
"definitions",
|
|
237
|
+
"dependentSchemas",
|
|
238
|
+
];
|
|
239
|
+
/**
|
|
240
|
+
* Remove JSON Schema keywords that some providers' function-calling schema
|
|
241
|
+
* validators reject. OpenAI (and Gemini via the Builder gateway) reject
|
|
242
|
+
* `propertyNames` — which Zod v4 emits for `z.record(z.string(), …)` — with a
|
|
243
|
+
* `400 invalid_function_parameters` error, causing the model turn to produce no
|
|
244
|
+
* content (surfacing as an empty assistant response). Anthropic ignores the
|
|
245
|
+
* keyword, so stripping it is safe across providers and keeps action schemas
|
|
246
|
+
* portable. `propertyNames` only constrained object *keys*; the value/shape of
|
|
247
|
+
* the object is unaffected by its removal.
|
|
248
|
+
*
|
|
249
|
+
* Only descends through actual subschema positions (properties, items, union
|
|
250
|
+
* branches, definitions, etc.) — never through value-bearing keywords like
|
|
251
|
+
* `default`, `const`, `enum`, or `examples`, whose objects may legitimately
|
|
252
|
+
* contain a `propertyNames` data key that must be preserved.
|
|
253
|
+
*/
|
|
254
|
+
function stripUnsupportedSchemaKeywords(node) {
|
|
255
|
+
if (!node || typeof node !== "object" || Array.isArray(node))
|
|
256
|
+
return node;
|
|
257
|
+
const obj = node;
|
|
258
|
+
delete obj.propertyNames;
|
|
259
|
+
for (const key of SUBSCHEMA_VALUE_KEYS) {
|
|
260
|
+
// `items`/`additionalItems` may also be an array of subschemas.
|
|
261
|
+
const value = obj[key];
|
|
262
|
+
if (Array.isArray(value)) {
|
|
263
|
+
for (const sub of value)
|
|
264
|
+
stripUnsupportedSchemaKeywords(sub);
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
stripUnsupportedSchemaKeywords(value);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (const key of SUBSCHEMA_ARRAY_KEYS) {
|
|
271
|
+
const value = obj[key];
|
|
272
|
+
if (Array.isArray(value)) {
|
|
273
|
+
for (const sub of value)
|
|
274
|
+
stripUnsupportedSchemaKeywords(sub);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const key of SUBSCHEMA_MAP_KEYS) {
|
|
278
|
+
const value = obj[key];
|
|
279
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
280
|
+
for (const sub of Object.values(value)) {
|
|
281
|
+
stripUnsupportedSchemaKeywords(sub);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return node;
|
|
286
|
+
}
|
|
213
287
|
/**
|
|
214
288
|
* Convert a Standard Schema to JSON Schema for the Claude API.
|
|
215
289
|
* Tries vendor-specific toJSONSchema first (Zod v4), then falls back
|
|
@@ -229,7 +303,7 @@ function schemaToJsonSchema(schema, _description) {
|
|
|
229
303
|
if (result && typeof result === "object") {
|
|
230
304
|
delete result.$schema;
|
|
231
305
|
}
|
|
232
|
-
return result;
|
|
306
|
+
return stripUnsupportedSchemaKeywords(result);
|
|
233
307
|
}
|
|
234
308
|
catch {
|
|
235
309
|
// Fall through to manual converter
|
|
@@ -237,7 +311,7 @@ function schemaToJsonSchema(schema, _description) {
|
|
|
237
311
|
}
|
|
238
312
|
// Fallback: manual conversion from Zod v4 internal defs
|
|
239
313
|
if (s._zod?.def) {
|
|
240
|
-
return zodDefToJsonSchema(s._zod.def);
|
|
314
|
+
return stripUnsupportedSchemaKeywords(zodDefToJsonSchema(s._zod.def));
|
|
241
315
|
}
|
|
242
316
|
// Last resort: empty object schema
|
|
243
317
|
return { type: "object", properties: {} };
|