--- /dev/null
+#!/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")
--- /dev/null
+<!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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>