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
@@ -1,119 +1,78 @@
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any, Literal, cast
from uuid import UUID
from fastapi import HTTPException
from pydantic import Field
from sqlalchemy import select
from agentscope.tool import ToolResponse
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth.jwt_verifier import JwtVerifier, TokenValidationError
from core.agentscope.tools.tool_response_builder import (
build_success_response,
build_error_response,
from core.agentscope.tools.utils.calendar_domain import (
build_schedule_metadata,
create_schedule_service,
map_calendar_exception,
merge_schedule_metadata_for_update,
parse_iso_datetime,
resolve_share_target_email_map,
schedule_event_to_dict,
)
from core.agentscope.schemas.runtime_models import ToolOutputContent
from core.config.settings import config
from core.auth.models import CurrentUser
from services.base.supabase import supabase_service
from models.profile import Profile
from v1.inbox_messages.repository import SQLAlchemyInboxMessageRepository
from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository
from core.agentscope.tools.utils.calendar_ui import (
calendar_error_output,
calendar_read_hints,
calendar_share_hints,
calendar_write_hints,
dump_tool_output,
)
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
from v1.schedule_items.schemas import (
ScheduleItemCreateRequest,
ScheduleItemMetadata,
ScheduleItemShareRequest,
ScheduleItemStatus,
ScheduleItemUpdateRequest,
)
from v1.schedule_items.service import ScheduleItemService
_HEX_COLOR_PATTERN = re.compile(r"^#[0-9A-Fa-f]{6}$")
class CalendarShareInvitee(BaseModel):
model_config = ConfigDict(extra="forbid")
def _verify_user_token(*, user_token: str, owner_id: UUID) -> bool:
jwt_secret = config.supabase.jwt_secret
if jwt_secret is None:
return False
verifier = JwtVerifier(
issuer=str(config.supabase.jwt_issuer),
jwt_secret=jwt_secret.get_secret_value(),
jwt_algorithm=config.supabase.jwt_algorithm,
user_id: str = Field(
alias="userId",
description="Target invitee user id as UUID string.",
)
try:
payload = verifier.verify(user_token)
except TokenValidationError:
return False
subject = payload.get("sub")
return isinstance(subject, str) and subject == str(owner_id)
def _map_exception(exc: Exception) -> tuple[str, str, bool]:
"""Map exception to error code, message, and retryable flag."""
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_service(session: AsyncSession, owner_id: UUID) -> ScheduleItemService:
return ScheduleItemService(
repository=SQLAlchemyScheduleItemRepository(session),
session=session,
current_user=CurrentUser(id=owner_id),
inbox_repository=SQLAlchemyInboxMessageRepository(session),
permission_view: bool = Field(
default=True,
alias="permissionView",
description="Whether the invitee can view the event.",
)
permission_edit: bool = Field(
default=False,
alias="permissionEdit",
description="Whether the invitee can edit the event.",
)
permission_invite: bool = Field(
default=False,
alias="permissionInvite",
description="Whether the invitee can invite other users.",
)
def _event_to_dict(event: object) -> dict[str, Any]:
"""Convert ScheduleItem entity to dict."""
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_metadata(
location: str | None,
color: str | None,
reminder_minutes: int | None,
) -> ScheduleItemMetadata:
"""Build ScheduleItemMetadata from parameters."""
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 _validate_runtime_context(
*,
tool_name: str,
tool_call_args: dict[str, Any],
session: Any,
owner_id: Any,
) -> ToolResponse | None:
if session is None or owner_id is None:
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="MISSING_RUNTIME_ARGS",
message="日历工具缺少运行时参数",
retryable=False,
)
return None
async def calendar_read(
@@ -131,65 +90,57 @@ async def calendar_read(
] = 20,
session: Any = None,
owner_id: Any = None,
user_token: str | None = None,
) -> ToolOutputContent:
"""
Read calendar events with optional filtering and pagination.
"""
if session is None or owner_id is None:
return build_error_response(
code="MISSING_RUNTIME_ARGS",
message="日历工具缺少运行时参数",
retryable=False,
)
) -> ToolResponse:
"""Read calendar events with optional keyword filtering and pagination.
if not isinstance(user_token, str) or not user_token.strip():
return build_error_response(
code="UNAUTHORIZED",
message="日历工具需要有效的用户令牌",
retryable=False,
)
Args:
query: Optional keyword used to filter events by text fields.
page: Page number starting from 1.
page_size: Number of items per page, between 1 and 100.
if not _verify_user_token(user_token=user_token, owner_id=cast(UUID, owner_id)):
return build_error_response(
code="UNAUTHORIZED",
message="日历工具需要有效的用户令牌",
retryable=False,
)
Returns:
ToolResponse with serialized ToolAgentOutput payload.
"""
tool_name = "calendar_read"
tool_call_args = {"query": query, "page": page, "page_size": page_size}
runtime_error = _validate_runtime_context(
tool_name=tool_name,
tool_call_args=tool_call_args,
session=session,
owner_id=owner_id,
)
if runtime_error is not None:
return runtime_error
try:
service = _create_service(cast(AsyncSession, session), cast(UUID, owner_id))
service = create_schedule_service(
cast(AsyncSession, session), cast(UUID, owner_id)
)
items, total = await service.list_paginated(page=page, page_size=page_size)
total_pages = max(1, (total + page_size - 1) // page_size) if total else 0
return build_success_response(
title="日程列表",
summary=f"{total} 个日程",
payload={
"ok": True,
"message": "已获取日程列表",
},
items=[_event_to_dict(item) for item in items],
kv_pairs=[
{"key": "total", "label": "总数", "value": total, "copyable": False},
{"key": "page", "label": "当前页", "value": page, "copyable": False},
{
"key": "page_size",
"label": "每页",
"value": page_size,
"copyable": False,
},
{
"key": "total_pages",
"label": "总页数",
"value": total_pages,
"copyable": False,
},
],
event_items = [schedule_event_to_dict(item) for item in items]
return dump_tool_output(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=f"{tool_name}-call",
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
result_summary=f"已获取日程列表,共 {total}",
ui_hints=calendar_read_hints(
total=total,
page=page,
page_size=page_size,
total_pages=total_pages,
events=event_items,
),
)
)
except Exception as exc:
code, message, retryable = _map_exception(exc)
return build_error_response(
code, message, retryable = map_calendar_exception(exc)
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code=code,
message=message,
retryable=retryable,
@@ -242,46 +193,60 @@ async def calendar_write(
Literal["active", "completed", "canceled", "archived"] | None,
Field(description="Event status: active, completed, canceled, or archived."),
] = None,
replace: Annotated[
bool,
Field(description="Whether to use the replace strategy for conflicts."),
] = False,
session: Any = None,
owner_id: Any = None,
user_token: str | None = None,
) -> ToolOutputContent:
"""
Write calendar event: create, update, or delete.
"""
if session is None or owner_id is None:
return build_error_response(
code="MISSING_RUNTIME_ARGS",
message="日历工具缺少运行时参数",
retryable=False,
)
) -> ToolResponse:
"""Create, update, or delete a calendar event.
if not isinstance(user_token, str) or not user_token.strip():
return build_error_response(
code="UNAUTHORIZED",
message="日历工具需要有效的用户令牌",
retryable=False,
)
Args:
operation: Write operation type, one of create, update, delete.
event_id: Target event id for update and delete operations.
title: Event title.
description: Event description.
start_at: Event start time in ISO 8601 format.
end_at: Event end time in ISO 8601 format.
event_timezone: IANA timezone string.
location: Event location.
color: Event color in hex format, for example #4F46E5.
reminder_minutes: Reminder lead time in minutes.
status: Event status value.
if not _verify_user_token(user_token=user_token, owner_id=cast(UUID, owner_id)):
return build_error_response(
code="UNAUTHORIZED",
message="日历工具需要有效的用户令牌",
retryable=False,
)
Returns:
ToolResponse with serialized ToolAgentOutput payload.
"""
tool_name = "calendar_write"
tool_call_args = {
"operation": operation,
"event_id": event_id,
"title": title,
"description": description,
"start_at": start_at,
"end_at": end_at,
"event_timezone": event_timezone,
"location": location,
"color": color,
"reminder_minutes": reminder_minutes,
"status": status,
}
runtime_error = _validate_runtime_context(
tool_name=tool_name,
tool_call_args=tool_call_args,
session=session,
owner_id=owner_id,
)
if runtime_error is not None:
return runtime_error
try:
service = _create_service(cast(AsyncSession, session), cast(UUID, owner_id))
service = create_schedule_service(
cast(AsyncSession, session), cast(UUID, owner_id)
)
if operation == "create":
parsed_start = _parse_datetime(start_at) if start_at else None
parsed_start = parse_iso_datetime(start_at) if start_at else None
if parsed_start is None:
parsed_start = datetime.now(timezone.utc) + timedelta(hours=1)
parsed_end = _parse_datetime(end_at) if end_at else None
parsed_end = parse_iso_datetime(end_at) if end_at else None
tz = (
event_timezone.strip()
if event_timezone and event_timezone.strip()
@@ -297,34 +262,32 @@ async def calendar_write(
start_at=parsed_start,
end_at=parsed_end,
timezone=tz,
metadata=_build_metadata(location, color, reminder_minutes),
metadata=build_schedule_metadata(location, color, reminder_minutes),
)
)
event_dict = _event_to_dict(created)
return build_success_response(
title="日程已创建",
summary=f"日程「{created.title}」已创建",
payload={"ok": True, "operation": "create"},
items=[event_dict],
kv_pairs=[
{
"key": "title",
"label": "标题",
"value": created.title,
"copyable": True,
},
{
"key": "start_at",
"label": "开始时间",
"value": created.start_at.isoformat(),
"copyable": True,
},
],
event_dict = schedule_event_to_dict(created)
summary = f"日程「{created.title}」已创建"
return dump_tool_output(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=f"{tool_name}-call",
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
result_summary=summary,
ui_hints=calendar_write_hints(
operation="create",
message=summary,
event=event_dict,
event_id=event_id,
),
)
)
if operation == "update":
if not event_id:
return build_error_response(
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="INVALID_ARGUMENT",
message="更新日程需要提供 event_id",
retryable=False,
@@ -336,315 +299,208 @@ async def calendar_write(
if description:
update_data["description"] = description.strip()
if start_at:
update_data["start_at"] = _parse_datetime(start_at)
update_data["start_at"] = parse_iso_datetime(start_at)
if end_at:
update_data["end_at"] = _parse_datetime(end_at)
update_data["end_at"] = parse_iso_datetime(end_at)
if event_timezone:
update_data["timezone"] = event_timezone.strip()
if status:
try:
update_data["status"] = ScheduleItemStatus(status)
except ValueError:
return build_error_response(
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="INVALID_ARGUMENT",
message="status 必须是 active, completed, canceled, archived 之一",
retryable=False,
)
if location or color or reminder_minutes is not None:
existing = await service.get_by_id(parsed_event_id)
metadata_dump = (
existing.metadata.model_dump() if existing.metadata else {}
)
if location:
metadata_dump["location"] = location.strip() or None
if color:
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:
return build_error_response(
code="INVALID_ARGUMENT",
message="color 必须是十六进制颜色值如 #4F46E5",
retryable=False,
)
if reminder_minutes is not None:
if reminder_minutes < 0 or reminder_minutes > 10080:
return build_error_response(
code="INVALID_ARGUMENT",
message="reminderMinutes 必须在 0-10080 之间",
retryable=False,
)
metadata_dump["reminder_minutes"] = reminder_minutes
update_data["metadata"] = ScheduleItemMetadata.model_validate(
metadata_dump
update_data["metadata"] = merge_schedule_metadata_for_update(
existing_metadata=existing.metadata,
location=location,
color=color,
reminder_minutes=reminder_minutes,
)
updated = await service.update(
parsed_event_id, ScheduleItemUpdateRequest.model_validate(update_data)
)
event_dict = _event_to_dict(updated)
return build_success_response(
title="日程已更新",
summary=f"日程「{updated.title}」已更新",
payload={"ok": True, "operation": "update"},
items=[event_dict],
kv_pairs=[
{
"key": "title",
"label": "标题",
"value": updated.title,
"copyable": True,
},
{
"key": "start_at",
"label": "开始时间",
"value": updated.start_at.isoformat(),
"copyable": True,
},
],
event_dict = schedule_event_to_dict(updated)
summary = f"日程「{updated.title}」已更新"
return dump_tool_output(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=f"{tool_name}-call",
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
result_summary=summary,
ui_hints=calendar_write_hints(
operation="update",
message=summary,
event=event_dict,
event_id=event_id,
),
)
)
if operation == "delete":
if not event_id:
return build_error_response(
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="INVALID_ARGUMENT",
message="删除日程需要提供 event_id",
retryable=False,
)
await service.delete(UUID(event_id))
return build_success_response(
title="日程已删除",
summary=f"日程 {event_id} 已删除",
payload={"ok": True, "operation": "delete", "event_id": event_id},
items=[],
kv_pairs=[
{
"key": "event_id",
"label": "已删除日程ID",
"value": event_id,
"copyable": True,
},
],
summary = f"日程 {event_id} 已删除"
return dump_tool_output(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=f"{tool_name}-call",
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
result_summary=summary,
ui_hints=calendar_write_hints(
operation="delete",
message=summary,
event=None,
event_id=event_id,
),
)
)
return build_error_response(
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="INVALID_ARGUMENT",
message="无效的操作类型",
retryable=False,
)
except Exception as exc:
code, message, retryable = _map_exception(exc)
return build_error_response(
code, message, retryable = map_calendar_exception(exc)
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code=code,
message=message,
retryable=retryable,
)
def _parse_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
async def calendar_share(
event_id: Annotated[
str,
Field(description="Target event ID (UUID string)."),
],
invite_user_emails: Annotated[
list[str] | None,
Field(description="Optional invite targets by email."),
] = None,
invite_user_names: Annotated[
list[str] | None,
Field(description="Optional invite targets by username."),
] = None,
invite_user_ids: Annotated[
list[str] | None,
Field(description="Optional invite targets by user ID (UUID string)."),
] = None,
invite_permission_view: Annotated[
bool,
Field(description="Invite permission: view."),
] = True,
invite_permission_edit: Annotated[
bool,
Field(description="Invite permission: edit."),
] = False,
invite_permission_invite: Annotated[
bool,
Field(description="Invite permission: invite others."),
] = False,
invitees: Annotated[
list[CalendarShareInvitee],
Field(
description=(
"Invitee list with userId and per-user permissions. "
"Prefer composing with user_lookup tool to get userId first."
),
min_length=1,
),
],
session: Any = None,
owner_id: Any = None,
user_token: str | None = None,
) -> ToolOutputContent:
) -> ToolResponse:
"""Share a calendar event with invitee user ids.
Args:
event_id: Target event id as UUID string.
invitees: Invitee list with user id and per-user permissions.
Returns:
ToolResponse with serialized ToolAgentOutput payload.
"""
Share a calendar event with other users.
"""
if session is None or owner_id is None:
return build_error_response(
code="MISSING_RUNTIME_ARGS",
message="日历工具缺少运行时参数",
retryable=False,
)
if not isinstance(user_token, str) or not user_token.strip():
return build_error_response(
code="UNAUTHORIZED",
message="日历工具需要有效的用户令牌",
retryable=False,
)
if not _verify_user_token(user_token=user_token, owner_id=cast(UUID, owner_id)):
return build_error_response(
code="UNAUTHORIZED",
message="日历工具需要有效的用户令牌",
retryable=False,
)
if not invite_user_emails and not invite_user_names and not invite_user_ids:
return build_error_response(
code="INVALID_ARGUMENT",
message="请提供至少一个邀请目标(邮箱、用户名或用户ID)",
retryable=False,
)
tool_name = "calendar_share"
tool_call_args = {
"event_id": event_id,
"invitees": [
invitee.model_dump(mode="json", by_alias=True) for invitee in invitees
],
}
runtime_error = _validate_runtime_context(
tool_name=tool_name,
tool_call_args=tool_call_args,
session=session,
owner_id=owner_id,
)
if runtime_error is not None:
return runtime_error
try:
service = _create_service(cast(AsyncSession, session), cast(UUID, owner_id))
service = create_schedule_service(
cast(AsyncSession, session), cast(UUID, owner_id)
)
target_uuid = UUID(event_id)
emails: set[str] = set()
if invite_user_emails:
emails = {e.strip().lower() for e in invite_user_emails if e and e.strip()}
email_map = resolve_share_target_email_map(
[invitee.user_id for invitee in invitees]
)
if invite_user_ids:
users = _list_auth_users()
for uid in invite_user_ids:
try:
user_uuid = UUID(uid)
email = _find_auth_email(users, user_uuid)
if email:
emails.add(email.lower())
except ValueError:
pass
if invite_user_names:
for username in invite_user_names:
if not username or not username.strip():
continue
profile = await _get_profile_by_username(
cast(AsyncSession, session), username.strip()
)
if profile:
users = _list_auth_users()
email = _find_auth_email(users, profile.id)
if email:
emails.add(email.lower())
if not emails:
return build_error_response(
if not email_map:
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="NOT_FOUND",
message="未找到任何有效的邀请目标",
retryable=False,
)
permission = {
"permission_view": invite_permission_view,
"permission_edit": invite_permission_edit,
"permission_invite": invite_permission_invite,
}
invited: list[str] = []
for email in sorted(emails):
for invitee in invitees:
try:
normalized_user_id = str(UUID(invitee.user_id.strip()))
except ValueError:
continue
email = email_map.get(normalized_user_id)
if email is None:
continue
permission = {
"permission_view": invitee.permission_view,
"permission_edit": invitee.permission_edit,
"permission_invite": invitee.permission_invite,
}
await service.share(
target_uuid, ScheduleItemShareRequest(email=email, **permission)
)
invited.append(email)
if not invited:
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="NOT_FOUND",
message="邀请目标均无有效邮箱",
retryable=False,
)
return build_success_response(
title="日程已分享",
summary=f"已邀请 {len(invited)}",
payload={
"ok": True,
"operation": "share",
"invited": invited,
"permission": permission,
},
items=[],
kv_pairs=[
{
"key": "event_id",
"label": "日程ID",
"value": event_id,
"copyable": True,
},
{
"key": "invited_count",
"label": "已邀请人数",
"value": len(invited),
"copyable": False,
},
{
"key": "invited_emails",
"label": "被邀请人",
"value": ", ".join(invited),
"copyable": False,
},
],
summary = f"日程已分享,已邀请 {len(invited)}"
return dump_tool_output(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=f"{tool_name}-call",
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
result_summary=summary,
ui_hints=calendar_share_hints(
event_id=event_id,
invited=invited,
permission={"per_user": True},
),
)
)
except Exception as exc:
code, message, retryable = _map_exception(exc)
return build_error_response(
code, message, retryable = map_calendar_exception(exc)
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code=code,
message=message,
retryable=retryable,
)
def _list_auth_users() -> list[Any]:
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(users: list[Any], user_id: UUID) -> str | None:
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
async def _get_profile_by_username(
session: AsyncSession, username: str
) -> Profile | None:
stmt = (
select(Profile)
.where(Profile.username == username)
.where(Profile.deleted_at.is_(None))
)
return (await session.execute(stmt)).scalar_one_or_none()
@@ -8,64 +8,112 @@ from pydantic import Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth.jwt_verifier import JwtVerifier, TokenValidationError
from core.agentscope.tools.tool_response_builder import (
build_success_response,
build_error_response,
from agentscope.tool import ToolResponse
from core.agentscope.tools.utils import (
find_auth_email_by_user_id,
list_auth_users,
)
from core.agentscope.tools.utils.tool_response_builder import (
build_error_output,
build_tool_response,
)
from core.agentscope.schemas.runtime_models import ToolOutputContent
from core.config.settings import config
from models.profile import Profile
from services.base.supabase import supabase_service
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
from schemas.agent.ui_hints import (
UiHintAction,
UiHintActionCopy,
UiHintActionStyle,
UiHintErrorBlock,
UiHintKeyValuePair,
UiHintKvBlock,
UiHintStatus,
UiHintsPayload,
)
from v1.auth.gateway import SupabaseAuthGateway
def _verify_user_token(*, user_token: str, owner_id: UUID) -> bool:
"""Verify the user token matches the owner_id."""
jwt_secret = config.supabase.jwt_secret
if jwt_secret is None:
return False
verifier = JwtVerifier(
issuer=str(config.supabase.jwt_issuer),
jwt_secret=jwt_secret.get_secret_value(),
jwt_algorithm=config.supabase.jwt_algorithm,
def _dump_tool_output(output: ToolAgentOutput) -> ToolResponse:
return build_tool_response(output)
def _lookup_error_output(
*,
tool_call_args: dict[str, Any],
code: str,
message: str,
retryable: bool,
) -> ToolResponse:
output = build_error_output(
tool_name="user_lookup",
tool_call_id="user_lookup-call",
code=code,
message=message,
retryable=retryable,
)
try:
payload = verifier.verify(user_token)
except TokenValidationError:
return False
subject = payload.get("sub")
return isinstance(subject, str) and subject == str(owner_id)
output = output.model_copy(
update={
"tool_call_args": tool_call_args,
"ui_hints": UiHintsPayload(
status=UiHintStatus.ERROR,
title="用户查找失败",
description=message,
blocks=[
UiHintErrorBlock(
kind="error",
title="查找失败",
errorCode=code,
message=message,
retryable=retryable,
)
],
),
}
)
return _dump_tool_output(output)
def _list_auth_users() -> list[Any]:
"""List all auth users from Supabase."""
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 user email by user ID from auth users 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
def _lookup_success_hints(resolved: dict[str, Any]) -> UiHintsPayload:
user_id = str(resolved.get("userId") or "")
email = str(resolved.get("email") or "")
username = str(resolved.get("username") or "")
matched_by = str(resolved.get("matchedBy") or "")
return UiHintsPayload(
status=UiHintStatus.SUCCESS,
title="用户信息",
description=f"匹配方式: {matched_by}",
blocks=[
UiHintKvBlock(
kind="kv",
title="查找结果",
pairs=[
UiHintKeyValuePair(
key="user_id", label="用户ID", value=user_id, copyable=True
),
UiHintKeyValuePair(
key="email", label="邮箱", value=email, copyable=True
),
UiHintKeyValuePair(
key="username", label="用户名", value=username or "-"
),
UiHintKeyValuePair(
key="matched_by", label="匹配方式", value=matched_by
),
],
)
],
actions=[
UiHintAction(
label="复制用户ID",
style=UiHintActionStyle.SECONDARY,
action=UiHintActionCopy(
type="copy",
content=user_id,
successMessage="用户ID已复制",
),
disabled=not bool(user_id),
)
],
)
async def _resolve_identity(
@@ -114,8 +162,8 @@ async def _resolve_identity(
if profile is None:
raise HTTPException(status_code=404, detail="用户不存在")
users = _list_auth_users()
email_value = _find_auth_email_by_user_id(users=users, user_id=profile.id)
users = list_auth_users()
email_value = find_auth_email_by_user_id(users=users, user_id=profile.id)
return {
"userId": str(profile.id),
@@ -136,42 +184,26 @@ async def user_lookup(
] = None,
session: Any = None,
owner_id: Any = None,
user_token: str | None = None,
) -> ToolOutputContent:
"""
Look up user information by email or username.
) -> ToolResponse:
"""Look up user identity by email or username.
Args:
user_email: User email address to look up.
user_name: Username to look up.
session: Database session (runtime preset).
owner_id: Current user ID (runtime preset).
user_token: Validated JWT token (runtime preset).
user_email: User email address for lookup.
user_name: Username for lookup.
Returns:
ToolOutputContent with user information or error.
ToolResponse with serialized ToolAgentOutput payload.
"""
tool_call_args = {"user_email": user_email, "user_name": user_name}
if session is None or owner_id is None:
return build_error_response(
return _lookup_error_output(
tool_call_args=tool_call_args,
code="MISSING_RUNTIME_ARGS",
message="用户查找工具缺少运行时参数",
retryable=False,
)
if not isinstance(user_token, str) or not user_token.strip():
return build_error_response(
code="UNAUTHORIZED",
message="用户查找工具需要有效的用户令牌",
retryable=False,
)
if not _verify_user_token(user_token=user_token, owner_id=cast(UUID, owner_id)):
return build_error_response(
code="UNAUTHORIZED",
message="用户查找工具需要有效的用户令牌",
retryable=False,
)
try:
resolved = await _resolve_identity(
session=cast(AsyncSession, session),
@@ -179,58 +211,36 @@ async def user_lookup(
user_name=user_name,
)
user_id = resolved.get("userId", "")
email = resolved.get("email", "")
username = resolved.get("username", "")
matched_by = resolved.get("matchedBy", "")
return build_success_response(
title="用户信息",
summary=f"已找到用户: {username or email}",
payload={
"ok": True,
"userId": user_id,
"email": email,
"username": username,
"matchedBy": matched_by,
},
items=[],
kv_pairs=[
{
"key": "user_id",
"label": "用户ID",
"value": user_id,
"copyable": True,
},
{"key": "email", "label": "邮箱", "value": email, "copyable": True},
{
"key": "username",
"label": "用户名",
"value": username or "-",
"copyable": True,
},
{
"key": "matched_by",
"label": "匹配方式",
"value": matched_by,
"copyable": False,
},
],
username = str(resolved.get("username") or "")
email = str(resolved.get("email") or "")
summary = f"已找到用户: {username or email}"
return _dump_tool_output(
ToolAgentOutput(
tool_name="user_lookup",
tool_call_id="user_lookup-call",
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
result_summary=summary,
ui_hints=_lookup_success_hints(resolved),
)
)
except HTTPException as exc:
if exc.status_code == 404:
return build_error_response(
return _lookup_error_output(
tool_call_args=tool_call_args,
code="NOT_FOUND",
message=exc.detail or "用户不存在",
retryable=False,
)
return build_error_response(
return _lookup_error_output(
tool_call_args=tool_call_args,
code="LOOKUP_FAILED",
message=exc.detail or "用户查找失败",
retryable=True,
)
except Exception as exc:
return build_error_response(
return _lookup_error_output(
tool_call_args=tool_call_args,
code="INTERNAL_ERROR",
message=f"用户查找失败: {str(exc)}",
retryable=True,
@@ -0,0 +1,58 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class ToolGroup(str, Enum):
READ = "read"
WRITE = "write"
@dataclass(frozen=True)
class ToolApprovalConfig:
required: bool = False
@dataclass(frozen=True)
class ToolConfig:
name: str
group: ToolGroup
approval: ToolApprovalConfig
TOOL_CONFIGS: dict[str, ToolConfig] = {
"calendar_read": ToolConfig(
name="calendar_read",
group=ToolGroup.READ,
approval=ToolApprovalConfig(required=False),
),
"user_lookup": ToolConfig(
name="user_lookup",
group=ToolGroup.READ,
approval=ToolApprovalConfig(required=False),
),
"calendar_write": ToolConfig(
name="calendar_write",
group=ToolGroup.WRITE,
approval=ToolApprovalConfig(required=False),
),
"calendar_share": ToolConfig(
name="calendar_share",
group=ToolGroup.WRITE,
approval=ToolApprovalConfig(required=False),
),
}
def get_tool_config(tool_name: str) -> ToolConfig:
config = TOOL_CONFIGS.get(tool_name)
if config is None:
raise ValueError(f"unknown tool: {tool_name}")
return config
def resolve_tool_names_by_groups(groups: set[ToolGroup]) -> set[str]:
if not groups:
return set()
return {name for name, config in TOOL_CONFIGS.items() if config.group in groups}
@@ -1,22 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
TOOL_APPROVAL_REQUIRED: dict[str, bool] = {
"calendar_read": False,
"calendar_write": False,
"calendar_share": False,
}
@dataclass(frozen=True)
class ToolMeta:
name: str
requires_approval: bool
TOOL_META: dict[str, ToolMeta] = {
tool_name: ToolMeta(name=tool_name, requires_approval=requires_approval)
for tool_name, requires_approval in TOOL_APPROVAL_REQUIRED.items()
}
@@ -2,29 +2,38 @@ from __future__ import annotations
from typing import Any, AsyncGenerator, Callable
from core.agentscope.tools.tool_response_builder import (
build_tool_response,
from core.agentscope.tools.utils.tool_response_builder import (
build_error_response,
)
from core.agentscope.tools.tool_meta import ToolMeta
from core.agentscope.tools.tool_config import ToolConfig, TOOL_CONFIGS
def register_tool_middlewares(
*,
toolkit: Any,
meta_by_name: dict[str, ToolMeta],
config_by_name: dict[str, ToolConfig] | None = None,
meta_by_name: dict[str, ToolConfig] | None = None,
approval_resolver: Callable[[str, dict[str, Any], ToolConfig], str | None]
| None = None,
) -> None:
toolkit.register_middleware(create_hitl_middleware(meta_by_name=meta_by_name))
effective_config = config_by_name or meta_by_name or TOOL_CONFIGS
toolkit.register_middleware(
create_approval_middleware(
config_by_name=effective_config,
approval_resolver=approval_resolver,
)
)
def create_hitl_middleware(
def create_approval_middleware(
*,
meta_by_name: dict[str, ToolMeta],
approval_resolver: Callable[[str, dict[str, Any]], str | None] | None = None,
config_by_name: dict[str, ToolConfig],
approval_resolver: Callable[[str, dict[str, Any], ToolConfig], str | None]
| None = None,
) -> Callable[..., AsyncGenerator[Any, None]]:
async def hitl_middleware(
async def approval_middleware(
kwargs: dict[str, Any],
next_handler: Callable,
next_handler: Callable[..., Any],
) -> AsyncGenerator[Any, None]:
tool_call = kwargs.get("tool_call")
if not isinstance(tool_call, dict):
@@ -38,8 +47,8 @@ def create_hitl_middleware(
yield response
return
meta = meta_by_name.get(tool_name)
if meta is None or not meta.requires_approval:
config = config_by_name.get(tool_name)
if config is None or not config.approval.required:
async for response in await next_handler(**kwargs):
yield response
return
@@ -47,7 +56,9 @@ def create_hitl_middleware(
tool_input = tool_call.get("input")
tool_args = tool_input if isinstance(tool_input, dict) else {}
decision = (
approval_resolver(tool_name, tool_args) if approval_resolver else None
approval_resolver(tool_name, tool_args, config)
if approval_resolver
else None
)
if decision == "approved":
@@ -62,6 +73,8 @@ def create_hitl_middleware(
if decision == "rejected":
content = build_error_response(
tool_name=tool_name,
tool_call_id=tool_call.get("id", "unknown"),
code="TOOL_REJECTED",
message=f"工具 {tool_name} 的调用已被审核拒绝",
retryable=False,
@@ -70,10 +83,12 @@ def create_hitl_middleware(
"status": "rejected",
},
)
yield build_tool_response(content)
yield content
return
content = build_error_response(
pending_response = build_error_response(
tool_name=tool_name,
tool_call_id=tool_call.get("id", "unknown"),
code="TOOL_PENDING_APPROVAL",
message=f"工具 {tool_name} 需要审核批准",
retryable=True,
@@ -82,6 +97,18 @@ def create_hitl_middleware(
"status": "pending",
},
)
yield build_tool_response(content)
yield pending_response
return hitl_middleware
return approval_middleware
def create_hitl_middleware(
*,
meta_by_name: dict[str, ToolConfig],
approval_resolver: Callable[[str, dict[str, Any], ToolConfig], str | None]
| None = None,
) -> Callable[..., AsyncGenerator[Any, None]]:
return create_approval_middleware(
config_by_name=meta_by_name,
approval_resolver=approval_resolver,
)
@@ -1,110 +0,0 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from agentscope.message import TextBlock
from agentscope.tool import ToolResponse
if TYPE_CHECKING:
from core.agentscope.schemas.runtime_models import ToolOutputContent
def build_tool_response(
content: "ToolOutputContent",
*,
tool_name: str = "unknown",
) -> ToolResponse:
"""
Build a ToolResponse from ToolOutputContent.
Args:
content: The ToolOutputContent instance to serialize.
tool_name: Name of the tool (for debugging).
Returns:
ToolResponse with serialized content.
"""
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_success_response(
title: str | None = None,
summary: str | None = None,
payload: dict | None = None,
items: list[dict] | None = None,
kv_pairs: list[dict] | None = None,
**kwargs,
) -> "ToolOutputContent":
"""
Build a success ToolOutputContent.
Args:
title: Optional title for the response.
summary: Optional summary/description.
payload: Optional structured payload data.
items: Optional list of items (for list UI).
kv_pairs: Optional key-value pairs (for kv UI).
**kwargs: Additional fields for ToolOutputContent.
Returns:
ToolOutputContent with success status.
"""
from core.agentscope.schemas.runtime_models import ToolOutputContent
return ToolOutputContent(
title=title,
summary=summary,
payload=payload or {},
items=items or [],
kv_pairs=kv_pairs or [],
**kwargs,
)
def build_error_response(
code: str,
message: str,
retryable: bool = False,
details: dict | None = None,
**kwargs,
) -> "ToolOutputContent":
"""
Build an error ToolOutputContent.
Args:
code: Error code (e.g., NOT_FOUND, UNAUTHORIZED).
message: Human-readable error message.
retryable: Whether the operation can be retried.
details: Additional error details.
**kwargs: Additional fields for ToolOutputContent.
Returns:
ToolOutputContent with error information.
"""
from core.agentscope.schemas.runtime_models import ToolOutputContent
return ToolOutputContent(
title="操作失败",
summary=message,
payload={
"code": code,
"message": message,
"retryable": retryable,
"details": details or {},
},
items=[],
kv_pairs=[
{"key": "error_code", "label": "错误代码", "value": code},
{"key": "message", "label": "错误信息", "value": message},
],
**kwargs,
)
@@ -1,78 +0,0 @@
from __future__ import annotations
import asyncio
import json
from typing import Any
from services.base.supabase import supabase_service
class SupabaseToolResultStorage:
def _bucket_client(self, *, bucket: str) -> Any:
client = supabase_service.get_admin_client()
storage = getattr(client, "storage", None)
if storage is None:
raise RuntimeError("Supabase storage client unavailable")
from_bucket = getattr(storage, "from_", None)
if not callable(from_bucket):
raise RuntimeError("Supabase storage bucket accessor unavailable")
return from_bucket(bucket)
async def upload_json(
self,
*,
bucket: str,
path: str,
payload: dict[str, object],
) -> str:
data = json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode(
"utf-8"
)
def _upload() -> object:
bucket_client = self._bucket_client(bucket=bucket)
upload = getattr(bucket_client, "upload", None)
if not callable(upload):
raise RuntimeError("Supabase storage upload is unavailable")
return upload(
path,
data,
{
"content-type": "application/json",
"upsert": "true",
},
)
result = await asyncio.to_thread(_upload)
return str(result or "")
async def read_json(self, *, bucket: str, path: str) -> dict[str, object] | None:
def _download() -> object:
bucket_client = self._bucket_client(bucket=bucket)
download = getattr(bucket_client, "download", None)
if not callable(download):
raise RuntimeError("Supabase storage download is unavailable")
return download(path)
raw = await asyncio.to_thread(_download)
if isinstance(raw, bytes):
text = raw.decode("utf-8")
elif isinstance(raw, str):
text = raw
else:
return None
try:
payload = json.loads(text)
except ValueError:
return None
if not isinstance(payload, dict):
return None
return payload
def create_tool_result_storage() -> SupabaseToolResultStorage | None:
try:
supabase_service.get_admin_client()
except Exception:
return None
return SupabaseToolResultStorage()
+71 -115
View File
@@ -1,142 +1,95 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, cast
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from agentscope.tool import Toolkit
from agentscope.types import JSONSerializableObject
from core.agentscope.tools.custom.calendar import (
calendar_share,
calendar_read,
calendar_share,
calendar_write,
)
from core.agentscope.tools.custom.user_lookup import (
user_lookup,
from core.agentscope.tools.custom.user_lookup import user_lookup
from core.agentscope.tools.tool_config import (
TOOL_CONFIGS,
ToolGroup,
resolve_tool_names_by_groups,
)
from core.agentscope.tools.hitl_middleware import register_tool_middlewares
from core.agentscope.tools.tool_meta import TOOL_META
from core.agentscope.tools.tool_middleware import register_tool_middlewares
from sqlalchemy.ext.asyncio import AsyncSession
@dataclass(frozen=True)
class CustomToolBinding:
name: str
func: Any
preset_kwargs: dict[str, object]
@dataclass(frozen=True)
class ToolGroup:
stage: str
tool_names: frozenset[str]
TOOL_GROUPS: dict[str, ToolGroup] = {
"intent": ToolGroup(stage="intent", tool_names=frozenset({"calendar_read"})),
"execution": ToolGroup(
stage="execution",
tool_names=frozenset(
{
"calendar_read",
"calendar_write",
"calendar_share",
"user_lookup",
}
),
),
"report": ToolGroup(stage="report", tool_names=frozenset()),
TOOL_FUNCTIONS: dict[str, Any] = {
"calendar_read": calendar_read,
"calendar_write": calendar_write,
"calendar_share": calendar_share,
"user_lookup": user_lookup,
}
def get_tool_group(stage: str) -> ToolGroup:
group = TOOL_GROUPS.get(stage)
if group is None:
raise ValueError(f"unknown tool group stage: {stage}")
return group
STAGE_TO_GROUPS: dict[str, set[ToolGroup]] = {
"intent": {ToolGroup.READ},
"execution": {ToolGroup.READ, ToolGroup.WRITE},
"report": set(),
}
def _load_custom_tool_bindings(
def _resolve_enabled_tools(
*,
session: AsyncSession,
owner_id: UUID,
user_token: str | None,
) -> list[CustomToolBinding]:
return [
CustomToolBinding(
name="calendar_read",
func=calendar_read,
preset_kwargs={
"session": session,
"owner_id": owner_id,
"user_token": user_token or "",
},
),
CustomToolBinding(
name="calendar_write",
func=calendar_write,
preset_kwargs={
"session": session,
"owner_id": owner_id,
"user_token": user_token or "",
},
),
CustomToolBinding(
name="calendar_share",
func=calendar_share,
preset_kwargs={
"session": session,
"owner_id": owner_id,
"user_token": user_token or "",
},
),
CustomToolBinding(
name="user_lookup",
func=user_lookup,
preset_kwargs={
"session": session,
"owner_id": owner_id,
"user_token": user_token or "",
},
),
]
groups: set[ToolGroup] | None,
enabled_tool_names: set[str] | None,
) -> set[str]:
if enabled_tool_names is not None:
unknown = enabled_tool_names - set(TOOL_FUNCTIONS)
if unknown:
raise ValueError(f"unknown tools in enabled_tool_names: {sorted(unknown)}")
return set(enabled_tool_names)
if groups is None:
return set(TOOL_FUNCTIONS)
resolved = resolve_tool_names_by_groups(groups)
unknown = resolved - set(TOOL_FUNCTIONS)
if unknown:
raise ValueError(f"tool config contains unknown tools: {sorted(unknown)}")
return resolved
def build_toolkit(
*,
session: AsyncSession,
owner_id: UUID,
user_token: str | None = None,
enable_hitl: bool = True,
groups: set[ToolGroup] | None = None,
enabled_tool_names: set[str] | None = None,
enable_hitl: bool | None = None,
enable_approval_layer: bool = True,
):
from agentscope.tool import Toolkit
from agentscope.types import JSONSerializableObject
toolkit = Toolkit()
bindings = _load_custom_tool_bindings(
session=session,
owner_id=owner_id,
user_token=user_token,
enabled_names = _resolve_enabled_tools(
groups=groups,
enabled_tool_names=enabled_tool_names,
)
registered_tool_names: set[str] = set()
for binding in bindings:
if enabled_tool_names is not None and binding.name not in enabled_tool_names:
continue
registered_tool_names.add(binding.name)
preset_kwargs = cast(
dict[str, JSONSerializableObject],
{
"session": session,
"owner_id": owner_id,
},
)
for tool_name in sorted(enabled_names):
tool_func = TOOL_FUNCTIONS[tool_name]
toolkit.register_tool_function(
binding.func,
func_name=binding.name,
preset_kwargs=cast(
dict[str, JSONSerializableObject],
binding.preset_kwargs,
),
tool_func,
func_name=tool_name,
preset_kwargs=preset_kwargs,
)
if enabled_tool_names is not None:
missing = enabled_tool_names - registered_tool_names
if missing:
raise ValueError(f"unknown tools in enabled_tool_names: {sorted(missing)}")
if enable_hitl:
register_tool_middlewares(toolkit=toolkit, meta_by_name=TOOL_META)
approval_enabled = enable_approval_layer if enable_hitl is None else enable_hitl
if approval_enabled:
register_tool_middlewares(toolkit=toolkit, config_by_name=TOOL_CONFIGS)
return toolkit
@@ -145,14 +98,17 @@ def build_stage_toolkit(
stage: str,
session: AsyncSession,
owner_id: UUID,
user_token: str | None = None,
enable_hitl: bool = True,
enable_hitl: bool | None = None,
enable_approval_layer: bool = True,
):
group = get_tool_group(stage)
groups = STAGE_TO_GROUPS.get(stage)
if groups is None:
raise ValueError(f"unknown stage: {stage}")
return build_toolkit(
session=session,
owner_id=owner_id,
user_token=user_token,
groups=set(groups),
enable_hitl=enable_hitl,
enabled_tool_names=set(group.tool_names),
enable_approval_layer=enable_approval_layer,
)
@@ -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",
]