chore: no changes needed for calendar message card

This commit is contained in:
qzl
2026-03-11 21:06:02 +08:00
parent 98f22a2127
commit a8dacbe81f
8 changed files with 590 additions and 0 deletions
@@ -0,0 +1,51 @@
from __future__ import annotations
import asyncio
from typing import Any
from services.base.supabase import supabase_service
class AgentAttachmentStorage:
def _bucket_client(self, *, bucket: str) -> Any:
client = supabase_service.get_admin_client()
storage = getattr(client, "storage", None)
if storage is None:
raise RuntimeError("Supabase storage client unavailable")
from_bucket = getattr(storage, "from_", None)
if not callable(from_bucket):
raise RuntimeError("Supabase storage bucket accessor unavailable")
return from_bucket(bucket)
async def upload_bytes(
self,
*,
bucket: str,
path: str,
content: bytes,
content_type: str,
) -> str:
def _upload() -> object:
bucket_client = self._bucket_client(bucket=bucket)
upload = getattr(bucket_client, "upload", None)
if not callable(upload):
raise RuntimeError("Supabase storage upload is unavailable")
return upload(
path,
content,
{
"content-type": content_type,
"upsert": "true",
},
)
await asyncio.to_thread(_upload)
return path
def create_attachment_storage() -> AgentAttachmentStorage | None:
try:
supabase_service.get_admin_client()
except Exception:
return None
return AgentAttachmentStorage()
+3
View File
@@ -20,6 +20,7 @@ from core.config.settings import config
from core.db import get_db
from services.base.redis import get_or_init_redis_client
from v1.agent.repository import AgentRepository
from v1.agent.attachment_storage import create_attachment_storage
from v1.agent.service import AgentService
DEDUP_WAIT_RETRIES = 20
@@ -109,8 +110,10 @@ class RedisEventStream:
def get_agent_service(session: AsyncSession = Depends(get_db)) -> AgentService:
tool_result_storage = create_tool_result_storage()
attachment_storage = create_attachment_storage()
return AgentService(
repository=AgentRepository(session, tool_result_storage=tool_result_storage),
queue=TaskiqQueueClient(),
stream=RedisEventStream(),
attachment_storage=attachment_storage,
)
+45
View File
@@ -84,6 +84,43 @@ class AgentRepository:
await self._session.delete(session)
await self._session.flush()
async def persist_user_message(
self,
*,
session_id: str,
run_id: str,
content_text: str,
metadata: dict[str, object] | None,
) -> None:
try:
session_uuid = UUID(session_id)
except ValueError as exc:
raise HTTPException(status_code=422, detail="Invalid session_id") from exc
stmt = (
select(AgentChatSession)
.where(AgentChatSession.id == session_uuid)
.with_for_update()
)
session_row = (await self._session.execute(stmt)).scalar_one_or_none()
if session_row is None:
raise HTTPException(status_code=404, detail="Session not found")
next_seq = int(session_row.message_count or 0) + 1
payload_metadata = dict(metadata or {})
payload_metadata["run_id"] = run_id
message = AgentChatMessage(
session_id=session_uuid,
seq=next_seq,
role=AgentChatMessageRole.USER,
content=content_text,
metadata_json=payload_metadata,
)
self._session.add(message)
session_row.message_count = next_seq
session_row.last_activity_at = datetime.now(timezone.utc)
await self._session.flush()
async def get_history_day(
self, *, session_id: str, before: date | None
) -> dict[str, object] | None:
@@ -218,4 +255,12 @@ class AgentRepository:
payload["content"] = message.content
else:
payload["content"] = message.content
metadata = message.metadata_json or {}
attachments = (
metadata.get("attachments") if isinstance(metadata, dict) else None
)
if isinstance(attachments, list):
rendered = [item for item in attachments if isinstance(item, dict)]
if rendered:
payload["attachments"] = rendered
return payload
+113
View File
@@ -1,8 +1,10 @@
from __future__ import annotations
import asyncio
import base64
from dataclasses import dataclass
from datetime import date
import hashlib
from typing import Any, Protocol
import dashscope
@@ -12,6 +14,7 @@ from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from core.auth.models import CurrentUser
from core.agentscope.schemas.agui_input import extract_latest_user_payload
from core.config.settings import config
from core.logging import get_logger
@@ -54,6 +57,15 @@ class AgentRepositoryLike(Protocol):
async def get_latest_session_id_for_user(self, *, user_id: str) -> str | None: ...
async def persist_user_message(
self,
*,
session_id: str,
run_id: str,
content_text: str,
metadata: dict[str, object] | None,
) -> None: ...
class QueueClientLike(Protocol):
async def enqueue(
@@ -70,6 +82,17 @@ class EventStreamLike(Protocol):
) -> list[dict[str, object]]: ...
class AttachmentStorageLike(Protocol):
async def upload_bytes(
self,
*,
bucket: str,
path: str,
content: bytes,
content_type: str,
) -> str: ...
def ensure_session_owner(*, owner_id: str, current_user: CurrentUser) -> None:
if owner_id != str(current_user.id):
raise HTTPException(status_code=403, detail="Forbidden")
@@ -79,6 +102,7 @@ class AgentService:
_repository: AgentRepositoryLike
_queue: QueueClientLike
_stream: EventStreamLike
_attachment_storage: AttachmentStorageLike | None
def __init__(
self,
@@ -86,10 +110,12 @@ class AgentService:
repository: AgentRepositoryLike,
queue: QueueClientLike,
stream: EventStreamLike,
attachment_storage: AttachmentStorageLike | None = None,
) -> None:
self._repository = repository
self._queue = queue
self._stream = stream
self._attachment_storage = attachment_storage
async def enqueue_run(
self,
@@ -119,6 +145,18 @@ class AgentService:
else:
ensure_session_owner(owner_id=owner, current_user=current_user)
user_message_text, user_message_metadata = await self._prepare_user_message(
run_input=run_input,
current_user=current_user,
)
await self._repository.persist_user_message(
session_id=thread_id,
run_id=run_id,
content_text=user_message_text,
metadata=user_message_metadata,
)
await self._repository.commit()
task_id = await self._queue.enqueue(
command={
"command": "run",
@@ -135,6 +173,54 @@ class AgentService:
created=created,
)
async def _prepare_user_message(
self,
*,
run_input: RunAgentInput,
current_user: CurrentUser,
) -> tuple[str, dict[str, object] | None]:
text, content_blocks = extract_latest_user_payload(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}"
)
stored_path = await self._attachment_storage.upload_bytes(
bucket=config.storage.bucket,
path=path,
content=payload,
content_type=mime_type,
)
attachments.append(
{
"bucket": config.storage.bucket,
"path": stored_path,
"mimeType": mime_type,
}
)
metadata: dict[str, object] = {}
if attachments:
metadata["attachments"] = attachments
return text, metadata or None
async def enqueue_resume(
self,
*,
@@ -340,3 +426,30 @@ 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 _mime_to_suffix(mime_type: str) -> str:
mapping = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
}
return mapping.get(mime_type.lower(), "bin")