From 6b644c2d6611e9ff2a2a91224e3507da506f9673 Mon Sep 17 00:00:00 2001 From: Codebuddy Date: Fri, 28 Aug 2026 17:42:37 +0800 Subject: [PATCH] =?utf8?q?fix:=20=E4=BA=A4=E4=BA=92=E6=8F=90=E7=A4=BA?= =?utf8?q?=E6=A1=86=E5=A4=84=E5=8F=AF=E8=87=AA=E7=94=B1=E4=B8=8A=E7=BF=BB?= =?utf8?q?=EF=BC=9B=E4=B8=8B=E6=8B=89=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8?= =?utf8?q?=E5=AE=9E=E6=97=B6=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit 滚动(终端): - 交互提示框会频繁重绘,重绘输出会在滚动事件生效前把画面拉回底部,导致用户上翻被拽回 - 在 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 --- backend/app.py | 77 ++++++++++++++++++++++++++------------------- frontend/index.html | 32 +++++++++++++++++-- 2 files changed, 75 insertions(+), 34 deletions(-) diff --git a/backend/app.py b/backend/app.py index 2b1a8a2..76fb73b 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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 diff --git a/frontend/index.html b/frontend/index.html index 2886049..0283d3f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -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); } -- 2.43.0