"""扫描模型列表并写入缓存。返回 {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:
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
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}")
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"],
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
let pid = 0;
let currentTask = null;
let followBottom = true; // 终端是否随新输出自动贴底
+ // 用户手动上翻后的“滚动保护期”:期间忽略任何自动滚底,防止交互提示框重绘把画面拉回底部
+ let scrollLockUntil = 0;
+ let scrollEndTimer = null;
const jumpBtn = document.getElementById('jump-bottom');
// iPad/iOS 关键修复:把页面壳精确钉到“可视视口”,避开顶部地址栏与软键盘,
// 加载模型列表并渲染下拉框
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;
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 软键盘/工具栏收起时:重算终端尺寸并贴底,避免被遮挡
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); }