refactor: 重构 Tool Result 契约,移除 ui_hints 统一使用 result 字段

- ToolAgentOutput 移除 result_summary 和 ui_hints,统一使用 result 字段
- 日历/用户查找工具移除 ui_hints 输出,改为机器可读的结构化结果
- Agent History 移除 tool 消息的 ui_hints 处理逻辑
- App 版本检查改为 manifest.json 方式,支持多渠道发布
- 更新 settings 配置和测试用例适配新结构
This commit is contained in:
qzl
2026-03-17 12:18:09 +08:00
parent c26cdbbc27
commit aa30fe0ce6
44 changed files with 984 additions and 655 deletions
+6 -2
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from schemas.agent.ui_schema import UiSchemaRenderer
@@ -51,7 +53,9 @@ class HistoryMessage(BaseModel):
id: str = Field(description="Message UUID")
seq: int = Field(description="Message sequence number")
role: str = Field(description="Message role: user | assistant | tool")
role: Literal["user", "assistant"] = Field(
description="Message role: user | assistant"
)
content: str = Field(description="Message text content")
attachments: list[HistoryMessageAttachment] = Field(
default_factory=list,
@@ -59,7 +63,7 @@ class HistoryMessage(BaseModel):
)
ui_schema: UiSchemaRenderer | None = Field(
default=None,
description="Compiled UI schema from worker/tool ui_hints for frontend rendering",
description="Compiled UI schema from worker ui_hints for frontend rendering",
)
timestamp: str = Field(description="Message creation timestamp in ISO-8601 format")
+2
View File
@@ -449,6 +449,8 @@ class AgentService:
)
for msg_dict in raw_messages:
msg = AgentChatMessage.model_validate(msg_dict)
if msg.role == "tool":
continue
signed_urls: dict[str, str] = {}
attachments = extract_user_message_attachments(msg.metadata)
-45
View File
@@ -24,7 +24,6 @@ def convert_message_to_history(
转换规则:
- role=user: 读取 metadata.user_message_attachments,转换为 attachments[]
- role=tool: 读取 content 和 metadata.tool_agent_output.ui_hints,编译成 ui_schema
- role=assistant: 读取 metadata.worker_agent_output.ui_hints,编译成 ui_schema
"""
role = message.role
@@ -37,9 +36,6 @@ def convert_message_to_history(
if role == "user":
attachments = _convert_user_attachments(metadata, get_signed_url_fn)
elif role == "tool":
ui_schema = _compile_tool_ui_hints(metadata)
elif role == "assistant":
ui_schema = _compile_worker_ui_hints(metadata)
@@ -92,47 +88,6 @@ def _convert_user_attachments(
return signed_attachments
def _compile_tool_ui_hints(
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
) -> dict[str, Any] | None:
"""编译 tool 消息的 ui_hints"""
if not metadata:
return None
if isinstance(metadata, AgentChatMessageMetadata):
tool_output = metadata.tool_agent_output
else:
tool_output_data = metadata.get("tool_agent_output")
if not tool_output_data:
return None
if isinstance(tool_output_data, dict):
raw_ui_schema = tool_output_data.get("ui_schema")
if isinstance(raw_ui_schema, dict):
return raw_ui_schema
legacy_ui_schema = tool_output_data.get("uiSchema")
if isinstance(legacy_ui_schema, dict):
return legacy_ui_schema
from schemas.agent.runtime_models import ToolAgentOutput
try:
tool_output = ToolAgentOutput.model_validate(tool_output_data)
except Exception:
return None
if not tool_output:
return None
ui_hints = tool_output.ui_hints
if not ui_hints:
return None
try:
compiled = compile_ui_hints(ui_hints)
return compiled
except Exception:
return None
def _compile_worker_ui_hints(
metadata: AgentChatMessageMetadata | dict[str, Any] | None,
) -> dict[str, Any] | None:
+109 -77
View File
@@ -1,128 +1,160 @@
from __future__ import annotations
import re
import json
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, Query
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, ValidationError
from core.config.settings import config
from core.config.settings import PROJECT_ROOT, config
from core.logging import get_logger
class AppVersionInfo(BaseModel):
has_update: bool = Field(description="是否有新版本可用")
latest_version: str = Field(description="最新版本号,如 0.1.0")
latest_build: int = Field(description="最新构建号")
min_required_version: str = Field(description="强制更新版本号")
update_type: Literal["none", "optional", "required"] = Field(
description="更新类型: none=无更新, optional=可选更新, required=必须更新"
)
latest_version_name: str = Field(description="最新展示版本号,如 0.1.1")
latest_version_code: int = Field(description="最新构建号(versionCode/buildNumber)")
min_supported_version_code: int = Field(description="最低支持版本构建号")
download_url: str | None = Field(default=None, description="安装包下载链接")
release_notes: str | None = Field(default=None, description="版本更新说明")
file_name: str | None = Field(default=None, description="安装包文件名")
file_size: int | None = Field(default=None, description="安装包大小(字节)")
sha256: str | None = Field(default=None, description="安装包哈希")
class ReleaseRecord(BaseModel):
platform: Literal["android", "ios"]
channel: str = Field(default="release", min_length=1, max_length=32)
version_name: str = Field(min_length=1, max_length=32)
version_code: int = Field(ge=1)
min_supported_version_code: int = Field(ge=1)
file_name: str = Field(min_length=1)
release_notes: str | None = None
file_size: int | None = Field(default=None, ge=1)
sha256: str | None = Field(default=None, min_length=32, max_length=128)
class ReleaseManifest(BaseModel):
releases: list[ReleaseRecord] = Field(default_factory=list)
router = APIRouter(prefix="/app", tags=["app"])
logger = get_logger("api.app.version")
def _parse_version(filename: str) -> tuple[str, int, tuple[int, ...]] | None:
pattern = r"app[-_]v?(\d+\.\d+\.\d+)\+(\d+)\.(?:apk|ipa)"
match = re.search(pattern, filename, re.IGNORECASE)
if match:
version = match.group(1)
build = int(match.group(2))
version_tuple = tuple(int(x) for x in version.split("."))
return (version, build, version_tuple)
return None
def _manifest_file_path() -> Path:
return (PROJECT_ROOT / config.app_version.manifest_path).resolve()
def _get_latest_release(
platform: Literal["ios", "android"],
) -> tuple[str, int, str] | None:
releases_dir = config.app_version.releases_dir
base_path = Path.cwd().parent / "deploy" / "static" / releases_dir
def _load_manifest() -> ReleaseManifest:
manifest_file = _manifest_file_path()
if not manifest_file.is_file():
return ReleaseManifest()
if not base_path.exists():
return None
try:
raw = json.loads(manifest_file.read_text(encoding="utf-8"))
return ReleaseManifest.model_validate(raw)
except (json.JSONDecodeError, OSError, ValidationError):
logger.warning("Invalid release manifest, fallback to empty manifest")
return ReleaseManifest()
target_ext = "ipa" if platform == "ios" else "apk"
candidates = []
MIN_APK_SIZE = 1024 * 1024 # 1MB
MIN_IPA_SIZE = 1024 * 1024 # 1MB
for f in base_path.iterdir():
if not f.is_file():
continue
ext = f.suffix.lstrip(".").lower()
if ext != target_ext:
continue
# 简单校验文件大小,排除伪装文件
if f.stat().st_size < (MIN_APK_SIZE if ext == "apk" else MIN_IPA_SIZE):
continue
parsed = _parse_version(f.name)
if parsed:
version, build, version_tuple = parsed
candidates.append((version_tuple, build, f.name))
def _select_latest_release(
manifest: ReleaseManifest,
platform: Literal["android", "ios"],
channel: str,
) -> ReleaseRecord | None:
candidates = [
release
for release in manifest.releases
if release.platform == platform and release.channel == channel
]
if not candidates:
return None
candidates.sort(key=lambda x: (x[0], x[1]), reverse=True)
result = candidates[0]
return result[2].replace("+", "."), result[1], result[2]
return max(candidates, key=lambda release: release.version_code)
def _compare_versions(
current_version: str, current_build: int, latest_version: str, latest_build: int
) -> tuple[bool, Literal["none", "optional", "required"]]:
if current_build >= latest_build:
return False, "none"
def _build_download_url(file_name: str) -> str | None:
base_url = config.app_version.download_base_url
if base_url is None:
return None
if current_build < latest_build - 2:
return True, "required"
return True, "optional"
base_url_text = str(base_url).rstrip("/")
path_prefix = config.app_version.release_path_prefix.strip().strip("/")
if path_prefix:
return f"{base_url_text}/{path_prefix}/{file_name}"
return f"{base_url_text}/{file_name}"
def _resolve_update_type(
current_version_code: int,
latest_version_code: int,
min_supported_version_code: int,
) -> Literal["none", "optional", "required"]:
if current_version_code >= latest_version_code:
return "none"
if current_version_code < min_supported_version_code:
return "required"
return "optional"
@router.get("/check-updates", response_model=AppVersionInfo)
async def check_updates(
current_version: str | None = Query(None, description="前端当前版本,如 0.1.0"),
current_build: int | None = Query(None, description="前端当前构建号,如 1"),
current_version_code: int = Query(
..., ge=1, description="前端当前构建号(versionCode/buildNumber),如 1"
),
current_version_name: str = Query(
"0.0.0",
min_length=1,
max_length=32,
description="前端当前展示版本号,如 0.1.0",
),
platform: Literal["ios", "android"] = Query("ios", description="平台类型"),
channel: str = Query(
"release",
min_length=1,
max_length=32,
pattern=r"^[a-z0-9_-]+$",
description="发布渠道,如 release/beta",
),
) -> AppVersionInfo:
current_build = current_build or 0
manifest = _load_manifest()
latest = _select_latest_release(manifest, platform=platform, channel=channel)
latest = _get_latest_release(platform)
if not latest:
if latest is None:
return AppVersionInfo(
has_update=False,
latest_version=config.app_version.current_version,
latest_build=config.app_version.current_build,
min_required_version=config.app_version.current_version,
update_type="none",
latest_version_name=current_version_name,
latest_version_code=current_version_code,
min_supported_version_code=current_version_code,
download_url=None,
release_notes=None,
file_name=None,
file_size=None,
sha256=None,
)
latest_version, latest_build, filename = latest
has_update, update_type = _compare_versions(
current_version or "0.0.0",
current_build,
latest_version,
latest_build,
update_type = _resolve_update_type(
current_version_code=current_version_code,
latest_version_code=latest.version_code,
min_supported_version_code=latest.min_supported_version_code,
)
download_url: str | None = None
if has_update and config.app_version.download_base_url:
download_url = f"{config.app_version.download_base_url.rstrip('/')}/{config.app_version.releases_dir}/{filename}"
has_update = update_type != "none"
return AppVersionInfo(
has_update=has_update,
latest_version=latest_version,
latest_build=latest_build,
min_required_version=latest_version,
update_type=update_type,
download_url=download_url,
release_notes="问题修复和体验优化",
latest_version_name=latest.version_name,
latest_version_code=latest.version_code,
min_supported_version_code=latest.min_supported_version_code,
download_url=_build_download_url(latest.file_name) if has_update else None,
release_notes=latest.release_notes,
file_name=latest.file_name,
file_size=latest.file_size,
sha256=latest.sha256,
)
+20 -1
View File
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
from typing import TYPE_CHECKING, Protocol, Sequence
from uuid import UUID
from sqlalchemy import func, select, update, delete
from sqlalchemy import func, or_, select, update, delete
from sqlalchemy.exc import SQLAlchemyError
from core.db.base_repository import BaseRepository
@@ -39,6 +39,7 @@ class ScheduleItemRepository(Protocol):
*,
page: int,
page_size: int,
query: str | None = None,
) -> tuple[list[ScheduleItem], int]: ...
async def create_subscription(self, data: dict) -> ScheduleSubscription: ...
async def get_subscriptions_by_item_id(
@@ -164,8 +165,12 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
*,
page: int,
page_size: int,
query: str | None = None,
) -> tuple[list[ScheduleItem], int]:
offset = (page - 1) * page_size
normalized_query = (query or "").strip()
has_query = bool(normalized_query)
query_like = f"%{normalized_query}%"
try:
count_stmt = (
select(func.count())
@@ -173,6 +178,13 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
.where(ScheduleItem.owner_id == owner_id)
.where(ScheduleItem.deleted_at.is_(None))
)
if has_query:
count_stmt = count_stmt.where(
or_(
ScheduleItem.title.ilike(query_like),
ScheduleItem.description.ilike(query_like),
)
)
count_result = await self._session.execute(count_stmt)
total = int(count_result.scalar_one() or 0)
@@ -184,6 +196,13 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
.offset(offset)
.limit(page_size)
)
if has_query:
items_stmt = items_stmt.where(
or_(
ScheduleItem.title.ilike(query_like),
ScheduleItem.description.ilike(query_like),
)
)
items_result = await self._session.execute(items_stmt)
items = list(items_result.scalars().all())
return items, total
+2
View File
@@ -269,6 +269,7 @@ class ScheduleItemService(BaseService):
*,
page: int,
page_size: int,
query: str | None = None,
) -> tuple[list[ScheduleItemResponse], int]:
user_id = self.require_user_id()
if page < 1:
@@ -280,6 +281,7 @@ class ScheduleItemService(BaseService):
user_id,
page=page,
page_size=page_size,
query=query,
)
except SQLAlchemyError:
logger.exception(