feat: integrate provisioner sandbox and shared thread file APIs
This commit is contained in:
parent
a4e5486e51
commit
e356b3fcec
@ -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/
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
15
docker/sandbox_provisioner/Dockerfile
Normal file
15
docker/sandbox_provisioner/Dockerfile
Normal 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"]
|
||||
336
docker/sandbox_provisioner/app.py
Normal file
336
docker/sandbox_provisioner/app.py
Normal 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)
|
||||
3
docker/sandbox_provisioner/requirements.txt
Normal file
3
docker/sandbox_provisioner/requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
fastapi>=0.121
|
||||
uvicorn[standard]>=0.34.2
|
||||
kubernetes>=31.0.0
|
||||
@ -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 # 代码最大行宽
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# > === 消息反馈分组 ===
|
||||
# =============================================================================
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
35
src/sandbox/__init__.py
Normal 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
208
src/sandbox/backend.py
Normal 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
88
src/sandbox/paths.py
Normal 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
131
src/sandbox/provider.py
Normal 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()
|
||||
73
src/sandbox/provisioner_client.py
Normal file
73
src/sandbox/provisioner_client.py
Normal 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}"
|
||||
)
|
||||
@ -1,23 +1,29 @@
|
||||
import shlex
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from langgraph.types import Command
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.agents import agent_manager
|
||||
from src.repositories.conversation_repository import ConversationRepository
|
||||
from src.sandbox import (
|
||||
ProvisionerSandboxBackend,
|
||||
ensure_thread_dirs,
|
||||
get_sandbox_provider,
|
||||
sandbox_uploads_dir,
|
||||
)
|
||||
from src.services.doc_converter import (
|
||||
ATTACHMENT_ALLOWED_EXTENSIONS,
|
||||
MAX_ATTACHMENT_SIZE_BYTES,
|
||||
convert_upload_to_markdown,
|
||||
)
|
||||
from src.storage.minio.client import get_minio_client
|
||||
from src.utils.datetime_utils import utc_isoformat
|
||||
from src.utils.logging_config import logger
|
||||
|
||||
# 附件存储桶名称
|
||||
ATTACHMENTS_BUCKET = "chat-attachments"
|
||||
UPLOADS_VIRTUAL_PREFIX = "/mnt/user-data/uploads"
|
||||
LEGACY_ATTACHMENTS_PREFIX = "/attachments/"
|
||||
|
||||
|
||||
async def require_user_conversation(conv_repo: ConversationRepository, thread_id: str, user_id: str):
|
||||
@ -27,21 +33,27 @@ async def require_user_conversation(conv_repo: ConversationRepository, thread_id
|
||||
return conversation
|
||||
|
||||
|
||||
def _make_attachment_path(file_name: str) -> str:
|
||||
"""生成附件在文件系统中的路径(无需 thread_id,state 已隔离)
|
||||
|
||||
统一使用 .md 扩展名,因为文件内容已经是 Markdown 格式
|
||||
"""
|
||||
# 提取不带扩展名的部分
|
||||
def _sanitize_file_stem(file_name: str) -> str:
|
||||
base_name = file_name
|
||||
for ext in [".docx", ".txt", ".html", ".htm", ".pdf", ".md"]:
|
||||
if file_name.lower().endswith(ext):
|
||||
base_name = file_name[: -len(ext)]
|
||||
break
|
||||
safe_name = base_name.replace("/", "_").replace("\\", "_").strip(" .")
|
||||
return safe_name or "attachment"
|
||||
|
||||
# 替换路径分隔符
|
||||
safe_name = base_name.replace("/", "_").replace("\\", "_")
|
||||
return f"/attachments/{safe_name}.md"
|
||||
|
||||
def _make_attachment_markdown_virtual_path(file_name: str) -> str:
|
||||
return f"{UPLOADS_VIRTUAL_PREFIX}/{_sanitize_file_stem(file_name)}.md"
|
||||
|
||||
|
||||
def _make_attachment_original_virtual_path(file_name: str) -> str:
|
||||
safe_name = file_name.replace("/", "_").replace("\\", "_").strip(" .")
|
||||
return f"{UPLOADS_VIRTUAL_PREFIX}/{safe_name or 'attachment.bin'}"
|
||||
|
||||
|
||||
def _artifact_url(thread_id: str, virtual_path: str) -> str:
|
||||
return f"/api/chat/thread/{thread_id}/artifacts/{virtual_path.lstrip('/')}"
|
||||
|
||||
|
||||
def _build_state_files(attachments: list[dict]) -> dict:
|
||||
@ -80,19 +92,18 @@ async def _sync_thread_attachment_state(
|
||||
graph = await agent.get_graph()
|
||||
config = {"configurable": {"thread_id": thread_id, "user_id": str(user_id)}}
|
||||
|
||||
# 先获取现有 state,保留非附件文件
|
||||
state = await graph.aget_state(config)
|
||||
state_values = getattr(state, "values", {}) if state else {}
|
||||
existing_files = state_values.get("files", {}) if isinstance(state_values, dict) else {}
|
||||
if not isinstance(existing_files, dict):
|
||||
existing_files = {}
|
||||
|
||||
# 仅对 /attachments 命名空间做增量更新,避免覆盖 agent 运行期生成的其它文件。
|
||||
next_attachment_files = _build_state_files(attachments)
|
||||
prev_attachment_paths = {
|
||||
path
|
||||
for path in existing_files.keys()
|
||||
if isinstance(path, str) and path.startswith("/attachments/")
|
||||
if isinstance(path, str)
|
||||
and (path.startswith(LEGACY_ATTACHMENTS_PREFIX) or path.startswith(f"{UPLOADS_VIRTUAL_PREFIX}/"))
|
||||
}
|
||||
next_attachment_paths = set(next_attachment_files.keys())
|
||||
|
||||
@ -100,7 +111,6 @@ async def _sync_thread_attachment_state(
|
||||
for removed_path in prev_attachment_paths - next_attachment_paths:
|
||||
file_updates[removed_path] = None
|
||||
|
||||
# 使用 Command 确保 reducer 被正确应用
|
||||
await graph.aupdate_state(
|
||||
config=config,
|
||||
values={
|
||||
@ -108,12 +118,11 @@ async def _sync_thread_attachment_state(
|
||||
"files": file_updates,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync attachment state for thread {thread_id}: {e}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(f"Failed to sync attachment state for thread {thread_id}: {exc}")
|
||||
|
||||
|
||||
def serialize_attachment(record: dict) -> dict:
|
||||
"""序列化附件记录,返回给前端"""
|
||||
return {
|
||||
"file_id": record.get("file_id"),
|
||||
"file_name": record.get("file_name"),
|
||||
@ -122,7 +131,11 @@ def serialize_attachment(record: dict) -> dict:
|
||||
"status": record.get("status", "parsed"),
|
||||
"uploaded_at": record.get("uploaded_at"),
|
||||
"truncated": record.get("truncated", False),
|
||||
"minio_url": record.get("minio_url"), # 仅用于前端下载
|
||||
"virtual_path": record.get("virtual_path") or record.get("file_path"),
|
||||
"artifact_url": record.get("artifact_url"),
|
||||
"original_virtual_path": record.get("original_virtual_path"),
|
||||
"original_artifact_url": record.get("original_artifact_url"),
|
||||
"minio_url": record.get("minio_url"),
|
||||
}
|
||||
|
||||
|
||||
@ -237,27 +250,31 @@ async def upload_thread_attachment_view(
|
||||
logger.error(f"附件解析失败: {exc}")
|
||||
raise HTTPException(status_code=500, detail="附件解析失败,请稍后重试") from exc
|
||||
|
||||
# 生成文件路径
|
||||
file_path = _make_attachment_path(conversion.file_name)
|
||||
markdown_virtual_path = _make_attachment_markdown_virtual_path(conversion.file_name)
|
||||
original_virtual_path = _make_attachment_original_virtual_path(conversion.file_name)
|
||||
markdown_artifact_url = _artifact_url(thread_id, markdown_virtual_path)
|
||||
original_artifact_url = _artifact_url(thread_id, original_virtual_path)
|
||||
|
||||
# 上传源文件到 MinIO(用于前端下载)
|
||||
minio_url = None
|
||||
try:
|
||||
file_content = await file.read()
|
||||
await file.seek(0)
|
||||
client = get_minio_client()
|
||||
object_name = f"attachments/{thread_id}/{conversion.file_name}"
|
||||
result = client.upload_file(
|
||||
bucket_name=ATTACHMENTS_BUCKET,
|
||||
object_name=object_name,
|
||||
data=file_content,
|
||||
content_type=conversion.file_type or "application/octet-stream",
|
||||
ensure_thread_dirs(thread_id)
|
||||
uploads_dir = sandbox_uploads_dir(thread_id)
|
||||
markdown_actual_path = uploads_dir / Path(markdown_virtual_path).name
|
||||
original_actual_path = uploads_dir / Path(original_virtual_path).name
|
||||
|
||||
await file.seek(0)
|
||||
file_content = await file.read()
|
||||
markdown_actual_path.write_text(conversion.markdown, encoding="utf-8")
|
||||
original_actual_path.write_bytes(file_content)
|
||||
|
||||
provider = get_sandbox_provider()
|
||||
connection = provider.get(thread_id, create_if_missing=False)
|
||||
if connection is not None:
|
||||
backend = ProvisionerSandboxBackend(thread_id=thread_id)
|
||||
backend.upload_files(
|
||||
[
|
||||
(markdown_virtual_path, conversion.markdown.encode("utf-8")),
|
||||
(original_virtual_path, file_content),
|
||||
]
|
||||
)
|
||||
minio_url = result.public_url
|
||||
logger.info(f"Uploaded attachment to MinIO: {object_name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upload attachment to MinIO: {e}")
|
||||
# 继续处理,不因为上传失败而中断
|
||||
|
||||
attachment_record = {
|
||||
"file_id": conversion.file_id,
|
||||
@ -268,8 +285,14 @@ async def upload_thread_attachment_view(
|
||||
"markdown": conversion.markdown,
|
||||
"uploaded_at": utc_isoformat(),
|
||||
"truncated": conversion.truncated,
|
||||
"file_path": file_path, # 用于 StateBackend,前端不返回此字段
|
||||
"minio_url": minio_url, # 暂未使用
|
||||
"file_path": markdown_virtual_path,
|
||||
"virtual_path": markdown_virtual_path,
|
||||
"artifact_url": markdown_artifact_url,
|
||||
"original_virtual_path": original_virtual_path,
|
||||
"original_artifact_url": original_artifact_url,
|
||||
"minio_url": None,
|
||||
"storage_path": str(markdown_actual_path),
|
||||
"original_storage_path": str(original_actual_path),
|
||||
}
|
||||
await conv_repo.add_attachment(conversation.id, attachment_record)
|
||||
all_attachments = await conv_repo.get_attachments(conversation.id)
|
||||
@ -310,9 +333,28 @@ async def delete_thread_attachment_view(
|
||||
) -> dict:
|
||||
conv_repo = ConversationRepository(db)
|
||||
conversation = await require_user_conversation(conv_repo, thread_id, str(current_user_id))
|
||||
|
||||
existing_attachments = await conv_repo.get_attachments(conversation.id)
|
||||
target_attachment = next((item for item in existing_attachments if item.get("file_id") == file_id), None)
|
||||
|
||||
removed = await conv_repo.remove_attachment(conversation.id, file_id)
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail="附件不存在或已被删除")
|
||||
|
||||
if target_attachment:
|
||||
for candidate in [
|
||||
target_attachment.get("storage_path"),
|
||||
target_attachment.get("original_storage_path"),
|
||||
]:
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
file_path = Path(candidate)
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning(f"Failed to remove attachment file {candidate}: {exc}")
|
||||
|
||||
all_attachments = await conv_repo.get_attachments(conversation.id)
|
||||
await _sync_thread_attachment_state(
|
||||
thread_id=thread_id,
|
||||
@ -320,4 +362,20 @@ async def delete_thread_attachment_view(
|
||||
agent_id=conversation.agent_id,
|
||||
attachments=all_attachments,
|
||||
)
|
||||
|
||||
if target_attachment:
|
||||
provider = get_sandbox_provider()
|
||||
connection = provider.get(thread_id, create_if_missing=False)
|
||||
if connection is not None:
|
||||
backend = ProvisionerSandboxBackend(thread_id=thread_id)
|
||||
delete_commands = []
|
||||
for path in [
|
||||
target_attachment.get("virtual_path") or target_attachment.get("file_path"),
|
||||
target_attachment.get("original_virtual_path"),
|
||||
]:
|
||||
if isinstance(path, str) and path.strip():
|
||||
delete_commands.append(f"rm -f {shlex.quote(path)}")
|
||||
if delete_commands:
|
||||
backend.execute(" && ".join(delete_commands))
|
||||
|
||||
return {"message": "附件已删除"}
|
||||
|
||||
141
src/services/thread_files_service.py
Normal file
141
src/services/thread_files_service.py
Normal 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
169
uv.lock
@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
revision = 2
|
||||
requires-python = ">=3.12, <3.14"
|
||||
resolution-markers = [
|
||||
"(python_full_version >= '3.13' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
@ -31,6 +31,56 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/d2/c581486aa6c4fbd7394c23c47b83fa1a919d34194e16944241daf9e762dd/accelerate-1.12.0-py3-none-any.whl", hash = "sha256:3e2091cd341423207e2f084a6654b1efcd250dc326f2a37d6dde446e07cabb11", size = 380935, upload-time = "2025-11-21T11:27:44.522Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "agent-sandbox"
|
||||
version = "0.0.26"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx", extra = ["socks"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "volcengine-python-sdk" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/68/931c61b5e28ee344b5c2301bf56739f7fc90767da007c9e44a9b0a1ea937/agent_sandbox-0.0.26.tar.gz", hash = "sha256:67ec87e58794d017f6be321fc46913d76e84453d0cfe426a5f4b9bf8f5bd619b", size = 98635, upload-time = "2026-03-02T08:47:24.853Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/26/b5/2748e86137ee757b749d2fb7f5504e039f780c9346be798870ae01ee1559/agent_sandbox-0.0.26-py2.py3-none-any.whl", hash = "sha256:6421fc4eb6144f10ddfdd2668c6644e132056eaeb6ab89d76a0b2e4bcf327a0a", size = 216489, upload-time = "2026-03-02T08:47:23.351Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aioboto3"
|
||||
version = "15.5.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiobotocore", extra = ["boto3"] },
|
||||
{ name = "aiofiles" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/01/92e9ab00f36e2899315f49eefcd5b4685fbb19016c7f19a9edf06da80bb0/aioboto3-15.5.0.tar.gz", hash = "sha256:ea8d8787d315594842fbfcf2c4dce3bac2ad61be275bc8584b2ce9a3402a6979", size = 255069, upload-time = "2025-10-30T13:37:16.122Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/3e/e8f5b665bca646d43b916763c901e00a07e40f7746c9128bdc912a089424/aioboto3-15.5.0-py3-none-any.whl", hash = "sha256:cc880c4d6a8481dd7e05da89f41c384dbd841454fc1998ae25ca9c39201437a6", size = 35913, upload-time = "2025-10-30T13:37:14.549Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiobotocore"
|
||||
version = "2.25.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "aioitertools" },
|
||||
{ name = "botocore" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "multidict" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/94/2e4ec48cf1abb89971cb2612d86f979a6240520f0a659b53a43116d344dc/aiobotocore-2.25.1.tar.gz", hash = "sha256:ea9be739bfd7ece8864f072ec99bb9ed5c7e78ebb2b0b15f29781fbe02daedbc", size = 120560, upload-time = "2025-10-28T22:33:21.787Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/2a/d275ec4ce5cd0096665043995a7d76f5d0524853c76a3d04656de49f8808/aiobotocore-2.25.1-py3-none-any.whl", hash = "sha256:eb6daebe3cbef5b39a0bb2a97cffbe9c7cb46b2fcc399ad141f369f3c2134b1f", size = 86039, upload-time = "2025-10-28T22:33:19.949Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
boto3 = [
|
||||
{ name = "boto3" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
version = "25.1.0"
|
||||
@ -100,6 +150,15 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aioitertools"
|
||||
version = "0.13.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosignal"
|
||||
version = "1.4.0"
|
||||
@ -319,6 +378,34 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/c1/84fc6811122f54b20de2e5afb312ee07a3a47a328755587d1e505475239b/blockbuster-1.5.26-py3-none-any.whl", hash = "sha256:f8e53fb2dd4b6c6ec2f04907ddbd063ca7cd1ef587d24448ef4e50e81e3a79bb", size = 13226, upload-time = "2025-12-05T10:43:48.778Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.40.61"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "s3transfer" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/f9/6ef8feb52c3cce5ec3967a535a6114b57ac7949fd166b0f3090c2b06e4e5/boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12", size = 111535, upload-time = "2025-10-28T19:26:57.247Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/61/24/3bf865b07d15fea85b63504856e137029b6acbc73762496064219cdb265d/boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c", size = 139321, upload-time = "2025-10-28T19:26:55.007Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.40.61"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/28/a3/81d3a47c2dbfd76f185d3b894f2ad01a75096c006a2dd91f237dca182188/botocore-1.40.61.tar.gz", hash = "sha256:a2487ad69b090f9cccd64cf07c7021cd80ee9c0655ad974f87045b02f3ef52cd", size = 14393956, upload-time = "2025-10-28T19:26:46.108Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/c5/f6ce561004db45f0b847c2cd9b19c67c6bf348a82018a48cb718be6b58b0/botocore-1.40.61-py3-none-any.whl", hash = "sha256:17ebae412692fd4824f99cde0f08d50126dc97954008e5ba2b522eb049238aa7", size = 14055973, upload-time = "2025-10-28T19:26:42.15Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bracex"
|
||||
version = "2.6"
|
||||
@ -762,8 +849,8 @@ dependencies = [
|
||||
{ name = "safetensors", extra = ["torch"] },
|
||||
{ name = "torch", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" },
|
||||
{ name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" },
|
||||
{ name = "torchvision", version = "0.23.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or sys_platform == 'darwin'" },
|
||||
{ name = "torchvision", version = "0.23.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||
{ name = "torchvision", version = "0.23.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
|
||||
{ name = "torchvision", version = "0.23.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "transformers" },
|
||||
]
|
||||
@ -1276,6 +1363,11 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
socks = [
|
||||
{ name = "socksio" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.3"
|
||||
@ -1401,6 +1493,15 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2f/9c/6753e6522b8d0ef07d3a3d239426669e984fb0eba15a315cdbc1253904e4/jiter-0.12.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c24e864cb30ab82311c6425655b0cdab0a98c5d973b065c66a3f020740c2324c", size = 346110, upload-time = "2025-11-09T20:49:21.817Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jmespath"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "joblib"
|
||||
version = "1.5.3"
|
||||
@ -3965,6 +4066,18 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247", size = 13080224, upload-time = "2026-01-15T20:14:45.853Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "s3transfer"
|
||||
version = "0.14.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "safetensors"
|
||||
version = "0.7.0"
|
||||
@ -4120,6 +4233,15 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socksio"
|
||||
version = "1.0.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.8.1"
|
||||
@ -4422,16 +4544,18 @@ name = "torchvision"
|
||||
version = "0.23.0"
|
||||
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.13' and platform_machine == 'aarch64' and platform_python_implementation != 'CPython' and sys_platform == 'linux'",
|
||||
"python_full_version < '3.13' and platform_machine == 'aarch64' and platform_python_implementation != 'CPython' and sys_platform == 'linux'",
|
||||
"python_full_version >= '3.13' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'",
|
||||
"python_full_version < '3.13' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'",
|
||||
"python_full_version >= '3.13' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.13' and sys_platform == 'darwin'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", marker = "(platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or sys_platform == 'darwin'" },
|
||||
{ name = "pillow", marker = "(platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or sys_platform == 'darwin'" },
|
||||
{ name = "numpy", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
|
||||
{ name = "pillow", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
|
||||
{ name = "torch", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" },
|
||||
{ name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'" },
|
||||
{ name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torchvision-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e0e2c04a91403e8dd3af9756c6a024a1d9c0ed9c0d592a8314ded8f4fe30d440" },
|
||||
@ -4448,14 +4572,12 @@ version = "0.23.0+cpu"
|
||||
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
||||
resolution-markers = [
|
||||
"(python_full_version >= '3.13' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version >= '3.13' and platform_machine == 'aarch64' and platform_python_implementation != 'CPython' and sys_platform == 'linux'",
|
||||
"(python_full_version < '3.13' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version < '3.13' and platform_machine == 'aarch64' and platform_python_implementation != 'CPython' and sys_platform == 'linux'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||
{ name = "pillow", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||
{ name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||
{ name = "numpy", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||
{ name = "pillow", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||
{ name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://download.pytorch.org/whl/cpu/torchvision-0.23.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ae459d4509d3b837b978dc6c66106601f916b6d2cda75c137e3f5f48324ce1da" },
|
||||
@ -4775,6 +4897,21 @@ wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "volcengine-python-sdk"
|
||||
version = "5.0.13"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "six" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/83/f60e4e1fb82a4c45bc7353cb9abd742efeb37e5a27bff4c33eb366e104eb/volcengine_python_sdk-5.0.13.tar.gz", hash = "sha256:58d444618b50b0ee573572c63f8d5cf8335234ae96b701f6c06789b6a53285ca", size = 7786741, upload-time = "2026-03-02T13:32:54.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/2c/38df87249e7181a8f120bc1cec84b6f7ebe3ab94ee65d7d82307117ad889/volcengine_python_sdk-5.0.13-py2.py3-none-any.whl", hash = "sha256:53fc6edc86c7665b3d0b871e8ef2db9a7c81fbce03ce2c93bf5f7a98fa6d5dbd", size = 30611404, upload-time = "2026-03-02T13:32:50.256Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.1.1"
|
||||
@ -5059,6 +5196,8 @@ name = "yuxi-know"
|
||||
version = "0.5.0.dev0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "agent-sandbox" },
|
||||
{ name = "aioboto3" },
|
||||
{ name = "aiofiles" },
|
||||
{ name = "aiohttp" },
|
||||
{ name = "aiosqlite" },
|
||||
@ -5119,12 +5258,13 @@ dependencies = [
|
||||
{ name = "tomli-w" },
|
||||
{ name = "torch", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" },
|
||||
{ name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" },
|
||||
{ name = "torchvision", version = "0.23.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or sys_platform == 'darwin'" },
|
||||
{ name = "torchvision", version = "0.23.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||
{ name = "torchvision", version = "0.23.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or sys_platform == 'darwin'" },
|
||||
{ name = "torchvision", version = "0.23.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "typer" },
|
||||
{ name = "unstructured" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "wcmatch" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@ -5140,6 +5280,8 @@ test = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-sandbox", specifier = ">=0.0.26" },
|
||||
{ name = "aioboto3", specifier = ">=13.0.0" },
|
||||
{ name = "aiofiles", specifier = ">=24.1.0" },
|
||||
{ name = "aiohttp", specifier = ">=3.9.0" },
|
||||
{ name = "aiosqlite", specifier = ">=0.20.0" },
|
||||
@ -5204,6 +5346,7 @@ requires-dist = [
|
||||
{ name = "typer", specifier = ">=0.16.0" },
|
||||
{ name = "unstructured", specifier = ">=0.17.2" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.2" },
|
||||
{ name = "wcmatch", specifier = ">=8.0.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user