diff --git a/dashboard/docker-compose.yml b/dashboard/docker-compose.yml index fab1838..7e87128 100644 --- a/dashboard/docker-compose.yml +++ b/dashboard/docker-compose.yml @@ -44,6 +44,7 @@ services: - TELEGRAM_PROXY=${TELEGRAM_PROXY:-} - CORS_ORIGINS=https://nas.jimmygan.com - LITELLM_HEALTH_API_KEY=${LITELLM_HEALTH_API_KEY:-} + - LITELLM_URL=http://litellm:4005 volumes: - /volume1:/volume1 - /volume1/docker/nas-dashboard/ssh/dashboard_terminal:/app/ssh/id_ed25519:ro @@ -62,6 +63,7 @@ services: networks: - internal - gitea_gitea + - nas-dashboard_internal depends_on: - docker-socket-proxy logging: @@ -74,3 +76,5 @@ networks: internal: gitea_gitea: external: true + nas-dashboard_internal: + external: true diff --git a/dashboard/frontend/src/routes/Terminal.svelte b/dashboard/frontend/src/routes/Terminal.svelte index 719f358..de6ed55 100644 --- a/dashboard/frontend/src/routes/Terminal.svelte +++ b/dashboard/frontend/src/routes/Terminal.svelte @@ -16,6 +16,57 @@ let tabCounter = 0; const tabData = new Map(); + function cleanName(name) { + return (name || "image") + .replace(/[^a-zA-Z0-9._-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") || "image"; + } + + function extensionFor(type) { + if (type === "image/png") return "png"; + if (type === "image/jpeg") return "jpg"; + if (type === "image/webp") return "webp"; + if (type === "image/gif") return "gif"; + return "bin"; + } + + async function fileToBase64(file) { + const buf = await file.arrayBuffer(); + let binary = ""; + const bytes = new Uint8Array(buf); + const chunkSize = 0x8000; + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); + } + return btoa(binary); + } + + async function sendDroppedImage(term, ws, file) { + if (!file?.type?.startsWith("image/")) return; + if (ws.readyState !== 1) return; + + const ext = extensionFor(file.type); + const baseName = cleanName(file.name).replace(/\.[a-zA-Z0-9]+$/, ""); + const stamp = Date.now(); + const imagePath = `/tmp/claude-images/${baseName || "image"}-${stamp}.${ext}`; + const b64 = await fileToBase64(file); + + const cmd = [ + "python3 - <<'PY'", + "import base64, pathlib", + `p = pathlib.Path(${JSON.stringify(imagePath)})`, + "p.parent.mkdir(parents=True, exist_ok=True)", + `p.write_bytes(base64.b64decode(${JSON.stringify(b64)}))`, + `print(f'saved image -> {p}')`, + "PY", + "", + ].join("\n"); + + ws.send(new TextEncoder().encode(cmd)); + term.write(`\r\n\x1b[90m[Uploaded image to ${imagePath}]\x1b[0m\r\n`); + } + function addTab(hostId) { const id = ++tabCounter; const label = hosts.find((h) => h.id === hostId).label; @@ -29,6 +80,9 @@ if (d) { d.voice?.destroy(); d.ro?.disconnect(); + d.term?.element?.removeEventListener("dragover", d.handleDragOver); + d.term?.element?.removeEventListener("drop", d.handleDrop); + d.term?.textarea?.removeEventListener("paste", d.handlePaste); d.ws?.close(); d.term?.dispose(); tabData.delete(id); @@ -89,17 +143,52 @@ ws.onerror = () => updateTab(tabId, { connected: false, error: "Connection failed" }); term.onData((data) => ws.readyState === 1 && ws.send(new TextEncoder().encode(data))); + const handleDragOver = (e) => { + e.preventDefault(); + }; + + const handleDrop = async (e) => { + e.preventDefault(); + if (activeTab !== tabId) return; + const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type?.startsWith("image/")); + for (const file of files) { + await sendDroppedImage(term, ws, file); + } + }; + + const handlePaste = async (e) => { + if (activeTab !== tabId) return; + const items = [...(e.clipboardData?.items || [])] + .filter((it) => it.kind === "file" && it.type?.startsWith("image/")); + for (const item of items) { + const file = item.getAsFile(); + if (file) await sendDroppedImage(term, ws, file); + } + }; + + term.element?.addEventListener("dragover", handleDragOver); + term.element?.addEventListener("drop", handleDrop); + term.textarea?.addEventListener("paste", handlePaste); + const ro = new ResizeObserver(() => { fit.fit(); sendSize(); }); ro.observe(el); const voice = new VoiceSession(() => { voiceTick++; }); voice.attach(term, ws); - tabData.set(tabId, { ws, term, ro, el, voice }); + tabData.set(tabId, { ws, term, ro, el, voice, handleDragOver, handleDrop, handlePaste }); })(); } function activeVoice() { return voiceTick >= 0 && tabData.get(activeTab)?.voice; } - onDestroy(() => tabData.forEach((d) => { d.voice?.destroy(); d.ro?.disconnect(); d.ws?.close(); d.term?.dispose(); })); + onDestroy(() => tabData.forEach((d) => { + d.voice?.destroy(); + d.ro?.disconnect(); + d.term?.element?.removeEventListener("dragover", d.handleDragOver); + d.term?.element?.removeEventListener("drop", d.handleDrop); + d.term?.textarea?.removeEventListener("paste", d.handlePaste); + d.ws?.close(); + d.term?.dispose(); + }));
+ Tip: drag & drop or paste images into the active terminal tab to upload them to /tmp/claude-images.
+