feat: 添加 Agent 步骤事件与图片附件功能
- 新增 stepStarted/stepFinished 事件类型支持 - 前端实现图片附件上传和预览功能 - 后端增强工具结果存储和事件处理 - 完善相关单元测试和集成测试
This commit is contained in:
+297
-60
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
import hashlib
|
||||
@@ -19,17 +18,22 @@ from core.config.settings import config
|
||||
from core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
_ALLOWED_ATTACHMENT_MIME_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||
_MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024
|
||||
_MAX_TOTAL_ATTACHMENT_BYTES = 12 * 1024 * 1024
|
||||
|
||||
|
||||
def _extract_user_token_from_run_input(run_input: RunAgentInput) -> str | None:
|
||||
forwarded = run_input.forwarded_props
|
||||
if not isinstance(forwarded, dict):
|
||||
def _normalize_bearer_token(value: str | None) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
for key in ("accessToken", "userToken", "token"):
|
||||
value = forwarded.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
return None
|
||||
lower = normalized.lower()
|
||||
if lower.startswith("bearer "):
|
||||
token = normalized[7:].strip()
|
||||
return token or None
|
||||
return normalized
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -66,6 +70,14 @@ class AgentRepositoryLike(Protocol):
|
||||
metadata: dict[str, object] | None,
|
||||
) -> None: ...
|
||||
|
||||
async def get_message_attachment_reference(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
message_id: str,
|
||||
attachment_index: int,
|
||||
) -> dict[str, str] | None: ...
|
||||
|
||||
|
||||
class QueueClientLike(Protocol):
|
||||
async def enqueue(
|
||||
@@ -92,6 +104,16 @@ class AttachmentStorageLike(Protocol):
|
||||
content_type: str,
|
||||
) -> str: ...
|
||||
|
||||
async def download_bytes(self, *, bucket: str, path: str) -> bytes: ...
|
||||
|
||||
async def create_signed_url(
|
||||
self,
|
||||
*,
|
||||
bucket: str,
|
||||
path: str,
|
||||
expires_in_seconds: int,
|
||||
) -> str: ...
|
||||
|
||||
|
||||
def ensure_session_owner(*, owner_id: str, current_user: CurrentUser) -> None:
|
||||
if owner_id != str(current_user.id):
|
||||
@@ -104,6 +126,8 @@ class AgentService:
|
||||
_stream: EventStreamLike
|
||||
_attachment_storage: AttachmentStorageLike | None
|
||||
|
||||
_SIGNED_URL_EXPIRES_IN_SECONDS = 3600
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -122,6 +146,7 @@ class AgentService:
|
||||
*,
|
||||
run_input: RunAgentInput,
|
||||
current_user: CurrentUser,
|
||||
user_token: str | None = None,
|
||||
) -> TaskAccepted:
|
||||
created = False
|
||||
thread_id = run_input.thread_id
|
||||
@@ -161,7 +186,7 @@ class AgentService:
|
||||
command={
|
||||
"command": "run",
|
||||
"owner_id": str(current_user.id),
|
||||
"user_token": _extract_user_token_from_run_input(run_input),
|
||||
"user_token": _normalize_bearer_token(user_token),
|
||||
"run_input": run_input.model_dump(mode="json", by_alias=True),
|
||||
},
|
||||
dedup_key=None,
|
||||
@@ -179,57 +204,115 @@ class AgentService:
|
||||
run_input: RunAgentInput,
|
||||
current_user: CurrentUser,
|
||||
) -> tuple[str, dict[str, object] | None]:
|
||||
text, content_blocks = extract_latest_user_payload(run_input)
|
||||
text, _ = extract_latest_user_payload(run_input)
|
||||
content_blocks = _extract_latest_user_content_blocks(run_input)
|
||||
attachments: list[dict[str, object]] = []
|
||||
if self._attachment_storage is not None:
|
||||
for index, block in enumerate(content_blocks):
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if block.get("type") != "image_url":
|
||||
continue
|
||||
image_value = block.get("image_url")
|
||||
if not isinstance(image_value, dict):
|
||||
continue
|
||||
url = image_value.get("url")
|
||||
if not isinstance(url, str) or not url.startswith("data:"):
|
||||
continue
|
||||
decoded = _decode_data_url(url)
|
||||
if decoded is None:
|
||||
continue
|
||||
mime_type, payload = decoded
|
||||
suffix = _mime_to_suffix(mime_type)
|
||||
checksum = hashlib.sha1(payload).hexdigest()[:16]
|
||||
path = (
|
||||
f"agent-inputs/{current_user.id}/{run_input.thread_id}/"
|
||||
f"{run_input.run_id}/attachment-{index}-{checksum}.{suffix}"
|
||||
binary_blocks = [
|
||||
block
|
||||
for block in content_blocks
|
||||
if isinstance(block, dict) and block.get("type") == "binary"
|
||||
]
|
||||
if binary_blocks:
|
||||
if self._attachment_storage is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Attachment storage unavailable",
|
||||
)
|
||||
bucket_name = config.storage.bucket
|
||||
forwarded_props = (
|
||||
run_input.forwarded_props
|
||||
if isinstance(run_input.forwarded_props, dict)
|
||||
else {}
|
||||
)
|
||||
raw_attachments = forwarded_props.get("attachments")
|
||||
if not isinstance(raw_attachments, list):
|
||||
raise HTTPException(
|
||||
status_code=422, detail="Invalid attachments payload"
|
||||
)
|
||||
if len(raw_attachments) != len(binary_blocks):
|
||||
raise HTTPException(
|
||||
status_code=422, detail="Invalid attachments payload"
|
||||
)
|
||||
|
||||
total_attachment_bytes = 0
|
||||
expected_prefix = f"agent-inputs/{current_user.id}/{run_input.thread_id}/"
|
||||
for index, raw_attachment in enumerate(raw_attachments):
|
||||
if not isinstance(raw_attachment, dict):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Invalid attachment reference",
|
||||
)
|
||||
bucket = raw_attachment.get("bucket")
|
||||
path = raw_attachment.get("path")
|
||||
mime_type = raw_attachment.get("mimeType")
|
||||
if (
|
||||
not isinstance(bucket, str)
|
||||
or not isinstance(path, str)
|
||||
or not isinstance(mime_type, str)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Invalid attachment reference",
|
||||
)
|
||||
if bucket != config.storage.bucket:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
if not _is_safe_attachment_path(path, expected_prefix=expected_prefix):
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
if mime_type.lower() not in _ALLOWED_ATTACHMENT_MIME_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Unsupported attachment type",
|
||||
)
|
||||
|
||||
binary_block = binary_blocks[index]
|
||||
binary_mime = binary_block.get("mimeType")
|
||||
binary_url = binary_block.get("url")
|
||||
if (
|
||||
not isinstance(binary_mime, str)
|
||||
or binary_mime != mime_type
|
||||
or not isinstance(binary_url, str)
|
||||
or not binary_url
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Invalid attachments payload",
|
||||
)
|
||||
|
||||
try:
|
||||
stored_path = await self._attachment_storage.upload_bytes(
|
||||
bucket=bucket_name,
|
||||
payload = await self._attachment_storage.download_bytes(
|
||||
bucket=bucket,
|
||||
path=path,
|
||||
content=payload,
|
||||
content_type=mime_type,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception(
|
||||
"Attachment upload failed",
|
||||
"Attachment validation download failed",
|
||||
extra={
|
||||
"bucket": bucket_name,
|
||||
"bucket": bucket,
|
||||
"path": path,
|
||||
"mime_type": mime_type,
|
||||
"thread_id": run_input.thread_id,
|
||||
"run_id": run_input.run_id,
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="Failed to upload attachment",
|
||||
detail="Failed to fetch attachment",
|
||||
)
|
||||
payload_size = len(payload)
|
||||
if payload_size > _MAX_ATTACHMENT_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail="Attachment too large",
|
||||
)
|
||||
total_attachment_bytes += payload_size
|
||||
if total_attachment_bytes > _MAX_TOTAL_ATTACHMENT_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail="Attachments too large",
|
||||
)
|
||||
|
||||
attachments.append(
|
||||
{
|
||||
"bucket": bucket_name,
|
||||
"path": stored_path,
|
||||
"bucket": bucket,
|
||||
"path": path,
|
||||
"mimeType": mime_type,
|
||||
}
|
||||
)
|
||||
@@ -238,12 +321,94 @@ class AgentService:
|
||||
metadata["attachments"] = attachments
|
||||
return text, metadata or None
|
||||
|
||||
async def upload_attachment(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
filename: str | None,
|
||||
content_type: str | None,
|
||||
payload: bytes,
|
||||
current_user: CurrentUser,
|
||||
) -> dict[str, str]:
|
||||
try:
|
||||
owner = await self._repository.get_session_owner(session_id=thread_id)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
try:
|
||||
await self._repository.create_session_for_user(
|
||||
user_id=str(current_user.id),
|
||||
session_id=thread_id,
|
||||
)
|
||||
await self._repository.commit()
|
||||
except IntegrityError:
|
||||
await self._repository.rollback()
|
||||
owner = await self._repository.get_session_owner(session_id=thread_id)
|
||||
ensure_session_owner(owner_id=owner, current_user=current_user)
|
||||
else:
|
||||
ensure_session_owner(owner_id=owner, current_user=current_user)
|
||||
if self._attachment_storage is None:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Attachment storage unavailable"
|
||||
)
|
||||
|
||||
if not isinstance(content_type, str):
|
||||
raise HTTPException(status_code=422, detail="Unsupported attachment type")
|
||||
mime_type = content_type.lower()
|
||||
if mime_type not in _ALLOWED_ATTACHMENT_MIME_TYPES:
|
||||
raise HTTPException(status_code=422, detail="Unsupported attachment type")
|
||||
if not payload:
|
||||
raise HTTPException(status_code=422, detail="Empty attachment")
|
||||
if len(payload) > _MAX_ATTACHMENT_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Attachment too large")
|
||||
|
||||
suffix = _mime_to_suffix(mime_type)
|
||||
checksum = hashlib.sha1(payload).hexdigest()[:16]
|
||||
filename_seed = filename if isinstance(filename, str) and filename else "upload"
|
||||
filename_hash = hashlib.sha1(filename_seed.encode("utf-8")).hexdigest()[:8]
|
||||
path = (
|
||||
f"agent-inputs/{current_user.id}/{thread_id}/uploads/"
|
||||
f"{filename_hash}-{checksum}.{suffix}"
|
||||
)
|
||||
bucket_name = config.storage.bucket
|
||||
try:
|
||||
stored_path = await self._attachment_storage.upload_bytes(
|
||||
bucket=bucket_name,
|
||||
path=path,
|
||||
content=payload,
|
||||
content_type=mime_type,
|
||||
)
|
||||
signed_url = await self._attachment_storage.create_signed_url(
|
||||
bucket=bucket_name,
|
||||
path=stored_path,
|
||||
expires_in_seconds=self._SIGNED_URL_EXPIRES_IN_SECONDS,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception(
|
||||
"Attachment upload failed",
|
||||
extra={
|
||||
"bucket": bucket_name,
|
||||
"path": path,
|
||||
"mime_type": mime_type,
|
||||
"thread_id": thread_id,
|
||||
},
|
||||
)
|
||||
raise HTTPException(status_code=502, detail="Failed to upload attachment")
|
||||
|
||||
return {
|
||||
"bucket": bucket_name,
|
||||
"path": stored_path,
|
||||
"mimeType": mime_type,
|
||||
"url": signed_url,
|
||||
}
|
||||
|
||||
async def enqueue_resume(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
run_input: RunAgentInput,
|
||||
current_user: CurrentUser,
|
||||
user_token: str | None = None,
|
||||
) -> TaskAccepted:
|
||||
owner = await self._repository.get_session_owner(session_id=thread_id)
|
||||
ensure_session_owner(owner_id=owner, current_user=current_user)
|
||||
@@ -253,7 +418,7 @@ class AgentService:
|
||||
command={
|
||||
"command": "resume",
|
||||
"owner_id": str(current_user.id),
|
||||
"user_token": _extract_user_token_from_run_input(run_input),
|
||||
"user_token": _normalize_bearer_token(user_token),
|
||||
"run_input": run_input.model_dump(mode="json", by_alias=True),
|
||||
},
|
||||
dedup_key=dedup_key,
|
||||
@@ -336,6 +501,63 @@ class AgentService:
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
async def get_attachment_preview(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
message_id: str,
|
||||
attachment_index: int,
|
||||
current_user: CurrentUser,
|
||||
) -> tuple[bytes, str]:
|
||||
owner = await self._repository.get_session_owner(session_id=thread_id)
|
||||
ensure_session_owner(owner_id=owner, current_user=current_user)
|
||||
if self._attachment_storage is None:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Attachment storage unavailable"
|
||||
)
|
||||
|
||||
ref = await self._repository.get_message_attachment_reference(
|
||||
session_id=thread_id,
|
||||
message_id=message_id,
|
||||
attachment_index=attachment_index,
|
||||
)
|
||||
if ref is None:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
bucket = ref.get("bucket")
|
||||
path = ref.get("path")
|
||||
mime_type = ref.get("mimeType")
|
||||
if (
|
||||
not isinstance(bucket, str)
|
||||
or not isinstance(path, str)
|
||||
or not isinstance(mime_type, str)
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
if bucket != config.storage.bucket:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
expected_prefix = f"agent-inputs/{current_user.id}/{thread_id}/"
|
||||
if not _is_safe_attachment_path(path, expected_prefix=expected_prefix):
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
try:
|
||||
payload = await self._attachment_storage.download_bytes(
|
||||
bucket=bucket,
|
||||
path=path,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception(
|
||||
"Attachment download failed",
|
||||
extra={
|
||||
"thread_id": thread_id,
|
||||
"message_id": message_id,
|
||||
"attachment_index": attachment_index,
|
||||
"bucket": bucket,
|
||||
},
|
||||
)
|
||||
raise HTTPException(status_code=502, detail="Failed to fetch attachment")
|
||||
return payload, mime_type
|
||||
|
||||
|
||||
class AsrService:
|
||||
def __init__(self) -> None:
|
||||
@@ -445,22 +667,26 @@ class AsrService:
|
||||
asr_service = AsrService()
|
||||
|
||||
|
||||
def _decode_data_url(data_url: str) -> tuple[str, bytes] | None:
|
||||
if not data_url.startswith("data:"):
|
||||
return None
|
||||
header, sep, payload = data_url.partition(",")
|
||||
if not sep:
|
||||
return None
|
||||
mime_type = "image/png"
|
||||
if ";" in header:
|
||||
maybe_mime = header[5:].split(";", 1)[0].strip()
|
||||
if maybe_mime:
|
||||
mime_type = maybe_mime
|
||||
try:
|
||||
decoded = base64.b64decode(payload, validate=True)
|
||||
except ValueError:
|
||||
return None
|
||||
return mime_type, decoded
|
||||
def _extract_latest_user_content_blocks(
|
||||
run_input: RunAgentInput,
|
||||
) -> list[dict[str, Any]]:
|
||||
if not run_input.messages:
|
||||
return []
|
||||
latest = run_input.messages[-1]
|
||||
content = getattr(latest, "content", None)
|
||||
if not isinstance(content, list):
|
||||
return []
|
||||
blocks: list[dict[str, Any]] = []
|
||||
for item in content:
|
||||
if isinstance(item, dict):
|
||||
blocks.append(item)
|
||||
continue
|
||||
model_dump = getattr(item, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
dumped = model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
if isinstance(dumped, dict):
|
||||
blocks.append(dumped)
|
||||
return blocks
|
||||
|
||||
|
||||
def _mime_to_suffix(mime_type: str) -> str:
|
||||
@@ -470,3 +696,14 @@ def _mime_to_suffix(mime_type: str) -> str:
|
||||
"image/webp": "webp",
|
||||
}
|
||||
return mapping.get(mime_type.lower(), "bin")
|
||||
|
||||
|
||||
def _is_safe_attachment_path(path: str, *, expected_prefix: str) -> bool:
|
||||
normalized = path.strip()
|
||||
if not normalized:
|
||||
return False
|
||||
if normalized.startswith("/"):
|
||||
return False
|
||||
if ".." in normalized:
|
||||
return False
|
||||
return normalized.startswith(expected_prefix)
|
||||
|
||||
Reference in New Issue
Block a user