]> acesimba.cloud Git - codebuddy-web.git/commitdiff
Initial commit: Codebuddy Web Console
authorCodebuddy <codebuddy@localhost>
Thu, 18 Jun 2026 03:56:19 +0000 (11:56 +0800)
committerCodebuddy <codebuddy@localhost>
Thu, 18 Jun 2026 03:56:19 +0000 (11:56 +0800)
前后端分离结构:
- backend/app.py: FastAPI 后端 (PTY 终端 + 任务注册表 + JWT 鉴权)
- frontend/index.html: xterm.js 前端页面
- data/: 任务注册表运行时状态 (tasks.json 已 gitignore)
- logs/: 运行时日志 (已 gitignore)

原单文件 /root/codebuddy-web.py 拆分为前后端分离,
systemd 服务指向 /home/Codebuddy-web/backend/app.py

.gitignore [new file with mode: 0644]
backend/app.py [new file with mode: 0644]
data/.gitkeep [new file with mode: 0644]
frontend/index.html [new file with mode: 0644]
logs/.gitkeep [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..d634206
--- /dev/null
@@ -0,0 +1,13 @@
+# Python
+__pycache__/
+*.pyc
+*.pyo
+
+# Runtime logs
+logs/*.log
+
+# Runtime state: task registry (session IDs, last-used timestamps)
+data/tasks.json
+
+# Backups (git history serves this purpose)
+*.bak
diff --git a/backend/app.py b/backend/app.py
new file mode 100644 (file)
index 0000000..73e7d15
--- /dev/null
@@ -0,0 +1,456 @@
+#!/usr/bin/env python3
+"""
+Codebuddy Web Console - 通过浏览器远程使用 Codebuddy CLI
+支持多任务并行 + 历史会话隔离。
+
+启动: python3 codebuddy-web.py
+访问: http://localhost:5555
+"""
+
+import os
+import sys
+import json
+import glob
+import uuid
+import asyncio
+import signal
+import secrets
+import hashlib
+import datetime
+import logging
+import subprocess
+from pathlib import Path
+
+import uvicorn
+from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request
+from fastapi.responses import HTMLResponse, JSONResponse
+
+# JWT
+from jose import jwt, JWTError
+
+# PTY
+import pty
+import fcntl
+import termios
+import struct
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
+logger = logging.getLogger("codebuddy-web")
+
+# ==================== 配置 ====================
+PORT = int(os.environ.get("CODEBUDDY_WEB_PORT", "5555"))
+HOST = os.environ.get("CODEBUDDY_WEB_HOST", "0.0.0.0")
+PASSWORD = os.environ.get("CODEBUDDY_WEB_PASSWORD", "")
+WORK_DIR = os.environ.get("CODEBUDDY_WEB_WORKDIR", str(Path.home()))
+JWT_SECRET = os.environ.get("CODEBUDDY_WEB_JWT_SECRET", secrets.token_hex(32))
+JWT_ALGORITHM = "HS256"
+JWT_EXPIRE_HOURS = 24
+
+# 任务注册表
+TASKS_FILE = os.environ.get("CODEBUDDY_WEB_TASKS", str(Path.home() / ".codebuddy-web-tasks.json"))
+# 任务默认工作目录的根
+TASK_HOME_ROOT = os.environ.get("CODEBUDDY_WEB_TASK_ROOT", "/home")
+# codebuddy 会话文件存放目录
+CODEBUDDY_PROJECTS_DIR = Path.home() / ".codebuddy" / "projects"
+
+# 活动任务锁:同一任务同时只允许一个会话,防止两窗口同时 resume 损坏 jsonl
+ACTIVE_TASKS = set()
+
+if not PASSWORD:
+    PASSWORD = secrets.token_urlsafe(12)
+    logger.info(f"🔑 自动生成密码: {PASSWORD}")
+
+# ==================== FastAPI ====================
+app = FastAPI(title="Codebuddy Web Console")
+
+
+def create_token() -> str:
+    expire = datetime.datetime.utcnow() + datetime.timedelta(hours=JWT_EXPIRE_HOURS)
+    return jwt.encode({"exp": expire, "rand": secrets.token_hex(8)}, JWT_SECRET, algorithm=JWT_ALGORITHM)
+
+
+def verify_token(token: str) -> bool:
+    try:
+        jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
+        return True
+    except JWTError:
+        return False
+
+
+def hash_password(pw: str) -> str:
+    return hashlib.sha256(pw.encode()).hexdigest()
+
+
+PASSWORD_HASH = hash_password(PASSWORD)
+
+
+def _auth_ok(request: Request) -> bool:
+    token = request.headers.get("Authorization", "").replace("Bearer ", "")
+    return verify_token(token)
+
+
+# ==================== 任务注册表 ====================
+def gen_id() -> str:
+    return str(uuid.uuid4())
+
+
+def now_iso() -> str:
+    return datetime.datetime.now().isoformat(timespec="seconds")
+
+
+def load_tasks() -> dict:
+    if not os.path.isfile(TASKS_FILE):
+        data = seed_tasks()
+        save_tasks(data)
+        return data
+    try:
+        with open(TASKS_FILE, "r", encoding="utf-8") as f:
+            return json.load(f)
+    except (json.JSONDecodeError, OSError):
+        data = seed_tasks()
+        save_tasks(data)
+        return data
+
+
+def save_tasks(data: dict) -> None:
+    tmp = TASKS_FILE + ".tmp"
+    with open(tmp, "w", encoding="utf-8") as f:
+        json.dump(data, f, ensure_ascii=False, indent=2)
+    os.replace(tmp, TASKS_FILE)
+
+
+def seed_tasks() -> dict:
+    """首次运行:预置 words 任务。"""
+    now = now_iso()
+    return {
+        "tasks": [
+            {
+                "id": gen_id(),
+                "name": "words",
+                "cwd": "/home/words",
+                "session_id": gen_id(),
+                "created_at": now,
+                "last_used": now,
+            }
+        ]
+    }
+
+
+def find_task(data: dict, task_id: str) -> dict | None:
+    for t in data.get("tasks", []):
+        if t["id"] == task_id:
+            return t
+    return None
+
+
+def session_exists(session_id: str) -> bool:
+    """session-id 全局唯一,跨所有 project 目录查找。"""
+    pattern = str(CODEBUDDY_PROJECTS_DIR / "*" / f"{session_id}.jsonl")
+    return bool(glob.glob(pattern))
+
+
+def touch_task(task_id: str) -> None:
+    data = load_tasks()
+    t = find_task(data, task_id)
+    if t:
+        t["last_used"] = now_iso()
+        save_tasks(data)
+
+
+# ==================== 前端 HTML ====================
+# 前后端分离:前端页面单独放在 frontend/index.html,便于维护
+FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
+with open(FRONTEND_DIR / "index.html", "r", encoding="utf-8") as _f:
+    HTML_PAGE = _f.read()
+# ==================== 后端路由 ====================
+@app.get("/")
+async def index():
+    return HTMLResponse(HTML_PAGE)
+
+
+@app.post("/api/login")
+async def api_login(request: Request):
+    body = await request.json()
+    pw = body.get("password", "")
+    if hash_password(pw) == PASSWORD_HASH:
+        token = create_token()
+        return JSONResponse({"ok": True, "token": token})
+    return JSONResponse({"ok": False, "error": "密码错误"}, status_code=401)
+
+
+@app.get("/api/check")
+async def api_check(request: Request):
+    if _auth_ok(request):
+        return JSONResponse({"ok": True})
+    return JSONResponse({"ok": False}, status_code=401)
+
+
+@app.get("/api/tasks")
+async def api_tasks(request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"ok": False, "error": "未授权"}, status_code=401)
+    data = load_tasks()
+    # 标记活动状态
+    tasks = []
+    for t in data.get("tasks", []):
+        tt = dict(t)
+        tt["active"] = t["id"] in ACTIVE_TASKS
+        tasks.append(tt)
+    return JSONResponse({"ok": True, "tasks": tasks})
+
+
+@app.post("/api/tasks")
+async def api_create_task(request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"ok": False, "error": "未授权"}, status_code=401)
+    body = await request.json()
+    name = (body.get("name") or "").strip()
+    if not name:
+        return JSONResponse({"ok": False, "error": "名称不能为空"}, status_code=400)
+    if "/" in name or "\\" in name or name in (".", ".."):
+        return JSONResponse({"ok": False, "error": "名称含有非法字符"}, status_code=400)
+
+    data = load_tasks()
+    if any(t["name"] == name for t in data["tasks"]):
+        return JSONResponse({"ok": False, "error": "任务名已存在"}, status_code=409)
+
+    cwd = os.path.join(TASK_HOME_ROOT, name)
+    try:
+        os.makedirs(cwd, exist_ok=True)
+    except OSError as e:
+        return JSONResponse({"ok": False, "error": f"无法创建目录 {cwd}: {e}"}, status_code=400)
+
+    now = now_iso()
+    task = {
+        "id": gen_id(),
+        "name": name,
+        "cwd": cwd,
+        "session_id": gen_id(),
+        "created_at": now,
+        "last_used": now,
+    }
+    data["tasks"].append(task)
+    save_tasks(data)
+    logger.info(f"新建任务: name={name}, cwd={cwd}")
+    return JSONResponse({"ok": True, "task": task})
+
+
+@app.delete("/api/tasks/{task_id}")
+async def api_delete_task(task_id: str, request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"ok": False, "error": "未授权"}, status_code=401)
+    if task_id in ACTIVE_TASKS:
+        return JSONResponse({"ok": False, "error": "任务正在运行,请先关闭其终端窗口"}, status_code=409)
+    data = load_tasks()
+    before = len(data["tasks"])
+    data["tasks"] = [t for t in data["tasks"] if t["id"] != task_id]
+    if len(data["tasks"]) == before:
+        return JSONResponse({"ok": False, "error": "任务不存在"}, status_code=404)
+    save_tasks(data)
+    return JSONResponse({"ok": True})
+
+
+@app.post("/api/kill/{pid}")
+async def api_kill(pid: int, request: Request):
+    if not _auth_ok(request):
+        return JSONResponse({"ok": False}, status_code=401)
+    try:
+        os.kill(pid, signal.SIGTERM)
+    except ProcessLookupError:
+        pass
+    return JSONResponse({"ok": True})
+
+
+# ==================== WebSocket 终端 ====================
+@app.websocket("/ws")
+async def websocket_terminal(websocket: WebSocket):
+    token = websocket.query_params.get("token", "")
+    task_id = websocket.query_params.get("task", "")
+    if not verify_token(token):
+        await websocket.close(code=4001, reason="认证失败")
+        return
+    if not task_id:
+        await websocket.accept()
+        await websocket.send_json({"type": "error", "data": "缺少任务参数"})
+        await websocket.close()
+        return
+
+    data = load_tasks()
+    task = find_task(data, task_id)
+    if not task:
+        await websocket.accept()
+        await websocket.send_json({"type": "error", "data": "任务不存在"})
+        await websocket.close()
+        return
+
+    if task_id in ACTIVE_TASKS:
+        await websocket.accept()
+        await websocket.send_json({"type": "error", "data": "该任务已在另一窗口运行,请先关闭该窗口"})
+        await websocket.close()
+        return
+
+    # 找到 codebuddy 可执行文件
+    codebuddy_path = None
+    for candidate in [
+        os.path.expanduser("~/.local/bin/codebuddy"),
+        "/root/.local/bin/codebuddy",
+        "/usr/local/bin/codebuddy",
+    ]:
+        if os.path.isfile(candidate) and os.access(candidate, os.X_OK):
+            codebuddy_path = candidate
+            break
+    if not codebuddy_path:
+        import shutil
+        codebuddy_path = shutil.which("codebuddy")
+    if not codebuddy_path:
+        await websocket.accept()
+        await websocket.send_json({"type": "output", "data": "\r\n\x1b[31m✗ 找不到 codebuddy 命令,请确认已正确安装\x1b[0m\r\n"})
+        await websocket.close()
+        return
+
+    # 决定启动参数:已存在 session 则 resume,否则用固定 session-id 新建
+    sid = task["session_id"]
+    if session_exists(sid):
+        cmd = [codebuddy_path, "--resume", sid]
+        mode = "resume"
+    else:
+        cmd = [codebuddy_path, "--session-id", sid]
+        mode = "new"
+
+    cwd = task["cwd"]
+    try:
+        os.makedirs(cwd, exist_ok=True)
+    except OSError:
+        pass  # 启动时若目录不可用,交给 codebuddy 报错
+
+    ACTIVE_TASKS.add(task_id)
+    await websocket.accept()
+    logger.info(f"任务启动: name={task['name']}, cwd={cwd}, mode={mode}, sid={sid}")
+
+    # 创建 PTY
+    master_fd, slave_fd = pty.openpty()
+    winsize = struct.pack("HHHH", 24, 80, 0, 0)
+    fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, winsize)
+
+    env = os.environ.copy()
+    env["TERM"] = "xterm-256color"
+    env["COLORTERM"] = "truecolor"
+    env["COLUMNS"] = "80"
+    env["LINES"] = "24"
+
+    try:
+        proc = subprocess.Popen(
+            cmd,
+            stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
+            cwd=cwd, env=env, preexec_fn=os.setsid,
+        )
+    except Exception as e:
+        os.close(slave_fd)
+        await websocket.send_json({"type": "output", "data": f"\r\n\x1b[31m✗ 启动 codebuddy 失败: {e}\x1b[0m\r\n"})
+        await websocket.close()
+        ACTIVE_TASKS.discard(task_id)
+        return
+    finally:
+        try:
+            os.close(slave_fd)
+        except OSError:
+            pass
+
+    os.set_blocking(master_fd, False)
+    await websocket.send_json({"type": "pid", "pid": proc.pid})
+
+    # 更新 last_used
+    touch_task(task_id)
+
+    async def read_output():
+        loop = asyncio.get_event_loop()
+        while proc.poll() is None:
+            try:
+                data = await loop.run_in_executor(None, lambda: os.read(master_fd, 65536))
+                if data:
+                    try:
+                        text = data.decode("utf-8", errors="replace")
+                    except Exception:
+                        text = data.decode("latin-1", errors="replace")
+                    await websocket.send_json({"type": "output", "data": text})
+                else:
+                    await asyncio.sleep(0.01)
+            except BlockingIOError:
+                await asyncio.sleep(0.05)
+            except OSError:
+                break
+            except Exception:
+                await asyncio.sleep(0.05)
+
+        code = proc.poll()
+        try:
+            while True:
+                try:
+                    d = os.read(master_fd, 65536)
+                    if not d:
+                        break
+                    await websocket.send_json({"type": "output", "data": d.decode("utf-8", errors="replace")})
+                except OSError:
+                    break
+        except Exception:
+            pass
+        await websocket.send_json({"type": "exit", "code": code})
+        logger.info(f"任务结束: name={task['name']}, pid={proc.pid}, code={code}")
+
+    async def write_input():
+        try:
+            while True:
+                msg = await websocket.receive_json()
+                if msg.get("type") == "input":
+                    os.write(master_fd, msg.get("data", "").encode("utf-8"))
+                elif msg.get("type") == "resize":
+                    cols = msg.get("cols", 80)
+                    rows = msg.get("rows", 24)
+                    winsize = struct.pack("HHHH", rows, cols, 0, 0)
+                    try:
+                        fcntl.ioctl(master_fd, termios.TIOCSWINSZ, winsize)
+                    except OSError:
+                        pass
+        except Exception:
+            pass
+
+    read_task = asyncio.create_task(read_output())
+    write_task = asyncio.create_task(write_input())
+    try:
+        await asyncio.wait([read_task, write_task], return_when=asyncio.FIRST_COMPLETED)
+    except Exception:
+        pass
+    finally:
+        for t in [read_task, write_task]:
+            if not t.done():
+                t.cancel()
+        try:
+            os.close(master_fd)
+        except Exception:
+            pass
+        try:
+            if proc.poll() is None:
+                os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
+                try:
+                    proc.wait(timeout=3)
+                except Exception:
+                    os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
+        except Exception:
+            pass
+        try:
+            await websocket.close()
+        except Exception:
+            pass
+        ACTIVE_TASKS.discard(task_id)
+        logger.info(f"任务清理完成: name={task['name']}, pid={proc.pid}")
+
+
+# ==================== 启动 ====================
+if __name__ == "__main__":
+    # 初始化任务注册表(首次自动 seed words)
+    _init = load_tasks()
+    logger.info(f"🚀 Codebuddy Web Console 启动中...")
+    logger.info(f"   地址: http://{HOST}:{PORT}")
+    logger.info(f"   密码: {PASSWORD}")
+    logger.info(f"   任务数: {len(_init.get('tasks', []))}  注册表: {TASKS_FILE}")
+    uvicorn.run(app, host=HOST, port=PORT, log_level="info")
diff --git a/data/.gitkeep b/data/.gitkeep
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644 (file)
index 0000000..7a20c92
--- /dev/null
@@ -0,0 +1,415 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>Codebuddy Web Console</title>
+<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
+<style>
+* { margin: 0; padding: 0; box-sizing: border-box; }
+html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
+
+/* 登录页 */
+#login-page {
+  display: flex; justify-content: center; align-items: center; height: 100vh;
+  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
+}
+.login-card {
+  background: rgba(255,255,255,0.05); backdrop-filter: blur(20px);
+  border: 1px solid rgba(255,255,255,0.1); border-radius: 16px;
+  padding: 48px 40px; width: 380px; text-align: center;
+  box-shadow: 0 25px 50px rgba(0,0,0,0.4);
+}
+.login-card h1 { font-size: 24px; margin-bottom: 8px; color: #fff; }
+.login-card .subtitle { font-size: 14px; color: #888; margin-bottom: 32px; }
+.login-card .logo { font-size: 48px; margin-bottom: 16px; }
+.login-input {
+  width: 100%; padding: 14px 18px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.15);
+  background: rgba(255,255,255,0.08); color: #fff; font-size: 15px; outline: none;
+  transition: border-color 0.2s;
+}
+.login-input:focus { border-color: #4ecca3; }
+.login-input::placeholder { color: #666; }
+.login-btn {
+  width: 100%; padding: 14px; border: none; border-radius: 10px; margin-top: 20px;
+  background: linear-gradient(135deg, #4ecca3, #38b2ac); color: #1a1a2e;
+  font-size: 16px; font-weight: 600; cursor: pointer; transition: transform 0.1s, opacity 0.2s;
+}
+.login-btn:hover { opacity: 0.9; }
+.login-btn:active { transform: scale(0.98); }
+.login-error { color: #ff6b6b; font-size: 14px; margin-top: 12px; min-height: 20px; }
+
+/* 顶栏 */
+.top-bar {
+  display: flex; justify-content: space-between; align-items: center;
+  padding: 8px 16px; background: rgba(0,0,0,0.3); border-bottom: 1px solid rgba(255,255,255,0.1);
+  font-size: 15px; flex-shrink: 0; gap: 12px;
+}
+.top-bar .left { display: flex; align-items: center; gap: 10px; min-width: 0; }
+.top-bar .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
+.top-bar .dot.connected { background: #4ecca3; box-shadow: 0 0 6px #4ecca3; }
+.top-bar .dot.disconnected { background: #ff6b6b; box-shadow: 0 0 6px #ff6b6b; }
+.top-bar .task-name { color: #4ecca3; font-weight: 600; }
+.top-bar .task-cwd { color: #888; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.top-bar .actions { display: flex; gap: 8px; flex-shrink: 0; }
+.top-bar .action-btn {
+  padding: 4px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.15);
+  background: rgba(255,255,255,0.05); color: #ccc; font-size: 14px; cursor: pointer;
+  transition: all 0.15s;
+}
+.top-bar .action-btn:hover { background: rgba(255,255,255,0.12); color: #fff; }
+
+#terminal-container { flex: 1; overflow: hidden; padding: 4px; }
+
+/* 任务列表页 */
+#picker-page { display: none; flex-direction: column; height: 100vh; }
+.picker-header {
+  display: flex; justify-content: space-between; align-items: center;
+  padding: 20px 28px; border-bottom: 1px solid rgba(255,255,255,0.08);
+}
+.picker-header h2 { font-size: 22px; color: #fff; }
+.picker-header h2 small { color: #888; font-size: 14px; font-weight: normal; margin-left: 10px; }
+.picker-body { flex: 1; overflow: auto; padding: 28px; }
+.task-grid {
+  display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 18px;
+  max-width: 1200px; margin: 0 auto;
+}
+.task-card {
+  background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1);
+  border-radius: 14px; padding: 22px; transition: all 0.2s; position: relative;
+  display: flex; flex-direction: column; gap: 6px;
+}
+.task-card:hover { border-color: #4ecca3; background: rgba(78,204,163,0.06); transform: translateY(-2px); }
+.task-card .tc-name { font-size: 18px; font-weight: 600; color: #4ecca3; }
+.task-card .tc-cwd { font-size: 13px; color: #999; word-break: break-all; }
+.task-card .tc-time { font-size: 12px; color: #666; }
+.task-card .tc-actions { display: flex; gap: 8px; margin-top: 12px; }
+.task-card .tc-btn {
+  flex: 1; padding: 9px; border-radius: 8px; border: none; cursor: pointer; font-size: 14px; font-weight: 500;
+}
+.task-card .tc-enter { background: linear-gradient(135deg, #4ecca3, #38b2ac); color: #1a1a2e; }
+.task-card .tc-enter:hover { opacity: 0.9; }
+.task-card .tc-del { background: rgba(255,107,107,0.12); color: #ff8787; border: 1px solid rgba(255,107,107,0.25); flex: 0 0 56px; }
+.task-card .tc-del:hover { background: rgba(255,107,107,0.22); }
+.task-card.tc-active { border-color: #ffe66d; }
+.task-card.tc-active .tc-status { color: #ffe66d; font-size: 12px; }
+.task-new {
+  display: flex; align-items: center; justify-content: center; gap: 8px;
+  background: rgba(78,204,163,0.06); border: 2px dashed rgba(78,204,163,0.3);
+  border-radius: 14px; padding: 22px; cursor: pointer; color: #4ecca3; font-size: 16px;
+  min-height: 130px; transition: all 0.2s;
+}
+.task-new:hover { border-color: #4ecca3; background: rgba(78,204,163,0.12); }
+.picker-empty { color: #666; text-align: center; padding: 60px; font-size: 15px; }
+</style>
+</head>
+<body>
+
+<!-- 登录页 -->
+<div id="login-page">
+  <div class="login-card">
+    <div class="logo">🤖</div>
+    <h1>Codebuddy Console</h1>
+    <p class="subtitle">AI 编程助手 · 多任务远程控制台</p>
+    <input type="password" id="password-input" class="login-input" placeholder="输入访问密码" autofocus>
+    <button id="login-btn" class="login-btn">进入控制台</button>
+    <div id="login-error" class="login-error"></div>
+  </div>
+</div>
+
+<!-- 任务列表页 -->
+<div id="picker-page">
+  <div class="picker-header">
+    <h2>任务清单 <small id="picker-subtitle"></small></h2>
+    <button class="action-btn" id="btn-logout-picker">退出登录</button>
+  </div>
+  <div class="picker-body">
+    <div class="task-grid" id="task-grid"></div>
+  </div>
+</div>
+
+<!-- 终端页 -->
+<div id="terminal-page" style="display:none; height:100vh; flex-direction:column;">
+  <div class="top-bar">
+    <div class="left">
+      <div id="status-dot" class="dot disconnected"></div>
+      <span id="status-text">未连接</span>
+      <span class="task-name" id="task-title"></span>
+      <span class="task-cwd" id="task-cwd"></span>
+    </div>
+    <div class="actions">
+      <button class="action-btn" id="btn-restart" title="重启会话(续上次历史)">↻ 重启会话</button>
+      <button class="action-btn" id="btn-list" title="打开任务列表">≡ 列表</button>
+      <button class="action-btn" id="btn-close" title="关闭窗口">✕ 关闭</button>
+    </div>
+  </div>
+  <div id="terminal-container"></div>
+</div>
+
+<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
+<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
+<script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
+<script>
+(function() {
+  const API = '';
+  const PARAMS = new URLSearchParams(location.search);
+  const TASK_ID = PARAMS.get('task');
+  const MODE = TASK_ID ? 'terminal' : 'picker';
+
+  let authToken = localStorage.getItem('cb_token') || '';
+  let ws = null;
+  let term = null;
+  let fitAddon = null;
+  let pid = 0;
+  let currentTask = null;
+
+  const loginPage = document.getElementById('login-page');
+  const pickerPage = document.getElementById('picker-page');
+  const terminalPage = document.getElementById('terminal-page');
+  const passwordInput = document.getElementById('password-input');
+  const loginBtn = document.getElementById('login-btn');
+  const loginError = document.getElementById('login-error');
+  const statusDot = document.getElementById('status-dot');
+  const statusText = document.getElementById('status-text');
+  const termContainer = document.getElementById('terminal-container');
+
+  function show(page) {
+    loginPage.style.display = 'none';
+    pickerPage.style.display = 'none';
+    terminalPage.style.display = 'none';
+    if (page === 'login') { loginPage.style.display = 'flex'; passwordInput.focus(); }
+    else if (page === 'picker') pickerPage.style.display = 'flex';
+    else if (page === 'terminal') terminalPage.style.display = 'flex';
+  }
+
+  function authHeaders() { return { 'Authorization': 'Bearer ' + authToken }; }
+
+  // ========== 认证 ==========
+  async function login(password) {
+    try {
+      const res = await fetch(API + '/api/login', {
+        method: 'POST', headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ password })
+      });
+      const data = await res.json();
+      if (data.ok) {
+        authToken = data.token;
+        localStorage.setItem('cb_token', authToken);
+        enterApp();
+      } else {
+        loginError.textContent = data.error || '密码错误';
+      }
+    } catch(e) { loginError.textContent = '连接失败: ' + e.message; }
+  }
+
+  async function checkAuth() {
+    if (!authToken) return false;
+    try {
+      const res = await fetch(API + '/api/check', { headers: authHeaders() });
+      return res.ok;
+    } catch { return false; }
+  }
+
+  function logout() {
+    authToken = '';
+    localStorage.removeItem('cb_token');
+    if (ws) ws.close();
+    if (pid) fetch(API + '/api/kill/' + pid, { headers: authHeaders() }).catch(()=>{});
+    pid = 0;
+    show('login');
+    passwordInput.value = '';
+  }
+
+  loginBtn.onclick = () => login(passwordInput.value);
+  passwordInput.addEventListener('keydown', e => {
+    if (e.key === 'Enter') login(passwordInput.value);
+    loginError.textContent = '';
+  });
+  document.getElementById('btn-logout-picker').onclick = logout;
+
+  async function enterApp() {
+    if (MODE === 'terminal') await showTerminalMode();
+    else await showPicker();
+  }
+
+  // ========== 任务列表 ==========
+  async function fetchTasks() {
+    const res = await fetch(API + '/api/tasks', { headers: authHeaders() });
+    if (!res.ok) { show('login'); return null; }
+    const data = await res.json();
+    return data.tasks || [];
+  }
+
+  async function showPicker() {
+    show('picker');
+    await renderTasks();
+  }
+
+  async function renderTasks() {
+    const tasks = await fetchTasks();
+    if (!tasks) return;
+    const grid = document.getElementById('task-grid');
+    document.getElementById('picker-subtitle').textContent = '共 ' + tasks.length + ' 个任务';
+    grid.innerHTML = '';
+
+    if (tasks.length === 0) {
+      grid.innerHTML = '<div class="picker-empty">还没有任务,点击下方卡片新建</div>';
+    }
+
+    tasks.forEach(t => {
+      const card = document.createElement('div');
+      card.className = 'task-card';
+      const last = t.last_used ? '上次使用 ' + t.last_used.replace('T', ' ') : '';
+      card.innerHTML =
+        '<div class="tc-name">' + escapeHtml(t.name) + '</div>' +
+        '<div class="tc-cwd">' + escapeHtml(t.cwd) + '</div>' +
+        '<div class="tc-time">' + last + '</div>' +
+        '<div class="tc-actions">' +
+          '<button class="tc-btn tc-enter">进入</button>' +
+          '<button class="tc-btn tc-del" title="删除任务">✕</button>' +
+        '</div>';
+      card.querySelector('.tc-enter').onclick = () => openTask(t.id);
+      card.querySelector('.tc-del').onclick = () => deleteTask(t.id, t.name);
+      grid.appendChild(card);
+    });
+
+    // 新建卡片
+    const newCard = document.createElement('div');
+    newCard.className = 'task-new';
+    newCard.innerHTML = '+ 新建任务';
+    newCard.onclick = createTask;
+    grid.appendChild(newCard);
+  }
+
+  function escapeHtml(s) {
+    return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+  }
+
+  async function createTask() {
+    const name = prompt('输入新任务名称(将作为 /home/<名称> 工作目录):');
+    if (!name || !name.trim()) return;
+    const res = await fetch(API + '/api/tasks', {
+      method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
+      body: JSON.stringify({ name: name.trim() })
+    });
+    if (res.ok) { await renderTasks(); }
+    else { const d = await res.json().catch(()=>({})); alert(d.error || '创建失败'); }
+  }
+
+  async function deleteTask(id, name) {
+    if (!confirm('删除任务 "' + name + '"?\\n(仅删除任务记录,不删除工作目录与历史会话)')) return;
+    await fetch(API + '/api/tasks/' + id, { method: 'DELETE', headers: authHeaders() });
+    await renderTasks();
+  }
+
+  function openTask(id) {
+    // 新窗口打开终端;窗口名固定以便复用
+    window.open('/?task=' + encodeURIComponent(id), 'cb_task_' + id);
+  }
+
+  // ========== 终端 ==========
+  async function showTerminalMode() {
+    const tasks = await fetchTasks();
+    if (!tasks) return;
+    currentTask = tasks.find(t => t.id === TASK_ID);
+    if (!currentTask) {
+      alert('任务不存在或已被删除,将返回任务列表');
+      window.location.href = '/';
+      return;
+    }
+    document.getElementById('task-title').textContent = '🗂 ' + currentTask.name;
+    document.getElementById('task-cwd').textContent = currentTask.cwd;
+    show('terminal');
+    initTerminal();
+    connectWS();
+  }
+
+  function initTerminal() {
+    if (term) return;
+    term = new Terminal({
+      cursorBlink: true, fontSize: 20,
+      fontFamily: "'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, Monaco, 'Courier New', monospace",
+      theme: {
+        background: '#1a1a2e', foreground: '#e0e0e0', cursor: '#4ecca3', cursorAccent: '#1a1a2e',
+        selectionBackground: 'rgba(78,204,163,0.3)',
+        black: '#1a1a2e', red: '#ff6b6b', green: '#4ecca3', yellow: '#ffe66d',
+        blue: '#6c9bff', magenta: '#c792ea', cyan: '#89ddff', white: '#e0e0e0',
+        brightBlack: '#666', brightRed: '#ff8787', brightGreen: '#6fd9b4',
+        brightYellow: '#fff082', brightBlue: '#8cb4ff', brightMagenta: '#d4a8f0',
+        brightCyan: '#a6e8ff', brightWhite: '#fff'
+      },
+      allowProposedApi: true
+    });
+    fitAddon = new FitAddon.FitAddon();
+    term.loadAddon(fitAddon);
+    term.loadAddon(new WebLinksAddon.WebLinksAddon());
+    term.open(termContainer);
+    fitAddon.fit();
+
+    term.onData(data => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data })); });
+    term.onResize(({ cols, rows }) => {
+      if (ws && ws.readyState === WebSocket.OPEN && pid) ws.send(JSON.stringify({ type: 'resize', cols, rows }));
+    });
+    window.addEventListener('resize', () => { if (fitAddon) fitAddon.fit(); });
+  }
+
+  function setConnected(v) {
+    statusDot.className = 'dot ' + (v ? 'connected' : 'disconnected');
+    statusText.textContent = v ? '已连接' : '未连接';
+  }
+
+  function connectWS() {
+    if (ws) { ws.close(); ws = null; }
+    if (!authToken || !TASK_ID) return;
+    const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
+    const url = proto + '//' + location.host + '/ws?token=' + encodeURIComponent(authToken) + '&task=' + encodeURIComponent(TASK_ID);
+    term.writeln('\\x1b[33m⏳ 正在连接任务 [' + (currentTask ? currentTask.name : TASK_ID) + ']...\\x1b[0m');
+    setConnected(false);
+
+    ws = new WebSocket(url);
+    ws.binaryType = 'arraybuffer';
+
+    ws.onopen = () => { setConnected(true); term.writeln('\\x1b[32m✓ 连接成功\\x1b[0m'); };
+    ws.onmessage = (event) => {
+      try {
+        const msg = JSON.parse(event.data);
+        if (msg.type === 'output' && msg.data) term.write(msg.data);
+        else if (msg.type === 'pid') pid = msg.pid;
+        else if (msg.type === 'error') { term.writeln('\\x1b[31m✗ ' + msg.data + '\\x1b[0m'); }
+        else if (msg.type === 'exit') { term.writeln('\\x1b[33m⚠ 进程已退出 (code: ' + msg.code + ')\\x1b[0m'); setConnected(false); }
+      } catch(e) { term.write(event.data); }
+    };
+    ws.onclose = () => { setConnected(false); term.writeln('\\x1b[31m✗ 连接已断开\\x1b[0m'); };
+    ws.onerror = () => { setConnected(false); term.writeln('\\x1b[31m✗ 连接错误\\x1b[0m'); };
+  }
+
+  function killCurrent() {
+    if (pid) { fetch(API + '/api/kill/' + pid, { headers: authHeaders() }).catch(()=>{}); pid = 0; }
+  }
+
+  document.getElementById('btn-restart').onclick = () => {
+    killCurrent();
+    if (term) term.clear();
+    term.writeln('\\x1b[33m⏳ 重启会话中...\\x1b[0m');
+    connectWS();
+  };
+  document.getElementById('btn-list').onclick = () => { window.open('/', 'cb_picker'); };
+  document.getElementById('btn-close').onclick = () => {
+    killCurrent();
+    if (ws) ws.close();
+    window.close();
+  };
+  // 窗口关闭前尽力杀进程
+  window.addEventListener('beforeunload', () => {
+    if (pid) navigator.sendBeacon && navigator.sendBeacon(API + '/api/kill/' + pid + '?token=' + encodeURIComponent(authToken));
+  });
+
+  // ========== 初始化 ==========
+  (async () => {
+    if (await checkAuth()) await enterApp();
+    else show('login');
+  })();
+})();
+</script>
+</body>
+</html>
diff --git a/logs/.gitkeep b/logs/.gitkeep
new file mode 100644 (file)
index 0000000..e69de29