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
@@ -125,7 +125,7 @@ def upgrade() -> None:
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at)
@@ -52,8 +52,8 @@ def upgrade() -> None:
op.execute(
"""
CREATE OR REPLACE FUNCTION public.create_profile_for_new_user()
RETURNS trigger
CREATE OR REPLACE FUNCTION public.generate_invite_code()
RETURNS TEXT
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
@@ -78,7 +78,7 @@ def upgrade() -> None:
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
SET search_path = ''
AS $$
DECLARE
invite_code_value TEXT;
@@ -69,10 +69,15 @@ def upgrade() -> None:
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
SET search_path = ''
AS $$
DECLARE
invite_code_value TEXT;
referrer_id UUID;
new_code TEXT;
attempts INT := 0;
BEGIN
INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at)
INSERT INTO public.profiles (id, username, avatar_url, bio, settings, referred_by, created_at, updated_at)
VALUES (
NEW.id,
COALESCE(
@@ -82,11 +87,54 @@ def upgrade() -> None:
),
NULL,
NULL,
'{"agent_prompts": {}}'::jsonb,
'{}'::jsonb,
NULL,
now(),
now()
)
ON CONFLICT (id) DO NOTHING;
LOOP
BEGIN
new_code := public.generate_invite_code();
INSERT INTO public.invite_codes (code, owner_id, status, used_count, max_uses, expires_at, reward_config)
VALUES (
new_code,
NEW.id,
'active',
0,
NULL,
NULL,
'{}'::jsonb
);
EXIT;
EXCEPTION WHEN unique_violation THEN
attempts := attempts + 1;
IF attempts >= 100 THEN
RAISE EXCEPTION 'Failed to generate unique invite code after 100 attempts';
END IF;
END;
END LOOP;
invite_code_value := NEW.raw_user_meta_data ->> 'invite_code';
IF invite_code_value IS NOT NULL AND length(invite_code_value) = 4 THEN
invite_code_value := upper(invite_code_value);
IF invite_code_value ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{4}$' THEN
UPDATE public.invite_codes
SET used_count = used_count + 1
WHERE code = invite_code_value
AND status = 'active'
AND (max_uses IS NULL OR used_count < max_uses)
AND (expires_at IS NULL OR expires_at > NOW())
RETURNING owner_id INTO referrer_id;
IF referrer_id IS NOT NULL THEN
UPDATE public.profiles
SET referred_by = referrer_id
WHERE id = NEW.id;
END IF;
END IF;
END IF;
RETURN NEW;
END;
@@ -121,7 +169,7 @@ def downgrade() -> None:
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at)
@@ -71,7 +71,7 @@ def upgrade() -> None:
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at)
@@ -114,7 +114,7 @@ def downgrade() -> None:
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.profiles (id, username, avatar_url, bio, settings, created_at, updated_at)
@@ -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={
@@ -0,0 +1,25 @@
from __future__ import annotations
from core.agentscope.prompts.route_prompt import (
build_frontend_route_prompt,
load_frontend_routes_catalog,
)
def test_load_frontend_routes_catalog_contains_known_routes() -> None:
catalog = load_frontend_routes_catalog()
assert catalog.version == "1.0"
route_ids = {route.route_id for route in catalog.routes}
assert "home.main" in route_ids
assert "calendar.event_detail" in route_ids
assert "todo.detail" in route_ids
def test_build_frontend_route_prompt_has_guidance_and_routes() -> None:
prompt = build_frontend_route_prompt()
assert "[Frontend Route Catalog]" in prompt
assert "version=1.0" in prompt
assert "route_id=home.main; path=/home;" in prompt
assert "route_id=calendar.event_detail; path=/calendar/events/{id};" in prompt
@@ -148,7 +148,9 @@ def test_build_system_prompt_keeps_sections_focused_without_language_duplication
assert "[Identity]" in prompt
assert "[Runtime Context]" in prompt
assert "<!-- ROUTE_START -->" in prompt
assert "[Safety Rules]" in prompt
assert "[Frontend Route Catalog]" in prompt
assert "[Agent Identity]" in prompt
assert "[Available Tools]" in prompt
assert "[Answer Style]" in prompt
@@ -0,0 +1,71 @@
from __future__ import annotations
import pytest
from pydantic import ValidationError
from schemas.agent.ui_hints import UiHintsPayload
def _base_payload() -> dict[str, object]:
return {
"intent": "status",
"status": "success",
"title": "Todo created",
}
def test_navigation_action_accepts_concrete_path_and_scalar_params() -> None:
payload = {
**_base_payload(),
"actions": [
{
"label": "View todo",
"action": {
"type": "navigation",
"path": "/todo/123",
"params": {"from": "assistant", "focus": True},
},
}
],
}
parsed = UiHintsPayload.model_validate(payload)
assert parsed.actions[0].action.type == "navigation"
def test_navigation_action_rejects_template_path_placeholder() -> None:
payload = {
**_base_payload(),
"actions": [
{
"label": "Open",
"action": {
"type": "navigation",
"path": "/todo/:id",
},
}
],
}
with pytest.raises(ValidationError):
UiHintsPayload.model_validate(payload)
def test_navigation_action_rejects_nested_params() -> None:
payload = {
**_base_payload(),
"actions": [
{
"label": "Open",
"action": {
"type": "navigation",
"path": "/todo/123",
"params": {"filters": {"status": "open"}},
},
}
],
}
with pytest.raises(ValidationError):
UiHintsPayload.model_validate(payload)