fix: reduce workspace scans in host mode

This commit is contained in:
JOJO 2026-02-06 17:09:19 +08:00
parent 55ef45e04d
commit b0941a247b
10 changed files with 162 additions and 43 deletions

View File

@ -2734,6 +2734,9 @@ class MainTerminal:
async def show_files(self, args: str = ""): async def show_files(self, args: str = ""):
"""显示项目文件""" """显示项目文件"""
if self.context_manager._is_host_mode_without_safety():
print("\n⚠️ 宿主机模式下文件树不可用")
return
structure = self.context_manager.get_project_structure() structure = self.context_manager.get_project_structure()
print(f"\n📁 项目文件结构:") print(f"\n📁 项目文件结构:")
print(self.context_manager._build_file_tree(structure)) print(self.context_manager._build_file_tree(structure))

View File

@ -566,11 +566,12 @@ class WebTerminal(MainTerminal):
# 如果是文件操作,广播文件树更新 # 如果是文件操作,广播文件树更新
if tool_name in ['create_file', 'delete_file', 'rename_file', 'create_folder', 'save_webpage']: if tool_name in ['create_file', 'delete_file', 'rename_file', 'create_folder', 'save_webpage']:
try: if not self.context_manager._is_host_mode_without_safety():
structure = self.context_manager.get_project_structure() try:
self.broadcast('file_tree_update', structure) structure = self.context_manager.get_project_structure()
except Exception as e: self.broadcast('file_tree_update', structure)
logger.error(f"广播文件树更新失败: {e}") except Exception as e:
logger.error(f"广播文件树更新失败: {e}")
# 如果是记忆操作,广播记忆状态更新 # 如果是记忆操作,广播记忆状态更新
if tool_name == 'update_memory': if tool_name == 'update_memory':

View File

@ -14,6 +14,8 @@ try:
OUTPUT_FORMATS, OUTPUT_FORMATS,
READ_TOOL_MAX_FILE_SIZE, READ_TOOL_MAX_FILE_SIZE,
PROJECT_MAX_STORAGE_BYTES, PROJECT_MAX_STORAGE_BYTES,
TERMINAL_SANDBOX_MODE,
LINUX_SAFETY,
) )
except ImportError: # 兼容全局环境中存在同名包的情况 except ImportError: # 兼容全局环境中存在同名包的情况
import sys import sys
@ -28,6 +30,8 @@ except ImportError: # 兼容全局环境中存在同名包的情况
OUTPUT_FORMATS, OUTPUT_FORMATS,
READ_TOOL_MAX_FILE_SIZE, READ_TOOL_MAX_FILE_SIZE,
PROJECT_MAX_STORAGE_BYTES, PROJECT_MAX_STORAGE_BYTES,
TERMINAL_SANDBOX_MODE,
LINUX_SAFETY,
) )
from modules.container_file_proxy import ContainerFileProxy from modules.container_file_proxy import ContainerFileProxy
from utils.logger import setup_logger from utils.logger import setup_logger
@ -70,9 +74,16 @@ class FileManager:
"error": "容器未就绪,无法执行文件操作" "error": "容器未就绪,无法执行文件操作"
} }
return self._container_proxy.run(action, payload) return self._container_proxy.run(action, payload)
def _is_docker_mode(self) -> bool:
if self.container_session and getattr(self.container_session, "mode", None) is not None:
return getattr(self.container_session, "mode", None) == "docker"
return (TERMINAL_SANDBOX_MODE or "").lower() == "docker" or bool(LINUX_SAFETY)
def _get_project_size(self) -> int: def _get_project_size(self) -> int:
"""计算项目目录的总大小(字节),遇到异常时记录并抛出。""" """计算项目目录的总大小(字节),遇到异常时记录并抛出。"""
if not self._is_docker_mode():
return 0
total = 0 total = 0
if not self.project_path.exists(): if not self.project_path.exists():
return 0 return 0
@ -631,20 +642,21 @@ class FileManager:
try: try:
relative_path = self._relative_path(full_path) relative_path = self._relative_path(full_path)
current_size = self._get_project_size() if PROJECT_MAX_STORAGE_BYTES and self._is_docker_mode():
existing_size = full_path.stat().st_size if full_path.exists() else 0 current_size = self._get_project_size()
if mode == "a": existing_size = full_path.stat().st_size if full_path.exists() else 0
projected_total = current_size + len(content) if mode == "a":
else: projected_total = current_size + len(content)
projected_total = current_size - existing_size + len(content) else:
if PROJECT_MAX_STORAGE_BYTES and projected_total > PROJECT_MAX_STORAGE_BYTES: projected_total = current_size - existing_size + len(content)
return { if projected_total > PROJECT_MAX_STORAGE_BYTES:
"success": False, return {
"error": "写入失败:超出项目磁盘配额", "success": False,
"limit_bytes": PROJECT_MAX_STORAGE_BYTES, "error": "写入失败:超出项目磁盘配额",
"project_size_bytes": current_size, "limit_bytes": PROJECT_MAX_STORAGE_BYTES,
"attempt_size_bytes": len(content) "project_size_bytes": current_size,
} "attempt_size_bytes": len(content)
}
if self._use_container(): if self._use_container():
result = self._container_call("write_file", { result = self._container_call("write_file", {
"path": relative_path, "path": relative_path,

View File

@ -2366,8 +2366,9 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
sender('update_action', update_payload) sender('update_action', update_payload)
if function_name in ['create_file', 'delete_file', 'rename_file', 'create_folder']: if function_name in ['create_file', 'delete_file', 'rename_file', 'create_folder']:
structure = web_terminal.context_manager.get_project_structure() if not web_terminal.context_manager._is_host_mode_without_safety():
sender('file_tree_update', structure) structure = web_terminal.context_manager.get_project_structure()
sender('file_tree_update', structure)
# ===== 增量保存:立即保存工具结果 ===== # ===== 增量保存:立即保存工具结果 =====
metadata_payload = None metadata_payload = None

View File

@ -2280,8 +2280,9 @@ async def handle_task_with_sender(terminal: WebTerminal, workspace: UserWorkspac
sender('update_action', update_payload) sender('update_action', update_payload)
if function_name in ['create_file', 'delete_file', 'rename_file', 'create_folder']: if function_name in ['create_file', 'delete_file', 'rename_file', 'create_folder']:
structure = web_terminal.context_manager.get_project_structure() if not web_terminal.context_manager._is_host_mode_without_safety():
sender('file_tree_update', structure) structure = web_terminal.context_manager.get_project_structure()
sender('file_tree_update', structure)
# ===== 增量保存:立即保存工具结果 ===== # ===== 增量保存:立即保存工具结果 =====
metadata_payload = None metadata_payload = None

View File

@ -869,7 +869,8 @@ const appOptions = {
fileSetTreeFromResponse: 'setFileTreeFromResponse', fileSetTreeFromResponse: 'setFileTreeFromResponse',
fileFetchTodoList: 'fetchTodoList', fileFetchTodoList: 'fetchTodoList',
fileSetTodoList: 'setTodoList', fileSetTodoList: 'setTodoList',
fileHideContextMenu: 'hideContextMenu' fileHideContextMenu: 'hideContextMenu',
fileMarkTreeUnavailable: 'markFileTreeUnavailable'
}), }),
...mapActions(useMonitorStore, { ...mapActions(useMonitorStore, {
monitorSyncDesktop: 'syncDesktopFromTree', monitorSyncDesktop: 'syncDesktopFromTree',
@ -1455,11 +1456,6 @@ const appOptions = {
try { try {
debugLog('加载初始数据...'); debugLog('加载初始数据...');
// 并行拉取文件/待办,优先获取状态以尽快同步运行模式
const treePromise = this.fileFetchTree();
const focusPromise = this.focusFetchFiles();
const todoPromise = this.fileFetchTodoList();
const statusResponse = await fetch('/api/status'); const statusResponse = await fetch('/api/status');
const statusData = await statusResponse.json(); const statusData = await statusResponse.json();
this.projectPath = statusData.project_path || ''; this.projectPath = statusData.project_path || '';
@ -1473,6 +1469,16 @@ const appOptions = {
await policyStore.fetchPolicy(); await policyStore.fetchPolicy();
this.applyPolicyUiLocks(); this.applyPolicyUiLocks();
const focusPromise = this.focusFetchFiles();
const todoPromise = this.fileFetchTodoList();
let treePromise: Promise<any> | null = null;
const isHostMode = statusData?.container?.mode === 'host';
if (isHostMode) {
this.fileMarkTreeUnavailable('宿主机模式下文件树不可用');
} else {
treePromise = this.fileFetchTree();
}
// 获取当前对话信息 // 获取当前对话信息
const statusConversationId = statusData.conversation && statusData.conversation.current_id; const statusConversationId = statusData.conversation && statusData.conversation.current_id;
if (statusConversationId && !this.currentConversationId) { if (statusConversationId && !this.currentConversationId) {
@ -1521,7 +1527,11 @@ const appOptions = {
} }
// 等待其他加载项完成(允许部分失败不阻塞模式切换) // 等待其他加载项完成(允许部分失败不阻塞模式切换)
await Promise.allSettled([treePromise, focusPromise, todoPromise]); const pendingPromises = [focusPromise, todoPromise];
if (treePromise) {
pendingPromises.push(treePromise);
}
await Promise.allSettled(pendingPromises);
await this.loadToolSettings(true); await this.loadToolSettings(true);
debugLog('初始数据加载完成'); debugLog('初始数据加载完成');

View File

@ -118,14 +118,18 @@
</div> </div>
</div> </div>
<div v-else class="file-tree" @contextmenu.prevent> <div v-else class="file-tree" @contextmenu.prevent>
<FileNode <div v-if="fileTreeUnavailable" class="file-tree-empty">{{ fileTreeMessage }}</div>
v-for="node in fileTree" <template v-else>
:key="node.path" <div v-if="!fileTree.length" class="file-tree-empty">暂无文件</div>
:node="node" <FileNode
:level="0" v-for="node in fileTree"
:expanded-folders="expandedFolders" :key="node.path"
:icon-style="iconStyle" :node="node"
/> :level="0"
:expanded-folders="expandedFolders"
:icon-style="iconStyle"
/>
</template>
</div> </div>
</div> </div>
</div> </div>
@ -218,7 +222,7 @@ const modeIndicatorTitle = computed(() => {
}); });
const fileStore = useFileStore(); const fileStore = useFileStore();
const subAgentStore = useSubAgentStore(); const subAgentStore = useSubAgentStore();
const { fileTree, expandedFolders, todoList } = storeToRefs(fileStore); const { fileTree, expandedFolders, todoList, fileTreeUnavailable, fileTreeMessage } = storeToRefs(fileStore);
const { subAgents } = storeToRefs(subAgentStore); const { subAgents } = storeToRefs(subAgentStore);
const openSubAgent = (agent: any) => { const openSubAgent = (agent: any) => {

View File

@ -38,6 +38,8 @@ interface FileState {
fileTree: FileNode[]; fileTree: FileNode[];
expandedFolders: Record<string, boolean>; expandedFolders: Record<string, boolean>;
todoList: TodoList | null; todoList: TodoList | null;
fileTreeUnavailable: boolean;
fileTreeMessage: string;
contextMenu: ContextMenuState; contextMenu: ContextMenuState;
} }
@ -76,6 +78,8 @@ export const useFileStore = defineStore('file', {
fileTree: [], fileTree: [],
expandedFolders: {}, expandedFolders: {},
todoList: null, todoList: null,
fileTreeUnavailable: false,
fileTreeMessage: '',
contextMenu: { contextMenu: {
visible: false, visible: false,
x: 0, x: 0,
@ -94,16 +98,32 @@ export const useFileStore = defineStore('file', {
console.error('获取文件树失败:', error); console.error('获取文件树失败:', error);
} }
}, },
markFileTreeUnavailable(message: string) {
this.fileTreeUnavailable = true;
this.fileTreeMessage = message || '宿主机模式下文件树不可用';
this.fileTree = [];
this.expandedFolders = {};
},
setFileTreeFromResponse(payload: any) { setFileTreeFromResponse(payload: any) {
if (!payload) { if (!payload) {
console.warn('[FileTree] 空 payload'); console.warn('[FileTree] 空 payload');
return; return;
} }
if (payload.unavailable) {
this.markFileTreeUnavailable(payload.message || payload.error);
return;
}
const structure = payload.structure || payload; const structure = payload.structure || payload;
if (structure && structure.unavailable) {
this.markFileTreeUnavailable(structure.message || structure.error);
return;
}
if (!structure || !structure.tree) { if (!structure || !structure.tree) {
console.warn('[FileTree] 缺少 structure.tree', structure); console.warn('[FileTree] 缺少 structure.tree', structure);
return; return;
} }
this.fileTreeUnavailable = false;
this.fileTreeMessage = '';
const nodes = buildNodes(structure.tree); const nodes = buildNodes(structure.tree);
const expanded = { ...this.expandedFolders }; const expanded = { ...this.expandedFolders };
const validFolderPaths = new Set<string>(); const validFolderPaths = new Set<string>();

View File

@ -442,7 +442,8 @@
} }
.todo-empty, .todo-empty,
.sub-agent-empty { .sub-agent-empty,
.file-tree-empty {
font-size: 14px; font-size: 14px;
color: var(--claude-text-secondary); color: var(--claude-text-secondary);
padding: 12px; padding: 12px;

View File

@ -439,7 +439,7 @@ class ContextManager:
meta = self.conversation_metadata or {} meta = self.conversation_metadata or {}
stored_tree = meta.get("project_file_tree") stored_tree = meta.get("project_file_tree")
if stored_tree: if stored_tree and stored_tree != "宿主机模式下文件树不可用":
self.project_snapshot = { self.project_snapshot = {
"file_tree": stored_tree, "file_tree": stored_tree,
"statistics": meta.get("project_statistics"), "statistics": meta.get("project_statistics"),
@ -448,7 +448,7 @@ class ContextManager:
return self.project_snapshot return self.project_snapshot
# 首次生成并缓存 # 首次生成并缓存
structure = self.get_project_structure() structure = self._get_project_structure_for_prompt()
snapshot = { snapshot = {
"file_tree": self._build_file_tree(structure), "file_tree": self._build_file_tree(structure),
"statistics": { "statistics": {
@ -470,6 +470,63 @@ class ContextManager:
self.conversation_metadata["project_statistics"] = snapshot["statistics"] self.conversation_metadata["project_statistics"] = snapshot["statistics"]
self.conversation_metadata["project_snapshot_at"] = snapshot["snapshot_at"] self.conversation_metadata["project_snapshot_at"] = snapshot["snapshot_at"]
return snapshot return snapshot
def _get_project_structure_for_prompt(self, limit: int = 20) -> Dict:
"""获取用于 prompt 的浅层文件结构(仅根目录,优先文件夹)。"""
structure = {
"path": str(self.project_path),
"files": [],
"folders": [],
"total_files": 0,
"total_size": 0,
"tree": {}
}
if not self.project_path.exists():
return structure
try:
entries = [p for p in self.project_path.iterdir() if not p.name.startswith('.')]
except PermissionError:
return structure
folders = [p for p in entries if p.is_dir()]
files = [p for p in entries if p.is_file()]
folders.sort(key=lambda p: p.name.lower())
files.sort(key=lambda p: p.name.lower())
selected = (folders + files)[:max(0, limit)]
for entry in selected:
relative_path = str(entry.relative_to(self.project_path))
if entry.is_dir():
structure["folders"].append({
"name": entry.name,
"path": relative_path
})
structure["tree"][entry.name] = {
"type": "folder",
"path": relative_path,
"children": {}
}
else:
try:
size = entry.stat().st_size
except OSError:
size = 0
file_info = {
"name": entry.name,
"path": relative_path,
"size": size,
"annotation": self.file_annotations.get(relative_path, "")
}
structure["files"].append(file_info)
structure["total_files"] += 1
structure["total_size"] += size
structure["tree"][entry.name] = {
"type": "file",
"path": relative_path,
"size": size,
"annotation": file_info["annotation"]
}
return structure
def save_current_conversation(self) -> bool: def save_current_conversation(self) -> bool:
""" """
@ -928,6 +985,11 @@ class ContextManager:
"total_size": 0, "total_size": 0,
"tree": {} # 新增:树形结构数据 "tree": {} # 新增:树形结构数据
} }
if self._is_host_mode_without_safety():
structure["unavailable"] = True
structure["message"] = "宿主机模式下文件树不可用"
return structure
# 记录实际存在的文件 # 记录实际存在的文件
existing_files = set() existing_files = set()
@ -1415,7 +1477,11 @@ class ContextManager:
container_memory=self.container_memory_limit, container_memory=self.container_memory_limit,
project_storage=self.project_storage_limit, project_storage=self.project_storage_limit,
current_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), current_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
file_tree=context["project_info"]["file_tree"], file_tree=(
"(以下为工作区根目录的部分文件和文件夹)\n" + context["project_info"]["file_tree"]
if context["project_info"].get("file_tree")
else ""
),
memory=context["memory"], memory=context["memory"],
) )