Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 7 additions & 6 deletions backend/app/api/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,8 +32,8 @@
AgentEvent,
AgentProfile,
ChatSession,
HarnessTurnRecord,
HarnessTaskFrameRecord,
HarnessTurnRecord,
HumanHandoffRequest,
Message,
MessageFeedback,
Expand Down Expand Up @@ -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,
)
Expand All @@ -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,
Expand All @@ -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"}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os as _os
from functools import lru_cache

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


Expand Down Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion backend/app/public_api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
121 changes: 121 additions & 0 deletions backend/app/public_api/attachments.py
Original file line number Diff line number Diff line change
@@ -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"]
54 changes: 41 additions & 13 deletions backend/app/public_api/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -400,23 +411,32 @@ 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")
store_idempotent_response(
db,
principal,
request,
body.model_dump(mode="json"),
idempotency_payload,
payload,
status_code=202,
resource_id=job.id,
Expand All @@ -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,
Expand All @@ -455,7 +483,7 @@ def create_run_stream_route(
db,
principal,
request,
request_payload,
idempotency_payload,
payload,
status_code=202,
resource_id=job.id,
Expand Down
2 changes: 1 addition & 1 deletion backend/app/session/attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading