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:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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` 为一次性任务,不长期驻留。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)设计,重点保障:
|
||||
|
||||
- 复用现有架构;
|
||||
- 低配置复杂度;
|
||||
- 可审计与可维护并存;
|
||||
- 为后续扩展保留演进空间。
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user