refactor: 移除 LiteLLM proxy 架构,后端直连 Provider API

- 移除 backend/scripts/build_litellm_proxy_config.py
- 简化 LiteLLMService,移除 run_completion_with_cost 方法
- AgentScopeRunner 改为从 LlmFactory 获取 api_base 和 api_key
- 部署配置移除 litellm/litellm-config-job 服务
- Flutter 新增 AuthBootScreen 引导页
- Android 添加通知权限 (POST_NOTIFICATIONS, RECEIVE_BOOT_COMPLETED, SCHEDULE_EXACT_ALARM)
- 优化 LocalNotificationService 调度失败 fallback
- 更新 manifest.json (version 3)
This commit is contained in:
qzl
2026-03-17 18:05:49 +08:00
parent cf56b358ad
commit 19981964fb
26 changed files with 417 additions and 1018 deletions
-5
View File
@@ -16,11 +16,6 @@ SOCIAL_WEB__HOST=0.0.0.0
SOCIAL_WEB__PORT=5775
SOCIAL_WEB__WORKERS=2
############
# LiteLLM Proxy 网关配置
############
SOCIAL_LITELLM__PORT=3875
############
# Redis 配置
############
@@ -1,5 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
@@ -76,16 +76,29 @@ class LocalNotificationService {
),
);
await _plugin.zonedSchedule(
notificationId,
event.title,
_buildReminderBody(event, reminderMinutes),
scheduledAt,
details,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
);
try {
await _plugin.zonedSchedule(
notificationId,
event.title,
_buildReminderBody(event, reminderMinutes),
scheduledAt,
details,
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
);
} catch (_) {
await _plugin.zonedSchedule(
notificationId,
event.title,
_buildReminderBody(event, reminderMinutes),
scheduledAt,
details,
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
uiLocalNotificationDateInterpretation:
UILocalNotificationDateInterpretation.absoluteTime,
);
}
}
Future<void> cancelEventReminder(String eventId) async {
+15 -1
View File
@@ -3,6 +3,7 @@ import '../../features/auth/presentation/bloc/auth_bloc.dart';
import '../../features/auth/presentation/bloc/auth_state.dart';
import 'go_router_refresh_stream.dart';
import '../../features/auth/ui/screens/login_screen.dart';
import '../../features/auth/ui/screens/auth_boot_screen.dart';
import '../../features/auth/ui/screens/register_screen.dart';
import '../../features/auth/ui/screens/register_verification_screen.dart';
import '../../features/auth/ui/screens/reset_password_screen.dart';
@@ -43,11 +44,14 @@ final _protectedRoutes = [
GoRouter createAppRouter(AuthBloc authBloc) {
return GoRouter(
initialLocation: '/',
initialLocation: '/boot',
refreshListenable: GoRouterRefreshStream(authBloc.stream),
redirect: (context, state) {
final authState = authBloc.state;
final isAuthenticated = authState is AuthAuthenticated;
final isAuthChecking =
authState is AuthInitial || authState is AuthLoading;
final isBootRoute = state.matchedLocation == '/boot';
final isAuthRoute =
state.matchedLocation == '/' ||
state.matchedLocation.startsWith('/login') ||
@@ -56,6 +60,12 @@ GoRouter createAppRouter(AuthBloc authBloc) {
(route) => state.matchedLocation.startsWith(route),
);
if (isAuthChecking && !isBootRoute) {
return '/boot';
}
if (!isAuthChecking && isBootRoute) {
return isAuthenticated ? '/home' : '/';
}
if (!isAuthenticated && isProtected) {
return '/';
}
@@ -65,6 +75,10 @@ GoRouter createAppRouter(AuthBloc authBloc) {
return null;
},
routes: [
GoRoute(
path: '/boot',
builder: (context, state) => const AuthBootScreen(),
),
GoRoute(path: '/', builder: (context, state) => const LoginScreen()),
GoRoute(
path: '/calendar/events/:id',
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
class AuthBootScreen extends StatelessWidget {
const AuthBootScreen({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
backgroundColor: AppColors.authBackgroundTop,
body: SafeArea(
child: Center(
child: AppLoadingIndicator(
variant: AppLoadingVariant.surface,
size: 28,
strokeWidth: 2.5,
color: AppColors.authPrimaryButton,
trackColor: AppColors.authPrimaryButtonDisabled,
),
),
),
);
}
}
@@ -638,7 +638,11 @@ class _CreateEventSheetState extends State<CreateEventSheet>
try {
final notificationService = sl<LocalNotificationService>();
await notificationService.upsertEventReminder(saved);
} catch (_) {}
} catch (_) {
if (mounted) {
Toast.show(context, '提醒创建失败,请检查通知权限', type: ToastType.warning);
}
}
widget.onSaved?.call();
if (mounted) {
+1 -1
View File
@@ -1,7 +1,7 @@
name: social_app
description: "Social App - A Flutter mobile application"
publish_to: 'none'
version: 0.1.0+2
version: 0.1.0+3
environment:
sdk: ^3.10.7
@@ -1,90 +0,0 @@
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Any
import yaml
from core.config.initial.init_data import load_llm_catalog
def _provider_key_env_name(factory_name: str) -> str:
normalized = factory_name.strip().upper()
if normalized == "VOLCENGINE":
normalized = "ARK"
return f"SOCIAL_LLM__PROVIDER_KEYS__{normalized}"
def build_proxy_config() -> dict[str, Any]:
catalog = load_llm_catalog()
factories = catalog.get("factories", [])
llms = catalog.get("llms", [])
if not isinstance(factories, list) or not isinstance(llms, list):
raise ValueError("invalid llm catalog format")
factory_url_map: dict[str, str] = {}
for factory in factories:
if not isinstance(factory, dict):
continue
name = str(factory.get("name", "")).strip().lower()
request_url = str(factory.get("request_url", "")).strip()
if name and request_url:
factory_url_map[name] = request_url
model_list: list[dict[str, Any]] = []
for llm in llms:
if not isinstance(llm, dict):
continue
model_code = str(llm.get("model_code", "")).strip()
factory_name = str(llm.get("factory_name", "")).strip()
litellm_model = str(llm.get("litellm_model", "")).strip()
if not model_code or not factory_name or not litellm_model:
continue
api_base = factory_url_map.get(factory_name.lower())
if not api_base:
raise ValueError(
f"factory request_url missing for model {model_code}: {factory_name}"
)
env_key_name = _provider_key_env_name(factory_name)
provider_model = (
litellm_model.split("/", 1)[1] if "/" in litellm_model else litellm_model
)
model_list.append(
{
"model_name": model_code,
"litellm_params": {
"model": f"openai/{provider_model}",
"api_base": api_base,
"api_key": f"os.environ/{env_key_name}",
},
}
)
if not model_list:
raise ValueError("no models found in llm catalog")
return {"model_list": model_list}
def main() -> int:
parser = argparse.ArgumentParser(description="Build LiteLLM proxy config")
parser.add_argument("--output", required=True, help="Output YAML file path")
args = parser.parse_args()
output_path = Path(args.output).resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
config = build_proxy_config()
with output_path.open("w", encoding="utf-8") as file:
yaml.safe_dump(config, file, sort_keys=False, allow_unicode=False)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+28 -5
View File
@@ -21,9 +21,11 @@ from core.agentscope.utils import (
finalize_json_response,
patch_agentscope_json_repair_compat,
)
from core.config.settings import config
from core.db.session import AsyncSessionLocal
from core.logging import get_logger
from models.llm import Llm
from models.llm_factory import LlmFactory
from models.system_agents import SystemAgents
from schemas.agent.runtime_models import (
RouterAgentOutput,
@@ -50,6 +52,8 @@ logger = get_logger("core.agentscope.runtime.runner")
class SystemAgentRuntimeConfig:
agent_type: AgentType
model_code: str
api_base_url: str
api_key: str
llm_config: SystemAgentLLMConfig
@@ -63,7 +67,7 @@ class StageExecutionResult:
class AgentScopeRunner:
def __init__(self, *, litellm_service: LiteLLMService | None = None) -> None:
patch_agentscope_json_repair_compat()
self._litellm_service = litellm_service or LiteLLMService()
self._litellm_service: LiteLLMService = litellm_service or LiteLLMService()
async def execute(
self,
@@ -221,23 +225,42 @@ class AgentScopeRunner:
agent_type: AgentType,
) -> SystemAgentRuntimeConfig:
stmt = (
select(SystemAgents, Llm)
select(SystemAgents, Llm, LlmFactory)
.join(Llm, SystemAgents.llm_id == Llm.id)
.join(LlmFactory, Llm.factory_id == LlmFactory.id)
.where(SystemAgents.agent_type == agent_type.value)
)
row = (await session.execute(stmt)).one_or_none()
if row is None:
raise RuntimeError(f"system agent config not found: {agent_type.value}")
system_agent, llm = row
system_agent, llm, factory = row
status = str(system_agent.status).strip().lower()
if status != "active":
raise RuntimeError(f"system agent is not active: {agent_type.value}")
return SystemAgentRuntimeConfig(
agent_type=agent_type,
model_code=llm.model_code,
api_base_url=factory.request_url,
api_key=self._resolve_provider_api_key(factory_name=factory.name),
llm_config=SystemAgentLLMConfig.model_validate(system_agent.config or {}),
)
@staticmethod
def _resolve_provider_api_key(*, factory_name: str) -> str:
normalized_factory_name = factory_name.strip().upper()
if normalized_factory_name == "VOLCENGINE":
normalized_factory_name = "ARK"
provider_keys = {
str(key).strip().upper(): str(value).strip()
for key, value in config.llm.provider_keys.items()
if str(value).strip()
}
api_key = provider_keys.get(normalized_factory_name, "")
if not api_key:
raise RuntimeError(f"provider api key missing for factory: {factory_name}")
return api_key
async def _run_router_stage(
self,
*,
@@ -363,9 +386,9 @@ class AgentScopeRunner:
model = OpenAIChatModel(
model_name=stage_config.model_code,
api_key=self._litellm_service.proxy_api_key,
api_key=stage_config.api_key,
stream=False,
client_kwargs={"base_url": self._litellm_service.proxy_base_url},
client_kwargs={"base_url": stage_config.api_base_url},
generate_kwargs=generate_kwargs,
)
return TrackingChatModel(model)
+2 -6
View File
@@ -1,9 +1,5 @@
from __future__ import annotations
from services.litellm.service import (
LiteLLMResponseWithCost,
LiteLLMService,
LiteLLMUsage,
)
from services.litellm.service import LiteLLMService
__all__ = ["LiteLLMService", "LiteLLMUsage", "LiteLLMResponseWithCost"]
__all__ = ["LiteLLMService"]
+2 -104
View File
@@ -1,11 +1,8 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
from typing import Any
from litellm import completion
from core.config.settings import config
from core.config.initial.init_data import load_llm_catalog
@@ -17,34 +14,10 @@ class PricingTier:
cache_hit_cost_per_token: float
@dataclass(frozen=True)
class LiteLLMUsage:
prompt_tokens: int
completion_tokens: int
total_tokens: int
cached_prompt_tokens: int
cost: float
@dataclass(frozen=True)
class LiteLLMResponseWithCost:
response: dict[str, Any]
usage: LiteLLMUsage
class LiteLLMService:
proxy_base_url: str
proxy_api_key: str
_pricing_by_model: dict[str, tuple[PricingTier, ...]]
def __init__(
self,
*,
proxy_base_url: str | None = None,
proxy_api_key: str | None = None,
) -> None:
self.proxy_base_url = proxy_base_url or config.litellm.base_url
self.proxy_api_key = proxy_api_key or config.litellm.api_key
def __init__(self) -> None:
self._pricing_by_model = self._build_pricing_map()
@staticmethod
@@ -142,78 +115,3 @@ class LiteLLMService:
"cost": cost,
"latencyMs": latency_ms,
}
def run_completion_with_cost(
self,
*,
model: str,
messages: list[dict[str, Any]],
temperature: float | None = None,
max_tokens: int | None = None,
timeout: float | None = None,
response_format: dict[str, Any] | None = None,
completion_fn: Callable[..., dict[str, Any]] | None = None,
) -> LiteLLMResponseWithCost:
caller = completion_fn or completion
request_model = model if model.startswith("openai/") else f"openai/{model}"
request_kwargs: dict[str, Any] = {
"model": request_model,
"api_key": self.proxy_api_key,
"api_base": self.proxy_base_url,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
"timeout": timeout,
"stream": False,
}
if response_format is not None:
request_kwargs["response_format"] = response_format
response_any = caller(**request_kwargs)
response = self._normalize_response(response_any)
usage_raw = response.get("usage")
if not isinstance(usage_raw, dict):
raise ValueError("missing usage in response")
prompt_tokens = int(usage_raw.get("prompt_tokens", 0) or 0)
completion_tokens = int(usage_raw.get("completion_tokens", 0) or 0)
total_tokens = int(
usage_raw.get("total_tokens", prompt_tokens + completion_tokens) or 0
)
cached_prompt_tokens = 0
prompt_tokens_details = usage_raw.get("prompt_tokens_details")
if isinstance(prompt_tokens_details, dict):
cached_prompt_tokens = int(
prompt_tokens_details.get("cached_tokens", 0) or 0
)
resolved_model = str(response.get("model", model)).strip()
cost = self.calculate_cost(
model=resolved_model,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
cached_prompt_tokens=cached_prompt_tokens,
)
return LiteLLMResponseWithCost(
response=response,
usage=LiteLLMUsage(
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
cached_prompt_tokens=cached_prompt_tokens,
cost=cost,
),
)
@staticmethod
def _normalize_response(response_any: Any) -> dict[str, Any]:
if isinstance(response_any, dict):
return response_any
model_dump = getattr(response_any, "model_dump", None)
if callable(model_dump):
dumped = model_dump()
if isinstance(dumped, dict):
return dumped
raise ValueError("litellm response is not serializable")
@@ -133,6 +133,8 @@ async def test_execute_uses_router_ui_mode_to_select_worker_output_model(
model_code="qwen3.5-flash"
if kwargs["agent_type"] == AgentType.ROUTER
else "deepseek-chat",
api_base_url="https://example.com/v1",
api_key="sk-test",
llm_config=SystemAgentLLMConfig(
temperature=0.1, max_tokens=256, timeout_seconds=30
),
@@ -233,6 +235,8 @@ async def test_execute_passes_runtime_client_time_to_router_and_worker(
return SystemAgentRuntimeConfig(
agent_type=kwargs["agent_type"],
model_code="model-a",
api_base_url="https://example.com/v1",
api_key="sk-test",
llm_config=SystemAgentLLMConfig(
temperature=0.1, max_tokens=256, timeout_seconds=30
),
@@ -296,3 +300,29 @@ async def test_execute_passes_runtime_client_time_to_router_and_worker(
assert captured["router_timezone"] == "America/Los_Angeles"
assert captured["worker_timezone"] == "America/Los_Angeles"
def test_resolve_provider_api_key_maps_volcengine_to_ark(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"core.agentscope.runtime.runner.config.llm.provider_keys",
{"ARK": "ark-key", "DASHSCOPE": "dash-key"},
)
assert (
AgentScopeRunner._resolve_provider_api_key(factory_name="volcengine")
== "ark-key"
)
def test_resolve_provider_api_key_raises_when_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"core.agentscope.runtime.runner.config.llm.provider_keys",
{"DASHSCOPE": "dash-key"},
)
with pytest.raises(RuntimeError, match="provider api key missing"):
AgentScopeRunner._resolve_provider_api_key(factory_name="deepseek")
@@ -31,37 +31,6 @@ def test_calculate_cost_uses_second_qwen_tier() -> None:
assert cost == pytest.approx(0.1856)
def test_run_completion_extracts_usage_and_cost() -> None:
service = LiteLLMService()
captured: dict[str, object] = {}
def _fake_completion(**kwargs: object) -> dict[str, object]:
captured.update(kwargs)
return {
"model": "dashscope/qwen3.5-flash",
"usage": {
"prompt_tokens": 2000,
"completion_tokens": 100,
"total_tokens": 2100,
"prompt_tokens_details": {"cached_tokens": 500},
},
"choices": [{"message": {"content": "ok"}}],
}
result = service.run_completion_with_cost(
model="dashscope/qwen3.5-flash",
messages=[{"role": "user", "content": "hello"}],
response_format={"type": "json_object"},
completion_fn=_fake_completion,
)
assert result.usage.prompt_tokens == 2000
assert result.usage.completion_tokens == 100
assert result.usage.total_tokens == 2100
assert result.usage.cost == pytest.approx(0.00051)
assert captured["response_format"] == {"type": "json_object"}
def test_build_usage_metadata_calculates_cost_from_usage_summary() -> None:
service = LiteLLMService()
-7
View File
@@ -16,13 +16,6 @@ SOCIAL_WEB__HOST=0.0.0.0
SOCIAL_WEB__PORT=5775
SOCIAL_WEB__WORKERS=2
############
# LiteLLM Proxy 网关配置
############
# 可选:覆盖官方 LiteLLM 镜像(默认使用 compose 内置 digest
# SOCIAL_LITELLM_IMAGE=ghcr.io/berriai/litellm@sha256:b959a1816fa454a14d2842242d0fa1cd0d39f96fc94d3a1f4e1de4e48e2398c6
SOCIAL_LITELLM__PORT=3875
############
# Redis 配置
############
+5 -7
View File
@@ -2,7 +2,7 @@
本目录是单机 `docker compose` 的生产交付包,架构为:
- 应用层:`litellm + web + worker-critical + worker-default + worker-bulk + init-job`
- 应用层:`web + worker-critical + worker-default + worker-bulk + init-job`
- 中间件:`redis`
- 数据与认证:云 Supabase(通过环境变量访问)
- 反向代理:由服务器侧 nginx 托管(不在本目录编排)
@@ -19,7 +19,7 @@
- `deploy/.env.prod.example` 仅作为模板,真实密钥请在服务器上填写到 `deploy/.env.prod`,不要提交仓库。
- Redis 密码必填;为空时容器会启动失败。
- 后端镜像默认使用非 root 用户运行。
- 容器间通信仅走 Docker 内网(`redis``litellm` 服务名)。
- 容器间通信仅走 Docker 内网(`redis` 服务名)。
## 目录结构
@@ -75,14 +75,13 @@ cp deploy/.env.prod.example deploy/.env.prod
说明:
- 容器内通信统一使用 Docker 内网:`SOCIAL_REDIS__HOST=redis``SOCIAL_LITELLM__HOST=litellm`
- `SOCIAL_WEB__HOST`/`SOCIAL_LITELLM__BIND_HOST` 是容器内监听地址,生产建议保持 `0.0.0.0`
- 容器内通信统一使用 Docker 内网:`SOCIAL_REDIS__HOST=redis`
- `SOCIAL_WEB__HOST` 是容器内监听地址,生产建议保持 `0.0.0.0`
### 2) 启动常驻服务
```bash
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
docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml up -d redis web worker-critical worker-default worker-bulk
```
### 3) 执行一次性 bootstrap
@@ -125,5 +124,4 @@ docker compose --env-file deploy/.env.prod -f deploy/docker-compose.prod.yml up
## 已知约束
- LiteLLM 配置由 `litellm-config-job` 一次性生成到共享 volume`litellm_config`)。若更新了 LLM 目录或 Provider Key,需重新执行 `up -d litellm-config-job` 后重启 `litellm`
- `init-job` 为一次性任务,不长期驻留。
-65
View File
@@ -23,50 +23,6 @@ services:
timeout: 3s
retries: 10
litellm-config-job:
image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod}
container_name: social-prod-litellm-config-job
restart: "no"
env_file:
- ./.env.prod
environment:
- PYTHONPATH=/app/backend/src
- PYTHONDONTWRITEBYTECODE=1
command: >
.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: 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:
[
"CMD",
"python",
"-c",
"import os,sys,urllib.request;port=os.getenv('SOCIAL_LITELLM__PORT','3875');u=f'http://127.0.0.1:{port}/health';sys.exit(0 if urllib.request.urlopen(u, timeout=3).getcode() < 500 else 1)",
]
interval: 15s
timeout: 5s
retries: 10
web:
image: ${SOCIAL_BACKEND_IMAGE:-social-app-backend:prod}
container_name: social-prod-web
@@ -80,8 +36,6 @@ services:
- SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod}
- SOCIAL_REDIS__HOST=redis
- SOCIAL_REDIS__PORT=6379
- SOCIAL_LITELLM__HOST=litellm
- SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875}
command: >
sh -c '.venv/bin/uvicorn app:app --host ${SOCIAL_WEB__HOST:-0.0.0.0} --port ${SOCIAL_WEB__PORT:-5775} --workers ${SOCIAL_WEB__WORKERS:-2} --log-level $(printf "%s" "${SOCIAL_RUNTIME__LOG_LEVEL:-info}" | tr "[:upper:]" "[:lower:]")'
ports:
@@ -89,8 +43,6 @@ services:
depends_on:
redis:
condition: service_healthy
litellm:
condition: service_healthy
volumes:
- ../logs:/app/logs
- ./static/releases:/app/deploy/static/releases:ro
@@ -119,15 +71,11 @@ services:
- SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod}
- SOCIAL_REDIS__HOST=redis
- SOCIAL_REDIS__PORT=6379
- SOCIAL_LITELLM__HOST=litellm
- SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875}
command: >
sh -c '.venv/bin/taskiq worker core.taskiq.app:critical_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__CRITICAL__CONCURRENCY:-2}'
depends_on:
redis:
condition: service_healthy
litellm:
condition: service_healthy
volumes:
- ../logs:/app/logs
- ./static/releases:/app/deploy/static/releases:ro
@@ -145,15 +93,11 @@ services:
- SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod}
- SOCIAL_REDIS__HOST=redis
- SOCIAL_REDIS__PORT=6379
- SOCIAL_LITELLM__HOST=litellm
- SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875}
command: >
sh -c '.venv/bin/taskiq worker core.taskiq.app:default_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__DEFAULT__CONCURRENCY:-2}'
depends_on:
redis:
condition: service_healthy
litellm:
condition: service_healthy
volumes:
- ../logs:/app/logs
- ./static/releases:/app/deploy/static/releases:ro
@@ -171,15 +115,11 @@ services:
- SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod}
- SOCIAL_REDIS__HOST=redis
- SOCIAL_REDIS__PORT=6379
- SOCIAL_LITELLM__HOST=litellm
- SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875}
command: >
sh -c '.venv/bin/taskiq worker core.taskiq.app:bulk_broker core.agentscope.runtime.tasks --workers ${SOCIAL_WORKER__GROUPS__BULK__CONCURRENCY:-1}'
depends_on:
redis:
condition: service_healthy
litellm:
condition: service_healthy
volumes:
- ../logs:/app/logs
- ./static/releases:/app/deploy/static/releases:ro
@@ -197,14 +137,10 @@ services:
- SOCIAL_RUNTIME__ENVIRONMENT=${SOCIAL_RUNTIME__ENVIRONMENT:-prod}
- SOCIAL_REDIS__HOST=redis
- SOCIAL_REDIS__PORT=6379
- SOCIAL_LITELLM__HOST=litellm
- SOCIAL_LITELLM__PORT=${SOCIAL_LITELLM__PORT:-3875}
command: .venv/bin/python -m core.runtime.cli bootstrap
depends_on:
redis:
condition: service_healthy
litellm:
condition: service_healthy
volumes:
- ../logs:/app/logs
- ./static/releases:/app/deploy/static/releases:ro
@@ -213,4 +149,3 @@ services:
volumes:
redis_data:
litellm_config:
+6 -17
View File
@@ -4,23 +4,12 @@
"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"
"version_code": 3,
"min_supported_version_code": 3,
"file_name": "social-app-android-v0.1.0+3-release.apk",
"release_notes": null,
"file_size": 21371568,
"sha256": "34691f96004b3dc3b2070d84ae0e7f0d2943f6c9978160eb78550081bc72a74a"
}
]
}
@@ -1,86 +0,0 @@
# 账户页与主页工具渲染统一表面语言 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 统一 `我的账户` 页面与主页 `ui_schema` 渲染的视觉语言,修正 STEP 协议映射并将等待文案从“正在思考”切换为协议阶段语义。
**Architecture:** 设置域继续复用 `AccountSurfaceScaffold + AccountSectionCard`;聊天域在 `UiSchemaRenderer` 中重做 surface 层级与按钮/徽章/KV 呈现,保持协议字段不变。将 stage 映射改为 `router/worker` 协议值,在 UI 显示为“意图识别中/任务执行中/任务处理中”。
**Tech Stack:** Flutter, flutter_bloc, design tokens (`AppColors/AppSpacing/AppRadius`), AG-UI SSE protocol mapping
---
### Task 1: 先补失败测试(Stage 文案与 UI Schema 呈现)
**Files:**
- Create: `apps/test/features/chat/presentation/agent_stage_mapping_test.dart`
- Modify: `apps/test/features/chat/ui_schema_renderer_test.dart`
**Step 1: 写失败测试**
- 新增 stage 映射测试,断言 `router -> intentLabel``worker -> executionLabel`、未知 -> processingLabel
- 扩展 `ui_schema_renderer_test.dart`,断言 card surface、按钮文案、badge 仍可渲染
**Step 2: 跑测试确认失败**
Run: `flutter test test/features/chat/presentation/agent_stage_mapping_test.dart test/features/chat/ui_schema_renderer_test.dart`
Expected: FAIL(映射函数/新视觉结构尚未实现)
**Step 3: 最小实现使测试通过**
- 新增 stage 映射文件并接入 chat/home
- 重构 `UiSchemaRenderer` 的 surface 风格并保持现有 schema 兼容
**Step 4: 再跑测试确认通过**
Run: `flutter test test/features/chat/presentation/agent_stage_mapping_test.dart test/features/chat/ui_schema_renderer_test.dart`
Expected: PASS
### Task 2: 重构我的账户页面为统一表面语言
**Files:**
- Modify: `apps/lib/features/settings/ui/screens/account_screen.dart`
**Step 1: 写失败测试(轻量)**
- 可选新增页面结构测试,断言标题/分组/退出按钮存在
**Step 2: 运行测试确认失败(若新增测试)**
Run: `flutter test test/features/settings/ui/screens/...`
Expected: FAIL
**Step 3: 最小实现**
-`AccountSurfaceScaffold` 承载页面
-`AccountSectionCard` 承载账户菜单与安全操作分组
- 退出登录按钮改为统一 token 风格
**Step 4: 测试通过**
Run: `flutter test test/features/settings/ui/screens/...`
Expected: PASS
### Task 3: 协议对齐与主页等待文案修正
**Files:**
- Modify: `apps/lib/features/chat/presentation/bloc/chat_bloc.dart`
- Modify: `apps/lib/features/home/ui/screens/home_screen.dart`
- Create: `apps/lib/features/chat/presentation/bloc/agent_stage.dart`
**Step 1: 保持协议语义**
- 将 step 映射改为文档约定:`router``worker`
- 显示文案:`意图识别中``任务执行中``任务处理中`
**Step 2: 运行相关测试**
Run: `flutter test test/features/chat/presentation/agent_stage_mapping_test.dart test/features/home/ui/widgets/home_screen_layout_test.dart`
Expected: PASS
### Task 4: 全量验证
**Step 1: 静态检查**
Run: `flutter analyze`
Expected: 无新增错误
**Step 2: 回归测试**
Run: `flutter test`
Expected: PASS
@@ -1,285 +0,0 @@
# Calendar Timezone Unification Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Eliminate calendar time mismatches by enforcing one end-to-end timezone policy across App input, Agent runtime context, tool execution, and UTC database storage.
**Architecture:** Keep database schema unchanged (`start_at/end_at TIMESTAMPTZ + timezone`) and enforce strict runtime normalization. Device timezone is injected from `RunAgentInput.forwardedProps`, resolved into a single `effective_timezone`, then written explicitly into tool arguments and persisted as event timezone while timestamps are stored in UTC. Calendar read responses include deterministic event-timezone-rendered values so frontend rendering is stable and no implicit `toLocal()` conversion remains.
**Tech Stack:** FastAPI, Pydantic v2, AgentScope runtime/tooling, Flutter (Dart), PostgreSQL TIMESTAMPTZ, pytest, Flutter test.
---
## Chunk 1: Protocol and Backend Runtime Normalization
### Task 1: Freeze protocol and timezone precedence contract
**Files:**
- Modify: `docs/protocols/agent/run-agent-input.md`
- Create: `docs/protocols/calendar/timezone-policy.md`
- [ ] **Step 1: Write protocol delta checklist in docs first**
Document the exact policy:
- `event_timezone > device_timezone > profile.timezone > UTC`
- `event_timezone` must be present in final tool call
- `start_at/end_at` must be timezone-aware
- DB stores UTC timestamps and IANA timezone string
- [ ] **Step 2: Update RunAgentInput protocol with forwardedProps contract**
Add canonical payload example:
```json
{
"forwardedProps": {
"client_time": {
"device_timezone": "America/Los_Angeles",
"client_now_iso": "2026-03-16T09:12:33-07:00",
"client_epoch_ms": 1773658353000
}
}
}
```
- [ ] **Step 3: Add calendar timezone policy protocol doc**
Include:
- accepted datetime formats
- explicit error codes
- write/read response semantics
- DST handling rule
- [ ] **Step 4: Verify docs consistency**
Run: `cd backend && uv run python -m pytest tests/unit/core/agentscope/test_system_prompt.py -q`
Expected: PASS (no protocol-breaking prompt assumptions)
### Task 2: Parse forwarded device time and compute effective timezone
**Files:**
- Modify: `backend/src/core/agentscope/schemas/agui_input.py`
- Modify: `backend/src/core/agentscope/runtime/runner.py`
- Modify: `backend/src/core/agentscope/prompts/system_prompt.py`
- Test: `backend/tests/unit/core/agentscope/test_system_prompt.py`
- [ ] **Step 1: Write failing tests for effective timezone resolution**
Add tests covering:
- forwarded `device_timezone` present -> selected
- missing forwarded timezone -> fallback profile timezone
- invalid forwarded timezone -> fallback profile timezone
- [ ] **Step 2: Run tests to confirm RED**
Run: `cd backend && uv run pytest tests/unit/core/agentscope/test_system_prompt.py -k timezone -v`
Expected: FAIL on new assertions
- [ ] **Step 3: Implement minimal runtime context extraction**
Implement a typed helper in runner path to read:
- `run_input.forwarded_props.client_time.device_timezone`
- `client_now_iso`
- `client_epoch_ms`
Compute `effective_timezone` using fixed precedence and pass it into `build_system_prompt(...)`.
- [ ] **Step 4: Inject effective_timezone into ENV section**
Update `build_system_prompt` env payload to include:
- `timezone_profile`
- `timezone_device`
- `timezone_effective`
Update guidance sentence to resolve ambiguous time with `timezone_effective`.
- [ ] **Step 5: Run tests to confirm GREEN**
Run: `cd backend && uv run pytest tests/unit/core/agentscope/test_system_prompt.py -v`
Expected: PASS
### Task 3: Remove timezone ambiguity and hidden fallbacks from calendar write
**Files:**
- Modify: `backend/src/core/agentscope/tools/utils/calendar_domain.py`
- Modify: `backend/src/core/agentscope/tools/custom/calendar.py`
- Modify: `backend/src/v1/schedule_items/schemas.py`
- Modify: `backend/src/v1/schedule_items/service.py`
- Test: `backend/tests/unit/core/agentscope/test_calendar_tools.py`
- Test: `backend/tests/unit/v1/schedule_items/test_schemas.py`
- Test: `backend/tests/unit/v1/schedule_items/test_service.py`
- [ ] **Step 1: Write failing tests for forbidden naive datetime and required timezone**
Add tests for:
- naive `start_at` rejected
- missing `event_timezone` rejected in tool path
- parse failure does not fallback to `now + 1h`
- [ ] **Step 2: Run tests to confirm RED**
Run: `cd backend && uv run pytest tests/unit/core/agentscope/test_calendar_tools.py tests/unit/v1/schedule_items/test_schemas.py -v`
Expected: FAIL on new constraints
- [ ] **Step 3: Implement strict parsing and normalization**
Implementation requirements:
- `parse_iso_datetime` rejects naive input
- remove default `Asia/Shanghai` in tool
- remove fallback auto-generated start time
- validate IANA timezone and normalize `start_at/end_at` to UTC before persistence
- [ ] **Step 4: Enforce service-level invariants**
Service invariant set:
- timezone non-empty and valid IANA
- `end_at is None or end_at >= start_at`
- [ ] **Step 5: Run backend tests**
Run: `cd backend && uv run pytest tests/unit/core/agentscope/test_calendar_tools.py tests/unit/v1/schedule_items/test_schemas.py tests/unit/v1/schedule_items/test_service.py tests/integration/test_schedule_items_routes.py -v`
Expected: PASS
### Task 4: Keep DB schema, add non-breaking constraint migration only
**Files:**
- Create: `backend/alembic/versions/20260316_000x_schedule_items_time_constraints.py`
- Test: `backend/tests/integration/test_schedule_items_routes.py`
- [ ] **Step 1: Write migration test expectation first**
Add/extend integration assertion for invalid `end_at < start_at` returning 422.
- [ ] **Step 2: Run integration test to confirm RED**
Run: `cd backend && uv run pytest tests/integration/test_schedule_items_routes.py -k end_at -v`
Expected: FAIL
- [ ] **Step 3: Implement migration with CHECK only (no new columns)**
Migration includes:
- `CHECK (end_at IS NULL OR end_at >= start_at)`
- [ ] **Step 4: Run migration + integration test**
Run: `cd backend && uv run alembic upgrade head && uv run pytest tests/integration/test_schedule_items_routes.py -v`
Expected: PASS
---
## Chunk 2: Frontend Deterministic Display and Agent Input Wiring
### Task 5: Wire device timezone into RunAgentInput forwardedProps
**Files:**
- Modify: `apps/lib/features/chat/data/services/ag_ui_service.dart`
- Modify: `apps/lib/features/chat/data/models/ag_ui_event.dart` (only if serialization helper is needed)
- Test: `apps/test/features/chat/ag_ui_event_test.dart`
- [ ] **Step 1: Write failing test for forwarded client_time payload**
Assert outgoing run request contains:
- `forwardedProps.client_time.device_timezone`
- `client_now_iso`
- `client_epoch_ms`
- [ ] **Step 2: Run test to confirm RED**
Run: `cd apps && flutter test test/features/chat/ag_ui_event_test.dart`
Expected: FAIL
- [ ] **Step 3: Implement payload injection in one place**
Add a single helper to build client time context and attach it to run input requests.
- [ ] **Step 4: Run test to confirm GREEN**
Run: `cd apps && flutter test test/features/chat/ag_ui_event_test.dart`
Expected: PASS
### Task 6: Remove implicit local-time rendering and render by event timezone
**Files:**
- Modify: `apps/lib/features/calendar/data/models/schedule_item_model.dart`
- Modify: `apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart`
- Modify: `apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart`
- Modify: `apps/lib/features/calendar/ui/screens/calendar_month_screen.dart`
- Modify: `apps/lib/features/messages/ui/widgets/calendar_message_card.dart`
- Modify: `apps/lib/features/calendar/ui/widgets/create_event_sheet.dart`
- Test: `apps/test/features/calendar/ui/calendar_time_utils_test.dart`
- Test: `apps/test/features/calendar/ui/create_event_sheet_time_align_test.dart`
- [ ] **Step 1: Write failing tests for timezone-specific rendering**
Cover cases:
- same UTC event shows different local clock time under different `event.timezone`
- list/day/week/month are consistent for one event
- create sheet sends explicit timezone in payload
- [ ] **Step 2: Run tests to confirm RED**
Run: `cd apps && flutter test test/features/calendar/ui/calendar_time_utils_test.dart test/features/calendar/ui/create_event_sheet_time_align_test.dart`
Expected: FAIL
- [ ] **Step 3: Implement deterministic time conversion utility**
Implement one utility used by all calendar UI surfaces:
- input: UTC datetime + IANA timezone
- output: event-local datetime
Replace direct `.toLocal()` usage in calendar model/view with this utility.
- [ ] **Step 4: Enforce explicit timezone on create/update payload**
Create/update must always include `timezone` field from selected event timezone.
- [ ] **Step 5: Run Flutter tests**
Run: `cd apps && flutter test test/features/calendar/ui/calendar_time_utils_test.dart test/features/calendar/ui/create_event_sheet_time_align_test.dart`
Expected: PASS
### Task 7: End-to-end verification matrix and release checklist
**Files:**
- Modify: `docs/plans/timezone-e2e-checklist.md`
- Test: `backend/tests/integration/test_schedule_items_routes.py`
- Test: `apps/test/features/calendar/ui/create_event_sheet_time_align_test.dart`
- [ ] **Step 1: Add reproducible matrix**
Matrix axes:
- device timezone: `America/Los_Angeles`, `Asia/Shanghai`
- profile timezone: `Asia/Shanghai`, `Europe/Paris`
- explicit event timezone: `Asia/Tokyo`
- [ ] **Step 2: Run backend + frontend verification commands**
Run:
- `cd backend && uv run pytest tests/unit/core/agentscope/test_system_prompt.py tests/unit/core/agentscope/test_calendar_tools.py tests/integration/test_schedule_items_routes.py -v`
- `cd apps && flutter test test/features/chat/ag_ui_event_test.dart test/features/calendar/ui/calendar_time_utils_test.dart test/features/calendar/ui/create_event_sheet_time_align_test.dart`
Expected: all PASS
- [ ] **Step 3: Manual scenario check**
Manual script:
1. device timezone set to Los Angeles
2. profile timezone set to Shanghai
3. ask agent create "明天上午9点开会"
4. verify assistant text, tool card, DB UTC value, and calendar detail all align to chosen event timezone semantics
- [ ] **Step 4: Capture release notes**
Record:
- removed hidden timezone defaults
- deterministic precedence
- no schema expansion
---
Plan complete and saved to `docs/superpowers/plans/2026-03-16-calendar-timezone-unification.md`. Ready to execute?
@@ -1,99 +0,0 @@
# 设置-账户子页面视觉重构设计(编辑资料 / 修改密码)
## 背景与目标
当前 `编辑资料``修改密码` 子页面存在明显的“文档页/默认表单堆叠”观感,未满足 `apps/rules/visual_design_language.md` 要求的“高级、柔和、分层、助手产品感”。
本次设计目标:
- 延续项目现有蓝灰体系,不改变品牌语气
- 建立清晰的表面层级:背景层 -> 主内容层 -> 次级分组层 -> 交互强调层
-`修改密码` 对齐 `忘记密码` 的设计语言(分段、状态提示、密码输入体验),但保持“设置域”语义
- 严格使用 `AppColors` / `AppSpacing` / `AppRadius`,移除新增硬编码视觉值
## 约束来源
- `apps/AGENTS.md`
- `apps/rules/visual_design_language.md`
- 现有共享组件体系(`AppButton``AppBanner``FixedLengthCodeInput`、Toast 系统)
## 方案选择
已确认采用方案 B:设置域统一壳层。
方案要点:
- 在 settings feature 内建立可复用的页面壳与分组卡片语义
- 两个子页面共享同一结构语言与间距节奏
- `修改密码` 采用步骤型结构(Step 1 验证邮箱 / Step 2 设置新密码)
## 信息架构
### 编辑资料
- 页面头部:返回 + 标题 + 简短辅助说明
- 主内容卡:
- 资料概览组(头像占位、当前账号信息)
- 基础信息组(用户名输入)
- 个人简介组(多行输入 + 字数)
- 底部强调操作区:`保存修改` 按钮(仅在有变更且可提交时可用)
### 修改密码(步骤型)
- 页面头部:返回 + 标题 + 简短辅助说明
- Step 1:邮箱验证与验证码发送
- 展示当前邮箱
- 发送/重发验证码 + 倒计时
- Step 2:输入验证码并设置新密码
- 验证码输入
- 新密码、确认密码
- 状态提示(使用 `AppBanner`
- 底部强调操作区:`确认修改`
## 视觉与交互规则
- 背景:`AppColors.surfaceSecondary`
- 主卡:`AppColors.white` + `AppColors.borderSecondary` + 圆角 `AppRadius.lg/xl`
- 次级分组:`AppColors.surfaceTertiary/surfaceInfoLight`(按语义使用)+ `AppColors.borderTertiary`
- 输入聚焦态:`AppColors.blue500`
- 间距节奏:
- 页面外边距:`AppSpacing.xl`
- 组内:`AppSpacing.sm/lg`
- 组间:`AppSpacing.xxl`
- 动效策略:仅保留柔和状态反馈(按钮 loading/disabled、步骤区显隐、输入 focus),不添加噱头动画
## 组件与复用策略
- 优先复用:
- `AppButton`
- `FixedLengthCodeInput`
- `AppBanner`
- Toast 系统
- `PasswordField`(与忘记密码页统一密码输入体验)
- 在 settings 域新增可复用 UI
- `account_surface_scaffold.dart`
- `account_section_card.dart`
## 影响文件
- 修改:`apps/lib/features/settings/ui/screens/edit_profile_screen.dart`
- 修改:`apps/lib/features/settings/ui/screens/change_password_screen.dart`
- 新增:`apps/lib/features/settings/ui/widgets/account_surface_scaffold.dart`
- 新增:`apps/lib/features/settings/ui/widgets/account_section_card.dart`
- 可能补充:`apps/lib/core/theme/design_tokens.dart`(仅缺失 token 时)
## 验收标准
- 两个页面具备一致的“柔和卡片分层”结构
- 修改密码页面与忘记密码页面在设计语言上可感知一致
- 不出现“纯白文档页/后台表单页”观感
- 不新增视觉硬编码,遵守 token 体系
- 核心交互逻辑不回归(验证码、倒计时、提交、错误提示)
## 验证计划
- `flutter analyze`
- `flutter test`(至少覆盖 auth/reset-password 相关与 settings 相关用例)
- 手工验证:
- 编辑资料:无改动禁用、改动后可保存、成功后返回
- 修改密码:发送验证码、倒计时、错误提示、成功返回
@@ -1,151 +0,0 @@
# 设置账户子页面视觉重构 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 重构设置页中的编辑资料与修改密码子页面,使其符合视觉设计语言并统一为柔和卡片分层风格。
**Architecture:** 在 settings feature 内新增复用型页面壳与分组卡组件,统一两页的表面层级、间距节奏与交互强调区;修改密码页面借鉴忘记密码页面的步骤分段与状态提示语义,但保留设置域信息架构与文案。保持现有业务逻辑不变,优先做样式与结构重构。
**Tech Stack:** Flutter, go_router, flutter_bloc, formz, design tokens (`AppColors/AppSpacing/AppRadius`), shared widgets (`AppButton`, `AppBanner`, `FixedLengthCodeInput`, Toast)
---
### Task 1: 创建设置域复用 UI 壳层组件
**Files:**
- Create: `apps/lib/features/settings/ui/widgets/account_surface_scaffold.dart`
- Create: `apps/lib/features/settings/ui/widgets/account_section_card.dart`
**Step 1: 写一个失败的基础渲染测试(可选,若项目已有 widget test 基础)**
```dart
testWidgets('AccountSectionCard renders title and child', (tester) async {
await tester.pumpWidget(...);
expect(find.text('标题'), findsOneWidget);
});
```
**Step 2: 运行测试确认失败(若执行 Step 1)**
Run: `flutter test apps/test/... -r expanded`
Expected: FAIL(新组件不存在)
**Step 3: 实现最小组件**
- `AccountSurfaceScaffold`:统一背景、头部、滚动主内容、可选底部强调区
- `AccountSectionCard`:统一分组卡片容器(标题、描述、child)
**Step 4: 运行测试确认通过(若执行 Step 1)**
Run: `flutter test apps/test/... -r expanded`
Expected: PASS
**Step 5: Commit**
```bash
git add apps/lib/features/settings/ui/widgets/account_surface_scaffold.dart apps/lib/features/settings/ui/widgets/account_section_card.dart
git commit -m "feat: add reusable account surface widgets"
```
### Task 2: 重构编辑资料页面为柔和卡片分层
**Files:**
- Modify: `apps/lib/features/settings/ui/screens/edit_profile_screen.dart`
**Step 1: 写失败测试(仅在当前测试基建允许时)**
```dart
testWidgets('Edit profile shows grouped sections and disabled save initially', (tester) async {
// verify section titles and save button disabled by default
});
```
**Step 2: 运行测试确认失败(若执行 Step 1)**
Run: `flutter test apps/test/... -r expanded`
Expected: FAIL(旧结构不满足)
**Step 3: 实现最小重构**
- 接入 `AccountSurfaceScaffold`
- 将头像区、用户名区、简介区改为分组卡语义
- 统一输入风格(token 化)
- 底部强调操作区承载 `保存修改`
**Step 4: 运行测试确认通过(若执行 Step 1)**
Run: `flutter test apps/test/... -r expanded`
Expected: PASS
**Step 5: Commit**
```bash
git add apps/lib/features/settings/ui/screens/edit_profile_screen.dart
git commit -m "feat: redesign edit profile screen with layered surfaces"
```
### Task 3: 重构修改密码页面为步骤型结构
**Files:**
- Modify: `apps/lib/features/settings/ui/screens/change_password_screen.dart`
- Reference: `apps/lib/features/auth/ui/screens/reset_password_screen.dart`
**Step 1: 写失败测试(仅在当前测试基建允许时)**
```dart
testWidgets('Change password renders step sections and submit state', (tester) async {
// verify step titles and CTA disabled before code sent
});
```
**Step 2: 运行测试确认失败(若执行 Step 1)**
Run: `flutter test apps/test/... -r expanded`
Expected: FAIL(步骤结构不存在)
**Step 3: 实现最小重构**
- 接入 `AccountSurfaceScaffold` + `AccountSectionCard`
- 按 Step 1/Step 2 分段展示(邮箱验证 -> 验证码与新密码)
- 引入 `AppBanner` 做状态提示
- 保持验证码、倒计时、提交逻辑不变
**Step 4: 运行测试确认通过(若执行 Step 1)**
Run: `flutter test apps/test/... -r expanded`
Expected: PASS
**Step 5: Commit**
```bash
git add apps/lib/features/settings/ui/screens/change_password_screen.dart
git commit -m "feat: redesign change password flow with step-based sections"
```
### Task 4: 验证与文档同步
**Files:**
- Modify (if needed): `docs/plans/2026-03-16-settings-account-subpages-design.md`
**Step 1: 运行静态检查**
Run: `flutter analyze`
Expected: PASS
**Step 2: 运行回归测试**
Run: `flutter test`
Expected: PASS
**Step 3: 手工验收**
- 编辑资料:无变更禁用、有变更可保存
- 修改密码:发码、倒计时、错误提示、成功返回
- 视觉层级:背景/主卡/分组/强调区清晰
**Step 4: Commit**
```bash
git add docs/plans/2026-03-16-settings-account-subpages-design.md
git commit -m "docs: finalize account subpages redesign validation notes"
```
@@ -0,0 +1,227 @@
# 自动化记忆任务设计方案(v1
## 1. 背景与目标
本方案用于落地后端自动化记忆任务:
- 提供每日/每周自动执行能力;
- 自动提取用户近期对话中的可沉淀记忆并写入 `memories`
- 支持“忘记”操作;
- 复用现有 Agent 运行链路,避免并行维护两套运行时;
- 保证可审计、可追溯,同时对用户界面隐藏自动输入提示词。
## 2. 范围与非目标
### 2.1 范围
- `automation_jobs` 增加 `config`JSONB,强约束);
- 调度器执行 `automation_jobs`,按用户本地时区计算执行时间;
- 自动任务复用现有 `router -> agent` 运行策略;
- 自动输入落库但对用户不可见;
- 工具集按任务配置分发,仅允许白名单工具;
- 正式启用 `memory_prompt`,将 `memories` 注入系统提示词。
### 2.2 非目标
- 不开放复杂 DSL 编排;
- 不开放细粒度策略配置(如 `execution_profile/history_window_days/memory_policy`);
- 不引入第二套独立 Agent 运行框架。
## 3. 关键决策
1. **调度与执行分离**
- `run_at/next_run_at/timezone` 负责“何时执行”;
- `config` 负责“如何执行”(提示词与工具集)。
2. **配置最小化**
- `automation_jobs.config` 仅保留:
- `prompt: string`
- `tools: string[]`
3. **自动输入可审计但用户不可见**
- 自动任务提示词作为“用户消息”写入 `messages`
- 在 metadata 加可见性标记并在用户历史接口中默认过滤;
- 审计路径可查询完整消息链路。
4. **时区策略**
- 调度语义采用“用户本地时区 10:00”;
- 存储执行时间统一为 UTC 的 `next_run_at`
- 调度器仅按 UTC 扫描,降低运维复杂度。
## 4. 数据模型设计
## 4.1 `automation_jobs` 调整
- 保留字段:`id`, `owner_id`, `title`, `schedule_type`, `run_at`, `next_run_at`, `timezone`, `last_run_at`, `status` 等;
- 新增字段:`config JSONB NOT NULL`
- 迁移策略:
- 将历史 `prompt` 迁移到 `config.prompt`
- 切换业务代码后删除旧 `prompt` 字段(可分两步灰度)。
### 4.2 `config` 强约束 schema
```json
{
"prompt": "提取最近对话中的稳定记忆并写入",
"tools": ["memory_write", "memory_forget"]
}
```
约束要求:
- `prompt`:必填、非空、长度受限(建议 <= 2000);
- `tools`:必填数组,元素为注册工具名;
- `extra="forbid"`,拒绝未知字段;
- 执行前二次校验:`tools` 必须与后端 allowlist 取交集,禁止越权。
### 4.3 `messages.metadata` 扩展
新增建议字段:
- `hidden_from_user: bool`
- `origin: "chat" | "automation"`
用途:
- `hidden_from_user=true` 的消息在用户历史默认不可见;
- 审计查询可读取全量。
## 5. 运行时架构
## 5.1 复用现有 Agent 链路
自动任务不新建运行时,沿用现有 `router -> orchestrator -> worker` 流程:
- 输入:来自 `automation_jobs.config.prompt` 的合成“用户输入”;
- 上下文:默认加载“今天 + 昨天”历史;
- 工具:仅启用 `config.tools` 且通过 allowlist 校验。
## 5.2 会话与消息策略
- 自动任务写入独立 `session_type=automation` 会话,并关联 `job_id`
- 普通对话维持 `session_type=chat`
- 自动输入消息:`role=user``hidden_from_user=true`
- Assistant 输出消息:默认可见。
## 6. 调度器设计
## 6.1 扫描与触发
- 周期扫描条件:
- `status='active'`
- `next_run_at <= now_utc`
- 命中后入队执行命令(复用现有任务队列)。
## 6.2 幂等与并发控制
- 使用任务槽位去重键:`job_id + scheduled_slot`
- 使用行级抢占或原子更新防止并发重复执行;
- 失败可重试并记录错误上下文。
## 6.3 下次执行时间计算
- 每次执行完成后按 `schedule_type + timezone` 计算下一次触发时间;
- 保存到 `next_run_at`UTC);
- `last_run_at` 记录实际执行时间。
## 7. 默认任务创建
用户创建后自动创建一条默认 daily 记忆任务:
- `schedule_type = 'daily'`
- 本地时区目标时间:10:00
- `timezone`:优先用户设置时区,缺省 `Asia/Shanghai`
- `config.prompt`:内置记忆提取提示词模板
- `config.tools = ["memory_write", "memory_forget"]`
## 8. 记忆工具设计
## 8.1 `memory_write`
- 用途:写入/更新用户记忆;
- 输入:受 schema 限制(标题、内容、来源等);
- 安全:owner 作用域强制绑定,不信任模型输入的 owner 信息。
## 8.2 `memory_forget`
- 用途:失效或删除用户记忆;
- 输入:`memory_id` 或受限条件;
- 安全:仅允许当前 owner 范围内操作。
## 8.3 工具授权
- 任务声明工具集来自 `config.tools`
- 运行时执行 `declared_tools ∩ allowlist`
- 非白名单工具直接拒绝并记录审计日志。
## 9. `memory_prompt` 启用策略
- 从数据库读取当前用户可用 `memories`
- 组装为系统提示词 memory section
- 设置条数与长度上限,避免 token 膨胀;
- 自动任务与普通对话统一使用该注入机制。
## 10. 前端展示策略
- 历史消息接口默认过滤 `hidden_from_user=true`
- 用户仅看到 assistant 输出(以及可选系统摘要,不包含原始自动 prompt);
- 前端无需大量工具分支 if/else,可统一按 metadata 标记处理。
## 11. 安全与审计
- 关键要求:
- 禁止越权工具调用;
- 禁止跨用户 memory 读写;
- 日志不输出敏感数据和完整私密上下文。
- 审计能力:
- 自动输入、工具调用、输出全链路可追踪;
- 用户界面只显示允许可见内容。
## 12. 迁移与发布步骤
1. 新增 `config` 字段与 schema 代码;
2. 数据迁移:旧 `prompt -> config.prompt`
3. 运行时切换到 `config.prompt/config.tools`
4. 灰度验证后移除旧 `prompt`
5. 上线调度器与默认任务创建;
6. 启用 `memory_prompt` 并完成联调。
## 13. 测试与验收
### 13.1 单元测试
- `config` schema 校验(非法字段、非法工具、空 prompt);
- `next_run_at` 计算(跨时区、边界时间);
- 隐藏消息过滤逻辑;
- `memory_write/memory_forget` owner 边界。
### 13.2 集成测试
- 用户创建触发默认 daily 任务创建;
- 调度触发 -> Agent 执行 -> memory 写入 -> `next_run_at` 更新;
- 自动输入在审计可见、在用户历史不可见。
### 13.3 验收标准
- 每日 10:00(用户本地时区)稳定执行;
- 自动化链路有完整审计;
- 工具授权边界有效;
- 前端历史展示符合“隐藏自动输入、展示输出”的预期。
## 14. 实施顺序建议
1. 先更新协议文档(`docs/protocols`)明确契约;
2. 数据库与 schema 改造(`automation_jobs.config`);
3. 工具集与授权边界实现;
4. 调度器与默认任务创建;
5. `memory_prompt` 注入与端到端验证;
6. 前端过滤逻辑收口。
---
本方案是最小可行版本(MVP)设计,重点保障:
- 复用现有架构;
- 低配置复杂度;
- 可审计与可维护并存;
- 为后续扩展保留演进空间。
+1
View File
@@ -0,0 +1 @@
- 语音识别计费
@@ -0,0 +1,41 @@
# Worker Token/Latency Optimization TODO
Date: 2026-03-17
Owner: backend runtime
Status: pending
## Background
- Router cost/latency is acceptable.
- Worker stage (deepseek-chat) has significantly higher input tokens and latency.
- Current optimization work is deferred due to prioritization.
## Observations (from `public.messages`)
- Worker avg input tokens are much higher than router (about 12k+ vs 3k).
- Worker avg latency is much higher than router (about 41s vs 4s).
- Worker cost dominates total cost.
## Root Cause Hypothesis
- Worker ReAct path repeatedly includes full tool schemas per model call.
- `calendar_write` tool schema is large and contributes major prompt overhead.
- Finalize JSON step performs an additional model call after ReAct.
## Deferred Optimization Items
1. Tool schema slimming for calendar write path.
- Split `calendar_write` into focused tools (`calendar_create`, `calendar_update`, `calendar_delete`).
- Reduce redundant/verbose field descriptions where possible.
2. Dynamic tool set exposure by routed intent.
- Only expose tools needed for current task.
3. Evaluate finalize overhead.
- Verify whether finalize call can be reduced or replaced in specific flows.
4. Add before/after benchmark script.
- Compare worker `input_tokens`, `latency_ms`, and `cost` for the same scripted multi-turn scenario.
## Acceptance Metrics (target)
- Reduce worker input tokens by >= 30% in multi-turn calendar CRUD scenario.
- Reduce worker p95 latency by >= 25%.
- Keep functional behavior unchanged for agent runs.
-45
View File
@@ -19,50 +19,6 @@ 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: ../..
@@ -91,4 +47,3 @@ services:
volumes:
redis_data:
litellm_config:
+2 -2
View File
@@ -116,7 +116,7 @@ kill_listening_processes() {
start() {
echo "=== App Up ==="
echo "This script starts local web + worker processes in tmux."
echo "LiteLLM/Redis should be managed separately as long-running docker services."
echo "Redis should be managed separately as a long-running docker service."
echo "NOTE: Bootstrap (migrate + init-data) must be run separately."
echo ""
@@ -184,7 +184,7 @@ ${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)."
echo "Stopping tmux app processes (docker redis is not managed here)."
load_env_if_exists
WEB_PORT="${SOCIAL_WEB__PORT:-5775}"