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:
+8
-7
@@ -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
|
||||
|
||||
@@ -264,24 +264,10 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
}
|
||||
|
||||
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<ChatState> {
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.uiSchema != null) {
|
||||
if (!isTool && msg.uiSchema != null) {
|
||||
converted.add(
|
||||
ToolResultItem(
|
||||
id: '${msg.id}-ui',
|
||||
@@ -444,15 +430,6 @@ class ChatBloc extends Cubit<ChatState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _upsertById(List<ChatListItem> items, ChatListItem nextItem) {
|
||||
final index = items.indexWhere((item) => item.id == nextItem.id);
|
||||
if (index >= 0) {
|
||||
items[index] = nextItem;
|
||||
return;
|
||||
}
|
||||
items.add(nextItem);
|
||||
}
|
||||
|
||||
Future<String> transcribeAudioFile(String filePath) {
|
||||
return _service.transcribeAudio(filePath);
|
||||
}
|
||||
|
||||
@@ -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<String, dynamic> 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<AppVersionResponse> checkUpdates({
|
||||
required int currentBuild,
|
||||
String? currentVersion,
|
||||
required int currentVersionCode,
|
||||
required String currentVersionName,
|
||||
String platform = 'android',
|
||||
String channel = 'release',
|
||||
}) async {
|
||||
final params = <String, String>{
|
||||
'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);
|
||||
}
|
||||
|
||||
@@ -532,8 +532,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
try {
|
||||
final settingsApi = sl<SettingsApi>();
|
||||
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<SettingsScreen> {
|
||||
}
|
||||
|
||||
final message = result.updateType == 'required'
|
||||
? '有新版本可用 (${result.latestVersion}),请立即更新'
|
||||
: '发现新版本 (${result.latestVersion}),是否更新?';
|
||||
? '有新版本可用 (${result.latestVersionName}),请立即更新'
|
||||
: '发现新版本 (${result.latestVersionName}),是否更新?';
|
||||
|
||||
final shouldUpdate = await showDialog<bool>(
|
||||
context: context,
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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<ToolCallResultEvent>());
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 [],
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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": "会议"}]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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=
|
||||
|
||||
+7
-2
@@ -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` 为一次性任务,不长期驻留。
|
||||
|
||||
Executable
+178
@@ -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 <ip-or-domain> [--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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -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` 对外返回。
|
||||
|
||||
### 说明
|
||||
|
||||
- 若用户没有任何会话:返回
|
||||
|
||||
@@ -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` 对外返回
|
||||
|
||||
@@ -157,13 +157,14 @@ data: <json>
|
||||
"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`
|
||||
|
||||
@@ -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": "<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": "<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`)由打包脚本自动递增
|
||||
@@ -93,11 +93,13 @@ data: <json>
|
||||
|
||||
### 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 当前命名差异(实现现状)
|
||||
|
||||
两条链路字段命名已统一:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+8
-34
@@ -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."
|
||||
}
|
||||
|
||||
|
||||
+3
-5
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user