feat: 添加视觉设计语言系统并重构认证页面UI

- 新增 visual_design_language.md 设计规范文档
- 新增 auth 设计 tokens (authBackground, authCard, authInput, feedback 系列等)
- 重构登录/注册/验证码/重置密码页面为新设计系统
- 新增 AuthHeroHeader, AuthSurfaceCard, AuthSection, AuthField, PasswordField 组件
- 重构 AppBanner 和 Toast 支持多类型配置 (info/success/warning/error)
- 后端 AgentScope: 重整 schemas/prompts/tools 作用域, 新增协议文档
- 更新 AGENTS.md 集成视觉设计语言约束
This commit is contained in:
qzl
2026-03-13 14:10:13 +08:00
parent fb3c649db7
commit a10a2db27a
100 changed files with 6333 additions and 4800 deletions
@@ -0,0 +1,9 @@
from core.agentscope.tools.utils.auth_helpers import (
find_auth_email_by_user_id,
list_auth_users,
)
__all__ = [
"list_auth_users",
"find_auth_email_by_user_id",
]
@@ -0,0 +1,36 @@
from __future__ import annotations
from typing import Any
from uuid import UUID
from services.base.supabase import supabase_service
def list_auth_users() -> list[Any]:
"""List all users from Supabase Auth admin API."""
admin_client = supabase_service.get_admin_client()
users: list[Any] = []
page = 1
while page <= 100:
response = admin_client.auth.admin.list_users(page=page, per_page=100)
batch = (
list(response)
if isinstance(response, list)
else list(getattr(response, "users", []))
)
users.extend(batch)
if len(batch) < 100:
break
page += 1
return users
def find_auth_email_by_user_id(*, users: list[Any], user_id: UUID) -> str | None:
"""Find auth email by user id from fetched user list."""
target = str(user_id)
for user in users:
if str(getattr(user, "id", "")) == target:
email = getattr(user, "email", None)
if isinstance(email, str) and email.strip():
return email.strip()
return None
@@ -0,0 +1,144 @@
from __future__ import annotations
import re
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from core.agentscope.tools.utils.auth_helpers import (
find_auth_email_by_user_id,
list_auth_users,
)
from core.auth.models import CurrentUser
from v1.inbox_messages.repository import SQLAlchemyInboxMessageRepository
from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository
from v1.schedule_items.schemas import ScheduleItemMetadata
from v1.schedule_items.service import ScheduleItemService
_HEX_COLOR_PATTERN = re.compile(r"^#[0-9A-Fa-f]{6}$")
def map_calendar_exception(exc: Exception) -> tuple[str, str, bool]:
if isinstance(exc, HTTPException):
detail = exc.detail
if isinstance(detail, str) and detail.strip():
return "OPERATION_FAILED", detail.strip(), True
return "OPERATION_FAILED", "日历操作失败", True
if isinstance(exc, ValueError):
return "INVALID_ARGUMENT", str(exc), False
return "INTERNAL_ERROR", "日历操作失败", True
def create_schedule_service(
session: AsyncSession, owner_id: UUID
) -> ScheduleItemService:
return ScheduleItemService(
repository=SQLAlchemyScheduleItemRepository(session),
session=session,
current_user=CurrentUser(id=owner_id),
inbox_repository=SQLAlchemyInboxMessageRepository(session),
)
def schedule_event_to_dict(event: object) -> dict[str, Any]:
event_id = str(getattr(event, "id"))
metadata = getattr(event, "metadata", None)
location_value = getattr(metadata, "location", None)
color_value = getattr(metadata, "color", None) or "#4F46E5"
reminder_minutes_value = getattr(metadata, "reminder_minutes", None)
return {
"id": event_id,
"title": getattr(event, "title"),
"description": getattr(event, "description"),
"startAt": getattr(event, "start_at").isoformat(),
"endAt": getattr(event, "end_at").isoformat()
if getattr(event, "end_at") is not None
else None,
"timezone": getattr(event, "timezone"),
"location": location_value,
"color": color_value,
"reminderMinutes": reminder_minutes_value,
}
def build_schedule_metadata(
location: str | None,
color: str | None,
reminder_minutes: int | None,
) -> ScheduleItemMetadata:
location_value = location.strip() if location and location.strip() else None
raw_color = color.strip() if color and color.strip() else "#4F46E5"
color_value = raw_color if _HEX_COLOR_PATTERN.match(raw_color) else "#4F46E5"
reminder_value: int | None = None
if reminder_minutes is not None:
if reminder_minutes < 0 or reminder_minutes > 10080:
raise ValueError("reminderMinutes must be 0..10080")
reminder_value = reminder_minutes
return ScheduleItemMetadata(
location=location_value,
color=color_value,
reminder_minutes=reminder_value,
)
def merge_schedule_metadata_for_update(
*,
existing_metadata: ScheduleItemMetadata | None,
location: str | None,
color: str | None,
reminder_minutes: int | None,
) -> ScheduleItemMetadata:
metadata_dump = existing_metadata.model_dump() if existing_metadata else {}
if location is not None:
metadata_dump["location"] = location.strip() or None
if color is not None:
color_str = color.strip()
if not color_str:
metadata_dump["color"] = None
elif _HEX_COLOR_PATTERN.match(color_str):
metadata_dump["color"] = color_str
else:
raise ValueError("color 必须是十六进制颜色值如 #4F46E5")
if reminder_minutes is not None:
if reminder_minutes < 0 or reminder_minutes > 10080:
raise ValueError("reminderMinutes 必须在 0-10080 之间")
metadata_dump["reminder_minutes"] = reminder_minutes
return ScheduleItemMetadata.model_validate(metadata_dump)
def parse_iso_datetime(value: str | None) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
except ValueError:
return None
def resolve_share_target_email_map(invitee_user_ids: list[str]) -> dict[str, str]:
users = list_auth_users()
resolved: dict[str, str] = {}
for raw_user_id in invitee_user_ids:
if not isinstance(raw_user_id, str):
continue
normalized_user_id = raw_user_id.strip()
if not normalized_user_id:
continue
try:
user_uuid = UUID(normalized_user_id)
except ValueError:
continue
email = find_auth_email_by_user_id(users=users, user_id=user_uuid)
if email:
resolved[str(user_uuid)] = email.lower()
return resolved
@@ -0,0 +1,238 @@
from __future__ import annotations
from typing import Any
from agentscope.tool import ToolResponse
from core.agentscope.tools.utils.tool_response_builder import (
build_error_output,
build_tool_response,
)
from schemas.agent.runtime_models import ToolAgentOutput
from schemas.agent.ui_hints import (
UiHintAction,
UiHintActionNavigation,
UiHintActionStyle,
UiHintErrorBlock,
UiHintKeyValuePair,
UiHintKvBlock,
UiHintListBlock,
UiHintListItem,
UiHintOperationBlock,
UiHintOperationResult,
UiHintOperationType,
UiHintStatus,
UiHintTextBlock,
UiHintTextFormat,
UiHintsPayload,
)
def dump_tool_output(output: ToolAgentOutput) -> ToolResponse:
return build_tool_response(output)
def calendar_error_output(
*,
tool_name: str,
tool_call_args: dict[str, Any],
code: str,
message: str,
retryable: bool,
) -> ToolResponse:
ui_hints = UiHintsPayload(
status=UiHintStatus.ERROR,
title="日历操作失败",
description=message,
blocks=[
UiHintErrorBlock(
kind="error",
title="操作失败",
errorCode=code,
message=message,
retryable=retryable,
)
],
)
output = build_error_output(
tool_name=tool_name,
tool_call_id=f"{tool_name}-call",
code=code,
message=message,
retryable=retryable,
)
output = output.model_copy(
update={"tool_call_args": tool_call_args, "ui_hints": ui_hints}
)
return dump_tool_output(output)
def calendar_read_hints(
*,
total: int,
page: int,
page_size: int,
total_pages: int,
events: list[dict[str, Any]],
) -> UiHintsPayload:
event_items = [
UiHintListItem(
id=event.get("id"),
title=str(event.get("title") or "未命名日程"),
subtitle=str(event.get("startAt") or ""),
description=str(event.get("location") or "") or None,
)
for event in events
]
return UiHintsPayload(
status=UiHintStatus.SUCCESS,
title="日程列表",
description=f"{total} 个日程",
blocks=[
UiHintKvBlock(
kind="kv",
title="分页信息",
pairs=[
UiHintKeyValuePair(key="total", label="总数", value=total),
UiHintKeyValuePair(key="page", label="当前页", value=page),
UiHintKeyValuePair(key="page_size", label="每页", value=page_size),
UiHintKeyValuePair(
key="total_pages", label="总页数", value=total_pages
),
],
),
UiHintListBlock(
kind="list",
title="日程项",
items=event_items,
emptyText="当前没有日程",
),
],
actions=[
UiHintAction(
label="打开日历",
style=UiHintActionStyle.PRIMARY,
action=UiHintActionNavigation(type="navigation", path="/calendar"),
)
],
meta={"total": total, "page": page, "page_size": page_size},
)
def calendar_write_hints(
*,
operation: str,
message: str,
event: dict[str, Any] | None,
event_id: str | None,
) -> UiHintsPayload:
operation_type = UiHintOperationType.EXECUTE
if operation == "create":
operation_type = UiHintOperationType.CREATE
elif operation == "update":
operation_type = UiHintOperationType.UPDATE
elif operation == "delete":
operation_type = UiHintOperationType.DELETE
blocks: list[Any] = [
UiHintOperationBlock(
kind="operation",
title="日历写入结果",
operation=operation_type,
result=UiHintOperationResult.SUCCESS,
message=message,
affectedCount=1,
)
]
if event:
blocks.append(
UiHintKvBlock(
kind="kv",
title="日程详情",
pairs=[
UiHintKeyValuePair(
key="event_id",
label="日程ID",
value=str(event.get("id") or ""),
copyable=True,
),
UiHintKeyValuePair(
key="title",
label="标题",
value=str(event.get("title") or ""),
copyable=True,
),
UiHintKeyValuePair(
key="start_at",
label="开始时间",
value=str(event.get("startAt") or ""),
copyable=True,
),
],
)
)
elif event_id:
blocks.append(
UiHintTextBlock(
kind="text",
content=f"目标日程 ID: {event_id}",
format=UiHintTextFormat.PLAIN,
)
)
return UiHintsPayload(
status=UiHintStatus.SUCCESS,
title="日历操作完成",
description=message,
blocks=blocks,
actions=[
UiHintAction(
label="查看日历",
style=UiHintActionStyle.PRIMARY,
action=UiHintActionNavigation(type="navigation", path="/calendar"),
)
],
)
def calendar_share_hints(
*,
event_id: str,
invited: list[str],
permission: dict[str, Any],
) -> UiHintsPayload:
permission_text = (
", ".join([k for k, v in permission.items() if v is True]) or "按邀请人单独设置"
)
return UiHintsPayload(
status=UiHintStatus.SUCCESS,
title="日程已分享",
description=f"已邀请 {len(invited)}",
blocks=[
UiHintOperationBlock(
kind="operation",
title="分享结果",
operation=UiHintOperationType.EXECUTE,
result=UiHintOperationResult.SUCCESS,
message=f"已邀请 {len(invited)}",
affectedCount=len(invited),
),
UiHintKvBlock(
kind="kv",
title="分享信息",
pairs=[
UiHintKeyValuePair(
key="event_id", label="日程ID", value=event_id, copyable=True
),
UiHintKeyValuePair(
key="permission", label="权限", value=permission_text
),
],
),
UiHintListBlock(
kind="list",
title="被邀请人",
items=[UiHintListItem(title=email) for email in invited],
emptyText="暂无被邀请人",
),
],
)
@@ -0,0 +1,73 @@
from __future__ import annotations
import json
from typing import Any
from agentscope.message import TextBlock
from agentscope.tool import ToolResponse
from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus
def build_tool_response(content: ToolAgentOutput) -> ToolResponse:
"""Wrap ToolAgentOutput into AgentScope ToolResponse."""
payload = content.model_dump(mode="json", exclude_none=True)
return ToolResponse(
content=[
TextBlock(
type="text",
text=json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
)
]
)
def build_error_output(
tool_name: str,
tool_call_id: str,
code: str,
message: str,
retryable: bool = False,
details: dict[str, Any] | None = None,
) -> ToolAgentOutput:
"""Build a ToolAgentOutput in failure status."""
return ToolAgentOutput(
tool_name=tool_name,
tool_call_id=tool_call_id,
status=ToolStatus.FAILURE,
result_summary=message,
error=ErrorInfo(
code=code,
message=message,
retryable=retryable,
details=details,
),
)
def build_error_response(
tool_name: str,
tool_call_id: str,
code: str,
message: str,
retryable: bool = False,
details: dict[str, Any] | None = None,
) -> ToolResponse:
"""Build standardized ToolResponse for error cases."""
return build_tool_response(
build_error_output(
tool_name=tool_name,
tool_call_id=tool_call_id,
code=code,
message=message,
retryable=retryable,
details=details,
)
)
__all__ = [
"build_tool_response",
"build_error_output",
"build_error_response",
"ToolAgentOutput",
]