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:
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user