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
# 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()
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)
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):
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):
# 决定启动参数:已存在 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"]
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()
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")
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; }
<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>
}
// ========== 终端 ==========
+ 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;
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 }));