feat: 添加 Agent 步骤事件与图片附件功能
- 新增 stepStarted/stepFinished 事件类型支持 - 前端实现图片附件上传和预览功能 - 后端增强工具结果存储和事件处理 - 完善相关单元测试和集成测试
This commit is contained in:
@@ -9,6 +9,7 @@ from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.config.settings import config
|
||||
from models.agent_chat_message import AgentChatMessage, AgentChatMessageRole
|
||||
from models.agent_chat_session import AgentChatSession
|
||||
|
||||
@@ -200,6 +201,61 @@ class AgentRepository:
|
||||
return None
|
||||
return str(latest_id)
|
||||
|
||||
async def get_message_attachment_reference(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
message_id: str,
|
||||
attachment_index: int,
|
||||
) -> dict[str, str] | None:
|
||||
try:
|
||||
session_uuid = UUID(session_id)
|
||||
message_uuid = UUID(message_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="Invalid message/session id"
|
||||
) from exc
|
||||
|
||||
stmt = (
|
||||
select(AgentChatMessage)
|
||||
.where(AgentChatMessage.id == message_uuid)
|
||||
.where(AgentChatMessage.session_id == session_uuid)
|
||||
.where(AgentChatMessage.deleted_at.is_(None))
|
||||
)
|
||||
message = (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
if message is None:
|
||||
return None
|
||||
|
||||
metadata = (
|
||||
message.metadata_json if isinstance(message.metadata_json, dict) else {}
|
||||
)
|
||||
attachments_raw = metadata.get("attachments")
|
||||
if not isinstance(attachments_raw, list):
|
||||
return None
|
||||
if attachment_index < 0 or attachment_index >= len(attachments_raw):
|
||||
return None
|
||||
|
||||
attachment = attachments_raw[attachment_index]
|
||||
if not isinstance(attachment, dict):
|
||||
return None
|
||||
bucket = attachment.get("bucket")
|
||||
path = attachment.get("path")
|
||||
mime_type = attachment.get("mimeType")
|
||||
if (
|
||||
not isinstance(bucket, str)
|
||||
or not bucket
|
||||
or not isinstance(path, str)
|
||||
or not path
|
||||
or not isinstance(mime_type, str)
|
||||
or not mime_type
|
||||
):
|
||||
return None
|
||||
return {
|
||||
"bucket": bucket,
|
||||
"path": path,
|
||||
"mimeType": mime_type,
|
||||
}
|
||||
|
||||
async def _to_snapshot_message(
|
||||
self, message: AgentChatMessage
|
||||
) -> dict[str, object]:
|
||||
@@ -233,30 +289,65 @@ class AgentRepository:
|
||||
storage_bucket = metadata.get("storage_bucket")
|
||||
storage_path = metadata.get("storage_path")
|
||||
if isinstance(storage_bucket, str) and isinstance(storage_path, str):
|
||||
try:
|
||||
hydrated_content = await self._tool_result_storage.read_json(
|
||||
bucket=storage_bucket,
|
||||
path=storage_path,
|
||||
expected_bucket = config.storage.bucket
|
||||
message_session_id = getattr(message, "session_id", None)
|
||||
expected_prefix = (
|
||||
f"tool-results/{message_session_id}/"
|
||||
if message_session_id is not None
|
||||
else None
|
||||
)
|
||||
tool_call_id = metadata.get("tool_call_id")
|
||||
is_legacy_path = isinstance(
|
||||
tool_call_id, str
|
||||
) and storage_path.endswith(f"/{tool_call_id}.json")
|
||||
if (
|
||||
storage_bucket == expected_bucket
|
||||
and _is_safe_storage_path(storage_path)
|
||||
and (
|
||||
(
|
||||
expected_prefix is not None
|
||||
and storage_path.startswith(expected_prefix)
|
||||
)
|
||||
or (
|
||||
storage_path.startswith("tool-results/")
|
||||
and is_legacy_path
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
hydrated_content = None
|
||||
):
|
||||
try:
|
||||
hydrated_content = (
|
||||
await self._tool_result_storage.read_json(
|
||||
bucket=storage_bucket,
|
||||
path=storage_path,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
hydrated_content = None
|
||||
|
||||
resolved_content = hydrated_content or parsed_content
|
||||
payload["content"] = message.content
|
||||
if resolved_content is not None:
|
||||
result = resolved_content.get("result")
|
||||
if isinstance(result, dict):
|
||||
result_content = result.get("content")
|
||||
if isinstance(result_content, str):
|
||||
payload["content"] = result_content
|
||||
ui = resolved_content.get("ui")
|
||||
if not isinstance(ui, dict):
|
||||
ui = resolved_content.get("ui_schema")
|
||||
if isinstance(ui, dict):
|
||||
payload["ui"] = ui
|
||||
display_content = resolved_content.get("content")
|
||||
if isinstance(display_content, str):
|
||||
if not isinstance(display_content, str):
|
||||
nested_result = resolved_content.get("result")
|
||||
if isinstance(nested_result, dict):
|
||||
nested_content = nested_result.get("content")
|
||||
if isinstance(nested_content, str):
|
||||
display_content = nested_content
|
||||
if (
|
||||
isinstance(display_content, str)
|
||||
and display_content.strip()
|
||||
and (
|
||||
not payload["content"]
|
||||
or _looks_like_offloaded_placeholder(str(payload["content"]))
|
||||
)
|
||||
):
|
||||
payload["content"] = display_content
|
||||
|
||||
if "content" not in payload:
|
||||
payload["content"] = message.content
|
||||
else:
|
||||
payload["content"] = message.content
|
||||
metadata = message.metadata_json or {}
|
||||
@@ -264,7 +355,22 @@ class AgentRepository:
|
||||
metadata.get("attachments") if isinstance(metadata, dict) else None
|
||||
)
|
||||
if isinstance(attachments, list):
|
||||
rendered = [item for item in attachments if isinstance(item, dict)]
|
||||
rendered: list[dict[str, object]] = []
|
||||
for index, item in enumerate(attachments):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
mime_type = item.get("mimeType")
|
||||
if not isinstance(mime_type, str) or not mime_type:
|
||||
continue
|
||||
rendered.append(
|
||||
{
|
||||
"mimeType": mime_type,
|
||||
"previewPath": (
|
||||
f"/api/v1/agent/runs/{message.session_id}/attachments/"
|
||||
f"{message.id}/{index}"
|
||||
),
|
||||
}
|
||||
)
|
||||
if rendered:
|
||||
payload["attachments"] = rendered
|
||||
return payload
|
||||
@@ -279,3 +385,19 @@ def _derive_session_title(content_text: str) -> str | None:
|
||||
if not normalized:
|
||||
return None
|
||||
return normalized[:80]
|
||||
|
||||
|
||||
def _is_safe_storage_path(path: str) -> bool:
|
||||
normalized = path.strip()
|
||||
if not normalized:
|
||||
return False
|
||||
if normalized.startswith("/"):
|
||||
return False
|
||||
if ".." in normalized:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _looks_like_offloaded_placeholder(content: str) -> bool:
|
||||
normalized = content.strip().lower()
|
||||
return normalized in {'{"offloaded":true}', '{"offloaded": true}'}
|
||||
|
||||
Reference in New Issue
Block a user