refactor: 重整 schemas 作用域并统一用户上下文模型

This commit is contained in:
zl-q
2026-03-13 01:01:54 +08:00
parent f201babb48
commit fb3c649db7
42 changed files with 4205 additions and 2013 deletions
+32
View File
@@ -0,0 +1,32 @@
"""Centralized shared schemas for cross-module contracts."""
from schemas.inbox.messages import InboxMessageStatus, InboxMessageType
from schemas.schedule.items import (
AttachmentType,
CalendarContent,
CalendarDeleteContent,
CalendarInviteContent,
CalendarUpdateContent,
ScheduleItemMetadata,
ScheduleItemMetadataAttachment,
ScheduleItemSourceType,
ScheduleItemStatus,
parse_calendar_content,
)
from schemas.user.context import UserContext
__all__ = [
"AttachmentType",
"CalendarContent",
"CalendarDeleteContent",
"CalendarInviteContent",
"CalendarUpdateContent",
"InboxMessageStatus",
"InboxMessageType",
"ScheduleItemMetadata",
"ScheduleItemMetadataAttachment",
"ScheduleItemSourceType",
"ScheduleItemStatus",
"UserContext",
"parse_calendar_content",
]
+54
View File
@@ -0,0 +1,54 @@
from schemas.agent.agui_input import (
extract_latest_tool_result,
parse_run_input,
validate_run_request_messages_contract,
)
from schemas.agent.agent_runtime import (
AcceptedTaskResponse,
AgUiWireEvent,
HistorySnapshotResponse,
InternalRuntimeEvent,
ResumeCommand,
RunCommand,
TaskAccepted,
TaskAcceptedResponse,
)
from schemas.agent.runtime_models import (
RouterAgentOutput,
ToolAgentOutput,
UiHintsPayload,
WorkerAgentOutput,
)
from schemas.agent.execution import (
ExecutionBatchOutput,
ExecutionTaskOutput,
)
from schemas.agent.intent import IntentOutput, IntentTask
from schemas.agent.report import ReportOutput
from schemas.agent.runtime import RuntimeOutput
from schemas.agent.config import SystemAgentLLMConfig
__all__ = [
"AgUiWireEvent",
"AcceptedTaskResponse",
"ExecutionBatchOutput",
"ExecutionTaskOutput",
"HistorySnapshotResponse",
"IntentOutput",
"IntentTask",
"InternalRuntimeEvent",
"parse_run_input",
"validate_run_request_messages_contract",
"extract_latest_tool_result",
"SystemAgentLLMConfig",
"ReportOutput",
"ResumeCommand",
"RouterAgentOutput",
"RuntimeOutput",
"RunCommand",
"TaskAccepted",
"TaskAcceptedResponse",
"ToolAgentOutput",
"UiHintsPayload",
"WorkerAgentOutput",
]
@@ -0,0 +1,69 @@
from __future__ import annotations
from typing import Any, ClassVar, Literal
from pydantic import BaseModel, ConfigDict, Field
class _AliasModel(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(
populate_by_name=True, serialize_by_alias=True, extra="forbid"
)
class AcceptedTaskResponse(_AliasModel):
task_id: str = Field(alias="taskId", min_length=1)
thread_id: str = Field(alias="threadId", min_length=1)
run_id: str = Field(alias="runId", min_length=1)
created: bool
class RunCommand(_AliasModel):
thread_id: str = Field(alias="threadId", min_length=1)
run_id: str = Field(alias="runId", min_length=1)
state: dict[str, Any] | None = None
messages: list[dict[str, Any]] = Field(default_factory=list)
tools: list[dict[str, Any]] = Field(default_factory=list)
context: dict[str, Any] | list[dict[str, Any]] = Field(default_factory=list)
parent_run_id: str | None = Field(default=None, alias="parentRunId")
forwarded_props: dict[str, Any] = Field(
default_factory=dict, alias="forwardedProps"
)
class ResumeCommand(RunCommand):
pass
# Backward compatibility alias during migration.
TaskAcceptedResponse = AcceptedTaskResponse
TaskAccepted = AcceptedTaskResponse
class InternalRuntimeEvent(_AliasModel):
type: str = Field(min_length=1)
thread_id: str | None = Field(default=None, alias="threadId")
run_id: str | None = Field(default=None, alias="runId")
data: dict[str, Any] = Field(default_factory=dict)
class AgUiWireEvent(_AliasModel):
type: str = Field(min_length=1)
thread_id: str | None = Field(default=None, alias="threadId")
run_id: str | None = Field(default=None, alias="runId")
payload: Any = None
class HistorySnapshot(_AliasModel):
scope: Literal["history_day"] = "history_day"
thread_id: str | None = Field(default=None, alias="threadId")
day: str | None = None
has_more: bool = Field(default=False, alias="hasMore")
messages: list[dict[str, Any]] = Field(default_factory=list)
class HistorySnapshotResponse(_AliasModel):
type: Literal["STATE_SNAPSHOT"] = "STATE_SNAPSHOT"
thread_id: str | None = Field(default=None, alias="threadId")
run_id: str | None = Field(default=None, alias="runId")
snapshot: HistorySnapshot
+202
View File
@@ -0,0 +1,202 @@
from __future__ import annotations
import json
from typing import Any
from uuid import UUID
from ag_ui.core import RunAgentInput
from pydantic import ValidationError
MAX_RUN_INPUT_BYTES = 256_000
MAX_RUN_ID_LENGTH = 128
MAX_MESSAGES = 200
MAX_TEXT_CHARS = 10_000
def _safe_len(value: str | None) -> int:
if value is None:
return 0
return len(value)
def _user_text_chars(run_input: RunAgentInput) -> int:
total = 0
for message in run_input.messages:
if getattr(message, "role", None) != "user":
continue
content = getattr(message, "content", None)
if isinstance(content, str):
total += len(content)
continue
if isinstance(content, list):
for item in content:
if getattr(item, "type", None) != "text":
continue
text = getattr(item, "text", None)
if isinstance(text, str):
total += len(text)
return total
def parse_run_input(payload: dict[str, Any]) -> RunAgentInput:
payload_bytes = len(
json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode("utf-8")
)
if payload_bytes > MAX_RUN_INPUT_BYTES:
raise ValueError("RunAgentInput payload exceeds size limit")
try:
run_input = RunAgentInput.model_validate(payload)
except ValidationError as exc:
raise ValueError("invalid AG-UI RunAgentInput payload") from exc
try:
UUID(run_input.thread_id)
except ValueError as exc:
raise ValueError("threadId must be a valid UUID") from exc
if _safe_len(run_input.run_id) > MAX_RUN_ID_LENGTH:
raise ValueError("runId exceeds length limit")
if len(run_input.messages) > MAX_MESSAGES:
raise ValueError("RunAgentInput.messages exceeds limit")
if _user_text_chars(run_input) > MAX_TEXT_CHARS:
raise ValueError("RunAgentInput user message text exceeds limit")
return run_input
def validate_run_request_messages_contract(run_input: RunAgentInput) -> None:
if len(run_input.messages) != 1:
raise ValueError("RunAgentInput.messages must contain exactly one user message")
message = run_input.messages[0]
if getattr(message, "role", None) != "user":
raise ValueError("RunAgentInput.messages[0].role must be user")
_validate_user_content_blocks(getattr(message, "content", None))
extract_latest_user_payload(run_input)
def extract_latest_user_text(run_input: RunAgentInput) -> str:
text, _ = extract_latest_user_payload(run_input)
return text
def extract_latest_user_content(
run_input: RunAgentInput,
) -> list[dict[str, Any]]:
_, content_blocks = extract_latest_user_payload(run_input)
return content_blocks
def extract_latest_user_payload(
run_input: RunAgentInput,
) -> tuple[str, list[dict[str, Any]]]:
for message in reversed(run_input.messages):
role = getattr(message, "role", None)
if role != "user":
continue
content = getattr(message, "content", None)
if isinstance(content, str):
text = content.strip()
if text:
return text, [{"type": "text", "text": text}]
continue
if isinstance(content, list):
text_parts: list[str] = []
blocks: list[dict[str, Any]] = []
for item in content:
item_type = getattr(item, "type", None)
if item_type == "text":
text = getattr(item, "text", None)
if isinstance(text, str) and text:
text_parts.append(text)
blocks.append({"type": "text", "text": text})
continue
if item_type != "binary":
continue
source_url = (
item.get("url")
if isinstance(item, dict)
else getattr(item, "url", None)
)
if isinstance(source_url, str) and source_url:
blocks.append(
{"type": "image_url", "image_url": {"url": source_url}}
)
combined = "".join(text_parts).strip()
if combined or blocks:
return combined, blocks
raise ValueError(
"RunAgentInput.messages requires at least one non-empty user message"
)
def _validate_user_content_blocks(content: Any) -> None:
if isinstance(content, str):
if content.strip():
return
raise ValueError(
"RunAgentInput.messages requires at least one non-empty user message"
)
if not isinstance(content, list):
raise ValueError("RunAgentInput.messages[0].content must be string or list")
has_text = False
has_binary = False
for item in content:
item_type = getattr(item, "type", None)
if item_type == "text":
text = getattr(item, "text", None)
if isinstance(text, str) and text.strip():
has_text = True
continue
if item_type == "binary":
mime_type = (
item.get("mimeType")
if isinstance(item, dict)
else getattr(item, "mime_type", None)
)
url = (
item.get("url")
if isinstance(item, dict)
else getattr(item, "url", None)
)
data = (
item.get("data")
if isinstance(item, dict)
else getattr(item, "data", None)
)
if not isinstance(mime_type, str) or not mime_type.startswith("image/"):
raise ValueError("binary content requires image mimeType")
if not isinstance(url, str) or not url:
raise ValueError("binary content requires url")
if isinstance(data, str) and data:
raise ValueError("binary content data is not allowed")
has_binary = True
continue
raise ValueError("unsupported content block type")
if not has_text and not has_binary:
raise ValueError(
"RunAgentInput.messages requires at least one non-empty user message"
)
def extract_latest_tool_result(
run_input: RunAgentInput,
) -> tuple[str, dict[str, object]]:
for message in reversed(run_input.messages):
role = getattr(message, "role", None)
if role != "tool":
continue
tool_call_id = getattr(message, "tool_call_id", None)
content = getattr(message, "content", None)
if not isinstance(tool_call_id, str) or not tool_call_id:
continue
if not isinstance(content, str):
break
try:
parsed = json.loads(content)
except (TypeError, ValueError):
return tool_call_id, {"content": content}
if isinstance(parsed, dict):
return tool_call_id, parsed
return tool_call_id, {"content": content}
raise ValueError(
"RunAgentInput.messages requires a tool message with toolCallId for resume"
)
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
from pydantic import BaseModel, Field
class SystemAgentLLMConfig(BaseModel):
temperature: float | None = Field(default=None, ge=0.0, le=2.0)
max_tokens: int | None = Field(default=None, ge=1)
timeout_seconds: float | None = Field(default=30.0, gt=0.0, le=300.0)
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
class ExecutionToolCall(BaseModel):
tool_name: str = Field(min_length=1)
args: dict[str, Any] = Field(default_factory=dict)
result: Any | None = None
error: str | None = None
class ExecutionTaskOutput(BaseModel):
task_id: str = Field(min_length=1)
status: Literal["SUCCESS", "PARTIAL", "FAILED"]
execution_summary: str = Field(min_length=1)
execution_data: dict[str, Any] = Field(default_factory=dict)
user_feedback_needs: list[str] = Field(default_factory=list)
response_metadata: dict[str, Any] = Field(default_factory=dict)
tool_calls: list[ExecutionToolCall] = Field(default_factory=list)
class ExecutionBatchOutput(BaseModel):
task_results: list[ExecutionTaskOutput] = Field(default_factory=list)
overall_status: Literal["SUCCESS", "PARTIAL", "FAILED"]
aggregate_summary: str = Field(min_length=1)
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from typing import Any
from typing import Literal
from pydantic import BaseModel, Field, model_validator
class IntentTask(BaseModel):
task_id: str = Field(min_length=1)
title: str = Field(min_length=1)
objective: str = Field(min_length=1)
class IntentOutput(BaseModel):
route: Literal["DIRECT_RESPONSE", "TASK_EXECUTION"]
intent_summary: str = Field(min_length=1)
direct_response: str | None = None
tasks: list[IntentTask] = Field(default_factory=list)
complexity: Literal["simple", "complex"]
response_metadata: dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_route(self) -> "IntentOutput":
if self.route == "DIRECT_RESPONSE":
if not self.direct_response:
raise ValueError("direct_response is required for DIRECT_RESPONSE")
if self.tasks:
raise ValueError("tasks must be empty for DIRECT_RESPONSE")
if self.route == "TASK_EXECUTION":
if not self.tasks:
raise ValueError("tasks is required for TASK_EXECUTION")
return self
+10
View File
@@ -0,0 +1,10 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class ReportOutput(BaseModel):
assistant_text: str = Field(min_length=1)
response_metadata: dict[str, Any] = Field(default_factory=dict)
+13
View File
@@ -0,0 +1,13 @@
from __future__ import annotations
from pydantic import BaseModel
from schemas.agent.execution import ExecutionBatchOutput
from schemas.agent.intent import IntentOutput
from schemas.agent.report import ReportOutput
class RuntimeOutput(BaseModel):
intent: IntentOutput
execution: ExecutionBatchOutput | None = None
report: ReportOutput
+415
View File
@@ -0,0 +1,415 @@
from __future__ import annotations
from enum import Enum
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class TaskType(str, Enum):
KNOWLEDGE = "knowledge"
RECOMMENDATION = "recommendation"
PLANNING = "planning"
SCHEDULING = "scheduling"
REMINDER_MANAGEMENT = "reminder_management"
TODO_MANAGEMENT = "todo_management"
COMMUNICATION_DRAFTING = "communication_drafting"
INFORMATION_ORGANIZATION = "information_organization"
STATUS_TRACKING = "status_tracking"
TRANSACTION_ASSIST = "transaction_assist"
ACTION_EXECUTION = "action_execution"
TROUBLESHOOTING = "troubleshooting"
UNKNOWN = "unknown"
class ResultType(str, Enum):
DIRECT_ANSWER = "direct_answer"
OPTIONS_WITH_RECOMMENDATION = "options_with_recommendation"
ACTION_PLAN = "action_plan"
SCHEDULE_PROPOSAL = "schedule_proposal"
TODO_LIST = "todo_list"
DRAFT_MESSAGE = "draft_message"
SUMMARY = "summary"
PROGRESS_SUMMARY = "progress_summary"
DIAGNOSIS_REPORT = "diagnosis_report"
STRUCTURED_PAYLOAD = "structured_payload"
EXECUTION_REPORT = "execution_report"
CLARIFICATION_REQUEST = "clarification_request"
SAFETY_BLOCK = "safety_block"
ERROR_REPORT = "error_report"
UNKNOWN = "unknown"
class TaskTyping(BaseModel):
model_config = ConfigDict(extra="forbid")
primary: TaskType
secondary: list[TaskType] = Field(default_factory=list)
class ResultTyping(BaseModel):
model_config = ConfigDict(extra="forbid")
primary: ResultType
secondary: list[ResultType] = Field(default_factory=list)
class ExecutionMode(str, Enum):
ONESTEP = "onestep"
TOOL_ASSISTED = "tool_assisted"
MULTISTEP = "multistep"
class RunStatus(str, Enum):
SUCCESS = "success"
PARTIAL_SUCCESS = "partial_success"
FAILED = "failed"
class ToolStatus(str, Enum):
SUCCESS = "success"
FAILURE = "failure"
PARTIAL = "partial"
class UiHintStatus(str, Enum):
INFO = "info"
SUCCESS = "success"
WARNING = "warning"
ERROR = "error"
PENDING = "pending"
class UiHintActionStyle(str, Enum):
PRIMARY = "primary"
SECONDARY = "secondary"
GHOST = "ghost"
DANGER = "danger"
class UiHintTextFormat(str, Enum):
PLAIN = "plain"
MARKDOWN = "markdown"
class UiHintContainerDirection(str, Enum):
VERTICAL = "vertical"
HORIZONTAL = "horizontal"
class UiHintKvLayout(str, Enum):
VERTICAL = "vertical"
HORIZONTAL = "horizontal"
GRID = "grid"
class UiHintOperationType(str, Enum):
CREATE = "create"
UPDATE = "update"
DELETE = "delete"
EXECUTE = "execute"
class UiHintOperationResult(str, Enum):
SUCCESS = "success"
FAILURE = "failure"
PARTIAL = "partial"
class KeyEntity(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
type: str
value: str | None = None
class ConstraintItem(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str
value: str
required: bool = True
class NormalizedTaskInput(BaseModel):
model_config = ConfigDict(extra="forbid")
user_text: str = Field(..., description="归一化后的核心用户请求")
multimodal_summary: list[str] = Field(
default_factory=list,
description="Router 从图片/附件提炼出的要点",
)
class RouterAgentOutput(BaseModel):
model_config = ConfigDict(extra="forbid")
normalized_task_input: NormalizedTaskInput
key_entities: list[KeyEntity] = Field(default_factory=list)
constraints: list[ConstraintItem] = Field(default_factory=list)
task_typing: TaskTyping
execution_mode: ExecutionMode
result_typing: ResultTyping
class ErrorInfo(BaseModel):
model_config = ConfigDict(extra="forbid")
code: str
message: str
retryable: bool = False
details: dict[str, Any] | None = None
class UiHintConfirm(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str | None = None
message: str | None = None
confirm_label: str | None = Field(default=None, alias="confirmLabel")
cancel_label: str | None = Field(default=None, alias="cancelLabel")
class UiHintActionNavigation(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["navigation"]
path: str
params: dict[str, Any] | None = None
class UiHintActionUrl(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["url"]
url: str
target: Literal["_self", "_blank"] | None = None
class UiHintActionEvent(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["event"]
event: str
payload: dict[str, Any] | None = None
class UiHintActionTool(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["tool"]
tool_id: str = Field(alias="toolId")
params: dict[str, Any] | None = None
class UiHintActionCopy(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["copy"]
content: str
success_message: str | None = Field(default=None, alias="successMessage")
class UiHintActionPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal["payload"]
payload: dict[str, Any]
submit_to: str | None = Field(default=None, alias="submitTo")
UiHintActionTarget = Annotated[
(
UiHintActionNavigation
| UiHintActionUrl
| UiHintActionEvent
| UiHintActionTool
| UiHintActionCopy
| UiHintActionPayload
),
Field(discriminator="type"),
]
class UiHintAction(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str | None = None
label: str
style: UiHintActionStyle | None = None
disabled: bool = False
action: UiHintActionTarget
confirm: UiHintConfirm | None = None
class UiHintIcon(BaseModel):
model_config = ConfigDict(extra="forbid")
source: Literal["icon", "emoji", "url"]
value: str
color: str | None = None
size: int | None = None
class UiHintBadge(BaseModel):
model_config = ConfigDict(extra="forbid")
label: str
variant: Literal["default", "success", "warning", "error", "info"] = "default"
class UiHintKeyValuePair(BaseModel):
model_config = ConfigDict(extra="forbid")
key: str
label: str | None = None
value: str | int | bool | None = None
copyable: bool = False
class UiHintListItem(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str | None = None
title: str
subtitle: str | None = None
description: str | None = None
icon: UiHintIcon | None = None
badge: UiHintBadge | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
actions: list[UiHintAction] = Field(default_factory=list)
class UiHintPagination(BaseModel):
model_config = ConfigDict(extra="forbid")
page: int
page_size: int = Field(alias="pageSize")
total: int
has_more: bool = Field(alias="hasMore")
class UiHintBaseBlock(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str | None = None
title: str | None = None
description: str | None = None
status: UiHintStatus | None = None
actions: list[UiHintAction] = Field(default_factory=list)
class UiHintTextBlock(UiHintBaseBlock):
kind: Literal["text"]
content: str
format: UiHintTextFormat = UiHintTextFormat.PLAIN
class UiHintCardBlock(UiHintBaseBlock):
kind: Literal["card"]
children: list["UiHintBlock"] = Field(default_factory=list)
class UiHintKvBlock(UiHintBaseBlock):
kind: Literal["kv"]
pairs: list[UiHintKeyValuePair] = Field(default_factory=list)
layout: UiHintKvLayout = UiHintKvLayout.VERTICAL
class UiHintListBlock(UiHintBaseBlock):
kind: Literal["list"]
items: list[UiHintListItem] = Field(default_factory=list)
pagination: UiHintPagination | None = None
empty_text: str | None = Field(default=None, alias="emptyText")
class UiHintOperationBlock(UiHintBaseBlock):
kind: Literal["operation"]
operation: UiHintOperationType
result: UiHintOperationResult
message: str | None = None
affected_count: int | None = Field(default=None, alias="affectedCount")
details: dict[str, Any] | None = None
class UiHintErrorBlock(UiHintBaseBlock):
kind: Literal["error"]
error_code: str = Field(alias="errorCode")
message: str
retryable: bool = False
details: str | None = None
suggestions: list[str] = Field(default_factory=list)
class UiHintContainerBlock(UiHintBaseBlock):
kind: Literal["container"]
direction: UiHintContainerDirection = UiHintContainerDirection.VERTICAL
gap: int | None = None
children: list["UiHintBlock"] = Field(default_factory=list)
class UiHintCustomBlock(UiHintBaseBlock):
kind: Literal["custom"]
renderer_key: str = Field(alias="rendererKey")
payload: dict[str, Any] = Field(default_factory=dict)
UiHintBlock = Annotated[
(
UiHintTextBlock
| UiHintCardBlock
| UiHintKvBlock
| UiHintListBlock
| UiHintOperationBlock
| UiHintErrorBlock
| UiHintContainerBlock
| UiHintCustomBlock
),
Field(discriminator="kind"),
]
class UiHintsPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
version: str = "1.0"
status: UiHintStatus = UiHintStatus.INFO
title: str | None = None
description: str | None = None
blocks: list[UiHintBlock] = Field(default_factory=list)
actions: list[UiHintAction] = Field(default_factory=list)
meta: dict[str, Any] = Field(default_factory=dict)
class ToolAgentOutput(BaseModel):
model_config = ConfigDict(extra="forbid")
tool_name: str
tool_call_id: str
tool_call_args: dict[str, Any] | None = None
status: ToolStatus
result_summary: str
ui_hints: UiHintsPayload | None = None
error: ErrorInfo | None = None
class WorkerAgentOutput(BaseModel):
model_config = ConfigDict(extra="forbid")
status: RunStatus = RunStatus.SUCCESS
answer: str = Field(..., description="完整正文")
key_points: list[str] = Field(
default_factory=list, description="关键点,建议 0~5 条"
)
result_type: ResultType = ResultType.UNKNOWN
suggested_actions: list[str] = Field(
default_factory=list,
description="后续建议行动,0~3条",
)
ui_hints: UiHintsPayload | None = None
error: ErrorInfo | None = None
UiHintCardBlock.model_rebuild()
UiHintContainerBlock.model_rebuild()
+770
View File
@@ -0,0 +1,770 @@
"""
UI Schema Protocol Implementation.
This module is the single source of truth for UI Schema.
All implementations must follow docs/protocols/ui-schema.md.
Version: 1.0
"""
from __future__ import annotations
from enum import Enum
from typing import Any, Literal, NotRequired, TypedDict, Union
# ========== Enums ==========
class SchemaType(str, Enum):
"""Schema type identifier."""
TOOL_RESULT = "tool_result"
AGENT_RESPONSE = "agent_response"
NOTIFICATION = "notification"
class UiStatus(str, Enum):
"""Unified status for all nodes."""
INFO = "info"
SUCCESS = "success"
WARNING = "warning"
ERROR = "error"
PENDING = "pending"
class IconSource(str, Enum):
"""Icon source type."""
ICON = "icon"
EMOJI = "emoji"
URL = "url"
class ActionType(str, Enum):
"""Action type identifier."""
NAVIGATION = "navigation"
URL = "url"
EVENT = "event"
TOOL = "tool"
COPY = "copy"
PAYLOAD = "payload"
class OperationType(str, Enum):
"""Operation node operation type."""
CREATE = "create"
UPDATE = "update"
DELETE = "delete"
EXECUTE = "execute"
class OperationResult(str, Enum):
"""Operation node result type."""
SUCCESS = "success"
FAILURE = "failure"
PARTIAL = "partial"
class ContainerDirection(str, Enum):
"""Container node direction."""
VERTICAL = "vertical"
HORIZONTAL = "horizontal"
class TextFormat(str, Enum):
"""Text node format."""
PLAIN = "plain"
MARKDOWN = "markdown"
class KvLayout(str, Enum):
"""Key-value node layout."""
VERTICAL = "vertical"
HORIZONTAL = "horizontal"
GRID = "grid"
class BadgeVariant(str, Enum):
"""Badge variant."""
DEFAULT = "default"
SUCCESS = "success"
WARNING = "warning"
ERROR = "error"
INFO = "info"
class ActionStyle(str, Enum):
"""Action button style."""
PRIMARY = "primary"
SECONDARY = "secondary"
GHOST = "ghost"
DANGER = "danger"
class RendererTheme(str, Enum):
"""Renderer theme."""
DEFAULT = "default"
DARK = "dark"
LIGHT = "light"
# ========== Common Types ==========
class UiIcon(TypedDict, total=False):
"""Icon structure."""
source: IconSource
value: str
color: str
size: int
class UiBadge(TypedDict, total=False):
"""Badge structure."""
label: str
variant: BadgeVariant
class Pagination(TypedDict):
"""Pagination info."""
page: int
pageSize: int
total: int
hasMore: bool
class ActionConfirm(TypedDict, total=False):
"""Action confirmation config."""
title: str
message: str
confirmLabel: str
cancelLabel: str
class KeyValuePair(TypedDict, total=False):
"""Key-value pair."""
key: str
label: str
value: Union[str, int, bool]
copyable: bool
class TableColumn(TypedDict, total=False):
"""Table column definition."""
key: str
label: str
width: str
align: Literal["left", "center", "right"]
class TableRow(TypedDict, total=False):
"""Table row."""
id: str
cells: dict[str, Any]
metadata: dict[str, Any]
actions: list[dict[str, Any]]
class ListItem(TypedDict, total=False):
"""List item."""
id: str
title: str
subtitle: str
description: str
icon: UiIcon
badge: UiBadge
metadata: dict[str, Any]
actions: list[dict[str, Any]]
# ========== Action Types ==========
class NavigateAction(TypedDict):
"""Navigate to internal path."""
type: Literal["navigation"]
path: str
params: NotRequired[dict[str, Any]]
class LinkAction(TypedDict):
"""Open external URL."""
type: Literal["url"]
url: str
target: NotRequired[Literal["_self", "_blank"]]
class EventAction(TypedDict):
"""Trigger frontend event."""
type: Literal["event"]
event: str
payload: NotRequired[dict[str, Any]]
class ToolAction(TypedDict):
"""Re-execute a tool."""
type: Literal["tool"]
toolId: str
params: NotRequired[dict[str, Any]]
class CopyAction(TypedDict):
"""Copy content to clipboard."""
type: Literal["copy"]
content: str
successMessage: NotRequired[str]
class PayloadAction(TypedDict):
"""Submit payload to endpoint."""
type: Literal["payload"]
payload: dict[str, Any]
submitTo: NotRequired[str]
# ========== Action ==========
class UiAction(TypedDict, total=False):
"""Action structure."""
id: str
label: str
icon: UiIcon
style: ActionStyle
disabled: bool
action: (
NavigateAction
| LinkAction
| EventAction
| ToolAction
| CopyAction
| PayloadAction
)
confirm: ActionConfirm
# ========== Node Types (using dict for simplicity) ==========
# Type alias for any node
UiNode = dict[str, Any]
# ========== Builder Functions ==========
def build_document(
status: UiStatus,
nodes: list[UiNode],
*,
version: str = "1.0",
schema_type: SchemaType = SchemaType.TOOL_RESULT,
doc_id: str | None = None,
timestamp: str | None = None,
locale: str = "zh-CN",
renderer: dict[str, Any] | None = None,
meta: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build a UI schema document."""
doc: dict[str, Any] = {
"version": version,
"schemaType": schema_type.value,
"status": status.value,
"nodes": nodes,
}
if doc_id:
doc["docId"] = doc_id
if timestamp:
doc["timestamp"] = timestamp
if locale:
doc["locale"] = locale
if renderer:
doc["renderer"] = renderer
if meta:
doc["meta"] = meta
return doc
def build_success_document(
nodes: list[UiNode],
**kwargs: Any,
) -> dict[str, Any]:
"""Build a success document."""
return build_document(status=UiStatus.SUCCESS, nodes=nodes, **kwargs)
def build_error_document(
nodes: list[UiNode],
**kwargs: Any,
) -> dict[str, Any]:
"""Build an error document."""
return build_document(status=UiStatus.ERROR, nodes=nodes, **kwargs)
def build_text_node(
content: str,
*,
node_id: str | None = None,
format: TextFormat = TextFormat.PLAIN,
icon: dict[str, Any] | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build a text node."""
node: dict[str, Any] = {
"type": "text",
"content": content,
"format": format.value,
}
if node_id:
node["id"] = node_id
if icon:
node["icon"] = icon
if actions:
node["actions"] = actions
return node
def build_card_node(
*,
node_id: str | None = None,
title: str | None = None,
description: str | None = None,
icon: dict[str, Any] | None = None,
status: UiStatus | None = None,
timestamp: str | None = None,
children: list[UiNode] | None = None,
footer: dict[str, Any] | None = None,
extensions: dict[str, Any] | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build a card node."""
node: dict[str, Any] = {"type": "card"}
if node_id:
node["id"] = node_id
if title:
node["title"] = title
if description:
node["description"] = description
if icon:
node["icon"] = icon
if status:
node["status"] = status.value
if timestamp:
node["timestamp"] = timestamp
if children:
node["children"] = children
if footer:
node["footer"] = footer
if extensions:
node["extensions"] = extensions
if actions:
node["actions"] = actions
return node
def build_kv_node(
pairs: list[dict[str, Any]],
*,
node_id: str | None = None,
title: str | None = None,
description: str | None = None,
icon: dict[str, Any] | None = None,
status: UiStatus | None = None,
layout: KvLayout = KvLayout.VERTICAL,
extensions: dict[str, Any] | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build a key-value node."""
node: dict[str, Any] = {
"type": "kv",
"pairs": pairs,
"layout": layout.value,
}
if node_id:
node["id"] = node_id
if title:
node["title"] = title
if description:
node["description"] = description
if icon:
node["icon"] = icon
if status:
node["status"] = status.value
if extensions:
node["extensions"] = extensions
if actions:
node["actions"] = actions
return node
def build_list_node(
items: list[dict[str, Any]],
*,
node_id: str | None = None,
title: str | None = None,
description: str | None = None,
icon: dict[str, Any] | None = None,
status: UiStatus | None = None,
pagination: dict[str, Any] | None = None,
empty_text: str | None = None,
extensions: dict[str, Any] | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build a list node."""
node: dict[str, Any] = {
"type": "list",
"items": items,
}
if node_id:
node["id"] = node_id
if title:
node["title"] = title
if description:
node["description"] = description
if icon:
node["icon"] = icon
if status:
node["status"] = status.value
if pagination:
node["pagination"] = pagination
if empty_text:
node["emptyText"] = empty_text
if extensions:
node["extensions"] = extensions
if actions:
node["actions"] = actions
return node
def build_error_node(
error_code: str,
message: str,
*,
node_id: str | None = None,
title: str | None = None,
icon: dict[str, Any] | None = None,
details: str | None = None,
stack: str | None = None,
retryable: bool = False,
suggestions: list[str] | None = None,
retry: dict[str, Any] | None = None,
support: dict[str, Any] | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build an error node."""
node: dict[str, Any] = {
"type": "error",
"errorCode": error_code,
"message": message,
"retryable": retryable,
}
if node_id:
node["id"] = node_id
if title:
node["title"] = title
if icon:
node["icon"] = icon
if details:
node["details"] = details
if stack:
node["stack"] = stack
if suggestions:
node["suggestions"] = suggestions
if retry:
node["retry"] = retry
if support:
node["support"] = support
if actions:
node["actions"] = actions
return node
def build_operation_node(
operation: OperationType,
result: OperationResult,
*,
node_id: str | None = None,
title: str | None = None,
description: str | None = None,
icon: dict[str, Any] | None = None,
status: UiStatus | None = None,
message: str | None = None,
affected_count: int | None = None,
details: dict[str, Any] | None = None,
rollback: dict[str, Any] | None = None,
extensions: dict[str, Any] | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build an operation node."""
node: dict[str, Any] = {
"type": "operation",
"operation": operation.value,
"result": result.value,
}
if node_id:
node["id"] = node_id
if title:
node["title"] = title
if description:
node["description"] = description
if icon:
node["icon"] = icon
if status:
node["status"] = status.value
if message:
node["message"] = message
if affected_count is not None:
node["affectedCount"] = affected_count
if details:
node["details"] = details
if rollback:
node["rollback"] = rollback
if extensions:
node["extensions"] = extensions
if actions:
node["actions"] = actions
return node
def build_container_node(
children: list[UiNode],
direction: ContainerDirection = ContainerDirection.VERTICAL,
*,
node_id: str | None = None,
gap: int | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build a container node."""
node: dict[str, Any] = {
"type": "container",
"direction": direction.value,
"children": children,
}
if node_id:
node["id"] = node_id
if gap is not None:
node["gap"] = gap
if actions:
node["actions"] = actions
return node
def build_action(
action_id: str,
label: str,
action: (
NavigateAction
| LinkAction
| EventAction
| ToolAction
| CopyAction
| PayloadAction
),
*,
icon: dict[str, Any] | None = None,
style: ActionStyle | None = None,
disabled: bool = False,
confirm: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build an action."""
act: dict[str, Any] = {
"id": action_id,
"label": label,
"action": action,
}
if icon:
act["icon"] = icon
if style:
act["style"] = style.value
if disabled:
act["disabled"] = disabled
if confirm:
act["confirm"] = confirm
return act
def build_icon(
source: IconSource,
value: str,
*,
color: str | None = None,
size: int | None = None,
) -> dict[str, Any]:
"""Build an icon."""
icon: dict[str, Any] = {"source": source.value, "value": value}
if color:
icon["color"] = color
if size is not None:
icon["size"] = size
return icon
# ========== Legacy Compatibility Wrappers ==========
# These wrappers maintain API compatibility with existing code
def build_card(
card_type: str,
data: dict[str, Any],
*,
version: str = "1.0",
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Legacy wrapper - builds a card node."""
return {
"type": card_type,
"version": version,
"data": data,
"actions": actions or [],
}
def build_calendar_card(
title: str,
start_at: str,
*,
id: str | None = None,
end_at: str | None = None,
description: str | None = None,
timezone: str | None = None,
location: str | None = None,
color: str | None = None,
source_type: str | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Legacy wrapper for calendar card."""
data: dict[str, Any] = {
"title": title,
"startAt": start_at,
}
if id is not None:
data["id"] = id
if end_at is not None:
data["endAt"] = end_at
if description is not None:
data["description"] = description
if timezone is not None:
data["timezone"] = timezone
if location is not None:
data["location"] = location
if color is not None:
data["color"] = color
if source_type is not None:
data["sourceType"] = source_type
return build_card(
card_type="calendar_card.v1",
data=data,
actions=actions,
)
def build_calendar_list(
items: list[dict[str, Any]],
*,
page: int = 1,
page_size: int = 20,
total: int | None = None,
total_pages: int | None = None,
) -> dict[str, Any]:
"""Legacy wrapper for calendar list."""
pagination: dict[str, Any] = {
"page": page,
"pageSize": page_size,
}
if total is not None:
pagination["total"] = total
if total_pages is not None:
pagination["totalPages"] = total_pages
return build_card(
card_type="calendar_event_list.v1",
data={
"items": items,
"pagination": pagination,
},
)
def build_calendar_operation(
operation: str,
ok: bool,
message: str,
*,
code: str | None = None,
actions: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Legacy wrapper for calendar operation."""
data: dict[str, Any] = {
"operation": operation,
"ok": ok,
"message": message,
}
if code is not None:
data["code"] = code
return build_card(
card_type="calendar_operation.v1",
data=data,
actions=actions,
)
def build_error_card(
message: str,
*,
code: str | None = None,
) -> dict[str, Any]:
"""Legacy wrapper for error card."""
data: dict[str, Any] = {"message": message}
if code is not None:
data["code"] = code
return build_card(
card_type="error_card.v1",
data=data,
)
def build_action_legacy(
label: str,
action_type: str = "primary",
*,
target: str | None = None,
action: str | None = None,
) -> dict[str, Any]:
"""Legacy wrapper for action."""
result: dict[str, Any] = {
"type": action_type,
"label": label,
}
if target is not None:
result["target"] = target
if action is not None:
result["action"] = action
return result
+3
View File
@@ -0,0 +1,3 @@
from schemas.inbox.messages import InboxMessageStatus, InboxMessageType
__all__ = ["InboxMessageStatus", "InboxMessageType"]
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
from enum import Enum
class InboxMessageType(str, Enum):
FRIEND_REQUEST = "friend_request"
CALENDAR = "calendar"
SYSTEM = "system"
GROUP = "group"
class InboxMessageStatus(str, Enum):
PENDING = "pending"
ACCEPTED = "accepted"
REJECTED = "rejected"
DISMISSED = "dismissed"
+25
View File
@@ -0,0 +1,25 @@
from schemas.schedule.items import (
AttachmentType,
CalendarContent,
CalendarDeleteContent,
CalendarInviteContent,
CalendarUpdateContent,
ScheduleItemMetadata,
ScheduleItemMetadataAttachment,
ScheduleItemSourceType,
ScheduleItemStatus,
parse_calendar_content,
)
__all__ = [
"AttachmentType",
"CalendarContent",
"CalendarDeleteContent",
"CalendarInviteContent",
"CalendarUpdateContent",
"ScheduleItemMetadata",
"ScheduleItemMetadataAttachment",
"ScheduleItemSourceType",
"ScheduleItemStatus",
"parse_calendar_content",
]
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import json
from enum import Enum
from typing import ClassVar, Literal, Union
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class AttachmentType(str, Enum):
DOCUMENT = "document"
REMINDER = "reminder"
class ScheduleItemMetadataAttachment(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
name: str
type: AttachmentType
visible_to: list[UUID] = Field(default_factory=list)
url: str | None = None
note: str | None = None
content: str | None = None
class ScheduleItemMetadata(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
color: str | None = Field(default=None, pattern=r"^#[0-9A-Fa-f]{6}$")
location: str | None = None
notes: str | None = None
attachments: list[ScheduleItemMetadataAttachment] = Field(default_factory=list)
reminder_minutes: int | None = Field(default=None, ge=0, le=10080)
version: Literal[1] = 1
class ScheduleItemStatus(str, Enum):
ACTIVE = "active"
COMPLETED = "completed"
CANCELED = "canceled"
ARCHIVED = "archived"
class ScheduleItemSourceType(str, Enum):
MANUAL = "manual"
IMPORTED = "imported"
AGENT_GENERATED = "agent_generated"
class CalendarInviteContent(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
type: Literal["invite"]
permission: int = Field(..., description="权限: 1=view, 4=edit, 8=invite")
action: Literal["pending"] = "pending"
class CalendarUpdateContent(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
type: Literal["update"]
title: str = Field(..., description="事件标题")
action: Literal["updated"] = "updated"
class CalendarDeleteContent(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
type: Literal["delete"]
title: str = Field(..., description="事件标题")
action: Literal["deleted"] = "deleted"
CalendarContent = Union[
CalendarInviteContent,
CalendarUpdateContent,
CalendarDeleteContent,
]
def parse_calendar_content(content: str | None) -> CalendarContent | None:
if not content:
return None
try:
data = json.loads(content)
content_type = data.get("type")
if content_type == "invite":
return CalendarInviteContent(**data)
if content_type == "update":
return CalendarUpdateContent(**data)
if content_type == "delete":
return CalendarDeleteContent(**data)
raise ValueError(f"Unknown calendar content type: {content_type}")
except Exception:
return None
+17
View File
@@ -0,0 +1,17 @@
from schemas.user.context import (
PreferenceSettings,
ProfileSettingsUnion,
ProfileSettingsV1,
UserContext,
parse_profile_settings,
upgrade_to_latest,
)
__all__ = [
"PreferenceSettings",
"ProfileSettingsUnion",
"ProfileSettingsV1",
"UserContext",
"parse_profile_settings",
"upgrade_to_latest",
]
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
import re
from typing import Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, Field, field_validator
_BCP47_PATTERN = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$")
_COUNTRY_PATTERN = re.compile(r"^[A-Z]{2}$")
class PreferenceSettings(BaseModel):
interface_language: str = "zh-CN"
ai_language: str = "zh-CN"
timezone: str = "Asia/Shanghai"
country: str = "CN"
@field_validator("interface_language", "ai_language")
@classmethod
def validate_language(cls, value: str) -> str:
if not _BCP47_PATTERN.fullmatch(value):
raise ValueError("language must be a valid BCP-47 tag")
return value
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str) -> str:
try:
ZoneInfo(value)
except ZoneInfoNotFoundError as exc:
raise ValueError("timezone must be a valid IANA timezone") from exc
return value
@field_validator("country")
@classmethod
def validate_country(cls, value: str) -> str:
normalized = value.upper()
if not _COUNTRY_PATTERN.fullmatch(normalized):
raise ValueError("country must be an ISO 3166-1 alpha-2 code")
return normalized
class ProfileSettingsV1(BaseModel):
version: Literal[1] = 1
preferences: PreferenceSettings = Field(default_factory=PreferenceSettings)
privacy: dict = Field(default_factory=dict)
notification: dict = Field(default_factory=dict)
ProfileSettingsUnion = ProfileSettingsV1
def parse_profile_settings(raw: dict | None) -> ProfileSettingsUnion:
payload = dict(raw or {})
payload.setdefault("version", 1)
return ProfileSettingsV1.model_validate(payload)
def upgrade_to_latest(settings: ProfileSettingsUnion) -> ProfileSettingsV1:
return settings
class UserContext(BaseModel):
id: str
username: str
avatar_url: str | None = None
bio: str | None = None
settings: ProfileSettingsUnion | None = None