feat: 实现 Auth 全局状态机与 401 统一处理机制

- 新增 AuthSessionInvalidated 事件处理 token 失效场景
- ApiInterceptor 新增 authFailureCallback 单飞机制
- AuthBloc 区分 manual logout 与 auto expiry 语义
- 新增 startup recovery fallback 防止启动卡死

feat: 重构 Calendar DayWeek 视图事件布局引擎

- 新增 DayEventLayoutEngine 解耦事件计算与渲染
- 新增 DayTimelineMetrics 统一时间轴常量
- 新增 DayViewScale 支持捏合缩放

feat: 新增 Settings 页面共享 UI 组件

- 新增 BackTitlePageHeader 统一页面 header
- 新增 DetailHeaderActionMenu 统一操作菜单
- 新增 DestructiveActionSheet 统一删除确认
- 新增 AppToggleSwitch 统一开关组件

feat: Chat UI Schema 支持导航操作

- 支持 navigation 类型 action 触发内部路由跳转
- 新增路径验证与参数处理

chore: 更新相关测试覆盖 auth 失效路径
This commit is contained in:
qzl
2026-03-18 13:35:25 +08:00
parent 19981964fb
commit b34697660d
56 changed files with 2602 additions and 784 deletions
@@ -0,0 +1,76 @@
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Any, ClassVar
import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class FrontendRoute(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
route_id: str
path: str
description: str
category: str
auth_required: bool
path_params: list[str] = Field(default_factory=list)
query_params: list[str] = Field(default_factory=list)
class FrontendRouteCatalog(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
version: str
routes: list[FrontendRoute]
def _default_catalog_path() -> Path:
return (
Path(__file__).resolve().parents[2]
/ "config"
/ "static"
/ "route"
/ "frontend_routes.yaml"
)
@lru_cache(maxsize=1)
def load_frontend_routes_catalog() -> FrontendRouteCatalog:
path = _default_catalog_path()
with path.open("r", encoding="utf-8") as file:
loaded: Any = yaml.safe_load(file) or {}
if not isinstance(loaded, dict):
raise ValueError(f"Invalid frontend routes catalog format: {path}")
try:
return FrontendRouteCatalog.model_validate(loaded)
except ValidationError as exc:
raise ValueError(f"Invalid frontend routes catalog data: {path}") from exc
def build_frontend_route_prompt() -> str:
catalog = load_frontend_routes_catalog()
lines = [
"[Frontend Route Catalog]",
f"version={catalog.version}",
"rules: use listed route_id only; output concrete path; no placeholders; no query in path; put query in params; params scalar only.",
"ROUTES:",
]
for route in catalog.routes:
path_params = ", ".join(route.path_params) if route.path_params else "none"
query_params = ", ".join(route.query_params) if route.query_params else "none"
lines.append(
"- "
f"route_id={route.route_id}; "
f"path={route.path}; "
f"path_params={path_params}; "
f"query_params={query_params}"
)
return "\n".join(lines)
@@ -9,6 +9,7 @@ from ag_ui.core.types import Tool
from core.agentscope.prompts.agent_prompt import (
build_agent_prompt,
)
from core.agentscope.prompts.route_prompt import build_frontend_route_prompt
from core.agentscope.prompts.tool_prompt import build_tools_prompt
from schemas.agent.system_agent import AgentType
from schemas.agent.forwarded_props import ClientTimeContext
@@ -19,6 +20,7 @@ def _wrap_section(section: str, content: str) -> str:
marker_map = {
"env": ("<!-- ENV_START -->", "<!-- ENV_END -->"),
"identity": ("<!-- IDENTITY_START -->", "<!-- IDENTITY_END -->"),
"route": ("<!-- ROUTE_START -->", "<!-- ROUTE_END -->"),
"schema": ("<!-- SCHEMA_START -->", "<!-- SCHEMA_END -->"),
"safety": ("<!-- SAFETY_START -->", "<!-- SAFETY_END -->"),
"output": ("<!-- OUTPUT_START -->", "<!-- OUTPUT_END -->"),
@@ -193,6 +195,10 @@ def _build_output_rules() -> str:
)
def _build_route_section() -> str:
return _wrap_section("route", build_frontend_route_prompt())
def build_system_prompt(
*,
agent_type: AgentType,
@@ -202,7 +208,7 @@ def build_system_prompt(
extra_context: str | None = None,
tools: Sequence[Tool | dict[str, Any]] | None = None,
) -> str:
sections = [
sections: list[str | None] = [
_build_identity_section(),
_build_env_section(
user_context=user_context,
@@ -210,6 +216,7 @@ def build_system_prompt(
runtime_client_time=runtime_client_time,
extra_context=extra_context,
),
_build_route_section(),
_build_safety_section(),
build_agent_prompt(
agent_type=agent_type,
@@ -0,0 +1,118 @@
version: "1.0"
routes:
- route_id: auth.boot
path: /boot
description: Bootstraps auth session and redirects to login or home.
category: auth
auth_required: false
- route_id: auth.login
path: /
description: Login entry for unauthenticated users.
category: auth
auth_required: false
- route_id: auth.register
path: /register
description: Account registration page.
category: auth
auth_required: false
- route_id: auth.register_verification
path: /register/verification
description: Verifies registration code after signup.
category: auth
auth_required: false
- route_id: auth.reset_password
path: /reset-password
description: Resets password using verification flow.
category: auth
auth_required: false
- route_id: home.main
path: /home
description: Main assistant home screen.
category: home
auth_required: true
- route_id: message.invite_list
path: /messages/invites
description: Lists message invitations.
category: messages
auth_required: true
- route_id: message.invite_detail
path: /messages/invites/{id}
description: Shows details for a single invitation.
category: messages
auth_required: true
path_params:
- id
- route_id: contacts.list
path: /contacts
description: Contact list and quick relationship actions.
category: contacts
auth_required: true
- route_id: contacts.add
path: /contacts/add
description: Create or edit a contact profile.
category: contacts
auth_required: true
- route_id: calendar.dayweek
path: /calendar/dayweek
description: Day and week calendar view.
category: calendar
auth_required: true
query_params:
- date
- from
- route_id: calendar.month
path: /calendar/month
description: Month calendar overview.
category: calendar
auth_required: true
query_params:
- from
- route_id: calendar.event_detail
path: /calendar/events/{id}
description: Detail page for one calendar event.
category: calendar
auth_required: true
path_params:
- id
- route_id: todo.list
path: /todo
description: Todo quadrants and backlog overview.
category: todo
auth_required: true
- route_id: todo.detail
path: /todo/{id}
description: Detail page for one todo item.
category: todo
auth_required: true
path_params:
- id
- route_id: settings.main
path: /settings
description: Settings hub page.
category: settings
auth_required: true
- route_id: settings.features
path: /settings/features
description: Cycle planning settings page.
category: settings
auth_required: true
- route_id: settings.memory
path: /settings/memory
description: Memory preferences and controls.
category: settings
auth_required: true
- route_id: settings.account
path: /settings/account
description: Account profile and security entry points.
category: settings
auth_required: true
- route_id: settings.change_password
path: /change-password
description: Password change page.
category: settings
auth_required: true
- route_id: settings.edit_profile
path: /edit-profile
description: Profile editing page.
category: settings
auth_required: true
+48 -3
View File
@@ -13,9 +13,16 @@ Version: 2.1
from __future__ import annotations
from enum import Enum
from typing import Any, Literal
import re
from typing import Any, ClassVar, Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import field_validator
_NAVIGATION_PATH_PATTERN = re.compile(r"^/[A-Za-z0-9/_-]*$")
_NAVIGATION_PARAM_KEY_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,31}$")
_MAX_NAVIGATION_PARAMS = 8
# ============================================================
# Enums
@@ -74,7 +81,7 @@ class UiHintIconSource(str, Enum):
class UiHintBaseModel(BaseModel):
model_config = ConfigDict(
model_config: ClassVar[ConfigDict] = ConfigDict(
extra="forbid",
populate_by_name=True,
)
@@ -90,6 +97,44 @@ class UiHintActionNavigation(UiHintBaseModel):
path: str = Field(..., description="Internal route path.")
params: dict[str, Any] | None = Field(default=None, description="Route params.")
@field_validator("path")
@classmethod
def validate_navigation_path(cls, value: str) -> str:
path = value.strip()
if not path:
raise ValueError("navigation path must not be empty")
if len(path) > 256:
raise ValueError("navigation path is too long")
if path.startswith("//") or "://" in path:
raise ValueError("navigation path must be internal")
if "?" in path or "#" in path:
raise ValueError("navigation path must not contain query or fragment")
if ":" in path:
raise ValueError("navigation path must be concrete without placeholders")
if _NAVIGATION_PATH_PATTERN.fullmatch(path) is None:
raise ValueError("navigation path contains unsupported characters")
return path
@field_validator("params")
@classmethod
def validate_navigation_params(
cls, value: dict[str, Any] | None
) -> dict[str, Any] | None:
if value is None:
return None
if len(value) > _MAX_NAVIGATION_PARAMS:
raise ValueError("navigation params exceed limit")
normalized: dict[str, Any] = {}
for key, param_value in value.items():
if _NAVIGATION_PARAM_KEY_PATTERN.fullmatch(key) is None:
raise ValueError("navigation param key is invalid")
if isinstance(param_value, (str, int, float, bool)):
normalized[key] = param_value
continue
raise ValueError("navigation params must be scalar")
return normalized
class UiHintActionUrl(UiHintBaseModel):
type: Literal["url"]
@@ -203,7 +248,7 @@ class UiHintsPayload(UiHintBaseModel):
- 编译器负责转换为完整 UiSchemaRenderer
"""
model_config = ConfigDict(
model_config: ClassVar[ConfigDict] = ConfigDict(
extra="forbid",
populate_by_name=True,
json_schema_extra={