From 67354bba055299b2dbdbbb0522acd7844afaf89d Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 2 Jun 2026 09:57:44 -0400 Subject: [PATCH 01/22] feat(autobidsify): parse .mat v7.3 files and rename local AI URL label --- .../Dashboard/DatasetOrganizer/LLMPanel.tsx | 2 +- .../DatasetOrganizer/utils/fileProcessors.ts | 68 ++++++++++++++++--- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx index c574bf5..78b36eb 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx @@ -1494,7 +1494,7 @@ const LLMPanel: React.FC = ({ {provider === "local-ollama" && ( setLocalOllamaUrl(e.target.value)} placeholder="http://localhost:11434" diff --git a/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts b/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts index 8e66d22..a896438 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts +++ b/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts @@ -170,11 +170,8 @@ export const processFile = async ( entry.content = extractExcelContent(buffer); entry.contentType = "office"; } else if (fileType === "matlab") { - entry.content = `MATLAB File: ${file.name}\nSize: ${( - file.size / 1024 - ).toFixed( - 2 - )} KB\nFormat: .mat (fNIRS data — will be converted to SNIRF by autobidsify)`; + const buffer = await file.arrayBuffer(); + entry.content = parseMatlabFile(buffer, file.name); entry.contentType = "matlab"; } else if (fileType === "dicom") { // entry.content = `DICOM File: ${file.name}\nSize: ${( @@ -399,11 +396,9 @@ export const processZip = async ( entry.content = `Binary NeuroJSON: ${fileName}\nSize: ${sizeKB} KB\nFormat: BJData`; entry.contentType = "neurojson"; } - // matlab placeholder else if (fileType === "matlab") { const arrayBuffer = await zipEntry.async("arraybuffer"); - const sizeKB = (arrayBuffer.byteLength / 1024).toFixed(2); - entry.content = `MATLAB File: ${fileName}\nSize: ${sizeKB} KB\nFormat: .mat (fNIRS data — will be converted to SNIRF by autobidsify)`; + entry.content = parseMatlabFile(arrayBuffer, fileName); entry.contentType = "matlab"; } // dicom header extraction from ZIP @@ -626,6 +621,63 @@ export const parseNiftiHeader = (buffer: ArrayBuffer): any => { } }; +// Parse .mat file — v7.3 files are HDF5; older v5 files are not supported +const parseMatlabFile = (buffer: ArrayBuffer, fileName: string): string => { + // Check magic bytes: v7.3 starts with "MATLAB 7.3" in the first 116 bytes + const header = new Uint8Array(buffer.slice(0, 116)); + const headerStr = String.fromCharCode(...header.slice(0, 10)); + const isV73 = headerStr.startsWith("MATLAB 7.3"); + + if (!isV73) { + // v5 .mat — can't parse in browser, report what we know + const sizeKB = (buffer.byteLength / 1024).toFixed(2); + return `MATLAB File: ${fileName}\nSize: ${sizeKB} KB\nFormat: .mat v5 (older format — variable names not readable in browser)\nNote: autobidsify will convert this to SNIRF locally`; + } + + // v7.3 is HDF5 — parse with jsfive + try { + const tree = parseHDF5Tree(buffer); + if (tree.error) { + return `MATLAB File: ${fileName}\nFormat: .mat v7.3 (HDF5)\nError reading contents: ${tree.error}`; + } + + const sizeKB = (buffer.byteLength / 1024).toFixed(2); + let result = `MATLAB File: ${fileName}\nSize: ${sizeKB} KB\nFormat: .mat v7.3 (HDF5)\n\nVariables:\n`; + + const vars = tree.children || []; + for (const v of vars) { + if (v.name === "#refs#") continue; // internal HDF5 reference group + if (v.type === "dataset") { + result += ` ${v.name}: shape=[${(v.shape || []).join("×")}] dtype=${v.dtype || "?"}`; + if (v.value !== undefined) { + const valStr = Array.isArray(v.value) + ? `[${v.value.slice(0, 5).join(", ")}${v.value.length > 5 ? "..." : ""}]` + : String(v.value).slice(0, 60); + result += ` = ${valStr}`; + } + result += "\n"; + } else if (v.type === "group") { + result += ` ${v.name}/ (group with ${(v.children || []).length} fields)\n`; + for (const field of (v.children || []).slice(0, 10)) { + result += ` ${field.name}`; + if (field.shape) result += `: [${field.shape.join("×")}]`; + if (field.value !== undefined) { + const valStr = Array.isArray(field.value) + ? `[${field.value.slice(0, 5).join(", ")}${field.value.length > 5 ? "..." : ""}]` + : String(field.value).slice(0, 60); + result += ` = ${valStr}`; + } + result += "\n"; + } + if ((v.children || []).length > 10) result += ` ... (${v.children.length - 10} more)\n`; + } + } + return result; + } catch (e: any) { + return `MATLAB File: ${fileName}\nFormat: .mat v7.3\nError: ${e.message}`; + } +}; + // Parse HDF5/SNIRF tree structure const parseHDF5Tree = (buffer: ArrayBuffer): any => { try { From 5ac48c83a4876493ea71e64df71194e913fd369c Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 2 Jun 2026 14:23:50 -0400 Subject: [PATCH 02/22] fix(llm): route local-ollama to localhost, not backend proxy --- .../User/Dashboard/DatasetOrganizer/utils/llm.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/User/Dashboard/DatasetOrganizer/utils/llm.ts b/src/components/User/Dashboard/DatasetOrganizer/utils/llm.ts index 4e17c34..0726c41 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/utils/llm.ts +++ b/src/components/User/Dashboard/DatasetOrganizer/utils/llm.ts @@ -123,11 +123,10 @@ export const callLLM = async ( ): Promise => { const { provider, model, apiKey, baseUrl, isAnthropic, noApiKey } = llmConfig; - // ── Qwen via Ollama proxy ───────────────────────────────────────── - // Mirrors _call_qwen() → _call_qwen_ollama() / _call_qwen_rest_api() - // Python supports local Ollama + REST API + DashScope. - // TS only supports the REST API proxy (OllamaService routes to jin.neu.edu:11434). - if (provider === "ollama" || isQwenModel(model)) { + // ── Backend Ollama proxy (save mode only) ───────────────────────── + // Routes to OllamaService → jin.neu.edu:11434. + // "local-ollama" falls through to the OpenAI-compatible block below. + if (provider === "ollama") { const temp = inferQwenTemperature(model, temperature); try { const res = await OllamaService.chat( From 69a4dd836a50cb332936c76aba327f95c9642ee3 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 2 Jun 2026 14:25:33 -0400 Subject: [PATCH 03/22] fix(llm): rename local-ollama provider to local-ai for clarity --- .../User/Dashboard/DatasetOrganizer/LLMPanel.tsx | 14 +++++++------- .../User/Dashboard/DatasetOrganizer/utils/llm.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx index 78b36eb..05b0939 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx @@ -74,7 +74,7 @@ const llmProviders: Record = { ], noApiKey: true, }, - "local-ollama": { + "local-ai": { name: "Local AI (Ollama / LM Studio / Jan)", baseUrl: "http://localhost:11434/v1/chat/completions", models: [ @@ -145,7 +145,7 @@ const LLMPanel: React.FC = ({ onClose, isPrivateMode = false, }) => { - const [provider, setProvider] = useState(isPrivateMode ? "local-ollama" : "ollama"); + const [provider, setProvider] = useState(isPrivateMode ? "local-ai" : "ollama"); const [model, setModel] = useState(isPrivateMode ? "llama3.2:latest" : "qwen3-coder-next:latest"); const [localOllamaUrl, setLocalOllamaUrl] = useState("http://localhost:11434"); // const [ollamaUrl, setOllamaUrl] = useState( @@ -177,7 +177,7 @@ const LLMPanel: React.FC = ({ provider, model, apiKey, - baseUrl: provider === "local-ollama" + baseUrl: provider === "local-ai" ? `${localOllamaUrl}/v1/chat/completions` : currentProvider.baseUrl, isAnthropic: currentProvider.isAnthropic, @@ -1440,7 +1440,7 @@ const LLMPanel: React.FC = ({ }} > {Object.entries(llmProviders) - .filter(([key]) => isPrivateMode ? key !== "ollama" : key !== "local-ollama") + .filter(([key]) => isPrivateMode ? key !== "ollama" : key !== "local-ai") .map(([key, p]) => ( {p.name} @@ -1472,7 +1472,7 @@ const LLMPanel: React.FC = ({ )} - {provider === "local-ollama" && ( + {provider === "local-ai" && ( = ({ /> )} - {isPrivateMode && provider !== "local-ollama" && ( + {isPrivateMode && provider !== "local-ai" && ( Your file information will be sent to {currentProvider.name}, an external AI service. Switch to Local AI (Ollama / LM Studio / Jan) to keep everything local. )} - {provider === "local-ollama" && ( + {provider === "local-ai" && ( Date: Tue, 2 Jun 2026 14:31:01 -0400 Subject: [PATCH 04/22] refactor(ollama): rename chat endpoint to bidsify --- backend/src/routes/ollama.routes.js | 2 +- src/services/ollama.service.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/routes/ollama.routes.js b/backend/src/routes/ollama.routes.js index be86d75..484f066 100644 --- a/backend/src/routes/ollama.routes.js +++ b/backend/src/routes/ollama.routes.js @@ -3,7 +3,7 @@ const router = express.Router(); const { proxyChat, getTags } = require("../controllers/ollama.controller"); const { requireAuth } = require("../middleware/auth.middleware"); -router.post("/chat", requireAuth, proxyChat); +router.post("/bidsify", requireAuth, proxyChat); // router.get("/tags", requireAuth, getTags); module.exports = router; diff --git a/src/services/ollama.service.ts b/src/services/ollama.service.ts index 3decc45..f725687 100644 --- a/src/services/ollama.service.ts +++ b/src/services/ollama.service.ts @@ -13,7 +13,7 @@ export const OllamaService = { temperature?: number ): Promise => { // const temperature = getQwenTemperature(model); - const response = await fetch(`${API_URL}/ollama/chat`, { + const response = await fetch(`${API_URL}/ollama/bidsify`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ From ec920b6f3c4c830694d4fdd4863e2fec3f078ee3 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 2 Jun 2026 15:43:17 -0400 Subject: [PATCH 05/22] feat(llm): fetch local AI models dynamically --- .../Dashboard/DatasetOrganizer/LLMPanel.tsx | 82 +++++++++++++++---- 1 file changed, 68 insertions(+), 14 deletions(-) diff --git a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx index 05b0939..84863a1 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx @@ -14,6 +14,7 @@ import { AutoAwesome, DriveFileMove, InfoOutlined, + Refresh, } from "@mui/icons-material"; import { Box, @@ -172,6 +173,10 @@ const LLMPanel: React.FC = ({ const [panelHeight, setPanelHeight] = useState(450); const [isResizing, setIsResizing] = useState(false); + const [localModels, setLocalModels] = useState>([]); + const [fetchingModels, setFetchingModels] = useState(false); + const [fetchModelsError, setFetchModelsError] = useState(null); + // Build LLMConfig for all helper calls — mirrors autobidsify CLI arg assembly const buildLLMConfig = (): LLMConfig => ({ provider, @@ -184,6 +189,27 @@ const LLMPanel: React.FC = ({ noApiKey: currentProvider.noApiKey, }); + const fetchLocalModels = async () => { + setFetchingModels(true); + setFetchModelsError(null); + try { + const res = await fetch(`${localOllamaUrl}/v1/models`); + if (!res.ok) throw new Error(`Server returned ${res.status}`); + const data = await res.json(); + const models: Array<{ id: string; name: string }> = (data.data ?? []).map((m: any) => ({ + id: m.id, + name: m.id, + })); + if (models.length === 0) throw new Error("No models found"); + setLocalModels(models); + setModel(models[0].id); + } catch (e: any) { + setFetchModelsError("Could not fetch models — is your local AI running?"); + } finally { + setFetchingModels(false); + } + }; + // ======================================================================== // BUTTON 1: GENERATE EVIDENCE BUNDLE // ======================================================================== @@ -1456,20 +1482,48 @@ const LLMPanel: React.FC = ({ )} {provider !== "ollama" && ( - - Model - - + + + Model + + + {provider === "local-ai" && ( + + + + {fetchingModels ? : } + + + + )} + + )} + {fetchModelsError && provider === "local-ai" && ( + {fetchModelsError} )} {provider === "local-ai" && ( From 8b67e8ca2bfdaa5e6e6d259e65c4aa0b8e7da4f2 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 2 Jun 2026 16:07:31 -0400 Subject: [PATCH 06/22] =?UTF-8?q?revert(llm):=20remove=20local=20model=20f?= =?UTF-8?q?etch=20button=20=E2=80=94=20blocked=20by=20browser=20CORS/Priva?= =?UTF-8?q?te=20Network=20Access?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dashboard/DatasetOrganizer/LLMPanel.tsx | 81 ++++--------------- 1 file changed, 14 insertions(+), 67 deletions(-) diff --git a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx index 84863a1..26d2e88 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx @@ -14,7 +14,6 @@ import { AutoAwesome, DriveFileMove, InfoOutlined, - Refresh, } from "@mui/icons-material"; import { Box, @@ -173,9 +172,6 @@ const LLMPanel: React.FC = ({ const [panelHeight, setPanelHeight] = useState(450); const [isResizing, setIsResizing] = useState(false); - const [localModels, setLocalModels] = useState>([]); - const [fetchingModels, setFetchingModels] = useState(false); - const [fetchModelsError, setFetchModelsError] = useState(null); // Build LLMConfig for all helper calls — mirrors autobidsify CLI arg assembly const buildLLMConfig = (): LLMConfig => ({ @@ -189,27 +185,6 @@ const LLMPanel: React.FC = ({ noApiKey: currentProvider.noApiKey, }); - const fetchLocalModels = async () => { - setFetchingModels(true); - setFetchModelsError(null); - try { - const res = await fetch(`${localOllamaUrl}/v1/models`); - if (!res.ok) throw new Error(`Server returned ${res.status}`); - const data = await res.json(); - const models: Array<{ id: string; name: string }> = (data.data ?? []).map((m: any) => ({ - id: m.id, - name: m.id, - })); - if (models.length === 0) throw new Error("No models found"); - setLocalModels(models); - setModel(models[0].id); - } catch (e: any) { - setFetchModelsError("Could not fetch models — is your local AI running?"); - } finally { - setFetchingModels(false); - } - }; - // ======================================================================== // BUTTON 1: GENERATE EVIDENCE BUNDLE // ======================================================================== @@ -1482,48 +1457,20 @@ const LLMPanel: React.FC = ({ )} {provider !== "ollama" && ( - - - Model - - - {provider === "local-ai" && ( - - - - {fetchingModels ? : } - - - - )} - - )} - {fetchModelsError && provider === "local-ai" && ( - {fetchModelsError} + + Model + + )} {provider === "local-ai" && ( From 82a7d3e7351862376ffa247e047c3e89744150c1 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 3 Jun 2026 12:34:39 -0400 Subject: [PATCH 07/22] feat(dropzone): add Browse Folder button with full folder tree structure preserved --- .../Dashboard/DatasetOrganizer/DropZone.tsx | 55 ++++++++++++++++--- .../Dashboard/DatasetOrganizer/LLMPanel.tsx | 29 ++++++---- .../DatasetOrganizer/utils/fileProcessors.ts | 45 +++++++++++++++ 3 files changed, 109 insertions(+), 20 deletions(-) diff --git a/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx b/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx index 679d978..db9bbaa 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/DropZone.tsx @@ -1,5 +1,5 @@ // src/components/DatasetOrganizer/DropZone.tsx -import { processFile, processFolder, processZip } from "./utils/fileProcessors"; +import { processFile, processFolder, processFolderFromFiles, processZip } from "./utils/fileProcessors"; import { CloudUpload, Add, CheckCircle } from "@mui/icons-material"; import { Box, @@ -38,6 +38,7 @@ const DropZone: React.FC = ({ const [isDragging, setIsDragging] = useState(false); const [isProcessing, setIsProcessing] = useState(false); // ← add const fileInputRef = useRef(null); + const folderInputRef = useRef(null); // const [basePath, setBasePath] = useState(""); // change const handleDragOver = (e: React.DragEvent) => { @@ -117,6 +118,24 @@ const DropZone: React.FC = ({ } }; + const handleFolderSelect = async (e: React.ChangeEvent) => { + const selectedFiles = Array.from(e.target.files || []); + if (selectedFiles.length === 0) return; + setIsProcessing(true); + + // Auto-detect root folder name from webkitRelativePath (e.g. "myDataset/sub-01/file.nii") + const rootFolder = selectedFiles[0].webkitRelativePath.split("/")[0]; + if (rootFolder && !baseDirectoryPath) setBaseDirectoryPath(rootFolder); + + try { + const items = await processFolderFromFiles(selectedFiles, baseDirectoryPath); + setFiles((prev) => [...prev, ...items]); + } finally { + setIsProcessing(false); + e.target.value = ""; + } + }; + return ( {/* Show file count if files exist */} @@ -136,7 +155,7 @@ const DropZone: React.FC = ({ onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} - onClick={() => fileInputRef.current?.click()} + onClick={(e) => { if (e.target === e.currentTarget) fileInputRef.current?.click(); }} sx={{ border: `2px dashed ${isDragging ? Colors.purple : Colors.lightGray}`, borderRadius: 2, @@ -198,13 +217,24 @@ const DropZone: React.FC = ({ 📁 Folders • 🗜️ ZIP files • 📄 Documents (.json, .txt, .md) • 📊 Office (.docx, .pdf, .xlsx) - + + + + )} = ({ onChange={handleFileSelect} accept=".nii,.nii.gz,.snirf,.h5,.hdf5,.jnii,.jmsh,.json,.txt,.md,.zip,.docx,.pdf,.xlsx,.xls,.mat,.dcm,.nirs" /> + = { name: "Local AI (Ollama / LM Studio / Jan)", baseUrl: "http://localhost:11434/v1/chat/completions", models: [ - { id: "llama3.2:latest", name: "Llama 3.2 (Ollama)" }, - { id: "llama3.1:latest", name: "Llama 3.1 (Ollama)" }, - { id: "qwen2.5-coder:latest", name: "Qwen 2.5 Coder (Ollama)" }, - { id: "mistral:latest", name: "Mistral (Ollama)" }, - { id: "gemma3:latest", name: "Gemma 3 (Ollama)" }, - { id: "llama-3.2-3b-instruct", name: "Llama 3.2 3B (LM Studio)" }, - { id: "llama-3.1-8b-instruct", name: "Llama 3.1 8B (LM Studio)" }, - { id: "mistral-7b-instruct-v0.3", name: "Mistral 7B (LM Studio)" }, - { id: "llama3.2:3b", name: "Llama 3.2 3B (Jan)" }, - { id: "mistral:7b", name: "Mistral 7B (Jan)" }, + // Ollama + { id: "qwen3.5:latest", name: "Qwen3.5 (Ollama)" }, + { id: "qwen3:latest", name: "Qwen3 (Ollama)" }, + { id: "qwen3:30b", name: "Qwen3 30B (Ollama)" }, + { id: "deepseek-r1:latest", name: "DeepSeek R1 (Ollama)" }, + { id: "llama4:latest", name: "Llama 4 Scout (Ollama)" }, + { id: "gemma4:latest", name: "Gemma 4 (Ollama)" }, + { id: "gemma4:e4b", name: "Gemma 4 E4B — low RAM (Ollama)" }, + { id: "gpt-oss:20b", name: "GPT-OSS 20B (Ollama)" }, + { id: "qwen2.5-coder:32b", name: "Qwen2.5 Coder 32B (Ollama)" }, + // LM Studio / Jan — use Custom Model Name field if ID differs + { id: "qwen3.5", name: "Qwen3.5 (LM Studio / Jan)" }, + { id: "qwen3-coder-next", name: "Qwen3 Coder Next (LM Studio / Jan)" }, + { id: "llama4-scout-17b", name: "Llama 4 Scout 17B (LM Studio / Jan)" }, + { id: "deepseek-r1", name: "DeepSeek R1 (LM Studio / Jan)" }, + { id: "gemma4", name: "Gemma 4 (LM Studio / Jan)" }, + { id: "phi-4", name: "Phi-4 (LM Studio / Jan)" }, ], noApiKey: true, }, @@ -146,7 +153,7 @@ const LLMPanel: React.FC = ({ isPrivateMode = false, }) => { const [provider, setProvider] = useState(isPrivateMode ? "local-ai" : "ollama"); - const [model, setModel] = useState(isPrivateMode ? "llama3.2:latest" : "qwen3-coder-next:latest"); + const [model, setModel] = useState(isPrivateMode ? "qwen3.5:latest" : "qwen3-coder-next:latest"); const [localOllamaUrl, setLocalOllamaUrl] = useState("http://localhost:11434"); // const [ollamaUrl, setOllamaUrl] = useState( // "http://jin.neu.edu:11434" diff --git a/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts b/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts index a896438..aacea87 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts +++ b/src/components/User/Dashboard/DatasetOrganizer/utils/fileProcessors.ts @@ -213,6 +213,51 @@ export const processFile = async ( }; // Process ZIP files +// Builds a FileItem tree from File[] with webkitRelativePath (folder picker input) +export const processFolderFromFiles = async ( + files: File[], + basePath?: string +): Promise => { + const allItems: FileItem[] = []; + const pathMap: Record = {}; // folder path → id + + const sorted = [...files].sort((a, b) => + a.webkitRelativePath.localeCompare(b.webkitRelativePath) + ); + + for (const file of sorted) { + const relPath = file.webkitRelativePath || file.name; + const parts = relPath.split("/"); + + // Build folder hierarchy for each path segment except the filename + let parentId: string | null = null; + let currentPath = ""; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + currentPath = currentPath ? `${currentPath}/${part}` : part; + if (!pathMap[currentPath]) { + const folderId = generateId(); + pathMap[currentPath] = folderId; + allItems.push({ + id: folderId, + name: part, + type: "folder", + parentId, + sourcePath: currentPath, + } as FileItem); + } + parentId = pathMap[currentPath]; + } + + // Process the file itself + const fileItem = await processFile(file, basePath); + fileItem.parentId = parentId; + allItems.push(fileItem); + } + + return allItems; +}; + export const processZip = async ( file: File, basePath?: string From bc495370a2359be051c291f3f250a5bb531fc4e6 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Wed, 3 Jun 2026 13:44:52 -0400 Subject: [PATCH 08/22] feat(connector): route local-ai through connector when available; detect Ollama/LM Studio/Jan automatically --- .../Dashboard/DatasetOrganizer/LLMPanel.tsx | 3 + .../Dashboard/DatasetOrganizer/utils/llm.ts | 40 +++++++++++- src/pages/BidsConverterPage.tsx | 65 ++++++++++++++++++- 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx index 0d7c7ad..2a986f1 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/LLMPanel.tsx @@ -49,6 +49,7 @@ interface LLMPanelProps { updateFiles: (updater: React.SetStateAction) => void; // ✅ Add onClose: () => void; isPrivateMode?: boolean; + connectorUrl?: string; } interface LLMProvider { @@ -151,6 +152,7 @@ const LLMPanel: React.FC = ({ updateFiles, // ✅ Add onClose, isPrivateMode = false, + connectorUrl, }) => { const [provider, setProvider] = useState(isPrivateMode ? "local-ai" : "ollama"); const [model, setModel] = useState(isPrivateMode ? "qwen3.5:latest" : "qwen3-coder-next:latest"); @@ -189,6 +191,7 @@ const LLMPanel: React.FC = ({ ? `${localOllamaUrl}/v1/chat/completions` : currentProvider.baseUrl, isAnthropic: currentProvider.isAnthropic, + connectorUrl, noApiKey: currentProvider.noApiKey, }); diff --git a/src/components/User/Dashboard/DatasetOrganizer/utils/llm.ts b/src/components/User/Dashboard/DatasetOrganizer/utils/llm.ts index ca06785..7e31616 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/utils/llm.ts +++ b/src/components/User/Dashboard/DatasetOrganizer/utils/llm.ts @@ -99,6 +99,7 @@ export interface LLMConfig { baseUrl: string; isAnthropic?: boolean; noApiKey?: boolean; + connectorUrl?: string; // set when AutoBIDSify Connector is running locally } // ============================================================================ @@ -121,7 +122,7 @@ export const callLLM = async ( temperature: number | null = null, signal?: AbortSignal ): Promise => { - const { provider, model, apiKey, baseUrl, isAnthropic, noApiKey } = llmConfig; + const { provider, model, apiKey, baseUrl, isAnthropic, noApiKey, connectorUrl } = llmConfig; // ── Backend Ollama proxy (save mode only) ───────────────────────── // Routes to OllamaService → jin.neu.edu:11434. @@ -197,6 +198,43 @@ export const callLLM = async ( } } + // ── Local AI via connector (private mode + connector running) ──────── + // Routes through AutoBIDSify Connector at localhost:3210, which proxies + // to whatever local AI is running, using baseUrl from the UI. + if (provider === "local-ai" && connectorUrl) { + const params: Record = { + model, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPayload }, + ], + baseUrl, // connector strips this and uses it as the forwarding target + }; + if (!isReasoningModel(model)) { + params.max_completion_tokens = 16000; + if (temperature !== null) params.temperature = temperature; + } else { + params.max_completion_tokens = 32000; + } + try { + const res = await fetch(`${connectorUrl}/ollama/chat`, { + method: "POST", + signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }); + const data = await res.json(); + if (!res.ok) + throw new LLMHardFail(step, "ConnectorError", data?.detail ?? res.statusText); + const content = data?.choices?.[0]?.message?.content ?? ""; + if (content.trim()) return content.trim(); + throw new LLMHardFail(step, "EmptyResponse", "Local AI returned empty content"); + } catch (e) { + if (e instanceof LLMHardFail) throw e; + throw new LLMHardFail(step, "ConnectorError", String(e)); + } + } + // ── OpenAI-compatible (OpenAI, Groq, OpenRouter) ────────────────── // Mirrors _call_openai() in llm.py. // Groq and OpenRouter use the same OpenAI-compatible API format. diff --git a/src/pages/BidsConverterPage.tsx b/src/pages/BidsConverterPage.tsx index 54bc8aa..59f70be 100644 --- a/src/pages/BidsConverterPage.tsx +++ b/src/pages/BidsConverterPage.tsx @@ -10,6 +10,7 @@ import { LockOutlined, CloudUpload, InfoOutlined, + Circle, } from "@mui/icons-material"; import { Box, @@ -23,6 +24,8 @@ import { ToggleButtonGroup, Tooltip, IconButton, + Chip, + CircularProgress, } from "@mui/material"; import { Colors } from "design/theme"; import { useAppSelector } from "hooks/useAppSelector"; @@ -51,6 +54,26 @@ const BidsConverterPage: React.FC = () => { const [evidenceBundle, setEvidenceBundle] = useState(null); const [trioGenerated, setTrioGenerated] = useState(false); + const [connectorStatus, setConnectorStatus] = useState(null); + const [checkingConnector, setCheckingConnector] = useState(false); + + const checkConnector = async () => { + setCheckingConnector(true); + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + const res = await fetch("http://localhost:3210/status", { signal: controller.signal }); + clearTimeout(timer); + if (!res.ok) throw new Error(); + const data = await res.json(); + setConnectorStatus(data); + } catch { + setConnectorStatus(null); + } finally { + setCheckingConnector(false); + } + }; + // After login succeeds in save mode, redirect to dashboard to create a project useEffect(() => { if (isLoggedIn && mode === "save") { @@ -227,7 +250,46 @@ const BidsConverterPage: React.FC = () => { - + + {connectorStatus ? ( + + AutoBIDSify Connector v{connectorStatus.version} + {[ + { label: "Local AI", ok: connectorStatus.capabilities?.localAI, yes: `${connectorStatus.tools?.localAI?.provider || "Local AI"} connected`, no: "Local AI not running" }, + { label: "DICOM conversion", ok: connectorStatus.capabilities?.dicomConversion, yes: "dcm2niix available", no: "dcm2niix not installed" }, + { label: "Validation", ok: connectorStatus.capabilities?.validation, yes: "bids-validator available", no: "bids-validator not installed" }, + ].map(({ label, ok, yes, no }) => ( + + {ok ? "✓" : "–"} + {label}: {ok ? yes : no} + + ))} + + } + componentsProps={{ tooltip: { sx: { bgcolor: "white", color: Colors.darkPurple, boxShadow: "0 2px 8px rgba(0,0,0,0.15)" } } }} + > + } + label="Connector Connected" + onClick={checkConnector} + size="small" + sx={{ bgcolor: "#e8f5e9", color: "#2e7d32", fontWeight: 500, cursor: "pointer" }} + /> + + ) : ( + + )} - )} + + + 2. + + + + Skip if already downloaded + + + + + 3. + + Open the Executor, select the unzipped conversion package folder and your raw data folder, then click Run. + + + + + )} + {/* - - Skip if already downloaded - - + + + Skip if already downloaded + 3. @@ -1838,6 +1836,7 @@ const LLMPanel: React.FC = ({ > Copy */} + {/* Download zip file for convert — moved to Next Steps card above + */} diff --git a/src/pages/BidsConverterPage.tsx b/src/pages/BidsConverterPage.tsx index 76ea3cb..06aac13 100644 --- a/src/pages/BidsConverterPage.tsx +++ b/src/pages/BidsConverterPage.tsx @@ -228,24 +228,6 @@ const BidsConverterPage: React.FC = () => { - {/* @@ -1810,7 +1810,7 @@ const LLMPanel: React.FC = ({ }} sx={{ borderColor: Colors.purple, color: Colors.purple, fontSize: "0.75rem", textTransform: "none" }} > - Download Executor + Download AutoBIDSify Converter Skip if already downloaded @@ -1819,7 +1819,7 @@ const LLMPanel: React.FC = ({ 3. - Open the Executor, select the unzipped conversion package folder and your raw data folder, then click Run. + Open the Converter, select the unzipped conversion bundle folder and your raw data folder, then click Run. diff --git a/src/pages/BidsConverterPage.tsx b/src/pages/BidsConverterPage.tsx index 28fdcfe..00f3993 100644 --- a/src/pages/BidsConverterPage.tsx +++ b/src/pages/BidsConverterPage.tsx @@ -190,7 +190,14 @@ const BidsConverterPage: React.FC = () => {
  • Drop your dataset files into the workspace.
  • Enter the number of subjects, modality, and base directory path.
  • The AI will analyze your files and generate a BIDS conversion plan.
  • -
  • Download and run the script locally to reorganize your data into BIDS format.
  • +
  • Download the conversion bundle and the AutoBIDSify Converter, then run the Converter locally to reorganize your data into BIDS format.
  • + + navigate("/about")} + sx={{ mt: 1, color: Colors.purple, cursor: "pointer", textDecoration: "underline" }} + > + Watch video tutorial } From ba5f83549bbe96f43724f68dcc0a1fa9636a3df2 Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 9 Jun 2026 13:08:07 -0400 Subject: [PATCH 21/22] fix(email): catch Ethereal API failure in dev so server doesn't crash --- backend/services/email.service.js | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/backend/services/email.service.js b/backend/services/email.service.js index 92c9f63..8fce68f 100644 --- a/backend/services/email.service.js +++ b/backend/services/email.service.js @@ -21,18 +21,23 @@ class EmailService { } async createTestTransporter() { - const testAccount = await nodemailer.createTestAccount(); - this.transporter = nodemailer.createTransport({ - host: "smtp.ethereal.email", - port: 587, - secure: false, - auth: { - user: testAccount.user, - pass: testAccount.pass, - }, - }); - console.log("📧 Using Ethereal email for development"); - console.log("Preview emails at: https://ethereal.email"); + try { + const testAccount = await nodemailer.createTestAccount(); + this.transporter = nodemailer.createTransport({ + host: "smtp.ethereal.email", + port: 587, + secure: false, + auth: { + user: testAccount.user, + pass: testAccount.pass, + }, + }); + console.log("📧 Using Ethereal email for development"); + console.log("Preview emails at: https://ethereal.email"); + } catch (err) { + console.warn("⚠️ Ethereal unavailable, email disabled in dev:", err.message); + this.transporter = null; + } } async sendVerificationEmail(user, token) { From 1561140fd74b98d45c5d588ee448f7158e9fa28a Mon Sep 17 00:00:00 2001 From: elainefan331 Date: Tue, 9 Jun 2026 13:18:12 -0400 Subject: [PATCH 22/22] feat(autobidsify): add how-to tooltip in project workspace; rename package to bundle throughout --- .../Dashboard/DatasetOrganizer/FileTree.tsx | 2 +- .../User/Dashboard/DatasetOrganizer/index.tsx | 49 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/components/User/Dashboard/DatasetOrganizer/FileTree.tsx b/src/components/User/Dashboard/DatasetOrganizer/FileTree.tsx index b6abf95..05b276f 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/FileTree.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/FileTree.tsx @@ -654,7 +654,7 @@ const FileTree: React.FC = ({ variant="body2" sx={{ color: Colors.darkPurple, fontWeight: 400 }} > - BIDS Conversion Package Preview + BIDS Conversion Bundle Preview {outputFiles diff --git a/src/components/User/Dashboard/DatasetOrganizer/index.tsx b/src/components/User/Dashboard/DatasetOrganizer/index.tsx index f9019c7..bb1d332 100644 --- a/src/components/User/Dashboard/DatasetOrganizer/index.tsx +++ b/src/components/User/Dashboard/DatasetOrganizer/index.tsx @@ -2,7 +2,7 @@ import DropZone from "./DropZone"; import FileTree from "./FileTree"; import LLMPanel from "./LLMPanel"; import { generateId } from "./utils/fileProcessors"; -import { ArrowBack, Save, GetApp, Psychology } from "@mui/icons-material"; +import { ArrowBack, Save, GetApp, Psychology, InfoOutlined } from "@mui/icons-material"; import { Box, Button, @@ -16,6 +16,7 @@ import { DialogContentText, Chip, Tooltip, + IconButton, } from "@mui/material"; import { Colors } from "design/theme"; import { useAppDispatch } from "hooks/useAppDispatch"; @@ -314,6 +315,52 @@ const DatasetOrganizer: React.FC = () => { }} /> + + How to use: + +
  • Drop your dataset files into the workspace.
  • +
  • Enter the number of subjects, modality, and base directory path.
  • +
  • The AI will analyze your files and generate a BIDS conversion plan.
  • +
  • Download the conversion bundle and the AutoBIDSify Converter, then run the Converter locally to reorganize your data into BIDS format.
  • +
    + navigate("/about")} + sx={{ mt: 1, color: Colors.purple, cursor: "pointer", textDecoration: "underline" }} + > + Watch video tutorial + + + } + placement="bottom-start" + arrow + componentsProps={{ + tooltip: { + sx: { + backgroundColor: "white", + color: Colors.darkPurple, + border: `1px solid ${Colors.lightGray}`, + boxShadow: 3, + fontSize: "0.875rem", + lineHeight: 1.5, + p: 1.5, + maxWidth: 320, + }, + }, + arrow: { + sx: { + color: "white", + "&::before": { border: `1px solid ${Colors.lightGray}` }, + }, + }, + }} + > + + + +