From 589789607456a8dc39040a338444d556dce997ec Mon Sep 17 00:00:00 2001 From: Jojo Du Date: Mon, 31 Aug 2026 16:52:56 +0800 Subject: [PATCH] feat(api): add public run attachment upload --- backend/.env.example | 2 + backend/app/api/chat.py | 13 +- backend/app/config.py | 2 + backend/app/public_api/app.py | 16 +- backend/app/public_api/attachments.py | 121 +++++++ backend/app/public_api/runs.py | 54 ++- backend/app/session/attachments.py | 2 +- backend/tests/test_public_api_attachments.py | 334 +++++++++++++++++++ backend/tests/test_public_api_v1.py | 1 + docs/open-api-v1.md | 38 +++ 10 files changed, 562 insertions(+), 21 deletions(-) create mode 100644 backend/app/public_api/attachments.py create mode 100644 backend/tests/test_public_api_attachments.py diff --git a/backend/.env.example b/backend/.env.example index 4f2c369d6..aa6f0c266 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -24,6 +24,8 @@ GENERAL_SKILL_RUNTIME_PACKAGES="requests,httpx" GENERAL_SKILL_RUNTIME_AUTO_INSTALL="true" CHANNEL_SECRET="" STAFFDECK_ROLE="all" +# Maximum bytes accepted for each chat/Public API attachment (default: 12 MiB). +CHAT_ATTACHMENT_MAX_BYTES="12582912" WECHAT_ILINK_BASE_URL="https://ilinkai.weixin.qq.com" CHANNEL_DELIVERY_POLL_SECONDS="1.0" CHANNEL_DELIVERY_MAX_ATTEMPTS="8" diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 8c18d1e32..6402deaf6 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -20,6 +20,7 @@ from app.agents.branching import model_for_agent, visible_published_skills from app.channels.service_outbox import stage_channel_delivery +from app.config import get_settings from app.core import AgentLoop from app.core.cancellation import cancel_chat_turn, is_chat_turn_cancelled from app.core.capability_manifest import CapabilityManifestBuilder @@ -31,8 +32,8 @@ AgentEvent, AgentProfile, ChatSession, - HarnessTurnRecord, HarnessTaskFrameRecord, + HarnessTurnRecord, HumanHandoffRequest, Message, MessageFeedback, @@ -63,6 +64,7 @@ from app.security.permissions import agent_owned_by_user, is_admin_user from app.security.tenant import ensure_tenant from app.session.attachments import ( + MAX_CHAT_ATTACHMENTS, parse_chat_attachment, validate_chat_turn_attachments, ) @@ -71,12 +73,12 @@ remove_chat_session_workspace, ) from app.session.helpers import public_session +from app.session.message_read import message_read from app.session.message_visibility import ( internal_message_turn_ids, visible_message_content, visible_message_rows, ) -from app.session.message_read import message_read from app.session.origin import pilotdeck_origin_session_ids from app.session.session_schema import ( ChatAttachmentRead, @@ -101,8 +103,6 @@ STREAM_RELAY_HEARTBEAT_SECONDS = 5.0 STREAM_RELAY_IDLE_TIMEOUT_SECONDS = 660.0 STREAM_INTERRUPTED_TRACEBACK_CHAR_LIMIT = 6000 -MAX_CHAT_ATTACHMENT_BYTES = 12 * 1024 * 1024 -MAX_CHAT_ATTACHMENTS = 8 SESSION_TITLE_SUMMARY_EVENT = "session_title_summarized" SCHEDULE_WEEKDAY_LABELS = ("周一", "周二", "周三", "周四", "周五", "周六", "周日") EVENT_PAYLOAD_META_KEYS = {"id", "event", "type", "event_type", "created_at", "data"} @@ -907,7 +907,7 @@ def _validate_chat_turn_attachments( attachments = validate_chat_turn_attachments( request.attachments, max_attachments=MAX_CHAT_ATTACHMENTS, - max_attachment_bytes=MAX_CHAT_ATTACHMENT_BYTES, + max_attachment_bytes=get_settings().chat_attachment_max_bytes, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -954,9 +954,10 @@ async def upload_chat_attachments( parsed: list[ChatAttachmentRead] = [] from app.session.attachment_store import stage_chat_attachment + max_attachment_bytes = get_settings().chat_attachment_max_bytes for file in files: data = await file.read() - if len(data) > MAX_CHAT_ATTACHMENT_BYTES: + if len(data) > max_attachment_bytes: raise HTTPException(status_code=413, detail=f"{file.filename or '文件'} 超过上传大小限制") attachment = parse_chat_attachment( file.filename or "uploaded-file", diff --git a/backend/app/config.py b/backend/app/config.py index bb9e0d628..54240530f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,6 +1,7 @@ import os as _os from functools import lru_cache +from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -36,6 +37,7 @@ class Settings(BaseSettings): general_skill_network_install: bool = True channel_secret: str = "" staffdeck_role: str = "all" + chat_attachment_max_bytes: int = Field(default=12 * 1024 * 1024, ge=1) wechat_ilink_base_url: str = "https://ilinkai.weixin.qq.com" channel_delivery_poll_seconds: float = 1.0 channel_delivery_max_attempts: int = 8 diff --git a/backend/app/public_api/app.py b/backend/app/public_api/app.py index 0724e8402..8d58d99e9 100644 --- a/backend/app/public_api/app.py +++ b/backend/app/public_api/app.py @@ -9,7 +9,20 @@ from app.config import get_settings from app.db import engine -from app.public_api import agents, credentials, examples, gallery, jobs, operations, resources, runs, sessions, sops, webhooks +from app.public_api import ( + agents, + attachments, + credentials, + examples, + gallery, + jobs, + operations, + resources, + runs, + sessions, + sops, + webhooks, +) from app.public_api.errors import ( PublicAPIError, public_api_error_handler, @@ -63,6 +76,7 @@ def health() -> dict[str, str]: app.include_router(credentials.router) app.include_router(gallery.router) app.include_router(agents.router) + app.include_router(attachments.router) app.include_router(sessions.router) app.include_router(runs.router) app.include_router(jobs.router) diff --git a/backend/app/public_api/attachments.py b/backend/app/public_api/attachments.py new file mode 100644 index 000000000..dce253161 --- /dev/null +++ b/backend/app/public_api/attachments.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, File, UploadFile +from pydantic import ValidationError +from sqlmodel import Session + +from app.config import get_settings +from app.db import get_session +from app.public_api.auth import PublicPrincipal, enforce_agent_access, require_scopes +from app.public_api.errors import PublicAPIError +from app.public_api.sessions import ensure_public_agent +from app.session.attachment_store import read_staged_chat_attachment, stage_chat_attachment +from app.session.attachments import ( + MAX_CHAT_ATTACHMENTS, + parse_chat_attachment, + validate_chat_turn_attachments, +) +from app.session.session_schema import ChatAttachmentRead + +router = APIRouter(tags=["attachments"]) + + +@router.post("/agents/{agent_id}/attachments", response_model=list[ChatAttachmentRead]) +async def upload_public_attachments( + agent_id: str, + files: list[UploadFile] = File(default=[], alias="files[]"), + principal: PublicPrincipal = Depends(require_scopes("runs:create")), + db: Session = Depends(get_session), +) -> list[ChatAttachmentRead]: + enforce_agent_access(principal, agent_id) + ensure_public_agent(db, principal, agent_id) + if not files: + raise PublicAPIError(400, "NO_FILES_UPLOADED", "At least one file is required.") + if len(files) > MAX_CHAT_ATTACHMENTS: + raise PublicAPIError( + 400, + "TOO_MANY_ATTACHMENTS", + f"At most {MAX_CHAT_ATTACHMENTS} attachments may be uploaded at once.", + ) + + max_attachment_bytes = get_settings().chat_attachment_max_bytes + parsed: list[tuple[ChatAttachmentRead, bytes]] = [] + for file in files: + data = await file.read() + if len(data) > max_attachment_bytes: + raise PublicAPIError( + 413, + "ATTACHMENT_TOO_LARGE", + f"{file.filename or 'Attachment'} exceeds the upload size limit.", + ) + parsed.append( + ( + parse_chat_attachment( + file.filename or "uploaded-file", + file.content_type, + data, + extract_text=False, + ), + data, + ) + ) + + staged: list[ChatAttachmentRead] = [] + for attachment, data in parsed: + try: + staged.append( + stage_chat_attachment( + attachment, + data, + tenant_id=principal.tenant_id, + user_id=principal.actor_user.id, + ) + ) + except OSError as exc: + raise PublicAPIError( + 500, + "ATTACHMENT_STAGING_FAILED", + "The attachment could not be staged.", + ) from exc + return staged + + +def validate_staged_run_attachments( + raw_attachments: list[dict[str, Any]], + *, + principal: PublicPrincipal, +) -> list[ChatAttachmentRead]: + if not raw_attachments: + return [] + try: + attachments = [ChatAttachmentRead.model_validate(item) for item in raw_attachments] + normalized = validate_chat_turn_attachments( + attachments, + max_attachments=MAX_CHAT_ATTACHMENTS, + max_attachment_bytes=get_settings().chat_attachment_max_bytes, + ) + except (TypeError, ValueError, ValidationError) as exc: + raise PublicAPIError( + 400, + "INVALID_ATTACHMENT", + "One or more attachments are invalid.", + ) from exc + + for attachment in normalized: + staged_data = read_staged_chat_attachment( + attachment, + tenant_id=principal.tenant_id, + user_id=principal.actor_user.id, + ) + if staged_data is None: + raise PublicAPIError( + 400, + "INVALID_ATTACHMENT", + "One or more attachments are missing or do not match their staged content.", + ) + return normalized + + +__all__ = ["router", "validate_staged_run_attachments"] diff --git a/backend/app/public_api/runs.py b/backend/app/public_api/runs.py index a6facfb1a..b5770217a 100644 --- a/backend/app/public_api/runs.py +++ b/backend/app/public_api/runs.py @@ -2,8 +2,8 @@ import mimetypes import re -from typing import Any import threading +from typing import Any from urllib.parse import quote from fastapi import APIRouter, Depends, Header, Query, Request, Response @@ -16,16 +16,21 @@ from app.core.harness_session_cleanup import harness_task_workspace_path from app.db import engine, get_session from app.db.models import ( + AgentEvent, APIClient, APICredential, APIJob, - AgentEvent, HarnessInvocationRecord, HarnessTaskFrameRecord, HarnessTurnRecord, Message, ) -from app.harness import HarnessArtifactAccessError, normalize_harness_artifact_path, open_harness_artifact +from app.harness import ( + HarnessArtifactAccessError, + normalize_harness_artifact_path, + open_harness_artifact, +) +from app.public_api.attachments import validate_staged_run_attachments from app.public_api.auth import PublicPrincipal, enforce_agent_access, require_scopes from app.public_api.errors import PublicAPIError from app.public_api.idempotency import replay_idempotent_response, store_idempotent_response @@ -34,14 +39,17 @@ ensure_not_cancelled, job_read, register_job_handler, + stream_job_events, update_job, ) -from app.public_api.jobs import stream_job_events from app.public_api.schemas import AgentRunCreate, PublicSessionCreate -from app.public_api.sessions import create_public_session_row, ensure_public_agent, owned_public_session +from app.public_api.sessions import ( + create_public_session_row, + ensure_public_agent, + owned_public_session, +) from app.session.session_schema import ChatAttachmentRead, ChatTurnRequest, ChatTurnResponse - router = APIRouter(tags=["runs"]) _TRACE_EVENT_MAP = { @@ -187,7 +195,10 @@ def execute_run(db: Session, job: APIJob) -> dict[str, Any]: event_type="run.executing", event_data={"session_id": session_id, "engine": "harness_v2"}, ) - attachments = [ChatAttachmentRead.model_validate(item) for item in payload.get("attachments") or []] + attachments = [ + ChatAttachmentRead.model_validate(item) + for item in payload.get("attachments") or [] + ] request = ChatTurnRequest( tenant_id=job.tenant_id, session_id=session_id, @@ -400,15 +411,24 @@ def create_run_route( ensure_public_agent(db, principal, agent_id) if body.session_id: owned_public_session(db, principal, agent_id, body.session_id) - replay = replay_idempotent_response(db, principal, request, body.model_dump(mode="json")) + idempotency_payload = body.model_dump(mode="json") + replay = replay_idempotent_response(db, principal, request, idempotency_payload) if replay: response.status_code = replay[0] return replay[1] + request_payload = dict(idempotency_payload) + request_payload["attachments"] = [ + attachment.model_dump(mode="json") + for attachment in validate_staged_run_attachments( + body.attachments, + principal=principal, + ) + ] job = create_job( db, principal, kind="run", - request_payload=body.model_dump(mode="json"), + request_payload=request_payload, agent_id=agent_id, ) payload = job_read(job).model_dump(mode="json") @@ -416,7 +436,7 @@ def create_run_route( db, principal, request, - body.model_dump(mode="json"), + idempotency_payload, payload, status_code=202, resource_id=job.id, @@ -437,12 +457,20 @@ def create_run_stream_route( ensure_public_agent(db, principal, agent_id) if body.session_id: owned_public_session(db, principal, agent_id, body.session_id) - request_payload = body.model_dump(mode="json") - replay = replay_idempotent_response(db, principal, request, request_payload) + idempotency_payload = body.model_dump(mode="json") + replay = replay_idempotent_response(db, principal, request, idempotency_payload) if replay: run_id = str(replay[1].get("id") or "") job = _owned_run(db, principal, run_id) else: + request_payload = dict(idempotency_payload) + request_payload["attachments"] = [ + attachment.model_dump(mode="json") + for attachment in validate_staged_run_attachments( + body.attachments, + principal=principal, + ) + ] job = create_job( db, principal, @@ -455,7 +483,7 @@ def create_run_stream_route( db, principal, request, - request_payload, + idempotency_payload, payload, status_code=202, resource_id=job.id, diff --git a/backend/app/session/attachments.py b/backend/app/session/attachments.py index fb47ad025..5a6445d42 100644 --- a/backend/app/session/attachments.py +++ b/backend/app/session/attachments.py @@ -15,10 +15,10 @@ from app.session.attachment_store import sandbox_attachment_path from app.session.session_schema import ChatAttachmentRead - MAX_EXTRACTED_TEXT_CHARS = 24_000 MAX_PREVIEW_CHARS = 600 IMAGE_DATA_URL_LIMIT_BYTES = 4 * 1024 * 1024 +MAX_CHAT_ATTACHMENTS = 8 SUPPORTED_IMAGE_EXTENSIONS = {".gif", ".png", ".svg", ".jpg", ".jpeg", ".webp", ".bmp"} SUPPORTED_IMAGE_CONTENT_TYPES = { "image/gif", diff --git a/backend/tests/test_public_api_attachments.py b/backend/tests/test_public_api_attachments.py new file mode 100644 index 000000000..4f362108c --- /dev/null +++ b/backend/tests/test_public_api_attachments.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException +from fastapi.responses import StreamingResponse +from sqlmodel import Session, select +from test_public_api_v1 import _client, _tenant_key + +from app.api import chat as chat_api +from app.config import get_settings +from app.core.harness_attachments import materialize_task_attachments +from app.core.harness_session_cleanup import harness_task_workspace_path +from app.db.models import APIJob +from app.public_api import attachments as public_attachments +from app.public_api import runs as public_runs +from app.session.attachment_store import read_staged_chat_attachment +from app.session.session_schema import ChatAttachmentRead + + +def _upload( + client, + token: str, + files: list[tuple[str, tuple[str, bytes, str]]], + agent_id: str = "agent_api", +): + return client.post( + f"/agents/{agent_id}/attachments", + headers={"Authorization": f"Bearer {token}"}, + files=[("files[]", value) for _field, value in files], + ) + + +def _create_key( + client, + admin_token: str, + name: str, + scopes: list[str], + agent_id: str | None = None, +) -> str: + created = client.post( + "/api-clients", + headers={"Authorization": f"Bearer {admin_token}"}, + json={"name": name, "scopes": ["*"]}, + ) + assert created.status_code == 201, created.text + credential = client.post( + f"/api-clients/{created.json()['id']}/credentials", + headers={"Authorization": f"Bearer {admin_token}"}, + json={"name": f"{name} runtime", "scopes": scopes, "agent_id": agent_id}, + ) + assert credential.status_code == 201, credential.text + return credential.json()["api_key"] + + +def test_chat_attachment_limit_is_configurable(monkeypatch) -> None: + monkeypatch.setenv("CHAT_ATTACHMENT_MAX_BYTES", "3") + get_settings.cache_clear() + try: + assert get_settings().chat_attachment_max_bytes == 3 + finally: + get_settings.cache_clear() + + monkeypatch.setattr( + chat_api, + "get_settings", + lambda: SimpleNamespace(chat_attachment_max_bytes=3), + ) + request = chat_api.ChatTurnRequest( + tenant_id="tenant_api", + user_id="user_api_admin", + message="上传文件", + attachments=[ + ChatAttachmentRead( + id="file-1", + filename="a.txt", + content_type="text/plain", + size=4, + kind="text", + ) + ], + ) + with pytest.raises(HTTPException) as error: + chat_api._validate_chat_turn_attachments(request) + assert error.value.status_code == 400 + + +def test_public_attachment_upload_stages_original_bytes_and_returns_descriptors( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + client, _engine, admin_token = _client(monkeypatch) + token = _tenant_key(client, admin_token, ["runs:create", "runs:read"]) + files = [ + ("files", ("requirements.txt", b"first requirement\n", "text/plain")), + ("files", ("requirements.pdf", b"%PDF-1.7\n", "application/pdf")), + ] + + response = _upload(client, token, files) + + assert response.status_code == 200, response.text + attachments = [ChatAttachmentRead.model_validate(item) for item in response.json()] + assert [item.filename for item in attachments] == ["requirements.txt", "requirements.pdf"] + assert all(item.sha256 for item in attachments) + assert all(item.sandbox_path for item in attachments) + assert read_staged_chat_attachment( + attachments[0], + tenant_id="tenant_api", + user_id="user_api_admin", + ) == files[0][1][1] + assert attachments[0].sha256 == hashlib.sha256(files[0][1][1]).hexdigest() + + +def test_public_attachment_upload_enforces_scope_and_configured_size( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + client, _engine, admin_token = _client(monkeypatch) + unauthenticated = client.post( + "/agents/agent_api/attachments", + ) + assert unauthenticated.status_code == 401 + assert unauthenticated.json()["code"] == "NOT_AUTHENTICATED" + + read_only = _create_key(client, admin_token, "read-only", ["runs:read"]) + + forbidden = _upload(client, read_only, [("files", ("a.txt", b"a", "text/plain"))]) + assert forbidden.status_code == 403 + assert forbidden.json()["code"] == "INSUFFICIENT_SCOPE" + + token = _create_key(client, admin_token, "upload", ["runs:create"]) + empty = client.post( + "/agents/agent_api/attachments", + headers={"Authorization": f"Bearer {token}"}, + ) + assert empty.status_code == 400 + assert empty.json()["code"] == "NO_FILES_UPLOADED" + + too_many = _upload( + client, + token, + [ + ("files", (f"file-{index}.txt", b"a", "text/plain")) + for index in range(9) + ], + ) + assert too_many.status_code == 400 + assert too_many.json()["code"] == "TOO_MANY_ATTACHMENTS" + + agent_token = _create_key( + client, + admin_token, + "agent-bound", + ["runs:create"], + agent_id="agent_api", + ) + wrong_agent = _upload( + client, + agent_token, + [("files", ("a.txt", b"a", "text/plain"))], + agent_id="agent_other", + ) + assert wrong_agent.status_code == 403 + assert wrong_agent.json()["code"] == "AGENT_SCOPE_MISMATCH" + + monkeypatch.setattr( + public_attachments, + "get_settings", + lambda: SimpleNamespace(chat_attachment_max_bytes=3), + ) + too_large = _upload(client, token, [("files", ("a.txt", b"abcd", "text/plain"))]) + assert too_large.status_code == 413 + assert too_large.json()["code"] == "ATTACHMENT_TOO_LARGE" + + +@pytest.mark.parametrize("field", ["id", "filename", "size", "sandbox_path", "sha256"]) +def test_run_rejects_missing_or_tampered_staged_attachment_before_job_creation( + monkeypatch, + tmp_path: Path, + field: str, +) -> None: + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + client, engine, admin_token = _client(monkeypatch) + token = _tenant_key(client, admin_token, ["runs:create", "runs:read"]) + uploaded = _upload(client, token, [("files", ("urs.txt", b"URS", "text/plain"))]) + assert uploaded.status_code == 200, uploaded.text + descriptor = uploaded.json()[0] + if field == "id": + descriptor["id"] = "missing-file" + elif field == "filename": + descriptor["filename"] = "tampered.txt" + elif field == "size": + descriptor["size"] += 1 + elif field == "sandbox_path": + descriptor["sandbox_path"] = "/workspace/attachments/tampered" + else: + descriptor["sha256"] = "0" * 64 + + response = client.post( + "/agents/agent_api/runs", + headers={"Authorization": f"Bearer {token}"}, + json={"input": "请分析附件", "session_mode": "stateless", "attachments": [descriptor]}, + ) + + assert response.status_code == 400, response.text + assert response.json()["code"] == "INVALID_ATTACHMENT" + with Session(engine) as db: + assert db.exec(select(APIJob)).all() == [] + + +def test_run_and_streaming_run_accept_uploaded_attachment_descriptors( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + client, engine, admin_token = _client(monkeypatch) + token = _tenant_key(client, admin_token, ["runs:create", "runs:read"]) + uploaded = _upload(client, token, [("files", ("urs.txt", b"URS", "text/plain"))]) + assert uploaded.status_code == 200, uploaded.text + descriptor = uploaded.json()[0] + + stream = StreamingResponse( + iter(["event: complete\ndata: {}\n\n"]), + media_type="text/event-stream", + ) + monkeypatch.setattr(public_runs, "stream_job_events", lambda *args, **kwargs: stream) + first = client.post( + "/agents/agent_api/runs", + headers={"Authorization": f"Bearer {token}"}, + json={"input": "分析附件", "session_mode": "stateless", "attachments": [descriptor]}, + ) + second = client.post( + "/agents/agent_api/runs:stream", + headers={"Authorization": f"Bearer {token}"}, + json={"input": "继续分析附件", "session_mode": "stateless", "attachments": [descriptor]}, + ) + + assert first.status_code == 202, first.text + assert second.status_code == 200, second.text + with Session(engine) as db: + jobs = db.exec(select(APIJob).where(APIJob.kind == "run")).all() + assert len(jobs) == 2 + assert all( + job.request_json["attachments"][0]["sha256"] == descriptor["sha256"] + for job in jobs + ) + + +def test_same_session_can_rematerialize_descriptor_but_does_not_auto_inherit( + monkeypatch, + tmp_path: Path, +) -> None: + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + client, engine, admin_token = _client(monkeypatch) + token = _tenant_key( + client, + admin_token, + ["runs:create", "runs:read", "sessions:read", "sessions:write"], + ) + uploaded = _upload(client, token, [("files", ("urs.txt", b"URS", "text/plain"))]) + assert uploaded.status_code == 200, uploaded.text + descriptor = ChatAttachmentRead.model_validate(uploaded.json()[0]) + session_response = client.post( + "/agents/agent_api/sessions", + headers={"Authorization": f"Bearer {token}"}, + json={"external_session_id": "follow-up"}, + ) + assert session_response.status_code == 201, session_response.text + session_id = session_response.json()["id"] + + first = materialize_task_attachments( + [descriptor], + tenant_id="tenant_api", + session_id=session_id, + task_frame_id="frame-one", + user_id="user_api_admin", + ) + second = materialize_task_attachments( + [descriptor], + tenant_id="tenant_api", + session_id=session_id, + task_frame_id="frame-two", + user_id="user_api_admin", + ) + + assert first[0]["materialized"] is True + assert second[0]["materialized"] is True + first_workspace = harness_task_workspace_path( + tenant_id="tenant_api", + session_id=session_id, + task_frame_id="frame-one", + ) + second_workspace = harness_task_workspace_path( + tenant_id="tenant_api", + session_id=session_id, + task_frame_id="frame-two", + ) + assert first_workspace != second_workspace + assert ( + first_workspace / first[0]["workspace_relative_path"] + ).read_bytes() == b"URS" + assert ( + second_workspace / second[0]["workspace_relative_path"] + ).read_bytes() == b"URS" + + follow_up = client.post( + "/agents/agent_api/runs", + headers={"Authorization": f"Bearer {token}"}, + json={ + "input": "根据刚才的附件继续分析", + "session_id": session_id, + "attachments": [descriptor.model_dump(mode="json")], + }, + ) + assert follow_up.status_code == 202, follow_up.text + + response = client.post( + "/agents/agent_api/runs", + headers={"Authorization": f"Bearer {token}"}, + json={"input": "不带附件的后续问题", "session_id": session_id}, + ) + assert response.status_code == 202, response.text + with Session(engine) as db: + follow_up_job = db.get(APIJob, follow_up.json()["id"]) + assert follow_up_job is not None + assert follow_up_job.request_json["attachments"][0]["id"] == descriptor.id + job = db.get(APIJob, response.json()["id"]) + assert job is not None + assert job.request_json["attachments"] == [] diff --git a/backend/tests/test_public_api_v1.py b/backend/tests/test_public_api_v1.py index 6038a78b7..09716dbe8 100644 --- a/backend/tests/test_public_api_v1.py +++ b/backend/tests/test_public_api_v1.py @@ -154,6 +154,7 @@ def test_problem_details_and_openapi_contract(monkeypatch) -> None: schema = client.get("/openapi.json").json() expected = { + "/agents/{agent_id}/attachments", "/agents/{agent_id}/runs", "/agents/{agent_id}/runs:stream", "/runs/{run_id}/events", diff --git a/docs/open-api-v1.md b/docs/open-api-v1.md index 09b0ced2f..65b7bd14b 100644 --- a/docs/open-api-v1.md +++ b/docs/open-api-v1.md @@ -77,6 +77,44 @@ curl -X POST "$BASE/api-clients/$CLIENT_ID/credentials" \ 不传 `agent_id` 时创建租户密钥;传入后创建只能访问指定员工的密钥。Credential scope 必须是 API Client scope 的子集。 +## Public Run 附件 + +外部系统可以使用同一个 `runs:create` scope 上传原始文件。上传接口复用对话端的 staging 存储,不依赖用户 JWT,也不会把文件直接写入尚未创建的 TaskFrame: + +```bash +curl -X POST "$BASE/agents/$AGENT_ID/attachments" \ + -H "Authorization: Bearer $STAFFDECK_API_KEY" \ + -F "files[]=@URS.docx" \ + -F "files[]=@appendix.pdf" +``` + +接口返回现有 `ChatAttachmentRead[]`。外部系统应保存完整响应,并在创建 Run 时原样放入 `attachments`: + +```json +{ + "input": "请分析附件中的 URS", + "session_id": "session_xxx", + "session_mode": "stateful", + "attachments": [ + { + "id": "file_xxx", + "filename": "URS.docx", + "content_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "size": 123456, + "kind": "binary", + "sandbox_path": "/workspace/attachments/...", + "sha256": "..." + } + ] +} +``` + +Run 创建前 StaffDeck 会重新校验附件的 staging 内容、租户、用户、大小、路径和 SHA-256;失败时返回 `400 INVALID_ATTACHMENT`,不会创建 Job。Run 执行时,StaffDeck 会把附件复制到每个 TaskFrame 自己的 workspace,Skill 使用 `/workspace/attachments/...` 的相对路径读取。 + +同一 Session 的后续 Run 不会自动继承附件。调用方无需重新上传,但必须再次传入之前保存的 `attachments` 描述。只传 `session_id` 时,模型可以看到历史对话文字,但新 TaskFrame 不保证能打开历史文件。 + +默认单文件上传上限为 12 MiB,可通过 `CHAT_ATTACHMENT_MAX_BYTES` 调整。该限制是传输限制;Harness 文件工具和具体 Skill 可能有更低的处理限制。 + ## 最小调用链 ### 1. 创建持续会话