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