From: Codebuddy Date: Tue, 28 Jul 2026 10:30:27 +0000 (+0800) Subject: 新增控制台模型切换能力(默认最新 GLM,每日扫描) X-Git-Url: http://acesimba.cloud/gitweb/?a=commitdiff_plain;h=eae89fdaa69240520223504298e09cb79f4076b2;p=codebuddy-web.git 新增控制台模型切换能力(默认最新 GLM,每日扫描) --- diff --git a/.gitignore b/.gitignore index a1329c5..d73e2f4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,8 @@ data/tasks.json # Runtime state: pasted screenshots uploaded via web console data/pastes/ +# Runtime state: scanned model list cache (regenerated daily by backend) +data/models.json + # Backups (git history serves this purpose) *.bak diff --git a/backend/app.py b/backend/app.py index c9c14ae..0a7ffe4 100644 --- a/backend/app.py +++ b/backend/app.py @@ -21,6 +21,10 @@ import logging import subprocess from pathlib import Path +import re +import time +import threading + import uvicorn from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request from fastapi.responses import HTMLResponse, JSONResponse @@ -53,6 +57,17 @@ TASK_HOME_ROOT = os.environ.get("CODEBUDDY_WEB_TASK_ROOT", "/home") # codebuddy 会话文件存放目录 CODEBUDDY_PROJECTS_DIR = Path.home() / ".codebuddy" / "projects" +# 模型列表(每日扫描缓存) +MODELS_CACHE_FILE = Path(__file__).resolve().parent.parent / "data" / "models.json" +MODELS_CACHE = {"models": [], "default": "", "scanned_at": 0.0} +MODELS_SCAN_INTERVAL = 24 * 3600 # 每日扫描一次 +MODELS_CACHE_LOCK = threading.Lock() +# 兜底模型列表(codebuddy 不可用时使用) +FALLBACK_MODELS = ["hy3", "glm-5.2", "glm-5.1", "glm-5.0", "glm-5.0-turbo", + "glm-5v-turbo", "glm-4.7", "minimax-m3-pay", "minimax-m2.7", + "kimi-k3-2", "kimi-k2.7", "kimi-k2.6", "deepseek-v4-pro", + "deepseek-v4-flash", "deepseek-v3-2-volc"] + # 活动任务锁:同一任务同时只允许一个会话,防止两窗口同时 resume 损坏 jsonl ACTIVE_TASKS = set() @@ -84,6 +99,101 @@ def hash_password(pw: str) -> str: PASSWORD_HASH = hash_password(PASSWORD) +# ==================== 模型列表扫描 ==================== +def find_codebuddy() -> str | None: + """定位 codebuddy 可执行文件。""" + 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): + return candidate + import shutil + return shutil.which("codebuddy") + + +def parse_model_list(help_text: str) -> list: + """从 codebuddy --help 中解析支持的模型列表。""" + m = re.search(r"Currently supported:\s*\(([^)]+)\)", help_text) + if not m: + return [] + return [x.strip() for x in m.group(1).split(",") if x.strip()] + + +def latest_glm(models: list) -> str: + """在 glm-* 模型里挑出版本号最大的(即最新 GLM)。""" + best = None + best_key = None + for name in models: + if not name.startswith("glm-"): + continue + base = name[len("glm-"):].split("-")[0] # 5.2 / 5v / 4.7 + mm = re.match(r"(\d+)(?:\.(\d+))?", base) + if not mm: + continue + major = int(mm.group(1)) + minor = int(mm.group(2)) if mm.group(2) else 0 + key = (major, minor) + if best_key is None or key > best_key: + best_key, best = key, name + return best or "" + + +def scan_models(force: bool = False) -> dict: + """扫描模型列表并写入缓存。返回 {models, default, scanned_at}。""" + global MODELS_CACHE + now = time.time() + need_scan = ( + force + or not MODELS_CACHE.get("models") + or now - MODELS_CACHE.get("scanned_at", 0) > MODELS_SCAN_INTERVAL + ) + if not need_scan: + return MODELS_CACHE + + models = [] + cb = find_codebuddy() + if cb: + try: + out = subprocess.run([cb, "--help"], capture_output=True, text=True, timeout=20).stdout + models = parse_model_list(out) + except Exception as e: + logger.warning(f"扫描模型列表失败: {e}") + + if not models: + # 优先用上次缓存,其次是兜底列表 + if MODELS_CACHE.get("models"): + models = MODELS_CACHE["models"] + else: + models = list(FALLBACK_MODELS) + + default = latest_glm(models) or (models[0] if models else "") + MODELS_CACHE = {"models": models, "default": default, "scanned_at": now} + # 持久化缓存,避免重启后立刻再扫 + try: + MODELS_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = str(MODELS_CACHE_FILE) + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(MODELS_CACHE, f, ensure_ascii=False, indent=2) + os.replace(tmp, MODELS_CACHE_FILE) + except Exception as e: + logger.warning(f"写入模型缓存失败: {e}") + logger.info(f"模型列表已扫描: 共 {len(models)} 个,默认(最新GLM)={default}") + return MODELS_CACHE + + +def _periodic_model_scan(): + """后台线程:每日定期扫描模型列表。""" + while True: + time.sleep(MODELS_SCAN_INTERVAL) + try: + with MODELS_CACHE_LOCK: + scan_models(force=True) + except Exception as e: + logger.warning(f"定期模型扫描异常: {e}") + + def _auth_ok(request: Request) -> bool: token = request.headers.get("Authorization", "").replace("Bearer ", "") return verify_token(token) @@ -185,6 +295,19 @@ async def api_check(request: Request): return JSONResponse({"ok": False}, status_code=401) +@app.get("/api/models") +async def api_models(request: Request): + if not _auth_ok(request): + return JSONResponse({"ok": False, "error": "未授权"}, status_code=401) + with MODELS_CACHE_LOCK: + data = scan_models() + return JSONResponse({ + "ok": True, + "models": data["models"], + "default": data["default"], + }) + + @app.get("/api/tasks") async def api_tasks(request: Request): if not _auth_ok(request): @@ -305,6 +428,35 @@ async def api_resume_task(task_id: str, request: Request): return JSONResponse({"ok": True, "task": t}) +@app.post("/api/tasks/{task_id}/model") +async def api_set_model(task_id: str, request: Request): + """设置任务的模型。model 为空字符串表示跟随默认(最新 GLM)。""" + if not _auth_ok(request): + return JSONResponse({"ok": False, "error": "未授权"}, status_code=401) + try: + body = await request.json() + except Exception: + return JSONResponse({"ok": False, "error": "无效的请求体"}, status_code=400) + model = (body.get("model") or "").strip() + data = load_tasks() + t = find_task(data, task_id) + if not t: + return JSONResponse({"ok": False, "error": "任务不存在"}, status_code=404) + if model: + # 校验是否在已知模型列表中(未知则拒绝,避免无效模型) + with MODELS_CACHE_LOCK: + known = MODELS_CACHE.get("models") or FALLBACK_MODELS + if model not in known: + return JSONResponse({"ok": False, "error": f"未知模型: {model}"}, status_code=400) + t["model"] = model + else: + # 空字符串:清除显式选择,跟随默认最新 GLM + t["model"] = "" + save_tasks(data) + logger.info(f"设置模型: id={task_id}, name={t.get('name')}, model='{t['model']}'") + return JSONResponse({"ok": True, "task": t}) + + @app.post("/api/kill/{pid}") async def api_kill(pid: int, request: Request): if not _auth_ok(request): @@ -406,11 +558,15 @@ async def websocket_terminal(websocket: WebSocket): # 决定启动参数:已存在 session 则 resume,否则用固定 session-id 新建 sid = task["session_id"] + # 计算生效模型:任务显式设置 > 默认最新 GLM + with MODELS_CACHE_LOCK: + _default_model = MODELS_CACHE.get("default") or (MODELS_CACHE.get("models") or [""])[0] or "glm-5.2" + model = (task.get("model") or "").strip() or _default_model if session_exists(sid): - cmd = [codebuddy_path, "--resume", sid] + cmd = [codebuddy_path, "--model", model, "--resume", sid] mode = "resume" else: - cmd = [codebuddy_path, "--session-id", sid] + cmd = [codebuddy_path, "--model", model, "--session-id", sid] mode = "new" cwd = task["cwd"] @@ -421,7 +577,7 @@ async def websocket_terminal(websocket: WebSocket): ACTIVE_TASKS.add(task_id) await websocket.accept() - logger.info(f"任务启动: name={task['name']}, cwd={cwd}, mode={mode}, sid={sid}") + logger.info(f"任务启动: name={task['name']}, cwd={cwd}, mode={mode}, model={model}, sid={sid}") # 创建 PTY master_fd, slave_fd = pty.openpty() @@ -545,8 +701,22 @@ async def websocket_terminal(websocket: WebSocket): if __name__ == "__main__": # 初始化任务注册表(首次自动 seed words) _init = load_tasks() + + # 加载模型缓存(若有),并做首次扫描;启动每日定期扫描线程 + try: + if MODELS_CACHE_FILE.is_file(): + with open(MODELS_CACHE_FILE, "r", encoding="utf-8") as _mf: + _cached = json.load(_mf) + if isinstance(_cached, dict) and _cached.get("models"): + MODELS_CACHE = _cached + except Exception as e: + logger.warning(f"读取模型缓存失败: {e}") + scan_models() + threading.Thread(target=_periodic_model_scan, daemon=True).start() + 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}") + logger.info(f" 模型数: {len(MODELS_CACHE.get('models', []))} 默认(最新GLM): {MODELS_CACHE.get('default')}") uvicorn.run(app, host=HOST, port=PORT, log_level="info") diff --git a/frontend/index.html b/frontend/index.html index 18a3ebc..d872fce 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -58,6 +58,13 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color: transition: all 0.15s; } .top-bar .action-btn:hover { background: rgba(255,255,255,0.12); color: #fff; } +.top-bar .model-select { + padding: 4px 8px; border-radius: 6px; border: 1px solid rgba(78,204,163,0.4); + background: rgba(78,204,163,0.08); color: #4ecca3; font-size: 13px; cursor: pointer; + outline: none; max-width: 200px; transition: all 0.15s; +} +.top-bar .model-select:hover { background: rgba(78,204,163,0.16); color: #fff; } +.top-bar .model-select option { background: #1a1a2e; color: #e0e0e0; } /* 终端页布局 */ #terminal-page { display: none; height: 100vh; flex-direction: column; } @@ -197,6 +204,9 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color: 未连接 +
@@ -490,6 +500,37 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color: } // ========== 终端 ========== + const modelSelect = document.getElementById('model-select'); + + // 加载模型列表并渲染下拉框 + async function loadModelSelect() { + try { + const res = await fetch(API + '/api/models', { headers: authHeaders() }); + if (!res.ok) return; + const data = await res.json(); + if (!data.ok) return; + const models = data.models || []; + const def = data.default || ''; + // 重建选项:第一项“默认·最新 GLM” + modelSelect.innerHTML = ''; + const optDef = document.createElement('option'); + optDef.value = ''; + optDef.textContent = def ? ('默认 · 最新 GLM (' + def + ')') : '默认 · 最新 GLM'; + modelSelect.appendChild(optDef); + models.forEach(m => { + const o = document.createElement('option'); + o.value = m; + o.textContent = m + ((m === def) ? ' ★' : ''); + modelSelect.appendChild(o); + }); + // 设置当前值:任务显式模型 或 空(跟随默认) + const cur = (currentTask && currentTask.model) ? currentTask.model : ''; + modelSelect.value = cur; + } catch (e) { + console.warn('加载模型列表失败:', e); + } + } + async function showTerminalMode() { const tasks = await fetchTasks(); if (!tasks) return; @@ -504,8 +545,44 @@ html { font-size: 15px; } html, body { height: 100%; background: #1a1a2e; color: show('terminal'); initTerminal(); connectWS(); + loadModelSelect(); } + // 切换模型:保存到任务,并重启会话以应用 + let modelSwitching = false; + modelSelect.addEventListener('change', async () => { + if (modelSwitching || !currentTask) return; + const newModel = modelSelect.value; + modelSwitching = true; + modelSelect.disabled = true; + try { + const res = await fetch(API + '/api/tasks/' + currentTask.id + '/model', { + method: 'POST', + headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), + body: JSON.stringify({ model: newModel }) + }); + if (!res.ok) { + const d = await res.json().catch(()=>({})); + alert(d.error || '设置模型失败'); + modelSelect.value = (currentTask.model) || ''; + return; + } + const data = await res.json(); + currentTask.model = (data.task && data.task.model) || ''; + term.writeln('\\x1b[33m⏳ 切换模型为 "' + (currentTask.model || '默认·最新GLM') + '",正在重启会话...\\x1b[0m'); + // 重启以应用新模型(resume 同样会采用新 --model) + killCurrent(); + if (term) term.clear(); + connectWS(); + } catch (e) { + alert('设置模型失败: ' + e.message); + modelSelect.value = (currentTask.model) || ''; + } finally { + modelSwitching = false; + modelSelect.disabled = false; + } + }); + // ========== 终端输入辅助 ========== function sendInput(text) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: text }));