From: Codebuddy Date: Thu, 18 Jun 2026 03:56:19 +0000 (+0800) Subject: Initial commit: Codebuddy Web Console X-Git-Url: http://acesimba.cloud/gitweb/?a=commitdiff_plain;h=97663d4c7fa9e9ba1b73b9da3d359dc75ef91280;p=codebuddy-web.git Initial commit: Codebuddy Web Console 前后端分离结构: - 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 --- 97663d4c7fa9e9ba1b73b9da3d359dc75ef91280 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d634206 --- /dev/null +++ b/.gitignore @@ -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 index 0000000..73e7d15 --- /dev/null +++ b/backend/app.py @@ -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 index 0000000..e69de29 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..7a20c92 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,415 @@ + + + + + +Codebuddy Web Console + + + + + + +
+ +
+ + +
+
+

任务清单

+ +
+
+
+
+
+ + + + + + + + + + diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29