diff --git a/docker-compose.yml b/docker-compose.yml index 09931b2b..08029b74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -141,6 +141,8 @@ services: - ./saves:/app/saves - ./docker/sandbox_provisioner/app.py:/app/app.py:ro - /var/run/docker.sock:/var/run/docker.sock + ports: + - "8002:8002" networks: - app-network extra_hosts: diff --git a/server/main.py b/server/main.py index f265b51f..1f44fd64 100644 --- a/server/main.py +++ b/server/main.py @@ -11,6 +11,7 @@ if sys.platform == "win32": sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) +import time from collections import defaultdict, deque import uvicorn diff --git a/server/routers/chat_router.py b/server/routers/chat_router.py index 44646857..1cc0e6a7 100644 --- a/server/routers/chat_router.py +++ b/server/routers/chat_router.py @@ -516,6 +516,7 @@ async def resume_agent_chat( db: AsyncSession = Depends(get_db), ): """恢复被人工审批中断的对话(需要登录)""" + def normalize_resume_input(raw_answer: Any, raw_approved: bool | None) -> Any: if raw_answer is not None: if isinstance(raw_answer, str): @@ -874,6 +875,7 @@ async def delete_thread_attachment( async def list_thread_files( thread_id: str, path: str = Query("/mnt/user-data"), + recursive: bool = Query(False), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_required_user), ): @@ -883,6 +885,7 @@ async def list_thread_files( current_user_id=str(current_user.id), db=db, path=path, + recursive=recursive, ) @@ -923,11 +926,7 @@ async def get_thread_artifact( ) media_type = guess_type(file_path.name)[0] or "application/octet-stream" - headers = ( - {"Content-Disposition": f'attachment; filename="{file_path.name}"'} - if download - else None - ) + headers = {"Content-Disposition": f'attachment; filename="{file_path.name}"'} if download else None return FileResponse(path=file_path, media_type=media_type, headers=headers) diff --git a/src/services/thread_files_service.py b/src/services/thread_files_service.py index e7344729..8c216312 100644 --- a/src/services/thread_files_service.py +++ b/src/services/thread_files_service.py @@ -34,6 +34,7 @@ async def list_thread_files_view( current_user_id: str, db, path: str | None = None, + recursive: bool = False, ) -> dict: conv_repo = ConversationRepository(db) await require_user_conversation(conv_repo, thread_id, str(current_user_id)) @@ -50,6 +51,9 @@ async def list_thread_files_view( if not actual_path.is_dir(): raise HTTPException(status_code=400, detail="path must be a directory") + if recursive: + return _list_files_recursive(thread_id, actual_path, virtual_path) + entries: list[dict[str, Any]] = [] for child in sorted(actual_path.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())): stat = child.stat() @@ -70,6 +74,35 @@ async def list_thread_files_view( return {"path": virtual_path, "files": entries} +def _list_files_recursive(thread_id: str, actual_path: Path, virtual_path: str) -> dict: + entries: list[dict[str, Any]] = [] + + def _scan_dir(base_actual_path: Path, base_virtual_path: str): + try: + for child in sorted(base_actual_path.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower())): + stat = child.stat() + child_virtual_path = virtual_path_for_thread_file(thread_id, child) + entries.append( + { + "path": child_virtual_path, + "name": child.name, + "is_dir": child.is_dir(), + "size": stat.st_size if child.is_file() else 0, + "modified_at": _to_iso8601(stat.st_mtime), + "artifact_url": None + if child.is_dir() + else f"/api/chat/thread/{thread_id}/artifacts/{child_virtual_path.lstrip('/')}", + } + ) + if child.is_dir(): + _scan_dir(child, child_virtual_path) + except PermissionError: + pass + + _scan_dir(actual_path, virtual_path) + return {"path": virtual_path, "files": entries} + + async def read_thread_file_content_view( *, thread_id: str, diff --git a/uv.lock b/uv.lock index ab7edcff..da2ccc26 100644 --- a/uv.lock +++ b/uv.lock @@ -3402,6 +3402,9 @@ wheels = [ binary = [ { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, ] +pool = [ + { name = "psycopg-pool" }, +] [[package]] name = "psycopg-binary" @@ -5653,7 +5656,7 @@ wheels = [ [[package]] name = "yuxi-know" -version = "0.5.1" +version = "0.5.2" source = { virtual = "." } dependencies = [ { name = "agent-sandbox" }, @@ -5697,7 +5700,7 @@ dependencies = [ { name = "openai" }, { name = "opencv-python-headless" }, { name = "pillow" }, - { name = "psycopg", extra = ["binary"] }, + { name = "psycopg", extra = ["binary", "pool"] }, { name = "pyjwt" }, { name = "pymilvus" }, { name = "pymupdf" }, @@ -5782,7 +5785,7 @@ requires-dist = [ { name = "openai", specifier = ">=1.109" }, { name = "opencv-python-headless", specifier = ">=4.11.0.86" }, { name = "pillow", specifier = ">=10.5.0" }, - { name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" }, + { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.3" }, { name = "pyjwt", specifier = ">=2.8.0" }, { name = "pymilvus", specifier = ">=2.5.8" }, { name = "pymupdf", specifier = ">=1.25.5" }, diff --git a/web/src/apis/agent_api.js b/web/src/apis/agent_api.js index 3a2aa08c..ca78525f 100644 --- a/web/src/apis/agent_api.js +++ b/web/src/apis/agent_api.js @@ -340,10 +340,11 @@ export const threadApi = { * 列出线程文件(目录) * @param {string} threadId * @param {string} path + * @param {boolean} recursive * @returns {Promise} */ - listThreadFiles: (threadId, path = '/mnt/user-data') => - apiGet(`/api/chat/thread/${threadId}/files?path=${encodeURIComponent(path)}`), + listThreadFiles: (threadId, path = '/mnt/user-data', recursive = false) => + apiGet(`/api/chat/thread/${threadId}/files?path=${encodeURIComponent(path)}&recursive=${recursive}`), /** * 读取线程文本文件内容(分页) diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index a41f3c2e..a5b2c56a 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -758,26 +758,9 @@ const fetchThreadMessages = async ({ agentId, threadId, delay = 0 }) => { const fetchThreadFiles = async (threadId) => { if (!threadId) return - const queue = ['/mnt/user-data'] - const visited = new Set() - const entries = [] - try { - while (queue.length > 0) { - const currentPath = queue.shift() - if (!currentPath || visited.has(currentPath)) continue - visited.add(currentPath) - - const response = await threadApi.listThreadFiles(threadId, currentPath) - const files = Array.isArray(response?.files) ? response.files : [] - files.forEach((item) => { - if (!item || typeof item.path !== 'string') return - entries.push(item) - if (item.is_dir === true) { - queue.push(item.path) - } - }) - } + const response = await threadApi.listThreadFiles(threadId, '/mnt/user-data', true) + const entries = Array.isArray(response?.files) ? response.files : [] threadFilesMap.value[threadId] = entries } catch (error) { console.warn('Failed to fetch thread files:', error)