Download app.py from HelloSun/qwen35-4b-ssd-streaming: direct link, hf CLI and curl.
- Browser
- Download file 27.6 kB
-
https://huggingface.co/HelloSun/qwen35-4b-ssd-streaming/resolve/main/app.py
- Command line
-
hf download hf://HelloSun/qwen35-4b-ssd-streaming/app.py
-
curl -L -o app.py https://huggingface.co/HelloSun/qwen35-4b-ssd-streaming/resolve/main/app.py
27.6 kB
| import os | |
| import pty | |
| import signal | |
| import struct | |
| import fcntl | |
| import termios | |
| import asyncio | |
| import json | |
| import base64 | |
| import logging | |
| import time | |
| import uuid | |
| import shutil | |
| import subprocess | |
| import spaces | |
| import httpx | |
| from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request | |
| from fastapi.responses import HTMLResponse, FileResponse, JSONResponse, StreamingResponse, Response | |
| import uvicorn | |
| logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") | |
| log = logging.getLogger("webterm") | |
| app = FastAPI() | |
| # ──────────────────────────── 全域設定 ──────────────────────────── | |
| HOME = os.path.expanduser("~") | |
| ALLOWED_BASES = [os.path.abspath(p) for p in ( | |
| "/home/user", "/workspace", "/home/app", HOME | |
| )] | |
| # 斷線後 session 保留的閒置時間(沒有任何分頁接回就會被清除) | |
| SESSION_IDLE_TIMEOUT = 30 * 60 # 30 分鐘 | |
| # 每個 session 保留的畫面回放緩衝區大小上限(bytes) | |
| SESSION_BUFFER_MAX = 300_000 | |
| # ──────────────────────────── Frontend ──────────────────────────── | |
| HTML_PAGE = r"""<!DOCTYPE html> | |
| <html lang="zh-TW"> | |
| <head> | |
| <meta charset="utf-8"/> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"/> | |
| <title>Web Terminal + OpenCode</title> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css"/> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| html,body{height:100%;overflow:hidden;background:#1e1e1e;color:#ccc;font-family:'Segoe UI',system-ui,sans-serif} | |
| #tab-bar{display:flex;align-items:flex-end;background:#252526;padding:4px 6px 0;gap:2px;border-bottom:1px solid #3c3c3c;min-height:38px;flex-shrink:0;overflow-x:auto} | |
| .tab{background:#2d2d2d;color:#969696;padding:6px 14px 5px;border-radius:6px 6px 0 0;cursor:pointer;font-size:13px;display:flex;align-items:center;gap:8px;user-select:none;white-space:nowrap;border:1px solid transparent;border-bottom:none} | |
| .tab:hover{color:#ddd} | |
| .tab.active{background:#1e1e1e;color:#fff;border-color:#3c3c3c} | |
| .tab .status-dot{width:7px;height:7px;border-radius:50%;background:#3fbf50;flex-shrink:0} | |
| .tab .status-dot.down{background:#c0392b} | |
| .tab .x{color:#666;font-size:15px;line-height:1;padding:0 2px} | |
| .tab .x:hover{color:#f55} | |
| #add-btn{background:none;border:1px dashed #555;color:#777;padding:3px 12px;border-radius:4px;cursor:pointer;font-size:18px;margin-bottom:4px;margin-left:4px} | |
| #add-btn:hover{color:#fff;border-color:#aaa} | |
| .action-btn{background:#2d2d2d;color:#969696;border:1px solid #3c3c3c;border-radius:4px;padding:4px 10px;cursor:pointer;font-size:13px;margin-bottom:4px;margin-left:4px;transition:all 0.2s;white-space:nowrap} | |
| .action-btn:hover{color:#fff;border-color:#aaa;background:#3c3c3c} | |
| #term-box{flex:1;overflow:hidden;padding:2px} | |
| .tw{width:100%;height:100%;display:none} | |
| .tw.active{display:block} | |
| body{display:flex;flex-direction:column} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="tab-bar"> | |
| <button id="add-btn" title="New Terminal (Ctrl+Shift+T)">+</button> | |
| <button id="copy-btn" class="action-btn" title="Copy selected text">📋 複製</button> | |
| <button id="paste-btn" class="action-btn" title="Paste to terminal">📌 貼上</button> | |
| <button id="upload-btn" class="action-btn" title="Upload to current directory">📤 上傳</button> | |
| <button id="download-btn" class="action-btn" title="Download from current directory">📥 下載</button> | |
| </div> | |
| <div id="term-box"></div> | |
| <input type="file" id="file-input" style="display:none;" /> | |
| <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script> | |
| <script> | |
| const bar = document.getElementById('tab-bar'); | |
| const box = document.getElementById('term-box'); | |
| const addBtn = document.getElementById('add-btn'); | |
| const copyBtn = document.getElementById('copy-btn'); | |
| const pasteBtn = document.getElementById('paste-btn'); | |
| const uploadBtn = document.getElementById('upload-btn'); | |
| const downloadBtn = document.getElementById('download-btn'); | |
| const fileInput = document.getElementById('file-input'); | |
| let tabs = [], activeId = null, cnt = 0; | |
| function activeTab() { return tabs.find(t => t.id === activeId); } | |
| function wsUrl(sessionId) { | |
| const p = location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| let url = `${p}//${location.host}/ws`; | |
| if (sessionId) url += `?session_id=${encodeURIComponent(sessionId)}`; | |
| return url; | |
| } | |
| // ── 分頁狀態持久化:把「分頁 id + 對應的伺服器 session_id」存進 localStorage, | |
| // 這樣重新整理整個網頁,甚至瀏覽器關掉重開,都能接回原本還在跑的終端機。 | |
| function saveTabs() { | |
| try { | |
| const data = tabs.map(t => ({ id: t.id, sessionId: t.sessionId || null })); | |
| localStorage.setItem('webterm_tabs', JSON.stringify(data)); | |
| localStorage.setItem('webterm_cnt', String(cnt)); | |
| } catch (e) { /* localStorage 不可用時忽略 */ } | |
| } | |
| function loadTabs() { | |
| try { | |
| const raw = localStorage.getItem('webterm_tabs'); | |
| if (!raw) return null; | |
| const data = JSON.parse(raw); | |
| if (Array.isArray(data) && data.length) return data; | |
| } catch (e) { /* ignore */ } | |
| return null; | |
| } | |
| function connect(tabObj) { | |
| const ws = new WebSocket(wsUrl(tabObj.sessionId)); | |
| tabObj.ws = ws; | |
| tabObj.reconnectDelay = tabObj.reconnectDelay || 500; | |
| ws.onopen = () => { | |
| tabObj.reconnectDelay = 500; | |
| tabObj.el.querySelector('.status-dot').classList.remove('down'); | |
| ws.send(JSON.stringify({type: 'resize', cols: tabObj.term.cols, rows: tabObj.term.rows})); | |
| }; | |
| ws.onmessage = e => { | |
| try { | |
| const m = JSON.parse(e.data); | |
| if (m.type === 'session') { | |
| // 伺服器告訴我們這條連線對應的 session id:記住它,下次斷線/重整才能接回同一個終端機 | |
| tabObj.sessionId = m.id; | |
| saveTabs(); | |
| } else if (m.type === 'output') { | |
| const pwdRegex = /\x1b\]0;PWD:(.*?)\x07/g; | |
| let match; | |
| while ((match = pwdRegex.exec(m.data)) !== null) { | |
| tabObj.currentDir = match[1]; | |
| } | |
| m.data = m.data.replace(/\x1b\]0;PWD:.*?\x07/g, ''); | |
| tabObj.term.write(m.data); | |
| } else if (m.type === 'system') { | |
| tabObj.term.write(m.data); | |
| } | |
| } catch { | |
| tabObj.term.write(e.data); | |
| } | |
| }; | |
| ws.onclose = () => { | |
| if (tabObj.closed) return; | |
| tabObj.el.querySelector('.status-dot').classList.add('down'); | |
| tabObj.term.write(`\r\n\x1b[31m[連線中斷,${(tabObj.reconnectDelay/1000).toFixed(1)}s 後自動重連(將接回原本畫面)...]\x1b[0m\r\n`); | |
| setTimeout(() => { | |
| if (!tabObj.closed) connect(tabObj); | |
| }, tabObj.reconnectDelay); | |
| tabObj.reconnectDelay = Math.min(tabObj.reconnectDelay * 1.7, 8000); | |
| }; | |
| ws.onerror = () => ws.close(); | |
| } | |
| function createTab(sessionId) { | |
| const id = ++cnt; | |
| const el = document.createElement('div'); | |
| el.className = 'tab'; | |
| el.dataset.id = id; | |
| el.innerHTML = `<span class="status-dot"></span><span>Term ${id}</span><span class="x">×</span>`; | |
| bar.insertBefore(el, copyBtn); | |
| const wrap = document.createElement('div'); | |
| wrap.className = 'tw'; | |
| wrap.dataset.id = id; | |
| box.appendChild(wrap); | |
| const term = new Terminal({ | |
| cursorBlink: true, | |
| fontSize: 14, | |
| fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace", | |
| allowProposedApi: true, | |
| theme: { background: '#1e1e1e', foreground: '#d4d4d4', cursor: '#aeafad', selectionBackground: '#264f78' } | |
| }); | |
| const fit = new FitAddon.FitAddon(); | |
| term.loadAddon(fit); | |
| term.loadAddon(new WebLinksAddon.WebLinksAddon()); | |
| term.open(wrap); | |
| fit.fit(); | |
| const obj = {id, el, wrap, term, fit, ws: null, closed: false, currentDir: '~', sessionId: sessionId || null}; | |
| tabs.push(obj); | |
| connect(obj); | |
| saveTabs(); | |
| term.onData(d => { | |
| if (obj.ws && obj.ws.readyState === WebSocket.OPEN) obj.ws.send(JSON.stringify({type: 'input', data: d})); | |
| }); | |
| term.onResize(({cols, rows}) => { | |
| if (obj.ws && obj.ws.readyState === WebSocket.OPEN) obj.ws.send(JSON.stringify({type: 'resize', cols, rows})); | |
| }); | |
| el.onclick = e => { | |
| if (e.target.classList.contains('x')) closeTab(id); | |
| else activate(id); | |
| }; | |
| activate(id); | |
| return obj; | |
| } | |
| function activate(id) { | |
| activeId = id; | |
| tabs.forEach(t => { | |
| const on = t.id === id; | |
| t.el.classList.toggle('active', on); | |
| t.wrap.classList.toggle('active', on); | |
| if (on) setTimeout(() => { t.fit.fit(); t.term.focus(); }, 30); | |
| }); | |
| } | |
| function closeTab(id) { | |
| const i = tabs.findIndex(t => t.id === id); | |
| if (i < 0) return; | |
| const t = tabs[i]; | |
| t.closed = true; | |
| if (t.ws) { | |
| // 使用者主動關閉分頁才真正通知伺服器砍掉該 session(區別於單純網路斷線) | |
| if (t.ws.readyState === WebSocket.OPEN) { | |
| try { t.ws.send(JSON.stringify({type: 'kill'})); } catch (e) {} | |
| } | |
| t.ws.close(); | |
| } | |
| t.term.dispose(); | |
| t.el.remove(); | |
| t.wrap.remove(); | |
| tabs.splice(i, 1); | |
| saveTabs(); | |
| if (!tabs.length) { createTab(); return; } | |
| if (activeId === id) activate(tabs[Math.min(i, tabs.length - 1)].id); | |
| } | |
| addBtn.onclick = () => createTab(); | |
| copyBtn.onclick = async () => { | |
| const t = activeTab(); | |
| if (!t) return; | |
| const text = t.term.getSelection(); | |
| if (text) { | |
| try { | |
| await navigator.clipboard.writeText(text); | |
| t.term.write('\r\n\x1b[32m[✅ 已複製選取文字到系統剪貼簿]\x1b[0m\r\n'); | |
| } catch (err) { | |
| t.term.write('\r\n\x1b[31m[❌ 複製失敗,請檢查瀏覽器權限]\x1b[0m\r\n'); | |
| } | |
| } else { | |
| t.term.write('\r\n\x1b[33m[⚠️ 請先用滑鼠在終端機中選取要複製的文字]\x1b[0m\r\n'); | |
| } | |
| }; | |
| pasteBtn.onclick = async () => { | |
| const t = activeTab(); | |
| if (!t) return; | |
| try { | |
| const text = await navigator.clipboard.readText(); | |
| if (t.ws && t.ws.readyState === WebSocket.OPEN) t.ws.send(JSON.stringify({type: 'input', data: text})); | |
| } catch (err) { | |
| t.term.write('\r\n\x1b[31m[❌ 貼上失敗,請允許瀏覽器讀取剪貼簿權限]\x1b[0m\r\n'); | |
| } | |
| }; | |
| uploadBtn.onclick = () => fileInput.click(); | |
| fileInput.onchange = (e) => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const t = activeTab(); | |
| if (!t) return; | |
| if (file.size > 200 * 1024 * 1024) { | |
| alert("透過終端機上傳限制 200MB 以內的檔案(大型模型等檔案請直接在終端機用 curl/wget 下載)"); | |
| fileInput.value = ''; | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = (evt) => { | |
| const base64Data = evt.target.result.split(',')[1]; | |
| if (t.ws && t.ws.readyState === WebSocket.OPEN) { | |
| t.ws.send(JSON.stringify({type: 'upload', filename: file.name, data: base64Data, targetDir: t.currentDir})); | |
| t.term.write(`\r\n\x1b[33m[⏳ 正在上傳 ${file.name} 至 ${t.currentDir} ...]\x1b[0m\r\n`); | |
| } | |
| }; | |
| reader.readAsDataURL(file); | |
| fileInput.value = ''; | |
| }; | |
| downloadBtn.onclick = () => { | |
| const t = activeTab(); | |
| if (!t) return; | |
| const filename = prompt(`請輸入要下載的檔案名稱:\n(預設目錄: ${t.currentDir})`); | |
| if (!filename) return; | |
| const trimmed = filename.trim(); | |
| const path = trimmed.startsWith('/') ? trimmed : (t.currentDir.replace(/\/$/, '') + '/' + trimmed); | |
| const url = '/download?path=' + encodeURIComponent(path); | |
| window.open(url, '_blank'); | |
| t.term.write(`\r\n\x1b[33m[⏳ 已在新分頁開始下載 ${path} ...]\x1b[0m\r\n`); | |
| }; | |
| window.addEventListener('resize', () => tabs.forEach(t => { | |
| if (t.wrap.classList.contains('active')) t.fit.fit(); | |
| })); | |
| document.addEventListener('keydown', e => { | |
| if (e.ctrlKey && e.shiftKey && e.key === 'T') { e.preventDefault(); createTab(); } | |
| if (e.ctrlKey && e.shiftKey && e.key === 'W') { e.preventDefault(); if (activeId) closeTab(activeId); } | |
| }); | |
| // ── 頁面載入時:如果 localStorage 有先前留下的分頁紀錄,就依序用相同 session_id 接回, | |
| // 而不是每次重新整理都開全新的終端機。 | |
| (function initTabs() { | |
| const saved = loadTabs(); | |
| const savedCnt = parseInt(localStorage.getItem('webterm_cnt') || '0', 10); | |
| if (savedCnt) cnt = savedCnt; | |
| if (saved && saved.length) { | |
| saved.forEach(t => createTab(t.sessionId)); | |
| } else { | |
| createTab(); | |
| } | |
| })(); | |
| </script> | |
| </body> | |
| </html>""" | |
| # ──────────────────────────── Routes ────────────────────────────── | |
| async def index(): | |
| return HTMLResponse(HTML_PAGE) | |
| def _safe_path(path: str) -> str | None: | |
| abs_path = os.path.abspath(path) | |
| for base in ALLOWED_BASES: | |
| if abs_path == base or abs_path.startswith(base + os.sep): | |
| return abs_path | |
| return None | |
| async def download_route(path: str): | |
| safe = _safe_path(path) | |
| if safe is None: | |
| return JSONResponse({"error": "path not allowed"}, status_code=403) | |
| if not os.path.isfile(safe): | |
| return JSONResponse({"error": "file not found"}, status_code=404) | |
| return FileResponse(safe, filename=os.path.basename(safe)) | |
| # ──────────────────────────── 背景安裝任務 ──────────────────────────── | |
| async def _run(cmd, cwd=None, env=None): | |
| """非阻塞地执行一个 shell 指令,回传 (returncode, stdout, stderr)。""" | |
| proc = await asyncio.create_subprocess_shell( | |
| cmd, cwd=cwd, env=env, | |
| stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, | |
| ) | |
| stdout, stderr = await proc.communicate() | |
| return proc.returncode, stdout.decode(errors="replace"), stderr.decode(errors="replace") | |
| async def install_opencode(): | |
| """安裝 opencode(執行官方安裝腳本)。""" | |
| log.info("正在背景安装 opencode...") | |
| rc, out, err = await _run("curl -fsSL https://opencode.ai/install | bash") | |
| if rc == 0: | |
| log.info("✅ opencode 安裝成功") | |
| else: | |
| log.warning("⚠️ opencode 安裝失敗或已存在: %s", err[-500:]) | |
| async def install_editors(): | |
| """安裝 vim / neovim(選用)。""" | |
| log.info("正在安裝 vim / neovim ...") | |
| for prefix in ("", "sudo "): | |
| rc, out, err = await _run(f"{prefix}apt-get update -y && {prefix}apt-get install -y vim neovim") | |
| if rc == 0: | |
| log.info("✅ vim / neovim 安裝成功 (prefix=%r)", prefix) | |
| await _run("update-alternatives --set editor /usr/bin/vim.basic || true") | |
| with open(os.path.join(HOME, ".bashrc"), "a") as f: | |
| f.write('\nexport EDITOR=vim\nalias vi="vim"\n') | |
| return | |
| log.error("❌ vim / neovim 安裝失敗(apt 與 sudo apt 皆失敗),請檢查容器權限與套件來源") | |
| async def run_installpi_script(): | |
| """執行 ~/app/installpi.sh(若存在),並自動設定執行權限。""" | |
| installpi_path = os.path.expanduser("~/app/installpi.sh") | |
| if not os.path.isfile(installpi_path): | |
| log.warning("⚠️ installpi.sh 不存在於 ~/app/installpi.sh,跳過執行") | |
| return False | |
| try: | |
| # 設定執行權限 | |
| os.chmod(installpi_path, 0o755) | |
| log.info("✅ 已設定 installpi.sh 執行權限 (755)") | |
| # 執行腳本 | |
| log.info("正在執行 ~/app/installpi.sh ...") | |
| rc, out, err = await _run(installpi_path) | |
| if rc == 0: | |
| log.info("✅ installpi.sh 執行成功") | |
| if out: | |
| log.info("輸出: %s", out[:500]) | |
| return True | |
| else: | |
| log.error("❌ installpi.sh 執行失敗,returncode=%s", rc) | |
| if err: | |
| log.error("錯誤訊息: %s", err[:500]) | |
| return False | |
| except Exception as e: | |
| log.error("❌ 執行 installpi.sh 時發生例外: %s", e) | |
| return False | |
| def dummy_gpu(): | |
| """空函數,僅用於讓平台識別 GPU 裝飾器(若無 GPU 可忽略)。""" | |
| return | |
| async def startup_event(): | |
| dummy_gpu() | |
| # 依序執行:安裝 opencode → 執行 installpi.sh → 安裝編輯器(背景) | |
| await install_opencode() | |
| #await run_installpi_script() | |
| # 安裝編輯器不影響主要流程,放背景執行 | |
| asyncio.create_task(install_editors()) | |
| # ──────────────────────────── Terminal Session 管理 ────────────────────────────── | |
| # 核心概念:pty(bash 進程)的生命週期跟 WebSocket 連線脫鉤。 | |
| # WebSocket 斷線/頁面重新整理時,只是少了一個「訂閱者」,背後的 bash 依然在跑; | |
| # 只有使用者主動關閉分頁(送出 kill 訊息),或閒置太久沒人接回,才會真正砍掉該 session。 | |
| SESSIONS: dict[str, "TerminalSession"] = {} | |
| SESSIONS_LOCK = asyncio.Lock() | |
| class TerminalSession: | |
| def __init__(self, session_id: str, pid: int, fd: int, cols: int = 120, rows: int = 30): | |
| self.id = session_id | |
| self.pid = pid | |
| self.fd = fd | |
| self.cols = cols | |
| self.rows = rows | |
| self.buffer = bytearray() # 畫面回放緩衝區,供重連時還原終端機畫面 | |
| self.subscribers: dict[int, asyncio.Queue] = {} | |
| self._next_sub_id = 0 | |
| self.cleanup_task: asyncio.Task | None = None | |
| def add_subscriber(self): | |
| sid = self._next_sub_id | |
| self._next_sub_id += 1 | |
| q: asyncio.Queue = asyncio.Queue() | |
| self.subscribers[sid] = q | |
| if self.cleanup_task: | |
| self.cleanup_task.cancel() | |
| self.cleanup_task = None | |
| return sid, q | |
| def remove_subscriber(self, sid: int, loop: asyncio.AbstractEventLoop): | |
| self.subscribers.pop(sid, None) | |
| if not self.subscribers: | |
| self.cleanup_task = loop.create_task(self._schedule_cleanup(loop)) | |
| async def _schedule_cleanup(self, loop: asyncio.AbstractEventLoop): | |
| try: | |
| await asyncio.sleep(SESSION_IDLE_TIMEOUT) | |
| except asyncio.CancelledError: | |
| return | |
| if not self.subscribers: | |
| log.info("session %s 閒置超過 %ds 無人接回,清除", self.id, SESSION_IDLE_TIMEOUT) | |
| await kill_session(self.id, loop) | |
| def feed(self, data: bytes): | |
| self.buffer.extend(data) | |
| overflow = len(self.buffer) - SESSION_BUFFER_MAX | |
| if overflow > 0: | |
| del self.buffer[:overflow] | |
| dead = [] | |
| for sid, q in self.subscribers.items(): | |
| try: | |
| q.put_nowait(data) | |
| except Exception: | |
| dead.append(sid) | |
| for sid in dead: | |
| self.subscribers.pop(sid, None) | |
| def _set_winsize(fd: int, cols: int, rows: int): | |
| fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) | |
| def _fork_shell(): | |
| pid, fd = pty.fork() | |
| if pid == 0: | |
| os.environ["TERM"] = "xterm-256color" | |
| os.environ["COLORTERM"] = "truecolor" | |
| os.environ.setdefault("EDITOR", "vim") | |
| opencode_bin = os.path.expanduser("~/.opencode/bin") | |
| current_path = os.environ.get("PATH", "") | |
| if opencode_bin not in current_path: | |
| os.environ["PATH"] = f"{current_path}:{opencode_bin}" | |
| banner = ( | |
| 'echo -e "\\033[36m歡迎使用 Web Terminal|vim/nvim 已安裝|' | |
| 'opencode 與 installpi.sh 已執行(請查看日誌)\\033[0m";' | |
| ) | |
| bash_cmd = ( | |
| f"{banner} " | |
| "export PROMPT_COMMAND='echo -ne \"\\033]0;PWD:$PWD\\007\"'; " | |
| "exec bash --login" | |
| ) | |
| os.execvp("bash", ["bash", "-c", bash_cmd]) | |
| return pid, fd | |
| async def create_session(cols: int = 120, rows: int = 30) -> TerminalSession: | |
| pid, fd = _fork_shell() | |
| session_id = uuid.uuid4().hex[:12] | |
| sess = TerminalSession(session_id, pid, fd, cols, rows) | |
| _set_winsize(fd, cols, rows) | |
| fl = fcntl.fcntl(fd, fcntl.F_GETFL) | |
| fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) | |
| loop = asyncio.get_running_loop() | |
| def _on_read(): | |
| try: | |
| data = os.read(fd, 65536) | |
| except (BlockingIOError, OSError): | |
| return | |
| if not data: | |
| # bash 行程已結束(例如使用者在終端機內打了 exit) | |
| try: | |
| loop.remove_reader(fd) | |
| except Exception: | |
| pass | |
| asyncio.create_task(kill_session(session_id, loop, already_dead=True)) | |
| return | |
| sess.feed(data) | |
| loop.add_reader(fd, _on_read) | |
| async with SESSIONS_LOCK: | |
| SESSIONS[session_id] = sess | |
| return sess | |
| async def _reap_process(pid: int, loop: asyncio.AbstractEventLoop): | |
| """非阻塞地終止並回收子行程。""" | |
| for sig in (signal.SIGTERM, signal.SIGKILL): | |
| try: | |
| os.kill(pid, sig) | |
| except ProcessLookupError: | |
| return | |
| for _ in range(20): | |
| await asyncio.sleep(0.1) | |
| try: | |
| wpid, _status = os.waitpid(pid, os.WNOHANG) | |
| if wpid == pid: | |
| return | |
| except ChildProcessError: | |
| return | |
| try: | |
| await loop.run_in_executor(None, os.waitpid, pid, 0) | |
| except Exception: | |
| pass | |
| async def kill_session(session_id: str, loop: asyncio.AbstractEventLoop, already_dead: bool = False): | |
| async with SESSIONS_LOCK: | |
| sess = SESSIONS.pop(session_id, None) | |
| if not sess: | |
| return | |
| try: | |
| loop.remove_reader(sess.fd) | |
| except Exception: | |
| pass | |
| if not already_dead: | |
| await _reap_process(sess.pid, loop) | |
| try: | |
| os.close(sess.fd) | |
| except Exception: | |
| pass | |
| for q in list(sess.subscribers.values()): | |
| try: | |
| q.put_nowait(None) | |
| except Exception: | |
| pass | |
| log.info("session %s 已清除", session_id) | |
| # ──────────────────────────── Terminal WebSocket ────────────────────────────── | |
| async def terminal_ws(websocket: WebSocket): | |
| await websocket.accept() | |
| loop = asyncio.get_running_loop() | |
| requested_id = websocket.query_params.get("session_id") | |
| try: | |
| cols = int(websocket.query_params.get("cols", 120)) | |
| rows = int(websocket.query_params.get("rows", 30)) | |
| except ValueError: | |
| cols, rows = 120, 30 | |
| sess = SESSIONS.get(requested_id) if requested_id else None | |
| reattached = sess is not None | |
| if sess is None: | |
| try: | |
| sess = await create_session(cols, rows) | |
| except OSError as e: | |
| await websocket.send_text(json.dumps({ | |
| "type": "system", | |
| "data": f"\r\n\x1b[31m[❌ 無法建立新終端機 (pty.fork 失敗: {e}),請稍後再試或關閉其他分頁]\x1b[0m\r\n", | |
| })) | |
| await websocket.close() | |
| return | |
| # 告知前端這次連線對應的 session id,讓前端存起來,日後斷線重連 / 重新整理都能接回同一個 | |
| await websocket.send_text(json.dumps({"type": "session", "id": sess.id, "reattached": reattached})) | |
| if reattached: | |
| if sess.buffer: | |
| try: | |
| await websocket.send_text(json.dumps({ | |
| "type": "output", | |
| "data": bytes(sess.buffer).decode("utf-8", errors="replace"), | |
| })) | |
| except Exception: | |
| pass | |
| try: | |
| await websocket.send_text(json.dumps({ | |
| "type": "system", | |
| "data": "\r\n\x1b[32m[✅ 已重新連線,畫面已還原]\x1b[0m\r\n", | |
| })) | |
| except Exception: | |
| pass | |
| elif requested_id: | |
| # 前端要求接回某個 session,但伺服器已找不到(逾時被清除或伺服器重啟過) | |
| try: | |
| await websocket.send_text(json.dumps({ | |
| "type": "system", | |
| "data": "\r\n\x1b[33m[⚠️ 找不到先前的工作階段(可能已逾時或伺服器重新啟動),已建立新的終端機]\x1b[0m\r\n", | |
| })) | |
| except Exception: | |
| pass | |
| sub_id, queue = sess.add_subscriber() | |
| async def _pty_to_ws(): | |
| while True: | |
| chunk = await queue.get() | |
| if chunk is None: | |
| break | |
| try: | |
| await websocket.send_text( | |
| json.dumps({"type": "output", "data": chunk.decode("utf-8", errors="replace")}) | |
| ) | |
| except Exception: | |
| break | |
| sender = asyncio.create_task(_pty_to_ws()) | |
| try: | |
| while True: | |
| raw = await websocket.receive_text() | |
| try: | |
| msg = json.loads(raw) | |
| except json.JSONDecodeError: | |
| continue | |
| mtype = msg.get("type") | |
| if mtype == "input": | |
| try: | |
| os.write(sess.fd, msg["data"].encode("utf-8")) | |
| except OSError: | |
| break | |
| elif mtype == "resize": | |
| try: | |
| sess.cols, sess.rows = msg["cols"], msg["rows"] | |
| _set_winsize(sess.fd, msg["cols"], msg["rows"]) | |
| except Exception: | |
| pass | |
| elif mtype == "upload": | |
| filename = os.path.basename(msg.get("filename", "uploaded_file")) | |
| target_dir = msg.get("targetDir", "/home/user") | |
| safe_dir = _safe_path(target_dir) or "/home/user" | |
| filepath = os.path.join(safe_dir, filename) | |
| try: | |
| payload = base64.b64decode(msg["data"]) | |
| def _write(path=filepath, data=payload): | |
| with open(path, "wb") as f: | |
| f.write(data) | |
| await asyncio.to_thread(_write) | |
| await websocket.send_text(json.dumps({ | |
| "type": "system", | |
| "data": f"\r\n\x1b[32m[✅ 檔案已成功上傳至: {filepath}]\x1b[0m\r\n", | |
| })) | |
| except Exception as e: | |
| await websocket.send_text(json.dumps({ | |
| "type": "system", | |
| "data": f"\r\n\x1b[31m[❌ 上傳失敗: {e}]\x1b[0m\r\n", | |
| })) | |
| elif mtype == "kill": | |
| # 使用者主動關閉這個分頁:真正終止此 session(不再保留供重連) | |
| await kill_session(sess.id, loop) | |
| break | |
| except (WebSocketDisconnect, RuntimeError): | |
| pass | |
| except Exception: | |
| log.exception("terminal_ws 循環發生未預期錯誤") | |
| finally: | |
| sender.cancel() | |
| # 注意:這裡「不」殺掉 pty/bash,只是移除這個訂閱者, | |
| # 讓 session 保留在 SESSIONS 裡,等待同一個分頁重連接回。 | |
| sess.remove_subscriber(sub_id, loop) | |
| if __name__ == "__main__": | |
| port = int(os.environ.get("PORT", 7860)) | |
| print(f"🚀 Terminal running on 0.0.0.0:{port}") | |
| uvicorn.run(app, host="0.0.0.0", port=port, log_level="info") |