perf(host): 新建对话延迟从10s降至3s并消除后台日志刷屏

- 索引覆盖检查改与 min(needed, 文件总数) 比较,修复每次列表请求
  必触发两次全量重建的逻辑错误(重建索引日志刷屏根源)
- OCRClient 的 httpx.Client/OpenAI 改为首次调用 VLM 时懒加载,
  构造耗时 3675ms -> 2ms(此前每个对话级 terminal 创建都要付一次)
- WebTerminal 复用父类 TerminalManager 仅注入广播回调,不再二次创建;
  close_all 在无终端时静默,消除 GC 路径的关闭终端噪音日志
- 新建对话版本控制初始化去重:create_new_conversation 返回
  versioning_initialized 标记,路由层跳过重复初始化
- _update_index 读-改-写整体纳入 _io_lock,修复并发丢索引条目
  进而引发连锁全量重建的隐患
This commit is contained in:
JOJO 2026-07-30 17:42:12 +08:00
parent 510fb8497f
commit 97e1327987
6 changed files with 97 additions and 56 deletions

View File

@ -115,15 +115,20 @@ class WebTerminal(MainTerminal):
# 默认允许输出api_client.web_mode=False 表示允许 _print若需静默可设置 WEB_API_SILENT=1
self.api_client.web_mode = bool(os.environ.get("WEB_API_SILENT"))
# 重新初始化终端管理器
self.terminal_manager = TerminalManager(
project_path=project_path,
max_terminals=MAX_TERMINALS,
terminal_buffer_size=TERMINAL_BUFFER_SIZE,
terminal_display_size=TERMINAL_DISPLAY_SIZE,
broadcast_callback=message_callback,
container_session=self.container_session
)
# 复用父类已创建的 TerminalManager仅注入广播回调。
# 此前这里会新建一个实例顶掉父类的,旧实例被 GC 时触发 __del__ -> close_all()
# 导致每次创建对话级 terminal 都输出“关闭所有终端会话”噪音日志。
if self.terminal_manager is not None:
self.terminal_manager.broadcast = message_callback
else:
self.terminal_manager = TerminalManager(
project_path=project_path,
max_terminals=MAX_TERMINALS,
terminal_buffer_size=TERMINAL_BUFFER_SIZE,
terminal_display_size=TERMINAL_DISPLAY_SIZE,
broadcast_callback=message_callback,
container_session=self.container_session
)
# 让 run_command 与实时终端共享同一容器环境
self.terminal_ops.attach_terminal_manager(self.terminal_manager)
@ -276,10 +281,12 @@ class WebTerminal(MainTerminal):
perf_log("create_new_conversation before default versioning", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": conversation_id})
# 根据个性化设置默认开启版本控制
versioning_initialized = False
try:
default_versioning_enabled = bool((prefs or {}).get("versioning_enabled_by_default", True))
if default_versioning_enabled:
self._ensure_conversation_versioning_enabled(conversation_id)
versioning_initialized = True
except Exception as exc:
logger.warning("新对话应用默认版本控制失败: %s", exc)
perf_log("create_new_conversation after default versioning", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": conversation_id})
@ -290,7 +297,9 @@ class WebTerminal(MainTerminal):
return {
"success": True,
"conversation_id": conversation_id,
"message": f"已创建新对话: {conversation_id}"
"message": f"已创建新对话: {conversation_id}",
# 路由层据此跳过重复的版本控制初始化(此前每次新建对话初始化两遍)
"versioning_initialized": versioning_initialized,
}
except Exception as e:
perf_log("create_new_conversation error", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"error": str(e)})

View File

@ -19,6 +19,22 @@ class OCRClient:
self.project_path = Path(project_path).resolve()
self.file_manager = file_manager
# 懒加载httpx.Client() 构造会加载 CA 证书并初始化 SSL 上下文,
# 在 WindowsDefender 实时扫描)下可耗时数秒;每个对话级 terminal
# 都会构造 OCRClient因此推迟到首次真正调用 VLM 时再创建。
self.http_client = None
self.client = None
self._client_ready = False
self.model = OCR_MODEL_ID
self.max_tokens = OCR_MAX_TOKENS or 4096
# 默认大小上限10MB超出则警告并拒绝
self.max_image_size = 10 * 1024 * 1024
def _ensure_client(self):
"""首次使用时创建 httpx / OpenAI 客户端(线程安全由 GIL 保证最坏情况重复创建一次)。"""
if self._client_ready:
return
# 补全 base_url兼容是否包含 /v1
base_url = (OCR_API_BASE_URL or "").rstrip("/")
if base_url and not base_url.endswith("/v1"):
@ -26,18 +42,13 @@ class OCRClient:
# httpx 0.28 起不再支持 proxies 参数,显式传入 http_client 以避免默认封装报错
self.http_client = httpx.Client()
self.client = None
if OCR_API_KEY:
self.client = OpenAI(
api_key=OCR_API_KEY,
base_url=base_url or None,
http_client=self.http_client,
)
self.model = OCR_MODEL_ID
self.max_tokens = OCR_MAX_TOKENS or 4096
# 默认大小上限10MB超出则警告并拒绝
self.max_image_size = 10 * 1024 * 1024
self._client_ready = True
def _validate_image_path(self, path: str):
"""复用 FileManager 的路径校验,确保在项目内。"""
@ -60,8 +71,11 @@ class OCRClient:
if not prompt or not str(prompt).strip():
return {"success": False, "error": "prompt 不能为空", "warnings": warnings}
if not self.client or not OCR_API_KEY or not OCR_API_BASE_URL or not self.model:
if not OCR_API_KEY or not OCR_API_BASE_URL or not self.model:
return {"success": False, "error": "VLM 配置缺失,请设置 OCR_API_BASE_URL / OCR_API_KEY / OCR_MODEL_ID", "warnings": warnings}
self._ensure_client()
if not self.client:
return {"success": False, "error": "VLM 客户端初始化失败", "warnings": warnings}
try:
data = full_path.read_bytes()

View File

@ -744,6 +744,10 @@ class TerminalManager:
def close_all(self):
"""关闭所有终端会话"""
# 无终端时静默返回:避免 __del__/竞态重建路径对空实例输出噪音日志
if not getattr(self, "terminals", None):
self.active_terminal = None
return
print(f"{OUTPUT_FORMATS['info']} 关闭所有终端会话...")
for session_name in list(self.terminals.keys()):

View File

@ -730,9 +730,10 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam
session['thinking_mode'] = terminal.thinking_mode
perf_log("create_conversation before default versioning", elapsed_ms=(time.perf_counter() - t0) * 1000, extra={"conv_id": result.get("conversation_id")})
# 根据个性化设置,为新对话默认开启版本控制(安全导航路径也需要)。
# create_new_conversation 内部已完成初始化时跳过,避免每个新对话初始化两遍。
try:
prefs = load_personalization_config(workspace.data_dir)
if bool(prefs.get("versioning_enabled_by_default", True)):
if bool(prefs.get("versioning_enabled_by_default", True)) and not result.get("versioning_initialized"):
_ensure_conversation_versioning_enabled(terminal, workspace, result["conversation_id"])
except Exception as exc:
debug_log(f"[Versioning] create_conversation apply default failed: {exc}")

View File

@ -273,42 +273,46 @@ class CrudMixin:
def _update_index(self, conversation_id: str, conversation_data: Dict):
"""更新对话索引"""
try:
index = self._load_index()
# 创建元数据
metadata = ConversationMetadata(
id=conversation_id,
title=conversation_data["title"],
created_at=conversation_data["created_at"],
updated_at=conversation_data["updated_at"],
project_path=conversation_data["metadata"]["project_path"],
project_relative_path=conversation_data["metadata"].get("project_relative_path"),
thinking_mode=conversation_data["metadata"]["thinking_mode"],
run_mode=conversation_data["metadata"].get("run_mode", "thinking" if conversation_data["metadata"]["thinking_mode"] else "fast"),
total_messages=conversation_data["metadata"]["total_messages"],
total_tools=conversation_data["metadata"]["total_tools"],
status=conversation_data["metadata"].get("status", "active")
)
# 添加到索引
index[conversation_id] = {
"title": metadata.title,
"created_at": metadata.created_at,
"updated_at": metadata.updated_at,
"project_path": metadata.project_path,
"project_relative_path": metadata.project_relative_path,
"thinking_mode": metadata.thinking_mode,
"run_mode": metadata.run_mode,
"model_key": conversation_data["metadata"].get("model_key"),
"has_images": conversation_data["metadata"].get("has_images", False),
"has_videos": conversation_data["metadata"].get("has_videos", False),
"total_messages": metadata.total_messages,
"total_tools": metadata.total_tools,
"status": metadata.status,
"multi_agent_mode": bool(conversation_data["metadata"].get("multi_agent_mode", False))
}
self._save_index(index)
# 读-改-写必须整体持锁:此前仅在 _save_index 内加锁,
# 并发保存时后写者会用旧的读结果覆盖掉先写者的条目(丢失更新),
# 进而触发“索引缺文件→全量重建”的连锁反应。_io_lock 为 RLock可重入。
with self._io_lock:
index = self._load_index()
# 创建元数据
metadata = ConversationMetadata(
id=conversation_id,
title=conversation_data["title"],
created_at=conversation_data["created_at"],
updated_at=conversation_data["updated_at"],
project_path=conversation_data["metadata"]["project_path"],
project_relative_path=conversation_data["metadata"].get("project_relative_path"),
thinking_mode=conversation_data["metadata"]["thinking_mode"],
run_mode=conversation_data["metadata"].get("run_mode", "thinking" if conversation_data["metadata"]["thinking_mode"] else "fast"),
total_messages=conversation_data["metadata"]["total_messages"],
total_tools=conversation_data["metadata"]["total_tools"],
status=conversation_data["metadata"].get("status", "active")
)
# 添加到索引
index[conversation_id] = {
"title": metadata.title,
"created_at": metadata.created_at,
"updated_at": metadata.updated_at,
"project_path": metadata.project_path,
"project_relative_path": metadata.project_relative_path,
"thinking_mode": metadata.thinking_mode,
"run_mode": metadata.run_mode,
"model_key": conversation_data["metadata"].get("model_key"),
"has_images": conversation_data["metadata"].get("has_images", False),
"has_videos": conversation_data["metadata"].get("has_videos", False),
"total_messages": metadata.total_messages,
"total_tools": metadata.total_tools,
"status": metadata.status,
"multi_agent_mode": bool(conversation_data["metadata"].get("multi_agent_mode", False))
}
self._save_index(index)
except Exception as e:
print(f"⌘ 更新对话索引失败: {e}")

View File

@ -279,10 +279,19 @@ class IndexMixin:
self._save_index(rebuilt)
index = rebuilt
if len(index) >= needed:
# 关键:覆盖目标不应超过实际文件总数。此前只和 needed(offset+limit) 比较,
# 当 needed 大于对话总数时会每次触发 rebuild_needed + rebuild_full 两次全量重建,
# 日志刷屏且随对话数线性变慢。
try:
total_files = len(self._iter_conversation_files(sort_by_mtime=False))
except Exception:
total_files = None
coverage_target = needed if total_files is None else min(needed, total_files)
if len(index) >= coverage_target:
total_ms = (time.perf_counter() - t0) * 1000
if perf_log:
perf_log("_ensure_index_covering return", elapsed_ms=total_ms, extra={"index_size": len(index), "needed": needed})
perf_log("_ensure_index_covering return", elapsed_ms=total_ms, extra={"index_size": len(index), "needed": needed, "total_files": total_files})
return index
# 第一次尝试:扩展到需要的数量(按更新时间倒序)