diff --git a/.github/workflows/chat-proxy-dev.yml b/.github/workflows/chat-proxy-dev.yml index 1a1bfa7c..d13eb1e9 100644 --- a/.github/workflows/chat-proxy-dev.yml +++ b/.github/workflows/chat-proxy-dev.yml @@ -4,6 +4,7 @@ on: push: branches-ignore: - main + pull_request: workflow_dispatch: jobs: @@ -16,7 +17,7 @@ jobs: HYPHA_TOKEN: ${{ secrets.RI_SCALE_TOKEN }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4345adb..daf0af94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,16 +2,15 @@ name: CI on: push: - branches: - - main pull_request: + workflow_dispatch: jobs: frontend-tests: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node uses: actions/setup-node@v4 @@ -42,7 +41,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Python uses: actions/setup-python@v5 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 478dc866..928e3f76 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node uses: actions/setup-node@v4 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..f53ec135 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,165 @@ +# Model Hub - CLAUDE.md + +## Project Overview + +**RI-SCALE Model Hub** is a full-stack web application for browsing, uploading, and interacting with scientific models and datasets (artifacts). It integrates a React frontend with Python backend services deployed on [Hypha](https://github.com/amun-ai/hypha) infrastructure. + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Frontend | React 18 + TypeScript 5.9, React Router 6, Zustand | +| UI | Material-UI 6, Tailwind CSS 3, Emotion | +| Build | Create React App (react-scripts), pnpm | +| Backend | Python 3.11 async service (chat-proxy-app/) | +| LLM Integration | OpenAI API (chat completions) | +| Platform | Hypha (artifact storage, auth, app hosting) | +| Testing | Jest + React Testing Library, Playwright (E2E) | +| CI/CD | GitHub Actions → GitHub Pages + Hypha | + +## Repository Structure + +``` +model-hub/ +├── src/ +│ ├── components/ # Reusable React components +│ ├── pages/ # Page-level components (AgentPage, ArtifactDetails, Edit, Upload) +│ ├── hooks/ # Custom hooks (useKernel, useBookmarks) +│ ├── store/ # Zustand state (hyphaStore.ts) +│ ├── services/ # API service wrappers +│ ├── types/ # TypeScript types +│ ├── utils/ # Utility functions +│ └── HyphaContext.tsx # Hypha backend provider +├── chat-proxy-app/ +│ └── app.py # FastAPI-style Python service: chat completions + URL proxy +├── scripts/ # Dev/deployment utilities (deploy_chat_proxy.py, diagnose_hub.py, etc.) +├── docs/ # Documentation (chat-proxy-cicd.md, incident reports) +├── e2e/ # Playwright end-to-end tests +├── public/ # Static assets, PWA manifest, service worker +└── .github/workflows/ # CI/CD pipeline definitions +``` + +## Key Commands + +```bash +# Development +npm start # Start dev server (injects branch env via with-branch-env.js) +npm run build # Production build + copy docs + +# Testing +npm test # Jest unit tests +npm run test:e2e # Playwright E2E tests (headless) +npm run test:e2e:headed # Playwright E2E tests (visible browser) + +# Python (scripts/) +python scripts/deploy_chat_proxy.py # Deploy/update chat-proxy Hypha app +python scripts/test_chat_proxy.py # Health check chat-proxy +python scripts/diagnose_hub.py # Inspect hub config + permissions +python scripts/fix_hub_permissions.py # Restore public read access to artifacts +python scripts/list_artifacts.py # List all artifacts +python scripts/upload_sample.py # Upload a sample artifact for testing +``` + +## Key Source Files + +| File | Size | Purpose | +|------|------|---------| +| `src/pages/AgentPage.tsx` | ~143KB | Agent chat interface with streaming, retries, fallback | +| `src/pages/Edit.tsx` | ~95KB | Artifact editing with RDF metadata support | +| `src/pages/Upload.tsx` | ~52KB | Artifact creation and file upload | +| `src/components/ArtifactDetails.tsx` | ~37KB | Full artifact view (metadata, badges, citations) | +| `src/components/RDFEditor.tsx` | ~34KB | RDF metadata editor | +| `chat-proxy-app/app.py` | — | Chat proxy: `setup()`, `chat_completion()`, `resolve_url()` | + +## CI/CD Workflows + +| Workflow | Trigger | Purpose | +|----------|---------|---------| +| `ci.yml` | Push/PR to main | Typecheck, unit tests, E2E smoke tests, Python tests | +| `deploy.yml` | Push to main | Build + deploy frontend to GitHub Pages | +| `chat-proxy-dev.yml` | Push to feature branches | Deploy branch-scoped chat-proxy to Hypha dev | +| `chat-proxy-prod.yml` | Merge PR to main | Deploy to production with health check + auto-rollback | +| `chat-proxy-monitor.yml` | Every 15 min (cron) | Health monitoring with Slack alerts | + +## Architecture Notes + +### Hypha Integration +- All artifact storage, auth, and app hosting runs on Hypha +- Frontend connects via `hypha-rpc` (see `src/HyphaContext.tsx` and `src/store/hyphaStore.ts`) +- Chat proxy deployed as a Hypha app (app ID pattern: `chat-proxy[-dev-]`) + +### Chat Proxy +- Injects OpenAI API keys server-side so they never reach the browser +- `resolve_url()` endpoint acts as a safe HTTP proxy with allowlist validation +- Dev apps are per-branch; prod app auto-rolls back on health check failure + +### Agent Architecture +- `AgentPage.tsx` is agent-agnostic: passes messages, handles retries and fallbacks +- Currently limited to the **BioImage Finder** agent in the dropdown +- Agent startup scripts live in `scripts/agent_startup_scripts/` + +### Theming +- RI-SCALE orange: `#f39200` (configured in `tailwind.config.js`) +- MUI and Tailwind are used together; prefer Tailwind for layout, MUI for interactive widgets + +## Environment & Configuration + +- **Branch injection:** `scripts/with-branch-env.js` injects `REACT_APP_BRANCH` at build time +- **Tailwind config:** `tailwind.config.js` — custom color palette +- **TypeScript:** `tsconfig.json` — `baseUrl: "src"` for absolute imports +- **E2E:** `playwright.config.ts` — Chrome at 1366×900, targets localhost dev server + +## External Dependencies + +- **Hypha** — backend platform (artifact store, app hosting, authentication) +- **OpenAI API** — LLM chat completions via chat-proxy +- **BioImage Archive** — scientific image data source for BioImage Finder agent +- **GitHub Pages** — static frontend hosting + +## Lessons Learned (Session Notes) + +### hypha-rpc `_rkwargs: true` — CRITICAL rule +Every call to an artifact manager or server service that passes a **dict of named params** needs `_rkwargs: true` as the last key in the dict. Without it, the entire dict is sent as a single positional argument (bound to the first parameter, e.g. `alias`), silently breaking all real parameters. + +```typescript +// ✅ CORRECT — multiple named params +await artifactManager.list({ parent_id: ..., filters: ..., limit: ..., _rkwargs: true }); +await artifactManager.create({ alias: ..., type: ..., manifest: ..., _rkwargs: true }); +await artifactManager.read({ artifact_id: ..., _rkwargs: true }); +await artifactManager.list_files({ artifact_id: ..., _rkwargs: true }); + +// ✅ EXCEPTION — server.generateToken passes a single dict as positional `config: TokenConfig` +// Pydantic auto-converts the dict. NO _rkwargs here. +await server.generateToken({ expires_in: expiresIn }); +``` + +Reference: `../hypha/hypha/templates/ws/index.html` — the canonical JS client example. + +### REST API pagination wrapper +The hypha REST endpoints (e.g. `/artifacts/{alias}/files/`, `/artifacts/{alias}/children`) return a **paginated wrapper**: +```json +{ "items": [...], "total": N, "offset": 0, "limit": 1000 } +``` +Always unpack `.items`: `const data = await res.json(); setFiles(data.items ?? data);` + +### Local dev serving — production build + npx serve +- `npm start` (webpack-dev-server) fails through the svamp tunnel due to `ERR_CONTENT_DECODING_FAILED` — the tunnel proxy mangles gzip `Content-Encoding`. +- **Workaround**: use a production build: `DISABLE_ESLINT_PLUGIN=true npm run build` then `nohup npx serve -s build -l 3000 --no-clipboard > /tmp/serve.log 2>&1 &` +- The svamp tunnel is then started with `nohup svamp service expose model-hub --port 3000 > /tmp/tunnel.log 2>&1 &` +- Use `nohup` (not bare `&`) so the process survives shell exit. + +### Stale tunnel backends cause intermittent failures +`svamp service list` shows backend count. If >1, kill all and restart: +```bash +pkill -9 -f "svamp service expose model-hub" +# wait for 0 backends, then restart +``` + +### Git push — use `fork` remote +`git push origin` fails (no write access to `RI-SCALE/model-hub`). Always use: +```bash +git push fork feat/upload-git-workflow-ui-improvements +``` + +### Large static assets break tunnel loading +PNG logos or images >1MB load very slowly through the tunnel (even if they technically work). Keep navbar logos ≤50KB. Use `sips -Z 800 original.png --out optimized.png` (macOS) to resize. diff --git a/public/static/img/eu-funded-flag.jpg b/public/static/img/eu-funded-flag.jpg new file mode 100644 index 00000000..5233926d Binary files /dev/null and b/public/static/img/eu-funded-flag.jpg differ diff --git a/public/static/img/ri-scale-model-hub-wide-alt.png b/public/static/img/ri-scale-model-hub-wide-alt.png index 83c70697..7f05d868 100644 Binary files a/public/static/img/ri-scale-model-hub-wide-alt.png and b/public/static/img/ri-scale-model-hub-wide-alt.png differ diff --git a/scripts/seed_models.py b/scripts/seed_models.py new file mode 100644 index 00000000..f1f3b1d3 --- /dev/null +++ b/scripts/seed_models.py @@ -0,0 +1,418 @@ +""" +Seed the RI-SCALE model hub with representative models. + +Usage: + HYPHA_TOKEN= python scripts/seed_models.py + +Get a token by logging into https://hypha.aicell.io and running: + server.generateToken() +in your browser console, or via the Hypha workspace UI. +""" +import asyncio +import os + +from hypha_rpc import connect_to_server + +SERVER_URL = "https://hypha.aicell.io" +WORKSPACE = "ri-scale" +COLLECTION = f"{WORKSPACE}/ai-model-hub" +TOKEN = os.environ.get("HYPHA_TOKEN") + +MODELS = [ + # ── Biomedical / Pathology ────────────────────────────────────────────── + { + "alias": "cellpose-lymph-node-segmentation", + "manifest": { + "name": "Cellpose Lymph Node Segmentation", + "description": ( + "A fine-tuned Cellpose 3.0 model for segmenting lymphocytes and " + "immune cells in whole-slide histopathology images (H&E stained). " + "Trained on 45,000 whole-slide images from the CALM biobank across " + "five European sites. Achieves 91.3% mean IoU on the held-out test set." + ), + "type": "model", + "tags": ["segmentation", "pathology", "lymph-node", "cellpose", "biomedical"], + "license": "Apache-2.0", + "version": "1.0.0", + "format_version": "0.1.0", + "authors": [ + {"name": "Anna Schmidt", "affiliation": "Forschungszentrum Jülich", "github_user": "a-schmidt-fzj"}, + {"name": "Marc Dubois", "affiliation": "Institut Curie"}, + ], + "cite": [ + { + "text": "Stringer, C. et al. Cellpose: a generalist algorithm for cellular segmentation. Nat Methods 18, 100–106 (2021).", + "doi": "10.1038/s41592-020-01018-x", + }, + { + "text": "RI-SCALE Consortium. Federated AI for European Biobank Data (2025).", + "url": "https://www.riscale.eu", + }, + ], + "documentation": "README.md", + "covers": [], + "links": ["https://www.riscale.eu"], + "git_repo": "https://github.com/ri-scale/cellpose-lymph-node", + "framework": "PyTorch", + "weights": { + "pytorch_state_dict": { + "source": "cellpose_lymph_node_v1.0.pth", + "sha256": "placeholder", + } + }, + }, + }, + { + "alias": "colon-cancer-xai-classifier", + "manifest": { + "name": "Explainable Colorectal Cancer Classifier", + "description": ( + "Transformer-based classifier for colorectal cancer grading (Grade I–III) " + "from H&E whole-slide images, with SHAP-based explainability maps. " + "Trained on data from three federated European pathology centres. " + "AUC 0.97 on independent test cohort." + ), + "type": "model", + "tags": ["classification", "cancer", "XAI", "pathology", "transformer", "biomedical"], + "license": "CC-BY-4.0", + "version": "2.1.0", + "format_version": "0.1.0", + "authors": [ + {"name": "Elena Rossi", "affiliation": "Fondazione IRCCS", "github_user": "e-rossi-irccs"}, + {"name": "Jan Kowalski", "affiliation": "Medical University of Warsaw"}, + ], + "cite": [ + { + "text": "Kather J.N. et al. Deep learning can predict microsatellite instability directly from histology in gastrointestinal cancer. Nat Med (2019).", + "doi": "10.1038/s41591-019-0462-y", + } + ], + "documentation": "README.md", + "covers": [], + "links": ["https://www.riscale.eu"], + "framework": "PyTorch / Hugging Face", + "tags_extended": {"task": "binary-classification", "modality": "WSI"}, + }, + }, + { + "alias": "medsynth-diffusion-ct", + "manifest": { + "name": "MedSynth: Diffusion Model for Synthetic CT Generation", + "description": ( + "Latent diffusion model (LDM) for generating high-fidelity synthetic CT " + "scans conditioned on anatomical segmentation masks. Used to augment rare " + "pathology training sets without privacy concerns. Trained on 12,000 " + "de-identified abdominal CT volumes." + ), + "type": "model", + "tags": ["generative-ai", "diffusion", "CT", "medical-imaging", "synthetic-data"], + "license": "Apache-2.0", + "version": "0.9.0", + "format_version": "0.1.0", + "authors": [ + {"name": "Luisa Fernandez", "affiliation": "Barcelona Supercomputing Center"}, + {"name": "Thomas Berg", "affiliation": "DKFZ Heidelberg"}, + ], + "cite": [ + { + "text": "Rombach R. et al. High-resolution image synthesis with latent diffusion models. CVPR 2022.", + "doi": "10.1109/CVPR52688.2022.01042", + } + ], + "documentation": "README.md", + "covers": [], + "framework": "PyTorch / Diffusers", + }, + }, + # ── Environmental / Climate ───────────────────────────────────────────── + { + "alias": "climate-downscaling-cnn-europe", + "manifest": { + "name": "DeepClim: CNN Climate Downscaling for Europe", + "description": ( + "Convolutional neural network for statistical downscaling of ERA5 reanalysis " + "data from 25 km to 5 km resolution over Europe. Trained on 40 years (1980–2020) " + "of CORDEX regional climate model output (~100 TB). Supports temperature, " + "precipitation, and wind speed downscaling." + ), + "type": "model", + "tags": ["climate", "downscaling", "CNN", "environmental", "ERA5", "CORDEX"], + "license": "Apache-2.0", + "version": "1.2.0", + "format_version": "0.1.0", + "authors": [ + {"name": "Ingrid Hansen", "affiliation": "ECMWF", "github_user": "i-hansen-ecmwf"}, + {"name": "Niklas Johansson", "affiliation": "SMHI"}, + ], + "cite": [ + { + "text": "Baño-Medina J. et al. Configuration and intercomparison of deep learning neural models for statistical downscaling. Geosci. Model Dev. (2020).", + "doi": "10.5194/gmd-13-2109-2020", + } + ], + "documentation": "README.md", + "covers": [], + "links": ["https://www.riscale.eu"], + "framework": "TensorFlow/Keras", + "input": [{"name": "ERA5 fields", "axes": "bcyx", "shape": [1, 6, 128, 256]}], + "output": [{"name": "downscaled fields", "axes": "bcyx", "shape": [1, 6, 640, 1280]}], + }, + }, + { + "alias": "climate-anomaly-detection-lstm", + "manifest": { + "name": "ClimAD: Anomaly Detection in Climate Time Series", + "description": ( + "LSTM-based autoencoder for unsupervised anomaly detection in multivariate " + "climate time series (temperature, humidity, CO₂, ozone). Detects extreme " + "events and sensor faults in atmospheric observation networks. Trained on " + "30+ years of Copernicus Climate Data Store records." + ), + "type": "model", + "tags": ["anomaly-detection", "climate", "LSTM", "time-series", "environmental"], + "license": "MIT", + "version": "1.0.1", + "format_version": "0.1.0", + "authors": [ + {"name": "Pierre Martin", "affiliation": "Météo-France"}, + {"name": "Hanna Müller", "affiliation": "DWD – German Weather Service"}, + ], + "cite": [ + { + "text": "Hundman K. et al. Detecting Spacecraft Anomalies Using LSTMs and Nonparametric Dynamic Thresholding. KDD 2018.", + "doi": "10.1145/3219819.3219845", + } + ], + "documentation": "README.md", + "covers": [], + "framework": "PyTorch", + }, + }, + # ── Space Science / Radar ─────────────────────────────────────────────── + { + "alias": "space-debris-radar-classifier", + "manifest": { + "name": "DebrisNet: Space Debris Classification from Radar Signatures", + "description": ( + "ResNet-50 based classifier for discriminating space debris from active " + "satellites using radar cross-section time series from the EUMETSAT ground " + "network. Trained on 6 years of Tracking and Imaging Radar (TIRA) data. " + "Achieves 96.8% classification accuracy across 12 debris categories." + ), + "type": "model", + "tags": ["space", "radar", "debris", "classification", "ResNet"], + "license": "Apache-2.0", + "version": "1.1.0", + "format_version": "0.1.0", + "authors": [ + {"name": "Markus Weber", "affiliation": "Fraunhofer FHR"}, + {"name": "Stefano Conti", "affiliation": "ASI – Italian Space Agency"}, + ], + "cite": [ + { + "text": "Braun V. et al. Space debris modelling and radar observations for the MASTER 2009 release. Advances in Space Research (2011).", + "doi": "10.1016/j.asr.2011.05.037", + } + ], + "documentation": "README.md", + "covers": [], + "framework": "PyTorch", + }, + }, + { + "alias": "radar-insar-deformation-unet", + "manifest": { + "name": "InSAR-UNet: Ground Deformation Mapping from SAR Interferograms", + "description": ( + "U-Net architecture for automatic mapping of ground surface deformation " + "from Sentinel-1 SAR interferometric coherence maps. Detects subsidence, " + "landslides, and seismic deformation at millimetre precision. " + "Validated on 2,400 Sentinel-1 IW scenes across Europe." + ), + "type": "model", + "tags": ["SAR", "InSAR", "UNet", "earth-observation", "deformation", "space"], + "license": "CC-BY-4.0", + "version": "2.0.0", + "format_version": "0.1.0", + "authors": [ + {"name": "Catalina Lopez", "affiliation": "ESA ESRIN"}, + {"name": "Andreas Fischer", "affiliation": "TU Munich"}, + ], + "cite": [ + { + "text": "Ronneberger O. et al. U-Net: Convolutional Networks for Biomedical Image Segmentation. MICCAI 2015.", + "doi": "10.1007/978-3-319-24574-4_28", + } + ], + "documentation": "README.md", + "covers": [], + "framework": "TensorFlow/Keras", + }, + }, + # ── Bioimage Foundation Models ────────────────────────────────────────── + { + "alias": "bioimage-sam2-finetuned", + "manifest": { + "name": "BioSAM2: Segment Anything for Biological Microscopy", + "description": ( + "SAM 2 (Segment Anything Model 2) fine-tuned on a diverse collection of " + "fluorescence, brightfield, and electron microscopy images from the BioImage " + "Archive (>500,000 annotated objects). Supports interactive and automatic " + "segmentation of cells, organelles, and tissue structures." + ), + "type": "model", + "tags": ["segmentation", "foundation-model", "microscopy", "SAM2", "bioimage"], + "license": "Apache-2.0", + "version": "1.0.0", + "format_version": "0.1.0", + "authors": [ + {"name": "Wei Ouyang", "affiliation": "KTH Royal Institute of Technology", "github_user": "oeway"}, + {"name": "Caterina Fuster", "affiliation": "EMBL-EBI"}, + ], + "cite": [ + { + "text": "Ravi N. et al. SAM 2: Segment Anything in Images and Videos. arXiv 2024.", + "url": "https://arxiv.org/abs/2408.00714", + }, + { + "text": "Ouyang W. et al. BioImage Model Zoo: A Community-Driven Resource for Accessible Deep Learning in BioImage Analysis. Nat Methods (2022).", + "doi": "10.1038/s41592-022-01606-0", + }, + ], + "documentation": "README.md", + "covers": [], + "links": ["https://bioimage.io", "https://www.riscale.eu"], + "framework": "PyTorch", + }, + }, +] + +README_TEMPLATE = """# {name} + +{description} + +## Model Details + +| Property | Value | +|----------|-------| +| Type | {type} | +| License | {license} | +| Version | {version} | +| Framework | {framework} | + +## Usage + +```python +from hypha_rpc import connect_to_server + +server = await connect_to_server({{"server_url": "https://hypha.aicell.io"}}) +am = await server.get_service("public/artifact-manager") + +artifact = await am.read("ri-scale/{alias}") +print(artifact.manifest) +``` + +## Citation + +Please cite the following when using this model: + +{citations} + +## Acknowledgements + +This model was developed as part of the [RI-SCALE project](https://www.riscale.eu), +funded by the European Union under Grant Agreement 101881687. +""" + + +async def main(): + if not TOKEN: + raise ValueError( + "HYPHA_TOKEN environment variable is required.\n" + "Get a token from your Hypha workspace: server.generateToken()" + ) + + print(f"Connecting to {SERVER_URL}...") + api = await connect_to_server( + {"server_url": SERVER_URL, "token": TOKEN} + ) + am = await api.get_service("public/artifact-manager") + print("Connected.\n") + + created = [] + skipped = [] + failed = [] + + for model in MODELS: + alias = model["alias"] + manifest = model["manifest"] + print(f" Creating: {manifest['name']} ({alias})...", end=" ", flush=True) + + try: + # Build README content + citations = "\n".join( + f"- {c['text']}" + (f" DOI: {c['doi']}" if "doi" in c else f" URL: {c.get('url','')}") + for c in manifest.get("cite", []) + ) + readme = README_TEMPLATE.format( + name=manifest["name"], + description=manifest["description"], + type=manifest.get("type", "model"), + license=manifest.get("license", "N/A"), + version=manifest.get("version", "0.1.0"), + framework=manifest.get("framework", "PyTorch"), + alias=alias, + citations=citations or "See rdf.yaml for references.", + ) + + artifact = await am.create( + alias=alias, + parent_id=COLLECTION, + type="model", + manifest=manifest, + config={"storage": "git"}, + stage=True, + + ) + + # Upload README as a file placeholder + import httpx + put_url = await am.put_file( + artifact_id=artifact.id, + file_path="README.md", + + ) + async with httpx.AsyncClient() as client: + resp = await client.put( + put_url, + content=readme.encode(), + headers={"Content-Type": "text/markdown"}, + ) + resp.raise_for_status() + + # Commit + await am.commit(artifact_id=artifact.id) + print(f"OK (id: {artifact.id})") + created.append(alias) + + except Exception as e: + err_str = str(e) + if "already exists" in err_str.lower() or "conflict" in err_str.lower(): + print(f"SKIP (already exists)") + skipped.append(alias) + else: + print(f"FAIL: {err_str}") + failed.append((alias, err_str)) + + print("\n" + "=" * 60) + print(f"Created : {len(created)}") + print(f"Skipped : {len(skipped)} (already existed)") + print(f"Failed : {len(failed)}") + if failed: + for alias, err in failed: + print(f" - {alias}: {err}") + print("Done.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/components/About.tsx b/src/components/About.tsx index c40dae5f..dbaf72fb 100644 --- a/src/components/About.tsx +++ b/src/components/About.tsx @@ -73,7 +73,7 @@ const About: React.FC = () => { className="h-16 object-contain" /> EU Flag diff --git a/src/components/ArtifactDetails.tsx b/src/components/ArtifactDetails.tsx index 51e515df..7b7233be 100644 --- a/src/components/ArtifactDetails.tsx +++ b/src/components/ArtifactDetails.tsx @@ -102,18 +102,21 @@ const ArtifactDetails = () => { if (selectedResource?.manifest.documentation) { try { const docUrl = resolveHyphaUrl(selectedResource.manifest.documentation, selectedResource.id, true); - + const response = await fetch(docUrl); - const text = await response.text(); - setDocumentation(text); + if (!response.ok) { + setDocumentation(null); + } else { + const text = await response.text(); + setDocumentation(text); + } } catch (error) { console.error('Failed to fetch documentation:', error); - setDocumentation("Failed to fetch documentation."); + setDocumentation(null); } } else { - // No documentation found - setDocumentation("No documentation found."); + setDocumentation(null); } }; @@ -580,20 +583,20 @@ const ArtifactDetails = () => { {/* Left Column - Documentation */} {/* Documentation Card */} - {documentation && ( - - - + + {documentation ? ( + { } }} > - {documentation} - - - )} + ) : ( + + + + +

No documentation available

+

+ Add a README.md to your artifact and set{' '} + documentation: README.md in rdf.yaml. +

+
+ )} + +
{/* Right Column */} diff --git a/src/components/ArtifactFiles.tsx b/src/components/ArtifactFiles.tsx index 65f25c04..abb71e6c 100644 --- a/src/components/ArtifactFiles.tsx +++ b/src/components/ArtifactFiles.tsx @@ -69,7 +69,7 @@ const ArtifactFiles: React.FC = ({ } const filesData = await response.json(); - setFiles(filesData); + setFiles(Array.isArray(filesData) ? filesData : (filesData.items || [])); } catch (err) { console.error('Error fetching files:', err); setError(err instanceof Error ? err.message : 'Failed to fetch files'); diff --git a/src/components/ArtifactGrid.tsx b/src/components/ArtifactGrid.tsx index 972f33f0..942487f3 100644 --- a/src/components/ArtifactGrid.tsx +++ b/src/components/ArtifactGrid.tsx @@ -8,10 +8,14 @@ import { Grid } from '@mui/material'; interface ResourceGridProps {} const PHRASES = [ - "nucleus segmentation", - "spot detection", - "cell painting", - "standardized AI" + "cell segmentation", + "climate downscaling", + "space debris detection", + "medical imaging AI", + "anomaly detection", + "SAR interferometry", + "histopathology grading", + "synthetic data generation", ]; interface PaginationProps { @@ -273,8 +277,8 @@ const ArtifactGrid: React.FC = () => { Discover {text} models

- Access a curated collection of AI models designed for scientific workflows. - Brought to you by RI-SCALE. + Open AI models for biomedical imaging, climate science, space observation, and more — + from the RI-SCALE European research infrastructure network.

diff --git a/src/components/Edit.tsx b/src/components/Edit.tsx index 432afa0d..a834c58a 100644 --- a/src/components/Edit.tsx +++ b/src/components/Edit.tsx @@ -1,2567 +1,466 @@ import React, { useState, useEffect, useCallback } from 'react'; -import Editor from '@monaco-editor/react'; import { useHyphaStore } from '../store/hyphaStore'; -import { Dialog as MuiDialog, Checkbox, FormControlLabel } from '@mui/material'; -import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; +import { useParams, useNavigate, Link } from 'react-router-dom'; import { ArtifactInfo } from '../types/artifact'; -import { useDropzone } from 'react-dropzone'; -import yaml from 'js-yaml'; -import RDFEditor from './RDFEditor'; -import gridBg from '../assets/grid.svg'; -// Helper function to extract weight file paths from manifest -const extractWeightFiles = (manifest: any): string[] => { - if (!manifest || !manifest.weights) return []; - - const weightFiles: string[] = []; - Object.entries(manifest.weights).forEach(([_, weightInfo]: [string, any]) => { - if (weightInfo && weightInfo.source) { - // Handle paths that might start with ./ or just be filenames - let path = weightInfo.source; - if (path.startsWith('./')) { - path = path.substring(2); - } - weightFiles.push(path); - } - }); - - return weightFiles; -}; - -// Add this interface for size-only file info -interface SizeInfo { - fileSize: number; - type: 'size-only'; -} +const SERVER_URL = 'https://hypha.aicell.io'; -interface FileNode { - name: string; - path: string; - content?: string | ArrayBuffer | SizeInfo; - isDirectory: boolean; - children?: FileNode[]; - edited?: boolean; - isCommentsFile?: boolean; - fileSize?: number; -} - -// Add this interface for the tab type -interface ContentTab { - id: 'files' | 'review'; - label: string; - icon: React.ReactNode; -} +// ── Small helpers ───────────────────────────────────────────────────────────── -// Add this type definition near other interfaces -interface KeyboardShortcut { - key: string; - ctrlKey?: boolean; - metaKey?: boolean; - handler: () => void; -} - -// Add interface for validation result -interface ValidationResult { - success: boolean; - errors: string[]; -} - -// Helper function to determine MIME type -const getMimeType = (filename: string): string => { - const ext = filename.split('.').pop()?.toLowerCase(); - switch (ext) { - case 'html': return 'text/html'; - case 'js': return 'application/javascript'; - case 'css': return 'text/css'; - case 'json': return 'application/json'; - case 'png': return 'image/png'; - case 'jpg': case 'jpeg': return 'image/jpeg'; - case 'gif': return 'image/gif'; - case 'svg': return 'image/svg+xml'; - case 'txt': return 'text/plain'; - case 'yaml': case 'yml': return 'application/x-yaml'; - case 'md': return 'text/markdown'; - default: return ''; - } +const CopyButton: React.FC<{ text: string; label?: string }> = ({ text, label = 'Copy' }) => { + const [copied, setCopied] = useState(false); + return ( + + ); }; -const Edit: React.FC = () => { - // get edit version from url - const { version } = useParams<{ version: string }>(); - const { artifactId } = useParams<{ artifactId: string }>(); - const navigate = useNavigate(); - const [searchParams, setSearchParams] = useSearchParams(); - - // Determine where to navigate back to - const getBackPath = () => { - const referrerParam = searchParams.get('from'); - if (referrerParam) { - return referrerParam; - } - - // Check document.referrer for common paths - const referrer = document.referrer; - if (referrer.includes('/review')) { - return '/review'; - } - if (referrer.includes('/my-artifacts')) { - return '/my-artifacts'; - } - - // Default fallback - return '/my-artifacts'; - }; - const [files, setFiles] = useState([]); - const [selectedFile, setSelectedFile] = useState(null); - const { artifactManager, isLoggedIn, server, user} = useHyphaStore(); - const [uploadStatus, setUploadStatus] = useState<{ - message: string; - severity: 'info' | 'success' | 'error'; - progress?: number; - } | null>(null); - const [imageUrl, setImageUrl] = useState(null); - const [unsavedChanges, setUnsavedChanges] = useState<{[key: string]: string}>({}); - const [activeTab, setActiveTab] = useState<'files' | 'review'>(() => { - const tabParam = searchParams.get('tab'); - if (tabParam?.startsWith('@')) { - return 'files'; - } - return (tabParam as 'files' | 'review') || 'files'; - }); - const [artifactInfo, setArtifactInfo] = useState(null); - const [showPublishDialog, setShowPublishDialog] = useState(false); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); - const [showDeleteVersionDialog, setShowDeleteVersionDialog] = useState(false); - const [isStaged, setIsStaged] = useState(version === 'stage'); - const [showNewVersionDialog, setShowNewVersionDialog] = useState(false); - const [newVersionData, setNewVersionData] = useState({ - copyFiles: true - }); - const [isCreatingVersion, setIsCreatingVersion] = useState(false); - const [copyProgress, setCopyProgress] = useState<{ - current: number; - total: number; - file: string; - } | null>(null); - - const [isContentValid, setIsContentValid] = useState(true); - const [hasContentChanged, setHasContentChanged] = useState(false); - const [isSidebarOpen, setIsSidebarOpen] = useState(() => { - return window.innerWidth >= 1024; // 1024px is the lg breakpoint in Tailwind - }); - const [isCollectionAdmin, setIsCollectionAdmin] = useState(false); - const [lastVersion, setLastVersion] = useState(null); - const [artifactType, setArtifactType] = useState(null); - const [validationErrors, setValidationErrors] = useState([]); - const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null); - const [editVersion, setEditVersion] = useState(version); - const [isLoadingFiles, setIsLoadingFiles] = useState(false); - useEffect(() => { - setEditVersion(version); - }, [version]); - - useEffect(() => { - if (!isLoggedIn) { - navigate('/'); - } - }, [isLoggedIn, navigate]); - - useEffect(() => { - if (artifactId && artifactManager && isLoggedIn) { - loadArtifactFiles(); - } - }, [artifactId, artifactManager, isLoggedIn]); - - useEffect(() => { - if (artifactInfo?.versions && artifactInfo.versions.length > 0) { - const lastVersionObj = artifactInfo.versions[artifactInfo.versions.length - 1]; - setLastVersion(lastVersionObj.version); - } else { - setLastVersion(null); - } - }, [artifactInfo]); - - const isTextFile = (filename: string): boolean => { - const textExtensions = [ - '.txt', '.yml', '.yaml', '.json', '.md', '.py', - '.js', '.ts', '.jsx', '.tsx', '.css', '.html', - '.ijm' - ]; - return textExtensions.some(ext => filename.toLowerCase().endsWith(ext)); - }; - - const isImageFile = (filename: string): boolean => { - const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif']; - return imageExtensions.some(ext => filename.toLowerCase().endsWith(ext)); - }; - - const formatFileSize = (bytes: number): string => { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; - }; - - const getImageDataUrl = async (content: string | ArrayBuffer, fileName: string): Promise => { - if (typeof content === 'string') { - const encoder = new TextEncoder(); - const uint8Array = encoder.encode(content); - content = uint8Array.buffer as ArrayBuffer; - } - - const extension = fileName.toLowerCase().split('.').pop() || ''; - const bytes = new Uint8Array(content as ArrayBuffer); - const binary = bytes.reduce((data, byte) => data + String.fromCharCode(byte), ''); - const base64 = btoa(binary); - - const mimeType = `image/${extension === 'jpg' ? 'jpeg' : extension}`; - return `data:${mimeType};base64,${base64}`; - }; - - const getEditorLanguage = (filename: string): string => { - const extension = filename.toLowerCase().split('.').pop() || ''; - const languageMap: Record = { - 'py': 'python', - 'js': 'javascript', - 'ts': 'typescript', - 'jsx': 'javascript', - 'tsx': 'typescript', - 'css': 'css', - 'html': 'html', - 'json': 'json', - 'yml': 'yaml', - 'yaml': 'yaml', - 'md': 'markdown', - 'txt': 'plaintext', - 'ijm': 'javascript' - }; - return languageMap[extension] || 'plaintext'; - }; - - const loadArtifactFiles = async () => { - if (!artifactManager || !artifactId || !server) return; - try { - setIsLoadingFiles(true); - setUploadStatus({ - message: 'Loading files...', - severity: 'info' - }); - if (!artifactId) { - setUploadStatus({ - message: 'No artifact ID', - severity: 'error' - }); - setIsLoadingFiles(false); - return; - } - // Get artifact info - const artifact = await artifactManager.read({ - artifact_id: artifactId, - version: editVersion, - _rkwargs: true - }); - console.log("DEBUG:", {artifact, editVersion}) - // use local variable to ensure we have the correct version - let currentEditVersion = editVersion; - if(!currentEditVersion) { - // get the last value of .versions - currentEditVersion = artifact.versions[artifact.versions.length - 1].version; - setEditVersion(currentEditVersion); - } - - // Set artifact type from manifest - setArtifactType(artifact.manifest?.type || null); - - // Check collection admin status - try { - const collection = await artifactManager.read({ - artifact_id: 'ri-scale/ai-model-hub', - _rkwargs: true - }); - - if (user) { - // Check if user is in collection permissions or has admin role - const isAdmin = (collection.config?.permissions && user.id in collection.config.permissions) || - user.roles?.includes('admin'); - setIsCollectionAdmin(isAdmin); - } - } catch (error) { - console.error('Error checking collection admin status:', error); - setIsCollectionAdmin(false); - } - - setArtifactInfo(artifact); - - // List all files using the correct version - const fileList = await artifactManager.list_files({ - artifact_id: artifactId || '', - version: isStaged ? 'stage' : (currentEditVersion || 'latest'), - _rkwargs: true - }); - - if (!fileList || fileList.length === 0) { - setFiles([]); - setUploadStatus({ - message: 'No files found', - severity: 'error' - }); - setIsLoadingFiles(false); - return; - } - - // Convert the file list to FileNode format without fetching content - const nodes: FileNode[] = fileList.map((file: any) => ({ - name: file.name, - path: file.name, - isDirectory: file.type === 'directory', - children: file.type === 'directory' ? [] : undefined, - isCommentsFile: file.name === 'comments.json' - })); - - setFiles(nodes); - setUploadStatus({ - message: 'Files loaded successfully', - severity: 'success' - }); - setIsLoadingFiles(false); - - // Preserve the current tab state - const currentTab = searchParams.get('tab'); - if (currentTab) { - // If we're in review tab or have a specific file selected, maintain that state - if (currentTab === 'review' || currentTab.startsWith('@')) { - handleTabChange(currentTab === 'review' ? 'review' : 'files', - currentTab.startsWith('@') ? currentTab.substring(1) : undefined); - } - } - - } catch (error) { - console.error('Error loading artifact files:', error); - setUploadStatus({ - message: 'Error loading files', - severity: 'error' - }); - setIsLoadingFiles(false); - } - }; - - const fetchFileContent = async (file: FileNode): Promise => { - if (!artifactManager || file.isDirectory) return; - - // Enhanced logging grouping - console.group(`fetchFileContent: ${file.name}`); - - try { - setUploadStatus({ - message: 'Loading file content...', - severity: 'info' - }); - - // Determine version to fetch - let versionToFetch = isStaged ? 'stage' : (editVersion || 'latest'); - - if (!isStaged && (!editVersion || editVersion === 'latest') && artifactInfo?.versions && artifactInfo.versions.length > 0) { - versionToFetch = artifactInfo.versions[artifactInfo.versions.length - 1].version; - } - - console.log('Artifact ID:', artifactId); - console.log('File Path:', file.path); - console.log('Version Strategy:', { - isStaged, - editVersion, - filesVersion: artifactInfo?.versions?.length ? artifactInfo.versions[artifactInfo.versions.length - 1].version : 'N/A', - finalVersionToFetch: versionToFetch - }); - - let url; - try { - url = await artifactManager.get_file({ - artifact_id: artifactId || '', - file_path: file.path, - version: versionToFetch, - _rkwargs: true - }); - } catch (error: any) { - console.warn(`Failed to get file URL with version ${versionToFetch}. Retrying with latest version.`, error); - // Fallback to latest version if staging/specific version fails (e.g., due to permission or missing file) - // This is useful if the user session expired or if the file exists only in the public version - if (versionToFetch === 'stage') { - try { - url = await artifactManager.get_file({ - artifact_id: artifactId || '', - file_path: file.path, - version: 'latest', // or omit version to default to latest - _rkwargs: true - }); - console.log('Fallback to latest version successful'); - } catch (retryError) { - console.error('Retry failed:', retryError); - throw error; // Throw original error if retry also fails - } - } else { - throw error; - } - } - - console.log('Generated S3/Hypha URL:', url); - - // For text or image files, download the full content - if (isTextFile(file.name) || isImageFile(file.name)) { - console.log('Fetching content (Text/Image)...'); - const response = await fetch(url); - - console.log('Response Status:', response.status); - console.log('Response Headers:', Object.fromEntries(response.headers.entries())); - - if (response.status === 404) { - console.warn(`File ${file.name} not found (404) at version ${versionToFetch}`); - console.groupEnd(); - // Return empty content for 404s to avoid breaking the UI - if (isTextFile(file.name)) return ''; - return null; - } - - if (!response.ok) { - const errorText = await response.text(); - console.error('Fetch failed with body:', errorText); - console.groupEnd(); - throw new Error(`Failed to fetch file: ${response.status} ${response.statusText} (version: ${versionToFetch})`); - } - - const content = isTextFile(file.name) ? - await response.text() : - await response.arrayBuffer(); - - console.log('Content fetched successfully. Length:', - typeof content === 'string' ? content.length : (content as ArrayBuffer).byteLength); - - setUploadStatus({ - message: 'File loaded successfully', - severity: 'success' - }); - - console.groupEnd(); - return content; - } - // For unknown file types, just get the size using a HEAD request or Range request - else { - console.log('Fetching info (Binary)...'); - // If HEAD request fails or doesn't return content-length, try a Range request - const rangeResponse = await fetch(url, { - headers: { - Range: 'bytes=0-1' // Just get the first byte to determine file existence and size - } - }); - - console.log('Range Response Status:', rangeResponse.status); - - if (rangeResponse.status === 404) { - console.warn(`File ${file.name} not found (404)`); - console.groupEnd(); - return null; - } - - // Get content-range header which contains the file size - const contentRange = rangeResponse.headers.get('content-range'); - let size = 0; - - if (contentRange) { - // content-range format is like "bytes 0-1/12345" where 12345 is the total size - const match = contentRange.match(/bytes \d+-\d+\/(\d+)/); - if (match) { - size = parseInt(match[1], 10); - } - } else if (rangeResponse.headers.get('content-length')) { - // If there's no content-range but there is content-length - size = parseInt(rangeResponse.headers.get('content-length')!, 10); - } - - console.log('File Size detected:', size); - - setUploadStatus({ - message: 'File info loaded successfully', - severity: 'success' - }); - - console.groupEnd(); - // Return a placeholder with size info - return { - fileSize: size, - type: 'size-only' as const - }; - } - } catch (error) { - console.error('Error fetching file content:', error); - console.groupEnd(); - setUploadStatus({ - message: 'Error loading file content', - severity: 'error' - }); - } - }; - - const handleFileSelect = async (file: FileNode) => { - // First check if the file still exists in our files array - const fileExists = files.some(f => f.path === file.path); - if (!fileExists) { - setUploadStatus({ - message: `File ${file.name} no longer exists`, - severity: 'error' - }); - return; - } - - // Only update URL if it's different from current selection - const currentPath = searchParams.get('tab')?.substring(1); - if (currentPath !== file.path) { - handleTabChange('files', file.path); - } - - setSelectedFile(file); - setImageUrl(null); - - // Only fetch content if it hasn't been loaded yet - if (!file.content) { - const content = await fetchFileContent(file); - if (content !== undefined && content !== null) { - // Create updated file with content - const updatedFile = { ...file, content }; - - // Update selected file - setSelectedFile(updatedFile); - - // Update file in files array while preserving other files - setFiles(prevFiles => - prevFiles.map(f => - f.path === file.path ? updatedFile : f - ) - ); - - // If the file is an image, generate URL - if (isImageFile(file.name) && (typeof content === 'string' || content instanceof ArrayBuffer)) { - try { - const url = await getImageDataUrl(content, file.name); - setImageUrl(url); - } catch (error) { - console.error('Error generating image URL:', error); - } - } - } else if (content === null || content === '') { - // Handle 404 or empty content - // If it's a text file and returned empty string (404 handled), we treat it as empty file - if (content === '') { - const updatedFile = { ...file, content: '' }; - setSelectedFile(updatedFile); - setFiles(prevFiles => - prevFiles.map(f => - f.path === file.path ? updatedFile : f - ) - ); - } else { - // Null means 404 for binary or error - setUploadStatus({ - message: `File ${file.name} could not be loaded (404)`, - severity: 'error' - }); - } - } - } else if (isImageFile(file.name) && (typeof file.content === 'string' || file.content instanceof ArrayBuffer)) { - // If content is already loaded and it's an image, just generate the image URL - try { - const url = await getImageDataUrl(file.content, file.name); - setImageUrl(url); - } catch (error) { - console.error('Error generating image URL:', error); - } - } - }; - - // Update the validateRdfContent function - const validateRdfContent = (content: string, artifactId: string, artifactEmoji: string | null, userEmail: string): ValidationResult => { - try { - const manifest = yaml.load(content) as any; - const errors: string[] = []; - - // Check if id matches - const shortId = artifactId.split('/').pop() || ''; - if (manifest.id !== shortId) { - errors.push(`The 'id' field must be "${shortId}"`); - } - - // Check if id_emoji matches - if (manifest.id_emoji !== artifactEmoji) { - errors.push(`The 'id_emoji' field must be "${artifactEmoji}"`); - } - - // Only check uploader email if not a collection admin - if (!isCollectionAdmin) { - // Check uploader email only if it exists - if (manifest.uploader?.email && manifest.uploader.email !== userEmail) { - errors.push(`The uploader email must be "${userEmail}"`); - } - - // Check legacy nickname fields - if (manifest.config?.bioimageio?.nickname && manifest.config.bioimageio.nickname !== shortId) { - errors.push(`Legacy nickname field 'config.bioimageio.nickname' must be "${shortId}"`); - } - if (manifest.config?.bioimageio?.nickname_icon && manifest.config.bioimageio.nickname_icon !== artifactEmoji) { - errors.push(`Legacy nickname field 'config.bioimageio.nickname_icon' must be "${artifactEmoji}"`); - } - } - - return { - success: errors.length === 0, - errors - }; - } catch (error) { - return { - success: false, - errors: ['Invalid YAML format'] - }; - } - }; - - const handleEditorChange = (value: string | undefined, file: FileNode) => { - if (!value || !file) return; - - // Create updated file with new content - const updatedFile = { ...file, content: value, edited: true }; - - // Update files array while preserving other files - setFiles(prevFiles => - prevFiles.map(f => - f.path === file.path ? updatedFile : f - ) - ); - - // Update selected file - setSelectedFile(updatedFile); - - // Store unsaved changes - setUnsavedChanges(prev => ({ - ...prev, - [file.path]: value - })); - - // If this is the rdf.yaml file, mark content as changed and invalidate previous validation - if (file.path.endsWith('rdf.yaml')) { - setHasContentChanged(true); - setIsContentValid(false); - } - }; - - const handleSave = async (file: FileNode) => { - if (!artifactManager || !unsavedChanges[file.path]) return; - - // Check if this is an RDF file with changes that haven't been validated - if (file.path.endsWith('rdf.yaml') && hasContentChanged && !isContentValid) { - // If it's an RDF file and has changes that haven't been validated, run validation first - if (!user?.email) { - setValidationErrors(['You must be logged in to save changes']); - return; - } - - // Get the latest content - const content = unsavedChanges[file.path]; - - // Validate the content - const validation = validateRdfContent( - content, - artifactInfo?.id || "", - artifactInfo?.manifest?.id_emoji || null, - user.email - ); - - if (!validation.success) { - // Show validation errors - setValidationErrors(validation.errors); - setUploadStatus({ - message: 'Validation failed. Please fix the errors before saving.', - severity: 'error' - }); - return; - } - - // Mark content as valid if validation passes - setIsContentValid(true); - setHasContentChanged(false); - } - - try { - setUploadStatus({ - message: 'Saving changes...', - severity: 'info' - }); - - // If user is collection admin and not in stage mode, create temporary stage - let needsStageCleanup = false; - if (isCollectionAdmin && !isStaged) { - try { - // Create temporary stage - await artifactManager.edit({ - artifact_id: artifactId, - stage: true, - _rkwargs: true - }); - needsStageCleanup = true; - } catch (error) { - console.error('Error creating temporary stage:', error); - setUploadStatus({ - message: 'Error creating temporary stage', - severity: 'error' - }); - return; - } - } - - try { - // For rdf.yaml, validate content before saving - if (file.path.endsWith('rdf.yaml')) { - if (!user?.email) { - setValidationErrors(['You must be logged in to save changes']); - return; - } - - // Parse the YAML content - let content = unsavedChanges[file.path]; - let manifest = yaml.load(content) as any; - - // Only add/update uploader info if not a collection admin and uploader is missing - if (!isCollectionAdmin) { - if (!manifest.uploader?.email) { - manifest.uploader = { - ...manifest.uploader, - email: user.email - }; - // Update the content with new uploader info - content = yaml.dump(manifest); - // Update unsaved changes with new content - setUnsavedChanges(prev => ({ - ...prev, - [file.path]: content - })); - } - } - - // Proceed with full validation - const validation = validateRdfContent( - content, - artifactInfo?.id || '', - artifactInfo?.manifest?.id_emoji || null, - user.email - ); - - if (!validation.success) { - setValidationErrors(validation.errors); - return; - } - - try { - // Get the existing manifest to preserve fields like status - const existingManifest = artifactInfo?.manifest || {}; - // Merge the existing manifest with the new one from the editor - const mergedManifest = { - ...existingManifest, - ...manifest - }; - - // Get the presigned URL for uploading - const presignedUrl = await artifactManager.put_file({ - artifact_id: artifactId, - file_path: file.path, - _rkwargs: true - }); - - // Upload the file content - const response = await fetch(presignedUrl, { - method: 'PUT', - body: content, - headers: { - 'Content-Type': 'application/x-yaml' - } - }); - - if (!response.ok) { - throw new Error('Failed to upload file'); - } - - // Update the manifest - await artifactManager.edit({ - artifact_id: artifactId, - manifest: mergedManifest, // Use the merged manifest - _rkwargs: true - }); - - // Update local state - if ('type' in mergedManifest) { // Use mergedManifest here - setArtifactType(mergedManifest.type as string); - } - - // Update artifactInfo with new manifest - setArtifactInfo(prev => prev ? { - ...prev, - manifest: { - ...prev.manifest, - ...mergedManifest // Use mergedManifest here - } - } : null); - - } catch (error) { - console.error('Error saving rdf.yaml:', error); - setUploadStatus({ - message: 'Error saving rdf.yaml', - severity: 'error' - }); - return; - } - } else { - // Handle non-rdf.yaml files - const presignedUrl = await artifactManager.put_file({ - artifact_id: artifactId, - file_path: file.path, - _rkwargs: true - }); - - const mimeType = getMimeType(file.name); - const response = await fetch(presignedUrl, { - method: 'PUT', - body: unsavedChanges[file.path], - headers: { - 'Content-Type': mimeType - } - }); - - if (!response.ok) { - throw new Error('Failed to upload file'); - } - } +const CodeBlock: React.FC<{ code: string }> = ({ code }) => ( +
+
+      {code}
+    
+
+ +
+
+); - // If we created a temporary stage, commit changes immediately - if (needsStageCleanup) { - try { - await artifactManager.commit({ - artifact_id: artifactId, - comment: `Updated ${file.path}`, - _rkwargs: true - }); +// ── Git info box ────────────────────────────────────────────────────────────── - // Refresh artifact files to get the latest state - await loadArtifactFiles(); - } catch (error) { - console.error('Error committing changes:', error); - setUploadStatus({ - message: 'Error committing changes', - severity: 'error' - }); - return; - } - } +interface GitBoxProps { + artifact: ArtifactInfo; + server: any; +} - // Update the local state - setFiles(files.map(f => - f.path === file.path - ? { ...f, content: unsavedChanges[file.path], edited: false } - : f - )); +const GitInfoBox: React.FC = ({ artifact, server }) => { + const [gitAuthUrl, setGitAuthUrl] = useState(null); + const [generatingToken, setGeneratingToken] = useState(false); + const [tokenExpiry, setTokenExpiry] = useState(''); + const [showBox, setShowBox] = useState(true); - // Clear unsaved changes for this file - setUnsavedChanges(prev => { - const newState = { ...prev }; - delete newState[file.path]; - return newState; - }); + const gitUrl = (artifact as any).git_url; + const alias = artifact.alias || artifact.id.split('/').pop() || ''; + const publicGitUrl = gitUrl || `${SERVER_URL}/ri-scale/artifacts/${alias}/git`; - setUploadStatus({ - message: needsStageCleanup ? 'Changes saved and committed' : 'Changes saved', - severity: 'success' - }); + if (!gitUrl) return null; // Only show for git-storage artifacts - } catch (error) { - console.error('Error in save process:', error); - setUploadStatus({ - message: 'Error saving changes', - severity: 'error' - }); - } - } catch (error) { - console.error('Error in save process:', error); - setUploadStatus({ - message: 'Error saving changes', - severity: 'error' - }); - } - }; + const expiryOptions = [ + { label: '1 hour', seconds: 3600 }, + { label: '24 hours', seconds: 86400 }, + { label: '7 days', seconds: 604800 }, + { label: '30 days', seconds: 2592000 }, + ]; - const handlePublish = async () => { + const generateAuthUrl = async (expiresIn: number, label: string) => { + if (!server) return; + setGeneratingToken(true); try { - setUploadStatus({ - message: 'Publishing artifact...', - severity: 'info' - }); - - // Use local artifact state to ensure we have the latest changes - // If artifactInfo is not available (shouldn't happen), fallback to reading from server - let currentManifest = artifactInfo?.manifest; - let currentConfig = artifactInfo?.config; - - if (!currentManifest) { - const currentArtifact = await artifactManager?.read({ - artifact_id: artifactId, - version: 'stage', - _rkwargs: true - }); - currentManifest = currentArtifact.manifest; - currentConfig = currentArtifact.config; - } - - // add create_zip_file to download_weights - const newConfig = { - ...(currentConfig || {}), - download_weights:{ - ...(currentConfig?.download_weights || {}), - create_zip_file: 1.0 - } - }; - - // update the manifest - const newManifest = { - ...(currentManifest || {}), - status: 'published' - }; - - // Update the staged version first - await artifactManager?.edit({ - artifact_id: artifactId, - config: newConfig, - manifest: newManifest, - stage: true, - _rkwargs: true - }); - - // Then commit the staged version - await artifactManager?.commit({ - artifact_id: artifactId, - comment: `Published by ${user?.email}`, - _rkwargs: true - }); - - setUploadStatus({ - message: 'Changes committed successfully', - severity: 'success' - }); - - // Add a delay to allow changes to propagate - await new Promise(resolve => setTimeout(resolve, 2000)); - - setShowPublishDialog(false); - - // Clear edited flags after successful commit - setFiles(prevFiles => - prevFiles.map(f => ({ - ...f, - edited: false - })) - ); - - // Navigate back to the appropriate page after successful publish - navigate(getBackPath()); - - } catch (error) { - console.error('Error publishing artifact:', error); - setUploadStatus({ - message: 'Error publishing artifact', - severity: 'error' - }); - } - }; - - // Add helper function to get file size - const getFileSize = (file: FileNode): number | undefined => { - if (!file.content) return undefined; - - // If content is SizeInfo - if (typeof file.content === 'object' && 'type' in file.content && file.content.type === 'size-only') { - return file.content.fileSize; - } - - // If content is ArrayBuffer - if (file.content instanceof ArrayBuffer) { - return file.content.byteLength; - } - - // If content is string - if (typeof file.content === 'string') { - return file.content.length; + const token = await server.generateToken({ expires_in: expiresIn }); + const url = new URL(publicGitUrl); + url.username = 'git'; + url.password = token; + setGitAuthUrl(url.toString()); + setTokenExpiry(label); + } catch (err: any) { + alert('Failed to generate token: ' + err.message); + } finally { + setGeneratingToken(false); } - - return undefined; }; - const renderFileContent = () => { - if (!selectedFile) { - return ( -
- Select a file to view or edit -
- ); - } - - if (!selectedFile.content) { - return ( -
-
-
Loading file content...
-
- ); - } + const pushCommands = gitAuthUrl + ? `# Clone (or pull latest changes)\ngit clone ${gitAuthUrl} ${alias}\ncd ${alias}\n\n# Set up Git LFS for large files\ngit lfs install\ngit lfs track "*.pt" "*.ckpt" "*.h5" "*.pkl" "*.pth" "*.safetensors" "*.bin"\ngit add .gitattributes\n\n# Add / update your model files\ngit add .\ngit commit -m "Update model"\ngit push origin main` + : `# Clone (read-only)\ngit clone ${publicGitUrl} ${alias}\ncd ${alias}`; - if (selectedFile.name.endsWith('rdf.yaml')) { - return ( -
- handleEditorChange(value, selectedFile)} - readOnly={false} - showModeSwitch={true} - /> -
- ); - } - - if (isImageFile(selectedFile.name)) { - // Get file size and type information - const fileSize = getFileSize(selectedFile); - const fileType = selectedFile.name.split('.').pop()?.toUpperCase() || 'Unknown'; - - // Check if this is a cover image - const isCoverImage = artifactInfo?.manifest?.covers?.some( - cover => cover === selectedFile.name - ); - - // Determine warning status for cover images - const isTooBig = isCoverImage && imageDimensions && - (imageDimensions.width > 300 || imageDimensions.height > 160); - - return ( -
-
- {/* File info badge */} -
- {fileType} - - {imageDimensions ? `${imageDimensions.width}×${imageDimensions.height}` : '...'} - - {fileSize !== undefined ? formatFileSize(fileSize) : 'Unknown'} + return ( +
+ + + {showBox && ( +
+ {/* Public URL */} +
+

Public clone URL (read-only)

+
+ {publicGitUrl} +
- - {/* Cover image warning */} - {isCoverImage && ( -
- {isTooBig ? ( - <> - - - - Cover image too large (max: 300×160) - - ) : ( - <> - - - - Cover image (max: 300×160) - - )} +
+ + {/* Token generation */} +
+

Authenticated URL (read + push)

+ {!gitAuthUrl ? ( +
+ Generate a push token: + {expiryOptions.map(opt => ( + + ))}
- )} - - {/* Image container */} -
- {imageUrl ? ( - {selectedFile.name} checkImageDimensions(imageUrl, selectedFile.name)} - /> - ) : ( -
-
-
- Loading image... -
+ ) : ( +
+
+ + + + Token valid for {tokenExpiry}. Keep private. +
- )} -
- - {/* File name footer */} -
-

- {selectedFile.name} -

-
+
+ {gitAuthUrl} + +
+
+ )}
-
- ); - } - if (isTextFile(selectedFile.name)) { - return ( -
- handleEditorChange(value ?? '', selectedFile)} - options={{ - minimap: { enabled: false }, - scrollBeyondLastLine: true, - wordWrap: 'on', - lineNumbers: 'on', - renderWhitespace: 'selection', - folding: true, - readOnly: false // Explicitly set readOnly to false - }} - /> -
- ); - } - - // For binary or other unknown file types - const fileSize = getFileSize(selectedFile); - - return ( -
-
-

File Information

-
-

Name: {selectedFile.name}

-

Size: {fileSize !== undefined ? formatFileSize(fileSize) : 'Unknown'}

-

Type: {selectedFile.name.split('.').pop()?.toUpperCase() || 'Unknown'}

+ {/* Commands */} +
+

Git commands

+
-

This file type cannot be previewed

-
-
- ); - }; - - // Define available tabs - const tabs: ContentTab[] = [ - { - id: 'files', - label: 'Files', - icon: ( - - - - ) - } - ]; - - // Update renderContent to use activeTab - const renderContent = () => { - return renderFileContent(); - }; - - // Update the navigation button - const renderSidebarNav = () => ( - <> - {/* Show Publish button if in staging mode */} - {isStaged && ( -
- -
- )} - - {/* Only show New Version button if not in staging mode */} - {!isStaged && ( -
- -
)} - - ); - - // Update the publish confirmation dialog - const renderPublishDialog = () => ( - setShowPublishDialog(false)} - maxWidth="sm" - fullWidth - > -
-

- Confirm Publication -

-
- {/* Add reviewer responsibility section */} -
-

Reviewer's Responsibility

-
    -
  • Verify that the model meets RI-SCALE Model Hub technical specifications
  • -
  • Check that documentation is clear and complete
  • -
  • Ensure all required files are present and valid
  • -
  • Test model functionality with provided sample data
  • -
-
- -
-

- You are about to publish this artifact to: -

-
    -
  • The RI-SCALE Model Hub website
  • -
  • Zenodo (with DOI assignment)
  • -
-

- ⚠️ Warning: This action cannot be undone. Once published, the artifact cannot be withdrawn from either platform. -

-
-
-
- - -
-
-
+
); +}; - // Update URL when tab changes - const handleTabChange = (tab: 'files' | 'review', filePath?: string) => { - setActiveTab(tab); - const newParams = new URLSearchParams(searchParams); - - if (tab === 'files' && filePath) { - newParams.set('tab', `@${filePath}`); - } else { - newParams.set('tab', tab); - } - - // Use replace instead of push to avoid adding to browser history - setSearchParams(newParams, { replace: true }); - }; - - // Update the effect to handle @ prefix in URL - useEffect(() => { - if (artifactId && files.length > 0) { - const tabParam = searchParams.get('tab'); - - if (tabParam?.startsWith('@')) { - // Extract file path from tab parameter - const filePath = tabParam.substring(1); - const fileToSelect = files.find(f => f.path === filePath); - - if (fileToSelect) { - setActiveTab('files'); - handleFileSelect(fileToSelect); - } - } else { - setActiveTab(tabParam as 'files' | 'review' || 'files'); - - // If no specific file is selected and rdf.yaml exists, select it - if (!selectedFile) { - const rdfFile = files.find(file => file.path.endsWith('rdf.yaml')); - if (rdfFile) { - handleFileSelect(rdfFile); - } - } - } - } - }, [artifactId, files, searchParams, editVersion]); // Remove selectedFile from dependencies - - // Add this effect to handle tab state when staged status changes - useEffect(() => { - if (!isStaged && activeTab === 'review') { - handleTabChange('files'); - } - }, [isStaged]); - - // Add file upload handler - const onDrop = useCallback(async (acceptedFiles: File[]) => { - if (!artifactManager || !artifactId) return; - - // If collection admin and not in stage mode, create temporary stage - let needsStageCleanup = false; - if (isCollectionAdmin && !isStaged) { - try { - await artifactManager.edit({ - artifact_id: artifactId, - stage: true, - _rkwargs: true - }); - needsStageCleanup = true; - } catch (error) { - console.error('Error creating temporary stage:', error); - setUploadStatus({ - message: 'Error creating temporary stage', - severity: 'error' - }); - return; - } - } - - // Get the manifest to check for weight files - let weightFilePaths: string[] = []; - try { - // Find the rdf.yaml file - const rdfFile = files.find(file => file.path.endsWith('rdf.yaml')); - if (rdfFile) { - // Load content if needed - let rdfContent: string; - if (!rdfFile.content) { - const content = await fetchFileContent(rdfFile); - if (typeof content === 'string') { - rdfContent = content; - } else { - throw new Error('Failed to load rdf.yaml content'); - } - } else { - rdfContent = typeof rdfFile.content === 'string' - ? rdfFile.content - : new TextDecoder().decode(rdfFile.content as ArrayBuffer); - } - - // Parse the manifest and extract weight files - const manifest = yaml.load(rdfContent) as any; - if (manifest && manifest.type === 'model') { - weightFilePaths = extractWeightFiles(manifest); - } - } - } catch (error) { - console.error('Error checking for weight files:', error); - } - - for (const file of acceptedFiles) { - try { - setUploadStatus({ - message: `Uploading ${file.name}...`, - severity: 'info' - }); - - // Check if this is a weight file - const isWeightFile = weightFilePaths.some((weightPath: string) => { - const normalizedFilePath = file.name.startsWith('./') ? file.name.substring(2) : file.name; - return normalizedFilePath === weightPath || - normalizedFilePath.endsWith(`/${weightPath}`) || - weightPath.endsWith(`/${normalizedFilePath}`); - }); - - // Get presigned URL for upload - const putConfig: { - artifact_id: string; - file_path: string; - download_weight?: number; - _rkwargs: boolean; - } = { - artifact_id: artifactId, - file_path: file.name, - _rkwargs: true - }; - - if (isWeightFile) { - putConfig.download_weight = 1; - } - - const presignedUrl = await artifactManager.put_file(putConfig); +// ── File list ───────────────────────────────────────────────────────────────── - // Upload file content - const mimeType = getMimeType(file.name); - const response = await fetch(presignedUrl, { - method: 'PUT', - body: file, - headers: { - 'Content-Type': mimeType - } - }); +interface FileEntry { + name: string; + type: 'file' | 'directory'; + size?: number; +} - if (!response.ok) { - throw new Error('Failed to upload file'); - } +const formatBytes = (bytes: number) => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +}; - // If collection admin and not in stage mode, commit changes immediately - if (isCollectionAdmin && !isStaged) { - try { - await artifactManager.commit({ - artifact_id: artifactId, - comment: `Added ${file.name}`, - _rkwargs: true - }); - } catch (error) { - console.error('Error committing changes:', error); - setUploadStatus({ - message: 'Error committing changes', - severity: 'error' - }); - continue; - } - } +const fileIcon = (name: string) => { + const ext = name.split('.').pop()?.toLowerCase() || ''; + if (['pt', 'pth', 'ckpt', 'h5', 'pkl', 'safetensors', 'bin'].includes(ext)) + return ; + if (['md', 'txt', 'rst'].includes(ext)) + return 📄; + if (['yaml', 'yml', 'json', 'toml'].includes(ext)) + return ; + if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'].includes(ext)) + return 🖼; + if (['py', 'js', 'ts', 'sh'].includes(ext)) + return 📝; + return 📄; +}; - // Add file to local state - const content = await file.text(); - const newFile: FileNode = { - name: file.name, - path: file.name, - content, - isDirectory: false, - edited: false // Set to false since we've already committed if needed - }; +// ── Main component ──────────────────────────────────────────────────────────── - setFiles(prev => [...prev, newFile]); - setSelectedFile(newFile); +const Edit: React.FC = () => { + const { artifactId } = useParams<{ artifactId: string }>(); + const navigate = useNavigate(); + const { artifactManager, isLoggedIn, server } = useHyphaStore(); - setUploadStatus({ - message: `${file.name} uploaded successfully${isWeightFile ? ' (marked as weight file)' : ''}`, - severity: 'success' - }); - - // If we uploaded a weight file, refresh to update artifact info with new download_weights - if (isWeightFile) { - loadArtifactFiles(); - } - } catch (error) { - console.error('Error uploading file:', error); - setUploadStatus({ - message: `Error uploading ${file.name}`, - severity: 'error' - }); - } - } - }, [artifactId, artifactManager, files, isCollectionAdmin, isStaged]); + const [artifact, setArtifact] = useState(null); + const [fileList, setFileList] = useState([]); + const [loading, setLoading] = useState(true); + const [filesLoading, setFilesLoading] = useState(false); + const [error, setError] = useState(null); - const { getRootProps, getInputProps } = useDropzone({ - onDrop, - noClick: true, - noKeyboard: true - }); + const fullArtifactId = artifactId?.includes('/') + ? artifactId + : `ri-scale/${artifactId}`; - const handleDeleteFile = async (file: FileNode) => { + const loadArtifact = useCallback(async () => { if (!artifactManager || !artifactId) return; - + setLoading(true); + setError(null); try { - setUploadStatus({ - message: `Deleting ${file.name}...`, - severity: 'info' - }); - - // If collection admin and not in stage mode, create temporary stage - let needsStageCleanup = false; - if (isCollectionAdmin && !isStaged) { - try { - await artifactManager.edit({ - artifact_id: artifactId, - stage: true, - _rkwargs: true - }); - needsStageCleanup = true; - } catch (error) { - console.error('Error creating temporary stage:', error); - setUploadStatus({ - message: 'Error creating temporary stage', - severity: 'error' - }); - return; - } - } - - await artifactManager.remove_file({ - artifact_id: artifactId, - file_path: file.path, - _rkwargs: true - }); - - // If collection admin and not in stage mode, commit changes immediately - if (isCollectionAdmin && !isStaged) { - try { - await artifactManager.commit({ - artifact_id: artifactId, - comment: `Deleted ${file.name}`, - _rkwargs: true - }); - } catch (error) { - console.error('Error committing changes:', error); - setUploadStatus({ - message: 'Error committing changes', - severity: 'error' - }); - return; - } - } - - // Clear selected file if it was the deleted one - if (selectedFile?.path === file.path) { - setSelectedFile(null); - setImageUrl(null); - // Clear any unsaved changes for this file - setUnsavedChanges(prev => { - const newState = { ...prev }; - delete newState[file.path]; - return newState; - }); - } - - // Refresh the file list from the server - await loadArtifactFiles(); - - setUploadStatus({ - message: `${file.name} deleted successfully`, - severity: 'success' - }); - } catch (error) { - console.error('Error deleting file:', error); - setUploadStatus({ - message: `Error deleting ${file.name}`, - severity: 'error' - }); + const info = await artifactManager.read({ artifact_id: fullArtifactId, _rkwargs: true }); + setArtifact(info); + } catch (err: any) { + setError(err.message || 'Failed to load artifact'); + } finally { + setLoading(false); } - setShowDeleteConfirm(null); - }; - - // Modify the file list rendering to include delete button and drag-drop zone - const renderFileList = () => ( -
- {/* Add Files section with + File button */} -
-
-

Files

- {isStaged && ( - - )} -
-
-
- {isLoadingFiles ? ( -
-
-
Loading files...
-
- ) : files.length === 0 ? ( -
-
No files found
-
This artifact doesn't contain any files
-
- ) : ( - files.map((file) => ( -
handleFileSelect(file)} - className={`group relative flex items-center px-4 py-2 cursor-pointer hover:bg-gray-100 border-l-2 transition-colors ${ - selectedFile?.path === file.path - ? 'bg-white border-[#f39200] text-gray-900' - : 'border-transparent text-gray-600' - }`} - > - {/* File icon and name */} -
- {/* File Icon */} - - {file.name.endsWith('.yaml') || file.name.endsWith('.yml') ? ( - - - - ) : file.name.match(/\.(png|jpg|jpeg|gif)$/i) ? ( - - - - ) : ( - - - - )} - - - {/* File Name with Star for rdf.yaml */} -
- - {file.name} - - {file.name === 'rdf.yaml' && ( - - - - )} -
- - {/* Edit badge */} - {(file.edited || unsavedChanges[file.path]) && ( - - edited - - )} - - {/* Download weight badge - updated to check both config.download_weights and staging */} - {( - // Check in config.download_weights for published versions - (artifactInfo?.config?.download_weights && artifactInfo.config.download_weights[file.path] > 0) || - // Check in staging array for staged files - (isStaged && artifactInfo?.staging && artifactInfo.staging.some( - (item: {path: string; download_weight: number}) => - item.path === file.path && item.download_weight > 0 - )) - ) && ( - - weight - - )} - - {/* Action buttons - hidden by default, shown on group hover */} -
- {/* Download button */} - - - {/* Delete button */} - -
-
-
- )) - )} -
-
- ); - - // Update the handleValidationComplete function - const handleValidationComplete = (result: ValidationResult) => { - setUploadStatus({ - message: result.success ? 'Validation successful!' : 'Validation failed', - severity: result.success ? 'success' : 'error' - }); - - setIsContentValid(result.success); - setHasContentChanged(false); + }, [artifactManager, artifactId, fullArtifactId]); - // If validation failed and we're viewing rdf.yaml in form mode, - // find the RDFEditor and switch it to YAML mode - if (!result.success && selectedFile?.path.endsWith('rdf.yaml')) { - const rdfEditor = document.querySelector('[data-testid="rdf-editor"]'); - if (rdfEditor) { - // Find and click the YAML mode button - const yamlModeButton = rdfEditor.querySelector('[data-testid="yaml-mode-button"]'); - if (yamlModeButton instanceof HTMLButtonElement) { - yamlModeButton.click(); - } - } + const loadFiles = useCallback(async () => { + if (!artifactManager || !artifactId) return; + setFilesLoading(true); + try { + const files = await artifactManager.list_files({ artifact_id: fullArtifactId, _rkwargs: true }); + setFileList(Array.isArray(files) ? files : []); + } catch { + setFileList([]); + } finally { + setFilesLoading(false); } + }, [artifactManager, artifactId, fullArtifactId]); - // If validation successful, check for type changes in rdf.yaml - if (result.success) { - const rdfFile = files.find(file => file.path.endsWith('rdf.yaml')); - if (rdfFile) { - try { - // Get latest content including unsaved changes - const content = unsavedChanges[rdfFile.path] ?? - (typeof rdfFile.content === 'string' ? rdfFile.content : ''); - - // Parse YAML to get type - const rdfData = yaml.load(content); - if (rdfData && typeof rdfData === 'object' && 'type' in rdfData) { - setArtifactType(rdfData.type as string); - } - } catch (error) { - console.error('Error parsing rdf.yaml:', error); - } - } + useEffect(() => { + if (!isLoggedIn) { + navigate('/my-artifacts'); + return; } - }; + loadArtifact(); + }, [isLoggedIn, loadArtifact, navigate]); - // Update the renderActionButtons function - const renderActionButtons = () => { - // Get the latest content for rdf.yaml, including unsaved changes - const getLatestRdfContent = () => { - const rdfFile = files.find(file => file.path.endsWith('rdf.yaml')); - if (!rdfFile) return ''; - return unsavedChanges[rdfFile.path] ?? - (typeof rdfFile.content === 'string' ? rdfFile.content : ''); - }; + useEffect(() => { + if (artifact) loadFiles(); + }, [artifact, loadFiles]); - const isRdfFile = selectedFile?.path.endsWith('rdf.yaml'); - const shouldDisableActions = isRdfFile && (!isContentValid || hasContentChanged); + if (loading) { + return ( +
+
+ + + + +

Loading artifact…

+
+
+ ); + } + if (error || !artifact) { return ( -
- - {/* Save button */} - {selectedFile && isTextFile(selectedFile.name) && ( - - )} +
); - }; + } + + const manifest: any = artifact.manifest || {}; + const alias = artifact.alias || artifact.id.split('/').pop() || ''; + const isGitStorage = !!(artifact as any).git_url; - // Add delete confirmation dialog - const renderDeleteConfirmDialog = () => ( - showDeleteConfirm && ( -
-
-

- Delete File -

-

- Are you sure you want to delete "{files.find(f => f.path === showDeleteConfirm)?.name}"? This action cannot be undone. -

-
+ return ( +
+ {/* Header */} +
+
+
-
+
+ - Delete - + + + + + View +
- ) - ); - - // Add function to handle new version creation - const handleCreateNewVersion = async () => { - if (!artifactManager || isCreatingVersion) return; - - setIsCreatingVersion(true); - - try { - setUploadStatus({ - message: 'Creating new version...', - severity: 'info' - }); - - // Create the new version first - const newArtifact = await artifactManager.edit({ - artifact_id: artifactId, - type: artifactType, - stage: true, - version: 'new', - _rkwargs: true - }); - console.log('new version created', newArtifact); - // get the latest version, the last one - const latestVersion = newArtifact.versions[newArtifact.versions.length - 1].version; - - // If user wants to copy files, do the copy process - if (newVersionData.copyFiles) { - try { - // Get the file list from the previous version - setUploadStatus({ - message: 'Getting file list from previous version...', - severity: 'info' - }); - - const fileList = await artifactManager.list_files({ - artifact_id: artifactId, - version: latestVersion, - _rkwargs: true - }); - - if (!fileList || fileList.length === 0) { - setUploadStatus({ - message: 'No files found in previous version to copy.', - severity: 'info' - }); - } else { - // Filter out directories, only copy files - const filesToCopy = fileList.filter((file: any) => file.type !== 'directory'); - - setUploadStatus({ - message: `Found ${filesToCopy.length} files to copy. Starting copy process...`, - severity: 'info' - }); - - // Copy files one by one - for (let i = 0; i < filesToCopy.length; i++) { - const file = filesToCopy[i]; - - // Update progress - setCopyProgress({ - current: i + 1, - total: filesToCopy.length, - file: file.name - }); - - try { - // Get download URL for the file from previous version - const downloadUrl = await artifactManager.get_file({ - artifact_id: artifactId, - file_path: file.name, - version: latestVersion, - _rkwargs: true - }); - - // Download the file content - const response = await fetch(downloadUrl); - if (!response.ok) { - throw new Error(`Failed to download ${file.name}`); - } - - const fileContent = await response.blob(); - - // Get presigned URL for uploading to new version - const presignedUrl = await artifactManager.put_file({ - artifact_id: artifactId, - file_path: file.name, - _rkwargs: true - }); - - const mimeType = getMimeType(file.name); - // Upload the file to new version - const uploadResponse = await fetch(presignedUrl, { - method: 'PUT', - body: fileContent, - headers: { - 'Content-Type': mimeType - } - }); - if (!uploadResponse.ok) { - throw new Error(`Failed to upload ${file.name}`); - } +
- console.log(`Successfully copied ${file.name} (${i + 1}/${filesToCopy.length})`); - } catch (fileError) { - console.error(`Error copying ${file.name}:`, fileError); - // Continue with other files even if one fails - setUploadStatus({ - message: `Warning: Failed to copy ${file.name}. Continuing with other files...`, - severity: 'error' - }); - } - } - - // Clear copy progress - setCopyProgress(null); - - setUploadStatus({ - message: 'Files copied successfully. Redirecting to edit mode...', - severity: 'success' - }); - } - } catch (copyError) { - console.error('Error copying files:', copyError); - setCopyProgress(null); - setUploadStatus({ - message: 'Warning: New version created but failed to copy files. You can upload files manually.', - severity: 'error' - }); - } - } else { - setUploadStatus({ - message: 'New version created successfully. Redirecting to edit mode...', - severity: 'success' - }); - } - - // Close the dialog - setShowNewVersionDialog(false); - - // Redirect to the staging version after a short delay - setTimeout(() => { - const stagePath = `/edit/${encodeURIComponent(artifactId || '')}/stage`; - navigate(stagePath); - }, 1500); - - } catch (error) { - console.error('Error creating new version:', error); - setUploadStatus({ - message: 'Error creating new version', - severity: 'error' - }); - } finally { - setIsCreatingVersion(false); - } - }; - - // Add new version dialog component - const renderNewVersionDialog = () => ( - setShowNewVersionDialog(false)} - maxWidth="md" - fullWidth - > -
-

- Create New Version -

- - {/* Show progress during creation */} - {isCreatingVersion && ( -
-
- - - - -
-

Creating New Version

- - {/* Show current status */} -
- {uploadStatus?.message || 'Processing...'} -
+ {/* Git info box */} + {isGitStorage && server && ( + + )} - {/* Show file copying progress */} - {copyProgress && ( -
-
- Copying files ({copyProgress.current}/{copyProgress.total}) - {Math.round((copyProgress.current / copyProgress.total) * 100)}% -
- - {/* Progress bar */} -
-
-
- - {/* Current file being copied */} -
- Current file: {copyProgress.file} -
-
- )} + {/* Model info */} +
+

+ + + + Model Information +

+
+ {[ + { label: 'Name', value: manifest.name }, + { label: 'Type', value: manifest.type || artifact.type }, + { label: 'Version', value: manifest.version }, + { label: 'License', value: manifest.license }, + { label: 'Framework', value: manifest.framework }, + { label: 'Format version', value: manifest.format_version }, + ].filter(f => f.value).map(({ label, value }) => ( +
+
{label}
+
{value}
+
+ ))} + {manifest.description && ( +
+
Description
+
{manifest.description}
+
+ )} +
+ + {/* Tags */} + {manifest.tags?.length > 0 && ( +
+

Tags

+
+ {manifest.tags.map((tag: string) => ( + {tag} + ))}
-
- )} + )} - {/* Warning and guidance section - only show when not creating */} - {!isCreatingVersion && ( -
-
-
- - - -
-

When should you create a new version?

-
-

✅ Create new version when:

-
    -
  • Model weight files have changed
  • -
  • Model architecture or functionality has been modified
  • -
  • Breaking changes to the model interface
  • -
  • Significant improvements that warrant a version bump
  • -
- -

❌ You don't need a new version for:

-
    -
  • Editing the RDF.yaml metadata file
  • -
  • Updating cover images or documentation
  • -
  • Fixing typos in descriptions
  • -
  • Adding or updating tags and citations
  • -
-
-
+ {/* Authors */} + {manifest.authors?.length > 0 && ( +
+

Authors

+
+ {manifest.authors.map((a: any, i: number) => ( + + {a.name}{a.affiliation ? ({a.affiliation}) : null} + + ))}
+ )} -
-
- - - -
-

File Copying Process

-

If you choose to copy files, we will download all files from version {editVersion || 'latest'} and upload them to the new version. This process may take several minutes depending on file sizes.

-
+ {/* Versions */} + {artifact.versions?.length > 0 && ( +
+

Versions

+
+ {artifact.versions.map((v: any) => ( + + {v.version} + + ))}
-
- )} - - {/* Options - only show when not creating */} - {!isCreatingVersion && ( -
- setNewVersionData(prev => ({ ...prev, copyFiles: e.target.checked }))} - /> - } - label={ -
-
Copy existing files to new version
-
- This will download and copy all files from the current version to the new version. - Uncheck if you plan to upload completely new files. -
-
- } - /> -
- )} - -
- - + )}
-
- - ); - - // Add this handleValidate function before setupKeyboardShortcuts - const handleValidate = () => { - if (!selectedFile || !selectedFile.path.endsWith('rdf.yaml')) { - return; // Only validate RDF files - } - - if (!user?.email) { - setValidationErrors(['You must be logged in to validate changes']); - return; - } - - // Get the latest content including unsaved changes - const content = unsavedChanges[selectedFile.path] ?? - (typeof selectedFile.content === 'string' ? selectedFile.content : ''); - - // Validate the content - const validation = validateRdfContent( - content, - artifactInfo?.id || '', - artifactInfo?.manifest?.id_emoji || '', - user.email - ); - - // Update validation state and show results - setIsContentValid(validation.success); - setHasContentChanged(false); - - if (!validation.success) { - setValidationErrors(validation.errors); - setUploadStatus({ - message: 'Validation failed. Please fix the errors.', - severity: 'error' - }); - } else { - setUploadStatus({ - message: 'Validation successful!', - severity: 'success' - }); - } - }; - - // Update setupKeyboardShortcuts to include handleValidate in dependencies - const setupKeyboardShortcuts = useCallback(() => { - const shortcuts: KeyboardShortcut[] = [ - { - key: 's', - ctrlKey: true, - metaKey: true, - handler: () => { - if (selectedFile && isTextFile(selectedFile.name)) { - handleSave(selectedFile); - } - } - }, - { - key: 'v', - ctrlKey: true, - metaKey: true, - handler: () => { - if (selectedFile?.path.endsWith('rdf.yaml')) { - handleValidate(); - } - } - } - ]; - - const handleKeyDown = (e: KeyboardEvent) => { - shortcuts.forEach(shortcut => { - if ( - e.key === shortcut.key && - (!shortcut.ctrlKey || e.ctrlKey) && - (!shortcut.metaKey || e.metaKey) - ) { - e.preventDefault(); - shortcut.handler(); - } - }); - }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [selectedFile, handleSave, handleValidate, unsavedChanges, files]); // Add handleValidate - - // Add this useEffect to set up the keyboard shortcuts - useEffect(() => { - const cleanup = setupKeyboardShortcuts(); - return cleanup; - }, [setupKeyboardShortcuts]); - - // Add this function near the top of the component - const handleCopyId = () => { - const id = artifactInfo?.id.split('/').pop() || ''; - navigator.clipboard.writeText(id); - // Optionally add some visual feedback - setUploadStatus({ - message: 'ID copied to clipboard', - severity: 'success' - }); - }; - // Add download function - const handleDownload = () => { - if (!artifactInfo) return; - - const id = artifactInfo.id.split('/').pop() || ''; - const versionParam = isStaged ? '?version=stage' : ''; - const downloadUrl = `https://hypha.aicell.io/ri-scale/artifacts/${id}/create-zip-file${versionParam}`; - - window.open(downloadUrl, '_blank'); - }; - - // Add ValidationErrorDialog - const ValidationErrorDialog: React.FC<{ - open: boolean; - errors: string[]; - onClose: () => void; - }> = ({ open, errors, onClose }) => ( - -
-

- Invalid RDF.yaml Content -

-
-
-
    - {errors.map((error, index) => ( -
  • {error}
  • - ))} -
+ {/* File list */} +
+
+

+ + + + Files + {fileList.length > 0 && ( + ({fileList.length} items) + )} +

+
-

- Please fix these issues before saving. The ID and emoji fields must match the artifact's assigned values. -

-
-
- -
-
- - ); - - // Add this function before renderFileContent - const checkImageDimensions = (url: string, fileName: string) => { - const img = new Image(); - img.onload = () => { - setImageDimensions({ width: img.width, height: img.height }); - }; - img.src = url; - }; - - // Add handler for deleting a version - const handleDeleteVersion = async () => { - if (!artifactManager || !artifactId || !editVersion) return; - - try { - setUploadStatus({ - message: 'Deleting version...', - severity: 'info' - }); - - await artifactManager.delete({ - artifact_id: artifactId, - version: editVersion, - delete_files: true, - recursive: true, - _rkwargs: true - }); - setUploadStatus({ - message: 'Version deleted successfully', - severity: 'success' - }); - - // Close the dialog - setShowDeleteVersionDialog(false); - - // Navigate back to the appropriate page - navigate(getBackPath()); - } catch (error) { - setShowDeleteVersionDialog(false); - alert(`Error deleting version: ${error}`); - console.error('Error deleting version:', error); - setUploadStatus({ - message: 'Error deleting version', - severity: 'error' - }); - } - }; - - // Add this function before renderFileContent - const renderDeleteVersionDialog = () => ( - setShowDeleteVersionDialog(false)} - maxWidth="sm" - fullWidth - > -
-

- Delete Version -

-
-
-
- - + {filesLoading ? ( +
Loading files…
+ ) : fileList.length === 0 ? ( +
+ + -

Warning: This action cannot be undone

+

No files yet

+ {isGitStorage && ( +

+ Use the Git commands above to push your model files. +

+ )}
-

- You are about to permanently delete version {editVersion} of this artifact. This will remove all files and metadata associated with this version. -

-
-
-
- - -
-
- - ); - - return ( -
- {/* Header - make it fixed for small screens */} -
-
- {/* Toggle sidebar button */} - - - {/* Back button */} - -
-
- -
- {/* Sidebar - update z-index */} -
- - {/* Artifact Info Box - always visible */} -
- {artifactInfo ? ( - <> -
-

- {artifactInfo.manifest.name} -

- - {isStaged - ? ((artifactInfo?.manifest as any)?.status === 'published' ? 'Published (Staged)' : 'Stage') - : (lastVersion || '')} - -
-
- {artifactInfo.manifest.id_emoji && ( - - {artifactInfo.manifest.id_emoji} - + ) : ( +
+ {fileList.map((f, i) => ( +
+ {f.type === 'directory' ? '📁' : fileIcon(f.name)} + {f.name} + {f.size !== undefined && f.size > 0 && ( + {formatBytes(f.size)} )} - - {artifactInfo.id.split('/').pop()} - - - -
- {/* Add status badge if artifact is staged */} - {artifactInfo.staging !== null && ( -
- - status: {(artifactInfo.manifest as any)?.status || 'staged'} - -
- )} - - - ) : ( -
Loading artifact info...
- )} -
- {/* Navigation buttons */} - {renderSidebarNav()} - - {/* Files list - always visible */} - {renderFileList()} - -
- - {/* Main content area */} -
- {/* Status bar - only show when not in review tab */} - {files.length > 0 && activeTab !== 'review' && ( -
-
-
- {/* Status section - add max width for large screens */} -
- {copyProgress ? ( - <> -
- - Copying files ({copyProgress.current}/{copyProgress.total}): {copyProgress.file} - -
- - ) : ( - <> - {uploadStatus && ( -
- - {uploadStatus.message} - -
- )} - - )} -
- - {/* Buttons section - add relative positioning and higher z-index */} -
- {renderActionButtons()} -
-
-
- - {/* Progress bar at the bottom edge */} - {uploadStatus?.progress !== undefined && ( -
-
- )} + ))}
)} - - {/* Content area - update height calculation */} -
- {renderContent()} -
-
- - {/* Publish Confirmation Dialog */} - {renderPublishDialog()} - - {renderDeleteConfirmDialog()} - {renderNewVersionDialog()} - - {/* Add Delete Version Dialog */} - {renderDeleteVersionDialog()} - - {/* Add ValidationErrorDialog */} - 0} - errors={validationErrors} - onClose={() => setValidationErrors([])} - /> + {/* Citations */} + {manifest.cite?.length > 0 && ( +
+

Citations

+
    + {manifest.cite.map((c: any, i: number) => ( +
  • + {c.text} + {c.doi && ( + + [{c.doi}] + + )} + {c.url && ( + + [link] + + )} +
  • + ))} +
+
+ )} - {/* Update overlay for mobile */} - {isSidebarOpen && ( -
setIsSidebarOpen(false)} - /> - )} +
); }; -export default Edit; \ No newline at end of file +export default Edit; diff --git a/src/components/LoginButton.tsx b/src/components/LoginButton.tsx index 2dbb9b69..5aac6296 100644 --- a/src/components/LoginButton.tsx +++ b/src/components/LoginButton.tsx @@ -205,15 +205,6 @@ export default function LoginButton({ className = '' }: LoginButtonProps) {
{user.email}
- {user.roles?.includes('admin') && ( - setIsDropdownOpen(false)} - > - Admin Dashboard - - )} - setIsDropdownOpen(false)} - > - BioEngine - - {/* Add API Documentation link */} = ({ onPartnerClick }) => { return (
@@ -238,14 +238,11 @@ const PartnerScroll: React.FC = ({ onPartnerClick }) => { {/* Content with relative positioning */}
{/* Header */} -
-

- RI-SCALE Model Hub +
+

+ Our Partners

-
-

- Supported by our amazing community partners in AI-powered research -

+
{/* Partners Container */} @@ -265,7 +262,7 @@ const PartnerScroll: React.FC = ({ onPartnerClick }) => {
{ const target = e.target as HTMLDivElement; setShowLeftArrow(target.scrollLeft > 0); @@ -279,7 +276,7 @@ const PartnerScroll: React.FC = ({ onPartnerClick }) => { {partners.map((partner, index) => (
handleMouseEnter(e, partner)} onMouseLeave={handleMouseLeave} > @@ -287,7 +284,7 @@ const PartnerScroll: React.FC = ({ onPartnerClick }) => { onClick={(e) => handlePartnerClick(e, partner)} className="flex flex-col items-center w-full" > -
+
{partner.name} = ({ onPartnerClick }) => { }} />
- + {partner.name} diff --git a/src/components/Upload.tsx b/src/components/Upload.tsx index 27c2bd0c..8bf14271 100644 --- a/src/components/Upload.tsx +++ b/src/components/Upload.tsx @@ -1,1509 +1,647 @@ -import React, { useState, useCallback, useEffect } from 'react'; -import { useDropzone } from 'react-dropzone'; -import JSZip from 'jszip'; -import Editor from '@monaco-editor/react'; +import React, { useState, useEffect, useCallback } from 'react'; import { useHyphaStore } from '../store/hyphaStore'; -import axios from 'axios'; -import { LinearProgress } from '@mui/material'; -import yaml from 'js-yaml'; -import { Link, useNavigate } from 'react-router-dom'; -import RDFEditor from './RDFEditor'; -import gridBg from '../assets/grid.svg'; +import { Link } from 'react-router-dom'; -// Helper function to extract weight file paths from manifest -const extractWeightFiles = (manifest: any): string[] => { - if (!manifest || !manifest.weights) return []; - - const weightFiles: string[] = []; - Object.entries(manifest.weights).forEach(([_, weightInfo]: [string, any]) => { - if (weightInfo && weightInfo.source) { - // Handle paths that might start with ./ or just be filenames - let path = weightInfo.source; - if (path.startsWith('./')) { - path = path.substring(2); - } - weightFiles.push(path); - } - }); - - return weightFiles; -}; +const PARENT_ID = 'ri-scale/ai-model-hub'; +const SERVER_URL = 'https://hypha.aicell.io'; -interface FileNode { - name: string; - path: string; - content?: string | ArrayBuffer; - isDirectory: boolean; - children?: FileNode[]; - edited?: boolean; - size: number; - handle?: JSZip.JSZipObject; - loaded?: boolean; - file?: File; -} - -interface Manifest { - version?: string; - [key: string]: any; -} - -interface UploadStatus { - message: string; - severity: 'info' | 'success' | 'error'; - progress?: number; -} - -interface ValidationResult { - success: boolean; - details: string; -} - -interface TestResult { - name: string; - success: boolean; - details: Array<{ - name: string; - status: string; - errors: Array<{ - msg: string; - loc: string[]; - }>; - warnings: Array<{ - msg: string; - loc: string[]; - }>; - }>; -} - -type SupportedTextFiles = '.txt' | '.yml' | '.yaml' | '.json' | '.md' | '.py' | '.js' | '.ts' | '.jsx' | '.tsx' | '.css' | '.html' | '.ijm'; -type SupportedImageFiles = '.png' | '.jpg' | '.jpeg' | '.gif'; - -// Universal binary file detection -const isKnownTextFile = (filename: string): boolean => { - const textExtensions = [ - '.txt', '.yml', '.yaml', '.json', '.xml', '.csv', '.tsv', - '.md', '.rst', '.tex', - '.py', '.js', '.ts', '.jsx', '.tsx', '.css', '.html', '.htm', - '.c', '.cpp', '.h', '.hpp', '.java', '.php', '.rb', '.go', '.rs', - '.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat', '.cmd', - '.ijm', '.ini', '.cfg', '.conf', '.toml', '.log', '.sql', '.r', '.R', '.ipynb' - ]; - return textExtensions.some(ext => filename.toLowerCase().endsWith(ext)); -}; - -interface UploadProps { - artifactId?: string; -} - -interface UploadArtifact { +interface ArtifactItem { id: string; - version: string; -} - -interface RdfManifest { - type: 'model' | 'application' | 'dataset'; - name: string; - [key: string]: any; -} - -const LARGE_FILE_THRESHOLD = 10 * 1024 * 1024; // 10MB - -const findEmoji = (config: any, type: string, name: string): string => { - const category = type === 'model' ? 'animal' : - type === 'application' ? 'object' : - type === 'dataset' ? 'fruit' : null; - - if (!category || !config?.id_parts?.[category]) return '🦒'; - - const names = config.id_parts[category]; - const emojis = config.id_parts[`${category}_emoji`]; - const index = names.indexOf(name); - return index >= 0 ? emojis[index] : '🦒'; -}; - -const extractNounFromId = (id: string): string => { - const parts = id.split('-'); - const noun = parts[parts.length - 1]; - return noun; -}; - -const readFileContent = (file: File): Promise => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = (event) => { - if (event.target?.result) { - resolve(event.target.result); - } else { - reject(new Error('Failed to read file content')); - } - }; - - reader.onerror = () => { - reject(reader.error || new Error('Unknown error reading file')); - }; - - if (isKnownTextFile(file.name)) { - reader.readAsText(file); - } else { - reader.readAsArrayBuffer(file); - } - }); -}; - -const getMimeType = (filename: string): string => { - const ext = filename.split('.').pop()?.toLowerCase(); - switch (ext) { - case 'html': return 'text/html'; - case 'js': return 'application/javascript'; - case 'css': return 'text/css'; - case 'json': return 'application/json'; - case 'png': return 'image/png'; - case 'jpg': case 'jpeg': return 'image/jpeg'; - case 'gif': return 'image/gif'; - case 'svg': return 'image/svg+xml'; - case 'txt': return 'text/plain'; - case 'yaml': case 'yml': return 'application/x-yaml'; - case 'md': return 'text/markdown'; - default: return ''; - } -}; - -const Upload: React.FC = ({ artifactId }) => { - const [files, setFiles] = useState([]); - const [selectedFile, setSelectedFile] = useState(null); - const { artifactManager, isLoggedIn, server, user } = useHyphaStore(); - const [uploadStatus, setUploadStatus] = useState(null); - const [imageUrl, setImageUrl] = useState(null); - const [showDragDrop, setShowDragDrop] = useState(!files.length); - const navigate = useNavigate(); - const [isUploading, setIsUploading] = useState(false); - const [testResult, setTestResult] = useState(null); - const [isValidated, setIsValidated] = useState(false); - const [isUploaded, setIsUploaded] = useState(false); - const [uploadedArtifact, setUploadedArtifact] = useState(null); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const [generatedId, setGeneratedId] = useState(null); - const [generatedEmoji, setGeneratedEmoji] = useState(null); - const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null); - - useEffect(() => { - if (artifactId) { - loadArtifactFiles(); - } - }, [artifactId]); - - useEffect(() => { - if (files.some(f => f.edited)) { - setIsValidated(false); - setTestResult(null); - } - }, [files]); - - const isTextFile = (filename: string): boolean => { - const textExtensions: SupportedTextFiles[] = [ - '.txt', '.yml', '.yaml', '.json', '.md', '.py', - '.js', '.ts', '.jsx', '.tsx', '.css', '.html', - '.ijm' - ]; - return textExtensions.some(ext => filename.toLowerCase().endsWith(ext)); - }; - - const isImageFile = (filename: string): boolean => { - const imageExtensions: SupportedImageFiles[] = ['.png', '.jpg', '.jpeg', '.gif']; - return imageExtensions.some(ext => filename.toLowerCase().endsWith(ext)); + alias: string; + manifest: { + name?: string; + description?: string; + type?: string; + tags?: string[]; }; + git_url?: string; + created_at: number; + config?: Record; +} - const formatFileSize = (bytes: number): string => { - if (bytes === 0) return '0 Bytes'; - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +const CopyButton: React.FC<{ text: string; label?: string }> = ({ text, label = 'Copy' }) => { + const [copied, setCopied] = useState(false); + const handleCopy = () => { + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); }; + return ( + + ); +}; - const getImageDataUrl = async (content: string | ArrayBufferLike, fileName: string): Promise => { - if (typeof content === 'string') { - const encoder = new TextEncoder(); - content = encoder.encode(content).buffer; - } +const CodeBlock: React.FC<{ code: string; onCopy?: () => void }> = ({ code }) => ( +
+
+      {code}
+    
+
+ +
+
+); - const extension = fileName.toLowerCase().split('.').pop() || ''; - const bytes = new Uint8Array(content as ArrayBuffer); - const binary = bytes.reduce((data, byte) => data + String.fromCharCode(byte), ''); - const base64 = btoa(binary); - - const mimeType = `image/${extension === 'jpg' ? 'jpeg' : extension}`; - return `data:${mimeType};base64,${base64}`; - }; +interface CreateDialogProps { + onClose: () => void; + onCreate: (name: string, description: string) => Promise; + creating: boolean; +} - const getEditorLanguage = (filename: string): string => { - const extension = filename.toLowerCase().split('.').pop() || ''; - const languageMap: Record = { - 'py': 'python', - 'js': 'javascript', - 'ts': 'typescript', - 'jsx': 'javascript', - 'tsx': 'typescript', - 'css': 'css', - 'html': 'html', - 'json': 'json', - 'yml': 'yaml', - 'yaml': 'yaml', - 'md': 'markdown', - 'txt': 'plaintext', - 'ijm': 'javascript' - }; - return languageMap[extension] || 'plaintext'; - }; +const CreateDialog: React.FC = ({ onClose, onCreate, creating }) => { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [showCliInfo, setShowCliInfo] = useState(false); - const getCommonPrefix = (nodes: FileNode[]): string => { - if (nodes.length === 0) return ''; - const firstPath = nodes[0].path; - const parts = firstPath.split('/'); - - // If the first file is at root, there is no common directory prefix - if (parts.length === 1) return ''; - - const prefix = parts[0]; - for (let i = 1; i < nodes.length; i++) { - if (!nodes[i].path.startsWith(prefix + '/')) { - return ''; - } - } - return prefix; + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + await onCreate(name.trim(), description.trim()); }; - const ensureStaticSiteConfig = async (nodes: FileNode[]) => { - const commonPrefix = getCommonPrefix(nodes); - const expectedIndexPath = commonPrefix ? `${commonPrefix}/index.html` : 'index.html'; - const indexFile = nodes.find(file => file.path === expectedIndexPath); - - if (!indexFile) return nodes; - - let rdfFile = nodes.find(file => file.path.endsWith('rdf.yaml')); - - const relativeHtmlFiles = nodes - .filter(f => f.name.endsWith('.html')) - .map(f => { - let p = f.path; - if (commonPrefix && p.startsWith(commonPrefix + '/')) { - p = p.substring(commonPrefix.length + 1); - } - return p; - }); - - const viewConfig = { - root_directory: '.', - templates: relativeHtmlFiles, - template_engine: 'jinja2', - use_builtin_template: false, - headers: { - "Access-Control-Allow-Origin": "*", - "Cache-Control": "max-age=3600" - } - }; + // Preview the alias slug + const aliasPreview = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); - if (rdfFile) { - if (!rdfFile.loaded) { - try { - const content = await loadFileContent(rdfFile); - if (!content) return nodes; - } catch (e) { - console.warn("Failed to load rdf.yaml for static config update", e); - return nodes; - } - } - - // Re-find the file object as it might have been updated by loadFileContent (state update is async/detached here) - // Actually loadFileContent updates state but returns content directly. - // But we are operating on 'nodes' array passed in. - const content = rdfFile.content || await loadFileContent(rdfFile); - - if (content) { - try { - const contentStr = typeof content === 'string' - ? content - : new TextDecoder().decode(content); - - const manifest = yaml.load(contentStr) as RdfManifest; - - if (!manifest.config) manifest.config = {}; - - if (!manifest.config.view_config) { - manifest.config.view_config = viewConfig; - - if (!manifest.tags) manifest.tags = []; - if (!manifest.tags.includes('static-site')) manifest.tags.push('static-site'); - - const newContent = yaml.dump(manifest); - - return nodes.map(f => f.path === rdfFile.path ? { - ...f, - content: newContent, - edited: true, - loaded: true - } : f); - } - } catch (e) { - console.warn("Failed to update manifest with static site config", e); - } - } - } else { - const name = commonPrefix || 'New Application'; - - const manifestContent = yaml.dump({ - type: 'application', - name: name, - description: 'Static web application automatically generated.', - tags: ['static-site'], - config: { - view_config: viewConfig - } - }); - - const newRdfFile: FileNode = { - name: 'rdf.yaml', - path: commonPrefix ? `${commonPrefix}/rdf.yaml` : 'rdf.yaml', - content: manifestContent, - isDirectory: false, - size: manifestContent.length, - loaded: true, - edited: true - }; - - return [...nodes, newRdfFile]; - } - - return nodes; - }; - - const onDrop = useCallback(async (acceptedFiles: File[]) => { - const isZipFile = acceptedFiles.length === 1 && acceptedFiles[0].name.toLowerCase().endsWith('.zip'); - if (isZipFile) { - await processZipFile(acceptedFiles[0]); - } else { - await processFilesAndFolders(acceptedFiles); - } - }, []); - - const processZipFile = async (zipFile: File) => { - setUploadStatus({ - message: 'Processing zip file...', - severity: 'info', - progress: 0 - }); - - const zip = new JSZip(); - - try { - await new Promise(resolve => setTimeout(resolve, 100)); - - const loadedZip = await zip.loadAsync(zipFile); - const fileNodes: FileNode[] = []; - - const totalFiles = Object.keys(loadedZip.files).length; - let processedFiles = 0; - - setUploadStatus({ - message: 'Reading zip contents...', - severity: 'info', - progress: 5 - }); - - for (const [path, file] of Object.entries(loadedZip.files)) { - if (!file.dir) { - const pathParts = path.split('/'); - const fileName = pathParts[pathParts.length - 1]; - - const fileNode: FileNode = { - name: fileName, - path: path, - isDirectory: false, - size: (file as any)._data ? (file as any)._data.uncompressedSize : 0, - handle: file - }; - - if (fileName === 'rdf.yaml') { - let content = await file.async('string'); - if (user?.email) { - try { - const manifest = yaml.load(content) as RdfManifest; - if (!manifest.uploader?.email) { - manifest.uploader = { ...manifest.uploader, email: user.email }; - content = yaml.dump(manifest); - } - } catch (e) { - console.warn("Failed to inject email into rdf.yaml", e); - } - } - fileNode.content = content; - fileNode.loaded = true; - } + return ( +
+
+

Create New Artifact

+

+ A Git repository will be created for your model. You can push files using Git and Git LFS. +

+
+
+ + setName(e.target.value)} + placeholder="e.g. cellpose-v3-retrained" + className="w-full border border-gray-300 rounded-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#f39200] focus:border-transparent" + required + autoFocus + /> + {aliasPreview && ( +

+ Repository ID: {aliasPreview} +

+ )} +
+
+ +