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:
@@ -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()
|
||||
}
|
||||
+44
-17
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user