From e356b3fcec0854aa4c10f63c956e9a79d986fcf8 Mon Sep 17 00:00:00 2001 From: ztxiao <1165506270@qq.com> Date: Wed, 4 Mar 2026 08:53:50 +0800 Subject: [PATCH] feat: integrate provisioner sandbox and shared thread file APIs --- .env.template | 18 + docker-compose.prod.yml | 50 +- docker-compose.yml | 50 +- docker/sandbox_provisioner/Dockerfile | 15 + docker/sandbox_provisioner/app.py | 336 +++++++++++ docker/sandbox_provisioner/requirements.txt | 3 + pyproject.toml | 3 + server/routers/chat_router.py | 97 +++- server/utils/lifespan.py | 7 + src/agents/common/backends/composite.py | 26 +- src/agents/reporter/graph.py | 59 +- src/config/app.py | 597 +++++++------------- src/sandbox/__init__.py | 35 ++ src/sandbox/backend.py | 208 +++++++ src/sandbox/paths.py | 88 +++ src/sandbox/provider.py | 131 +++++ src/sandbox/provisioner_client.py | 73 +++ src/services/conversation_service.py | 142 +++-- src/services/thread_files_service.py | 141 +++++ uv.lock | 169 +++++- web/src/apis/agent_api.js | 39 ++ web/src/components/AgentChatComponent.vue | 139 +++-- web/src/components/AgentPanel.vue | 165 ++---- 23 files changed, 1926 insertions(+), 665 deletions(-) create mode 100644 docker/sandbox_provisioner/Dockerfile create mode 100644 docker/sandbox_provisioner/app.py create mode 100644 docker/sandbox_provisioner/requirements.txt create mode 100644 src/sandbox/__init__.py create mode 100644 src/sandbox/backend.py create mode 100644 src/sandbox/paths.py create mode 100644 src/sandbox/provider.py create mode 100644 src/sandbox/provisioner_client.py create mode 100644 src/services/thread_files_service.py diff --git a/.env.template b/.env.template index e70ed3d6..9115d6f7 100644 --- a/.env.template +++ b/.env.template @@ -6,6 +6,24 @@ RUN_CANCEL_KEY_TTL_SECONDS=1800 LANGGRAPH_CHECKPOINTER_BACKEND=postgres VITE_USE_RUNS_API=false +# Sandbox (deerFlow-style provisioner) +SANDBOX_PROVIDER=provisioner +SANDBOX_PROVISIONER_URL=http://sandbox-provisioner:8002 +SANDBOX_VIRTUAL_PATH_PREFIX=/mnt/user-data +SANDBOX_EXEC_TIMEOUT_SECONDS=180 +SANDBOX_MAX_OUTPUT_BYTES=262144 +# sandbox-provisioner backend: memory | kubernetes +SANDBOX_PROVISIONER_BACKEND=memory +# K8s provisioner options (used when SANDBOX_PROVISIONER_BACKEND=kubernetes) +# SANDBOX_K8S_NAMESPACE=yuxi-know +# SANDBOX_IMAGE=ghcr.io/bytedance/deer-flow-sandbox:latest +# SANDBOX_SKILLS_HOST_PATH=/app/saves/skills +# SANDBOX_THREADS_HOST_PATH=/app/saves/threads +# SANDBOX_NODE_HOST=host.docker.internal +# KUBECONFIG_PATH=/root/.kube/config +# Memory backend sandbox url template, supports {sandbox_id} +# MEMORY_SANDBOX_URL_TEMPLATE=http://agent-sandbox:8000 + # region model_provider SILICONFLOW_API_KEY= # 推荐使用硅基流动免费服务 https://cloud.siliconflow.cn/i/Eo5yTHGJ TAVILY_API_KEY= # 获取搜索服务的 api key 请访问 https://app.tavily.com/ diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4023ae8f..c6f18f47 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -32,8 +32,13 @@ services: - MINIO_URI=${MINIO_URI:-http://milvus-minio:9000} - MODEL_DIR_IN_DOCKER=/models - RUNNING_IN_DOCKER=true - - NO_PROXY=localhost,127.0.0.1,milvus,graph,milvus-minio,milvus-etcd,etcd,minio,mineru,paddlex,api.siliconflow.cn - - no_proxy=localhost,127.0.0.1,milvus,graph,milvus-minio,milvus-etcd,etcd,minio,mineru,paddlex,api.siliconflow.cn + - SANDBOX_PROVIDER=${SANDBOX_PROVIDER:-provisioner} + - SANDBOX_PROVISIONER_URL=${SANDBOX_PROVISIONER_URL:-http://sandbox-provisioner:8002} + - SANDBOX_VIRTUAL_PATH_PREFIX=${SANDBOX_VIRTUAL_PATH_PREFIX:-/mnt/user-data} + - SANDBOX_EXEC_TIMEOUT_SECONDS=${SANDBOX_EXEC_TIMEOUT_SECONDS:-180} + - SANDBOX_MAX_OUTPUT_BYTES=${SANDBOX_MAX_OUTPUT_BYTES:-262144} + - NO_PROXY=localhost,127.0.0.1,milvus,graph,milvus-minio,milvus-etcd,etcd,minio,mineru,paddlex,sandbox-provisioner,api.siliconflow.cn + - no_proxy=localhost,127.0.0.1,milvus,graph,milvus-minio,milvus-etcd,etcd,minio,mineru,paddlex,sandbox-provisioner,api.siliconflow.cn command: uv run --no-dev uvicorn server.main:app --host 0.0.0.0 --port 5050 restart: unless-stopped healthcheck: @@ -51,6 +56,8 @@ services: condition: service_healthy minio: condition: service_healthy + sandbox-provisioner: + condition: service_healthy worker: build: @@ -85,8 +92,13 @@ services: - MINIO_URI=${MINIO_URI:-http://milvus-minio:9000} - MODEL_DIR_IN_DOCKER=/models - RUNNING_IN_DOCKER=true - - NO_PROXY=localhost,127.0.0.1,milvus,graph,milvus-minio,milvus-etcd,etcd,minio,mineru,paddlex,api.siliconflow.cn - - no_proxy=localhost,127.0.0.1,milvus,graph,milvus-minio,milvus-etcd,etcd,minio,mineru,paddlex,api.siliconflow.cn + - SANDBOX_PROVIDER=${SANDBOX_PROVIDER:-provisioner} + - SANDBOX_PROVISIONER_URL=${SANDBOX_PROVISIONER_URL:-http://sandbox-provisioner:8002} + - SANDBOX_VIRTUAL_PATH_PREFIX=${SANDBOX_VIRTUAL_PATH_PREFIX:-/mnt/user-data} + - SANDBOX_EXEC_TIMEOUT_SECONDS=${SANDBOX_EXEC_TIMEOUT_SECONDS:-180} + - SANDBOX_MAX_OUTPUT_BYTES=${SANDBOX_MAX_OUTPUT_BYTES:-262144} + - NO_PROXY=localhost,127.0.0.1,milvus,graph,milvus-minio,milvus-etcd,etcd,minio,mineru,paddlex,sandbox-provisioner,api.siliconflow.cn + - no_proxy=localhost,127.0.0.1,milvus,graph,milvus-minio,milvus-etcd,etcd,minio,mineru,paddlex,sandbox-provisioner,api.siliconflow.cn command: uv run --no-dev arq server.worker_main.WorkerSettings restart: unless-stopped depends_on: @@ -98,6 +110,36 @@ services: condition: service_healthy minio: condition: service_healthy + sandbox-provisioner: + condition: service_healthy + + sandbox-provisioner: + build: + context: ./docker/sandbox_provisioner + dockerfile: Dockerfile + container_name: sandbox-provisioner + volumes: + - ./saves:/app/saves + networks: + - app-network + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + - PROVISIONER_BACKEND=${SANDBOX_PROVISIONER_BACKEND:-memory} + - K8S_NAMESPACE=${SANDBOX_K8S_NAMESPACE:-yuxi-know} + - SANDBOX_IMAGE=${SANDBOX_IMAGE:-ghcr.io/bytedance/deer-flow-sandbox:latest} + - SKILLS_HOST_PATH=${SANDBOX_SKILLS_HOST_PATH:-/app/saves/skills} + - THREADS_HOST_PATH=${SANDBOX_THREADS_HOST_PATH:-/app/saves/threads} + - NODE_HOST=${SANDBOX_NODE_HOST:-host.docker.internal} + - KUBECONFIG_PATH=${KUBECONFIG_PATH:-} + - MEMORY_SANDBOX_URL_TEMPLATE=${MEMORY_SANDBOX_URL_TEMPLATE:-http://agent-sandbox:8000} + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8002/health').read()"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 10s + restart: unless-stopped web: build: diff --git a/docker-compose.yml b/docker-compose.yml index 46b03b0f..3648323b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,8 +41,13 @@ services: - MINIO_URI=${MINIO_URI:-http://milvus-minio:9000} - MODEL_DIR_IN_DOCKER=/models - RUNNING_IN_DOCKER=true - - NO_PROXY=localhost,127.0.0.1,milvus,graph,milvus-minio,milvus-etcd-dev,etcd,minio,mineru,paddlex,api.siliconflow.cn - - no_proxy=localhost,127.0.0.1,milvus,graph,milvus-minio,milvus-etcd-dev,etcd,minio,mineru,paddlex,api.siliconflow.cn + - SANDBOX_PROVIDER=${SANDBOX_PROVIDER:-provisioner} + - SANDBOX_PROVISIONER_URL=${SANDBOX_PROVISIONER_URL:-http://sandbox-provisioner:8002} + - SANDBOX_VIRTUAL_PATH_PREFIX=${SANDBOX_VIRTUAL_PATH_PREFIX:-/mnt/user-data} + - SANDBOX_EXEC_TIMEOUT_SECONDS=${SANDBOX_EXEC_TIMEOUT_SECONDS:-180} + - SANDBOX_MAX_OUTPUT_BYTES=${SANDBOX_MAX_OUTPUT_BYTES:-262144} + - NO_PROXY=localhost,127.0.0.1,milvus,graph,milvus-minio,milvus-etcd-dev,etcd,minio,mineru,paddlex,sandbox-provisioner,api.siliconflow.cn + - no_proxy=localhost,127.0.0.1,milvus,graph,milvus-minio,milvus-etcd-dev,etcd,minio,mineru,paddlex,sandbox-provisioner,api.siliconflow.cn # endregion api_envs command: uv run --no-dev uvicorn server.main:app --host 0.0.0.0 --port 5050 --reload restart: unless-stopped @@ -61,6 +66,8 @@ services: condition: service_healthy minio: condition: service_healthy + sandbox-provisioner: + condition: service_healthy worker: build: @@ -103,8 +110,13 @@ services: - MINIO_URI=${MINIO_URI:-http://milvus-minio:9000} - MODEL_DIR_IN_DOCKER=/models - RUNNING_IN_DOCKER=true - - NO_PROXY=localhost,127.0.0.1,milvus,graph,milvus-minio,milvus-etcd-dev,etcd,minio,mineru,paddlex,api.siliconflow.cn - - no_proxy=localhost,127.0.0.1,milvus,graph,milvus-minio,milvus-etcd-dev,etcd,minio,mineru,paddlex,api.siliconflow.cn + - SANDBOX_PROVIDER=${SANDBOX_PROVIDER:-provisioner} + - SANDBOX_PROVISIONER_URL=${SANDBOX_PROVISIONER_URL:-http://sandbox-provisioner:8002} + - SANDBOX_VIRTUAL_PATH_PREFIX=${SANDBOX_VIRTUAL_PATH_PREFIX:-/mnt/user-data} + - SANDBOX_EXEC_TIMEOUT_SECONDS=${SANDBOX_EXEC_TIMEOUT_SECONDS:-180} + - SANDBOX_MAX_OUTPUT_BYTES=${SANDBOX_MAX_OUTPUT_BYTES:-262144} + - NO_PROXY=localhost,127.0.0.1,milvus,graph,milvus-minio,milvus-etcd-dev,etcd,minio,mineru,paddlex,sandbox-provisioner,api.siliconflow.cn + - no_proxy=localhost,127.0.0.1,milvus,graph,milvus-minio,milvus-etcd-dev,etcd,minio,mineru,paddlex,sandbox-provisioner,api.siliconflow.cn command: uv run --no-dev arq server.worker_main.WorkerSettings restart: unless-stopped depends_on: @@ -116,6 +128,36 @@ services: condition: service_healthy minio: condition: service_healthy + sandbox-provisioner: + condition: service_healthy + + sandbox-provisioner: + build: + context: ./docker/sandbox_provisioner + dockerfile: Dockerfile + container_name: sandbox-provisioner + volumes: + - ./saves:/app/saves + networks: + - app-network + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + - PROVISIONER_BACKEND=${SANDBOX_PROVISIONER_BACKEND:-memory} + - K8S_NAMESPACE=${SANDBOX_K8S_NAMESPACE:-yuxi-know} + - SANDBOX_IMAGE=${SANDBOX_IMAGE:-ghcr.io/bytedance/deer-flow-sandbox:latest} + - SKILLS_HOST_PATH=${SANDBOX_SKILLS_HOST_PATH:-/app/saves/skills} + - THREADS_HOST_PATH=${SANDBOX_THREADS_HOST_PATH:-/app/saves/threads} + - NODE_HOST=${SANDBOX_NODE_HOST:-host.docker.internal} + - KUBECONFIG_PATH=${KUBECONFIG_PATH:-} + - MEMORY_SANDBOX_URL_TEMPLATE=${MEMORY_SANDBOX_URL_TEMPLATE:-http://agent-sandbox:8000} + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8002/health').read()"] + interval: 10s + timeout: 5s + retries: 6 + start_period: 10s + restart: unless-stopped web: build: diff --git a/docker/sandbox_provisioner/Dockerfile b/docker/sandbox_provisioner/Dockerfile new file mode 100644 index 00000000..0a37ea7b --- /dev/null +++ b/docker/sandbox_provisioner/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY app.py /app/app.py + +EXPOSE 8002 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8002"] diff --git a/docker/sandbox_provisioner/app.py b/docker/sandbox_provisioner/app.py new file mode 100644 index 00000000..2c8887be --- /dev/null +++ b/docker/sandbox_provisioner/app.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +import os +import threading +from dataclasses import dataclass + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel + + +class CreateSandboxRequest(BaseModel): + sandbox_id: str + thread_id: str + + +class SandboxResponse(BaseModel): + sandbox_id: str + sandbox_url: str + status: str | None = None + + +class DeleteSandboxResponse(BaseModel): + ok: bool + sandbox_id: str + + +class ListSandboxesResponse(BaseModel): + sandboxes: list[SandboxResponse] + count: int + + +@dataclass(slots=True) +class SandboxRecord: + sandbox_id: str + sandbox_url: str + status: str | None = None + + +class MemoryProvisionerBackend: + def __init__(self): + self._lock = threading.Lock() + self._records: dict[str, SandboxRecord] = {} + self._url_template = os.getenv("MEMORY_SANDBOX_URL_TEMPLATE", "http://agent-sandbox:8000") + + def _url_for(self, sandbox_id: str) -> str: + template = self._url_template + if "{sandbox_id}" in template: + return template.format(sandbox_id=sandbox_id) + return template + + def create(self, sandbox_id: str, thread_id: str) -> SandboxRecord: + del thread_id + with self._lock: + existing = self._records.get(sandbox_id) + if existing is not None: + return existing + record = SandboxRecord( + sandbox_id=sandbox_id, + sandbox_url=self._url_for(sandbox_id), + status="Running", + ) + self._records[sandbox_id] = record + return record + + def discover(self, sandbox_id: str) -> SandboxRecord | None: + with self._lock: + return self._records.get(sandbox_id) + + def list(self) -> list[SandboxRecord]: + with self._lock: + return list(self._records.values()) + + def delete(self, sandbox_id: str) -> None: + with self._lock: + self._records.pop(sandbox_id, None) + + +class KubernetesProvisionerBackend: + def __init__(self): + from kubernetes import client, config + + self._lock = threading.Lock() + self._namespace = os.getenv("K8S_NAMESPACE", "yuxi-know") + self._sandbox_image = os.getenv("SANDBOX_IMAGE", "ghcr.io/bytedance/deer-flow-sandbox:latest") + self._skills_host_path = os.getenv("SKILLS_HOST_PATH", "/app/skills") + self._threads_host_path = os.getenv("THREADS_HOST_PATH", "/app/saves/threads") + self._node_host = os.getenv("NODE_HOST", "host.docker.internal") + self._container_port = int(os.getenv("SANDBOX_CONTAINER_PORT", "8000")) + + kubeconfig_path = os.getenv("KUBECONFIG_PATH") + if kubeconfig_path: + config.load_kube_config(config_file=kubeconfig_path) + else: + try: + config.load_incluster_config() + except Exception: + config.load_kube_config() + + self._core_api = client.CoreV1Api() + self._client = client + + @staticmethod + def _pod_name(sandbox_id: str) -> str: + return f"sandbox-{sandbox_id}" + + @staticmethod + def _service_name(sandbox_id: str) -> str: + return f"sandbox-{sandbox_id}" + + def _build_pod_spec(self, sandbox_id: str, thread_id: str): + pod_name = self._pod_name(sandbox_id) + user_data_path = f"{self._threads_host_path.rstrip('/')}/{thread_id}/user-data" + return self._client.V1Pod( + metadata=self._client.V1ObjectMeta( + name=pod_name, + labels={"app": "yuxi-sandbox", "sandbox-id": sandbox_id}, + annotations={"thread-id": thread_id}, + ), + spec=self._client.V1PodSpec( + restart_policy="Never", + containers=[ + self._client.V1Container( + name="sandbox", + image=self._sandbox_image, + ports=[self._client.V1ContainerPort(container_port=self._container_port)], + volume_mounts=[ + self._client.V1VolumeMount(name="user-data", mount_path="/mnt/user-data"), + self._client.V1VolumeMount(name="skills", mount_path="/mnt/skills", read_only=True), + ], + ) + ], + volumes=[ + self._client.V1Volume( + name="user-data", + host_path=self._client.V1HostPathVolumeSource(path=user_data_path, type="DirectoryOrCreate"), + ), + self._client.V1Volume( + name="skills", + host_path=self._client.V1HostPathVolumeSource(path=self._skills_host_path, type="Directory"), + ), + ], + ), + ) + + def _build_service_spec(self, sandbox_id: str): + service_name = self._service_name(sandbox_id) + return self._client.V1Service( + metadata=self._client.V1ObjectMeta( + name=service_name, + labels={"app": "yuxi-sandbox", "sandbox-id": sandbox_id}, + ), + spec=self._client.V1ServiceSpec( + type="NodePort", + selector={"sandbox-id": sandbox_id}, + ports=[ + self._client.V1ServicePort( + name="http", + port=self._container_port, + target_port=self._container_port, + protocol="TCP", + ) + ], + ), + ) + + def create(self, sandbox_id: str, thread_id: str) -> SandboxRecord: + from kubernetes.client.rest import ApiException + + with self._lock: + discovered = self.discover(sandbox_id) + if discovered is not None: + return discovered + + pod_name = self._pod_name(sandbox_id) + service_name = self._service_name(sandbox_id) + + try: + self._core_api.create_namespaced_pod( + namespace=self._namespace, + body=self._build_pod_spec(sandbox_id, thread_id), + ) + except ApiException as exc: + if exc.status != 409: + raise + + try: + self._core_api.create_namespaced_service( + namespace=self._namespace, + body=self._build_service_spec(sandbox_id), + ) + except ApiException as exc: + if exc.status != 409: + raise + + record = self.discover(sandbox_id) + if record is None: + raise RuntimeError(f"failed to discover sandbox after create: {sandbox_id}") + return record + + def discover(self, sandbox_id: str) -> SandboxRecord | None: + from kubernetes.client.rest import ApiException + + pod_name = self._pod_name(sandbox_id) + service_name = self._service_name(sandbox_id) + try: + pod = self._core_api.read_namespaced_pod(name=pod_name, namespace=self._namespace) + service = self._core_api.read_namespaced_service(name=service_name, namespace=self._namespace) + except ApiException as exc: + if exc.status == 404: + return None + raise + + node_port = None + if service.spec and service.spec.ports: + node_port = service.spec.ports[0].node_port + if not node_port: + sandbox_url = "" + else: + sandbox_url = f"http://{self._node_host}:{node_port}" + + return SandboxRecord( + sandbox_id=sandbox_id, + sandbox_url=sandbox_url, + status=(pod.status.phase if pod and pod.status else "Unknown"), + ) + + def list(self) -> list[SandboxRecord]: + from kubernetes.client.rest import ApiException + + try: + pod_list = self._core_api.list_namespaced_pod( + namespace=self._namespace, + label_selector="app=yuxi-sandbox", + ) + except ApiException: + return [] + + records: list[SandboxRecord] = [] + for pod in pod_list.items: + sandbox_id = (pod.metadata.labels or {}).get("sandbox-id") + if not sandbox_id: + continue + record = self.discover(sandbox_id) + if record is not None: + records.append(record) + return records + + def delete(self, sandbox_id: str) -> None: + from kubernetes.client.rest import ApiException + + pod_name = self._pod_name(sandbox_id) + service_name = self._service_name(sandbox_id) + + for delete_call in ( + lambda: self._core_api.delete_namespaced_service(name=service_name, namespace=self._namespace), + lambda: self._core_api.delete_namespaced_pod(name=pod_name, namespace=self._namespace), + ): + try: + delete_call() + except ApiException as exc: + if exc.status != 404: + raise + + +def _build_backend(): + backend = (os.getenv("PROVISIONER_BACKEND", "memory") or "memory").strip().lower() + if backend == "kubernetes": + return KubernetesProvisionerBackend(), backend + return MemoryProvisionerBackend(), backend + + +app = FastAPI(title="Yuxi Sandbox Provisioner") +backend_impl, backend_name = _build_backend() + + +@app.get("/health") +def health(): + return {"status": "ok", "backend": backend_name} + + +@app.post("/api/sandboxes", response_model=SandboxResponse) +def create_sandbox(payload: CreateSandboxRequest): + try: + record = backend_impl.create(payload.sandbox_id, payload.thread_id) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=str(exc)) from exc + return SandboxResponse( + sandbox_id=record.sandbox_id, + sandbox_url=record.sandbox_url, + status=record.status, + ) + + +@app.get("/api/sandboxes/{sandbox_id}", response_model=SandboxResponse) +def get_sandbox(sandbox_id: str): + try: + record = backend_impl.discover(sandbox_id) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=str(exc)) from exc + + if record is None: + raise HTTPException(status_code=404, detail="sandbox not found") + + return SandboxResponse( + sandbox_id=record.sandbox_id, + sandbox_url=record.sandbox_url, + status=record.status, + ) + + +@app.get("/api/sandboxes", response_model=ListSandboxesResponse) +def list_sandboxes(): + try: + records = backend_impl.list() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=str(exc)) from exc + + sandboxes = [ + SandboxResponse( + sandbox_id=record.sandbox_id, + sandbox_url=record.sandbox_url, + status=record.status, + ) + for record in records + ] + return ListSandboxesResponse(sandboxes=sandboxes, count=len(sandboxes)) + + +@app.delete("/api/sandboxes/{sandbox_id}", response_model=DeleteSandboxResponse) +def delete_sandbox(sandbox_id: str): + try: + backend_impl.delete(sandbox_id) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=str(exc)) from exc + + return DeleteSandboxResponse(ok=True, sandbox_id=sandbox_id) diff --git a/docker/sandbox_provisioner/requirements.txt b/docker/sandbox_provisioner/requirements.txt new file mode 100644 index 00000000..d8500503 --- /dev/null +++ b/docker/sandbox_provisioner/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.121 +uvicorn[standard]>=0.34.2 +kubernetes>=31.0.0 diff --git a/pyproject.toml b/pyproject.toml index 3585f60c..8de34870 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,12 +63,15 @@ dependencies = [ "arq>=0.26.3", "chardet>=5.0.0", "deepagents>=0.2.5", + "agent-sandbox>=0.0.26", "json-repair>=0.54.0", "torch>=2.8.0", "torchvision==0.23", "docling>=2.68.0", "loguru>=0.7.3", "redis>=5.2.0", + "aioboto3>=13.0.0", + "wcmatch>=8.0.0", ] [tool.ruff] line-length = 120 # 代码最大行宽 diff --git a/server/routers/chat_router.py b/server/routers/chat_router.py index 49d786d9..4153e150 100644 --- a/server/routers/chat_router.py +++ b/server/routers/chat_router.py @@ -1,8 +1,9 @@ import traceback import uuid +from mimetypes import guess_type from fastapi import APIRouter, Body, Depends, HTTPException, Query, UploadFile, File -from fastapi.responses import StreamingResponse +from fastapi.responses import FileResponse, StreamingResponse from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession @@ -29,6 +30,11 @@ from src.services.conversation_service import ( update_thread_view, upload_thread_attachment_view, ) +from src.services.thread_files_service import ( + list_thread_files_view, + read_thread_file_content_view, + resolve_thread_artifact_view, +) from src.services.feedback_service import get_message_feedback_view, submit_message_feedback_view from src.services.history_query_service import get_agent_history_view from src.repositories.agent_config_repository import AgentConfigRepository @@ -674,6 +680,11 @@ class AttachmentResponse(BaseModel): status: str uploaded_at: str truncated: bool | None = False + virtual_path: str | None = None + artifact_url: str | None = None + original_virtual_path: str | None = None + original_artifact_url: str | None = None + minio_url: str | None = None class AttachmentLimits(BaseModel): @@ -686,6 +697,29 @@ class AttachmentListResponse(BaseModel): limits: AttachmentLimits +class ThreadFileEntry(BaseModel): + path: str + name: str + is_dir: bool + size: int + modified_at: str | None = None + artifact_url: str | None = None + + +class ThreadFileListResponse(BaseModel): + path: str + files: list[ThreadFileEntry] + + +class ThreadFileContentResponse(BaseModel): + path: str + content: list[str] + offset: int + limit: int + total_lines: int + artifact_url: str + + # ============================================================================= # > === 会话管理分组 === # ============================================================================= @@ -792,6 +826,67 @@ async def delete_thread_attachment( ) +@chat.get("/thread/{thread_id}/files", response_model=ThreadFileListResponse) +async def list_thread_files( + thread_id: str, + path: str = Query("/mnt/user-data"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_required_user), +): + """列出线程文件目录。""" + return await list_thread_files_view( + thread_id=thread_id, + current_user_id=str(current_user.id), + db=db, + path=path, + ) + + +@chat.get("/thread/{thread_id}/files/content", response_model=ThreadFileContentResponse) +async def read_thread_file_content( + thread_id: str, + path: str = Query(...), + offset: int = Query(0, ge=0), + limit: int = Query(2000, ge=1, le=5000), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_required_user), +): + """读取线程文本文件(按行分页)。""" + return await read_thread_file_content_view( + thread_id=thread_id, + current_user_id=str(current_user.id), + db=db, + path=path, + offset=offset, + limit=limit, + ) + + +@chat.get("/thread/{thread_id}/artifacts/{path:path}") +async def get_thread_artifact( + thread_id: str, + path: str, + download: bool = Query(False), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_required_user), +): + """下载或预览线程文件。""" + file_path = await resolve_thread_artifact_view( + thread_id=thread_id, + current_user_id=str(current_user.id), + db=db, + path=path, + ) + + 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 + ) + return FileResponse(path=file_path, media_type=media_type, headers=headers) + + # ============================================================================= # > === 消息反馈分组 === # ============================================================================= diff --git a/server/utils/lifespan.py b/server/utils/lifespan.py index 42ff8e1d..5fd6d4e5 100644 --- a/server/utils/lifespan.py +++ b/server/utils/lifespan.py @@ -7,6 +7,7 @@ from src.services.mcp_service import init_mcp_servers from src.services.run_queue_service import close_queue_clients, get_redis_client from src.storage.postgres.manager import pg_manager from src.knowledge import knowledge_base +from src.sandbox import init_sandbox_provider, shutdown_sandbox_provider from src.utils import logger @@ -41,8 +42,14 @@ async def lifespan(app: FastAPI): except Exception as e: logger.warning(f"Run queue redis unavailable on startup: {e}") + try: + init_sandbox_provider() + except Exception as e: + logger.error(f"Failed to initialize sandbox provider during startup: {e}") + await tasker.start() yield await tasker.shutdown() + shutdown_sandbox_provider() await close_queue_clients() await pg_manager.close() diff --git a/src/agents/common/backends/composite.py b/src/agents/common/backends/composite.py index d6119471..54d68be6 100644 --- a/src/agents/common/backends/composite.py +++ b/src/agents/common/backends/composite.py @@ -1,5 +1,8 @@ -from deepagents.backends import CompositeBackend, StateBackend +from __future__ import annotations +from deepagents.backends import CompositeBackend + +from src.sandbox import ProvisionerSandboxBackend from src.services.skill_resolver import normalize_selected_skills from src.services.skill_service import is_valid_skill_slug @@ -18,11 +21,28 @@ def _get_visible_skills_from_runtime(runtime) -> list[str]: return normalize_selected_skills(selected) +def _extract_thread_id(runtime) -> str: + config = getattr(runtime, "config", None) + if isinstance(config, dict): + configurable = config.get("configurable", {}) + if isinstance(configurable, dict): + thread_id = configurable.get("thread_id") + if isinstance(thread_id, str) and thread_id.strip(): + return thread_id.strip() + + context = getattr(runtime, "context", None) + thread_id = getattr(context, "thread_id", None) + if isinstance(thread_id, str) and thread_id.strip(): + return thread_id.strip() + + raise ValueError("thread_id is required in runtime configurable context") + + def create_agent_composite_backend(runtime) -> CompositeBackend: - """为 agent 构建 backend:默认 StateBackend + /skills 路由只读 backend。""" visible_skills = _get_visible_skills_from_runtime(runtime) + thread_id = _extract_thread_id(runtime) return CompositeBackend( - default=StateBackend(runtime), + default=ProvisionerSandboxBackend(thread_id=thread_id), routes={ "/skills/": SelectedSkillsReadonlyBackend(selected_slugs=visible_skills), }, diff --git a/src/agents/reporter/graph.py b/src/agents/reporter/graph.py index 5d1731b7..df4ee0d3 100644 --- a/src/agents/reporter/graph.py +++ b/src/agents/reporter/graph.py @@ -1,90 +1,61 @@ from dataclasses import dataclass, field from typing import Annotated -from deepagents.backends import StateBackend from deepagents.middleware.filesystem import FilesystemMiddleware from langchain.agents import create_agent from src.agents.common import BaseAgent, BaseContext, load_chat_model -from src.agents.common.middlewares import ( - RuntimeConfigMiddleware, - save_attachments_to_fs, -) +from src.agents.common.backends import create_agent_composite_backend +from src.agents.common.middlewares import RuntimeConfigMiddleware, save_attachments_to_fs from src.agents.common.toolkits.mysql import get_mysql_tools from src.services.mcp_service import get_mcp_server_names, get_tools_from_all_servers from src.utils import logger def _create_fs_backend(rt): - """创建文件存储后端""" - return StateBackend(rt) + return create_agent_composite_backend(rt) -PROMPT = """你的任务是根据用户的指令,使用数据库工具和图表绘制工具,构建 SQL 查询报告。 -你需要根据用户的指令,生成相应的 SQL 查询,并将查询结果以报表的形式返回给用户。 -在生成报表时,你可以调用工具生成图表,以更直观地展示数据。 -务必确保生成的 SQL 查询是正确且高效的,以避免对数据库造成不必要的负担。 -在生成报表时,请遵循以下原则: -1. 理解用户的指令,明确报表的需求和目标。 -2. 图表生成工具的返回结果不会默认渲染,需要在最终的报表中以图片形式(markdown格式)嵌入。 -3. 必要时,使用网络检索相关工具补充信息。 +PROMPT = """你的任务是根据用户指令,使用数据库工具和图表工具生成 SQL 报告。 +请先理解需求,再给出准确且高效的 SQL 查询;必要时调用图表工具并在最终回答中以 Markdown 图片形式展示结果。 """ @dataclass(kw_only=True) class ReporterContext(BaseContext): - """覆盖 BaseContext,定义数据库报表助手智能体的可配置参数""" - - # 覆盖 system_prompt,提供更具体的默认值 system_prompt: Annotated[str, {"__template_metadata__": {"kind": "prompt"}}] = field( default=PROMPT, - metadata={"name": "系统提示词", "description": "用来描述智能体的角色和行为"}, + metadata={"name": "系统提示词", "description": "描述 SQL 报告助手的行为"}, ) - mcps: Annotated[list[str], {"__template_metadata__": {"kind": "mcps"}}] = field( default_factory=lambda: ["mcp-server-chart"], metadata={ - "name": "MCP服务器", + "name": "MCP 服务", "options": lambda: get_mcp_server_names(), - "description": ( - "MCP服务器列表,建议使用支持 SSE 的 MCP 服务器," - "如果需要使用 uvx 或 npx 运行的服务器,也请在项目外部启动 MCP 服务器,并在项目中配置 MCP 服务器。" - ), + "description": "报告场景默认启用图表 MCP。", }, ) class SqlReporterAgent(BaseAgent): - name = "数据库报表助手" - description = ( - "一个能够生成 SQL 查询报告的智能体助手。同时调用 Charts MCP 生成图表。" - "MySQL 工具默认启用,无法选择,mcp 默认启用 Charts MCPs。" - ) + name = "数据报表助手" + description = "根据用户需求生成 SQL 查询并输出图表化报告。" context_schema = ReporterContext - capabilities = [ - "file_upload", - "files", - ] - - def __init__(self, **kwargs): - super().__init__(**kwargs) + capabilities = ["file_upload", "files"] async def get_graph(self, **kwargs): - """构建图""" context = self.context_schema.from_file(module_name=self.module_name) all_mcp_tools = await get_tools_from_all_servers() - graph = create_agent( model=load_chat_model(context.model), system_prompt=context.system_prompt, - tools=get_mysql_tools(), # MySQL 工具默认启用,这里添加的 tools,不会在工具选择框中出现 + tools=get_mysql_tools(), middleware=[ - FilesystemMiddleware(backend=_create_fs_backend), # 文件系统后端 + FilesystemMiddleware(backend=_create_fs_backend), RuntimeConfigMiddleware(extra_tools=all_mcp_tools), - save_attachments_to_fs, # 附件保存到文件系统 + save_attachments_to_fs, ], checkpointer=await self._get_checkpointer(), ) - - logger.info("SqlReporterAgent 构建成功") + logger.info("SqlReporterAgent graph created") return graph diff --git a/src/config/app.py b/src/config/app.py index e42e243c..200791d7 100644 --- a/src/config/app.py +++ b/src/config/app.py @@ -1,11 +1,6 @@ -""" -应用配置模块 +"""Application configuration.""" -使用 Pydantic BaseModel 实现配置管理,支持: -- 从 TOML 文件加载用户配置 -- 仅保存用户修改过的配置项 -- 默认配置定义在代码中 -""" +from __future__ import annotations import os from pathlib import Path @@ -13,7 +8,7 @@ from typing import Any import tomli import tomli_w -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, PrivateAttr from src.config.static.models import ( DEFAULT_CHAT_MODEL_PROVIDERS, @@ -27,88 +22,47 @@ from src.utils.logging_config import logger class Config(BaseModel): - """应用配置类""" + save_dir: str = Field(default="saves", description="Storage root directory") + model_dir: str = Field(default="", description="Local model directory") - # ============================================================ - # 基础配置 - # ============================================================ - save_dir: str = Field(default="saves", description="保存目录") - model_dir: str = Field(default="", description="本地模型目录") + enable_reranker: bool = Field(default=False) + enable_content_guard: bool = Field(default=False) + enable_content_guard_llm: bool = Field(default=False) + enable_web_search: bool = Field(default=False) - # ============================================================ - # 功能开关 - # ============================================================ - enable_reranker: bool = Field(default=False, description="是否开启重排序") - enable_content_guard: bool = Field(default=False, description="是否启用内容审查") - enable_content_guard_llm: bool = Field(default=False, description="是否启用LLM内容审查") - enable_web_search: bool = Field(default=False, description="是否启用网络搜索") + default_model: str = Field(default="siliconflow/deepseek-ai/DeepSeek-V3.2") + fast_model: str = Field(default="siliconflow/THUDM/GLM-4-9B-0414") + embed_model: str = Field(default="siliconflow/BAAI/bge-m3") + reranker: str = Field(default="siliconflow/BAAI/bge-reranker-v2-m3") + content_guard_llm_model: str = Field(default="siliconflow/Qwen/Qwen3-235B-A22B-Instruct-2507") - # ============================================================ - # 模型配置 - # ============================================================ - default_model: str = Field( - default="siliconflow/deepseek-ai/DeepSeek-V3.2", - description="默认对话模型", - ) - fast_model: str = Field( - default="siliconflow/THUDM/GLM-4-9B-0414", - description="快速响应模型", - ) - embed_model: str = Field( - default="siliconflow/BAAI/bge-m3", - description="默认 Embedding 模型", - ) - reranker: str = Field( - default="siliconflow/BAAI/bge-reranker-v2-m3", - description="默认 Re-Ranker 模型", - ) - content_guard_llm_model: str = Field( - default="siliconflow/Qwen/Qwen3-235B-A22B-Instruct-2507", - description="内容审查LLM模型", - ) + default_agent_id: str = Field(default="") - # ============================================================ - # 智能体配置 - # ============================================================ - default_agent_id: str = Field(default="", description="默认智能体ID") + sandbox_provider: str = Field(default="provisioner") + sandbox_provisioner_url: str = Field(default="http://sandbox-provisioner:8002") + sandbox_virtual_path_prefix: str = Field(default="/mnt/user-data") + sandbox_exec_timeout_seconds: int = Field(default=180) + sandbox_max_output_bytes: int = Field(default=262144) - # ============================================================ - # 模型信息(只读,不持久化) - # ============================================================ model_names: dict[str, ChatModelProvider] = Field( default_factory=lambda: DEFAULT_CHAT_MODEL_PROVIDERS.copy(), - description="聊天模型提供商配置", exclude=True, ) embed_model_names: dict[str, EmbedModelInfo] = Field( default_factory=lambda: DEFAULT_EMBED_MODELS.copy(), - description="嵌入模型配置", exclude=True, ) reranker_names: dict[str, RerankerInfo] = Field( default_factory=lambda: DEFAULT_RERANKERS.copy(), - description="重排序模型配置", exclude=True, ) - # ============================================================ - # 运行时状态(不持久化) - # ============================================================ - model_provider_status: dict[str, bool] = Field( - default_factory=dict, - description="模型提供商可用状态", - exclude=True, - ) - valuable_model_provider: list[str] = Field( - default_factory=list, - description="可用的模型提供商列表", - exclude=True, - ) + model_provider_status: dict[str, bool] = Field(default_factory=dict, exclude=True) + valuable_model_provider: list[str] = Field(default_factory=list, exclude=True) - # 内部状态 - _config_file: Path | None = None - _user_modified_fields: set[str] = set() - _modified_providers: set[str] = set() # 记录具体修改的模型提供商 + _config_file: Path | None = PrivateAttr(default=None) + _user_modified_fields: set[str] = PrivateAttr(default_factory=set) + _modified_providers: set[str] = PrivateAttr(default_factory=set) model_config = {"arbitrary_types_allowed": True, "extra": "allow"} @@ -119,154 +73,132 @@ class Config(BaseModel): self._load_custom_providers() self._handle_environment() - def _setup_paths(self): - """设置配置文件路径""" + def _setup_paths(self) -> None: self.save_dir = os.getenv("SAVE_DIR") or self.save_dir self._config_file = Path(self.save_dir) / "config" / "base.toml" self._config_file.parent.mkdir(parents=True, exist_ok=True) - def _load_user_config(self): - """从 TOML 文件加载用户配置""" + def _load_user_config(self) -> None: if not self._config_file or not self._config_file.exists(): logger.info(f"Config file not found, using defaults: {self._config_file}") return - logger.info(f"Loading config from {self._config_file}") try: - with open(self._config_file, "rb") as f: - user_config = tomli.load(f) - - # 记录用户修改的字段 - self._user_modified_fields = set(user_config.keys()) - - # 更新配置 - for key, value in user_config.items(): - if key == "model_names": - # 特殊处理模型配置 - self._load_model_names(value) - elif hasattr(self, key): - setattr(self, key, value) - else: - logger.warning(f"Unknown config key: {key}") - - except Exception as e: - logger.error(f"Failed to load config from {self._config_file}: {e}") - - def _load_model_names(self, model_names_data): - """加载用户自定义的模型配置""" - try: - for provider, provider_data in model_names_data.items(): - if provider in self.model_names: - # 更新现有提供商的模型列表 - if "models" in provider_data: - self.model_names[provider].models = provider_data["models"] - else: - # 添加新的提供商 - self.model_names[provider] = ChatModelProvider(**provider_data) - logger.info(f"Loaded custom model configurations for {len(model_names_data)} providers") - except Exception as e: - logger.error(f"Failed to load model names: {e}") - - def _load_custom_providers(self): - """从独立的TOML文件加载自定义供应商配置""" - custom_config_file = self._config_file.parent / "custom_providers.toml" - - if not custom_config_file.exists(): - logger.info(f"Custom providers config file not found: {custom_config_file}") + with self._config_file.open("rb") as file: + user_config = tomli.load(file) + except Exception as exc: # noqa: BLE001 + logger.error(f"Failed to load config from {self._config_file}: {exc}") return - logger.info(f"Loading custom providers from {custom_config_file}") - try: - with open(custom_config_file, "rb") as f: - custom_config = tomli.load(f) - - # 加载自定义供应商 - if "model_names" in custom_config: - self._load_custom_model_providers(custom_config["model_names"]) - - except Exception as e: - logger.error(f"Failed to load custom providers from {custom_config_file}: {e}") - - def _load_custom_model_providers(self, providers_data): - """加载自定义模型供应商""" - try: - for provider, provider_data in providers_data.items(): - provider_data["custom"] = True - self.model_names[provider] = ChatModelProvider(**provider_data) - logger.info(f"Loaded {len(providers_data)} custom model providers") - except Exception as e: - logger.error(f"Failed to load custom model providers: {e}") - - def _handle_environment(self): - """处理环境变量和运行时状态""" - # 处理模型目录 - self.model_dir = os.environ.get("MODEL_DIR") or self.model_dir - if self.model_dir: - if os.path.exists(self.model_dir): - logger.debug(f"Model directory ({self.model_dir}) contains: {os.listdir(self.model_dir)}") + self._user_modified_fields = set(user_config.keys()) + for key, value in user_config.items(): + if key == "model_names": + self._load_model_names(value) + elif key in self.model_fields: + setattr(self, key, value) else: - logger.warning( - f"Model directory ({self.model_dir}) does not exist. If not configured, please ignore it." - ) + logger.warning(f"Unknown config key: {key}") + + def _load_model_names(self, model_names_data: dict[str, Any]) -> None: + for provider, provider_data in (model_names_data or {}).items(): + try: + if provider in self.model_names: + merged = self.model_names[provider].model_dump() | dict(provider_data or {}) + self.model_names[provider] = ChatModelProvider(**merged) + else: + self.model_names[provider] = ChatModelProvider(**provider_data) + except Exception as exc: # noqa: BLE001 + logger.warning(f"Skip invalid model provider config {provider}: {exc}") + + def _load_custom_providers(self) -> None: + if not self._config_file: + return + custom_config_file = self._config_file.parent / "custom_providers.toml" + if not custom_config_file.exists(): + return + + try: + with custom_config_file.open("rb") as file: + custom_config = tomli.load(file) + except Exception as exc: # noqa: BLE001 + logger.error(f"Failed to load custom providers from {custom_config_file}: {exc}") + return + + model_names = custom_config.get("model_names") or {} + self._load_custom_model_providers(model_names) + + def _load_custom_model_providers(self, providers_data: dict[str, Any]) -> None: + for provider, provider_data in (providers_data or {}).items(): + try: + payload = dict(provider_data or {}) + payload["custom"] = True + self.model_names[provider] = ChatModelProvider(**payload) + except Exception as exc: # noqa: BLE001 + logger.warning(f"Skip invalid custom provider {provider}: {exc}") + + def _handle_environment(self) -> None: + self.model_dir = os.environ.get("MODEL_DIR") or self.model_dir - # 检查模型提供商的环境变量 self.model_provider_status = {} for provider, info in self.model_names.items(): - env_var = info.env - - if env_var == "NO_API_KEY": + env_var = (info.env or "").strip() + if env_var.upper() == "NO_API_KEY": self.model_provider_status[provider] = True - else: - api_key = os.environ.get(env_var) - # 如果获取到的值与环境变量名不同,说明环境变量存在或配置了直接值 - self.model_provider_status[provider] = bool(api_key or info.custom) + continue + api_key = os.environ.get(env_var) + self.model_provider_status[provider] = bool(api_key or info.custom) - # 检查网络搜索 if os.getenv("TAVILY_API_KEY"): self.enable_web_search = True - # 获取可用的模型提供商 - self.valuable_model_provider = [k for k, v in self.model_provider_status.items() if v] + self.valuable_model_provider = [key for key, ok in self.model_provider_status.items() if ok] + + self.sandbox_provider = (os.getenv("SANDBOX_PROVIDER") or self.sandbox_provider or "provisioner").strip() + self.sandbox_provisioner_url = ( + os.getenv("SANDBOX_PROVISIONER_URL") or self.sandbox_provisioner_url or "http://sandbox-provisioner:8002" + ).strip() + self.sandbox_virtual_path_prefix = ( + os.getenv("SANDBOX_VIRTUAL_PATH_PREFIX") or self.sandbox_virtual_path_prefix or "/mnt/user-data" + ).strip() + self.sandbox_exec_timeout_seconds = int( + os.getenv("SANDBOX_EXEC_TIMEOUT_SECONDS") or self.sandbox_exec_timeout_seconds or 180 + ) + self.sandbox_max_output_bytes = int( + os.getenv("SANDBOX_MAX_OUTPUT_BYTES") or self.sandbox_max_output_bytes or 262144 + ) + + if self.sandbox_provider.lower() != "provisioner": + raise ValueError("Only sandbox_provider=provisioner is supported.") + if not self.sandbox_provisioner_url: + raise ValueError("SANDBOX_PROVISIONER_URL is required when sandbox provider is provisioner.") + if not self.sandbox_virtual_path_prefix.startswith("/"): + self.sandbox_virtual_path_prefix = f"/{self.sandbox_virtual_path_prefix}" if not self.valuable_model_provider: raise ValueError("No model provider available, please check your `.env` file.") - def save(self): - """保存配置到 TOML 文件(仅保存用户修改的字段)""" + def save(self) -> None: if not self._config_file: logger.warning("Config file path not set") return - logger.info(f"Saving config to {self._config_file}") - - # 获取默认配置 default_config = Config.model_construct() - - # 对比当前配置和默认配置,找出用户修改的字段 - user_modified = {} - for field_name in self.model_fields.keys(): - # 跳过 exclude=True 的字段 - field_info = self.model_fields[field_name] + user_modified: dict[str, Any] = {} + for field_name, field_info in self.model_fields.items(): if field_info.exclude: continue - current_value = getattr(self, field_name) default_value = getattr(default_config, field_name) - - # 如果值不同,说明用户修改了 if current_value != default_value: user_modified[field_name] = current_value - # 写入 TOML 文件 try: - with open(self._config_file, "wb") as f: - tomli_w.dump(user_modified, f) - logger.info(f"Config saved to {self._config_file}") - except Exception as e: - logger.error(f"Failed to save config to {self._config_file}: {e}") + with self._config_file.open("wb") as file: + tomli_w.dump(user_modified, file) + except Exception as exc: # noqa: BLE001 + logger.error(f"Failed to save config to {self._config_file}: {exc}") def dump_config(self) -> dict[str, Any]: - """导出配置为字典(用于 API 返回)""" config_dict = self.model_dump( exclude={ "model_names", @@ -276,282 +208,141 @@ class Config(BaseModel): "valuable_model_provider", } ) - - # 添加模型信息(转换为字典格式供前端使用) config_dict["model_names"] = {provider: info.model_dump() for provider, info in self.model_names.items()} config_dict["embed_model_names"] = { model_id: info.model_dump() for model_id, info in self.embed_model_names.items() } config_dict["reranker_names"] = {model_id: info.model_dump() for model_id, info in self.reranker_names.items()} - - # 添加运行时状态信息 config_dict["model_provider_status"] = self.model_provider_status config_dict["valuable_model_provider"] = self.valuable_model_provider - fields_info = {} + fields_info: dict[str, Any] = {} for field_name, field_info in Config.model_fields.items(): - if not field_info.exclude: # 排除内部字段 - fields_info[field_name] = { - "des": field_info.description, - "default": field_info.default, - "type": field_info.annotation.__name__ - if hasattr(field_info.annotation, "__name__") - else str(field_info.annotation), - "exclude": field_info.exclude if hasattr(field_info, "exclude") else False, - } + if field_info.exclude: + continue + annotation = field_info.annotation + fields_info[field_name] = { + "des": field_info.description, + "default": field_info.default, + "type": annotation.__name__ if hasattr(annotation, "__name__") else str(annotation), + "exclude": bool(field_info.exclude), + } config_dict["_config_items"] = fields_info - return config_dict def get_model_choices(self) -> list[str]: - """获取所有可用的聊天模型列表""" - choices = [] + choices: list[str] = [] for provider, info in self.model_names.items(): - if self.model_provider_status.get(provider, False): - for model in info.models: - choices.append(f"{provider}/{model}") + if not self.model_provider_status.get(provider, False): + continue + choices.extend([f"{provider}/{model}" for model in info.models]) return choices def get_embed_model_choices(self) -> list[str]: - """获取所有可用的嵌入模型列表""" return list(self.embed_model_names.keys()) def get_reranker_choices(self) -> list[str]: - """获取所有可用的重排序模型列表""" return list(self.reranker_names.keys()) - # ============================================================ - # 兼容旧代码的方法 - # ============================================================ - def __getitem__(self, key: str) -> Any: - """支持字典式访问 config[key]""" logger.warning("Using deprecated dict-style access for Config. Please use attribute access instead.") return getattr(self, key, None) - def __setitem__(self, key: str, value: Any): - """支持字典式赋值 config[key] = value""" + def __setitem__(self, key: str, value: Any) -> None: logger.warning("Using deprecated dict-style assignment for Config. Please use attribute access instead.") setattr(self, key, value) - def update(self, other: dict): - """批量更新配置(兼容旧代码)""" - for key, value in other.items(): - if hasattr(self, key): + def update(self, other: dict[str, Any]) -> None: + for key, value in (other or {}).items(): + if key in self.model_fields: setattr(self, key, value) else: logger.warning(f"Unknown config key: {key}") - def _save_models_to_file(self, provider_name: str = None): - """保存模型配置到主配置文件 - - Args: - provider_name: 如果提供,只保存特定provider的修改;否则保存所有model_names - """ + def _save_models_to_file(self, provider_name: str | None = None) -> None: if not self._config_file: logger.warning("Config file path not set") return - logger.info(f"Saving models config to {self._config_file}") + user_config: dict[str, Any] = {} + if self._config_file.exists(): + with self._config_file.open("rb") as file: + user_config = tomli.load(file) + user_config.setdefault("model_names", {}) - try: - # 读取现有配置 - user_config = {} - if self._config_file.exists(): - with open(self._config_file, "rb") as f: - user_config = tomli.load(f) + if provider_name: + if provider_name in self.model_names: + user_config["model_names"][provider_name] = self.model_names[provider_name].model_dump() + self._modified_providers.add(provider_name) + else: + user_config["model_names"] = {provider: info.model_dump() for provider, info in self.model_names.items()} + self._user_modified_fields.add("model_names") - # 初始化 model_names 配置(如果不存在) - if "model_names" not in user_config: - user_config["model_names"] = {} - - if provider_name: - # 只保存特定 provider 的修改 - if provider_name in self.model_names: - user_config["model_names"][provider_name] = self.model_names[provider_name].model_dump() - # 记录具体修改的 provider - self._modified_providers.add(provider_name) - logger.info(f"Saved models config for provider: {provider_name}") - else: - # 保存所有 model_names - user_config["model_names"] = { - provider: info.model_dump() for provider, info in self.model_names.items() - } - # 记录整个 model_names 字段的修改 - self._user_modified_fields.add("model_names") - logger.info("Saved all models config") - - # 写入配置文件 - with open(self._config_file, "wb") as f: - tomli_w.dump(user_config, f) - logger.info(f"Models config saved to {self._config_file}") - except Exception as e: - logger.error(f"Failed to save models config to {self._config_file}: {e}") - - # ============================================================ - # 自定义供应商管理方法 - # ============================================================ - - def add_custom_provider(self, provider_id: str, provider_data: dict) -> bool: - """添加自定义供应商 - - Args: - provider_id: 供应商唯一标识符 - provider_data: 供应商配置数据 - - Returns: - 是否添加成功 - """ - try: - # 处理环境变量,移除 ${} 包裹 - if "env" in provider_data and provider_data["env"]: - env_value = provider_data["env"] - if isinstance(env_value, str) and env_value.startswith("${") and env_value.endswith("}"): - provider_data["env"] = env_value[2:-1] - - # 确保标记为自定义供应商 - provider_data["custom"] = True - - # 检查供应商ID是否已存在(无论是内置还是自定义) - if provider_id in self.model_names: - logger.error(f"Provider ID already exists: {provider_id}") - return False - - # 添加到配置中 - self.model_names[provider_id] = ChatModelProvider(**provider_data) - - # 保存到自定义供应商配置文件 - self._save_custom_providers() - - # 重新处理环境变量 - self._handle_environment() - - logger.info(f"Added custom provider: {provider_id}") - return True - - except Exception as e: - logger.error(f"Failed to add custom provider {provider_id}: {e}") - return False - - def update_custom_provider(self, provider_id: str, provider_data: dict) -> bool: - """更新自定义供应商 - - Args: - provider_id: 供应商唯一标识符 - provider_data: 新的供应商配置数据 - - Returns: - 是否更新成功 - """ - try: - # 处理环境变量,移除 ${} 包裹 - if "env" in provider_data and provider_data["env"]: - env_value = provider_data["env"] - if isinstance(env_value, str) and env_value.startswith("${") and env_value.endswith("}"): - provider_data["env"] = env_value[2:-1] - - # 检查供应商是否存在且为自定义供应商 - if provider_id not in self.model_names: - logger.error(f"Provider not found: {provider_id}") - return False - - if not self.model_names[provider_id].custom: - logger.error(f"Cannot update non-custom provider: {provider_id}") - return False - - # 确保保持自定义供应商标记 - provider_data["custom"] = True - - # 更新供应商配置 - self.model_names[provider_id] = ChatModelProvider(**provider_data) - - # 保存到自定义供应商配置文件 - self._save_custom_providers() - - # 重新处理环境变量 - self._handle_environment() - - logger.info(f"Updated custom provider: {provider_id}") - return True - - except Exception as e: - logger.error(f"Failed to update custom provider {provider_id}: {e}") - return False - - def delete_custom_provider(self, provider_id: str) -> bool: - """删除自定义供应商 - - Args: - provider_id: 供应商唯一标识符 - - Returns: - 是否删除成功 - """ - try: - # 检查供应商是否存在且为自定义供应商 - if provider_id not in self.model_names: - logger.error(f"Provider not found: {provider_id}") - return False - - if not self.model_names[provider_id].custom: - logger.error(f"Cannot delete non-custom provider: {provider_id}") - return False - - # 从配置中删除 - del self.model_names[provider_id] - - # 保存到自定义供应商配置文件 - self._save_custom_providers() - - # 重新处理环境变量 - self._handle_environment() - - logger.info(f"Deleted custom provider: {provider_id}") - return True - - except Exception as e: - logger.error(f"Failed to delete custom provider {provider_id}: {e}") - return False + with self._config_file.open("wb") as file: + tomli_w.dump(user_config, file) def get_custom_providers(self) -> dict[str, ChatModelProvider]: - """获取所有自定义供应商 + return {provider: info for provider, info in self.model_names.items() if info.custom} - Returns: - 自定义供应商字典 - """ - return {k: v for k, v in self.model_names.items() if v.custom} - - def _save_custom_providers(self): - """保存自定义供应商到独立配置文件""" + def _save_custom_providers(self) -> None: if not self._config_file: logger.warning("Config file path not set") return custom_config_file = self._config_file.parent / "custom_providers.toml" + custom_providers = self.get_custom_providers() + custom_config: dict[str, Any] = {} + if custom_providers: + custom_config["model_names"] = {provider: info.model_dump() for provider, info in custom_providers.items()} - try: - # 获取所有自定义供应商 - custom_providers = self.get_custom_providers() + custom_config_file.parent.mkdir(parents=True, exist_ok=True) + with custom_config_file.open("wb") as file: + tomli_w.dump(custom_config, file) - # 创建配置数据 - custom_config = {} - if custom_providers: - custom_config["model_names"] = { - provider: info.model_dump() for provider, info in custom_providers.items() - } + def add_custom_provider(self, provider_id: str, provider_data: dict[str, Any]) -> bool: + if provider_id in self.model_names: + logger.error(f"Provider ID already exists: {provider_id}") + return False + payload = dict(provider_data or {}) + env_value = payload.get("env") + if isinstance(env_value, str) and env_value.startswith("${") and env_value.endswith("}"): + payload["env"] = env_value[2:-1] + payload["custom"] = True + self.model_names[provider_id] = ChatModelProvider(**payload) + self._save_custom_providers() + self._handle_environment() + return True - # 确保目录存在 - custom_config_file.parent.mkdir(parents=True, exist_ok=True) + def update_custom_provider(self, provider_id: str, provider_data: dict[str, Any]) -> bool: + if provider_id not in self.model_names: + logger.error(f"Provider not found: {provider_id}") + return False + if not self.model_names[provider_id].custom: + logger.error(f"Cannot update non-custom provider: {provider_id}") + return False - # 写入配置文件 - with open(custom_config_file, "wb") as f: - tomli_w.dump(custom_config, f) + payload = dict(provider_data or {}) + env_value = payload.get("env") + if isinstance(env_value, str) and env_value.startswith("${") and env_value.endswith("}"): + payload["env"] = env_value[2:-1] + payload["custom"] = True + self.model_names[provider_id] = ChatModelProvider(**payload) + self._save_custom_providers() + self._handle_environment() + return True - logger.info(f"Custom providers saved to {custom_config_file}") + def delete_custom_provider(self, provider_id: str) -> bool: + if provider_id not in self.model_names: + logger.error(f"Provider not found: {provider_id}") + return False + if not self.model_names[provider_id].custom: + logger.error(f"Cannot delete non-custom provider: {provider_id}") + return False - except Exception as e: - logger.error(f"Failed to save custom providers to {custom_config_file}: {e}") + del self.model_names[provider_id] + self._save_custom_providers() + self._handle_environment() + return True -# 全局配置实例 config = Config() diff --git a/src/sandbox/__init__.py b/src/sandbox/__init__.py new file mode 100644 index 00000000..f92274ad --- /dev/null +++ b/src/sandbox/__init__.py @@ -0,0 +1,35 @@ +from .backend import ProvisionerSandboxBackend +from .paths import ( + VIRTUAL_PATH_PREFIX, + ensure_thread_dirs, + resolve_virtual_path, + sandbox_outputs_dir, + sandbox_uploads_dir, + sandbox_user_data_dir, + sandbox_workspace_dir, + virtual_path_for_thread_file, +) +from .provider import ( + ProvisionerSandboxProvider, + get_sandbox_provider, + init_sandbox_provider, + sandbox_id_for_thread, + shutdown_sandbox_provider, +) + +__all__ = [ + "ProvisionerSandboxBackend", + "ProvisionerSandboxProvider", + "VIRTUAL_PATH_PREFIX", + "ensure_thread_dirs", + "get_sandbox_provider", + "init_sandbox_provider", + "resolve_virtual_path", + "sandbox_id_for_thread", + "sandbox_outputs_dir", + "sandbox_uploads_dir", + "sandbox_user_data_dir", + "sandbox_workspace_dir", + "shutdown_sandbox_provider", + "virtual_path_for_thread_file", +] diff --git a/src/sandbox/backend.py b/src/sandbox/backend.py new file mode 100644 index 00000000..55862658 --- /dev/null +++ b/src/sandbox/backend.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +import base64 +from pathlib import PurePosixPath +from typing import Any + +from deepagents.backends.sandbox import BaseSandbox +from deepagents.backends.protocol import ExecuteResponse, FileDownloadResponse, FileUploadResponse + +from src import config as conf +from src.utils.logging_config import logger + +from .provider import get_sandbox_provider, sandbox_id_for_thread + + +def _normalize_path(path: str) -> str: + raw = str(path or "").strip() + if not raw: + raise ValueError("path is required") + normalized = "/" + raw.lstrip("/") + pure = PurePosixPath(normalized) + if ".." in pure.parts: + raise ValueError("path traversal is not allowed") + return str(pure) + + +class ProvisionerSandboxBackend(BaseSandbox): + def __init__(self, thread_id: str): + self._thread_id = str(thread_id or "").strip() + if not self._thread_id: + raise ValueError("thread_id is required for ProvisionerSandboxBackend") + + self._provider = get_sandbox_provider() + self._id = sandbox_id_for_thread(self._thread_id) + self._client: Any | None = None + self._client_url: str | None = None + self._command_timeout_seconds = int(getattr(conf, "sandbox_exec_timeout_seconds", 180)) + self._max_output_bytes = int(getattr(conf, "sandbox_max_output_bytes", 262_144)) + + @property + def id(self) -> str: + return self._id + + def _build_client(self, sandbox_url: str): + try: + from agent_sandbox import Sandbox as AgentSandboxClient + except Exception as exc: # noqa: BLE001 + raise RuntimeError( + "agent-sandbox is required. Install dependency `agent-sandbox` in the docker image." + ) from exc + + return AgentSandboxClient(base_url=sandbox_url, timeout=self._command_timeout_seconds) + + def _get_client(self): + connection = self._provider.get(self._thread_id, create_if_missing=True) + if connection is None: + raise RuntimeError(f"sandbox is unavailable for thread {self._thread_id}") + + if self._client is None or self._client_url != connection.sandbox_url: + self._client = self._build_client(connection.sandbox_url) + self._client_url = connection.sandbox_url + + return self._client + + @staticmethod + def _extract_data(payload: Any) -> Any: + return getattr(payload, "data", payload) + + def _shell_exec(self, command: str): + client = self._get_client() + shell = getattr(client, "shell", None) + if shell is not None: + if hasattr(shell, "exec_command"): + return shell.exec_command(command=command) + if hasattr(shell, "exec"): + return shell.exec(command=command) + if hasattr(client, "exec_command"): + return client.exec_command(command=command) + raise RuntimeError("sandbox client does not provide shell execution API") + + def _write_binary(self, path: str, content: bytes) -> None: + client = self._get_client() + file_api = getattr(client, "file", None) + if file_api is None: + raise RuntimeError("sandbox client does not provide file API") + + encoded = base64.b64encode(content).decode("ascii") + if hasattr(file_api, "write_file"): + try: + file_api.write_file(file=path, content=encoded, encoding="base64") + return + except TypeError: + file_api.write_file(file=path, content=content.decode("utf-8", errors="replace")) + return + if hasattr(file_api, "write"): + file_api.write(path=path, content=encoded, encoding="base64") + return + raise RuntimeError("sandbox file API does not provide write method") + + def _read_binary(self, path: str) -> bytes: + client = self._get_client() + file_api = getattr(client, "file", None) + if file_api is None: + raise RuntimeError("sandbox client does not provide file API") + + result: Any + if hasattr(file_api, "read_file"): + try: + result = file_api.read_file(file=path, encoding="base64") + except TypeError: + result = file_api.read_file(file=path) + elif hasattr(file_api, "read"): + result = file_api.read(path=path, encoding="base64") + else: + raise RuntimeError("sandbox file API does not provide read method") + + data = self._extract_data(result) + content = getattr(data, "content", None) + if content is None and isinstance(data, dict): + content = data.get("content") + if content is None: + return b"" + if isinstance(content, bytes): + return content + if not isinstance(content, str): + return str(content).encode("utf-8") + + try: + return base64.b64decode(content, validate=True) + except Exception: # noqa: BLE001 + return content.encode("utf-8") + + def execute(self, command: str) -> ExecuteResponse: + try: + result = self._shell_exec(command) + data = self._extract_data(result) + output = getattr(data, "output", None) + if output is None and isinstance(data, dict): + output = data.get("output") + if output is None: + output = str(data) if data is not None else "" + if not isinstance(output, str): + output = str(output) + + exit_code = getattr(data, "exit_code", None) + if exit_code is None and isinstance(data, dict): + exit_code = data.get("exit_code") + if isinstance(exit_code, str) and exit_code.isdigit(): + exit_code = int(exit_code) + + truncated = bool(getattr(data, "truncated", False)) + encoded = output.encode("utf-8", errors="ignore") + if len(encoded) > self._max_output_bytes: + output = encoded[: self._max_output_bytes].decode("utf-8", errors="ignore") + truncated = True + + return ExecuteResponse( + output=output, + exit_code=exit_code if isinstance(exit_code, int) else None, + truncated=truncated, + ) + except Exception as exc: # noqa: BLE001 + logger.error(f"Sandbox execute failed for thread {self._thread_id}: {exc}") + return ExecuteResponse(output=f"Error: {exc}", exit_code=1, truncated=False) + + def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]: + responses: list[FileUploadResponse] = [] + for path, content in files: + try: + normalized_path = _normalize_path(path) + self._write_binary(normalized_path, content) + responses.append(FileUploadResponse(path=normalized_path, error=None)) + except PermissionError: + normalized_path = str(path) + responses.append(FileUploadResponse(path=normalized_path, error="permission_denied")) + except IsADirectoryError: + normalized_path = str(path) + responses.append(FileUploadResponse(path=normalized_path, error="is_directory")) + except FileNotFoundError: + normalized_path = str(path) + responses.append(FileUploadResponse(path=normalized_path, error="file_not_found")) + except Exception as exc: # noqa: BLE001 + normalized_path = str(path) + logger.warning(f"Upload to sandbox failed for {normalized_path}: {exc}") + responses.append(FileUploadResponse(path=normalized_path, error="invalid_path")) + return responses + + def download_files(self, paths: list[str]) -> list[FileDownloadResponse]: + responses: list[FileDownloadResponse] = [] + for path in paths: + try: + normalized_path = _normalize_path(path) + content = self._read_binary(normalized_path) + responses.append(FileDownloadResponse(path=normalized_path, content=content, error=None)) + except PermissionError: + normalized_path = str(path) + responses.append(FileDownloadResponse(path=normalized_path, content=None, error="permission_denied")) + except IsADirectoryError: + normalized_path = str(path) + responses.append(FileDownloadResponse(path=normalized_path, content=None, error="is_directory")) + except FileNotFoundError: + normalized_path = str(path) + responses.append(FileDownloadResponse(path=normalized_path, content=None, error="file_not_found")) + except Exception as exc: # noqa: BLE001 + normalized_path = str(path) + logger.warning(f"Download from sandbox failed for {normalized_path}: {exc}") + responses.append(FileDownloadResponse(path=normalized_path, content=None, error="invalid_path")) + return responses diff --git a/src/sandbox/paths.py b/src/sandbox/paths.py new file mode 100644 index 00000000..d0cef160 --- /dev/null +++ b/src/sandbox/paths.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from src import config as conf + +DEFAULT_VIRTUAL_PATH_PREFIX = "/mnt/user-data" +VIRTUAL_PATH_PREFIX = DEFAULT_VIRTUAL_PATH_PREFIX + +_SAFE_THREAD_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$") + + +def get_virtual_path_prefix() -> str: + configured = str(getattr(conf, "sandbox_virtual_path_prefix", "") or "").strip() + if not configured: + return DEFAULT_VIRTUAL_PATH_PREFIX + return "/" + configured.strip("/") + + +def _validate_thread_id(thread_id: str) -> str: + value = str(thread_id or "").strip() + if not value: + raise ValueError("thread_id is required") + if not _SAFE_THREAD_ID_RE.match(value): + raise ValueError("thread_id contains invalid characters") + return value + + +def _thread_root_dir(thread_id: str) -> Path: + safe_thread_id = _validate_thread_id(thread_id) + return Path(conf.save_dir) / "threads" / safe_thread_id / "user-data" + + +def sandbox_user_data_dir(thread_id: str) -> Path: + return _thread_root_dir(thread_id) + + +def sandbox_workspace_dir(thread_id: str) -> Path: + return _thread_root_dir(thread_id) / "workspace" + + +def sandbox_uploads_dir(thread_id: str) -> Path: + return _thread_root_dir(thread_id) / "uploads" + + +def sandbox_outputs_dir(thread_id: str) -> Path: + return _thread_root_dir(thread_id) / "outputs" + + +def ensure_thread_dirs(thread_id: str) -> None: + sandbox_workspace_dir(thread_id).mkdir(parents=True, exist_ok=True) + sandbox_uploads_dir(thread_id).mkdir(parents=True, exist_ok=True) + sandbox_outputs_dir(thread_id).mkdir(parents=True, exist_ok=True) + + +def resolve_virtual_path(thread_id: str, virtual_path: str) -> Path: + clean_virtual_path = "/" + str(virtual_path or "").strip().lstrip("/") + virtual_prefix = get_virtual_path_prefix() + + if clean_virtual_path != virtual_prefix and not clean_virtual_path.startswith(f"{virtual_prefix}/"): + raise ValueError(f"path must start with {virtual_prefix}") + + relative_path = clean_virtual_path[len(virtual_prefix) :].lstrip("/") + base_dir = sandbox_user_data_dir(thread_id).resolve() + target_path = (base_dir / relative_path).resolve() + + try: + target_path.relative_to(base_dir) + except ValueError as exc: + raise ValueError("path traversal detected") from exc + + return target_path + + +def virtual_path_for_thread_file(thread_id: str, path: str | Path) -> str: + base_dir = sandbox_user_data_dir(thread_id).resolve() + target_path = Path(path).resolve() + + try: + relative_path = target_path.relative_to(base_dir) + except ValueError as exc: + raise ValueError("file is outside thread user-data directory") from exc + + prefix = get_virtual_path_prefix().rstrip("/") + if not str(relative_path): + return prefix + return f"{prefix}/{relative_path.as_posix()}" diff --git a/src/sandbox/provider.py b/src/sandbox/provider.py new file mode 100644 index 00000000..fd848117 --- /dev/null +++ b/src/sandbox/provider.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import hashlib +import threading +from dataclasses import dataclass + +from src import config as conf +from src.utils.logging_config import logger + +from .provisioner_client import ProvisionerClient, SandboxRecord + + +def sandbox_id_for_thread(thread_id: str) -> str: + digest = hashlib.sha256(thread_id.encode("utf-8")).hexdigest() + return digest[:12] + + +@dataclass(slots=True) +class SandboxConnection: + thread_id: str + sandbox_id: str + sandbox_url: str + + +class ProvisionerSandboxProvider: + def __init__(self): + provider_name = str(getattr(conf, "sandbox_provider", "provisioner")).strip().lower() + if provider_name != "provisioner": + raise RuntimeError("only sandbox_provider=provisioner is supported") + + provisioner_url = str(getattr(conf, "sandbox_provisioner_url", "") or "").strip() + if not provisioner_url: + raise RuntimeError("sandbox_provisioner_url is required") + + self._client = ProvisionerClient(provisioner_url) + self._lock = threading.Lock() + self._thread_locks: dict[str, threading.Lock] = {} + self._connections: dict[str, SandboxConnection] = {} + + def _thread_lock(self, thread_id: str) -> threading.Lock: + with self._lock: + lock = self._thread_locks.get(thread_id) + if lock is None: + lock = threading.Lock() + self._thread_locks[thread_id] = lock + return lock + + def _record_to_connection(self, thread_id: str, record: SandboxRecord) -> SandboxConnection: + connection = SandboxConnection( + thread_id=thread_id, + sandbox_id=record.sandbox_id, + sandbox_url=record.sandbox_url, + ) + self._connections[thread_id] = connection + return connection + + def acquire(self, thread_id: str) -> str: + lock = self._thread_lock(thread_id) + with lock: + current = self._connections.get(thread_id) + if current: + return current.sandbox_id + + sandbox_id = sandbox_id_for_thread(thread_id) + record = self._client.discover(sandbox_id) + if record is None: + logger.info(f"Creating sandbox {sandbox_id} for thread {thread_id}") + record = self._client.create(sandbox_id, thread_id) + else: + logger.info(f"Reusing sandbox {sandbox_id} for thread {thread_id}") + + connection = self._record_to_connection(thread_id, record) + return connection.sandbox_id + + def get(self, thread_id: str, *, create_if_missing: bool = False) -> SandboxConnection | None: + lock = self._thread_lock(thread_id) + with lock: + current = self._connections.get(thread_id) + if current: + return current + + sandbox_id = sandbox_id_for_thread(thread_id) + record = self._client.discover(sandbox_id) + if record is None: + if not create_if_missing: + return None + record = self._client.create(sandbox_id, thread_id) + + return self._record_to_connection(thread_id, record) + + def shutdown(self) -> None: + with self._lock: + connections = list(self._connections.values()) + self._connections.clear() + + for connection in connections: + try: + self._client.delete(connection.sandbox_id) + except Exception as exc: # noqa: BLE001 + logger.warning( + f"Failed to release sandbox {connection.sandbox_id} " + f"for thread {connection.thread_id}: {exc}" + ) + + +_sandbox_provider: ProvisionerSandboxProvider | None = None +_sandbox_provider_lock = threading.Lock() + + +def init_sandbox_provider() -> ProvisionerSandboxProvider: + global _sandbox_provider + with _sandbox_provider_lock: + if _sandbox_provider is None: + _sandbox_provider = ProvisionerSandboxProvider() + return _sandbox_provider + + +def get_sandbox_provider() -> ProvisionerSandboxProvider: + provider = _sandbox_provider + if provider is not None: + return provider + return init_sandbox_provider() + + +def shutdown_sandbox_provider() -> None: + global _sandbox_provider + with _sandbox_provider_lock: + provider = _sandbox_provider + _sandbox_provider = None + if provider is not None: + provider.shutdown() diff --git a/src/sandbox/provisioner_client.py b/src/sandbox/provisioner_client.py new file mode 100644 index 00000000..cdaa4832 --- /dev/null +++ b/src/sandbox/provisioner_client.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import httpx + + +@dataclass(slots=True) +class SandboxRecord: + sandbox_id: str + sandbox_url: str + status: str | None = None + + +class ProvisionerClient: + def __init__(self, base_url: str, *, timeout_seconds: int = 20): + self._base_url = base_url.rstrip("/") + self._timeout = httpx.Timeout(timeout_seconds) + + def _request(self, method: str, path: str, **kwargs) -> httpx.Response: + return httpx.request( + method=method, + url=f"{self._base_url}{path}", + timeout=self._timeout, + **kwargs, + ) + + def health(self) -> bool: + response = self._request("GET", "/health") + return response.status_code == 200 + + def create(self, sandbox_id: str, thread_id: str) -> SandboxRecord: + response = self._request( + "POST", + "/api/sandboxes", + json={"sandbox_id": sandbox_id, "thread_id": thread_id}, + ) + if response.status_code >= 400: + raise RuntimeError( + f"failed to create sandbox {sandbox_id}: " + f"{response.status_code} {response.text}" + ) + payload = response.json() + return SandboxRecord( + sandbox_id=payload["sandbox_id"], + sandbox_url=payload["sandbox_url"], + status=payload.get("status"), + ) + + def discover(self, sandbox_id: str) -> SandboxRecord | None: + response = self._request("GET", f"/api/sandboxes/{sandbox_id}") + if response.status_code == 404: + return None + if response.status_code >= 400: + raise RuntimeError( + f"failed to discover sandbox {sandbox_id}: " + f"{response.status_code} {response.text}" + ) + payload = response.json() + return SandboxRecord( + sandbox_id=payload["sandbox_id"], + sandbox_url=payload["sandbox_url"], + status=payload.get("status"), + ) + + def delete(self, sandbox_id: str) -> None: + response = self._request("DELETE", f"/api/sandboxes/{sandbox_id}") + if response.status_code in {200, 404}: + return + raise RuntimeError( + f"failed to delete sandbox {sandbox_id}: " + f"{response.status_code} {response.text}" + ) diff --git a/src/services/conversation_service.py b/src/services/conversation_service.py index 684f180c..36493509 100644 --- a/src/services/conversation_service.py +++ b/src/services/conversation_service.py @@ -1,23 +1,29 @@ +import shlex import uuid from datetime import UTC, datetime +from pathlib import Path from fastapi import HTTPException, UploadFile -from langgraph.types import Command from sqlalchemy.ext.asyncio import AsyncSession from src.agents import agent_manager from src.repositories.conversation_repository import ConversationRepository +from src.sandbox import ( + ProvisionerSandboxBackend, + ensure_thread_dirs, + get_sandbox_provider, + sandbox_uploads_dir, +) from src.services.doc_converter import ( ATTACHMENT_ALLOWED_EXTENSIONS, MAX_ATTACHMENT_SIZE_BYTES, convert_upload_to_markdown, ) -from src.storage.minio.client import get_minio_client from src.utils.datetime_utils import utc_isoformat from src.utils.logging_config import logger -# 附件存储桶名称 -ATTACHMENTS_BUCKET = "chat-attachments" +UPLOADS_VIRTUAL_PREFIX = "/mnt/user-data/uploads" +LEGACY_ATTACHMENTS_PREFIX = "/attachments/" async def require_user_conversation(conv_repo: ConversationRepository, thread_id: str, user_id: str): @@ -27,21 +33,27 @@ async def require_user_conversation(conv_repo: ConversationRepository, thread_id return conversation -def _make_attachment_path(file_name: str) -> str: - """生成附件在文件系统中的路径(无需 thread_id,state 已隔离) - - 统一使用 .md 扩展名,因为文件内容已经是 Markdown 格式 - """ - # 提取不带扩展名的部分 +def _sanitize_file_stem(file_name: str) -> str: base_name = file_name for ext in [".docx", ".txt", ".html", ".htm", ".pdf", ".md"]: if file_name.lower().endswith(ext): base_name = file_name[: -len(ext)] break + safe_name = base_name.replace("/", "_").replace("\\", "_").strip(" .") + return safe_name or "attachment" - # 替换路径分隔符 - safe_name = base_name.replace("/", "_").replace("\\", "_") - return f"/attachments/{safe_name}.md" + +def _make_attachment_markdown_virtual_path(file_name: str) -> str: + return f"{UPLOADS_VIRTUAL_PREFIX}/{_sanitize_file_stem(file_name)}.md" + + +def _make_attachment_original_virtual_path(file_name: str) -> str: + safe_name = file_name.replace("/", "_").replace("\\", "_").strip(" .") + return f"{UPLOADS_VIRTUAL_PREFIX}/{safe_name or 'attachment.bin'}" + + +def _artifact_url(thread_id: str, virtual_path: str) -> str: + return f"/api/chat/thread/{thread_id}/artifacts/{virtual_path.lstrip('/')}" def _build_state_files(attachments: list[dict]) -> dict: @@ -80,19 +92,18 @@ async def _sync_thread_attachment_state( graph = await agent.get_graph() config = {"configurable": {"thread_id": thread_id, "user_id": str(user_id)}} - # 先获取现有 state,保留非附件文件 state = await graph.aget_state(config) state_values = getattr(state, "values", {}) if state else {} existing_files = state_values.get("files", {}) if isinstance(state_values, dict) else {} if not isinstance(existing_files, dict): existing_files = {} - # 仅对 /attachments 命名空间做增量更新,避免覆盖 agent 运行期生成的其它文件。 next_attachment_files = _build_state_files(attachments) prev_attachment_paths = { path for path in existing_files.keys() - if isinstance(path, str) and path.startswith("/attachments/") + if isinstance(path, str) + and (path.startswith(LEGACY_ATTACHMENTS_PREFIX) or path.startswith(f"{UPLOADS_VIRTUAL_PREFIX}/")) } next_attachment_paths = set(next_attachment_files.keys()) @@ -100,7 +111,6 @@ async def _sync_thread_attachment_state( for removed_path in prev_attachment_paths - next_attachment_paths: file_updates[removed_path] = None - # 使用 Command 确保 reducer 被正确应用 await graph.aupdate_state( config=config, values={ @@ -108,12 +118,11 @@ async def _sync_thread_attachment_state( "files": file_updates, }, ) - except Exception as e: - logger.warning(f"Failed to sync attachment state for thread {thread_id}: {e}") + except Exception as exc: # noqa: BLE001 + logger.warning(f"Failed to sync attachment state for thread {thread_id}: {exc}") def serialize_attachment(record: dict) -> dict: - """序列化附件记录,返回给前端""" return { "file_id": record.get("file_id"), "file_name": record.get("file_name"), @@ -122,7 +131,11 @@ def serialize_attachment(record: dict) -> dict: "status": record.get("status", "parsed"), "uploaded_at": record.get("uploaded_at"), "truncated": record.get("truncated", False), - "minio_url": record.get("minio_url"), # 仅用于前端下载 + "virtual_path": record.get("virtual_path") or record.get("file_path"), + "artifact_url": record.get("artifact_url"), + "original_virtual_path": record.get("original_virtual_path"), + "original_artifact_url": record.get("original_artifact_url"), + "minio_url": record.get("minio_url"), } @@ -237,27 +250,31 @@ async def upload_thread_attachment_view( logger.error(f"附件解析失败: {exc}") raise HTTPException(status_code=500, detail="附件解析失败,请稍后重试") from exc - # 生成文件路径 - file_path = _make_attachment_path(conversion.file_name) + markdown_virtual_path = _make_attachment_markdown_virtual_path(conversion.file_name) + original_virtual_path = _make_attachment_original_virtual_path(conversion.file_name) + markdown_artifact_url = _artifact_url(thread_id, markdown_virtual_path) + original_artifact_url = _artifact_url(thread_id, original_virtual_path) - # 上传源文件到 MinIO(用于前端下载) - minio_url = None - try: - file_content = await file.read() - await file.seek(0) - client = get_minio_client() - object_name = f"attachments/{thread_id}/{conversion.file_name}" - result = client.upload_file( - bucket_name=ATTACHMENTS_BUCKET, - object_name=object_name, - data=file_content, - content_type=conversion.file_type or "application/octet-stream", + ensure_thread_dirs(thread_id) + uploads_dir = sandbox_uploads_dir(thread_id) + markdown_actual_path = uploads_dir / Path(markdown_virtual_path).name + original_actual_path = uploads_dir / Path(original_virtual_path).name + + await file.seek(0) + file_content = await file.read() + markdown_actual_path.write_text(conversion.markdown, encoding="utf-8") + original_actual_path.write_bytes(file_content) + + provider = get_sandbox_provider() + connection = provider.get(thread_id, create_if_missing=False) + if connection is not None: + backend = ProvisionerSandboxBackend(thread_id=thread_id) + backend.upload_files( + [ + (markdown_virtual_path, conversion.markdown.encode("utf-8")), + (original_virtual_path, file_content), + ] ) - minio_url = result.public_url - logger.info(f"Uploaded attachment to MinIO: {object_name}") - except Exception as e: - logger.error(f"Failed to upload attachment to MinIO: {e}") - # 继续处理,不因为上传失败而中断 attachment_record = { "file_id": conversion.file_id, @@ -268,8 +285,14 @@ async def upload_thread_attachment_view( "markdown": conversion.markdown, "uploaded_at": utc_isoformat(), "truncated": conversion.truncated, - "file_path": file_path, # 用于 StateBackend,前端不返回此字段 - "minio_url": minio_url, # 暂未使用 + "file_path": markdown_virtual_path, + "virtual_path": markdown_virtual_path, + "artifact_url": markdown_artifact_url, + "original_virtual_path": original_virtual_path, + "original_artifact_url": original_artifact_url, + "minio_url": None, + "storage_path": str(markdown_actual_path), + "original_storage_path": str(original_actual_path), } await conv_repo.add_attachment(conversation.id, attachment_record) all_attachments = await conv_repo.get_attachments(conversation.id) @@ -310,9 +333,28 @@ async def delete_thread_attachment_view( ) -> dict: conv_repo = ConversationRepository(db) conversation = await require_user_conversation(conv_repo, thread_id, str(current_user_id)) + + existing_attachments = await conv_repo.get_attachments(conversation.id) + target_attachment = next((item for item in existing_attachments if item.get("file_id") == file_id), None) + removed = await conv_repo.remove_attachment(conversation.id, file_id) if not removed: raise HTTPException(status_code=404, detail="附件不存在或已被删除") + + if target_attachment: + for candidate in [ + target_attachment.get("storage_path"), + target_attachment.get("original_storage_path"), + ]: + if not candidate: + continue + try: + file_path = Path(candidate) + if file_path.exists(): + file_path.unlink() + except Exception as exc: # noqa: BLE001 + logger.warning(f"Failed to remove attachment file {candidate}: {exc}") + all_attachments = await conv_repo.get_attachments(conversation.id) await _sync_thread_attachment_state( thread_id=thread_id, @@ -320,4 +362,20 @@ async def delete_thread_attachment_view( agent_id=conversation.agent_id, attachments=all_attachments, ) + + if target_attachment: + provider = get_sandbox_provider() + connection = provider.get(thread_id, create_if_missing=False) + if connection is not None: + backend = ProvisionerSandboxBackend(thread_id=thread_id) + delete_commands = [] + for path in [ + target_attachment.get("virtual_path") or target_attachment.get("file_path"), + target_attachment.get("original_virtual_path"), + ]: + if isinstance(path, str) and path.strip(): + delete_commands.append(f"rm -f {shlex.quote(path)}") + if delete_commands: + backend.execute(" && ".join(delete_commands)) + return {"message": "附件已删除"} diff --git a/src/services/thread_files_service.py b/src/services/thread_files_service.py new file mode 100644 index 00000000..e7344729 --- /dev/null +++ b/src/services/thread_files_service.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from fastapi import HTTPException + +from src import config as conf +from src.repositories.conversation_repository import ConversationRepository +from src.sandbox import ( + ensure_thread_dirs, + resolve_virtual_path, + sandbox_user_data_dir, + virtual_path_for_thread_file, +) +from src.services.conversation_service import require_user_conversation + + +def _get_virtual_root() -> str: + prefix = str(getattr(conf, "sandbox_virtual_path_prefix", "/mnt/user-data") or "/mnt/user-data") + return "/" + prefix.strip("/") + + +def _to_iso8601(timestamp: float | None) -> str | None: + if timestamp is None: + return None + return datetime.fromtimestamp(timestamp, tz=UTC).isoformat() + + +async def list_thread_files_view( + *, + thread_id: str, + current_user_id: str, + db, + path: str | None = None, +) -> dict: + conv_repo = ConversationRepository(db) + await require_user_conversation(conv_repo, thread_id, str(current_user_id)) + + ensure_thread_dirs(thread_id) + virtual_path = path or _get_virtual_root() + try: + actual_path = resolve_virtual_path(thread_id, virtual_path) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + if not actual_path.exists(): + return {"path": virtual_path, "files": []} + if not actual_path.is_dir(): + raise HTTPException(status_code=400, detail="path must be a directory") + + 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() + 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('/')}", + } + ) + + return {"path": virtual_path, "files": entries} + + +async def read_thread_file_content_view( + *, + thread_id: str, + current_user_id: str, + db, + path: str, + offset: int = 0, + limit: int = 2000, +) -> dict: + conv_repo = ConversationRepository(db) + await require_user_conversation(conv_repo, thread_id, str(current_user_id)) + + try: + actual_path = resolve_virtual_path(thread_id, path) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + if not actual_path.exists(): + raise HTTPException(status_code=404, detail="file not found") + if not actual_path.is_file(): + raise HTTPException(status_code=400, detail="path must be a file") + + text = actual_path.read_text(encoding="utf-8", errors="replace") + lines = text.splitlines() + start = max(0, int(offset)) + count = min(max(1, int(limit)), 5000) + selected = lines[start : start + count] + + return { + "path": path, + "content": selected, + "offset": start, + "limit": count, + "total_lines": len(lines), + "artifact_url": f"/api/chat/thread/{thread_id}/artifacts/{path.lstrip('/')}", + } + + +async def resolve_thread_artifact_view( + *, + thread_id: str, + current_user_id: str, + db, + path: str, +) -> Path: + conv_repo = ConversationRepository(db) + await require_user_conversation(conv_repo, thread_id, str(current_user_id)) + + ensure_thread_dirs(thread_id) + + normalized = "/" + path.lstrip("/") + try: + actual_path = resolve_virtual_path(thread_id, normalized) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + if not actual_path.exists(): + raise HTTPException(status_code=404, detail="artifact not found") + if not actual_path.is_file(): + raise HTTPException(status_code=400, detail="artifact path is not a file") + + # Additional guard to ensure path remains under thread root even if helper changes. + thread_root = sandbox_user_data_dir(thread_id).resolve() + try: + actual_path.resolve().relative_to(thread_root) + except ValueError as exc: + raise HTTPException(status_code=403, detail="access denied") from exc + + return actual_path diff --git a/uv.lock b/uv.lock index ae1ee104..cb013d59 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12, <3.14" resolution-markers = [ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')", @@ -31,6 +31,56 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/d2/c581486aa6c4fbd7394c23c47b83fa1a919d34194e16944241daf9e762dd/accelerate-1.12.0-py3-none-any.whl", hash = "sha256:3e2091cd341423207e2f084a6654b1efcd250dc326f2a37d6dde446e07cabb11", size = 380935, upload-time = "2025-11-21T11:27:44.522Z" }, ] +[[package]] +name = "agent-sandbox" +version = "0.0.26" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "httpx", extra = ["socks"] }, + { name = "pydantic" }, + { name = "volcengine-python-sdk" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/68/931c61b5e28ee344b5c2301bf56739f7fc90767da007c9e44a9b0a1ea937/agent_sandbox-0.0.26.tar.gz", hash = "sha256:67ec87e58794d017f6be321fc46913d76e84453d0cfe426a5f4b9bf8f5bd619b", size = 98635, upload-time = "2026-03-02T08:47:24.853Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/b5/2748e86137ee757b749d2fb7f5504e039f780c9346be798870ae01ee1559/agent_sandbox-0.0.26-py2.py3-none-any.whl", hash = "sha256:6421fc4eb6144f10ddfdd2668c6644e132056eaeb6ab89d76a0b2e4bcf327a0a", size = 216489, upload-time = "2026-03-02T08:47:23.351Z" }, +] + +[[package]] +name = "aioboto3" +version = "15.5.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "aiobotocore", extra = ["boto3"] }, + { name = "aiofiles" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/01/92e9ab00f36e2899315f49eefcd5b4685fbb19016c7f19a9edf06da80bb0/aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979", size = 255069, upload-time = "2025-10-30T13:37:16.122Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/3e/e8f5b665bca646d43b916763c901e00a07e40f7746c9128bdc912a089424/aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6", size = 35913, upload-time = "2025-10-30T13:37:14.549Z" }, +] + +[[package]] +name = "aiobotocore" +version = "2.25.1" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aioitertools" }, + { name = "botocore" }, + { name = "jmespath" }, + { name = "multidict" }, + { name = "python-dateutil" }, + { name = "wrapt" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/94/2e4ec48cf1abb89971cb2612d86f979a6240520f0a659b53a43116d344dc/aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc", size = 120560, upload-time = "2025-10-28T22:33:21.787Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/2a/d275ec4ce5cd0096665043995a7d76f5d0524853c76a3d04656de49f8808/aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f", size = 86039, upload-time = "2025-10-28T22:33:19.949Z" }, +] + +[package.optional-dependencies] +boto3 = [ + { name = "boto3" }, +] + [[package]] name = "aiofiles" version = "25.1.0" @@ -100,6 +150,15 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, ] +[[package]] +name = "aioitertools" +version = "0.13.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -319,6 +378,34 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/c1/84fc6811122f54b20de2e5afb312ee07a3a47a328755587d1e505475239b/blockbuster-1.5.26-py3-none-any.whl", hash = "sha256:f8e53fb2dd4b6c6ec2f04907ddbd063ca7cd1ef587d24448ef4e50e81e3a79bb", size = 13226, upload-time = "2025-12-05T10:43:48.778Z" }, ] +[[package]] +name = "boto3" +version = "1.40.61" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/f9/6ef8feb52c3cce5ec3967a535a6114b57ac7949fd166b0f3090c2b06e4e5/boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12", size = 111535, upload-time = "2025-10-28T19:26:57.247Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/61/24/3bf865b07d15fea85b63504856e137029b6acbc73762496064219cdb265d/boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c", size = 139321, upload-time = "2025-10-28T19:26:55.007Z" }, +] + +[[package]] +name = "botocore" +version = "1.40.61" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/a3/81d3a47c2dbfd76f185d3b894f2ad01a75096c006a2dd91f237dca182188/botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd", size = 14393956, upload-time = "2025-10-28T19:26:46.108Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/c5/f6ce561004db45f0b847c2cd9b19c67c6bf348a82018a48cb718be6b58b0/botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7", size = 14055973, upload-time = "2025-10-28T19:26:42.15Z" }, +] + [[package]] name = "bracex" version = "2.6" @@ -762,8 +849,8 @@ dependencies = [ { name = "safetensors", extra = ["torch"] }, { name = "torch", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, - { name = "torchvision", version = "0.23.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or sys_platform == 'darwin'" }, - { name = "torchvision", version = "0.23.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, + { name = "torchvision", version = "0.23.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or sys_platform == 'darwin'" }, + { name = "torchvision", version = "0.23.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, { name = "tqdm" }, { name = "transformers" }, ] @@ -1276,6 +1363,11 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +socks = [ + { name = "socksio" }, +] + [[package]] name = "httpx-sse" version = "0.4.3" @@ -1401,6 +1493,15 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" }, ] +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + [[package]] name = "joblib" version = "1.5.3" @@ -3965,6 +4066,18 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247", size = 13080224, upload-time = "2026-01-15T20:14:45.853Z" }, ] +[[package]] +name = "s3transfer" +version = "0.14.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" }, +] + [[package]] name = "safetensors" version = "0.7.0" @@ -4120,6 +4233,15 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "socksio" +version = "1.0.0" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, +] + [[package]] name = "soupsieve" version = "2.8.1" @@ -4422,16 +4544,18 @@ name = "torchvision" version = "0.23.0" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ + "python_full_version >= '3.13' and platform_machine == 'aarch64' and platform_python_implementation != 'CPython' and sys_platform == 'linux'", + "python_full_version < '3.13' and platform_machine == 'aarch64' and platform_python_implementation != 'CPython' and sys_platform == 'linux'", "python_full_version >= '3.13' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'", "python_full_version < '3.13' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'", "python_full_version >= '3.13' and sys_platform == 'darwin'", "python_full_version < '3.13' and sys_platform == 'darwin'", ] dependencies = [ - { name = "numpy", marker = "(platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or sys_platform == 'darwin'" }, - { name = "pillow", marker = "(platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or sys_platform == 'darwin'" }, + { name = "numpy", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or sys_platform == 'darwin'" }, + { name = "pillow", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or sys_platform == 'darwin'" }, { name = "torch", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, - { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'" }, + { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, ] wheels = [ { url = "https://download.pytorch.org/whl/cpu/torchvision-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e0e2c04a91403e8dd3af9756c6a024a1d9c0ed9c0d592a8314ded8f4fe30d440" }, @@ -4448,14 +4572,12 @@ version = "0.23.0+cpu" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ "(python_full_version >= '3.13' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')", - "python_full_version >= '3.13' and platform_machine == 'aarch64' and platform_python_implementation != 'CPython' and sys_platform == 'linux'", "(python_full_version < '3.13' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')", - "python_full_version < '3.13' and platform_machine == 'aarch64' and platform_python_implementation != 'CPython' and sys_platform == 'linux'", ] dependencies = [ - { name = "numpy", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, - { name = "pillow", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, - { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, + { name = "numpy", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, + { name = "pillow", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, + { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, ] wheels = [ { url = "https://download.pytorch.org/whl/cpu/torchvision-0.23.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ae459d4509d3b837b978dc6c66106601f916b6d2cda75c137e3f5f48324ce1da" }, @@ -4775,6 +4897,21 @@ wheels = [ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, ] +[[package]] +name = "volcengine-python-sdk" +version = "5.0.13" +source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" } +dependencies = [ + { name = "certifi" }, + { name = "python-dateutil" }, + { name = "six" }, + { name = "urllib3" }, +] +sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/83/f60e4e1fb82a4c45bc7353cb9abd742efeb37e5a27bff4c33eb366e104eb/volcengine_python_sdk-5.0.13.tar.gz", hash = "sha256:58d444618b50b0ee573572c63f8d5cf8335234ae96b701f6c06789b6a53285ca", size = 7786741, upload-time = "2026-03-02T13:32:54.995Z" } +wheels = [ + { url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/2c/38df87249e7181a8f120bc1cec84b6f7ebe3ab94ee65d7d82307117ad889/volcengine_python_sdk-5.0.13-py2.py3-none-any.whl", hash = "sha256:53fc6edc86c7665b3d0b871e8ef2db9a7c81fbce03ce2c93bf5f7a98fa6d5dbd", size = 30611404, upload-time = "2026-03-02T13:32:50.256Z" }, +] + [[package]] name = "watchfiles" version = "1.1.1" @@ -5059,6 +5196,8 @@ name = "yuxi-know" version = "0.5.0.dev0" source = { virtual = "." } dependencies = [ + { name = "agent-sandbox" }, + { name = "aioboto3" }, { name = "aiofiles" }, { name = "aiohttp" }, { name = "aiosqlite" }, @@ -5119,12 +5258,13 @@ dependencies = [ { name = "tomli-w" }, { name = "torch", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, { name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, - { name = "torchvision", version = "0.23.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or sys_platform == 'darwin'" }, - { name = "torchvision", version = "0.23.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, + { name = "torchvision", version = "0.23.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or sys_platform == 'darwin'" }, + { name = "torchvision", version = "0.23.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" }, { name = "tqdm" }, { name = "typer" }, { name = "unstructured" }, { name = "uvicorn", extra = ["standard"] }, + { name = "wcmatch" }, ] [package.dev-dependencies] @@ -5140,6 +5280,8 @@ test = [ [package.metadata] requires-dist = [ + { name = "agent-sandbox", specifier = ">=0.0.26" }, + { name = "aioboto3", specifier = ">=13.0.0" }, { name = "aiofiles", specifier = ">=24.1.0" }, { name = "aiohttp", specifier = ">=3.9.0" }, { name = "aiosqlite", specifier = ">=0.20.0" }, @@ -5204,6 +5346,7 @@ requires-dist = [ { name = "typer", specifier = ">=0.16.0" }, { name = "unstructured", specifier = ">=0.17.2" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.2" }, + { name = "wcmatch", specifier = ">=8.0.0" }, ] [package.metadata.requires-dev] diff --git a/web/src/apis/agent_api.js b/web/src/apis/agent_api.js index 6bf52c9b..3f269798 100644 --- a/web/src/apis/agent_api.js +++ b/web/src/apis/agent_api.js @@ -331,6 +331,45 @@ export const threadApi = { */ getThreadAttachments: (threadId) => apiGet(`/api/chat/thread/${threadId}/attachments`), + /** + * 列出线程文件(目录) + * @param {string} threadId + * @param {string} path + * @returns {Promise} + */ + listThreadFiles: (threadId, path = '/mnt/user-data') => + apiGet(`/api/chat/thread/${threadId}/files?path=${encodeURIComponent(path)}`), + + /** + * 读取线程文本文件内容(分页) + * @param {string} threadId + * @param {string} path + * @param {number} offset + * @param {number} limit + * @returns {Promise} + */ + readThreadFile: (threadId, path, offset = 0, limit = 2000) => + apiGet( + `/api/chat/thread/${threadId}/files/content?path=${encodeURIComponent(path)}&offset=${offset}&limit=${limit}` + ), + + /** + * 获取线程文件下载/预览 URL + * @param {string} threadId + * @param {string} path + * @param {boolean} download + * @returns {string} + */ + getThreadArtifactUrl: (threadId, path, download = false) => { + const encodedPath = path + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join('/') + const query = download ? '?download=true' : '' + return `/api/chat/thread/${threadId}/artifacts/${encodedPath}${query}` + }, + /** * 上传附件 * @param {string} threadId diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index 67abb29b..19ca8e54 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -183,6 +183,7 @@ { const currentAgentState = computed(() => { return currentChatId.value ? getThreadState(currentChatId.value)?.agentState || null : null }) - -const countFiles = (files) => { - if (!files) return 0 - if (Array.isArray(files)) { - return files.reduce((c, item) => c + (item && typeof item === 'object' ? Object.keys(item).length : 0), 0) - } - return typeof files === 'object' ? Object.keys(files).length : 0 -} +const currentThreadFiles = computed(() => { + if (!currentChatId.value) return [] + return threadFilesMap.value[currentChatId.value] || [] +}) +const currentThreadAttachments = computed(() => { + if (!currentChatId.value) return [] + return threadAttachmentsMap.value[currentChatId.value] || [] +}) const hasAgentStateContent = computed(() => { const s = currentAgentState.value - if (!s) return false - const todoCount = Array.isArray(s.todos) ? s.todos.length : 0 - const fileCount = countFiles(s.files) + if (!s && currentThreadFiles.value.length === 0) return false + const todoCount = Array.isArray(s?.todos) ? s.todos.length : 0 + const fileCount = currentThreadFiles.value.filter((item) => item?.is_dir !== true).length return todoCount > 0 || fileCount > 0 }) const mentionConfig = computed(() => { - const rawFiles = currentAgentState.value?.files || {} - const files = [] - - // 处理 files - 兼容字典格式 {"/path/file": {content: [...]}} 和旧数组格式 - if (typeof rawFiles === 'object' && !Array.isArray(rawFiles) && rawFiles !== null) { - // 新格式:字典格式 {"/attachments/xxx/file.md": {...}} - Object.entries(rawFiles).forEach(([filePath, fileData]) => { - files.push({ - path: filePath, - ...fileData + const fileMap = new Map() + currentThreadFiles.value + .filter((item) => item && item.is_dir !== true && typeof item.path === 'string') + .forEach((item) => { + fileMap.set(item.path, { + path: item.path, + size: item.size, + modified_at: item.modified_at, + artifact_url: item.artifact_url }) }) - } else if (Array.isArray(rawFiles)) { - // 旧格式:数组格式 - rawFiles.forEach((item) => { - if (typeof item === 'object' && item !== null) { - Object.entries(item).forEach(([filePath, fileData]) => { - files.push({ - path: filePath, - ...fileData - }) + + currentThreadAttachments.value.forEach((item) => { + const candidates = [ + [item?.virtual_path, item?.artifact_url], + [item?.original_virtual_path, item?.original_artifact_url] + ] + candidates.forEach(([path, artifactUrl]) => { + if (typeof path !== 'string' || !path) return + if (!fileMap.has(path)) { + fileMap.set(path, { + path, + artifact_url: artifactUrl || null }) } }) - } + }) + + const files = Array.from(fileMap.values()) // Filter KBs and MCPs based on agent config const configItems = configurableItems.value || {} @@ -546,6 +553,8 @@ const cleanupThreadState = (threadId) => { } delete chatState.threadStates[threadId] } + delete threadFilesMap.value[threadId] + delete threadAttachmentsMap.value[threadId] } // ==================== STREAM HANDLING LOGIC ==================== @@ -606,6 +615,8 @@ const createThread = async (agentId, title = '新的对话') => { if (thread) { threads.value.unshift(thread) threadMessages.value[thread.id] = [] + threadFilesMap.value[thread.id] = [] + threadAttachmentsMap.value[thread.id] = [] } return thread } catch (error) { @@ -626,6 +637,8 @@ const deleteThread = async (threadId) => { await threadApi.deleteThread(threadId) threads.value = threads.value.filter((thread) => thread.id !== threadId) delete threadMessages.value[threadId] + delete threadFilesMap.value[threadId] + delete threadAttachmentsMap.value[threadId] if (chatState.currentThreadId === threadId) { chatState.currentThreadId = null @@ -680,6 +693,53 @@ 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) + } + }) + } + threadFilesMap.value[threadId] = entries + } catch (error) { + console.warn('Failed to fetch thread files:', error) + threadFilesMap.value[threadId] = [] + } +} + +const fetchThreadAttachments = async (threadId) => { + if (!threadId) return + try { + const response = await threadApi.getThreadAttachments(threadId) + threadAttachmentsMap.value[threadId] = Array.isArray(response?.attachments) + ? response.attachments + : [] + } catch (error) { + console.warn('Failed to fetch thread attachments:', error) + threadAttachmentsMap.value[threadId] = [] + } +} + +const refreshThreadFilesAndAttachments = async (threadId) => { + if (!threadId) return + await Promise.all([fetchThreadFiles(threadId), fetchThreadAttachments(threadId)]) +} + const fetchAgentState = async (agentId, threadId) => { if (!agentId || !threadId) return try { @@ -1042,7 +1102,7 @@ const startRunStream = async (threadId, runId, afterSeq = '0') => { ts.lastRetryableJobTry = null clearActiveRunSnapshot(threadId) fetchThreadMessages({ agentId: currentAgentId.value, threadId, delay: 200 }).finally(() => { - fetchAgentState(currentAgentId.value, threadId) + handleAgentStateRefresh(threadId) }) } else if (ts.activeRunId === runId) { window.setTimeout(() => { @@ -1061,7 +1121,7 @@ const startRunStream = async (threadId, runId, afterSeq = '0') => { clearActiveRunSnapshot(threadId) fetchThreadMessages({ agentId: currentAgentId.value, threadId, delay: 300 }).finally(() => { resetOnGoingConv(threadId) - fetchAgentState(currentAgentId.value, threadId) + handleAgentStateRefresh(threadId) scrollController.scrollToBottom() }) } @@ -1292,7 +1352,7 @@ const selectChat = async (chatId) => { await nextTick() scrollController.scrollToBottomStaticForce() - await fetchAgentState(currentAgentId.value, chatId) + await handleAgentStateRefresh(chatId) await resumeActiveRunForThread(chatId) } @@ -1420,7 +1480,7 @@ const handleSendMessage = async ({ image } = {}) => { () => { // 历史记录加载完成后,安全地清空当前进行中的对话 resetOnGoingConv(threadId) - fetchAgentState(currentAgentId.value, threadId) + handleAgentStateRefresh(threadId) scrollController.scrollToBottom() } ) @@ -1559,7 +1619,10 @@ const handleAgentStateRefresh = async (threadId = null) => { if (!currentAgentId.value) return const chatId = threadId || currentChatId.value if (!chatId) return - await fetchAgentState(currentAgentId.value, chatId) + await Promise.all([ + fetchAgentState(currentAgentId.value, chatId), + refreshThreadFilesAndAttachments(chatId) + ]) } const toggleAgentPanel = () => { @@ -1645,6 +1708,8 @@ const loadChatsList = async () => { console.warn('No agent selected, cannot load chats list') threads.value = [] chatState.currentThreadId = null + threadFilesMap.value = {} + threadAttachmentsMap.value = {} return } @@ -1691,6 +1756,8 @@ watch( // 清理当前线程状态 chatState.currentThreadId = null threadMessages.value = {} + threadFilesMap.value = {} + threadAttachmentsMap.value = {} // 清理所有线程状态 resetOnGoingConv() diff --git a/web/src/components/AgentPanel.vue b/web/src/components/AgentPanel.vue index 476b28b4..7d4598d7 100644 --- a/web/src/components/AgentPanel.vue +++ b/web/src/components/AgentPanel.vue @@ -136,7 +136,7 @@