Fix per-block ID collisions and add block cleanup for unstructured uploads (#6944)
* unstructured block removal * Enhance unstructured block handling * Restrict block cleanup to upload UIDs * cleanup for seed block uploads * upload cleanup queue for unstructured blocks in recipe studio * Fix unstructured upload cleanup edge cases * Fix unstructured upload import ownership * Fix-unstructured-import-path-ownership * Guard failed-delete restore against stale block in unstructured drop zone * Drain queued upload cleanups when autosave is skipped --------- Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Co-authored-by: imagineer99 <samleejackson0@gmail.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com>
This commit is contained in:
parent
81f789ba85
commit
3b73cd8829
13 changed files with 502 additions and 71 deletions
|
|
@ -10,6 +10,7 @@ import binascii
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from itertools import islice
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
|
@ -59,6 +60,9 @@ UNSTRUCTURED_ALLOWED_EXTS = {".pdf", ".docx", ".txt", ".md"}
|
|||
SEED_UPLOAD_DIR = seed_uploads_root()
|
||||
UNSTRUCTURED_UPLOAD_ROOT = unstructured_uploads_root()
|
||||
_SAFE_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
|
||||
# Frontend-generated upload namespace (UUID4 hex). Legacy node ids (n1, ...)
|
||||
# never match: those directories can be shared by several recipes.
|
||||
_UPLOAD_UID_RE = re.compile(r"^[0-9a-f]{32}$")
|
||||
|
||||
|
||||
def _validate_safe_id(value: str, label: str) -> str:
|
||||
|
|
@ -580,6 +584,39 @@ async def remove_unstructured_file(block_id: str, file_id: str):
|
|||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/seed/unstructured-block/{block_id}")
|
||||
async def remove_unstructured_block(block_id: str):
|
||||
"""Delete a block's upload directory; files on disk still count toward its quota.
|
||||
|
||||
Only uid-namespaced directories may be bulk-deleted: they have exactly one
|
||||
owning block. Legacy node-id directories (n1, ...) can be shared by other
|
||||
recipes, so they are managed file-by-file instead.
|
||||
"""
|
||||
_validate_safe_id(block_id, "block_id")
|
||||
if not _UPLOAD_UID_RE.match(block_id):
|
||||
raise HTTPException(400, "Invalid block_id: only uid-namespaced blocks can be deleted")
|
||||
|
||||
block_dir = (UNSTRUCTURED_UPLOAD_ROOT / block_id).resolve()
|
||||
if not block_dir.is_relative_to(UNSTRUCTURED_UPLOAD_ROOT.resolve()):
|
||||
raise HTTPException(400, "Invalid block_id: outside upload root")
|
||||
if not block_dir.exists():
|
||||
return {"status": "ok", "deleted": False}
|
||||
|
||||
try:
|
||||
shutil.rmtree(block_dir)
|
||||
except OSError as exc:
|
||||
raise log_and_http_error(
|
||||
exc,
|
||||
500,
|
||||
"failed to delete uploaded files",
|
||||
event = "data_recipe.seed.unstructured_block_delete_failed",
|
||||
log = logger,
|
||||
) from exc
|
||||
if block_dir.exists():
|
||||
raise HTTPException(500, "failed to delete uploaded files")
|
||||
return {"status": "ok", "deleted": True}
|
||||
|
||||
|
||||
@router.post("/seed/inspect-upload", response_model = SeedInspectResponse)
|
||||
def inspect_seed_upload(payload: SeedInspectUploadRequest) -> SeedInspectResponse:
|
||||
if payload.file_ids is not None:
|
||||
|
|
|
|||
|
|
@ -124,3 +124,100 @@ def test_unstructured_upload_import_errors_stay_generic(monkeypatch, tmp_path, e
|
|||
assert result.status == "error"
|
||||
assert result.error == "Text extraction failed."
|
||||
assert _block_files(seed_route) == []
|
||||
|
||||
|
||||
_TEST_UPLOAD_UID = "0f" * 16
|
||||
|
||||
|
||||
def test_remove_unstructured_block_deletes_directory(monkeypatch, tmp_path):
|
||||
seed_route = _load_seed_route(monkeypatch, tmp_path)
|
||||
_run_upload(seed_route, "notes.txt", b"hello", block_id = _TEST_UPLOAD_UID)
|
||||
assert _block_files(seed_route, _TEST_UPLOAD_UID) != []
|
||||
|
||||
result = asyncio.run(seed_route.remove_unstructured_block(_TEST_UPLOAD_UID))
|
||||
|
||||
assert result == {"status": "ok", "deleted": True}
|
||||
assert not (seed_route.UNSTRUCTURED_UPLOAD_ROOT / _TEST_UPLOAD_UID).exists()
|
||||
|
||||
|
||||
def test_remove_unstructured_block_missing_directory_is_ok(monkeypatch, tmp_path):
|
||||
seed_route = _load_seed_route(monkeypatch, tmp_path)
|
||||
|
||||
result = asyncio.run(seed_route.remove_unstructured_block(_TEST_UPLOAD_UID))
|
||||
|
||||
assert result == {"status": "ok", "deleted": False}
|
||||
|
||||
|
||||
def test_remove_unstructured_block_rejects_unsafe_ids(monkeypatch, tmp_path):
|
||||
seed_route = _load_seed_route(monkeypatch, tmp_path)
|
||||
|
||||
with pytest.raises(seed_route.HTTPException) as exc:
|
||||
asyncio.run(seed_route.remove_unstructured_block("../escape"))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_remove_unstructured_block_rejects_legacy_node_ids(monkeypatch, tmp_path):
|
||||
seed_route = _load_seed_route(monkeypatch, tmp_path)
|
||||
_run_upload(seed_route, "notes.txt", b"hello", block_id = "n1")
|
||||
assert _block_files(seed_route, "n1") != []
|
||||
|
||||
with pytest.raises(seed_route.HTTPException) as exc:
|
||||
asyncio.run(seed_route.remove_unstructured_block("n1"))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert _block_files(seed_route, "n1") != []
|
||||
|
||||
|
||||
def test_remove_unstructured_block_rejects_symlink_escape(monkeypatch, tmp_path):
|
||||
seed_route = _load_seed_route(monkeypatch, tmp_path)
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "victim.txt").write_text("keep me")
|
||||
root = seed_route.UNSTRUCTURED_UPLOAD_ROOT
|
||||
root.mkdir(parents = True)
|
||||
(root / _TEST_UPLOAD_UID).symlink_to(outside)
|
||||
|
||||
with pytest.raises(seed_route.HTTPException) as exc:
|
||||
asyncio.run(seed_route.remove_unstructured_block(_TEST_UPLOAD_UID))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert (outside / "victim.txt").exists()
|
||||
|
||||
|
||||
def test_remove_unstructured_block_fails_if_directory_remains(monkeypatch, tmp_path):
|
||||
seed_route = _load_seed_route(monkeypatch, tmp_path)
|
||||
root = seed_route.UNSTRUCTURED_UPLOAD_ROOT
|
||||
block_dir = root / _TEST_UPLOAD_UID
|
||||
block_dir.mkdir(parents = True)
|
||||
(block_dir / "victim.txt").write_text("keep me")
|
||||
|
||||
calls = []
|
||||
|
||||
def noop_rmtree(path, *args, **kwargs):
|
||||
calls.append((path, args, kwargs))
|
||||
|
||||
monkeypatch.setattr(seed_route.shutil, "rmtree", noop_rmtree)
|
||||
|
||||
with pytest.raises(seed_route.HTTPException) as exc:
|
||||
asyncio.run(seed_route.remove_unstructured_block(_TEST_UPLOAD_UID))
|
||||
|
||||
assert calls
|
||||
assert exc.value.status_code == 500
|
||||
assert block_dir.exists()
|
||||
|
||||
|
||||
def test_total_upload_quota_is_scoped_per_block(monkeypatch, tmp_path):
|
||||
seed_route = _load_seed_route(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(seed_route, "UNSTRUCTURED_RECIPE_UPLOAD_TOTAL_MAX_BYTES", 10)
|
||||
|
||||
first = _run_upload(seed_route, "a.txt", b"123456789")
|
||||
assert first.status == "ok"
|
||||
|
||||
with pytest.raises(seed_route.HTTPException) as exc:
|
||||
_run_upload(seed_route, "b.txt", b"123")
|
||||
assert exc.value.status_code == 413
|
||||
|
||||
# Another block starts with its own untouched budget.
|
||||
other = _run_upload(seed_route, "c.txt", b"123", block_id = "other")
|
||||
assert other.status == "ok"
|
||||
|
|
|
|||
|
|
@ -494,3 +494,13 @@ export async function removeUnstructuredFile(
|
|||
throw new Error("Failed to remove file");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeUnstructuredBlock(blockId: string): Promise<void> {
|
||||
const res = await authFetch(
|
||||
`${DATA_DESIGNER_API_BASE}/seed/unstructured-block/${encodeURIComponent(blockId)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok && res.status !== 404) {
|
||||
throw new Error("Failed to remove uploaded files");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ import {
|
|||
inspectSeedDataset,
|
||||
inspectSeedUpload,
|
||||
} from "../../api";
|
||||
import { useRecipeStudioStore } from "../../stores/recipe-studio";
|
||||
import {
|
||||
makeUnstructuredUploadUid,
|
||||
resolveUnstructuredUploadBlockId,
|
||||
} from "../../utils/config-factories";
|
||||
import { resolveImagePreview } from "../../utils/image-preview";
|
||||
import type {
|
||||
GithubItemType,
|
||||
|
|
@ -597,6 +602,41 @@ export function SeedDialog({
|
|||
const mode = config.seed_source_type ?? "hf";
|
||||
const previewEmpty = getPreviewEmptyStateCopy(mode);
|
||||
|
||||
const queueUploadCleanup = useRecipeStudioStore(
|
||||
(state) => state.queueUploadCleanup,
|
||||
);
|
||||
|
||||
// config.id collides across recipes (ids reset to n1 on import); use a
|
||||
// stable per-block uid instead. Generate one synchronously so the first
|
||||
// rendered drop zone cannot upload under a legacy node id.
|
||||
const uploadUid = config.unstructured_upload_uid?.trim() ?? "";
|
||||
const unstructuredFileCount = config.unstructured_file_ids?.length ?? 0;
|
||||
const generatedUploadUidRef = useRef<string | null>(null);
|
||||
if (
|
||||
mode === "unstructured" &&
|
||||
!uploadUid &&
|
||||
unstructuredFileCount === 0 &&
|
||||
generatedUploadUidRef.current === null
|
||||
) {
|
||||
generatedUploadUidRef.current = makeUnstructuredUploadUid();
|
||||
}
|
||||
const uploadBlockId = resolveUnstructuredUploadBlockId({
|
||||
configId: config.id,
|
||||
uploadUid,
|
||||
generatedUploadUid: generatedUploadUidRef.current,
|
||||
unstructuredFileCount,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "unstructured") return;
|
||||
if (uploadUid) return;
|
||||
if (unstructuredFileCount > 0) return;
|
||||
const nextUid =
|
||||
generatedUploadUidRef.current ?? makeUnstructuredUploadUid();
|
||||
generatedUploadUidRef.current = nextUid;
|
||||
onUpdate({ unstructured_upload_uid: nextUid });
|
||||
}, [mode, uploadUid, unstructuredFileCount, onUpdate]);
|
||||
|
||||
const prevModeRef = useRef(mode);
|
||||
useEffect(() => {
|
||||
const prevMode = prevModeRef.current;
|
||||
|
|
@ -720,6 +760,11 @@ export function SeedDialog({
|
|||
subset: config.hf_subset?.trim() || undefined,
|
||||
preview_size: 10,
|
||||
});
|
||||
// Queue the block's upload directory for deletion after the next
|
||||
// save; only uid-namespaced directories qualify (single owner).
|
||||
if (uploadUid && unstructuredFileCount > 0) {
|
||||
queueUploadCleanup(uploadUid);
|
||||
}
|
||||
onUpdate({
|
||||
hf_path: response.resolved_path,
|
||||
seed_columns: response.columns,
|
||||
|
|
@ -730,6 +775,7 @@ export function SeedDialog({
|
|||
hf_split: response.split ?? "",
|
||||
hf_subset: response.subset ?? "",
|
||||
local_file_name: "",
|
||||
unstructured_upload_uid: "",
|
||||
unstructured_file_ids: [],
|
||||
unstructured_file_names: [],
|
||||
unstructured_file_sizes: [],
|
||||
|
|
@ -754,6 +800,11 @@ export function SeedDialog({
|
|||
content_base64: payload,
|
||||
preview_size: 10,
|
||||
});
|
||||
// Queue the block's upload directory for deletion after the next
|
||||
// save; only uid-namespaced directories qualify (single owner).
|
||||
if (uploadUid && unstructuredFileCount > 0) {
|
||||
queueUploadCleanup(uploadUid);
|
||||
}
|
||||
onUpdate({
|
||||
hf_path: response.resolved_path,
|
||||
seed_columns: response.columns,
|
||||
|
|
@ -765,6 +816,7 @@ export function SeedDialog({
|
|||
hf_subset: "",
|
||||
hf_split: "",
|
||||
local_file_name: localFile.name,
|
||||
unstructured_upload_uid: "",
|
||||
unstructured_file_ids: [],
|
||||
unstructured_file_names: [],
|
||||
unstructured_file_sizes: [],
|
||||
|
|
@ -789,7 +841,7 @@ export function SeedDialog({
|
|||
|
||||
const { chunkSize, chunkOverlap } = resolveChunking(config);
|
||||
const response = await inspectSeedUpload({
|
||||
block_id: config.id,
|
||||
block_id: uploadBlockId,
|
||||
file_ids: fileIds,
|
||||
file_names: fileNames,
|
||||
preview_size: 10,
|
||||
|
|
@ -827,7 +879,18 @@ export function SeedDialog({
|
|||
setIsInspecting(false);
|
||||
}
|
||||
},
|
||||
[config, getCurrentLoadKey, localFile, mode, onUpdate, unstructuredFiles],
|
||||
[
|
||||
config,
|
||||
getCurrentLoadKey,
|
||||
localFile,
|
||||
mode,
|
||||
onUpdate,
|
||||
queueUploadCleanup,
|
||||
unstructuredFiles,
|
||||
unstructuredFileCount,
|
||||
uploadBlockId,
|
||||
uploadUid,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -997,7 +1060,7 @@ export function SeedDialog({
|
|||
|
||||
{mode === "unstructured" && (
|
||||
<UnstructuredDropZone
|
||||
blockId={config.id}
|
||||
blockId={uploadBlockId}
|
||||
files={unstructuredFiles}
|
||||
onFilesChange={handleUnstructuredFilesChange}
|
||||
disabled={isInspecting}
|
||||
|
|
|
|||
|
|
@ -54,11 +54,17 @@ export function UnstructuredDropZone({
|
|||
}: UnstructuredDropZoneProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const filesRef = useRef(files);
|
||||
const blockIdRef = useRef(blockId);
|
||||
const mountedRef = useRef(true);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
filesRef.current = files;
|
||||
}, [files]);
|
||||
blockIdRef.current = blockId;
|
||||
}, [files, blockId]);
|
||||
useEffect(() => () => {
|
||||
mountedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
||||
|
||||
|
|
@ -134,15 +140,32 @@ export function UnstructuredDropZone({
|
|||
if (entry.status === "uploading" && entry.abortController) {
|
||||
entry.abortController.abort();
|
||||
}
|
||||
if (
|
||||
const needsServerRemove =
|
||||
entry.id &&
|
||||
entry.status === "ok" &&
|
||||
!deletedIdsRef.current.has(entry.id)
|
||||
) {
|
||||
deletedIdsRef.current.add(entry.id);
|
||||
void removeUnstructuredFile(blockId, entry.id).catch(() => {});
|
||||
}
|
||||
!deletedIdsRef.current.has(entry.id);
|
||||
onFilesChange((prev) => prev.filter((_, i) => i !== index));
|
||||
if (!needsServerRemove) return;
|
||||
deletedIdsRef.current.add(entry.id);
|
||||
removeUnstructuredFile(blockId, entry.id).catch(() => {
|
||||
// Skip if the drop zone unmounted or its block changed: the id no
|
||||
// longer belongs here and restoring would leak it into another block.
|
||||
if (!mountedRef.current || blockIdRef.current !== blockId) return;
|
||||
// Still exists server-side (counts toward quota); restore it at its
|
||||
// original position.
|
||||
deletedIdsRef.current.delete(entry.id);
|
||||
onFilesChange((prev) => {
|
||||
const next = [...prev];
|
||||
next.splice(Math.min(index, next.length), 0, {
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
size: entry.size,
|
||||
status: "ok",
|
||||
error: "Remove failed — try again",
|
||||
});
|
||||
return next;
|
||||
});
|
||||
});
|
||||
},
|
||||
[blockId, onFilesChange],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toastError, toastSuccess } from "@/shared/toast";
|
||||
import { normalizeNonEmptyName } from "@/utils";
|
||||
import { removeUnstructuredBlock } from "../api";
|
||||
import {
|
||||
buildSignature,
|
||||
copyTextToClipboard,
|
||||
formatSavedLabel,
|
||||
} from "../executions/execution-helpers";
|
||||
import { useRecipeStudioStore } from "../stores/recipe-studio";
|
||||
import { importRecipePayload, type RecipeSnapshot } from "../utils/import";
|
||||
import type { RecipePayloadResult } from "../utils/payload/types";
|
||||
|
||||
|
|
@ -72,7 +74,10 @@ function stripApiKeys(value: unknown): unknown {
|
|||
!Array.isArray(output.env)
|
||||
) {
|
||||
output.env = Object.fromEntries(
|
||||
Object.keys(output.env as Record<string, unknown>).map((envKey) => [envKey, ""]),
|
||||
Object.keys(output.env as Record<string, unknown>).map((envKey) => [
|
||||
envKey,
|
||||
"",
|
||||
]),
|
||||
);
|
||||
}
|
||||
return output;
|
||||
|
|
@ -82,10 +87,7 @@ function inferHfRepoIdFromPath(pathValue: unknown): string {
|
|||
if (typeof pathValue !== "string") {
|
||||
return "";
|
||||
}
|
||||
const parts = pathValue
|
||||
.trim()
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
const parts = pathValue.trim().split("/").filter(Boolean);
|
||||
if (parts.length >= 3 && parts[0] === "datasets") {
|
||||
return `${parts[1]}/${parts[2]}`;
|
||||
}
|
||||
|
|
@ -126,8 +128,7 @@ function sanitizeSeedForShare(payload: unknown): unknown {
|
|||
typeof ui?.seed_source_type === "string" ? ui.seed_source_type : null;
|
||||
const sourceType =
|
||||
typeof source?.seed_type === "string" ? source.seed_type : null;
|
||||
const shouldResetHfState =
|
||||
sourceType === "hf" || uiSourceType === "hf";
|
||||
const shouldResetHfState = sourceType === "hf" || uiSourceType === "hf";
|
||||
const shouldResetLocalState =
|
||||
sourceType === "local" ||
|
||||
sourceType === "unstructured" ||
|
||||
|
|
@ -144,6 +145,7 @@ function sanitizeSeedForShare(payload: unknown): unknown {
|
|||
ui.seed_drop_columns = [];
|
||||
ui.seed_preview_rows = [];
|
||||
ui.local_file_name = "";
|
||||
ui.unstructured_upload_uid = "";
|
||||
ui.unstructured_file_ids = [];
|
||||
ui.unstructured_file_names = [];
|
||||
ui.unstructured_file_sizes = [];
|
||||
|
|
@ -165,6 +167,7 @@ function sanitizeSeedForShare(payload: unknown): unknown {
|
|||
ui.seed_drop_columns = [];
|
||||
ui.seed_preview_rows = [];
|
||||
ui.local_file_name = "";
|
||||
ui.unstructured_upload_uid = "";
|
||||
ui.unstructured_file_ids = [];
|
||||
ui.unstructured_file_names = [];
|
||||
ui.unstructured_file_sizes = [];
|
||||
|
|
@ -174,6 +177,43 @@ function sanitizeSeedForShare(payload: unknown): unknown {
|
|||
return root;
|
||||
}
|
||||
|
||||
// Delete queued upload directories once a save stops referencing them, so a
|
||||
// reload before autosave can never leave the saved recipe pointing at
|
||||
// already-deleted files. Skips any uid the just-saved payload still uses.
|
||||
function drainQueuedUploadCleanups(
|
||||
savedPayload: RecipePayloadResult["payload"],
|
||||
): void {
|
||||
const pending = useRecipeStudioStore.getState().pendingUploadCleanups;
|
||||
if (pending.length === 0) {
|
||||
return;
|
||||
}
|
||||
const ui =
|
||||
savedPayload && typeof savedPayload === "object"
|
||||
? (savedPayload as { ui?: Record<string, unknown> }).ui
|
||||
: undefined;
|
||||
const savedUid =
|
||||
ui && typeof ui.unstructured_upload_uid === "string"
|
||||
? ui.unstructured_upload_uid
|
||||
: "";
|
||||
const ready = pending.filter((uid) => uid !== savedUid);
|
||||
if (ready.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const uid of ready) {
|
||||
void removeUnstructuredBlock(uid)
|
||||
.then(() => {
|
||||
useRecipeStudioStore.setState((state) => ({
|
||||
pendingUploadCleanups: state.pendingUploadCleanups.filter(
|
||||
(pendingUid) => pendingUid !== uid,
|
||||
),
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Failed to clean up uploaded documents:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useRecipePersistence({
|
||||
recipeId,
|
||||
initialRecipeName,
|
||||
|
|
@ -202,8 +242,10 @@ export function useRecipePersistence({
|
|||
() => buildSignature(normalizedWorkflowName, currentPayload),
|
||||
[currentPayload, normalizedWorkflowName],
|
||||
);
|
||||
const isDirty = savedSignature.length > 0 && currentSignature !== savedSignature;
|
||||
const saveTone: SaveTone = !isDirty && Boolean(lastSavedAt) ? "success" : "error";
|
||||
const isDirty =
|
||||
savedSignature.length > 0 && currentSignature !== savedSignature;
|
||||
const saveTone: SaveTone =
|
||||
!isDirty && Boolean(lastSavedAt) ? "success" : "error";
|
||||
const savedAtLabel = formatSavedLabel(lastSavedAt);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -214,7 +256,9 @@ export function useRecipePersistence({
|
|||
setLastSavedAt(initialSavedAt);
|
||||
setCopied(false);
|
||||
|
||||
const parsed = importRecipePayload(JSON.stringify(initialPayload));
|
||||
const parsed = importRecipePayload(JSON.stringify(initialPayload), {
|
||||
preserveUnstructuredUploads: true,
|
||||
});
|
||||
if (parsed.snapshot) {
|
||||
loadRecipe(parsed.snapshot);
|
||||
} else {
|
||||
|
|
@ -252,6 +296,7 @@ export function useRecipePersistence({
|
|||
});
|
||||
setLastSavedAt(result.updatedAt);
|
||||
setSavedSignature(buildSignature(nextName, currentPayload));
|
||||
drainQueuedUploadCleanups(currentPayload);
|
||||
} catch (error) {
|
||||
console.error("Save recipe failed:", error);
|
||||
toastError("Save failed", "Could not save recipe.");
|
||||
|
|
@ -270,11 +315,28 @@ export function useRecipePersistence({
|
|||
return () => window.clearTimeout(timeoutId);
|
||||
}, [isDirty, persistRecipe, saveLoading]);
|
||||
|
||||
// Drain queued cleanups even when autosave is skipped: a net-zero edit (add
|
||||
// then remove an unstructured seed before the 800ms debounce) keeps isDirty
|
||||
// false, so the autosave effect never drains and the queued uid leaks its
|
||||
// upload dir. Not-dirty means currentPayload equals the saved recipe, and
|
||||
// drain skips the uid it still references, so only dirs no saved recipe
|
||||
// points at are deleted (keeps the save-first invariant).
|
||||
useEffect(() => {
|
||||
if (!initialRecipeReady || isDirty || saveLoading) {
|
||||
return;
|
||||
}
|
||||
drainQueuedUploadCleanups(currentPayload);
|
||||
}, [currentPayload, initialRecipeReady, isDirty, saveLoading]);
|
||||
|
||||
const copyRecipe = useCallback(async (): Promise<void> => {
|
||||
setCopied(false);
|
||||
try {
|
||||
const safePayload = sanitizeSeedForShare(stripApiKeys(payloadResult.payload));
|
||||
const ok = await copyTextToClipboard(JSON.stringify(safePayload, null, 2));
|
||||
const safePayload = sanitizeSeedForShare(
|
||||
stripApiKeys(payloadResult.payload),
|
||||
);
|
||||
const ok = await copyTextToClipboard(
|
||||
JSON.stringify(safePayload, null, 2),
|
||||
);
|
||||
if (!ok) {
|
||||
throw new Error("Clipboard not available.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
} from "../utils/handles";
|
||||
import type { RecipeSnapshot } from "../utils/import";
|
||||
import { getLayoutedElements } from "../utils/layout";
|
||||
import { makeUnstructuredUploadUid } from "../utils/config-factories";
|
||||
import {
|
||||
centerModelInfraNodes,
|
||||
optimizeModelInfraEdgeHandles,
|
||||
|
|
@ -76,6 +77,12 @@ type RecipeStudioState = {
|
|||
nextId: number;
|
||||
nextY: number;
|
||||
fitViewTick: number;
|
||||
// Upload-uid directories whose owning block dropped them; server-side
|
||||
// deletion is deferred until a save no longer references them, so a
|
||||
// reload before autosave cannot leave a saved recipe pointing at
|
||||
// deleted files.
|
||||
pendingUploadCleanups: string[];
|
||||
queueUploadCleanup: (uid: string) => void;
|
||||
setSheetOpen: (open: boolean) => void;
|
||||
setSheetView: (view: SheetView) => void;
|
||||
setProcessors: (processors: RecipeProcessorConfig[]) => void;
|
||||
|
|
@ -137,6 +144,7 @@ const INITIAL_STATE = {
|
|||
nextId: 3,
|
||||
nextY: 280,
|
||||
fitViewTick: 0,
|
||||
pendingUploadCleanups: [],
|
||||
} satisfies Pick<
|
||||
RecipeStudioState,
|
||||
| "nodes"
|
||||
|
|
@ -154,6 +162,7 @@ const INITIAL_STATE = {
|
|||
| "nextId"
|
||||
| "nextY"
|
||||
| "fitViewTick"
|
||||
| "pendingUploadCleanups"
|
||||
>;
|
||||
|
||||
function buildAddedNodeState(
|
||||
|
|
@ -269,6 +278,20 @@ function isModelSemanticEdge(
|
|||
);
|
||||
}
|
||||
|
||||
// Upload uid of a seed block whose server-side directory becomes orphaned
|
||||
// when the block drops it. Only uid directories qualify (single owner);
|
||||
// legacy node-id directories can be shared by other recipes.
|
||||
function seedUploadCleanupUid(config: NodeConfig | undefined): string | null {
|
||||
if (!config || config.kind !== "seed") {
|
||||
return null;
|
||||
}
|
||||
const uid = config.unstructured_upload_uid?.trim();
|
||||
if (!uid || !config.unstructured_file_ids?.length) {
|
||||
return null;
|
||||
}
|
||||
return uid;
|
||||
}
|
||||
|
||||
export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
|
||||
...INITIAL_STATE,
|
||||
setSheetOpen: (open) => set({ sheetOpen: open }),
|
||||
|
|
@ -278,6 +301,12 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
|
|||
setDialogOpen: (open) => set({ dialogOpen: open }),
|
||||
setExecutionLocked: (locked) => set({ executionLocked: locked }),
|
||||
resetRecipe: () => set(INITIAL_STATE),
|
||||
queueUploadCleanup: (uid) =>
|
||||
set((state) =>
|
||||
state.pendingUploadCleanups.includes(uid)
|
||||
? state
|
||||
: { pendingUploadCleanups: [...state.pendingUploadCleanups, uid] },
|
||||
),
|
||||
selectConfig: (id) => set({ activeConfigId: id, dialogOpen: false }),
|
||||
openConfig: (id) => set({ activeConfigId: id, dialogOpen: true }),
|
||||
setLayoutDirection: (direction) =>
|
||||
|
|
@ -383,7 +412,18 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
|
|||
}
|
||||
return buildAddedNodeState(state, "sampler", type, position, openDialog);
|
||||
}),
|
||||
addSeedNode: (type, position, openDialog = true) =>
|
||||
addSeedNode: (type, position, openDialog = true) => {
|
||||
const current = get();
|
||||
if (!current.executionLocked) {
|
||||
// The reset below clears the block's upload uid and file list; queue
|
||||
// its server-side directory for deletion after the next save.
|
||||
const uid = seedUploadCleanupUid(
|
||||
Object.values(current.configs).find((config) => config.kind === "seed"),
|
||||
);
|
||||
if (uid) {
|
||||
current.queueUploadCleanup(uid);
|
||||
}
|
||||
}
|
||||
set((state) => {
|
||||
if (state.executionLocked) {
|
||||
return state;
|
||||
|
|
@ -413,6 +453,8 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
|
|||
hf_token: "",
|
||||
hf_endpoint: "https://huggingface.co",
|
||||
local_file_name: "",
|
||||
unstructured_upload_uid:
|
||||
nextSourceType === "unstructured" ? makeUnstructuredUploadUid() : "",
|
||||
unstructured_file_ids: [],
|
||||
unstructured_file_names: [],
|
||||
unstructured_file_sizes: [],
|
||||
|
|
@ -446,7 +488,8 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
|
|||
activeConfigId: existing.id,
|
||||
dialogOpen: openDialog,
|
||||
};
|
||||
}),
|
||||
});
|
||||
},
|
||||
addLlmNode: (type, position, openDialog = true) =>
|
||||
set((state) => {
|
||||
if (state.executionLocked) {
|
||||
|
|
@ -699,6 +742,9 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
|
|||
dialogOpen: false,
|
||||
sheetView: "root",
|
||||
fitViewTick: state.fitViewTick + 1,
|
||||
// Queued cleanups belong to the previous recipe; draining them after
|
||||
// a save of this one could delete files its saved payload still uses.
|
||||
pendingUploadCleanups: [],
|
||||
})),
|
||||
setAuxNodePosition: (id, position) =>
|
||||
set((state) => {
|
||||
|
|
@ -786,6 +832,17 @@ export const useRecipeStudioStore = create<RecipeStudioState>((set, get) => ({
|
|||
set(applyUpdate);
|
||||
},
|
||||
onNodesChange: (changes) => {
|
||||
const current = get();
|
||||
if (!current.executionLocked) {
|
||||
for (const change of changes) {
|
||||
if (change.type === "remove") {
|
||||
const uid = seedUploadCleanupUid(current.configs[change.id]);
|
||||
if (uid) {
|
||||
current.queueUploadCleanup(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const applyNodesChange = (state: RecipeStudioState) => {
|
||||
if (state.executionLocked) {
|
||||
return state;
|
||||
|
|
|
|||
|
|
@ -340,6 +340,8 @@ export type SeedConfig = {
|
|||
hf_token?: string;
|
||||
hf_endpoint?: string;
|
||||
local_file_name?: string;
|
||||
// ui-only: stable per-block id for uploads, since node ids collide across imports
|
||||
unstructured_upload_uid?: string;
|
||||
unstructured_file_ids?: string[];
|
||||
unstructured_file_names?: string[];
|
||||
unstructured_file_sizes?: number[];
|
||||
|
|
|
|||
|
|
@ -20,6 +20,46 @@ import type {
|
|||
} from "../types";
|
||||
import { nextName } from "./naming";
|
||||
|
||||
export function makeUnstructuredUploadUid(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID().replace(/-/g, "").toLowerCase();
|
||||
}
|
||||
if (typeof globalThis.crypto?.getRandomValues === "function") {
|
||||
const bytes = new Uint8Array(16);
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||
"",
|
||||
);
|
||||
}
|
||||
let uid = "";
|
||||
while (uid.length < 32) {
|
||||
uid += Math.floor(Math.random() * 0x100000000)
|
||||
.toString(16)
|
||||
.padStart(8, "0");
|
||||
}
|
||||
return uid.slice(0, 32);
|
||||
}
|
||||
|
||||
export function resolveUnstructuredUploadBlockId({
|
||||
configId,
|
||||
uploadUid,
|
||||
generatedUploadUid,
|
||||
unstructuredFileCount,
|
||||
}: {
|
||||
configId: string;
|
||||
uploadUid: string;
|
||||
generatedUploadUid: string | null;
|
||||
unstructuredFileCount: number;
|
||||
}): string {
|
||||
if (uploadUid) {
|
||||
return uploadUid;
|
||||
}
|
||||
if (generatedUploadUid) {
|
||||
return generatedUploadUid;
|
||||
}
|
||||
return unstructuredFileCount > 0 ? configId : "";
|
||||
}
|
||||
|
||||
export function makeSamplerConfig(
|
||||
id: string,
|
||||
samplerType: SamplerType,
|
||||
|
|
@ -368,6 +408,9 @@ export function makeSeedConfig(
|
|||
hf_token: "",
|
||||
hf_endpoint: "https://huggingface.co",
|
||||
local_file_name: "",
|
||||
...(seedSourceType === "unstructured"
|
||||
? { unstructured_upload_uid: makeUnstructuredUploadUid() }
|
||||
: {}),
|
||||
unstructured_file_ids: [],
|
||||
unstructured_file_names: [],
|
||||
unstructured_file_sizes: [],
|
||||
|
|
|
|||
|
|
@ -16,11 +16,7 @@ import type {
|
|||
} from "../../types";
|
||||
import { buildEdges } from "./edges";
|
||||
import { isRecord, parseJson, readString } from "./helpers";
|
||||
import {
|
||||
parseColumn,
|
||||
parseModelConfig,
|
||||
parseModelProvider,
|
||||
} from "./parsers";
|
||||
import { parseColumn, parseModelConfig, parseModelProvider } from "./parsers";
|
||||
import { parseSeedConfig } from "./parsers/seed-config-parser";
|
||||
import { buildNodes, parseUi } from "./ui";
|
||||
import type { ImportResult } from "./types";
|
||||
|
|
@ -43,6 +39,7 @@ type UiInput = {
|
|||
seed_drop_columns?: unknown;
|
||||
seed_preview_rows?: unknown;
|
||||
local_file_name?: unknown;
|
||||
unstructured_upload_uid?: unknown;
|
||||
unstructured_file_ids?: unknown;
|
||||
unstructured_file_names?: unknown;
|
||||
unstructured_file_sizes?: unknown;
|
||||
|
|
@ -51,6 +48,10 @@ type UiInput = {
|
|||
advanced_open_by_node?: unknown;
|
||||
};
|
||||
|
||||
type ImportRecipePayloadOptions = {
|
||||
preserveUnstructuredUploads?: boolean;
|
||||
};
|
||||
|
||||
type UiMarkdownNoteNode = {
|
||||
name: string;
|
||||
markdown: string;
|
||||
|
|
@ -90,7 +91,7 @@ function parseProcessors(input: unknown): RecipeProcessorConfig[] {
|
|||
? templateRaw
|
||||
: isRecord(templateRaw)
|
||||
? JSON.stringify(templateRaw, null, 2)
|
||||
: "{\n \"text\": \"{{ column_name }}\"\n}";
|
||||
: '{\n "text": "{{ column_name }}"\n}';
|
||||
processors.push({
|
||||
id: `p${index + 1}`,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
|
|
@ -135,9 +136,7 @@ function parseSeedDropColumns(input: unknown): string[] {
|
|||
return Array.from(values);
|
||||
}
|
||||
|
||||
function parseMcpProviders(
|
||||
input: unknown,
|
||||
): Map<string, LlmMcpProviderConfig> {
|
||||
function parseMcpProviders(input: unknown): Map<string, LlmMcpProviderConfig> {
|
||||
const providers = new Map<string, LlmMcpProviderConfig>();
|
||||
if (!Array.isArray(input)) {
|
||||
return providers;
|
||||
|
|
@ -156,13 +155,12 @@ function parseMcpProviders(
|
|||
const args = Array.isArray(item.args)
|
||||
? item.args.map((value) => String(value))
|
||||
: [];
|
||||
const envPairs =
|
||||
isRecord(item.env)
|
||||
? Object.entries(item.env).map(([key, value]) => ({
|
||||
key: String(key),
|
||||
value: String(value),
|
||||
}))
|
||||
: [];
|
||||
const envPairs = isRecord(item.env)
|
||||
? Object.entries(item.env).map(([key, value]) => ({
|
||||
key: String(key),
|
||||
value: String(value),
|
||||
}))
|
||||
: [];
|
||||
providers.set(name, {
|
||||
id: `mcp-${index + 1}`,
|
||||
name,
|
||||
|
|
@ -209,7 +207,8 @@ function parseToolConfigs(input: unknown): Map<string, LlmToolConfig> {
|
|||
allow_tools: allowTools,
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
max_tool_call_turns:
|
||||
item.max_tool_call_turns === null || item.max_tool_call_turns === undefined
|
||||
item.max_tool_call_turns === null ||
|
||||
item.max_tool_call_turns === undefined
|
||||
? "5"
|
||||
: String(item.max_tool_call_turns),
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
|
|
@ -257,7 +256,9 @@ function parseUiMarkdownNoteNodes(input: unknown): UiMarkdownNoteNode[] {
|
|||
return noteNodes;
|
||||
}
|
||||
|
||||
function parseUiToolProfileNodes(input: unknown): Map<string, Record<string, string[]>> {
|
||||
function parseUiToolProfileNodes(
|
||||
input: unknown,
|
||||
): Map<string, Record<string, string[]>> {
|
||||
const toolProfiles = new Map<string, Record<string, string[]>>();
|
||||
if (!Array.isArray(input)) {
|
||||
return toolProfiles;
|
||||
|
|
@ -312,9 +313,15 @@ function parseAdvancedOpenByNode(input: unknown): Record<string, boolean> {
|
|||
return out;
|
||||
}
|
||||
|
||||
type AdvancedOpenConfig = LlmConfig | SamplerConfig | SeedConfig | ValidatorConfig;
|
||||
type AdvancedOpenConfig =
|
||||
| LlmConfig
|
||||
| SamplerConfig
|
||||
| SeedConfig
|
||||
| ValidatorConfig;
|
||||
|
||||
function isAdvancedOpenConfig(config: NodeConfig): config is AdvancedOpenConfig {
|
||||
function isAdvancedOpenConfig(
|
||||
config: NodeConfig,
|
||||
): config is AdvancedOpenConfig {
|
||||
return (
|
||||
config.kind === "llm" ||
|
||||
config.kind === "sampler" ||
|
||||
|
|
@ -350,7 +357,8 @@ function buildToolProfileConfig(
|
|||
.map((providerName) => mcpProvidersByName.get(providerName))
|
||||
.flatMap((provider) => (provider ? [cloneMcpProvider(provider)] : [])),
|
||||
// biome-ignore lint/style/useNamingConvention: ui schema
|
||||
fetched_tools_by_provider: fetchedToolsByProfileName.get(canonical.tool_alias) ?? {},
|
||||
fetched_tools_by_provider:
|
||||
fetchedToolsByProfileName.get(canonical.tool_alias) ?? {},
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
allow_tools: [...(canonical.allow_tools ?? [])],
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
|
|
@ -360,7 +368,10 @@ function buildToolProfileConfig(
|
|||
};
|
||||
}
|
||||
|
||||
export function importRecipePayload(input: string): ImportResult {
|
||||
export function importRecipePayload(
|
||||
input: string,
|
||||
options: ImportRecipePayloadOptions = {},
|
||||
): ImportResult {
|
||||
const parsed = parseJson(input);
|
||||
if (!parsed.data || !isRecord(parsed.data)) {
|
||||
return {
|
||||
|
|
@ -369,9 +380,9 @@ export function importRecipePayload(input: string): ImportResult {
|
|||
};
|
||||
}
|
||||
|
||||
const recipe = (isRecord(parsed.data.recipe)
|
||||
? parsed.data.recipe
|
||||
: parsed.data) as RecipeInput;
|
||||
const recipe = (
|
||||
isRecord(parsed.data.recipe) ? parsed.data.recipe : parsed.data
|
||||
) as RecipeInput;
|
||||
const ui = isRecord(parsed.data.ui) ? (parsed.data.ui as UiInput) : null;
|
||||
|
||||
if (!Array.isArray(recipe.columns)) {
|
||||
|
|
@ -410,21 +421,36 @@ export function importRecipePayload(input: string): ImportResult {
|
|||
.map((row) => ({ ...row }))
|
||||
: undefined;
|
||||
const uiLocalFileName = readString(ui?.local_file_name) ?? undefined;
|
||||
// Preserve file IDs/names from saved recipes (cleared at share time by sanitizeSeedForShare)
|
||||
const uiUnstructuredFileIds: string[] = Array.isArray(ui?.unstructured_file_ids)
|
||||
? (ui.unstructured_file_ids as string[]).filter((v): v is string => typeof v === "string")
|
||||
: [];
|
||||
const uiUnstructuredFileNames: string[] = Array.isArray(ui?.unstructured_file_names)
|
||||
? (ui.unstructured_file_names as string[]).filter((v): v is string => typeof v === "string")
|
||||
: [];
|
||||
const uiUnstructuredFileSizes: number[] = Array.isArray(ui?.unstructured_file_sizes)
|
||||
? (ui.unstructured_file_sizes as number[]).filter((v): v is number => typeof v === "number")
|
||||
: [];
|
||||
const preserveUnstructuredUploads =
|
||||
options.preserveUnstructuredUploads === true;
|
||||
const uiUnstructuredUploadUid = preserveUnstructuredUploads
|
||||
? (readString(ui?.unstructured_upload_uid) ?? undefined)
|
||||
: undefined;
|
||||
const uiUnstructuredFileIds: string[] =
|
||||
preserveUnstructuredUploads && Array.isArray(ui?.unstructured_file_ids)
|
||||
? (ui.unstructured_file_ids as string[]).filter(
|
||||
(v): v is string => typeof v === "string",
|
||||
)
|
||||
: [];
|
||||
const uiUnstructuredFileNames: string[] =
|
||||
preserveUnstructuredUploads && Array.isArray(ui?.unstructured_file_names)
|
||||
? (ui.unstructured_file_names as string[]).filter(
|
||||
(v): v is string => typeof v === "string",
|
||||
)
|
||||
: [];
|
||||
const uiUnstructuredFileSizes: number[] =
|
||||
preserveUnstructuredUploads && Array.isArray(ui?.unstructured_file_sizes)
|
||||
? (ui.unstructured_file_sizes as number[]).filter(
|
||||
(v): v is number => typeof v === "number",
|
||||
)
|
||||
: [];
|
||||
const uiUnstructuredChunkSize = readStringNumber(ui?.unstructured_chunk_size);
|
||||
const uiUnstructuredChunkOverlap = readStringNumber(
|
||||
ui?.unstructured_chunk_overlap,
|
||||
);
|
||||
const uiAdvancedOpenByNode = parseAdvancedOpenByNode(ui?.advanced_open_by_node);
|
||||
const uiAdvancedOpenByNode = parseAdvancedOpenByNode(
|
||||
ui?.advanced_open_by_node,
|
||||
);
|
||||
const uiMarkdownNotes = parseUiMarkdownNoteNodes(ui?.nodes);
|
||||
const uiToolProfilesByName = parseUiToolProfileNodes(ui?.nodes);
|
||||
|
||||
|
|
@ -459,11 +485,13 @@ export function importRecipePayload(input: string): ImportResult {
|
|||
: payloadSeedDropColumns,
|
||||
seed_preview_rows: uiSeedPreviewRows,
|
||||
local_file_name: uiLocalFileName,
|
||||
unstructuredUploadUid: uiUnstructuredUploadUid,
|
||||
unstructuredFileIds: uiUnstructuredFileIds,
|
||||
unstructuredFileNames: uiUnstructuredFileNames,
|
||||
unstructuredFileSizes: uiUnstructuredFileSizes,
|
||||
unstructured_chunk_size: uiUnstructuredChunkSize,
|
||||
unstructured_chunk_overlap: uiUnstructuredChunkOverlap,
|
||||
preserveUnstructuredUploads,
|
||||
});
|
||||
if (seedConfig) {
|
||||
applyAdvancedOpen(seedConfig, uiAdvancedOpenByNode);
|
||||
|
|
@ -567,12 +595,7 @@ export function importRecipePayload(input: string): ImportResult {
|
|||
const { layouts, auxNodes, edges: uiEdges, layoutDirection } = parseUi(ui);
|
||||
const resolvedLayoutDirection = layoutDirection ?? "LR";
|
||||
const nodes = buildNodes(configs, layouts);
|
||||
const edges = buildEdges(
|
||||
configs,
|
||||
nameToId,
|
||||
uiEdges,
|
||||
resolvedLayoutDirection,
|
||||
);
|
||||
const edges = buildEdges(configs, nameToId, uiEdges, resolvedLayoutDirection);
|
||||
const auxNodePositions = Object.fromEntries(
|
||||
auxNodes.flatMap((item) => {
|
||||
const llmId = nameToId.get(item.llm);
|
||||
|
|
@ -583,10 +606,7 @@ export function importRecipePayload(input: string): ImportResult {
|
|||
}),
|
||||
);
|
||||
|
||||
const maxY = nodes.reduce(
|
||||
(acc, node) => Math.max(acc, node.position.y),
|
||||
0,
|
||||
);
|
||||
const maxY = nodes.reduce((acc, node) => Math.max(acc, node.position.y), 0);
|
||||
|
||||
return {
|
||||
errors: [],
|
||||
|
|
|
|||
|
|
@ -197,17 +197,26 @@ export function parseSeedConfig(
|
|||
seed_drop_columns?: string[];
|
||||
seed_preview_rows?: Record<string, unknown>[];
|
||||
local_file_name?: string;
|
||||
unstructuredUploadUid?: string;
|
||||
unstructuredFileIds?: string[];
|
||||
unstructuredFileNames?: string[];
|
||||
unstructuredFileSizes?: number[];
|
||||
unstructured_chunk_size?: string;
|
||||
unstructured_chunk_overlap?: string;
|
||||
preserveUnstructuredUploads?: boolean;
|
||||
},
|
||||
): SeedConfig | null {
|
||||
if (!seedConfigRaw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseSeedSettings(seedConfigRaw);
|
||||
const parsed = { ...parseSeedSettings(seedConfigRaw) };
|
||||
if (
|
||||
parsed.seed_source_type === "unstructured" &&
|
||||
options?.preserveUnstructuredUploads !== true
|
||||
) {
|
||||
parsed.hf_path = "";
|
||||
parsed.resolved_paths = [];
|
||||
}
|
||||
let sourceType: SeedSourceType = "hf";
|
||||
if (parsed.seed_source_type === "hf") {
|
||||
sourceType = "hf";
|
||||
|
|
@ -230,6 +239,9 @@ export function parseSeedConfig(
|
|||
...(options?.local_file_name !== undefined
|
||||
? { local_file_name: options.local_file_name }
|
||||
: {}),
|
||||
...(options?.unstructuredUploadUid
|
||||
? { unstructured_upload_uid: options.unstructuredUploadUid }
|
||||
: {}),
|
||||
...(options?.unstructuredFileIds !== undefined
|
||||
? { unstructured_file_ids: options.unstructuredFileIds }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -440,6 +440,9 @@ export function buildRecipePayload(
|
|||
unstructured_file_names: firstSeed.unstructured_file_names,
|
||||
unstructured_file_sizes: firstSeed.unstructured_file_sizes,
|
||||
}),
|
||||
...(firstSeed?.unstructured_upload_uid?.trim() && {
|
||||
unstructured_upload_uid: firstSeed.unstructured_upload_uid,
|
||||
}),
|
||||
...(firstSeed &&
|
||||
firstSeed.unstructured_chunk_size !== undefined && {
|
||||
unstructured_chunk_size: firstSeed.unstructured_chunk_size,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ export type RecipePayload = {
|
|||
seed_preview_rows?: Record<string, unknown>[];
|
||||
local_file_name?: string;
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
unstructured_upload_uid?: string;
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
unstructured_file_ids?: string[];
|
||||
// biome-ignore lint/style/useNamingConvention: api schema
|
||||
unstructured_file_names?: string[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue