diff --git a/.env.example b/.env.example index 664b60a..ded410f 100644 --- a/.env.example +++ b/.env.example @@ -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 配置 ############ diff --git a/apps/android/app/src/main/AndroidManifest.xml b/apps/android/app/src/main/AndroidManifest.xml index 3f1c726..9ebc80d 100644 --- a/apps/android/app/src/main/AndroidManifest.xml +++ b/apps/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,8 @@ + + + diff --git a/apps/lib/core/notifications/local_notification_service.dart b/apps/lib/core/notifications/local_notification_service.dart index 996306a..62f92ba 100644 --- a/apps/lib/core/notifications/local_notification_service.dart +++ b/apps/lib/core/notifications/local_notification_service.dart @@ -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 cancelEventReminder(String eventId) async { diff --git a/apps/lib/core/router/app_router.dart b/apps/lib/core/router/app_router.dart index bc5ddd8..498ff68 100644 --- a/apps/lib/core/router/app_router.dart +++ b/apps/lib/core/router/app_router.dart @@ -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', diff --git a/apps/lib/features/auth/ui/screens/auth_boot_screen.dart b/apps/lib/features/auth/ui/screens/auth_boot_screen.dart new file mode 100644 index 0000000..81c62af --- /dev/null +++ b/apps/lib/features/auth/ui/screens/auth_boot_screen.dart @@ -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, + ), + ), + ), + ); + } +} diff --git a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart index 44d753b..686d0e2 100644 --- a/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart +++ b/apps/lib/features/calendar/ui/widgets/create_event_sheet.dart @@ -638,7 +638,11 @@ class _CreateEventSheetState extends State try { final notificationService = sl(); await notificationService.upsertEventReminder(saved); - } catch (_) {} + } catch (_) { + if (mounted) { + Toast.show(context, '提醒创建失败,请检查通知权限', type: ToastType.warning); + } + } widget.onSaved?.call(); if (mounted) { diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 17ec058..373b750 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -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 diff --git a/backend/scripts/build_litellm_proxy_config.py b/backend/scripts/build_litellm_proxy_config.py deleted file mode 100644 index 55a75fa..0000000 --- a/backend/scripts/build_litellm_proxy_config.py +++ /dev/null @@ -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()) diff --git a/backend/src/core/agentscope/runtime/runner.py b/backend/src/core/agentscope/runtime/runner.py index 037fc7b..22fe855 100644 --- a/backend/src/core/agentscope/runtime/runner.py +++ b/backend/src/core/agentscope/runtime/runner.py @@ -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) diff --git a/backend/src/services/litellm/__init__.py b/backend/src/services/litellm/__init__.py index 83c508e..8ea2ef7 100644 --- a/backend/src/services/litellm/__init__.py +++ b/backend/src/services/litellm/__init__.py @@ -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"] diff --git a/backend/src/services/litellm/service.py b/backend/src/services/litellm/service.py index 5ff86e7..6640479 100644 --- a/backend/src/services/litellm/service.py +++ b/backend/src/services/litellm/service.py @@ -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") diff --git a/backend/tests/unit/core/agentscope/runtime/test_runner.py b/backend/tests/unit/core/agentscope/runtime/test_runner.py index f0d32da..8ba65ba 100644 --- a/backend/tests/unit/core/agentscope/runtime/test_runner.py +++ b/backend/tests/unit/core/agentscope/runtime/test_runner.py @@ -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") diff --git a/backend/tests/unit/services/test_litellm_service.py b/backend/tests/unit/services/test_litellm_service.py index d03b4cf..e41f822 100644 --- a/backend/tests/unit/services/test_litellm_service.py +++ b/backend/tests/unit/services/test_litellm_service.py @@ -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() diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example index 805e86d..24a6bd0 100644 --- a/deploy/.env.prod.example +++ b/deploy/.env.prod.example @@ -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 配置 ############ diff --git a/deploy/README.md b/deploy/README.md index fcf819b..abd9c52 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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` 为一次性任务,不长期驻留。 diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 17e258f..4e78376 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -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: diff --git a/deploy/static/releases/manifest.json b/deploy/static/releases/manifest.json index 5abab9b..7807d3a 100644 --- a/deploy/static/releases/manifest.json +++ b/deploy/static/releases/manifest.json @@ -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" } ] } diff --git a/docs/plans/2026-03-16-account-and-chat-surface-language-implementation.md b/docs/plans/2026-03-16-account-and-chat-surface-language-implementation.md deleted file mode 100644 index 29a22c1..0000000 --- a/docs/plans/2026-03-16-account-and-chat-surface-language-implementation.md +++ /dev/null @@ -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 diff --git a/docs/plans/2026-03-16-calendar-timezone-unification.md b/docs/plans/2026-03-16-calendar-timezone-unification.md deleted file mode 100644 index 6c48811..0000000 --- a/docs/plans/2026-03-16-calendar-timezone-unification.md +++ /dev/null @@ -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? diff --git a/docs/plans/2026-03-16-settings-account-subpages-design.md b/docs/plans/2026-03-16-settings-account-subpages-design.md deleted file mode 100644 index 8439bd1..0000000 --- a/docs/plans/2026-03-16-settings-account-subpages-design.md +++ /dev/null @@ -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 相关用例) -- 手工验证: - - 编辑资料:无改动禁用、改动后可保存、成功后返回 - - 修改密码:发送验证码、倒计时、错误提示、成功返回 diff --git a/docs/plans/2026-03-16-settings-account-subpages-implementation.md b/docs/plans/2026-03-16-settings-account-subpages-implementation.md deleted file mode 100644 index 4827fd6..0000000 --- a/docs/plans/2026-03-16-settings-account-subpages-implementation.md +++ /dev/null @@ -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" -``` diff --git a/docs/plans/2026-03-17-automation-memory-design.md b/docs/plans/2026-03-17-automation-memory-design.md new file mode 100644 index 0000000..b343842 --- /dev/null +++ b/docs/plans/2026-03-17-automation-memory-design.md @@ -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)设计,重点保障: + +- 复用现有架构; +- 低配置复杂度; +- 可审计与可维护并存; +- 为后续扩展保留演进空间。 diff --git a/docs/todo/2026-03-17-asr.md b/docs/todo/2026-03-17-asr.md new file mode 100644 index 0000000..7730c05 --- /dev/null +++ b/docs/todo/2026-03-17-asr.md @@ -0,0 +1 @@ +- 语音识别计费 diff --git a/docs/todo/2026-03-17-worker-token-latency-optimization.md b/docs/todo/2026-03-17-worker-token-latency-optimization.md new file mode 100644 index 0000000..1b7772d --- /dev/null +++ b/docs/todo/2026-03-17-worker-token-latency-optimization.md @@ -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. diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml index 16f4fba..9aaa9e3 100644 --- a/infra/docker/docker-compose.yml +++ b/infra/docker/docker-compose.yml @@ -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: diff --git a/infra/scripts/app.sh b/infra/scripts/app.sh index e2ea733..83714c2 100755 --- a/infra/scripts/app.sh +++ b/infra/scripts/app.sh @@ -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}"