diff --git a/.env.example b/.env.example
index 9f5c7b1..b502446 100644
--- a/.env.example
+++ b/.env.example
@@ -13,3 +13,8 @@ HERMES_TT_URL=http://100.86.197.88:3010
HERMES_NYORA_URL=http://100.86.197.88:3020
HERMES_PERSO_URL=http://100.86.197.88:3031
HERMES_NABIL_URL=http://127.0.0.1:8642
+DSH_FILEBROWSER_URL=http://dsh-filebrowser:80
+
+# User IDs
+DSH_UID=1001
+DSH_GID=1001
diff --git a/app/config.py b/app/config.py
index 60651ec..1bc8e51 100644
--- a/app/config.py
+++ b/app/config.py
@@ -1,6 +1,6 @@
import os
from pathlib import Path
-from typing import Dict, Any, List
+from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field, field_validator
from pydantic_settings import BaseSettings
@@ -20,6 +20,8 @@ class UniverseConfig(BaseModel):
icon: str
persona_file: str
enabled: bool = True
+ supports_files: bool = False
+ files_url: Optional[str] = None
class Settings(BaseSettings):
app_name: str = "Hermes Hub"
@@ -36,6 +38,7 @@ class Settings(BaseSettings):
hermes_nyora_url: str = os.getenv("HERMES_NYORA_URL", "http://100.86.197.88:3020")
hermes_perso_url: str = os.getenv("HERMES_PERSO_URL", "http://100.86.197.88:3031")
hermes_nabil_url: str = os.getenv("HERMES_NABIL_URL", "http://127.0.0.1:8642")
+ dsh_filebrowser_url: str = os.getenv("DSH_FILEBROWSER_URL", "http://127.0.0.1:8901")
# Hub Secret — OBLIGATOIRE, aucun fallback codé en dur (Fail-Fast au démarrage)
hub_secret: str = Field(..., min_length=16, description="Clé secrète maîtresse requise pour Hermes Hub")
@@ -58,7 +61,6 @@ except Exception as e:
# but runtime will fail fast if HUB_SECRET is absent
if "HUB_SECRET" in os.environ:
raise e
- # Fallback only if running build/compile check with dummy env
settings = None
UNIVERSES: Dict[str, UniverseConfig] = {
@@ -71,7 +73,8 @@ UNIVERSES: Dict[str, UniverseConfig] = {
scope="tt",
accent_token="--accent-tt",
icon="briefcase",
- persona_file="tt.yaml"
+ persona_file="tt.yaml",
+ supports_files=False
),
"nyora": UniverseConfig(
id="nyora",
@@ -82,7 +85,8 @@ UNIVERSES: Dict[str, UniverseConfig] = {
scope="nyora",
accent_token="--accent-nyora",
icon="sparkles",
- persona_file="nyora.yaml"
+ persona_file="nyora.yaml",
+ supports_files=False
),
"perso": UniverseConfig(
id="perso",
@@ -93,7 +97,8 @@ UNIVERSES: Dict[str, UniverseConfig] = {
scope="perso",
accent_token="--accent-perso",
icon="home",
- persona_file="perso.yaml"
+ persona_file="perso.yaml",
+ supports_files=False
),
"nabil": UniverseConfig(
id="nabil",
@@ -104,7 +109,9 @@ UNIVERSES: Dict[str, UniverseConfig] = {
scope="nabil",
accent_token="--accent-nabil",
icon="terminal",
- persona_file="nabil.yaml"
+ persona_file="nabil.yaml",
+ supports_files=True,
+ files_url=os.getenv("DSH_FILEBROWSER_URL", "http://127.0.0.1:8901")
)
}
diff --git a/app/routers/proxy.py b/app/routers/proxy.py
index ba36da2..399dd80 100644
--- a/app/routers/proxy.py
+++ b/app/routers/proxy.py
@@ -1,9 +1,30 @@
from fastapi import APIRouter, Request, HTTPException
-from app.config import UNIVERSES
+from app.config import UNIVERSES, get_universe
from app.proxy import proxy_request
router = APIRouter(prefix="/u", tags=["Universe Proxy"])
+@router.api_route("/{universe_id}/files", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
+@router.api_route("/{universe_id}/files/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
+@router.api_route("/{universe_id}/files/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
+async def dynamic_files_proxy(universe_id: str, request: Request, path: str = ""):
+ if universe_id not in UNIVERSES:
+ raise HTTPException(status_code=404, detail=f"Universe '{universe_id}' not found.")
+
+ universe = UNIVERSES[universe_id]
+ if not universe.supports_files or not universe.files_url:
+ raise HTTPException(status_code=404, detail=f"Universe '{universe_id}' does not have an active filebrowser.")
+
+ # Filebrowser runs with --baseurl /u/{universe_id}/files
+ full_subpath = f"u/{universe_id}/files/{path}".rstrip("/") if path else f"u/{universe_id}/files/"
+
+ return await proxy_request(
+ request=request,
+ backend_url=universe.files_url,
+ path=full_subpath,
+ universe_id=universe_id
+ )
+
@router.api_route("/{universe_id}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
@router.api_route("/{universe_id}/", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
@router.api_route("/{universe_id}/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
diff --git a/app/templates/index.html b/app/templates/index.html
index a29fd31..1943d23 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -21,6 +21,7 @@
data-name="{{ u.name }}"
data-scope="{{ u.scope }}"
data-tagline="{{ u.tagline }}"
+ data-supports-files="{{ 'true' if u.supports_files else 'false' }}"
style="--item-accent: var({{ u.accent_token }});"
>
@@ -60,7 +61,20 @@
-
Session sécurisée VPS ↔ NAS
+
+
+
+
+
+
+
+
Session sécurisée VPS ↔ NAS
diff --git a/docker-compose.yml b/docker-compose.yml
index bd22d9c..0f42cfe 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -7,7 +7,7 @@ services:
restart: unless-stopped
user: "1026:100"
ports:
- # Bind strictement sur loopback pour n'etre accessible QUE par Cloudflare Tunnel / Tailscale
+ # Bind strictement sur loopback pour n'etre accessible QUE par Cloudflare Tunnel
- "127.0.0.1:8080:8080"
volumes:
- ./data:/app/data
@@ -21,8 +21,39 @@ services:
- HERMES_NYORA_URL=http://100.86.197.88:3020
- HERMES_PERSO_URL=http://100.86.197.88:3031
- HERMES_NABIL_URL=http://127.0.0.1:8642
+ - DSH_FILEBROWSER_URL=http://dsh-filebrowser:80
+ depends_on:
+ - dsh-filebrowser
dns:
- 1.1.1.1
- 8.8.8.8
+ networks:
+ - hub-net
labels:
com.centurylinklabs.watchtower.enable: "false"
+
+ dsh-filebrowser:
+ image: filebrowser/filebrowser:v2.32.0
+ container_name: dsh-filebrowser
+ restart: unless-stopped
+ user: "${DSH_UID:-1001}:${DSH_GID:-1001}"
+ command:
+ - "--noauth"
+ - "--root=/srv"
+ - "--baseurl=/u/nabil/files"
+ - "--address=0.0.0.0"
+ - "--port=80"
+ - "--branding.theme=dark"
+ volumes:
+ - /home/dsh-agent/dsh-vps/workspace:/srv
+ ports:
+ # Bind strictement loopback
+ - "127.0.0.1:8901:80"
+ networks:
+ - hub-net
+ labels:
+ com.centurylinklabs.watchtower.enable: "false"
+
+networks:
+ hub-net:
+ driver: bridge
diff --git a/static/css/style.css b/static/css/style.css
index ccb2bcb..b974949 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -443,3 +443,70 @@ html, body {
background-color: var(--hub-bg-card-hover);
color: var(--hub-text-primary);
}
+
+/* Mode Toggle Selector (Claude Design) */
+.hub-mode-toggle {
+ display: none;
+ align-items: center;
+ border: 1px solid var(--hub-border-medium);
+ height: 36px;
+ border-radius: var(--hub-radius-md);
+ overflow: hidden;
+ background-color: var(--hub-bg-card);
+}
+
+.hub-mode-toggle.visible {
+ display: flex;
+}
+
+.hub-mode-tab {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ height: 100%;
+ padding: 0 0.85rem;
+ border: none;
+ background: transparent;
+ color: var(--hub-text-secondary);
+ font-family: var(--hub-font-sans);
+ font-size: 0.8125rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ cursor: pointer;
+ transition: all 120ms ease;
+ user-select: none;
+}
+
+.hub-mode-tab:hover {
+ background-color: var(--hub-bg-card-hover);
+ color: var(--hub-text-primary);
+}
+
+.hub-mode-tab.active {
+ background: var(--hub-accent-current);
+ color: #ffffff;
+ cursor: default;
+ box-shadow: 0 0 10px var(--hub-accent-glow-current);
+}
+
+.hub-mode-divider {
+ width: 1px;
+ height: 60%;
+ background-color: var(--hub-border-medium);
+ flex: none;
+}
+
+@keyframes panelIn {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+.hub-canvas-anim {
+ animation: panelIn 140ms ease both;
+}
diff --git a/static/js/hub.js b/static/js/hub.js
index 9989864..70e5e59 100644
--- a/static/js/hub.js
+++ b/static/js/hub.js
@@ -1,15 +1,21 @@
/**
* Hermes Hub — Client-side Workspace Switcher & Context Manager
* Garantit l'isolation stricte du state front-end entre univers
+ * Intègre le sélecteur toggle DSH (Session vs Explorateur de fichiers)
*/
(function () {
let activeUniverse = "tt";
+ let activeMode = "session"; // "session" ou "files" (pour l'univers nabil)
+
const iframe = document.getElementById("workspace-iframe");
const loader = document.getElementById("hub-loader");
const activeTitle = document.getElementById("active-universe-name");
const activeScope = document.getElementById("active-universe-scope");
const activeTagline = document.getElementById("active-universe-tagline");
+ const nabilModeToggle = document.getElementById("nabil-mode-toggle");
+ const tabSession = document.getElementById("tab-session");
+ const tabFiles = document.getElementById("tab-files");
// Accent mappings
const ACCENT_MAP = {
@@ -33,21 +39,51 @@
document.documentElement.style.setProperty("--hub-accent-glow-current", glow);
}
- function switchUniverse(universeId) {
- if (!universeId || (activeUniverse === universeId && iframe.src !== "about:blank")) {
- return;
+ function getTargetUrl(universeId, mode) {
+ if (universeId === "nabil" && mode === "files") {
+ return "/u/nabil/files/";
}
+ return `/u/${universeId}/`;
+ }
+
+ function setMode(mode) {
+ activeMode = mode;
+ if (tabSession && tabFiles) {
+ if (mode === "session") {
+ tabSession.classList.add("active");
+ tabSession.setAttribute("aria-selected", "true");
+ tabFiles.classList.remove("active");
+ tabFiles.setAttribute("aria-selected", "false");
+ } else {
+ tabFiles.classList.add("active");
+ tabFiles.setAttribute("aria-selected", "true");
+ tabSession.classList.remove("active");
+ tabSession.setAttribute("aria-selected", "false");
+ }
+ }
+
+ // Recharger le canvas avec le mode choisi
+ loader.classList.remove("hidden");
+ iframe.src = "about:blank";
+ setTimeout(() => {
+ iframe.src = getTargetUrl(activeUniverse, activeMode);
+ }, 50);
+ }
+
+ function switchUniverse(universeId) {
+ if (!universeId) return;
const btn = document.querySelector(`.hub-universe-btn[data-universe="${universeId}"]`);
if (!btn) return;
// 1. ISOLATION TOTALE DU STATE CLIENT
- // Forcer le déchargement de l'iframe précédente pour éliminer tout résidu JS en mémoire
+ // Décharger immédiatement l'iframe précédente pour purger la mémoire JS
loader.classList.remove("hidden");
iframe.src = "about:blank";
// 2. Mettre à jour l'univers actif
activeUniverse = universeId;
+ activeMode = "session"; // Réinitialiser le mode à session par défaut
// 3. Mettre à jour la classe active sur la sidebar
document.querySelectorAll(".hub-universe-btn").forEach((el) => el.classList.remove("active"));
@@ -60,15 +96,31 @@
const name = btn.dataset.name || universeId.toUpperCase();
const scope = btn.dataset.scope || universeId;
const tagline = btn.dataset.tagline || "";
+ const supportsFiles = btn.dataset.supportsFiles === "true";
if (activeTitle) activeTitle.textContent = name;
if (activeScope) activeScope.textContent = `Scope: ${scope}`;
if (activeTagline) activeTagline.textContent = tagline;
- // 6. Charger le nouvel univers via le proxy dédié
- const targetProxyUrl = `/u/${universeId}/`;
+ // 6. Afficher ou masquer le sélecteur toggle DSH (Claude Design)
+ if (nabilModeToggle) {
+ if (supportsFiles) {
+ nabilModeToggle.classList.add("visible");
+ // Reset tabs visual state
+ if (tabSession && tabFiles) {
+ tabSession.classList.add("active");
+ tabSession.setAttribute("aria-selected", "true");
+ tabFiles.classList.remove("active");
+ tabFiles.setAttribute("aria-selected", "false");
+ }
+ } else {
+ nabilModeToggle.classList.remove("visible");
+ }
+ }
+
+ // 7. Charger le nouvel univers via le proxy dédié
setTimeout(() => {
- iframe.src = targetProxyUrl;
+ iframe.src = getTargetUrl(activeUniverse, activeMode);
}, 50);
}
@@ -105,7 +157,7 @@
}
}
- // Modal Personnalité
+ // Modales Personnalité & Clonage
const personaModal = document.getElementById("persona-modal");
const cloneModal = document.getElementById("clone-modal");
@@ -190,6 +242,10 @@
});
});
+ // Toggle Mode DSH (Session vs Files)
+ tabSession?.addEventListener("click", () => setMode("session"));
+ tabFiles?.addEventListener("click", () => setMode("files"));
+
// Outils Footer
document.getElementById("btn-edit-persona")?.addEventListener("click", openPersonaModal);
document.getElementById("btn-clone-universe")?.addEventListener("click", openCloneModal);