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

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