feat: integrate provisioner sandbox and shared thread file APIs

This commit is contained in:
ztxiao 2026-03-04 08:53:50 +08:00
parent a4e5486e51
commit e356b3fcec
23 changed files with 1926 additions and 665 deletions

View File

@ -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/

View File

@ -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:

View File

@ -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:

View File

@ -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"]

View File

@ -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)

View File

@ -0,0 +1,3 @@
fastapi>=0.121
uvicorn[standard]>=0.34.2
kubernetes>=31.0.0

View File

@ -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 # 代码最大行宽

View File

@ -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)
# =============================================================================
# > === 消息反馈分组 ===
# =============================================================================

View File

@ -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()

View File

@ -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),
},

View File

@ -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

View File

@ -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()

35
src/sandbox/__init__.py Normal file
View File

@ -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",
]

208
src/sandbox/backend.py Normal file
View File

@ -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

88
src/sandbox/paths.py Normal file
View File

@ -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()}"

131
src/sandbox/provider.py Normal file
View File

@ -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()

View File

@ -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}"
)

View File

@ -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_idstate 已隔离)
统一使用 .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": "附件已删除"}

View File

@ -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

169
uv.lock
View File

@ -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]

View File

@ -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

View File

@ -183,6 +183,7 @@
<AgentPanel
v-if="isAgentPanelOpen && hasAgentStateContent"
:agent-state="currentAgentState"
:thread-files="currentThreadFiles"
:thread-id="currentChatId"
:panel-ratio="panelRatio"
@refresh="handleAgentStateRefresh"
@ -286,6 +287,8 @@ const chatState = reactive({
// 线
const threads = ref([])
const threadMessages = ref({})
const threadFilesMap = ref({})
const threadAttachmentsMap = ref({})
// UI 使
const localUIState = reactive({
@ -352,49 +355,53 @@ const currentCapabilities = computed(() => {
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()

View File

@ -136,7 +136,7 @@
<script setup>
import { computed, ref, onMounted, onUpdated, nextTick } from 'vue'
import { Download, X, FolderCode, RefreshCw, Folder, FolderOpen } from 'lucide-vue-next'
import { Download, X, FolderCode } from 'lucide-vue-next'
import {
CheckCircleOutlined,
SyncOutlined,
@ -147,7 +147,8 @@ import {
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/preview.css'
import { useThemeStore } from '@/stores/theme'
import { getFileIcon, getFileIconColor, formatFileSize } from '@/utils/file_utils'
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
import { threadApi } from '@/apis'
import FileTreeComponent from '@/components/FileTreeComponent.vue'
const props = defineProps({
@ -155,6 +156,10 @@ const props = defineProps({
type: Object,
default: () => ({})
},
threadFiles: {
type: Array,
default: () => []
},
threadId: {
type: String,
default: null
@ -183,10 +188,6 @@ const todos = computed(() => {
return props.agentState?.todos || []
})
const files = computed(() => {
return props.agentState?.files || []
})
const completedCount = computed(() => {
return todos.value.filter((t) => t.status === 'completed').length
})
@ -229,35 +230,11 @@ onUpdated(() => {
nextTick(checkOverflow)
})
//
const normalizedFiles = computed(() => {
const rawFiles = files.value
const result = []
// {"/path/file": {content: [...]}}
if (typeof rawFiles === 'object' && !Array.isArray(rawFiles) && rawFiles !== null) {
//
Object.entries(rawFiles).forEach(([filePath, fileData]) => {
result.push({
path: filePath,
...fileData
})
})
} else if (Array.isArray(rawFiles)) {
//
rawFiles.forEach((item) => {
if (typeof item === 'object' && item !== null) {
Object.entries(item).forEach(([filePath, fileData]) => {
result.push({
path: filePath,
...fileData
})
})
}
})
}
return result
if (!Array.isArray(props.threadFiles)) return []
return props.threadFiles
.filter((item) => item && typeof item.path === 'string')
.map((item) => ({ ...item }))
})
const expandedKeys = ref([])
@ -284,7 +261,12 @@ const buildTreeData = (filesList) => {
}
filesList.forEach((file) => {
const cleanPath = file.path.startsWith('/') ? file.path.slice(1) : file.path
const userDataPrefix = '/mnt/user-data/'
const cleanPath = file.path.startsWith(userDataPrefix)
? file.path.slice(userDataPrefix.length)
: file.path.startsWith('/')
? file.path.slice(1)
: file.path
const parts = cleanPath.split('/')
let currentLevel = root
let currentPath = ''
@ -294,6 +276,7 @@ const buildTreeData = (filesList) => {
currentPath = currentPath ? `${currentPath}/${part}` : part
if (isLast) {
const isLeaf = file.is_dir !== true
const nameParts = part.split('.')
let nameStart = part
let nameEnd = ''
@ -318,15 +301,19 @@ const buildTreeData = (filesList) => {
}
}
currentLevel.push({
key: currentPath,
title: part,
nameStart,
nameEnd,
isLeaf: true,
fileData: file,
class: 'file-node'
})
if (isLeaf) {
currentLevel.push({
key: currentPath,
title: part,
nameStart,
nameEnd,
isLeaf: true,
fileData: file,
class: 'file-node'
})
} else {
findOrCreateFolder(currentLevel, currentPath, part)
}
} else {
const folderNode = findOrCreateFolder(currentLevel, currentPath, part)
currentLevel = folderNode.children
@ -350,36 +337,18 @@ const buildTreeData = (filesList) => {
return root
}
// Helper to truncate filename with tail preservation
const truncateFilename = (name) => {
if (!name) return ''
// This is a visual truncation helper; for true dynamic CSS truncation,
// we'd need a more complex setup. Here we rely on CSS text-overflow
// but if we want specifically "last 5 chars" visible, we might need
// to split the string if we were using a JS-only approach.
// However, the user asked for "show ellipsis, and last 5 chars".
// CSS `text-overflow: ellipsis` puts it at the end.
// To do middle truncation via CSS is hard.
// Let's try to do it via JS for the title attribute, but for visual
// we might use a CSS trick or just standard ellipsis if the JS one is too static.
// Let's stick to standard ellipsis for now but maybe try to implement the requested logic if possible.
// Actually, pure CSS start/end truncation is tricky.
// Let's provide a computed display name logic in the template or a method.
return name
}
const fileTreeData = computed(() => buildTreeData(normalizedFiles.value))
const onFileSelect = (selectedKeys, { node }) => {
if (node.isLeaf) {
if (node.isLeaf && props.threadId) {
if (node.fileData) {
showFileContent(node.key, node.fileData)
showFileContent(node.fileData)
}
}
}
const fileCount = computed(() => {
return normalizedFiles.value.length
return normalizedFiles.value.filter((item) => item.is_dir !== true).length
})
//
@ -390,31 +359,25 @@ const getFileName = (fileItem) => {
return '未知文件'
}
const formatDate = (dateString) => {
if (!dateString) return ''
try {
const date = new Date(dateString)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
} catch (error) {
return dateString
}
}
const formatContent = (contentArray) => {
if (!Array.isArray(contentArray)) return String(contentArray)
return contentArray.join('\n')
}
const showFileContent = (filePath, fileData) => {
currentFilePath.value = filePath
currentFile.value = fileData
modalVisible.value = true
const showFileContent = async (fileData) => {
if (!props.threadId || !fileData?.path) return
currentFilePath.value = fileData.path
try {
const response = await threadApi.readThreadFile(props.threadId, fileData.path, 0, 2000)
currentFile.value = {
...fileData,
content: Array.isArray(response?.content) ? response.content : [],
artifact_url: response?.artifact_url || fileData.artifact_url
}
modalVisible.value = true
} catch (error) {
console.error('读取文件内容失败:', error)
}
}
const closeModal = () => {
@ -425,25 +388,9 @@ const closeModal = () => {
const downloadFile = (fileItem) => {
try {
// /attachments/ Markdown
if (fileItem.path?.startsWith('/attachments/') && fileItem.content) {
const content = formatContent(fileItem.content)
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
if (fileItem.artifact_url) {
const link = document.createElement('a')
link.href = url
link.download = getFileName(fileItem)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
return
}
// 使 minio_url
if (fileItem.minio_url) {
const link = document.createElement('a')
link.href = fileItem.minio_url
link.href = fileItem.artifact_url
link.download = getFileName(fileItem)
link.target = '_blank'
document.body.appendChild(link)
@ -451,18 +398,6 @@ const downloadFile = (fileItem) => {
document.body.removeChild(link)
return
}
// content
const content = formatContent(fileItem.content)
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = getFileName(fileItem)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
} catch (error) {
console.error('下载文件失败:', error)
}