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:
@@ -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
|
||||
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user