diff --git a/.env.example b/.env.example index 16affb9..664b60a 100644 --- a/.env.example +++ b/.env.example @@ -85,11 +85,12 @@ SOCIAL_LLM__PROVIDER_KEYS__ZAI= ############ # App 版本更新配置 ############ -# 安装包目录,相对于项目根目录下的 deploy/static/ -SOCIAL_APP_VERSION__RELEASES_DIR=releases -# 当前版本号(语义化版本) -SOCIAL_APP_VERSION__CURRENT_VERSION=0.1.0 -# 当前构建号(整数,每次打包递增) -SOCIAL_APP_VERSION__CURRENT_BUILD=1 -# 下载链接基础域名(生产环境需配置) +SOCIAL_APP_VERSION__MANIFEST_PATH=deploy/static/releases/manifest.json +SOCIAL_APP_VERSION__RELEASE_PATH_PREFIX=releases SOCIAL_APP_VERSION__DOWNLOAD_BASE_URL= + +############ +# Test相关 +############ +SOCIAL_TEST__EMAIL=test@xunmee.com +SOCIAL_TEST__PASSWORD=Test@123456 diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index 45f2e67..62599f5 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -264,24 +264,10 @@ class ChatBloc extends Cubit { } void _handleToolCallResult(ToolCallResultEvent event) { - final timestamp = DateTime.now(); final items = state.items.where((item) { return !(item is ToolCallItem && item.id == event.toolCallId); }).toList(); - if (event.uiSchema != null) { - _upsertById( - items, - ToolResultItem( - id: event.messageId, - callId: event.toolCallId, - uiSchema: event.uiSchema!, - timestamp: timestamp, - sender: MessageSender.ai, - ), - ); - } - emit(state.copyWith(items: items)); } @@ -326,7 +312,7 @@ class ChatBloc extends Cubit { ); } - if (msg.uiSchema != null) { + if (!isTool && msg.uiSchema != null) { converted.add( ToolResultItem( id: '${msg.id}-ui', @@ -444,15 +430,6 @@ class ChatBloc extends Cubit { } } - void _upsertById(List items, ChatListItem nextItem) { - final index = items.indexWhere((item) => item.id == nextItem.id); - if (index >= 0) { - items[index] = nextItem; - return; - } - items.add(nextItem); - } - Future transcribeAudioFile(String filePath) { return _service.transcribeAudio(filePath); } diff --git a/apps/lib/features/settings/data/settings_api.dart b/apps/lib/features/settings/data/settings_api.dart index 4de5dbe..08cc8ae 100644 --- a/apps/lib/features/settings/data/settings_api.dart +++ b/apps/lib/features/settings/data/settings_api.dart @@ -2,32 +2,41 @@ import 'package:social_app/core/api/i_api_client.dart'; class AppVersionResponse { final bool hasUpdate; - final String latestVersion; - final int latestBuild; - final String minRequiredVersion; + final String latestVersionName; + final int latestVersionCode; + final int minSupportedVersionCode; final String updateType; final String? downloadUrl; final String? releaseNotes; + final String? fileName; + final int? fileSize; + final String? sha256; AppVersionResponse({ required this.hasUpdate, - required this.latestVersion, - required this.latestBuild, - required this.minRequiredVersion, + required this.latestVersionName, + required this.latestVersionCode, + required this.minSupportedVersionCode, required this.updateType, this.downloadUrl, this.releaseNotes, + this.fileName, + this.fileSize, + this.sha256, }); factory AppVersionResponse.fromJson(Map json) { return AppVersionResponse( hasUpdate: json['has_update'] as bool, - latestVersion: json['latest_version'] as String, - latestBuild: json['latest_build'] as int, - minRequiredVersion: json['min_required_version'] as String, + latestVersionName: json['latest_version_name'] as String, + latestVersionCode: json['latest_version_code'] as int, + minSupportedVersionCode: json['min_supported_version_code'] as int, updateType: json['update_type'] as String, downloadUrl: json['download_url'] as String?, releaseNotes: json['release_notes'] as String?, + fileName: json['file_name'] as String?, + fileSize: json['file_size'] as int?, + sha256: json['sha256'] as String?, ); } } @@ -39,20 +48,18 @@ class SettingsApi { SettingsApi(this._client); Future checkUpdates({ - required int currentBuild, - String? currentVersion, + required int currentVersionCode, + required String currentVersionName, String platform = 'android', + String channel = 'release', }) async { final params = { 'platform': platform, - 'current_build': currentBuild.toString(), + 'channel': channel, + 'current_version_code': currentVersionCode.toString(), + 'current_version_name': currentVersionName, }; - if (currentVersion != null) { - params['current_version'] = currentVersion; - } - final queryString = params.entries - .map((e) => '${e.key}=${e.value}') - .join('&'); + final queryString = Uri(queryParameters: params).query; final response = await _client.get('$_prefix/check-updates?$queryString'); return AppVersionResponse.fromJson(response.data); } diff --git a/apps/lib/features/settings/ui/screens/settings_screen.dart b/apps/lib/features/settings/ui/screens/settings_screen.dart index a8dc4f3..37d5516 100644 --- a/apps/lib/features/settings/ui/screens/settings_screen.dart +++ b/apps/lib/features/settings/ui/screens/settings_screen.dart @@ -532,8 +532,8 @@ class _SettingsScreenState extends State { try { final settingsApi = sl(); final result = await settingsApi.checkUpdates( - currentBuild: AppConstants.build, - currentVersion: AppConstants.version, + currentVersionCode: AppConstants.build, + currentVersionName: AppConstants.version, platform: 'android', ); @@ -545,8 +545,8 @@ class _SettingsScreenState extends State { } final message = result.updateType == 'required' - ? '有新版本可用 (${result.latestVersion}),请立即更新' - : '发现新版本 (${result.latestVersion}),是否更新?'; + ? '有新版本可用 (${result.latestVersionName}),请立即更新' + : '发现新版本 (${result.latestVersionName}),是否更新?'; final shouldUpdate = await showDialog( context: context, diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index c6baa3b..17ec058 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -1,8 +1,7 @@ name: social_app description: "Social App - A Flutter mobile application" publish_to: 'none' -version: 0.1.0+1 - +version: 0.1.0+2 environment: sdk: ^3.10.7 diff --git a/apps/test/features/chat/ag_ui_event_test.dart b/apps/test/features/chat/ag_ui_event_test.dart index 9fe27d5..024aa86 100644 --- a/apps/test/features/chat/ag_ui_event_test.dart +++ b/apps/test/features/chat/ag_ui_event_test.dart @@ -38,15 +38,6 @@ void main() { 'tool_name': 'calendar_read', 'status': 'success', 'result_summary': '找到 2 条结果', - 'ui_schema': { - 'version': '2.0', - 'root': { - 'type': 'stack', - 'direction': 'vertical', - 'appearance': 'card', - 'children': [], - }, - }, }); expect(event, isA()); @@ -54,7 +45,7 @@ void main() { expect(result.toolCallId, 'call_1'); expect(result.toolName, 'calendar_read'); expect(result.resultSummary, '找到 2 条结果'); - expect(result.uiSchema, isNotNull); + expect(result.uiSchema, isNull); }); test('parses history snapshot with ui_schema', () { diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 6847361..9936537 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -246,6 +246,17 @@ class AgentType(str, Enum): Agent loop functionality MUST follow the AG-UI protocol. **Use the `ag-ui` skill** for protocol reference and implementation guidance. +## Custom Tool Result Contract + +Custom tool `ToolAgentOutput` MUST follow these rules: + +- Use field name `result` only. Do not introduce or keep `result_summary` compatibility aliases. +- `result` is for downstream agent reasoning and tool chaining, not for end-user presentation. +- Prefer compact structural facts over prose: include identifiers and execution-critical facts (`id`, `status`, `count`, `page`, operation outcome, missing required args). +- For list/read tools, include multiple candidate records when needed (at least top matches) with stable identifiers. +- For write tools, always include affected resource identifiers in `result`. +- Keep `result` concise, deterministic, and machine-oriented; avoid decorative wording and UI-style formatting. + ## Multi-Agent Orchestration (AgentScope Framework) Multi-agent orchestration MUST use the AgentScope framework. **Use the `agentscope-skill`** for framework reference and implementation guidance. @@ -264,3 +275,12 @@ Multi-agent orchestration MUST use the AgentScope framework. **Use the `agentsco - **Pipelines**: Ordered orchestration flow between agents - **Tools**: Capabilities available to agents - **Flows**: Workflow orchestration and state management + +## Testing + +### Real Database Tests + +Tests requiring real Supabase operations MUST use environment variables: +- Define `TestSettings` in `settings.py` with nested configuration +- Access via `settings.test.email` / `settings.test.password` +- NEVER hardcode credentials in code diff --git a/backend/src/core/agentscope/events/agui_codec.py b/backend/src/core/agentscope/events/agui_codec.py index 5710334..d2dd0d8 100644 --- a/backend/src/core/agentscope/events/agui_codec.py +++ b/backend/src/core/agentscope/events/agui_codec.py @@ -54,10 +54,7 @@ def _is_agui_event(event: dict[str, Any]) -> bool: def _sanitize_agui_event(event: dict[str, Any]) -> dict[str, Any]: payload = dict(event) event_type = str(payload.get("type", "")).strip().upper() - if event_type in { - EventType.TEXT_MESSAGE_END.value, - EventType.TOOL_CALL_RESULT.value, - }: + if event_type == EventType.TEXT_MESSAGE_END.value: ui_hints = payload.get("ui_hints") if ui_hints is not None: try: @@ -67,7 +64,6 @@ def _sanitize_agui_event(event: dict[str, Any]) -> dict[str, Any]: except Exception: pass payload.pop("ui_hints", None) - if event_type == EventType.TEXT_MESSAGE_END.value: for key in ( "inputTokens", "outputTokens", @@ -76,6 +72,9 @@ def _sanitize_agui_event(event: dict[str, Any]) -> dict[str, Any]: "model", ): payload.pop(key, None) + if event_type == EventType.TOOL_CALL_RESULT.value: + payload.pop("ui_hints", None) + payload.pop("ui_schema", None) return payload @@ -130,10 +129,13 @@ def _build_text_end(event: dict[str, Any]) -> TextMessageEndEvent: def _build_tool_result(event: dict[str, Any]) -> ToolCallResultEvent: data = event.get("data", {}) + content = data.get("result") + if not isinstance(content, str): + content = data.get("toolAgentOutput", "") return ToolCallResultEvent( message_id=data.get("messageId", ""), tool_call_id=data.get("toolCallId", ""), - content=data.get("toolAgentOutput", ""), + content=content, role="tool", ) @@ -191,7 +193,7 @@ def to_agui_wire_event(event: dict[str, Any] | BaseEvent) -> dict[str, Any]: tool_result_payload["threadId"] = thread_id if isinstance(run_id, str) and run_id: tool_result_payload["runId"] = run_id - reserved = {"type", "threadId", "runId"} + reserved = {"type", "threadId", "runId", "ui_hints", "ui_schema"} tool_result_payload.update({k: v for k, v in data.items() if k not in reserved}) return tool_result_payload diff --git a/backend/src/core/agentscope/events/store.py b/backend/src/core/agentscope/events/store.py index a96a4dc..9fa1525 100644 --- a/backend/src/core/agentscope/events/store.py +++ b/backend/src/core/agentscope/events/store.py @@ -213,9 +213,8 @@ class SqlAlchemyEventStore: "tool_call_id": self._event_value(event, "tool_call_id"), "tool_call_args": self._event_value(event, "tool_call_args"), "status": self._event_value(event, "status"), - "result_summary": self._event_value(event, "result_summary"), + "result": self._event_value(event, "result"), "error": self._event_value(event, "error"), - "ui_hints": self._event_value(event, "ui_hints"), } try: @@ -231,7 +230,7 @@ class SqlAlchemyEventStore: ) return - content = tool_output.result_summary + content = tool_output.result locked_session = await session_repo.lock_session_for_update( session_id=session_id diff --git a/backend/src/core/agentscope/runtime/stage_emitter.py b/backend/src/core/agentscope/runtime/stage_emitter.py index 6b90439..9232d9d 100644 --- a/backend/src/core/agentscope/runtime/stage_emitter.py +++ b/backend/src/core/agentscope/runtime/stage_emitter.py @@ -112,13 +112,8 @@ class PipelineStageEmitter: "tool_call_id": tool_output.tool_call_id, "tool_call_args": tool_output.tool_call_args, "status": tool_output.status.value, - "result_summary": tool_output.result_summary, + "result": tool_output.result, } - ui_hints = tool_output.model_dump(mode="json", exclude_none=True).get( - "ui_hints" - ) - if ui_hints is not None: - payload["ui_hints"] = ui_hints if tool_output.error: payload["error"] = tool_output.error.model_dump(mode="json") diff --git a/backend/src/core/agentscope/tools/custom/calendar.py b/backend/src/core/agentscope/tools/custom/calendar.py index 60ab571..762530f 100644 --- a/backend/src/core/agentscope/tools/custom/calendar.py +++ b/backend/src/core/agentscope/tools/custom/calendar.py @@ -16,13 +16,9 @@ from core.agentscope.tools.utils.calendar_domain import ( ) 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 ErrorInfo, ToolAgentOutput, ToolStatus -from schemas.agent.ui_hints import UiHintListItem, UiHintStatus from v1.schedule_items.schemas import ( ScheduleItemCreateRequest, ScheduleItemShareRequest, @@ -73,6 +69,18 @@ def _validate_runtime_context( return None +def _format_event_brief(event_items: list[dict[str, Any]], limit: int = 3) -> str: + briefs: list[str] = [] + for item in event_items[:limit]: + event_id = str(item.get("id") or "") + title = str(item.get("title") or "") + start_at = str(item.get("startAt") or "") + if not event_id: + continue + briefs.append(f"{{id={event_id},title={title},startAt={start_at}}}") + return ",".join(briefs) + + async def calendar_read( query: Annotated[ str | None, @@ -114,24 +122,28 @@ async def calendar_read( 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 - + items, total = await service.list_paginated( + page=page, + page_size=page_size, + query=query, + ) + total_pages = (total + page_size - 1) // page_size if total else 0 event_items = [schedule_event_to_dict(item) for item in items] + query_value = (query or "").strip() or "*" + event_brief = _format_event_brief(event_items) + summary = ( + f"status=success query={query_value} total={total} page={page}/" + f"{total_pages or 1} returned={len(event_items)}" + ) + if event_brief: + summary = f"{summary} items=[{event_brief}]" 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, - ), + result=summary, ) ) except Exception as exc: @@ -297,6 +309,7 @@ async def calendar_write( success_count = 0 failed_count = 0 + success_event_ids: list[str] = [] result_items: list[dict[str, Any]] = [] for idx, operation in enumerate(operations): @@ -353,6 +366,7 @@ async def calendar_write( "message": f"日程「{created.title}」已创建", } ) + success_event_ids.append(str(created.id)) continue if operation == "update": @@ -397,6 +411,7 @@ async def calendar_write( "message": f"日程「{updated.title}」已更新", } ) + success_event_ids.append(str(updated.id)) continue if operation == "delete": @@ -413,6 +428,7 @@ async def calendar_write( "message": f"日程 {event_id} 已删除", } ) + success_event_ids.append(event_id) continue except Exception as exc: code, message, _ = map_calendar_exception(exc) @@ -430,16 +446,22 @@ async def calendar_write( if failed_count == 0: final_status = ToolStatus.SUCCESS - ui_status = UiHintStatus.SUCCESS - summary = f"日程批量操作完成,共 {batch_size} 条,成功 {success_count} 条" + summary = ( + f"status=success batch={batch_size} success={success_count} " + f"failed={failed_count} ids=[{','.join(success_event_ids)}]" + ) elif success_count == 0: final_status = ToolStatus.FAILURE - ui_status = UiHintStatus.ERROR - summary = f"日程批量操作失败,共 {batch_size} 条,失败 {failed_count} 条" + summary = ( + f"status=failure batch={batch_size} success={success_count} " + f"failed={failed_count}" + ) else: final_status = ToolStatus.PARTIAL - ui_status = UiHintStatus.WARNING - summary = f"日程批量操作部分成功,共 {batch_size} 条,成功 {success_count} 条,失败 {failed_count} 条" + summary = ( + f"status=partial batch={batch_size} success={success_count} " + f"failed={failed_count} ids=[{','.join(success_event_ids)}]" + ) error_info: ErrorInfo | None = None if final_status == ToolStatus.FAILURE: @@ -459,30 +481,10 @@ async def calendar_write( retryable=False, details={"results": result_items}, ) - - result_list_items = [ - UiHintListItem( - id=( - str(item.get("eventId")) - if isinstance(item, dict) and item.get("eventId") is not None - else None - ), - title=( - f"#{int(item.get('index', 0)) + 1} {str(item.get('operation', 'unknown'))}" - if isinstance(item, dict) - else "unknown" - ), - subtitle=( - "成功" - if isinstance(item, dict) and item.get("status") == "success" - else "失败" - ), - description=( - str(item.get("message") or "") if isinstance(item, dict) else "" - ), + summary = ( + f"{summary} first_error_code={error_info.code} " + f"first_error_message={error_info.message}" ) - for item in result_items - ] return dump_tool_output( ToolAgentOutput( @@ -490,24 +492,8 @@ async def calendar_write( tool_call_id=f"{tool_name}-call", tool_call_args=tool_call_args, status=final_status, - result_summary=summary, + result=summary, error=error_info, - ui_hints=calendar_write_hints( - operation="batch", - message=summary, - event=None, - event_id=None, - status=ui_status, - ).model_copy( - update={ - "list_items": result_list_items, - "meta": { - "total": batch_size, - "success": success_count, - "failed": failed_count, - }, - } - ), ) ) @@ -611,19 +597,14 @@ async def calendar_share( retryable=False, ) - summary = f"日程已分享,已邀请 {len(invited)} 人" + summary = f"status=success event_id={event_id} invited_count={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}, - ), + result=summary, ) ) except Exception as exc: diff --git a/backend/src/core/agentscope/tools/custom/user_lookup.py b/backend/src/core/agentscope/tools/custom/user_lookup.py index 77e182e..82cf989 100644 --- a/backend/src/core/agentscope/tools/custom/user_lookup.py +++ b/backend/src/core/agentscope/tools/custom/user_lookup.py @@ -17,15 +17,6 @@ from core.agentscope.tools.utils.tool_response_builder import ( ) from models.profile import Profile from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus -from schemas.agent.ui_hints import ( - UiHintAction, - UiHintActionCopy, - UiHintActionStyle, - UiHintIntent, - UiHintKvItem, - UiHintStatus, - UiHintsPayload, -) from v1.auth.gateway import SupabaseAuthGateway @@ -47,51 +38,10 @@ def _lookup_error_output( message=message, retryable=retryable, ) - output = output.model_copy( - update={ - "tool_call_args": tool_call_args, - "ui_hints": UiHintsPayload( - intent=UiHintIntent.STATUS, - status=UiHintStatus.ERROR, - title="用户查找失败", - body=message, - ), - } - ) + output = output.model_copy(update={"tool_call_args": tool_call_args}) return _dump_tool_output(output) -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( - intent=UiHintIntent.DATA, - status=UiHintStatus.SUCCESS, - title="用户信息", - description=f"匹配方式: {matched_by}", - items=[ - UiHintKvItem(key="user_id", label="用户ID", value=user_id, copyable=True), - UiHintKvItem(key="email", label="邮箱", value=email, copyable=True), - UiHintKvItem(key="username", label="用户名", value=username or "-"), - UiHintKvItem(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( *, session: AsyncSession, @@ -189,15 +139,19 @@ async def user_lookup( username = str(resolved.get("username") or "") email = str(resolved.get("email") or "") - summary = f"已找到用户: {username or email}" + user_id = str(resolved.get("userId") or "") + matched_by = str(resolved.get("matchedBy") or "") + summary = ( + f"status=success matched_by={matched_by} user_id={user_id} " + f"username={username} has_email={str(bool(email)).lower()}" + ) 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), + result=summary, ) ) except HTTPException as exc: diff --git a/backend/src/core/agentscope/tools/utils/calendar_ui.py b/backend/src/core/agentscope/tools/utils/calendar_ui.py index bbec047..27d08a7 100644 --- a/backend/src/core/agentscope/tools/utils/calendar_ui.py +++ b/backend/src/core/agentscope/tools/utils/calendar_ui.py @@ -8,16 +8,6 @@ from core.agentscope.tools.utils.tool_response_builder import ( build_tool_response, ) from schemas.agent.runtime_models import ToolAgentOutput -from schemas.agent.ui_hints import ( - UiHintAction, - UiHintActionNavigation, - UiHintActionStyle, - UiHintIntent, - UiHintKvItem, - UiHintListItem, - UiHintStatus, - UiHintsPayload, -) def dump_tool_output(output: ToolAgentOutput) -> ToolResponse: @@ -32,12 +22,6 @@ def calendar_error_output( message: str, retryable: bool, ) -> ToolResponse: - ui_hints = UiHintsPayload( - intent=UiHintIntent.STATUS, - status=UiHintStatus.ERROR, - title="日历操作失败", - body=message, - ) output = build_error_output( tool_name=tool_name, tool_call_id=f"{tool_name}-call", @@ -45,120 +29,5 @@ def calendar_error_output( message=message, retryable=retryable, ) - output = output.model_copy( - update={"tool_call_args": tool_call_args, "ui_hints": ui_hints} - ) + output = output.model_copy(update={"tool_call_args": tool_call_args}) 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( - intent=UiHintIntent.LIST, - status=UiHintStatus.SUCCESS, - title="日程列表", - description=f"共 {total} 个日程", - items=[ - UiHintKvItem(key="total", label="总数", value=total), - UiHintKvItem(key="page", label="当前页", value=page), - UiHintKvItem(key="page_size", label="每页", value=page_size), - UiHintKvItem(key="total_pages", label="总页数", value=total_pages), - ], - listItems=event_items, - 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, - status: UiHintStatus = UiHintStatus.SUCCESS, -) -> UiHintsPayload: - kv_items: list[UiHintKvItem] = [] - - if event: - kv_items = [ - UiHintKvItem( - key="event_id", - label="日程ID", - value=str(event.get("id") or ""), - copyable=True, - ), - UiHintKvItem( - key="title", - label="标题", - value=str(event.get("title") or ""), - copyable=True, - ), - UiHintKvItem( - key="start_at", - label="开始时间", - value=str(event.get("startAt") or ""), - copyable=True, - ), - ] - elif event_id: - message = f"目标日程 ID: {event_id}\n{message}" - - return UiHintsPayload( - intent=UiHintIntent.STATUS, - status=status, - title="日历操作完成", - body=message, - items=kv_items, - 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( - intent=UiHintIntent.STATUS, - status=UiHintStatus.SUCCESS, - title="日程已分享", - description=f"已邀请 {len(invited)} 人", - items=[ - UiHintKvItem(key="event_id", label="日程ID", value=event_id, copyable=True), - UiHintKvItem(key="permission", label="权限", value=permission_text), - ], - listItems=[UiHintListItem(title=email) for email in invited] if invited else [], - ) diff --git a/backend/src/core/agentscope/tools/utils/tool_response_builder.py b/backend/src/core/agentscope/tools/utils/tool_response_builder.py index f1275bf..0107f24 100644 --- a/backend/src/core/agentscope/tools/utils/tool_response_builder.py +++ b/backend/src/core/agentscope/tools/utils/tool_response_builder.py @@ -34,7 +34,7 @@ def build_error_output( tool_name=tool_name, tool_call_id=tool_call_id, status=ToolStatus.FAILURE, - result_summary=message, + result=f"status=failure code={code} message={message}", error=ErrorInfo( code=code, message=message, diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 21c68a4..120ba5a 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -199,23 +199,39 @@ class DatabaseSettings(BaseModel): class AppVersionSettings(BaseModel): - releases_dir: str = Field( + manifest_path: str = Field( + default="deploy/static/releases/manifest.json", + description="发布清单文件路径,相对于项目根目录", + ) + release_path_prefix: str = Field( default="releases", - description="安装包目录,相对于项目根目录", + description="下载 URL 中文件目录前缀", ) - current_version: str = Field( - default="0.1.0", - description="当前版本号", - ) - current_build: int = Field( - default=1, - description="当前构建号", - ) - download_base_url: str = Field( - default="", + download_base_url: AnyHttpUrl | None = Field( + default=None, description="下载链接基础域名,如 https://your-domain.com", ) + @field_validator("download_base_url", mode="before") + @classmethod + def empty_download_base_url_to_none(cls, value: object) -> object: + if value == "": + return None + return value + + @field_validator("manifest_path") + @classmethod + def validate_manifest_path(cls, value: str) -> str: + normalized = Path(value) + if normalized.is_absolute() or ".." in normalized.parts: + raise ValueError("manifest_path must be a safe relative path") + return value + + +class TestSettings(BaseModel): + email: str = "" + password: str = "" + def _resolve_env_file() -> str: current = Path(__file__).resolve() @@ -241,6 +257,7 @@ class Settings(BaseSettings): taskiq: TaskiqSettings = TaskiqSettings() database: DatabaseSettings = DatabaseSettings() app_version: AppVersionSettings = AppVersionSettings() + test: TestSettings = Field(default_factory=TestSettings) @computed_field @property diff --git a/backend/src/schemas/agent/runtime_models.py b/backend/src/schemas/agent/runtime_models.py index b130f7a..d12913a 100644 --- a/backend/src/schemas/agent/runtime_models.py +++ b/backend/src/schemas/agent/runtime_models.py @@ -312,13 +312,12 @@ class ToolAgentOutput(BaseModel): description="Snapshot of tool call arguments for traceability and debugging.", ) status: ToolStatus = Field(..., description="Tool execution status.") - result_summary: str = Field( + result: str = Field( ..., - description="Concise tool result summary with key facts and without verbose logs.", - ) - ui_hints: UiHintsPayload | None = Field( - default=None, - description="Optional UI semantic hints translated into ui_schema by ui_compiler.", + description=( + "Compact machine-oriented tool result. Keep it short but include " + "action-critical facts (ids/status/counts) for downstream agent steps." + ), ) error: ErrorInfo | None = Field( default=None, description="Tool execution error details." diff --git a/backend/src/v1/agent/schemas.py b/backend/src/v1/agent/schemas.py index 73d66fa..a5aaf5f 100644 --- a/backend/src/v1/agent/schemas.py +++ b/backend/src/v1/agent/schemas.py @@ -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") diff --git a/backend/src/v1/agent/service.py b/backend/src/v1/agent/service.py index 027fdb9..9e2a321 100644 --- a/backend/src/v1/agent/service.py +++ b/backend/src/v1/agent/service.py @@ -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) diff --git a/backend/src/v1/agent/utils.py b/backend/src/v1/agent/utils.py index c3d8e9b..efbf228 100644 --- a/backend/src/v1/agent/utils.py +++ b/backend/src/v1/agent/utils.py @@ -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: diff --git a/backend/src/v1/app/router.py b/backend/src/v1/app/router.py index ee42ef6..e85532f 100644 --- a/backend/src/v1/app/router.py +++ b/backend/src/v1/app/router.py @@ -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, ) diff --git a/backend/src/v1/schedule_items/repository.py b/backend/src/v1/schedule_items/repository.py index fb06565..5477a7f 100644 --- a/backend/src/v1/schedule_items/repository.py +++ b/backend/src/v1/schedule_items/repository.py @@ -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 diff --git a/backend/src/v1/schedule_items/service.py b/backend/src/v1/schedule_items/service.py index 0bbd285..5eb4cb3 100644 --- a/backend/src/v1/schedule_items/service.py +++ b/backend/src/v1/schedule_items/service.py @@ -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( diff --git a/backend/tests/unit/core/agentscope/events/test_agui_codec.py b/backend/tests/unit/core/agentscope/events/test_agui_codec.py index 2140eb6..4256cbe 100644 --- a/backend/tests/unit/core/agentscope/events/test_agui_codec.py +++ b/backend/tests/unit/core/agentscope/events/test_agui_codec.py @@ -38,8 +38,7 @@ def test_tool_result_wire_event_with_bare_fields() -> None: "tool_call_id": "call-1", "tool_call_args": {"start_date": "2024-01-01"}, "status": "success", - "result_summary": "summary", - "ui_schema": {"version": "2.0"}, + "result": "summary", }, } @@ -50,8 +49,7 @@ def test_tool_result_wire_event_with_bare_fields() -> None: assert result["tool_name"] == "calendar_write" assert result["tool_call_id"] == "call-1" assert result["status"] == "success" - assert result["result_summary"] == "summary" - assert result["ui_schema"] == {"version": "2.0"} + assert result["result"] == "summary" def test_text_end_event_with_bare_fields() -> None: @@ -124,7 +122,7 @@ def test_text_message_end_agui_event_strips_internal_usage_fields() -> None: assert "model" not in result -def test_tool_call_result_agui_event_compiles_ui_hints_to_ui_schema() -> None: +def test_tool_call_result_agui_event_strips_tool_ui_fields() -> None: event = { "type": "TOOL_CALL_RESULT", "threadId": "thread-1", @@ -136,19 +134,20 @@ def test_tool_call_result_agui_event_compiles_ui_hints_to_ui_schema() -> None: "tool_call_id": "call-1", "tool_call_args": {"page": 1}, "status": "success", - "result_summary": "ok", + "result": "ok", "ui_hints": { "intent": "status", "status": "success", "title": "Done", }, + "ui_schema": {"version": "2.0"}, } result = to_agui_wire_event(event) assert result["type"] == "TOOL_CALL_RESULT" assert "ui_hints" not in result - assert isinstance(result.get("ui_schema"), dict) + assert "ui_schema" not in result def test_text_message_end_agui_event_compiles_ui_hints_to_ui_schema() -> None: diff --git a/backend/tests/unit/core/agentscope/events/test_store.py b/backend/tests/unit/core/agentscope/events/test_store.py index 1e3381a..e39b913 100644 --- a/backend/tests/unit/core/agentscope/events/test_store.py +++ b/backend/tests/unit/core/agentscope/events/test_store.py @@ -125,19 +125,19 @@ async def test_store_persists_tool_output_with_summary_as_content( "tool_call_id": "call-1", "tool_call_args": {"title": "A"}, "status": "success", - "result_summary": "已创建日程 A", - "ui_hints": { - "intent": "status", - "status": "success", - "sections": [], - }, + "result": "status=success batch=1 success=1 failed=0 ids=[event-1]", } ) append_kwargs = cast(dict[str, Any], captured["append_kwargs"]) assert getattr(append_kwargs["role"], "value", None) == "tool" - assert append_kwargs["content"] == "已创建日程 A" + assert ( + append_kwargs["content"] + == "status=success batch=1 success=1 failed=0 ids=[event-1]" + ) metadata = cast(dict[str, Any], append_kwargs["metadata"]) assert sorted(metadata.keys()) == ["run_id", "tool_agent_output"] - assert metadata["tool_agent_output"]["result_summary"] == "已创建日程 A" - assert metadata["tool_agent_output"]["ui_hints"]["intent"] == "status" + assert ( + metadata["tool_agent_output"]["result"] + == "status=success batch=1 success=1 failed=0 ids=[event-1]" + ) diff --git a/backend/tests/unit/core/agentscope/events/test_tool_result_summary.py b/backend/tests/unit/core/agentscope/events/test_tool_result_summary.py deleted file mode 100644 index a79662e..0000000 --- a/backend/tests/unit/core/agentscope/events/test_tool_result_summary.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -from core.agentscope.events.tool_result_summary import build_tool_content_summary - - -def test_summary_prioritizes_error() -> None: - text = build_tool_content_summary( - tool_name="calendar_write", - args={"title": "A"}, - result={"message": "ignored"}, - error={"message": "denied"}, - ) - assert text == "calendar_write 执行失败:denied" - - -def test_summary_for_calendar_write() -> None: - text = build_tool_content_summary( - tool_name="calendar_write", - args={"title": "项目评审"}, - result={"startAt": "明天 10:00"}, - error=None, - ) - assert text == "已创建日程:项目评审(明天 10:00)" - - -def test_summary_for_calendar_read() -> None: - text = build_tool_content_summary( - tool_name="calendar_read", - args={"query": "今天"}, - result={"data": {"total": 3}}, - error=None, - ) - assert text == "查询到 3 条日程(今天)" - - -def test_summary_falls_back_to_result_content() -> None: - text = build_tool_content_summary( - tool_name="unknown_tool", - args=None, - result={"content": "这是非常长的说明" * 20}, - error=None, - ) - assert text.startswith("这是非常长的说明") - assert len(text) <= 80 - - -def test_summary_default_done() -> None: - text = build_tool_content_summary( - tool_name="unknown_tool", - args=None, - result=None, - error=None, - ) - assert text == "unknown_tool 执行完成" - - -def test_summary_marks_business_failure_when_ok_false() -> None: - text = build_tool_content_summary( - tool_name="calendar_write", - args={"title": "上学"}, - result={ - "type": "calendar_operation.v1", - "data": { - "ok": False, - "code": "UNAUTHORIZED", - "message": "calendar.write requires validated user token", - }, - }, - error=None, - ) - assert ( - text == "calendar_write 执行失败:calendar.write requires validated user token" - ) diff --git a/backend/tests/unit/core/agentscope/test_calendar_tools.py b/backend/tests/unit/core/agentscope/test_calendar_tools.py index 9d75fe3..ff9417e 100644 --- a/backend/tests/unit/core/agentscope/test_calendar_tools.py +++ b/backend/tests/unit/core/agentscope/test_calendar_tools.py @@ -1,11 +1,11 @@ from __future__ import annotations import json -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime, timezone from types import SimpleNamespace from typing import Any -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest from agentscope.tool import ToolResponse @@ -25,11 +25,30 @@ def _decode_tool_response(response: ToolResponse) -> dict[str, Any]: @dataclass class _FakeService: created_request: Any = None + created_id: str = field(default_factory=lambda: str(uuid4())) + list_calls: list[dict[str, Any]] = field(default_factory=list) + + async def list_paginated( + self, *, page: int, page_size: int, query: str | None = None + ): + self.list_calls.append({"page": page, "page_size": page_size, "query": query}) + item = SimpleNamespace( + id=UUID(self.created_id), + title="会议", + description="今天下午五点的会议", + start_at=datetime(2026, 3, 17, 9, 0, tzinfo=timezone.utc), + end_at=datetime(2026, 3, 17, 9, 30, tzinfo=timezone.utc), + timezone="Asia/Shanghai", + metadata=SimpleNamespace( + location=None, color="#4F46E5", reminder_minutes=15 + ), + ) + return [item], 1 async def create_agent_generated(self, request): self.created_request = request return SimpleNamespace( - id=uuid4(), + id=UUID(self.created_id), title=request.title, description=request.description, start_at=request.start_at, @@ -136,6 +155,8 @@ async def test_calendar_write_create_normalizes_to_utc( payload = _decode_tool_response(result) assert payload["status"] == "success" + assert payload["result"].startswith("status=success") + assert fake_service.created_id in payload["result"] assert fake_service.created_request is not None request = fake_service.created_request assert request.timezone == "Asia/Shanghai" @@ -164,3 +185,28 @@ async def test_calendar_write_rejects_misaligned_batch_lists( assert payload["status"] == "failure" assert payload["error"]["code"] == "INVALID_ARGUMENT" assert "长度必须与 operations 一致" in payload["error"]["message"] + + +@pytest.mark.asyncio +async def test_calendar_read_returns_structured_result_with_ids( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake_service = _FakeService() + monkeypatch.setattr( + calendar_module, "create_schedule_service", lambda *_: fake_service + ) + + result = await calendar_module.calendar_read( + query="会议", + page=1, + page_size=20, + session=SimpleNamespace(), + owner_id=uuid4(), + ) + payload = _decode_tool_response(result) + + assert payload["status"] == "success" + assert payload["result"].startswith("status=success") + assert "query=会议" in payload["result"] + assert fake_service.created_id in payload["result"] + assert fake_service.list_calls == [{"page": 1, "page_size": 20, "query": "会议"}] diff --git a/backend/tests/unit/v1/agent/test_service.py b/backend/tests/unit/v1/agent/test_service.py index 097c192..843d6d2 100644 --- a/backend/tests/unit/v1/agent/test_service.py +++ b/backend/tests/unit/v1/agent/test_service.py @@ -311,3 +311,74 @@ async def test_enqueue_run_rejects_too_many_attachments(monkeypatch) -> None: assert exc_info.value.status_code == 422 assert exc_info.value.detail == "Too many attachments" + + +@pytest.mark.asyncio +async def test_get_history_snapshot_filters_out_tool_messages() -> None: + class _HistoryRepository(_FakeRepository): + async def get_history_day( + self, *, session_id: str, before: date | None + ) -> dict[str, object] | None: + del session_id, before + return { + "day": "2026-03-17", + "hasMore": False, + "messages": [ + { + "id": "00000000-0000-0000-0000-000000000111", + "seq": 1, + "role": "user", + "content": "帮我查一下今天日程", + "metadata": None, + "timestamp": "2026-03-17T09:00:00Z", + }, + { + "id": "00000000-0000-0000-0000-000000000112", + "seq": 2, + "role": "tool", + "content": "已获取日程列表,共 3 条", + "metadata": { + "run_id": "run-1", + "tool_agent_output": { + "tool_name": "calendar_read", + "tool_call_id": "call-1", + "status": "success", + "result": "status=success total=3 returned=3", + }, + }, + "timestamp": "2026-03-17T09:00:01Z", + }, + { + "id": "00000000-0000-0000-0000-000000000113", + "seq": 3, + "role": "assistant", + "content": "今天共有 3 条日程。", + "metadata": { + "run_id": "run-1", + "worker_agent_output": { + "status": "success", + "answer": "今天共有 3 条日程。", + "key_points": [], + "result_type": "summary", + "suggested_actions": [], + }, + }, + "timestamp": "2026-03-17T09:00:02Z", + }, + ], + } + + service = AgentService( + repository=_HistoryRepository(), + queue=_FakeQueue(), + stream=_FakeStream(), + attachment_storage=_FakeAttachmentStorage(), + ) + + snapshot = await service.get_history_snapshot( + thread_id="00000000-0000-0000-0000-000000000001", + before=None, + current_user=_user(), + ) + + assert [message.role for message in snapshot.messages] == ["user", "assistant"] diff --git a/backend/tests/unit/v1/agent/test_utils.py b/backend/tests/unit/v1/agent/test_utils.py index 74f3600..70e7ff9 100644 --- a/backend/tests/unit/v1/agent/test_utils.py +++ b/backend/tests/unit/v1/agent/test_utils.py @@ -16,21 +16,18 @@ class _FakeMessage: self.timestamp = datetime.now(timezone.utc) -def test_convert_message_to_history_uses_ui_schema_key_for_tool_message() -> None: +def test_convert_message_to_history_does_not_attach_ui_schema_for_tool_message() -> ( + None +): message = _FakeMessage( role="tool", - metadata={ - "tool_agent_output": { - "ui_schema": {"version": "2.0", "root": {"type": "stack"}} - } - }, + metadata={"tool_agent_output": {"result": "done"}}, ) result = convert_message_to_history(message) # type: ignore[arg-type] - assert "ui_schema" in result + assert "ui_schema" not in result assert "uiSchema" not in result - assert result["ui_schema"] == {"version": "2.0", "root": {"type": "stack"}} def test_convert_message_to_history_uses_ui_schema_key_for_assistant_message() -> None: diff --git a/backend/tests/unit/v1/app/test_router.py b/backend/tests/unit/v1/app/test_router.py new file mode 100644 index 0000000..b25c274 --- /dev/null +++ b/backend/tests/unit/v1/app/test_router.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import json + +import pytest + +from v1.app import router + + +def _write_manifest(tmp_path, data: dict): + manifest_file = tmp_path / "manifest.json" + manifest_file.write_text(json.dumps(data), encoding="utf-8") + return manifest_file + + +@pytest.mark.asyncio +async def test_check_updates_returns_none_when_manifest_missing(monkeypatch) -> None: + monkeypatch.setattr( + router, "_manifest_file_path", lambda: router.PROJECT_ROOT / "missing.json" + ) + + result = await router.check_updates( + current_version_code=2, + current_version_name="0.1.1", + platform="android", + channel="release", + ) + + assert result.has_update is False + assert result.update_type == "none" + assert result.latest_version_name == "0.1.1" + assert result.latest_version_code == 2 + + +@pytest.mark.asyncio +async def test_check_updates_returns_optional_update(monkeypatch, tmp_path) -> None: + manifest_file = _write_manifest( + tmp_path, + { + "releases": [ + { + "platform": "android", + "channel": "release", + "version_name": "0.1.2", + "version_code": 3, + "min_supported_version_code": 2, + "file_name": "social-app-android-v0.1.2+3-release.apk", + "release_notes": "优化体验", + } + ] + }, + ) + monkeypatch.setattr(router, "_manifest_file_path", lambda: manifest_file) + monkeypatch.setattr( + router.config.app_version, + "download_base_url", + "https://download.example.com", + raising=False, + ) + monkeypatch.setattr( + router.config.app_version, + "release_path_prefix", + "releases", + raising=False, + ) + + result = await router.check_updates( + current_version_code=2, + current_version_name="0.1.1", + platform="android", + channel="release", + ) + + assert result.has_update is True + assert result.update_type == "optional" + assert result.latest_version_name == "0.1.2" + assert result.latest_version_code == 3 + assert result.min_supported_version_code == 2 + assert ( + result.download_url + == "https://download.example.com/releases/social-app-android-v0.1.2+3-release.apk" + ) + + +@pytest.mark.asyncio +async def test_check_updates_returns_required_update(monkeypatch, tmp_path) -> None: + manifest_file = _write_manifest( + tmp_path, + { + "releases": [ + { + "platform": "android", + "channel": "release", + "version_name": "0.1.3", + "version_code": 5, + "min_supported_version_code": 4, + "file_name": "social-app-android-v0.1.3+5-release.apk", + } + ] + }, + ) + monkeypatch.setattr(router, "_manifest_file_path", lambda: manifest_file) + monkeypatch.setattr( + router.config.app_version, + "download_base_url", + "https://download.example.com", + raising=False, + ) + + result = await router.check_updates( + current_version_code=3, + current_version_name="0.1.1", + platform="android", + channel="release", + ) + + assert result.has_update is True + assert result.update_type == "required" + assert result.min_supported_version_code == 4 diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example index 16affb9..805e86d 100644 --- a/deploy/.env.prod.example +++ b/deploy/.env.prod.example @@ -19,6 +19,8 @@ SOCIAL_WEB__WORKERS=2 ############ # LiteLLM Proxy 网关配置 ############ +# 可选:覆盖官方 LiteLLM 镜像(默认使用 compose 内置 digest) +# SOCIAL_LITELLM_IMAGE=ghcr.io/berriai/litellm@sha256:b959a1816fa454a14d2842242d0fa1cd0d39f96fc94d3a1f4e1de4e48e2398c6 SOCIAL_LITELLM__PORT=3875 ############ @@ -85,11 +87,6 @@ SOCIAL_LLM__PROVIDER_KEYS__ZAI= ############ # App 版本更新配置 ############ -# 安装包目录,相对于项目根目录下的 deploy/static/ -SOCIAL_APP_VERSION__RELEASES_DIR=releases -# 当前版本号(语义化版本) -SOCIAL_APP_VERSION__CURRENT_VERSION=0.1.0 -# 当前构建号(整数,每次打包递增) -SOCIAL_APP_VERSION__CURRENT_BUILD=1 -# 下载链接基础域名(生产环境需配置) +SOCIAL_APP_VERSION__MANIFEST_PATH=deploy/static/releases/manifest.json +SOCIAL_APP_VERSION__RELEASE_PATH_PREFIX=releases SOCIAL_APP_VERSION__DOWNLOAD_BASE_URL= diff --git a/deploy/README.md b/deploy/README.md index 340dc16..fcf819b 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -81,7 +81,8 @@ cp deploy/.env.prod.example deploy/.env.prod ### 2) 启动常驻服务 ```bash -docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml up -d redis litellm web worker-critical worker-default worker-bulk +docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml up -d redis litellm-config-job +docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml up -d litellm web worker-critical worker-default worker-bulk ``` ### 3) 执行一次性 bootstrap @@ -118,7 +119,11 @@ docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml up - 如果 nginx 运行在宿主机,`web` 需要保留 `127.0.0.1:host_port:container_port` 端口映射。 - 如果 nginx 也运行在 Docker 同网络内,可以移除 `web.ports`,改为容器内反向代理(例如 `proxy_pass http://web:5775`)。 +### App 安装包下载代理(必须配置) + +在 nginx 增加静态目录映射:`location /releases/ { alias /你的项目绝对路径/deploy/static/releases/; }`,这样 `https://你的域名/releases/xxx.apk` 可直接下载安装包。并在 `deploy/.env.prod` 设置 `SOCIAL_APP_VERSION__DOWNLOAD_BASE_URL=https://你的域名` 与 `SOCIAL_APP_VERSION__RELEASE_PATH_PREFIX=releases`,确保 `check-updates` 返回的 `download_url` 指向该路径。 + ## 已知约束 -- LiteLLM 会在容器启动时动态生成 `/tmp/litellm-proxy-config.yaml`,依赖 `SOCIAL_LLM__PROVIDER_KEYS__*` 已配置。 +- LiteLLM 配置由 `litellm-config-job` 一次性生成到共享 volume(`litellm_config`)。若更新了 LLM 目录或 Provider Key,需重新执行 `up -d litellm-config-job` 后重启 `litellm`。 - `init-job` 为一次性任务,不长期驻留。 diff --git a/deploy/build-android-release.sh b/deploy/build-android-release.sh new file mode 100755 index 0000000..339aa59 --- /dev/null +++ b/deploy/build-android-release.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(git rev-parse --show-toplevel)" +APPS_DIR="$ROOT_DIR/apps" +OUTPUT_APK="$APPS_DIR/build/app/outputs/flutter-apk/app-release.apk" +RELEASES_DIR="$ROOT_DIR/deploy/static/releases" +MANIFEST_FILE="$RELEASES_DIR/manifest.json" + +BACKEND_HOST="" +CHANNEL="release" +RELEASE_NOTES="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --backend-host) + BACKEND_HOST="$2" + shift 2 + ;; + --channel) + CHANNEL="$2" + shift 2 + ;; + --release-notes) + RELEASE_NOTES="$2" + shift 2 + ;; + *) + printf 'Unknown arg: %s\n' "$1" >&2 + exit 1 + ;; + esac +done + +if [[ -z "$BACKEND_HOST" ]]; then + printf 'Usage: %s --backend-host [--channel release] [--release-notes "..."]\n' "$0" >&2 + exit 1 +fi + +if [[ "$BACKEND_HOST" == *":"* ]]; then + printf 'Invalid backend host: do not include port (%s)\n' "$BACKEND_HOST" >&2 + exit 1 +fi + +if ! [[ "$CHANNEL" =~ ^[a-z0-9_-]+$ ]]; then + printf 'Invalid channel: %s\n' "$CHANNEL" >&2 + exit 1 +fi + +BACKEND_URL="http://$BACKEND_HOST" + +readarray -t VERSION_INFO < <( + python - <<'PY' +import re +from pathlib import Path + +text = Path("apps/pubspec.yaml").read_text(encoding="utf-8") +match = re.search(r"^version:\s*([0-9]+\.[0-9]+\.[0-9]+)\+([0-9]+)\s*$", text, re.MULTILINE) +if not match: + raise SystemExit("pubspec.yaml version format invalid") +print(match.group(1)) +print(match.group(2)) +PY +) + +VERSION_NAME="${VERSION_INFO[0]}" +CURRENT_VERSION_CODE="${VERSION_INFO[1]}" +NEXT_VERSION_CODE="$((CURRENT_VERSION_CODE + 1))" +FILE_NAME="social-app-android-v${VERSION_NAME}+${NEXT_VERSION_CODE}-${CHANNEL}.apk" +TARGET_APK="$RELEASES_DIR/$FILE_NAME" + +mkdir -p "$RELEASES_DIR" + +pushd "$APPS_DIR" >/dev/null +flutter build apk --release \ + --build-name="$VERSION_NAME" \ + --build-number="$NEXT_VERSION_CODE" \ + --dart-define="BACKEND_URL=$BACKEND_URL" \ + --target-platform=android-arm64 \ + --split-per-abi \ + --target "lib/main.dart" +popd >/dev/null + +SOURCE_APK="$APPS_DIR/build/app/outputs/flutter-apk/app-arm64-v8a-release.apk" +if [[ ! -f "$SOURCE_APK" ]]; then + SOURCE_APK="$OUTPUT_APK" +fi + +cp "$SOURCE_APK" "$TARGET_APK" + +FILE_SIZE="$(stat -c%s "$TARGET_APK")" +SHA256="$(sha256sum "$TARGET_APK" | cut -d' ' -f1)" + +python - <<'PY' "$ROOT_DIR/apps/pubspec.yaml" "$VERSION_NAME" "$NEXT_VERSION_CODE" +import re +import sys +from pathlib import Path + +pubspec_path = Path(sys.argv[1]) +version_name = sys.argv[2] +version_code = sys.argv[3] + +content = pubspec_path.read_text(encoding="utf-8") +updated, count = re.subn( + r"^version:\s*[0-9]+\.[0-9]+\.[0-9]+\+[0-9]+\s*$", + f"version: {version_name}+{version_code}", + content, + count=1, + flags=re.MULTILINE, +) +if count != 1: + raise SystemExit("failed to update version in pubspec.yaml") +pubspec_path.write_text(updated, encoding="utf-8") +PY + +RELEASE_NOTES="$RELEASE_NOTES" python - <<'PY' "$MANIFEST_FILE" "$FILE_NAME" "$VERSION_NAME" "$NEXT_VERSION_CODE" "$CHANNEL" "$FILE_SIZE" "$SHA256" +import json +import os +import sys +from pathlib import Path + +manifest_path = Path(sys.argv[1]) +file_name = sys.argv[2] +version_name = sys.argv[3] +version_code = int(sys.argv[4]) +channel = sys.argv[5] +file_size = int(sys.argv[6]) +sha256 = sys.argv[7] +release_notes = os.environ.get("RELEASE_NOTES", "") + +if manifest_path.is_file(): + data = json.loads(manifest_path.read_text(encoding="utf-8")) +else: + data = {"releases": []} + +releases = data.get("releases") or [] +releases = [ + item + for item in releases + if not ( + item.get("platform") == "android" + and item.get("channel") == channel + and item.get("version_code") == version_code + ) +] +releases.append( + { + "platform": "android", + "channel": channel, + "version_name": version_name, + "version_code": version_code, + "min_supported_version_code": version_code, + "file_name": file_name, + "release_notes": release_notes or None, + "file_size": file_size, + "sha256": sha256, + } +) +releases.sort( + key=lambda item: ( + item.get("platform", ""), + item.get("channel", ""), + item.get("version_code", 0), + ) +) + +manifest_path.write_text( + json.dumps({"releases": releases}, indent=2, ensure_ascii=True) + "\n", + encoding="utf-8", +) +PY + +printf 'Build completed\n' +printf 'Version: %s+%s\n' "$VERSION_NAME" "$NEXT_VERSION_CODE" +printf 'Previous buildNumber: %s\n' "$CURRENT_VERSION_CODE" +printf 'Backend URL: %s\n' "$BACKEND_URL" +printf 'Package: %s\n' "$TARGET_APK" +printf 'SHA256: %s\n' "$SHA256" diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 690ca39..2414e4b 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -17,19 +17,38 @@ services: timeout: 3s retries: 10 - litellm: + litellm-config-job: image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} - container_name: social-prod-litellm - restart: unless-stopped + container_name: social-prod-litellm-config-job + restart: "no" env_file: - ./.env.prod environment: - PYTHONPATH=/app/backend/src - PYTHONDONTWRITEBYTECODE=1 - - SOCIAL_REDIS__HOST=redis - - SOCIAL_REDIS__PORT=6379 command: > - sh -c '.venv/bin/python backend/scripts/build_litellm_proxy_config.py --output /tmp/litellm-proxy-config.yaml && .venv/bin/litellm --config /tmp/litellm-proxy-config.yaml --host ${SOCIAL_LITELLM__BIND_HOST:-0.0.0.0} --port ${SOCIAL_LITELLM__PORT:-3875}' + .venv/bin/python backend/scripts/build_litellm_proxy_config.py --output /config/litellm-proxy-config.yaml + volumes: + - litellm_config:/config + depends_on: + redis: + condition: service_healthy + + litellm: + image: ${SOCIAL_LITELLM_IMAGE:-ghcr.io/berriai/litellm@sha256:b959a1816fa454a14d2842242d0fa1cd0d39f96fc94d3a1f4e1de4e48e2398c6} + container_name: social-prod-litellm + restart: unless-stopped + env_file: + - ./.env.prod + command: > + --config /config/litellm-proxy-config.yaml --host ${SOCIAL_LITELLM__BIND_HOST:-0.0.0.0} --port ${SOCIAL_LITELLM__PORT:-3875} + volumes: + - litellm_config:/config:ro + depends_on: + redis: + condition: service_healthy + litellm-config-job: + condition: service_completed_successfully healthcheck: test: [ @@ -68,7 +87,7 @@ services: condition: service_healthy volumes: - ../logs:/app/logs - - ./static/releases:/app/static/releases:ro + - ./static/releases:/app/deploy/static/releases:ro healthcheck: test: [ @@ -105,7 +124,7 @@ services: condition: service_healthy volumes: - ../logs:/app/logs - - ./static/releases:/app/static/releases:ro + - ./static/releases:/app/deploy/static/releases:ro worker-default: image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} @@ -131,7 +150,7 @@ services: condition: service_healthy volumes: - ../logs:/app/logs - - ./static/releases:/app/static/releases:ro + - ./static/releases:/app/deploy/static/releases:ro worker-bulk: image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} @@ -157,7 +176,7 @@ services: condition: service_healthy volumes: - ../logs:/app/logs - - ./static/releases:/app/static/releases:ro + - ./static/releases:/app/deploy/static/releases:ro init-job: image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod} @@ -182,9 +201,10 @@ services: condition: service_healthy volumes: - ../logs:/app/logs - - ./static/releases:/app/static/releases:ro + - ./static/releases:/app/deploy/static/releases:ro profiles: - job volumes: redis_data: + litellm_config: diff --git a/deploy/static/releases/manifest.json b/deploy/static/releases/manifest.json new file mode 100644 index 0000000..5abab9b --- /dev/null +++ b/deploy/static/releases/manifest.json @@ -0,0 +1,26 @@ +{ + "releases": [ + { + "platform": "android", + "channel": "release", + "version_name": "0.1.0", + "version_code": 1, + "min_supported_version_code": 1, + "file_name": "social-app-android-v0.1.0+1-release.apk", + "release_notes": "\u95ee\u9898\u4fee\u590d\u548c\u4f53\u9a8c\u4f18\u5316", + "file_size": 21371504, + "sha256": "6cf53601f36e0037b6de909ea3567d1e18a1bcec1164e1b70d88c1802eafd44b" + }, + { + "platform": "android", + "channel": "release", + "version_name": "0.1.0", + "version_code": 2, + "min_supported_version_code": 2, + "file_name": "social-app-android-v0.1.0+2-release.apk", + "release_notes": "\u95ee\u9898\u4fee\u590d\u548c\u4f53\u9a8c\u4f18\u5316", + "file_size": 21371504, + "sha256": "8f769bda3ba5414dfd5712ac026d8a13663990b7e83a4e92b8e85caf9945d5eb" + } + ] +} diff --git a/deploy/static/releases/social-app-android-v0.1.0+1-release.apk b/deploy/static/releases/social-app-android-v0.1.0+1-release.apk new file mode 100644 index 0000000..16a3559 Binary files /dev/null and b/deploy/static/releases/social-app-android-v0.1.0+1-release.apk differ diff --git a/deploy/static/releases/social-app-android-v0.1.0+2-release.apk b/deploy/static/releases/social-app-android-v0.1.0+2-release.apk new file mode 100644 index 0000000..8b11cc2 Binary files /dev/null and b/deploy/static/releases/social-app-android-v0.1.0+2-release.apk differ diff --git a/docs/protocols/agent/api-endpoints.md b/docs/protocols/agent/api-endpoints.md index 6f212be..3732eb7 100644 --- a/docs/protocols/agent/api-endpoints.md +++ b/docs/protocols/agent/api-endpoints.md @@ -110,18 +110,20 @@ Base URL: `/api/v1/agent` messages: Array<{ id: string; seq: number; - role: "user" | "assistant" | "tool"; + role: "user" | "assistant"; content: string; attachments?: Array<{ // user 附件签名链接列表 mimeType: string; url: string; }>; - ui_schema?: object | null; // assistant/tool 的编译后 UI + ui_schema?: object | null; // assistant 的编译后 UI timestamp: string; // ISO-8601 }>; } ``` +tool 消息在存储层用于运行时上下文续接,不在 `/history` 对外返回。 + ### 说明 - 若用户没有任何会话:返回 diff --git a/docs/protocols/agent/run-agent-input.md b/docs/protocols/agent/run-agent-input.md index 3e11201..90a3ad3 100644 --- a/docs/protocols/agent/run-agent-input.md +++ b/docs/protocols/agent/run-agent-input.md @@ -428,16 +428,6 @@ interface HistoryAttachment { url: string; } -// role = "tool" -interface HistoryMessageTool { - id: string; - seq: number; - role: "tool"; - content: string; - ui_schema: UiSchemaRenderer | null; // 由 tool_agent_output.ui_hints 编译 - timestamp: string; -} - // role = "assistant" interface HistoryMessageAssistant { id: string; @@ -448,7 +438,7 @@ interface HistoryMessageAssistant { timestamp: string; } -type HistoryMessage = HistoryMessageUser | HistoryMessageTool | HistoryMessageAssistant; +type HistoryMessage = HistoryMessageUser | HistoryMessageAssistant; ``` ### UiSchemaRenderer @@ -529,3 +519,4 @@ interface UiSchemaRenderer { - `tools` 是前端工具通道字段;当前后端运行时不基于该字段构造后端工具 prompt - `RunAgentInput` 同时接受 camelCase 与 snake_case 别名输入(推荐统一使用 camelCase) - 日历能力依赖 `forwardedProps.client_time` 透传设备时间上下文;缺失时回退用户 profile 时区 +- tool 消息在存储层用于运行时上下文续接,不在 `/history` 对外返回 diff --git a/docs/protocols/agent/sse-events.md b/docs/protocols/agent/sse-events.md index 8394b63..22b5fae 100644 --- a/docs/protocols/agent/sse-events.md +++ b/docs/protocols/agent/sse-events.md @@ -157,13 +157,14 @@ data: "tool_name": "...", "tool_call_id": "...", "tool_call_args": {}, - "status": "success" | "failed", - "result_summary": "...", - "ui_schema": {}, + "status": "success" | "failure" | "partial", + "result": "...", "error": null } ``` +说明:`TOOL_CALL_RESULT` 不再携带 `ui_schema`。tool 结果通过 `result` 字段提供紧凑、结构化、可执行的信息(优先包含 id/status/count 等关键事实),用于 agent 后续推理与工具编排。 + ### 3.4 文本完成事件 #### `TEXT_MESSAGE_END` diff --git a/docs/protocols/app/update-check.md b/docs/protocols/app/update-check.md new file mode 100644 index 0000000..47835db --- /dev/null +++ b/docs/protocols/app/update-check.md @@ -0,0 +1,106 @@ +# App Update Check Protocol + +本文档定义移动端应用更新检查协议,适用于 Android/iOS。 + +## 1. Endpoint + +- Method: `GET` +- Path: `/api/v1/app/check-updates` + +## 2. Request Query + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `platform` | `android` \| `ios` | 否 | 默认 `ios` | +| `channel` | `string` | 否 | 发布渠道,默认 `release` | +| `current_version_code` | `int` | 是 | 当前构建号(Android versionCode / iOS buildNumber) | +| `current_version_name` | `string` | 否 | 当前展示版本号(如 `0.1.1`) | + +## 3. Response + +```json +{ + "has_update": true, + "update_type": "optional", + "latest_version_name": "0.1.1", + "latest_version_code": 2, + "min_supported_version_code": 1, + "download_url": "https://example.com/releases/social-app-android-v0.1.1+2-release.apk", + "release_notes": "问题修复和体验优化", + "file_name": "social-app-android-v0.1.1+2-release.apk", + "file_size": 59768832, + "sha256": "" +} +``` + +## 4. Update Decision Algorithm + +后端必须按以下顺序判定: + +1. `current_version_code >= latest_version_code` -> `update_type = none` +2. `current_version_code < min_supported_version_code` -> `update_type = required` +3. 其他且 `< latest_version_code` -> `update_type = optional` + +`has_update = (update_type != "none")` + +## 5. Release Manifest + +发布清单文件位置:`deploy/static/releases/manifest.json` + +```json +{ + "releases": [ + { + "platform": "android", + "channel": "release", + "version_name": "0.1.1", + "version_code": 2, + "min_supported_version_code": 1, + "file_name": "social-app-android-v0.1.1+2-release.apk", + "release_notes": "问题修复和体验优化", + "file_size": 59768832, + "sha256": "" + } + ] +} +``` + +同一 `platform + channel` 可存在多条记录,服务端按 `version_code` 最大值选最新版。 + +## 6. Package Naming Convention + +Android 安装包命名规范: + +`social-app-android-v{versionName}+{versionCode}-{channel}.apk` + +示例: + +- `social-app-android-v0.1.1+2-release.apk` +- `social-app-android-v0.1.2+3-beta.apk` + +规则: + +- `versionName` 给用户展示(如 `0.1.1`) +- `versionCode` 必须严格递增 +- `channel` 建议使用 `release` / `beta` + +推荐打包命令: + +```bash +./deploy/build-android-release.sh \ + --backend-host 115.190.63.157 \ + --channel release \ + --release-notes "问题修复和体验优化" +``` + +该脚本会: + +- 按命名规范生成 APK 文件到 `deploy/static/releases/` +- 每次打包自动将 `apps/pubspec.yaml` 的 `buildNumber` 递增 1 并写回 +- 自动更新 `deploy/static/releases/manifest.json` +- 输出版本号、文件路径和 SHA256 + +注意: + +- `versionName`(如 `0.1.0`)由开发者手动维护 +- `buildNumber`(如 `+2`)由打包脚本自动递增 diff --git a/docs/protocols/ui/data-flow.md b/docs/protocols/ui/data-flow.md index 3757344..e603183 100644 --- a/docs/protocols/ui/data-flow.md +++ b/docs/protocols/ui/data-flow.md @@ -93,11 +93,13 @@ data: ### 5.2 UI 编译器一致 -两条链路都使用后端 `ui_compiler.compile(...)` 将 `ui_hints` 编译为可渲染结构: +两条链路都使用后端 `ui_compiler.compile(...)` 将 **worker** 的 `ui_hints` 编译为可渲染结构: - events:在 runtime 发送事件前编译,字段名为 `ui_schema` - history:在历史转换时编译,字段名为 `ui_schema` +tool 结果不再走 UI 编译链路:`TOOL_CALL_RESULT` 仅提供 `result`,并在持久化时写入 message `content`。 + ### 5.3 当前命名差异(实现现状) 两条链路字段命名已统一: diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml index 9aaa9e3..16f4fba 100644 --- a/infra/docker/docker-compose.yml +++ b/infra/docker/docker-compose.yml @@ -19,6 +19,50 @@ services: timeout: 3s retries: 5 + litellm-config-job: + build: + context: ../.. + dockerfile: backend/Dockerfile + image: social-local-backend + container_name: social-local-litellm-config-job + restart: "no" + env_file: + - ../../.env + environment: + - PYTHONPATH=/app/backend/src + command: > + uv run python backend/scripts/build_litellm_proxy_config.py --output /config/litellm-proxy-config.yaml + volumes: + - litellm_config:/config + depends_on: + redis: + condition: service_healthy + + litellm: + image: ghcr.io/berriai/litellm@sha256:b959a1816fa454a14d2842242d0fa1cd0d39f96fc94d3a1f4e1de4e48e2398c6 + container_name: social-local-litellm + restart: unless-stopped + env_file: + - ../../.env + ports: + - "${SOCIAL_LITELLM__PORT:-3875}:${SOCIAL_LITELLM__PORT:-3875}" + volumes: + - litellm_config:/config:ro + command: + [ + "--config", + "/config/litellm-proxy-config.yaml", + "--host", + "0.0.0.0", + "--port", + "${SOCIAL_LITELLM__PORT:-3875}", + ] + depends_on: + redis: + condition: service_healthy + litellm-config-job: + condition: service_completed_successfully + init-job: build: context: ../.. @@ -47,3 +91,4 @@ services: volumes: redis_data: + litellm_config: diff --git a/infra/scripts/app.sh b/infra/scripts/app.sh index db2a7e5..e2ea733 100755 --- a/infra/scripts/app.sh +++ b/infra/scripts/app.sh @@ -3,16 +3,14 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)" SESSION_NAME="${SESSION_NAME:-social-dev}" -COMPOSE_FILE="$ROOT_DIR/infra/docker/docker-compose.yml" ENV_FILE="$ROOT_DIR/.env" -LITELLM_RUNTIME_CONFIG="$ROOT_DIR/.tmp/litellm-proxy-config.yaml" usage() { echo "Usage: $0 {start|stop|restart}" echo "" echo "Commands:" - echo " start Start LiteLLM + web + worker processes in tmux" - echo " stop Stop tmux session and clean orphaned processes" + echo " start Start local web/worker tmux only" + echo " stop Stop tmux session and orphaned local processes" echo " restart Stop then start all app processes" exit 1 } @@ -117,7 +115,8 @@ kill_listening_processes() { start() { echo "=== App Up ===" - echo "This script starts LiteLLM + web + worker processes in tmux." + echo "This script starts local web + worker processes in tmux." + echo "LiteLLM/Redis should be managed separately as long-running docker services." echo "NOTE: Bootstrap (migrate + init-data) must be run separately." echo "" @@ -131,17 +130,11 @@ start() { exit 1 fi - if [ ! -f "$COMPOSE_FILE" ]; then - echo "Error: compose file not found at $COMPOSE_FILE" >&2 - exit 1 - fi - load_env_if_exists UVICORN_LOG_LEVEL="${SOCIAL_RUNTIME__LOG_LEVEL:-info}" UVICORN_LOG_LEVEL="$(echo "$UVICORN_LOG_LEVEL" | tr '[:upper:]' '[:lower:]')" WEB_PORT="${SOCIAL_WEB__PORT:-5775}" - LITELLM_PORT="${SOCIAL_LITELLM__PORT:-3875}" if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then echo "Error: tmux session '$SESSION_NAME' already exists." >&2 @@ -155,12 +148,6 @@ start() { exit 1 fi - if is_port_in_use "$LITELLM_PORT"; then - echo "Error: litellm port ${LITELLM_PORT} is already in use." >&2 - echo "Hint: run '$0 stop' or change SOCIAL_LITELLM__PORT in .env" >&2 - exit 1 - fi - if [ -z "${SOCIAL_LLM__PROVIDER_KEYS__DASHSCOPE:-}" ]; then echo "Warning: SOCIAL_LLM__PROVIDER_KEYS__DASHSCOPE is empty; qwen calls may fail." >&2 fi @@ -168,12 +155,6 @@ start() { echo "Warning: SOCIAL_LLM__PROVIDER_KEYS__DEEPSEEK is empty; deepseek calls may fail." >&2 fi - echo "Starting LiteLLM + web + worker processes in tmux session '$SESSION_NAME'..." - - cd "$ROOT_DIR" && PYTHONPATH=backend/src uv run python backend/scripts/build_litellm_proxy_config.py --output "$LITELLM_RUNTIME_CONFIG" - - LITELLM_CMD="cd '$ROOT_DIR' && set -a && . '$ENV_FILE' && set +a && uv run litellm --config '$LITELLM_RUNTIME_CONFIG' --port ${LITELLM_PORT}" - WEB_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=web uv run uvicorn app:app --host \ ${SOCIAL_WEB__HOST:-0.0.0.0} --port ${WEB_PORT} --workers \ ${SOCIAL_WEB__WORKERS:-2} --log-level ${UVICORN_LOG_LEVEL}" @@ -182,8 +163,9 @@ ${SOCIAL_WEB__WORKERS:-2} --log-level ${UVICORN_LOG_LEVEL}" WORKER_DEFAULT_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-default uv run taskiq worker core.taskiq.app:default_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__DEFAULT__CONCURRENCY:-2}" WORKER_BULK_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src SOCIAL_RUNTIME__SERVICE_NAME=worker-bulk uv run taskiq worker core.taskiq.app:bulk_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__BULK__CONCURRENCY:-1}" - tmux new-session -d -s "$SESSION_NAME" -n litellm "bash -lc \"$LITELLM_CMD; echo '[litellm] exited'; exec bash\"" - tmux new-window -t "$SESSION_NAME" -n web "bash -lc \"$WEB_CMD; echo '[web] exited'; exec bash\"" + echo "Starting tmux workers in session '$SESSION_NAME'..." + + tmux new-session -d -s "$SESSION_NAME" -n web "bash -lc \"$WEB_CMD; echo '[web] exited'; exec bash\"" tmux new-window -t "$SESSION_NAME" -n worker-critical "bash -lc \"$WORKER_CRITICAL_CMD; echo '[worker-critical] exited'; exec bash\"" tmux new-window -t "$SESSION_NAME" -n worker-default "bash -lc \"$WORKER_DEFAULT_CMD; echo '[worker-default] exited'; exec bash\"" tmux new-window -t "$SESSION_NAME" -n worker-bulk "bash -lc \"$WORKER_BULK_CMD; echo '[worker-bulk] exited'; exec bash\"" @@ -202,9 +184,9 @@ ${SOCIAL_WEB__WORKERS:-2} --log-level ${UVICORN_LOG_LEVEL}" stop() { echo "=== App Down ===" + echo "Stopping tmux app processes (docker redis/litellm are not managed here)." load_env_if_exists WEB_PORT="${SOCIAL_WEB__PORT:-5775}" - LITELLM_PORT="${SOCIAL_LITELLM__PORT:-3875}" if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then echo "Stopping tmux session '$SESSION_NAME'..." @@ -216,11 +198,9 @@ stop() { echo "Checking for orphaned processes..." kill_matching_processes "uvicorn" "uv run uvicorn app:app" - kill_matching_processes "litellm" "uv run litellm --config" kill_matching_processes "taskiq workers" "uv run taskiq worker core.taskiq.app:" kill_listening_processes "port ${WEB_PORT} listeners" "$WEB_PORT" - kill_listening_processes "port ${LITELLM_PORT} listeners" "$LITELLM_PORT" if is_port_in_use "$WEB_PORT"; then echo "Warning: port ${WEB_PORT} is still in use after cleanup." >&2 @@ -228,12 +208,6 @@ stop() { return 1 fi - if is_port_in_use "$LITELLM_PORT"; then - echo "Warning: port ${LITELLM_PORT} is still in use after cleanup." >&2 - echo "Hint: check process with 'lsof -iTCP:${LITELLM_PORT} -sTCP:LISTEN'" >&2 - return 1 - fi - echo "Session stopped and cleaned up." } diff --git a/pyproject.toml b/pyproject.toml index 514b51b..36c3738 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,13 +7,9 @@ dependencies = [ "ag-ui-protocol>=0.1.13", "alembic>=1.18.3", "asyncpg>=0.31.0", - "basedpyright>=1.37.2", - "crewai>=1.6.1", - "crewai-tools>=1.6.1", "email-validator>=2.3.0", "fastapi>=0.128.0", - "litellm[proxy]>=1.52.0", - "playwright>=1.57.0", + "litellm>=1.52.0", "pydantic>=2.11.0", "pydantic-settings>=2.10.0", "pyjwt>=2.10.1", @@ -53,6 +49,8 @@ markers = [ [dependency-groups] dev = [ "aiosqlite>=0.22.1", + "basedpyright>=1.37.2", + "playwright>=1.57.0", "pre-commit>=4.5.1", "pytest>=9.0.2", "pytest-asyncio>=1.3.0",