docs: 更新协议文档,删除废弃计划文档

- 更新 http-error-codes, user-points-chat-data-protocol
- 更新 divination-run-protocol, profile-protocol
- 删除废弃的后端和前端设计计划文档
This commit is contained in:
qzl
2026-04-08 17:23:02 +08:00
parent 49fc9a116f
commit e80a82bef4
57 changed files with 4117 additions and 2269 deletions
+71 -13
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import date, datetime, time, timedelta, timezone
from decimal import Decimal
from typing import Protocol
from typing import Any, Protocol
from uuid import UUID, uuid4
from sqlalchemy import Select, func, select
@@ -45,8 +45,10 @@ class AgentRepository:
detail="Invalid session_id",
) from exc
stmt = select(AgentChatSession.user_id).where(
AgentChatSession.id == session_uuid
stmt = (
select(AgentChatSession.user_id)
.where(AgentChatSession.id == session_uuid)
.where(AgentChatSession.deleted_at.is_(None))
)
owner_id = (await self._session.execute(stmt)).scalar_one_or_none()
if owner_id is None:
@@ -103,10 +105,18 @@ class AgentRepository:
code="AGENT_SESSION_ID_INVALID",
detail="Invalid session_id",
) from exc
session = await self._session.get(AgentChatSession, session_uuid)
if session is not None:
await self._session.delete(session)
await self._session.flush()
stmt = (
select(AgentChatSession)
.where(AgentChatSession.id == session_uuid)
.with_for_update()
)
session = (await self._session.execute(stmt)).scalar_one_or_none()
if session is None:
return
if session.deleted_at is not None:
return
session.deleted_at = datetime.now(timezone.utc)
await self._session.flush()
async def persist_user_message(
self,
@@ -263,6 +273,37 @@ class AgentRepository:
"messages": snapshot_messages,
}
async def get_session_messages(
self,
*,
session_id: str,
visibility_mask: int | None = None,
) -> list[dict[str, object]]:
try:
session_uuid = UUID(session_id)
except ValueError as exc:
raise ApiProblemError(
status_code=422,
code="AGENT_SESSION_ID_INVALID",
detail="Invalid session_id",
) from exc
message_stmt = (
select(AgentChatMessage)
.where(AgentChatMessage.session_id == session_uuid)
.where(AgentChatMessage.deleted_at.is_(None))
.order_by(AgentChatMessage.seq.asc())
)
message_stmt = self._apply_visibility_filter(
stmt=message_stmt,
visibility_mask=visibility_mask,
)
messages = (await self._session.execute(message_stmt)).scalars().all()
snapshot_messages: list[dict[str, object]] = []
for message in messages:
snapshot_messages.append(await self._to_snapshot_message(message))
return snapshot_messages
async def get_recent_messages_by_user_window(
self,
*,
@@ -371,16 +412,32 @@ class AgentRepository:
.where(AgentChatMessage.deleted_at.is_(None))
.where(AgentChatMessage.role == AgentChatMessageRole.ASSISTANT)
.order_by(AgentChatMessage.created_at.desc())
.limit(1)
.limit(20)
)
message_stmt = self._apply_visibility_filter(
stmt=message_stmt,
visibility_mask=visibility_mask,
)
message = (await self._session.execute(message_stmt)).scalar_one_or_none()
if message is None:
candidate_messages = (
(await self._session.execute(message_stmt)).scalars().all()
)
if not candidate_messages:
continue
snapshots.append(await self._to_snapshot_message(message))
selected_snapshot: dict[str, object] | None = None
for message in candidate_messages:
snapshot = await self._to_snapshot_message(message)
metadata = snapshot.get("metadata")
if not isinstance(metadata, dict):
continue
agent_output = metadata.get("agent_output")
if not isinstance(agent_output, dict):
continue
derived = agent_output.get("divination_derived")
if isinstance(derived, dict) and derived:
selected_snapshot = snapshot
break
if selected_snapshot is not None:
snapshots.append(selected_snapshot)
snapshots.sort(
key=lambda item: str(item.get("timestamp") or ""),
@@ -416,6 +473,7 @@ class AgentRepository:
payload_model = AgentChatMessageSchema.model_validate(
{
"id": str(message.id),
"session_id": str(message.session_id),
"seq": int(message.seq),
"role": role,
"content": message.content,
@@ -434,9 +492,9 @@ class AgentRepository:
def _apply_visibility_filter(
self,
*,
stmt: Select,
stmt: Select[Any],
visibility_mask: int | None,
) -> Select:
) -> Select[Any]:
if visibility_mask is None:
return stmt
required_mask = max(int(visibility_mask), 0)
+11 -3
View File
@@ -5,7 +5,6 @@ import os
import re
import tempfile
from collections.abc import AsyncIterator
from datetime import date
from typing import Annotated
from ag_ui.core import RunAgentInput
@@ -25,6 +24,7 @@ from fastapi import (
Form,
Header,
Query,
Response,
Request,
UploadFile,
status,
@@ -297,15 +297,23 @@ async def get_user_history_snapshot(
service: Annotated[AgentService, Depends(get_agent_service)],
current_user: Annotated[CurrentUser, Depends(get_current_user)],
thread_id: str | None = Query(default=None, alias="threadId"),
before: date | None = Query(default=None),
) -> HistorySnapshotResponse:
return await service.get_user_history_snapshot(
current_user=current_user,
thread_id=thread_id,
before=before,
)
@router.delete("/sessions/{thread_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_session(
thread_id: str,
service: Annotated[AgentService, Depends(get_agent_service)],
current_user: Annotated[CurrentUser, Depends(get_current_user)],
) -> Response:
await service.delete_session(thread_id=thread_id, current_user=current_user)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/attachments",
response_model=AttachmentUploadResponse,
+16 -1
View File
@@ -7,6 +7,7 @@ from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
from schemas.agent.runtime_models import ErrorInfo
from schemas.domain.divination import DerivedDivinationData
@@ -21,6 +22,8 @@ class AgentRepositoryLike(Protocol):
async def rollback(self) -> None: ...
async def delete_session(self, *, session_id: str) -> None: ...
async def get_history_day(
self,
*,
@@ -29,6 +32,13 @@ class AgentRepositoryLike(Protocol):
visibility_mask: int | None = None,
) -> dict[str, object] | None: ...
async def get_session_messages(
self,
*,
session_id: str,
visibility_mask: int | None = None,
) -> list[dict[str, object]]: ...
async def get_latest_session_id_for_user(self, *, user_id: str) -> str | None: ...
async def get_latest_assistant_messages_by_user_sessions(
@@ -186,6 +196,7 @@ class HistoryMessage(BaseModel):
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
id: str = Field(description="Message UUID")
thread_id: str = Field(alias="threadId", description="Owning session UUID")
seq: int = Field(description="Message sequence number")
role: Literal["user", "assistant"] = Field(
description="Message role: user | assistant"
@@ -213,6 +224,7 @@ class HistoryAgentOutput(BaseModel):
advice: list[str] = Field(default_factory=list)
keywords: list[str] = Field(default_factory=list)
answer: str | None = None
error: ErrorInfo | None = None
divination_derived: DerivedDivinationData | None = None
@@ -221,7 +233,10 @@ class HistorySnapshotResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
scope: str = Field(default="history_day")
scope: str = Field(
default="history_session_full",
description="history_session_full | history_sessions_latest_assistant",
)
thread_id: str | None = Field(default=None, alias="threadId")
day: str | None = None
has_more: bool = Field(default=False, alias="hasMore")
+81 -67
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from datetime import date, datetime, timezone
from datetime import datetime, timezone
import hashlib
from urllib.parse import urlparse
@@ -46,7 +46,7 @@ from v1.agent.utils import (
)
logger = get_logger(__name__)
MAX_RUNS_PER_SESSION = 4
MAX_RUNS_PER_SESSION = 2
def ensure_session_owner(*, owner_id: str, current_user: CurrentUser) -> None:
@@ -94,6 +94,15 @@ class AgentService:
forwarded_props = getattr(run_input, "forwarded_props", None)
try:
runtime_mode = parse_forwarded_props_runtime_mode(forwarded_props)
except ValueError as exc:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="AGENT_RUNTIME_MODE_INVALID",
detail="Invalid forwardedProps.runtime_mode",
),
) from exc
try:
divination_payload = parse_forwarded_props_divination_payload(
forwarded_props
)
@@ -123,6 +132,14 @@ class AgentService:
except ApiProblemError as exc:
if exc.status_code != 404:
raise
if runtime_mode == RuntimeMode.FOLLOW_UP:
raise ApiProblemError(
status_code=404,
detail=problem_payload(
code="AGENT_SESSION_NOT_FOUND",
detail="Session not found",
),
) from exc
created = await self._create_session_if_missing(
thread_id=thread_id,
current_user=current_user,
@@ -204,6 +221,22 @@ class AgentService:
accepted=True,
)
async def delete_session(
self,
*,
thread_id: str,
current_user: CurrentUser,
) -> None:
try:
owner = await self._repository.get_session_owner(session_id=thread_id)
except ApiProblemError as exc:
if exc.status_code == 404:
return
raise
ensure_session_owner(owner_id=owner, current_user=current_user)
await self._repository.delete_session(session_id=thread_id)
await self._repository.commit()
async def _append_context_cache_user_message(
self,
*,
@@ -226,30 +259,21 @@ class AgentService:
if isinstance(metadata_payload, dict):
message_payload["metadata"] = metadata_payload
try:
context_cache = create_context_messages_cache()
await context_cache.append_message(
thread_id=thread_id,
runtime_mode=runtime_mode.value,
visibility_mask=visibility_mask,
message=message_payload,
)
except Exception as exc:
logger.warning(
"Failed to append user message to context cache",
thread_id=thread_id,
runtime_mode=runtime_mode.value,
error=str(exc),
)
context_cache = create_context_messages_cache()
await context_cache.append_message(
thread_id=thread_id,
runtime_mode=runtime_mode.value,
visibility_mask=visibility_mask,
message=message_payload,
)
async def _resolve_user_message_visibility_mask(
self, *, runtime_mode: RuntimeMode
) -> int:
if runtime_mode == RuntimeMode.CHAT:
return bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)) | bit_mask(
bit=int(SystemVisibilityBit.CONTEXT_ASSEMBLY)
)
return 0
_ = runtime_mode
return bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)) | bit_mask(
bit=int(SystemVisibilityBit.CONTEXT_ASSEMBLY)
)
async def _prepare_user_message(
self,
@@ -571,7 +595,6 @@ class AgentService:
self,
*,
thread_id: str,
before: date | None,
current_user: CurrentUser,
) -> HistorySnapshotResponse:
from schemas.domain.chat_message import AgentChatMessage
@@ -580,57 +603,47 @@ class AgentService:
owner = await self._repository.get_session_owner(session_id=thread_id)
ensure_session_owner(owner_id=owner, current_user=current_user)
day_payload = await self._repository.get_history_day(
raw_messages = await self._repository.get_session_messages(
session_id=thread_id,
before=before,
visibility_mask=bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)),
)
messages: list[HistoryMessage] = []
if day_payload:
raw_messages_obj = day_payload.get("messages")
raw_messages = (
raw_messages_obj if isinstance(raw_messages_obj, list) else []
)
for msg_dict in raw_messages:
msg = AgentChatMessage.model_validate(msg_dict)
if msg.role == "tool":
continue
for msg_dict in raw_messages:
msg = AgentChatMessage.model_validate(msg_dict)
if msg.role == "tool":
continue
signed_urls: dict[str, str] = {}
attachments = extract_user_message_attachments(msg.metadata)
if self._attachment_storage and attachments:
expected_prefix = (
f"agent-inputs/{current_user.id}/{thread_id}/uploads/"
signed_urls: dict[str, str] = {}
attachments = extract_user_message_attachments(msg.metadata)
if self._attachment_storage and attachments:
expected_prefix = f"agent-inputs/{current_user.id}/{thread_id}/uploads/"
for attachment in attachments:
if not is_safe_attachment_path(
attachment.path,
expected_prefix=expected_prefix,
):
continue
signed_url = await self._attachment_storage.create_signed_url(
bucket=attachment.bucket,
path=attachment.path,
expires_in_seconds=self._SIGNED_URL_EXPIRES_IN_SECONDS,
)
for attachment in attachments:
if not is_safe_attachment_path(
attachment.path,
expected_prefix=expected_prefix,
):
continue
signed_url = await self._attachment_storage.create_signed_url(
bucket=attachment.bucket,
path=attachment.path,
expires_in_seconds=self._SIGNED_URL_EXPIRES_IN_SECONDS,
)
key = f"{attachment.bucket}/{attachment.path}"
signed_urls[key] = signed_url
key = f"{attachment.bucket}/{attachment.path}"
signed_urls[key] = signed_url
def _get_signed_url(payload: dict[str, str]) -> str:
key = f"{payload['bucket']}/{payload['path']}"
return signed_urls[key]
def _get_signed_url(payload: dict[str, str]) -> str:
key = f"{payload['bucket']}/{payload['path']}"
return signed_urls[key]
converted = convert_message_to_history(msg, _get_signed_url)
messages.append(HistoryMessage.model_validate(converted))
converted = convert_message_to_history(msg, _get_signed_url)
messages.append(HistoryMessage.model_validate(converted))
return HistorySnapshotResponse(
scope="history_day",
scope="history_session_full",
threadId=thread_id,
day=str(day_payload.get("day"))
if day_payload and day_payload.get("day")
else None,
hasMore=bool(day_payload.get("hasMore")) if day_payload else False,
day=None,
hasMore=False,
messages=messages,
)
@@ -639,7 +652,6 @@ class AgentService:
*,
current_user: CurrentUser,
thread_id: str | None,
before: date | None,
) -> HistorySnapshotResponse:
from schemas.domain.chat_message import AgentChatMessage
from v1.agent.utils import convert_message_to_history
@@ -648,20 +660,22 @@ class AgentService:
if thread_id is not None:
return await self.get_history_snapshot(
thread_id=thread_id,
before=before,
current_user=current_user,
)
summary_limit = 50
raw_messages = (
await self._repository.get_latest_assistant_messages_by_user_sessions(
user_id=str(current_user.id),
visibility_mask=bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)),
session_limit=50,
session_limit=summary_limit + 1,
)
)
has_more = len(raw_messages) > summary_limit
visible_messages = raw_messages[:summary_limit]
messages: list[HistoryMessage] = []
for msg_dict in raw_messages:
for msg_dict in visible_messages:
msg = AgentChatMessage.model_validate(msg_dict)
converted = convert_message_to_history(msg)
messages.append(HistoryMessage.model_validate(converted))
@@ -670,7 +684,7 @@ class AgentService:
scope="history_sessions_latest_assistant",
threadId=None,
day=None,
hasMore=False,
hasMore=has_more,
messages=messages,
)
+8 -12
View File
@@ -7,7 +7,8 @@
from collections.abc import Callable
from typing import Any
from schemas.agent.runtime_models import AgentOutput
from pydantic import TypeAdapter
from schemas.agent.runtime_models import RuntimeAgentOutput
from schemas.domain.chat_message import (
AgentChatMessage,
AgentChatMessageMetadata,
@@ -18,6 +19,7 @@ ALLOWED_ATTACHMENT_MIME_TYPES = {"image/png", "image/jpeg", "image/webp"}
MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024
MAX_TOTAL_ATTACHMENT_BYTES = 12 * 1024 * 1024
MAX_ATTACHMENTS_PER_MESSAGE = 3
_RUNTIME_AGENT_OUTPUT_ADAPTER = TypeAdapter(RuntimeAgentOutput)
def convert_message_to_history(
@@ -46,6 +48,7 @@ def convert_message_to_history(
result: dict[str, Any] = {
"id": str(message.id),
"threadId": str(message.session_id),
"seq": message.seq,
"role": role,
"content": content,
@@ -78,12 +81,9 @@ def _convert_user_attachments(
signed_attachments: list[dict[str, str]] = []
for attachment in resolved:
try:
signed_url = get_signed_url_fn(
{"bucket": attachment.bucket, "path": attachment.path}
)
except Exception:
continue
signed_url = get_signed_url_fn(
{"bucket": attachment.bucket, "path": attachment.path}
)
signed_attachments.append(
{
"url": signed_url,
@@ -106,16 +106,12 @@ def _extract_worker_agent_output(
agent_output_data = metadata.get("agent_output")
if not agent_output_data:
return None
try:
agent_output = AgentOutput.model_validate(agent_output_data)
except Exception:
return None
agent_output = _RUNTIME_AGENT_OUTPUT_ADAPTER.validate_python(agent_output_data)
if not agent_output:
return None
payload = agent_output.model_dump(mode="json", by_alias=True, exclude_none=True)
payload.pop("ui_hints", None)
return payload or None