]> acesimba.cloud Git - codebuddy-web.git/commitdiff
fix: 交互提示框处可自由上翻;下拉模型列表实时刷新
authorCodebuddy <codebuddy@localhost>
Fri, 28 Aug 2026 09:42:37 +0000 (17:42 +0800)
committerCodebuddy <codebuddy@localhost>
Fri, 28 Aug 2026 09:42:37 +0000 (17:42 +0800)
滚动(终端):
- 交互提示框会频繁重绘,重绘输出会在滚动事件生效前把画面拉回底部,导致用户上翻被拽回
- 在 xterm viewport 上监听 wheel/touchmove 用户手势,立即停止贴底并进入 300ms 保护期
- 保护期内输出回调忽略 scrollToBottom;手势停止后按当前位置重新判定是否恢复跟随
- 只监听 wheel/touchmove,不监听 scroll(后者会被程序化 scrollToBottom 触发,会误伤流式贴底)
- 保留原行为:未手动滚动时流式输出仍自动滚到最新行

模型列表:
- /api/models 新增 ?force=1,强制重扫 codebuddy --help,前端下拉打开时带上该参数
- scan_models 改为自身管理锁,subprocess 移到锁外执行,避免阻塞 /ws 等请求
- api_set_model 遇到未知模型时强制重扫一次再判定,避免新上线模型被误拒

Co-Authored-By: CodeBuddy <noreply@codebuddy.ai>
backend/app.py
frontend/index.html

index 2b1a8a23ea30131ae3a278ac0ff69e072b9dd6b2..76fb73b1adbe8e1f7c12cba5992eafaca3f6116b 100644 (file)
@@ -145,21 +145,26 @@ def scan_models(force: bool = False) -> dict:
     """扫描模型列表并写入缓存。返回 {models, default, scanned_at}。
 
     系统默认模型固定为 hy3:所有未显式指定模型的会话(含既有与新建)在
-    启动 / 重启时均默认使用 hy3。"""
+    启动 / 重启时均默认使用 hy3。
+
+    注意:自身管理 MODELS_CACHE_LOCK,调用方不要再持锁调用(threading.Lock 不可重入)。
+    subprocess 调用放在锁外执行,避免阻塞其它请求(如 /ws 取默认模型)。"""
     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:
-        # 命中缓存:强制默认模型为 hy3,并确保 hy3 在可选列表中
-        MODELS_CACHE["default"] = "hy3"
-        if "hy3" not in MODELS_CACHE.get("models", []):
-            MODELS_CACHE["models"] = ["hy3"] + list(MODELS_CACHE.get("models", []))
-        return MODELS_CACHE
-
+    with MODELS_CACHE_LOCK:
+        need_scan = (
+            force
+            or not MODELS_CACHE.get("models")
+            or now - MODELS_CACHE.get("scanned_at", 0) > MODELS_SCAN_INTERVAL
+        )
+        if not need_scan:
+            # 命中缓存:强制默认模型为 hy3,并确保 hy3 在可选列表中
+            MODELS_CACHE["default"] = "hy3"
+            if "hy3" not in MODELS_CACHE.get("models", []):
+                MODELS_CACHE["models"] = ["hy3"] + list(MODELS_CACHE.get("models", []))
+            return MODELS_CACHE
+
+    # —— 以下为真正重扫,subprocess 调用放在锁外,避免阻塞其它请求 ——
     models = []
     cb = find_codebuddy()
     if cb:
@@ -171,25 +176,28 @@ def scan_models(force: bool = False) -> dict:
 
     if not models:
         # 优先用上次缓存,其次是兜底列表
-        if MODELS_CACHE.get("models"):
-            models = MODELS_CACHE["models"]
-        else:
-            models = list(FALLBACK_MODELS)
+        with MODELS_CACHE_LOCK:
+            if MODELS_CACHE.get("models"):
+                models = MODELS_CACHE["models"]
+            else:
+                models = list(FALLBACK_MODELS)
 
     # 系统默认模型固定为 hy3
     default = "hy3"
     if default not in models:
         models = [default] + models
-    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}")
+
+    with MODELS_CACHE_LOCK:
+        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)} 个,默认(hy3)={default}")
     return MODELS_CACHE
 
@@ -199,8 +207,7 @@ def _periodic_model_scan():
     while True:
         time.sleep(MODELS_SCAN_INTERVAL)
         try:
-            with MODELS_CACHE_LOCK:
-                scan_models(force=True)
+            scan_models(force=True)
         except Exception as e:
             logger.warning(f"定期模型扫描异常: {e}")
 
@@ -555,8 +562,9 @@ async def api_check(request: Request):
 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()
+    # force=1 时强制重扫 codebuddy --help,确保下拉列表拿到最新模型(如新上线的 hy4-preview)
+    force = request.query_params.get("force", "0") in ("1", "true", "yes", "on")
+    data = scan_models(force=force)
     return JSONResponse({
         "ok": True,
         "models": data["models"],
@@ -806,7 +814,12 @@ async def api_set_model(task_id: str, request: Request):
         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)
+            # 缓存可能滞后(新上线模型尚未被下拉扫描刷新):强制重扫一次再判定
+            scan_models(force=True)
+            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:
         # 空字符串:清除显式选择,跟随默认 hy3
index 288604963b4cd1aadb7046bb6a86b96d02bd5bcf..0283d3f11e847263b14f19ee1ab79a2efe729c46 100644 (file)
@@ -463,6 +463,9 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
   let pid = 0;
   let currentTask = null;
   let followBottom = true;   // 终端是否随新输出自动贴底
+  // 用户手动上翻后的“滚动保护期”:期间忽略任何自动滚底,防止交互提示框重绘把画面拉回底部
+  let scrollLockUntil = 0;
+  let scrollEndTimer = null;
   const jumpBtn = document.getElementById('jump-bottom');
 
   // iPad/iOS 关键修复:把页面壳精确钉到“可视视口”,避开顶部地址栏与软键盘,
@@ -827,7 +830,8 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
   // 加载模型列表并渲染下拉框
   async function loadModelSelect() {
     try {
-      const res = await fetch(API + '/api/models', { headers: authHeaders() });
+      // force=1:点开下拉时强制后端重扫 codebuddy --help,确保拿到最新模型(如新上线的 hy4-preview)
+      const res = await fetch(API + '/api/models?force=1', { headers: authHeaders() });
       if (!res.ok) return;
       const data = await res.json();
       if (!data.ok) return;
@@ -1635,6 +1639,27 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
       jumpBtn.style.display = atBottom ? 'none' : 'block';
     };
     term.onScroll(updateFollow);
+
+    // 用户手势上翻(滚轮 / 触摸):立即停止贴底并进入短暂保护期。
+    // 交互提示框会频繁重绘,若不加保护,重绘输出会在滚动事件生效前把画面拽回底部。
+    // 只监听 wheel / touchmove(均为用户主动手势),不监听 scroll —— 后者会被
+    // 程序化 scrollToBottom 触发,误伤流式输出的贴底跟随。
+    const onUserScroll = () => {
+      followBottom = false;
+      scrollLockUntil = Date.now() + 300;
+      jumpBtn.style.display = 'block';
+      if (scrollEndTimer) clearTimeout(scrollEndTimer);
+      // 手势停止后重新按当前位置判定:若已滚回底部则恢复贴底跟随
+      scrollEndTimer = setTimeout(() => {
+        scrollLockUntil = 0;
+        updateFollow();
+      }, 320);
+    };
+    const vpEl = term.element && term.element.querySelector('.xterm-viewport');
+    if (vpEl) {
+      vpEl.addEventListener('wheel', onUserScroll, { passive: true });
+      vpEl.addEventListener('touchmove', onUserScroll, { passive: true });
+    }
     jumpBtn.onclick = () => { term.scrollToBottom(); followBottom = true; jumpBtn.style.display = 'none'; };
 
     // iPad 软键盘/工具栏收起时:重算终端尺寸并贴底,避免被遮挡
@@ -1727,7 +1752,10 @@ body { position: fixed; top: 0; left: 0; right: 0; bottom: 0; width: 100%; heigh
       try {
         const msg = JSON.parse(event.data);
         if (msg.type === 'output' && msg.data) {
-          term.write(msg.data, () => { if (followBottom) term.scrollToBottom(); });
+          term.write(msg.data, () => {
+            // 保护期内不强制滚底:交互提示框重绘时,用户上翻的内容不会被拽回底部
+            if (followBottom && Date.now() >= scrollLockUntil) term.scrollToBottom();
+          });
         }
         else if (msg.type === 'pid') pid = msg.pid;
         else if (msg.type === 'reattach') { pid = msg.pid || pid; renderReattach(msg); }