refactor: 重整 schemas 作用域并统一用户上下文模型
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
from schemas.inbox.messages import InboxMessageStatus, InboxMessageType
|
||||
|
||||
__all__ = ["InboxMessageStatus", "InboxMessageType"]
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user