]> acesimba.cloud Git - codebuddy-web.git/commitdiff
新增控制台模型切换能力(默认最新 GLM,每日扫描)
authorCodebuddy <codebuddy@localhost>
Tue, 28 Jul 2026 10:30:27 +0000 (18:30 +0800)
committerCodebuddy <codebuddy@localhost>
Tue, 28 Jul 2026 10:30:27 +0000 (18:30 +0800)
.gitignore
backend/app.py
frontend/index.html

index a1329c57721fd2b1a67c1a5e8dedb512b89a2b88..d73e2f4fe59780f048b15526e72ed020c3e28f74 100644 (file)
@@ -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
index c9c14aee97de3ec8f3a39eb066b684bfc19431cb..0a7ffe4c4536b76c933ebe8b5be97641fae2fa44 100644 (file)
@@ -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")
index 18a3ebc64e23073b8211abc8a0bde28059395425..d872fce94b6dc4c129d9a092428f83987917b5ae 100644 (file)
@@ -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:
       <span id="status-text">未连接</span>
       <span class="task-name" id="task-title"></span>
       <span class="task-cwd" id="task-cwd"></span>
+      <select id="model-select" class="model-select" title="切换模型(切换将重启会话)">
+        <option value="">默认 · 最新 GLM</option>
+      </select>
     </div>
     <div class="actions">
       <button class="action-btn" id="btn-restart" title="重启会话(续上次历史)">↻ 重启会话</button>
@@ -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 }));