refactor(agent): restructure visibility masks, task queues, and memory service

Visibility mask refactoring:
- Replace dead UI_REALTIME bit with CONTEXT_ASSEMBLY (bit 1)
- Remove visibility_consumer_bit from SystemAgentLLMConfig and system_agents.yaml
- Simplify _resolve_user_message_visibility_mask: chat->UI_HISTORY|CONTEXT_ASSEMBLY, automation->0
- Simplify _resolve_stage_visibility_mask: memory->UI_HISTORY, router/worker->UI_HISTORY|CONTEXT_ASSEMBLY
- Remove stage_visibility_bit_map from store.py

Task queue renaming:
- Replace default_broker/bulk_broker/critical_broker with worker_agent_broker/worker_automation_broker
- Queue names: 'default'/'bulk'/'critical' -> 'agent'/'automation'
- Rename run_command_task -> run_command_task_agent/run_command_task_automation
- AgentService derives queue from runtime_mode: chat->agent, automation->automation

Architecture cleanup:
- Move context_service.py from runtime/ to agentscope/services/
- Add MemoryService in v1/memory/ following repository/service pattern
- Move consumer_registry.py and pipeline_spec.py from schemas/agent to agentscope/schemas/
- Delete dead code: registry_builder.py, VisibilityBitRef
- Delete superseded plan docs
This commit is contained in:
zl-q
2026-03-22 20:35:55 +08:00
parent 20b9e70e84
commit 80ad5141a6
37 changed files with 628 additions and 2428 deletions
+7 -51
View File
@@ -8,12 +8,10 @@ from core.agentscope.events.persistence import MessageRepository, SessionReposit
from core.logging import get_logger from core.logging import get_logger
from models.agent_chat_message import AgentChatMessageRole from models.agent_chat_message import AgentChatMessageRole
from models.agent_chat_session import AgentChatSessionStatus from models.agent_chat_session import AgentChatSessionStatus
from models.system_agents import SystemAgents from schemas.agent.system_agent import AgentType
from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig
from schemas.agent.runtime_models import AgentOutput, ToolAgentOutput from schemas.agent.runtime_models import AgentOutput, ToolAgentOutput
from schemas.agent.visibility import SystemVisibilityBit, bit_mask from schemas.agent.visibility import SystemVisibilityBit, bit_mask
from schemas.messages.chat_message import AgentChatMessageMetadata from schemas.messages.chat_message import AgentChatMessageMetadata
from sqlalchemy import select
class EventStore(Protocol): class EventStore(Protocol):
@@ -48,9 +46,6 @@ class SqlAlchemyEventStore:
async with self._session_factory() as session: async with self._session_factory() as session:
session_repo = SessionRepository(session) session_repo = SessionRepository(session)
message_repo = MessageRepository(session) message_repo = MessageRepository(session)
stage_visibility_bit_map = await self._load_stage_visibility_bit_map(
session=session
)
chat_session = await session_repo.get_session(session_id=session_id) chat_session = await session_repo.get_session(session_id=session_id)
if chat_session is None: if chat_session is None:
return return
@@ -83,7 +78,6 @@ class SqlAlchemyEventStore:
chat_session=chat_session, chat_session=chat_session,
session_repo=session_repo, session_repo=session_repo,
message_repo=message_repo, message_repo=message_repo,
stage_visibility_bit_map=stage_visibility_bit_map,
) )
elif event_type == "TOOL_CALL_RESULT": elif event_type == "TOOL_CALL_RESULT":
await self._persist_tool_call_result( await self._persist_tool_call_result(
@@ -92,7 +86,6 @@ class SqlAlchemyEventStore:
chat_session=chat_session, chat_session=chat_session,
session_repo=session_repo, session_repo=session_repo,
message_repo=message_repo, message_repo=message_repo,
stage_visibility_bit_map=stage_visibility_bit_map,
) )
await session.commit() await session.commit()
@@ -105,7 +98,6 @@ class SqlAlchemyEventStore:
chat_session: Any, chat_session: Any,
session_repo: SessionRepository, session_repo: SessionRepository,
message_repo: MessageRepository, message_repo: MessageRepository,
stage_visibility_bit_map: dict[str, int],
) -> None: ) -> None:
message_id_raw = self._event_value(event, "messageId") message_id_raw = self._event_value(event, "messageId")
message_id = message_id_raw if isinstance(message_id_raw, str) else "" message_id = message_id_raw if isinstance(message_id_raw, str) else ""
@@ -146,17 +138,7 @@ class SqlAlchemyEventStore:
try: try:
worker_output = AgentOutput.model_validate(worker_output_payload) worker_output = AgentOutput.model_validate(worker_output_payload)
raw_agent_type = self._event_value(event, "stage") agent_type = AgentType.WORKER
normalized_agent_type = (
str(raw_agent_type).strip().lower()
if isinstance(raw_agent_type, str)
else AgentType.WORKER.value
)
agent_type = (
AgentType.MEMORY
if normalized_agent_type == AgentType.MEMORY.value
else AgentType.WORKER
)
metadata_model = AgentChatMessageMetadata( metadata_model = AgentChatMessageMetadata(
run_id=run_id_value, run_id=run_id_value,
agent_type=agent_type, agent_type=agent_type,
@@ -199,7 +181,6 @@ class SqlAlchemyEventStore:
latency_ms=latency_ms, latency_ms=latency_ms,
visibility_mask=self._resolve_stage_visibility_mask( visibility_mask=self._resolve_stage_visibility_mask(
event=event, event=event,
stage_visibility_bit_map=stage_visibility_bit_map,
), ),
) )
@@ -226,7 +207,6 @@ class SqlAlchemyEventStore:
chat_session: Any, chat_session: Any,
session_repo: SessionRepository, session_repo: SessionRepository,
message_repo: MessageRepository, message_repo: MessageRepository,
stage_visibility_bit_map: dict[str, int],
) -> None: ) -> None:
run_id = self._event_value(event, "runId") run_id = self._event_value(event, "runId")
run_id_value = run_id if isinstance(run_id, str) and run_id else None run_id_value = run_id if isinstance(run_id, str) and run_id else None
@@ -272,7 +252,6 @@ class SqlAlchemyEventStore:
metadata=metadata_model.model_dump(mode="json", exclude_none=True), metadata=metadata_model.model_dump(mode="json", exclude_none=True),
visibility_mask=self._resolve_stage_visibility_mask( visibility_mask=self._resolve_stage_visibility_mask(
event=event, event=event,
stage_visibility_bit_map=stage_visibility_bit_map,
), ),
) )
@@ -301,39 +280,16 @@ class SqlAlchemyEventStore:
self, self,
*, *,
event: dict[str, Any], event: dict[str, Any],
stage_visibility_bit_map: dict[str, int],
) -> int: ) -> int:
base = bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY))
raw_stage = self._event_value(event, "stage") raw_stage = self._event_value(event, "stage")
if not isinstance(raw_stage, str): if not isinstance(raw_stage, str):
return base return bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY))
normalized_stage = raw_stage.strip().lower() normalized_stage = raw_stage.strip().lower()
bit = stage_visibility_bit_map.get(normalized_stage) if normalized_stage == "memory":
if bit is None and normalized_stage == AgentType.MEMORY.value: return bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY))
bit = 18 return bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)) | bit_mask(
if bit is None: bit=int(SystemVisibilityBit.CONTEXT_ASSEMBLY)
return base
return base | bit_mask(bit=bit)
async def _load_stage_visibility_bit_map(
self,
*,
session: Any,
) -> dict[str, int]:
stmt = select(SystemAgents.agent_type, SystemAgents.config).where(
SystemAgents.agent_type.in_(
[AgentType.ROUTER.value, AgentType.WORKER.value, AgentType.MEMORY.value]
)
) )
rows = (await session.execute(stmt)).all()
bit_map: dict[str, int] = {}
for agent_type, raw_config in rows:
if not isinstance(agent_type, str):
continue
config_payload = raw_config if isinstance(raw_config, dict) else {}
llm_config = SystemAgentLLMConfig.model_validate(config_payload)
bit_map[agent_type.strip().lower()] = llm_config.visibility_consumer_bit
return bit_map
async def _update_session_state( async def _update_session_state(
self, self,
@@ -1,12 +1,7 @@
from __future__ import annotations from __future__ import annotations
from schemas.agent.pipeline_spec import ( from core.agentscope.schemas.pipeline_spec import ExecutorKind, PipelineSpec, StageSpec
ContextPolicy, from schemas.agent.system_agent import AgentType
ContextWindowMode,
ExecutorKind,
PipelineSpec,
StageSpec,
)
def build_default_pipeline_spec(*, mode: str) -> PipelineSpec: def build_default_pipeline_spec(*, mode: str) -> PipelineSpec:
@@ -17,23 +12,13 @@ def build_default_pipeline_spec(*, mode: str) -> PipelineSpec:
stages=[ stages=[
StageSpec( StageSpec(
stage_name="router", stage_name="router",
agent_type=AgentType.ROUTER,
executor_kind=ExecutorKind.SINGLE_SHOT, executor_kind=ExecutorKind.SINGLE_SHOT,
default_visibility_mask=0,
context_policy=ContextPolicy(
consumer_agent_type="router",
window_mode=ContextWindowMode.DAY,
count=20,
),
), ),
StageSpec( StageSpec(
stage_name="worker", stage_name="worker",
agent_type=AgentType.WORKER,
executor_kind=ExecutorKind.REACT, executor_kind=ExecutorKind.REACT,
default_visibility_mask=0,
context_policy=ContextPolicy(
consumer_agent_type="worker",
window_mode=ContextWindowMode.NUMBER,
count=20,
),
), ),
], ],
) )
@@ -44,13 +29,8 @@ def build_default_pipeline_spec(*, mode: str) -> PipelineSpec:
stages=[ stages=[
StageSpec( StageSpec(
stage_name="memory", stage_name="memory",
agent_type=AgentType.MEMORY,
executor_kind=ExecutorKind.REACT, executor_kind=ExecutorKind.REACT,
default_visibility_mask=0,
context_policy=ContextPolicy(
consumer_agent_type="memory",
window_mode=ContextWindowMode.DAY,
count=20,
),
) )
], ],
) )
@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
from schemas.agent.consumer_registry import AgentConsumerBinding, ConsumerRegistry from core.agentscope.schemas.consumer_registry import (
AgentConsumerBinding,
ConsumerRegistry,
)
def build_consumer_registry( def build_consumer_registry(
+1 -16
View File
@@ -88,10 +88,7 @@ class AgentScopeRunner:
owner_id = UUID(user_context.id) owner_id = UUID(user_context.id)
runtime_client_time = self._resolve_runtime_client_time(run_input=run_input) runtime_client_time = self._resolve_runtime_client_time(run_input=run_input)
pipeline_spec = build_default_pipeline_spec(mode=system_agent_mode) pipeline_spec = build_default_pipeline_spec(mode=system_agent_mode)
stage_agent_types = [ stage_agent_types = [stage.agent_type for stage in pipeline_spec.stages]
self._parse_agent_type(stage_name=stage.stage_name)
for stage in pipeline_spec.stages
]
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
if stage_agent_types == [AgentType.ROUTER, AgentType.WORKER]: if stage_agent_types == [AgentType.ROUTER, AgentType.WORKER]:
@@ -177,17 +174,6 @@ class AgentScopeRunner:
enabled_tool_names=enabled_tool_names, enabled_tool_names=enabled_tool_names,
) )
@staticmethod
def _parse_agent_type(*, stage_name: str) -> AgentType:
normalized = stage_name.strip().lower()
if normalized == AgentType.ROUTER.value:
return AgentType.ROUTER
if normalized == AgentType.WORKER.value:
return AgentType.WORKER
if normalized == AgentType.MEMORY.value:
return AgentType.MEMORY
raise ValueError(f"unsupported stage name: {stage_name}")
async def _load_stage_config( async def _load_stage_config(
self, self,
*, *,
@@ -355,7 +341,6 @@ class AgentScopeRunner:
temperature=0.7, temperature=0.7,
max_tokens=None, max_tokens=None,
timeout_seconds=30, timeout_seconds=30,
visibility_consumer_bit=18,
context_messages=ContextMessagesConfig( context_messages=ContextMessagesConfig(
mode=( mode=(
ContextBuildStrategy.DAY ContextBuildStrategy.DAY
+14 -40
View File
@@ -12,7 +12,7 @@ from core.agentscope.events import (
RedisStreamBus, RedisStreamBus,
SqlAlchemyEventStore, SqlAlchemyEventStore,
) )
from core.agentscope.runtime.context_service import AgentContextService from core.agentscope.services.context_service import AgentContextService
from core.agentscope.runtime.orchestrator import AgentScopeRuntimeOrchestrator from core.agentscope.runtime.orchestrator import AgentScopeRuntimeOrchestrator
from core.agentscope.runtime.pipeline_registry import build_default_pipeline_spec from core.agentscope.runtime.pipeline_registry import build_default_pipeline_spec
from core.agentscope.schemas.agui_input import parse_run_input from core.agentscope.schemas.agui_input import parse_run_input
@@ -20,8 +20,7 @@ from core.auth.models import CurrentUser
from core.config.settings import config from core.config.settings import config
from core.db.session import AsyncSessionLocal from core.db.session import AsyncSessionLocal
from core.logging import get_logger from core.logging import get_logger
from core.taskiq.app import bulk_broker, critical_broker, default_broker from core.taskiq.app import worker_agent_broker, worker_automation_broker
from models.automation_jobs import AutomationJob
from schemas.agent.visibility import SystemVisibilityBit, bit_mask from schemas.agent.visibility import SystemVisibilityBit, bit_mask
from schemas.automation.config import AutomationJobConfig from schemas.automation.config import AutomationJobConfig
from schemas.messages.chat_message import ( from schemas.messages.chat_message import (
@@ -33,8 +32,10 @@ from schemas.user import UserContext
from services.base.redis import get_or_init_redis_client from services.base.redis import get_or_init_redis_client
from services.base.supabase import supabase_service from services.base.supabase import supabase_service
from v1.agent.repository import AgentRepository from v1.agent.repository import AgentRepository
from v1.memory.repository import MemoryRepository
from v1.memory.service import MemoryService
from v1.users.dependencies import get_user_service from v1.users.dependencies import get_user_service
from sqlalchemy import select
logger = get_logger("core.agentscope.runtime.tasks") logger = get_logger("core.agentscope.runtime.tasks")
_MAX_CONTEXT_ATTACHMENTS = 3 _MAX_CONTEXT_ATTACHMENTS = 3
@@ -188,29 +189,6 @@ async def _build_recent_context_messages(
return converted return converted
async def _load_memory_job_config(
*,
session: Any,
owner_id: UUID,
automation_job_id: str,
) -> AutomationJobConfig:
try:
job_uuid = UUID(automation_job_id)
except ValueError as exc:
raise ValueError("automation_job_id is invalid") from exc
stmt = (
select(AutomationJob)
.where(AutomationJob.id == job_uuid)
.where(AutomationJob.owner_id == owner_id)
.where(AutomationJob.deleted_at.is_(None))
)
row = (await session.execute(stmt)).scalar_one_or_none()
if row is None:
raise ValueError("automation job not found")
return AutomationJobConfig.model_validate(row.config or {})
async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]: async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]:
command_type = str(command.get("command", "run")).strip().lower() command_type = str(command.get("command", "run")).strip().lower()
raw_owner_id = command.get("owner_id") raw_owner_id = command.get("owner_id")
@@ -245,10 +223,11 @@ async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]:
memory_job_config: AutomationJobConfig | None = None memory_job_config: AutomationJobConfig | None = None
if system_agent_mode == "memory": if system_agent_mode == "memory":
assert isinstance(raw_automation_job_id, str) assert isinstance(raw_automation_job_id, str)
memory_job_config = await _load_memory_job_config( job_uuid = UUID(raw_automation_job_id)
session=session, memory_service = MemoryService(MemoryRepository(session))
memory_job_config = await memory_service.get_memory_job_config(
job_id=job_uuid,
owner_id=owner_id, owner_id=owner_id,
automation_job_id=raw_automation_job_id,
) )
redis_client = await get_or_init_redis_client() redis_client = await get_or_init_redis_client()
@@ -272,7 +251,7 @@ async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]:
context_messages = await _build_recent_context_messages( context_messages = await _build_recent_context_messages(
session=session, session=session,
thread_id=thread_id, thread_id=thread_id,
context_mode=pipeline_spec.stages[0].context_policy.consumer_agent_type, context_mode=pipeline_spec.stages[0].agent_type.value,
memory_job_config=memory_job_config, memory_job_config=memory_job_config,
) )
@@ -296,16 +275,11 @@ async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]:
} }
@default_broker.task(task_name="tasks.agentscope.run_command") @worker_agent_broker.task(task_name="tasks.agentscope.run_command.agent")
async def run_command_task(command: dict[str, Any]) -> dict[str, object]: async def run_command_task_agent(command: dict[str, object]) -> dict[str, object]:
return await run_agentscope_task(command) return await run_agentscope_task(command)
@critical_broker.task(task_name="tasks.agentscope.run_command.critical") @worker_automation_broker.task(task_name="tasks.agentscope.run_command.automation")
async def run_command_task_critical(command: dict[str, Any]) -> dict[str, object]: async def run_command_task_automation(command: dict[str, object]) -> dict[str, object]:
return await run_agentscope_task(command)
@bulk_broker.task(task_name="tasks.agentscope.run_command.bulk")
async def run_command_task_bulk(command: dict[str, Any]) -> dict[str, object]:
return await run_agentscope_task(command) return await run_agentscope_task(command)
@@ -4,40 +4,20 @@ from enum import Enum
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
from schemas.agent.system_agent import AgentType
class ExecutorKind(str, Enum): class ExecutorKind(str, Enum):
SINGLE_SHOT = "single_shot" SINGLE_SHOT = "single_shot"
REACT = "react" REACT = "react"
class ContextWindowMode(str, Enum):
DAY = "day"
NUMBER = "number"
class ContextPolicy(BaseModel):
model_config = ConfigDict(extra="forbid")
consumer_agent_type: str = Field(..., min_length=1, max_length=64)
window_mode: ContextWindowMode = ContextWindowMode.NUMBER
count: int = Field(default=20, ge=1, le=200)
@field_validator("consumer_agent_type")
@classmethod
def _normalize_consumer_agent_type(cls, value: str) -> str:
normalized = value.strip().lower()
if not normalized:
raise ValueError("consumer_agent_type must not be empty")
return normalized
class StageSpec(BaseModel): class StageSpec(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
stage_name: str = Field(..., min_length=1, max_length=64) stage_name: str = Field(..., min_length=1, max_length=64)
agent_type: AgentType
executor_kind: ExecutorKind executor_kind: ExecutorKind
default_visibility_mask: int = Field(..., ge=0, le=(1 << 63) - 1)
context_policy: ContextPolicy
@field_validator("stage_name") @field_validator("stage_name")
@classmethod @classmethod
@@ -5,7 +5,7 @@ from typing import Protocol
from core.agentscope.runtime.context_loader_registry import CONTEXT_LOADER_REGISTRY from core.agentscope.runtime.context_loader_registry import CONTEXT_LOADER_REGISTRY
from schemas.agent.system_agent import SystemAgentLLMConfig from schemas.agent.system_agent import SystemAgentLLMConfig
from schemas.agent.visibility import bit_mask from schemas.agent.visibility import SystemVisibilityBit, bit_mask
_DEFAULT_CONTEXT_WINDOW_USER_MESSAGES = 20 _DEFAULT_CONTEXT_WINDOW_USER_MESSAGES = 20
_DEFAULT_ROUTER_CONTEXT_DAY_COUNT = 20 _DEFAULT_ROUTER_CONTEXT_DAY_COUNT = 20
@@ -61,7 +61,7 @@ class AgentContextService:
normalized_config = self._normalize_system_agent_config(raw_llm_config) normalized_config = self._normalize_system_agent_config(raw_llm_config)
context_config = normalized_config.context_messages context_config = normalized_config.context_messages
visibility_mask = bit_mask(bit=normalized_config.visibility_consumer_bit) visibility_mask = bit_mask(bit=int(SystemVisibilityBit.CONTEXT_ASSEMBLY))
context_loader = CONTEXT_LOADER_REGISTRY.resolve(mode=context_config.mode) context_loader = CONTEXT_LOADER_REGISTRY.resolve(mode=context_config.mode)
return await context_loader( return await context_loader(
self, self,
+2 -2
View File
@@ -2,12 +2,12 @@ from __future__ import annotations
from core.logging import get_logger from core.logging import get_logger
from core.taskiq.app import bulk_broker from core.taskiq.app import worker_automation_broker
logger = get_logger("core.automation.tasks") logger = get_logger("core.automation.tasks")
@bulk_broker.task(task_name="tasks.automation.scan_due_jobs") @worker_automation_broker.task(task_name="tasks.automation.scan_due_jobs")
async def scan_due_automation_jobs_task(limit: int | None = None) -> dict[str, int]: async def scan_due_automation_jobs_task(limit: int | None = None) -> dict[str, int]:
from core.automation.scheduler import run_automation_scheduler_scan from core.automation.scheduler import run_automation_scheduler_scan
@@ -6,7 +6,6 @@ agents:
temperature: 0.7 temperature: 0.7
max_tokens: null max_tokens: null
timeout_seconds: 30 timeout_seconds: 30
visibility_consumer_bit: 16
context_messages: context_messages:
mode: day mode: day
count: 2 count: 2
@@ -19,7 +18,6 @@ agents:
temperature: 0.7 temperature: 0.7
max_tokens: null max_tokens: null
timeout_seconds: 30 timeout_seconds: 30
visibility_consumer_bit: 17
context_messages: context_messages:
mode: number mode: number
count: 20 count: 20
+2 -2
View File
@@ -1,3 +1,3 @@
from core.taskiq.app import broker, bulk_broker, critical_broker, default_broker from core.taskiq.app import broker, worker_agent_broker, worker_automation_broker
__all__ = ["broker", "default_broker", "critical_broker", "bulk_broker"] __all__ = ["broker", "worker_agent_broker", "worker_automation_broker"]
+4 -6
View File
@@ -21,11 +21,9 @@ def _build_broker(queue_name: str) -> ListQueueBroker:
) )
default_broker = _build_broker("default") worker_agent_broker = _build_broker("agent")
critical_broker = _build_broker("critical") worker_automation_broker = _build_broker("automation")
bulk_broker = _build_broker("bulk")
# Backward-compatible export name for existing imports/tests. broker = worker_agent_broker
broker = default_broker
__all__ = ["broker", "default_broker", "critical_broker", "bulk_broker"] __all__ = ["broker", "worker_agent_broker", "worker_automation_broker"]
+4 -17
View File
@@ -1,17 +1,10 @@
from schemas.agent.consumer_registry import AgentConsumerBinding, ConsumerRegistry
from schemas.agent.forwarded_props import ( from schemas.agent.forwarded_props import (
ClientTimeContext, ClientTimeContext,
ForwardedPropsPayload, ForwardedPropsPayload,
parse_forwarded_props_agent_type,
parse_forwarded_props_client_time, parse_forwarded_props_client_time,
parse_forwarded_props_runtime_mode,
) )
from schemas.agent.pipeline_spec import ( from schemas.agent.forwarded_props import RuntimeMode
ContextPolicy,
ContextWindowMode,
ExecutorKind,
PipelineSpec,
StageSpec,
)
from schemas.agent.runtime_models import ( from schemas.agent.runtime_models import (
AgentOutput, AgentOutput,
ConstraintItem, ConstraintItem,
@@ -45,28 +38,22 @@ from schemas.agent.ui_hints import (
__all__ = [ __all__ = [
"AgentType", "AgentType",
"AgentOutput", "AgentOutput",
"AgentConsumerBinding",
"ConstraintItem", "ConstraintItem",
"ConsumerRegistry",
"ContextPolicy",
"ContextWindowMode",
"ExecutionMode", "ExecutionMode",
"ExecutorKind",
"ForwardedPropsPayload", "ForwardedPropsPayload",
"KeyEntity", "KeyEntity",
"NormalizedTaskInput", "NormalizedTaskInput",
"PipelineSpec",
"ResultTyping", "ResultTyping",
"ClientTimeContext", "ClientTimeContext",
"ResultType", "ResultType",
"RouterAgentOutput", "RouterAgentOutput",
"RouterUiDecision", "RouterUiDecision",
"RunStatus", "RunStatus",
"RuntimeMode",
"TaskType", "TaskType",
"TaskTyping", "TaskTyping",
"SystemAgentLLMConfig", "SystemAgentLLMConfig",
"SystemVisibilityBit", "SystemVisibilityBit",
"StageSpec",
"ToolAgentOutput", "ToolAgentOutput",
"ToolStatus", "ToolStatus",
"UiMode", "UiMode",
@@ -79,7 +66,7 @@ __all__ = [
"WorkerAgentOutputLite", "WorkerAgentOutputLite",
"WorkerAgentOutputRich", "WorkerAgentOutputRich",
"bit_mask", "bit_mask",
"parse_forwarded_props_agent_type",
"parse_forwarded_props_client_time", "parse_forwarded_props_client_time",
"parse_forwarded_props_runtime_mode",
"resolve_worker_output_model", "resolve_worker_output_model",
] ]
+9 -11
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from enum import Enum
import re import re
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
@@ -59,20 +60,17 @@ class ClientTimeContext(BaseModel):
return value return value
class RuntimeMode(str, Enum):
CHAT = "chat"
AUTOMATION = "automation"
class ForwardedPropsPayload(BaseModel): class ForwardedPropsPayload(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid")
agent_type: str = Field(..., min_length=1, max_length=64) runtime_mode: RuntimeMode
client_time: ClientTimeContext | None = None client_time: ClientTimeContext | None = None
@field_validator("agent_type")
@classmethod
def validate_agent_type(cls, value: str) -> str:
normalized = value.strip().lower()
if not normalized:
raise ValueError("invalid forwarded_props.agent_type")
return normalized
def parse_forwarded_props(forwarded_props: object) -> ForwardedPropsPayload: def parse_forwarded_props(forwarded_props: object) -> ForwardedPropsPayload:
if not isinstance(forwarded_props, dict): if not isinstance(forwarded_props, dict):
@@ -90,6 +88,6 @@ def parse_forwarded_props_client_time(
return payload.client_time return payload.client_time
def parse_forwarded_props_agent_type(forwarded_props: object) -> str: def parse_forwarded_props_runtime_mode(forwarded_props: object) -> RuntimeMode:
payload = parse_forwarded_props(forwarded_props) payload = parse_forwarded_props(forwarded_props)
return payload.agent_type return payload.runtime_mode
+8 -8
View File
@@ -2,15 +2,13 @@ from __future__ import annotations
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field, field_validator
from core.agentscope.tools.tool_config import AgentTool, parse_agent_tool from core.agentscope.tools.tool_config import AgentTool, parse_agent_tool
from pydantic import BaseModel, Field, field_validator
class AgentType(str, Enum): class AgentType(str, Enum):
ROUTER = "router" ROUTER = "router"
WORKER = "worker" WORKER = "worker"
MEMORY = "memory"
class ContextBuildStrategy(str, Enum): class ContextBuildStrategy(str, Enum):
@@ -30,7 +28,6 @@ class SystemAgentLLMConfig(BaseModel):
context_messages: ContextMessagesConfig = Field( context_messages: ContextMessagesConfig = Field(
default_factory=ContextMessagesConfig default_factory=ContextMessagesConfig
) )
visibility_consumer_bit: int = Field(default=16, ge=16, le=63)
enabled_tools: list[AgentTool] = Field(default_factory=list, max_length=32) enabled_tools: list[AgentTool] = Field(default_factory=list, max_length=32)
@field_validator("enabled_tools", mode="before") @field_validator("enabled_tools", mode="before")
@@ -42,10 +39,13 @@ class SystemAgentLLMConfig(BaseModel):
raise ValueError("enabled_tools must be a list") raise ValueError("enabled_tools must be a list")
normalized: list[AgentTool] = [] normalized: list[AgentTool] = []
for item in value: for item in value:
raw_item = str(item or "").strip() if isinstance(item, AgentTool):
if not raw_item: tool = item
continue else:
tool = parse_agent_tool(raw_item) raw_item = str(item or "").strip()
if not raw_item:
continue
tool = parse_agent_tool(raw_item)
if tool not in normalized: if tool not in normalized:
normalized.append(tool) normalized.append(tool)
return normalized return normalized
+1 -1
View File
@@ -7,7 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
class SystemVisibilityBit(IntEnum): class SystemVisibilityBit(IntEnum):
UI_HISTORY = 0 UI_HISTORY = 0
UI_REALTIME = 1 CONTEXT_ASSEMBLY = 1
class VisibilityMask(BaseModel): class VisibilityMask(BaseModel):
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
import asyncio
from typing import Any
import dashscope
from dashscope.audio.asr import Recognition, RecognitionCallback
from core.config.settings import config
from core.logging import get_logger
logger = get_logger(__name__)
class AsrService:
def __init__(self) -> None:
self._api_key: str | None = None
def _get_api_key(self) -> str:
if self._api_key is None:
dashscope_key = config.llm.provider_keys.get("dashscope")
if not dashscope_key:
raise ValueError(
"DASHSCOPE_API_KEY not configured. Set SOCIAL_LLM__PROVIDER_KEYS__DASHSCOPE in environment."
)
self._api_key = dashscope_key
return self._api_key
async def transcribe_file(self, file_path: str, filename: str) -> str:
try:
dashscope.api_key = self._get_api_key()
loop = asyncio.get_event_loop()
class SyncCallback(RecognitionCallback):
error: str | None = None
def on_error(self, result: Any) -> None:
self.error = str(result)
callback = SyncCallback()
recognizer = Recognition(
model="fun-asr-realtime-2026-02-28",
callback=callback,
format="wav",
sample_rate=16000,
)
result: Any = await loop.run_in_executor(
None,
lambda: recognizer.call(file=file_path),
)
if callback.error:
raise RuntimeError(f"ASR error: {callback.error}")
status_code = self._extract_field(result, "status_code")
if status_code != 200:
message = self._extract_field(result, "message")
raise RuntimeError(f"ASR transcription failed: {message}")
sentence = self._extract_sentence_payload(result)
if sentence is None:
request_id = self._extract_field(result, "request_id")
logger.warning(
"ASR returned empty result", extra={"request_id": request_id}
)
return ""
if isinstance(sentence, dict):
transcription = sentence.get("text", "")
elif isinstance(sentence, list):
transcription = " ".join(
item.get("text", "") for item in sentence if isinstance(item, dict)
)
else:
transcription = str(sentence) if sentence else ""
logger.info(
"ASR transcription completed",
extra={"filename": filename, "transcript_length": len(transcription)},
)
return transcription
except asyncio.CancelledError:
raise
except RuntimeError:
raise
except Exception as exc:
logger.exception("ASR transcription error")
raise RuntimeError(f"ASR transcription failed: {exc}") from exc
def _extract_sentence_payload(self, result: Any) -> Any | None:
if isinstance(result, dict):
output = result.get("output")
if isinstance(output, dict):
return output.get("sentence")
if output is not None:
return getattr(output, "sentence", None)
return result.get("sentence")
get_sentence = getattr(result, "get_sentence", None)
if callable(get_sentence):
sentence = get_sentence()
if sentence is not None:
return sentence
output = getattr(result, "output", None)
if output is None:
return None
if isinstance(output, dict):
return output.get("sentence")
return getattr(output, "sentence", None)
def _extract_field(self, result: Any, field: str) -> Any | None:
if isinstance(result, dict):
return result.get(field)
return getattr(result, field, None)
asr_service = AsrService()
+6 -9
View File
@@ -44,17 +44,14 @@ class TaskiqQueueClient:
@staticmethod @staticmethod
def _select_queue_task(command: dict[str, object]) -> Any: def _select_queue_task(command: dict[str, object]) -> Any:
from core.agentscope.runtime.tasks import ( from core.agentscope.runtime.tasks import (
run_command_task, run_command_task_agent,
run_command_task_bulk, run_command_task_automation,
run_command_task_critical,
) )
queue = str(command.get("queue", "default")).strip().lower() queue = str(command.get("queue", "agent")).strip().lower()
if queue == "critical": if queue == "automation":
return run_command_task_critical return run_command_task_automation
if queue == "bulk": return run_command_task_agent
return run_command_task_bulk
return run_command_task
async def enqueue( async def enqueue(
self, *, command: dict[str, object], dedup_key: str | None self, *, command: dict[str, object], dedup_key: str | None
+39 -38
View File
@@ -6,7 +6,7 @@ import re
import tempfile import tempfile
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from datetime import date from datetime import date
from typing import Annotated, Union from typing import Annotated
from ag_ui.core import RunAgentInput from ag_ui.core import RunAgentInput
from core.agentscope.events import to_sse_event from core.agentscope.events import to_sse_event
@@ -28,7 +28,7 @@ from fastapi import (
UploadFile, UploadFile,
status, status,
) )
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import StreamingResponse
from services.base.redis import get_or_init_redis_client from services.base.redis import get_or_init_redis_client
from v1.agent.dependencies import get_agent_service from v1.agent.dependencies import get_agent_service
from v1.agent.schemas import ( from v1.agent.schemas import (
@@ -39,7 +39,8 @@ from v1.agent.schemas import (
HistorySnapshotResponse, HistorySnapshotResponse,
TaskAcceptedResponse, TaskAcceptedResponse,
) )
from v1.agent.service import AgentService, asr_service from v1.agent.asr import asr_service
from v1.agent.service import AgentService
from v1.users.dependencies import get_current_user from v1.users.dependencies import get_current_user
router = APIRouter(prefix="/agent", tags=["agent"]) router = APIRouter(prefix="/agent", tags=["agent"])
@@ -73,15 +74,13 @@ async def _acquire_sse_slot(*, user_id: str) -> bool:
count = await redis.incr(key) count = await redis.incr(key)
if count == 1: if count == 1:
await redis.expire(key, _SSE_SLOT_TTL_SECONDS) await redis.expire(key, _SSE_SLOT_TTL_SECONDS)
elif count > _MAX_SSE_CONNECTIONS_PER_USER:
await redis.decr(key)
return False
else: else:
ttl = await redis.ttl(key) ttl = await redis.ttl(key)
if int(ttl) < 0: if ttl < 0:
await redis.expire(key, _SSE_SLOT_TTL_SECONDS) await redis.expire(key, _SSE_SLOT_TTL_SECONDS)
if int(count) > _MAX_SSE_CONNECTIONS_PER_USER:
after_decr = await redis.decr(key)
if int(after_decr) <= 0:
await redis.delete(key)
return False
return True return True
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning( logger.warning(
@@ -97,13 +96,18 @@ async def _release_sse_slot(*, user_id: str) -> None:
redis = await get_or_init_redis_client() redis = await get_or_init_redis_client()
key = f"agent:sse-active:{user_id}" key = f"agent:sse-active:{user_id}"
count = await redis.decr(key) count = await redis.decr(key)
if int(count) <= 0: if count <= 0:
await redis.delete(key) await redis.delete(key)
return None else:
ttl = await redis.ttl(key) ttl = await redis.ttl(key)
if int(ttl) < 0: if ttl < 0:
await redis.expire(key, _SSE_SLOT_TTL_SECONDS) await redis.expire(key, _SSE_SLOT_TTL_SECONDS)
except Exception: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning(
"SSE slot release failed",
user_id=user_id,
reason=str(exc),
)
return None return None
@@ -176,6 +180,11 @@ async def stream_events(
last_event_id=cursor, last_event_id=cursor,
current_user=current_user, current_user=current_user,
) )
except TimeoutError:
idle_polls += 1
yield ": keep-alive\n\n"
await asyncio.sleep(0.2)
continue
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning( logger.warning(
"SSE stream read failed", "SSE stream read failed",
@@ -183,11 +192,6 @@ async def stream_events(
user_id=str(current_user.id), user_id=str(current_user.id),
reason=str(exc), reason=str(exc),
) )
if "Timeout reading from" in str(exc):
idle_polls += 1
yield ": keep-alive\n\n"
await asyncio.sleep(0.2)
continue
break break
if not rows: if not rows:
@@ -291,12 +295,12 @@ async def create_attachment_signed_url(
async def transcribe( async def transcribe(
audio: UploadFile, audio: UploadFile,
request: Request, request: Request,
_current_user: Annotated[CurrentUser, Depends(get_current_user)], current_user: Annotated[CurrentUser, Depends(get_current_user)],
) -> Union[AsrTranscribeResponse, JSONResponse]: ) -> AsrTranscribeResponse:
temp_path: str | None = None temp_path: str | None = None
try: try:
if audio.content_type not in _ALLOWED_AUDIO_CONTENT_TYPES: if audio.content_type not in _ALLOWED_AUDIO_CONTENT_TYPES:
raise ValueError("Unsupported audio format") raise HTTPException(status_code=400, detail="Unsupported audio format")
content_length = request.headers.get("content-length") content_length = request.headers.get("content-length")
if content_length is not None: if content_length is not None:
@@ -309,7 +313,7 @@ async def transcribe(
and declared_length and declared_length
> _MAX_TRANSCRIBE_AUDIO_BYTES + _MULTIPART_OVERHEAD_BYTES > _MAX_TRANSCRIBE_AUDIO_BYTES + _MULTIPART_OVERHEAD_BYTES
): ):
raise ValueError("Audio file too large") raise HTTPException(status_code=400, detail="Audio file too large")
with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp_file: with tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as tmp_file:
temp_path = tmp_file.name temp_path = tmp_file.name
@@ -322,16 +326,16 @@ async def transcribe(
break break
total_bytes += len(chunk) total_bytes += len(chunk)
if total_bytes > _MAX_TRANSCRIBE_AUDIO_BYTES: if total_bytes > _MAX_TRANSCRIBE_AUDIO_BYTES:
raise ValueError("Audio file too large") raise HTTPException(status_code=400, detail="Audio file too large")
if len(header) < _WAV_HEADER_MIN_BYTES: if len(header) < _WAV_HEADER_MIN_BYTES:
required = _WAV_HEADER_MIN_BYTES - len(header) required = _WAV_HEADER_MIN_BYTES - len(header)
header.extend(chunk[:required]) header.extend(chunk[:required])
tmp_file.write(chunk) tmp_file.write(chunk)
if total_bytes == 0: if total_bytes == 0:
raise ValueError("Empty audio file") raise HTTPException(status_code=400, detail="Empty audio file")
if not _looks_like_wav_header(bytes(header)): if not _looks_like_wav_header(bytes(header)):
raise ValueError("Unsupported audio format") raise HTTPException(status_code=400, detail="Unsupported audio format")
transcript = await asr_service.transcribe_file( transcript = await asr_service.transcribe_file(
temp_path, audio.filename or "unknown" temp_path, audio.filename or "unknown"
@@ -339,17 +343,14 @@ async def transcribe(
return AsrTranscribeResponse(transcript=transcript) return AsrTranscribeResponse(transcript=transcript)
except ValueError as exc: except HTTPException:
return JSONResponse( raise
status_code=status.HTTP_400_BAD_REQUEST,
content={"detail": str(exc)},
)
except RuntimeError: except RuntimeError:
return JSONResponse( raise HTTPException(status_code=502, detail="ASR service unavailable")
status_code=status.HTTP_502_BAD_GATEWAY,
content={"detail": "ASR service unavailable"},
)
finally: finally:
await audio.close() await audio.close()
if temp_path and os.path.exists(temp_path): if temp_path:
os.unlink(temp_path) try:
os.unlink(temp_path)
except OSError:
pass
+84 -1
View File
@@ -1,12 +1,95 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal from dataclasses import dataclass
from datetime import date
from typing import Any, Literal, Protocol
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from schemas.agent.ui_schema import UiSchemaRenderer from schemas.agent.ui_schema import UiSchemaRenderer
class AgentRepositoryLike(Protocol):
async def get_session_owner(self, *, session_id: str) -> str: ...
async def create_session_for_user(
self, *, user_id: str, session_id: str | None = None
) -> str: ...
async def commit(self) -> None: ...
async def rollback(self) -> None: ...
async def get_history_day(
self,
*,
session_id: str,
before: date | None,
visibility_mask: int | None = None,
) -> dict[str, object] | None: ...
async def get_latest_session_id_for_user(self, *, user_id: str) -> str | None: ...
async def persist_user_message(
self,
*,
session_id: str,
content: str,
metadata: Any,
visibility_mask: int,
) -> None: ...
async def get_system_agent_config(
self, *, agent_type: str
) -> dict[str, object] | None: ...
class QueueClientLike(Protocol):
async def enqueue(
self, *, command: dict[str, object], dedup_key: str | None
) -> str: ...
class EventStreamLike(Protocol):
async def read(
self,
*,
session_id: str,
last_event_id: str | None,
) -> list[dict[str, object]]: ...
class AttachmentStorageLike(Protocol):
async def upload_bytes(
self,
*,
bucket: str,
path: str,
content: bytes,
content_type: str,
) -> str: ...
async def download_bytes(self, *, bucket: str, path: str) -> bytes: ...
async def create_signed_url(
self,
*,
bucket: str,
path: str,
expires_in_seconds: int,
) -> str: ...
def parse_signed_url(self, url: str) -> tuple[str, str]: ...
@dataclass(frozen=True)
class TaskAccepted:
task_id: str
thread_id: str
run_id: str
created: bool
class TaskAcceptedResponse(BaseModel): class TaskAcceptedResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
+37 -288
View File
@@ -1,15 +1,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import date from datetime import date
import hashlib import hashlib
from typing import Any, Protocol
from urllib.parse import urlparse from urllib.parse import urlparse
import dashscope
from ag_ui.core import RunAgentInput from ag_ui.core import RunAgentInput
from dashscope.audio.asr import Recognition, RecognitionCallback
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -17,102 +13,32 @@ from core.auth.models import CurrentUser
from core.agentscope.schemas.agui_input import extract_latest_user_payload from core.agentscope.schemas.agui_input import extract_latest_user_payload
from core.config.settings import config from core.config.settings import config
from core.logging import get_logger from core.logging import get_logger
from schemas.agent.forwarded_props import parse_forwarded_props_agent_type from schemas.agent.forwarded_props import (
from schemas.agent.system_agent import SystemAgentLLMConfig parse_forwarded_props_runtime_mode,
RuntimeMode,
)
from schemas.agent.visibility import SystemVisibilityBit, bit_mask from schemas.agent.visibility import SystemVisibilityBit, bit_mask
from schemas.messages.chat_message import ( from schemas.messages.chat_message import (
AgentChatMessageMetadata, AgentChatMessageMetadata,
UserMessageAttachment, UserMessageAttachment,
extract_user_message_attachments, extract_user_message_attachments,
) )
from v1.agent.schemas import HistorySnapshotResponse from v1.agent.schemas import (
AgentRepositoryLike,
AttachmentStorageLike,
EventStreamLike,
HistorySnapshotResponse,
QueueClientLike,
TaskAccepted,
)
from v1.agent.utils import (
MAX_ATTACHMENT_BYTES,
MAX_ATTACHMENTS_PER_MESSAGE,
is_safe_attachment_path,
mime_to_suffix,
)
logger = get_logger(__name__) logger = get_logger(__name__)
_ALLOWED_ATTACHMENT_MIME_TYPES = {"image/png", "image/jpeg", "image/webp"}
_MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024
_MAX_TOTAL_ATTACHMENT_BYTES = 12 * 1024 * 1024
_MAX_ATTACHMENTS_PER_MESSAGE = 3
@dataclass(frozen=True)
class TaskAccepted:
task_id: str
thread_id: str
run_id: str
created: bool
class AgentRepositoryLike(Protocol):
async def get_session_owner(self, *, session_id: str) -> str: ...
async def create_session_for_user(
self, *, user_id: str, session_id: str | None = None
) -> str: ...
async def commit(self) -> None: ...
async def rollback(self) -> None: ...
async def get_history_day(
self,
*,
session_id: str,
before: date | None,
visibility_mask: int | None = None,
) -> dict[str, object] | None: ...
async def get_latest_session_id_for_user(self, *, user_id: str) -> str | None: ...
async def persist_user_message(
self,
*,
session_id: str,
content: str,
metadata: AgentChatMessageMetadata | None,
visibility_mask: int,
) -> None: ...
async def get_system_agent_config(
self, *, agent_type: str
) -> dict[str, object] | None: ...
class QueueClientLike(Protocol):
async def enqueue(
self, *, command: dict[str, object], dedup_key: str | None
) -> str: ...
class EventStreamLike(Protocol):
async def read(
self,
*,
session_id: str,
last_event_id: str | None,
) -> list[dict[str, object]]: ...
class AttachmentStorageLike(Protocol):
async def upload_bytes(
self,
*,
bucket: str,
path: str,
content: bytes,
content_type: str,
) -> str: ...
async def download_bytes(self, *, bucket: str, path: str) -> bytes: ...
async def create_signed_url(
self,
*,
bucket: str,
path: str,
expires_in_seconds: int,
) -> str: ...
def parse_signed_url(self, url: str) -> tuple[str, str]: ...
def ensure_session_owner(*, owner_id: str, current_user: CurrentUser) -> None: def ensure_session_owner(*, owner_id: str, current_user: CurrentUser) -> None:
@@ -152,14 +78,9 @@ class AgentService:
run_id = run_input.run_id run_id = run_input.run_id
forwarded_props = getattr(run_input, "forwarded_props", None) forwarded_props = getattr(run_input, "forwarded_props", None)
try: try:
agent_type = parse_forwarded_props_agent_type(forwarded_props) runtime_mode = parse_forwarded_props_runtime_mode(forwarded_props)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc raise HTTPException(status_code=422, detail=str(exc)) from exc
if agent_type == "memory":
raise HTTPException(
status_code=422,
detail="memory mode is automation-only",
)
try: try:
owner = await self._repository.get_session_owner(session_id=thread_id) owner = await self._repository.get_session_owner(session_id=thread_id)
@@ -185,7 +106,7 @@ class AgentService:
current_user=current_user, current_user=current_user,
) )
visibility_mask = await self._resolve_user_message_visibility_mask( visibility_mask = await self._resolve_user_message_visibility_mask(
agent_type=agent_type runtime_mode=runtime_mode
) )
await self._repository.persist_user_message( await self._repository.persist_user_message(
session_id=thread_id, session_id=thread_id,
@@ -195,6 +116,7 @@ class AgentService:
) )
await self._repository.commit() await self._repository.commit()
queue = "automation" if runtime_mode == RuntimeMode.AUTOMATION else "agent"
task_id = await self._queue.enqueue( task_id = await self._queue.enqueue(
command={ command={
"command": "run", "command": "run",
@@ -202,6 +124,7 @@ class AgentService:
"run_input": run_input.model_dump( "run_input": run_input.model_dump(
mode="json", by_alias=True, exclude_none=True mode="json", by_alias=True, exclude_none=True
), ),
"queue": queue,
}, },
dedup_key=None, dedup_key=None,
) )
@@ -212,60 +135,14 @@ class AgentService:
created=created, created=created,
) )
async def _resolve_user_message_visibility_mask(self, *, agent_type: str) -> int: async def _resolve_user_message_visibility_mask(
normalized_agent_type = agent_type.strip().lower() self, *, runtime_mode: RuntimeMode
history_bit_mask = bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)) ) -> int:
if runtime_mode == RuntimeMode.CHAT:
if normalized_agent_type == "memory": return bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)) | bit_mask(
return bit_mask(bit=18) bit=int(SystemVisibilityBit.CONTEXT_ASSEMBLY)
agent_config = await self._repository.get_system_agent_config(
agent_type=normalized_agent_type
)
if agent_config is None:
raise HTTPException(
status_code=422, detail="invalid forwarded_props.agent_type"
) )
llm_config = SystemAgentLLMConfig.model_validate( return 0
(agent_config.get("config") if isinstance(agent_config, dict) else {}) or {}
)
agent_mask = bit_mask(bit=llm_config.visibility_consumer_bit)
if normalized_agent_type == "worker":
router_config = await self._repository.get_system_agent_config(
agent_type="router"
)
worker_config = await self._repository.get_system_agent_config(
agent_type="worker"
)
if router_config is None or worker_config is None:
raise HTTPException(
status_code=500,
detail="system agent visibility config missing",
)
router_mask = bit_mask(
bit=SystemAgentLLMConfig.model_validate(
(
router_config.get("config")
if isinstance(router_config, dict)
else {}
)
or {}
).visibility_consumer_bit
)
worker_mask = bit_mask(
bit=SystemAgentLLMConfig.model_validate(
(
worker_config.get("config")
if isinstance(worker_config, dict)
else {}
)
or {}
).visibility_consumer_bit
)
return history_bit_mask | router_mask | worker_mask
return history_bit_mask | agent_mask
async def _prepare_user_message( async def _prepare_user_message(
self, self,
@@ -309,7 +186,7 @@ class AgentService:
mime_type=mime_type, mime_type=mime_type,
) )
) )
if len(user_attachments) > _MAX_ATTACHMENTS_PER_MESSAGE: if len(user_attachments) > MAX_ATTACHMENTS_PER_MESSAGE:
raise HTTPException(status_code=422, detail="Too many attachments") raise HTTPException(status_code=422, detail="Too many attachments")
except HTTPException: except HTTPException:
raise raise
@@ -360,14 +237,14 @@ class AgentService:
if not isinstance(content_type, str): if not isinstance(content_type, str):
raise HTTPException(status_code=422, detail="Unsupported attachment type") raise HTTPException(status_code=422, detail="Unsupported attachment type")
mime_type = content_type.lower() mime_type = content_type.lower()
if mime_type not in _ALLOWED_ATTACHMENT_MIME_TYPES: if mime_type not in {"image/png", "image/jpeg", "image/webp"}:
raise HTTPException(status_code=422, detail="Unsupported attachment type") raise HTTPException(status_code=422, detail="Unsupported attachment type")
if not payload: if not payload:
raise HTTPException(status_code=422, detail="Empty attachment") raise HTTPException(status_code=422, detail="Empty attachment")
if len(payload) > _MAX_ATTACHMENT_BYTES: if len(payload) > MAX_ATTACHMENT_BYTES:
raise HTTPException(status_code=413, detail="Attachment too large") raise HTTPException(status_code=413, detail="Attachment too large")
suffix = _mime_to_suffix(mime_type) suffix = mime_to_suffix(mime_type)
checksum = hashlib.sha1(payload).hexdigest()[:16] checksum = hashlib.sha1(payload).hexdigest()[:16]
filename_seed = filename if isinstance(filename, str) and filename else "upload" filename_seed = filename if isinstance(filename, str) and filename else "upload"
filename_hash = hashlib.sha1(filename_seed.encode("utf-8")).hexdigest()[:8] filename_hash = hashlib.sha1(filename_seed.encode("utf-8")).hexdigest()[:8]
@@ -424,7 +301,7 @@ class AgentService:
normalized_path = path.strip() normalized_path = path.strip()
expected_prefix = f"agent-inputs/{current_user.id}/" expected_prefix = f"agent-inputs/{current_user.id}/"
if not _is_safe_attachment_path( if not is_safe_attachment_path(
normalized_path, expected_prefix=expected_prefix normalized_path, expected_prefix=expected_prefix
): ):
raise HTTPException(status_code=422, detail="Invalid attachment path scope") raise HTTPException(status_code=422, detail="Invalid attachment path scope")
@@ -503,7 +380,7 @@ class AgentService:
f"agent-inputs/{current_user.id}/{thread_id}/uploads/" f"agent-inputs/{current_user.id}/{thread_id}/uploads/"
) )
for attachment in attachments: for attachment in attachments:
if not _is_safe_attachment_path( if not is_safe_attachment_path(
attachment.path, attachment.path,
expected_prefix=expected_prefix, expected_prefix=expected_prefix,
): ):
@@ -586,134 +463,6 @@ class AgentService:
raise HTTPException(status_code=422, detail="INVALID_BINARY_URL_BUCKET") raise HTTPException(status_code=422, detail="INVALID_BINARY_URL_BUCKET")
expected_prefix = f"agent-inputs/{current_user.id}/{thread_id}/uploads/" expected_prefix = f"agent-inputs/{current_user.id}/{thread_id}/uploads/"
if not _is_safe_attachment_path(path, expected_prefix=expected_prefix): if not is_safe_attachment_path(path, expected_prefix=expected_prefix):
raise HTTPException(status_code=422, detail="INVALID_BINARY_URL_PATH_SCOPE") raise HTTPException(status_code=422, detail="INVALID_BINARY_URL_PATH_SCOPE")
return bucket, path return bucket, path
class AsrService:
def __init__(self) -> None:
self._api_key: str | None = None
def _get_api_key(self) -> str:
if self._api_key is None:
dashscope_key = config.llm.provider_keys.get("dashscope")
if not dashscope_key:
raise ValueError(
"DASHSCOPE_API_KEY not configured. Set SOCIAL_LLM__PROVIDER_KEYS__DASHSCOPE in environment."
)
self._api_key = dashscope_key
return self._api_key
async def transcribe_file(self, file_path: str, filename: str) -> str:
try:
dashscope.api_key = self._get_api_key()
loop = asyncio.get_event_loop()
class SyncCallback(RecognitionCallback):
error: str | None = None
def on_error(self, result: Any) -> None:
self.error = str(result)
callback = SyncCallback()
recognizer = Recognition(
model="fun-asr-realtime-2026-02-28",
callback=callback,
format="wav",
sample_rate=16000,
)
result: Any = await loop.run_in_executor(
None,
lambda: recognizer.call(file=file_path),
)
if callback.error:
raise RuntimeError(f"ASR error: {callback.error}")
status_code = self._extract_field(result, "status_code")
if status_code != 200:
message = self._extract_field(result, "message")
raise RuntimeError(f"ASR transcription failed: {message}")
sentence = self._extract_sentence_payload(result)
if sentence is None:
request_id = self._extract_field(result, "request_id")
logger.warning(
"ASR returned empty result", extra={"request_id": request_id}
)
return ""
if isinstance(sentence, dict):
transcription = sentence.get("text", "")
elif isinstance(sentence, list):
transcription = " ".join(
item.get("text", "") for item in sentence if isinstance(item, dict)
)
else:
transcription = str(sentence) if sentence else ""
logger.info(
"ASR transcription completed",
extra={"filename": filename, "transcript_length": len(transcription)},
)
return transcription
except asyncio.CancelledError:
raise
except RuntimeError:
raise
except Exception as exc:
logger.exception("ASR transcription error")
raise RuntimeError(f"ASR transcription failed: {exc}") from exc
def _extract_sentence_payload(self, result: Any) -> Any | None:
if isinstance(result, dict):
output = result.get("output")
if isinstance(output, dict):
return output.get("sentence")
if output is not None:
return getattr(output, "sentence", None)
return result.get("sentence")
get_sentence = getattr(result, "get_sentence", None)
if callable(get_sentence):
sentence = get_sentence()
if sentence is not None:
return sentence
output = getattr(result, "output", None)
if output is None:
return None
if isinstance(output, dict):
return output.get("sentence")
return getattr(output, "sentence", None)
def _extract_field(self, result: Any, field: str) -> Any | None:
if isinstance(result, dict):
return result.get(field)
return getattr(result, field, None)
asr_service = AsrService()
def _mime_to_suffix(mime_type: str) -> str:
mapping = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
}
return mapping.get(mime_type.lower(), "bin")
def _is_safe_attachment_path(path: str, *, expected_prefix: str) -> bool:
normalized = path.strip()
if not normalized:
return False
if normalized.startswith("/"):
return False
if ".." in normalized:
return False
return normalized.startswith(expected_prefix)
+25
View File
@@ -14,6 +14,11 @@ from schemas.messages.chat_message import (
extract_user_message_attachments, extract_user_message_attachments,
) )
ALLOWED_ATTACHMENT_MIME_TYPES = {"image/png", "image/jpeg", "image/webp"}
MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024
MAX_TOTAL_ATTACHMENT_BYTES = 12 * 1024 * 1024
MAX_ATTACHMENTS_PER_MESSAGE = 3
def convert_message_to_history( def convert_message_to_history(
message: AgentChatMessage, message: AgentChatMessage,
@@ -124,3 +129,23 @@ def _compile_worker_ui_hints(
return compiled return compiled
except Exception: except Exception:
return None return None
def mime_to_suffix(mime_type: str) -> str:
mapping = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
}
return mapping.get(mime_type.lower(), "bin")
def is_safe_attachment_path(path: str, *, expected_prefix: str) -> bool:
normalized = path.strip()
if not normalized:
return False
if normalized.startswith("/"):
return False
if ".." in normalized:
return False
return normalized.startswith(expected_prefix)
+3
View File
@@ -0,0 +1,3 @@
from v1.memory.service import MemoryService
__all__ = ["MemoryService"]
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Protocol
from uuid import UUID
from sqlalchemy import select
from core.db.base_repository import BaseRepository
from models.automation_jobs import AutomationJob
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
class MemoryRepositoryLike(Protocol):
async def get_job_by_id_and_owner(
self, *, job_id: UUID, owner_id: UUID
) -> AutomationJob | None: ...
class MemoryRepository(BaseRepository[AutomationJob]):
def __init__(self, session: AsyncSession) -> None:
super().__init__(session=session, model=AutomationJob)
async def get_job_by_id_and_owner(
self, *, job_id: UUID, owner_id: UUID
) -> AutomationJob | None:
stmt = (
select(AutomationJob)
.where(AutomationJob.id == job_id)
.where(AutomationJob.owner_id == owner_id)
.where(AutomationJob.deleted_at.is_(None))
)
result = await self._session.execute(stmt)
return result.scalar_one_or_none()
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
from uuid import UUID
from fastapi import HTTPException
from schemas.automation.config import AutomationJobConfig
from v1.memory.repository import MemoryRepositoryLike
class MemoryService:
_repository: MemoryRepositoryLike
def __init__(self, repository: MemoryRepositoryLike) -> None:
self._repository = repository
async def get_memory_job_config(
self, *, job_id: UUID, owner_id: UUID
) -> AutomationJobConfig:
job = await self._repository.get_job_by_id_and_owner(
job_id=job_id, owner_id=owner_id
)
if job is None:
raise HTTPException(status_code=404, detail="Automation job not found")
return AutomationJobConfig.model_validate(job.config or {})
@@ -2,7 +2,7 @@ from __future__ import annotations
import pytest import pytest
from core.agentscope.runtime.consumer_registry import build_consumer_registry from core.agentscope.runtime.registry_builder import build_consumer_registry
def test_build_consumer_registry_from_system_agent_configs() -> None: def test_build_consumer_registry_from_system_agent_configs() -> None:
@@ -45,17 +45,6 @@ def _user_context() -> UserContext:
) )
def test_parse_agent_type_supports_known_stages() -> None:
assert AgentScopeRunner._parse_agent_type(stage_name="router") == AgentType.ROUTER
assert AgentScopeRunner._parse_agent_type(stage_name="worker") == AgentType.WORKER
assert AgentScopeRunner._parse_agent_type(stage_name="memory") == AgentType.MEMORY
def test_parse_agent_type_rejects_unknown_stage() -> None:
with pytest.raises(ValueError, match="unsupported stage name"):
AgentScopeRunner._parse_agent_type(stage_name="planner")
def test_build_worker_input_messages_only_contains_router_contract() -> None: def test_build_worker_input_messages_only_contains_router_contract() -> None:
runner = AgentScopeRunner() runner = AgentScopeRunner()
router_output = RouterAgentOutput( router_output = RouterAgentOutput(
+3 -4
View File
@@ -5,14 +5,13 @@ import sys
import pytest import pytest
from core.taskiq.app import broker, bulk_broker, critical_broker, default_broker from core.taskiq.app import broker, worker_agent_broker, worker_automation_broker
def test_taskiq_broker_is_configured() -> None: def test_taskiq_broker_is_configured() -> None:
assert broker is not None assert broker is not None
assert default_broker is broker assert worker_agent_broker is broker
assert critical_broker is not None assert worker_automation_broker is not None
assert bulk_broker is not None
def test_taskiq_app_configures_logging_on_import( def test_taskiq_app_configures_logging_on_import(
@@ -2,13 +2,12 @@ from __future__ import annotations
import pytest import pytest
from schemas.agent.consumer_registry import AgentConsumerBinding, ConsumerRegistry from core.agentscope.schemas.consumer_registry import (
from schemas.agent.pipeline_spec import ( AgentConsumerBinding,
ContextPolicy, ConsumerRegistry,
ExecutorKind,
PipelineSpec,
StageSpec,
) )
from core.agentscope.schemas.pipeline_spec import ExecutorKind, PipelineSpec, StageSpec
from schemas.agent.system_agent import AgentType
def test_consumer_registry_rejects_duplicate_bits() -> None: def test_consumer_registry_rejects_duplicate_bits() -> None:
@@ -29,9 +28,9 @@ def test_pipeline_spec_requires_non_empty_stages() -> None:
def test_stage_spec_normalizes_stage_name() -> None: def test_stage_spec_normalizes_stage_name() -> None:
spec = StageSpec( spec = StageSpec(
stage_name=" Worker ", stage_name=" Worker ",
agent_type=AgentType.WORKER,
executor_kind=ExecutorKind.REACT, executor_kind=ExecutorKind.REACT,
default_visibility_mask=1,
context_policy=ContextPolicy(consumer_agent_type="worker", count=20),
) )
assert spec.stage_name == "worker" assert spec.stage_name == "worker"
assert spec.agent_type == AgentType.WORKER
+5 -17
View File
@@ -159,7 +159,7 @@ def _user() -> CurrentUser:
) )
def _build_run_input(*, urls: list[str], agent_type: str = "worker") -> RunAgentInput: def _build_run_input(*, urls: list[str], runtime_mode: str = "chat") -> RunAgentInput:
content: list[dict[str, str]] = [{"type": "text", "text": "hello"}] content: list[dict[str, str]] = [{"type": "text", "text": "hello"}]
for url in urls: for url in urls:
content.append({"type": "binary", "mimeType": "image/png", "url": url}) content.append({"type": "binary", "mimeType": "image/png", "url": url})
@@ -177,7 +177,7 @@ def _build_run_input(*, urls: list[str], agent_type: str = "worker") -> RunAgent
], ],
"tools": [], "tools": [],
"context": [], "context": [],
"forwardedProps": {"agent_type": agent_type}, "forwardedProps": {"runtime_mode": runtime_mode},
} }
) )
@@ -275,7 +275,7 @@ async def test_enqueue_run_rejects_unknown_agent_type(monkeypatch) -> None:
urls=[ urls=[
f"{base_url}/storage/v1/object/sign/agent-test-bucket/{safe_path}?token=1" f"{base_url}/storage/v1/object/sign/agent-test-bucket/{safe_path}?token=1"
], ],
agent_type="planner", runtime_mode="planner",
) )
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
@@ -285,7 +285,7 @@ async def test_enqueue_run_rejects_unknown_agent_type(monkeypatch) -> None:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_enqueue_run_rejects_memory_mode_for_api(monkeypatch) -> None: async def test_enqueue_run_rejects_invalid_runtime_mode(monkeypatch) -> None:
monkeypatch.setattr( monkeypatch.setattr(
agent_service_module.config.storage, "bucket", "agent-test-bucket" agent_service_module.config.storage, "bucket", "agent-test-bucket"
) )
@@ -296,24 +296,12 @@ async def test_enqueue_run_rejects_memory_mode_for_api(monkeypatch) -> None:
stream=_FakeStream(), stream=_FakeStream(),
attachment_storage=_FakeAttachmentStorage(), attachment_storage=_FakeAttachmentStorage(),
) )
base_url = str(config.supabase.url).rstrip("/") run_input = _build_run_input(urls=[], runtime_mode="planner")
safe_path = quote(
"agent-inputs/00000000-0000-0000-0000-000000000001/"
"00000000-0000-0000-0000-000000000001/uploads/a.png"
)
run_input = _build_run_input(
urls=[
f"{base_url}/storage/v1/object/sign/agent-test-bucket/{safe_path}?token=1"
],
agent_type="memory",
)
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
await service.enqueue_run(run_input=run_input, current_user=_user()) await service.enqueue_run(run_input=run_input, current_user=_user())
assert exc_info.value.status_code == 422 assert exc_info.value.status_code == 422
assert exc_info.value.detail == "memory mode is automation-only"
assert repository.created_session_calls == 0
assert repository.persisted_user_messages == [] assert repository.persisted_user_messages == []
@@ -1,310 +0,0 @@
# 前端导航解耦与统一缓存重构设计
## 1. 背景与问题定义
当前 `apps` 端在日历(日/月)与待办页面中存在以下系统性问题:
1. 页面切换语义错误:将业务 tab 切换实现为 `push/go` 混用,导致页面重建与路由栈膨胀。
2. 数据刷新触发错误:页面通过路由监听触发 `load`,频繁重复请求后端。
3. 状态职责耦合:导航状态、页面状态、数据状态边界不清,导致“切换逻辑改动会牵出数据 bug”。
4. 回主页语义不一致:Dock 首页按钮被 `canPop -> pop` 策略污染,行为变成“返回上页”。
5. 缓存能力分散:仅存在局部的个人信息缓存(`SettingsUserCache`),缺少统一可复用缓存模块。
目标是完成一次结构化重构,建立「解耦的导航切换 + 统一缓存 + 可控一致性」体系。
## 2. 目标与非目标
### 2.1 目标
1. Home/Calendar/Todo 切换不重建主页面(保持页面实例与滚动状态)。
2. 日/月视图切换不触发整页重建和无必要网络请求。
3. 建立统一缓存模块,合并个人信息缓存并覆盖 Calendar/Todo 数据读取。
4. 启动体验采用「本地优先 + 后台静默刷新」策略,减少进入 App 的重复请求。
5. 数据只在必要时刷新:手动下拉、写操作失效、生命周期关键点、缓存策略命中。
6. 主页按钮语义固定为“回主页”,不再变成“返回上一页”。
7. 一级页面唯一为 Home,日历日/月视图、待办、设置均为二级页面;二级页面侧滑只允许返回一级页面,不允许直接退出 App。
8. App 退出入口仅存在于一级页面(Home)。
### 2.2 非目标
1. 本次不改后端协议与接口契约。
2. 本次不引入复杂离线同步冲突解决(如多端 CRDT)。
3. 本次不引入全量本地数据库迁移(先基于 SharedPreferences 持久化层)。
## 3. 复杂度与风险分级
- Complexity: `S3`
- 跨 router、calendar、todo、settings、DI 的架构级调整。
- Risk Tier: `L1`
- 不触及鉴权协议和支付等高风险域,但涉及导航返回栈与数据一致性高回归区。
## 4. 架构总览
### 4.1 导航分层
采用分级导航:
1. 一级页面(唯一):Home
- 仅 Home 允许触发系统退出路径。
2. 二级页面(主业务入口)
- Calendar Day/Month
- Todo ListQuadrants
- Settings
- 规则:二级页面的系统返回/侧滑返回统一回 Home,不允许直接退出 App。
3. 三级页面(细节页)
- Calendar event detail/edit/share
- Todo detail/edit
- Settings 子页面(account/profile edit 等)
- 规则:三级页面返回到上一级(二级或三级上层)。
### 4.2 状态与数据边界
1. 导航状态:Shell 当前分支 index、Calendar 内部视图类型。
2. UI 状态:选中日期、滚动位置、拖拽态、loading/error。
3. 数据状态:统一缓存模块管理(内存 + 持久化 + 网络回写)。
结论:页面只发“意图”,不直接承担缓存与路由策略。
## 5. 统一缓存模块设计
## 5.1 模块结构
新增 `apps/lib/core/cache/`
1. `cache_key.dart`
- 统一 key 命名规范。
2. `cache_policy.dart`
- TTL、软/硬过期、最小刷新间隔、刷新原因枚举。
3. `cache_entry.dart`
- 标准缓存实体(data/fetchedAt/expiresAt/version/dirty)。
4. `cache_store.dart`
- 抽象接口(get/set/remove/invalidateNamespace)。
5. `memory_cache_store.dart`
- 会话级热缓存。
6. `persistent_cache_store.dart`
- 本地冷缓存(SharedPreferences JSON)。
7. `hybrid_cache_store.dart`
- 两级缓存协调与 singleflight 去重。
8. `cache_invalidator.dart`
- 统一精准失效入口。
### 5.2 key 设计(首版)
1. 用户信息
- `user:profile:me`
2. 日历
- `calendar:day:YYYY-MM-DD`
- `calendar:month:YYYY-MM`
3. 待办
- `todo:list:pending`
- `todo:list:priority:<n>`(按需)
- `todo:detail:<id>`(按需)
### 5.3 策略设计(平衡型)
读取顺序:`memory -> persistent -> network`
刷新策略:
1. 软过期(stale-while-revalidate
- 先展示缓存,后台静默刷新。
2. 硬过期
- 超过硬过期后必须请求网络或提示数据过旧。
3. 最小刷新间隔
- 避免频繁切换/回前台引发抖动请求。
建议默认值:
1. `user:profile`:软过期 30min,硬过期 24h。
2. `calendar:day`:软过期 2min,硬过期 30min。
3. `calendar:month`:软过期 5min,硬过期 60min。
4. `todo:list:pending`:软过期 2min,硬过期 30min。
### 5.4 个人信息缓存合并方案
现有 `SettingsUserCache` 并入统一缓存模块:
1. 新建 `UserProfileRepository`(或在现有 settings service 中引入统一缓存)。
2. `getProfile()` 通过 hybrid cache 获取 `user:profile:me`
3. 更新 profile 成功后立即写回缓存并同步持久化。
4. 登出/会话失效时统一调用 `invalidateNamespace('user')`
## 6. 一致性风险与解决方案
平衡型缓存会存在“短暂陈旧窗口”。本设计通过以下机制将体验风险降到可接受范围。
### 6.1 触发刷新矩阵
1. 手动下拉刷新:强制网络刷新。
2. 写操作成功:精准失效受影响 key 并触发回填。
3. App 回前台:若超过最小刷新间隔,触发静默刷新。
4. 网络离线 -> 在线:触发静默刷新。
5. 进入关键详情页:按策略进行 freshness check。
### 6.2 写后一致性
1. 乐观更新:本地先更新 UI 与缓存,避免“我刚改完却没变”。
2. 失败回滚:API 失败时恢复旧值并 Toast 提示。
3. 精准失效:不做全局清空,只失效关联 key,兼顾一致性与性能。
### 6.3 并发安全
1. singleflight:同 key 同时只允许一个网络请求。
2. 版本保护:缓存写入比较 `updatedAt/version`,拒绝旧响应覆盖新状态。
3. 失败兜底:请求失败不清空旧缓存,保持可读并允许重试。
### 6.4 可见性保障
1. 页面可显示“上次同步时间”(轻提示)。
2. 硬过期数据需可见提醒(弱提示,不阻断基础浏览)。
3. 提供稳定手动刷新入口。
### 6.5 日历提醒取消动作的一致性兜底
1. 用户在提醒弹层点击“取消/归档”时,前端必须立即发送归档请求,要求后端立刻将事件归档/过期。
2. “延迟归档(outbox/pending)”仅在 App 进程不可用(被杀/未启动)时生效,作为离线或冷启动兜底。
3. App 冷启动或恢复前台后,必须优先冲刷 pending 归档请求,确保最终一致性。
4. 对用户可见行为要求:点击取消后 UI 立即反映归档状态,网络失败时展示重试提示,并保留 pending 记录。
## 7. 导航与页面职责重构
### 7.1 路由重构
1. `app_router` 引入 shell 分支,不再平铺所有主页面。
2. Dock 切换改为 branch index 切换,不再 `push` 主页面。
3. Calendar 内部 month/day 切换改为视图切换,不新增栈层。
4. 事件详情/编辑等保留 `push`(细节页合理叠栈)。
### 7.2 回主页逻辑修正
1. Dock Home 统一执行“切到 Home 分支/`go('/home')`”。
2. `returnToHomePreserveState` 仅用于非 Dock 的返回策略场景。
3. 消除 `canPop -> pop` 对主页按钮语义的影响。
4. 二级页面(Calendar Day/Month、Todo、Settings)统一拦截系统返回和侧滑返回,目标固定为 Home。
5. App 退出只允许在 Home 页面生效(可采用双击退出或系统默认行为)。
### 7.3 页面职责收敛
1. Calendar/Todo 页面移除路由监听触发 `load`
2. 页面只调用 repository
- `get(policy)`
- `refresh(force: true)`
- `mutate(...) + invalidate(...)`
3. 页面不直接感知“缓存在哪一层”。
## 8. 分阶段实施计划(里程碑)
### M1 导航壳层与切换语义
1. 引入 shell + 分支保活。
2. Dock 接口改造与主 tab 切换实现。
3. Home 按钮语义修正。
4. 建立分级返回约束:二级 -> Home,三级 -> 上一级,退出仅 Home。
### M2 统一缓存骨架
1. 新增 core cache 模块。
2. 接入 user profile(替换 `SettingsUserCache`)。
3. DI 注入 cache store 与 invalidator。
### M3 Calendar 接入
1. 引入 `CalendarRepository` 与 day/month key。
2. 移除 route listener 自动刷新。
3. 切换 month/day 时默认走缓存,不触发无必要请求。
### M4 Todo 接入
1. 引入 `TodoRepository` 与 list/detail key。
2. 拖拽、完成、编辑后的精准失效。
3. 下拉刷新走强制网络。
### M5 清理与验证
1. 清理旧缓存与重复加载逻辑。
2. 补齐测试与性能观测。
3. 评估参数并收敛默认策略。
4. 验证提醒“点击取消即实时归档”与“App 关闭时延迟归档兜底”双路径。
## 9. 验收标准
### 9.1 体验验收
1. Home/Calendar/Todo 切换无明显重建卡顿。
2. 日/月切换响应明显变快。
3. 首次冷启动可先看到本地缓存内容。
4. Dock Home 始终回主页。
5. 二级页面侧滑返回永远回 Home,不直接退出 App。
### 9.2 网络验收
1. 切换页面时网络请求显著减少。
2. 写操作后关联数据可及时更新。
3. 手动刷新可强制拉取并回写缓存。
4. 提醒取消动作触发实时归档请求,成功率可观测。
### 9.3 一致性验收
1. 不出现旧响应覆盖新数据。
2. 离线后恢复在线可自动静默同步。
3. 软过期/硬过期行为符合策略定义。
4. 提醒归档在在线/离线/冷启动场景下保持最终一致。
## 10. 测试与验证计划
### 10.1 单元测试
1. `hybrid_cache_store`:命中链路、singleflight、软硬过期判定。
2. `cache_invalidator`:写操作触发的 key 精准失效。
3. repository:读缓存、后台刷新、失败兜底、版本保护。
### 10.2 组件/页面测试(高回归)
1. Dock 切换不重建分支主页面。
2. 日/月切换不重复触发全量加载。
3. Home 按钮行为稳定。
4. 二级页面系统返回不会触发 App 退出。
### 10.3 集成回归
1. Calendar -> Todo -> Calendar 多轮切换请求计数。
2. Todo 完成后列表更新与缓存一致性。
3. profile 更新后设置页/其他依赖页可见一致。
4. 提醒取消 -> 立即归档 -> 日历列表刷新链路。
5. App 杀进程后触发提醒,重启后 pending 归档自动冲刷。
## 11. 风险与回滚
### 11.1 主要风险
1. 导航壳层改造可能引发深链与返回栈回归。
2. 缓存策略参数不当可能造成陈旧感。
3. 早期失效 key 设计不完整可能出现局部不刷新。
### 11.2 控制策略
1. 按里程碑逐步落地,每个里程碑可单独回滚。
2. 默认保留手动刷新兜底。
3. 增加请求计数与缓存命中日志(开发态)。
### 11.3 回滚策略
1. 若 M1 不稳定,可先回退 shell 改造并保留缓存模块。
2. 若缓存接入问题集中,可按域回退(user/calendar/todo 分域开关)。
## 12. 最终落地参数(2026-03-20
1. 导航分级
- 一级页面唯一为 `Home`
- 二级页面(日/月、待办、设置)侧滑返回统一回 `Home`,不允许直接退出 App。
- App 退出入口仅保留在 `Home`
2. 缓存默认策略
- `user:profile`:软过期 30min,硬过期 24h。
- `calendar:day`:软过期 2min,硬过期 30min。
- `calendar:month`:软过期 5min,硬过期 60min。
- `todo:list:pending`:软过期 2min,硬过期 30min。
3. 生命周期刷新
- App 回前台时启用最小间隔 5min 的静默刷新协调器。
4. 提醒归档策略
- App 活跃态点击取消:立即请求后端归档。
- 延迟归档(pending/outbox)仅用于 App 不可用场景兜底。
@@ -1,537 +0,0 @@
# 前端导航解耦与统一缓存重构 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 完成导航分级回退(一级唯一 Home)与统一缓存改造,实现本地优先显示、后台静默刷新、写后精准失效,并落地“提醒取消即实时归档 + App 关闭时延迟归档兜底”。
**Architecture:** 路由层采用“一级唯一 Home + 二级业务页 + 三级细节页”的分级返回模型,二级页面返回统一回 Home,退出入口仅 Home;数据层新增 `core/cache` 统一缓存模块(memory + persistent + hybrid);业务层通过 repository 接入缓存策略与失效器。提醒动作采用实时归档优先,pending outbox 仅用于 App 不可用场景兜底。
**Tech Stack:** Flutter, go_router, get_it, shared_preferences, flutter_test, mocktail
---
### Task 0: 锁定导航分级与退出语义(一级/二级/三级)
**Files:**
- Modify: `apps/lib/core/router/app_router.dart`
- Modify: `apps/lib/features/home/ui/navigation/home_return_policy.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/todo/ui/screens/todo_quadrants_screen.dart`
- Modify: `apps/lib/features/settings/ui/screens/settings_screen.dart`
- Test: `apps/test/features/home/ui/navigation/home_return_policy_test.dart`
**Step 1: Write the failing test**
```dart
test('second-level pages should return to home instead of exiting app', () {
final action = resolveHomeReturnAction(
canPop: false,
isAuthEntry: false,
forceGoHome: true,
);
expect(action, HomeReturnAction.goHome);
});
```
**Step 2: Run test to verify it fails**
Run: `cd apps && flutter test test/features/home/ui/navigation/home_return_policy_test.dart`
Expected: FAIL with old return behavior.
**Step 3: Write minimal implementation**
```dart
if (forceGoHome) return HomeReturnAction.goHome;
```
**Step 4: Run tests to verify they pass**
Run: `cd apps && flutter test test/features/home/ui/navigation/home_return_policy_test.dart`
Expected: PASS.
**Step 5: Commit**
```bash
git add apps/lib/core/router/app_router.dart apps/lib/features/home/ui/navigation/home_return_policy.dart apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart apps/lib/features/calendar/ui/screens/calendar_month_screen.dart apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart apps/lib/features/settings/ui/screens/settings_screen.dart apps/test/features/home/ui/navigation/home_return_policy_test.dart
git commit -m "feat: enforce hierarchical back navigation and home-only exit"
```
### Task 1: 建立统一缓存核心模型与策略
**Files:**
- Create: `apps/lib/core/cache/cache_entry.dart`
- Create: `apps/lib/core/cache/cache_key.dart`
- Create: `apps/lib/core/cache/cache_policy.dart`
- Test: `apps/test/core/cache/cache_policy_test.dart`
**Step 1: Write the failing test**
```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/cache/cache_policy.dart';
void main() {
test('soft expired should allow stale read with background refresh', () {
final now = DateTime(2026, 3, 20, 12);
final policy = CachePolicy(
softTtl: const Duration(minutes: 2),
hardTtl: const Duration(minutes: 30),
minRefreshInterval: const Duration(minutes: 1),
);
final fetchedAt = now.subtract(const Duration(minutes: 3));
final decision = policy.evaluate(now: now, fetchedAt: fetchedAt);
expect(decision.canUseCached, true);
expect(decision.shouldRefreshInBackground, true);
expect(decision.mustBlockForNetwork, false);
});
}
```
**Step 2: Run test to verify it fails**
Run: `cd apps && flutter test test/core/cache/cache_policy_test.dart`
Expected: FAIL with missing cache policy symbols.
**Step 3: Write minimal implementation**
```dart
class CacheDecision {
final bool canUseCached;
final bool shouldRefreshInBackground;
final bool mustBlockForNetwork;
const CacheDecision({
required this.canUseCached,
required this.shouldRefreshInBackground,
required this.mustBlockForNetwork,
});
}
```
**Step 4: Run test to verify it passes**
Run: `cd apps && flutter test test/core/cache/cache_policy_test.dart`
Expected: PASS.
**Step 5: Commit**
```bash
git add apps/lib/core/cache/cache_entry.dart apps/lib/core/cache/cache_key.dart apps/lib/core/cache/cache_policy.dart apps/test/core/cache/cache_policy_test.dart
git commit -m "feat: add unified cache policy primitives"
```
### Task 2: 实现 memory/persistent/hybrid cache store(含 singleflight
**Files:**
- Create: `apps/lib/core/cache/cache_store.dart`
- Create: `apps/lib/core/cache/memory_cache_store.dart`
- Create: `apps/lib/core/cache/persistent_cache_store.dart`
- Create: `apps/lib/core/cache/hybrid_cache_store.dart`
- Test: `apps/test/core/cache/hybrid_cache_store_test.dart`
**Step 1: Write the failing test**
```dart
test('same key concurrent load should execute loader once', () async {
var calls = 0;
final store = HybridCacheStore(...);
Future<String> loader() async {
calls += 1;
return 'ok';
}
await Future.wait([
store.getOrLoad<String>('k', loader: loader),
store.getOrLoad<String>('k', loader: loader),
]);
expect(calls, 1);
});
```
**Step 2: Run test to verify it fails**
Run: `cd apps && flutter test test/core/cache/hybrid_cache_store_test.dart`
Expected: FAIL with missing HybridCacheStore.
**Step 3: Write minimal implementation**
```dart
final Map<String, Future<dynamic>> _inflight = {};
```
**Step 4: Run test to verify it passes**
Run: `cd apps && flutter test test/core/cache/hybrid_cache_store_test.dart`
Expected: PASS.
**Step 5: Commit**
```bash
git add apps/lib/core/cache/cache_store.dart apps/lib/core/cache/memory_cache_store.dart apps/lib/core/cache/persistent_cache_store.dart apps/lib/core/cache/hybrid_cache_store.dart apps/test/core/cache/hybrid_cache_store_test.dart
git commit -m "feat: implement hybrid cache store with singleflight"
```
### Task 3: 接入 DI 与统一失效器
**Files:**
- Create: `apps/lib/core/cache/cache_invalidator.dart`
- Modify: `apps/lib/core/di/injection.dart`
- Test: `apps/test/core/cache/cache_invalidator_test.dart`
**Step 1: Write the failing test**
```dart
test('invalidate calendar day should also invalidate month key', () {
final inv = CacheInvalidator(...);
inv.invalidateCalendarDay(DateTime(2026, 3, 20));
expect(inv.wasInvalidated('calendar:day:2026-03-20'), true);
expect(inv.wasInvalidated('calendar:month:2026-03'), true);
});
```
**Step 2: Run test to verify it fails**
Run: `cd apps && flutter test test/core/cache/cache_invalidator_test.dart`
Expected: FAIL.
**Step 3: Write minimal implementation**
```dart
class CacheInvalidator {
void invalidateCalendarDay(DateTime date) { /* invalidate day + month */ }
}
```
**Step 4: Run test to verify it passes**
Run: `cd apps && flutter test test/core/cache/cache_invalidator_test.dart`
Expected: PASS.
**Step 5: Commit**
```bash
git add apps/lib/core/cache/cache_invalidator.dart apps/lib/core/di/injection.dart apps/test/core/cache/cache_invalidator_test.dart
git commit -m "refactor: wire unified cache and invalidator in di"
```
### Task 4: 合并个人信息缓存(替换 SettingsUserCache
**Files:**
- Modify: `apps/lib/features/settings/data/services/settings_user_cache.dart`
- Create: `apps/lib/features/settings/data/services/user_profile_cache_repository.dart`
- Modify: `apps/lib/features/settings/ui/screens/settings_screen.dart`
- Test: `apps/test/features/settings/data/services/settings_user_cache_test.dart`
- Create: `apps/test/features/settings/data/services/user_profile_cache_repository_test.dart`
**Step 1: Write the failing test**
```dart
test('repository should return persistent cache first then refresh in background', () async {
// Arrange cached profile in persistent store
// Assert immediate cached result + refresh called once
});
```
**Step 2: Run test to verify it fails**
Run: `cd apps && flutter test test/features/settings/data/services/user_profile_cache_repository_test.dart`
Expected: FAIL.
**Step 3: Write minimal implementation**
```dart
class UserProfileCacheRepository {
Future<UserResponse> getProfile({bool forceRefresh = false}) async { ... }
}
```
**Step 4: Run tests to verify they pass**
Run: `cd apps && flutter test test/features/settings/data/services/settings_user_cache_test.dart test/features/settings/data/services/user_profile_cache_repository_test.dart`
Expected: PASS.
**Step 5: Commit**
```bash
git add apps/lib/features/settings/data/services/settings_user_cache.dart apps/lib/features/settings/data/services/user_profile_cache_repository.dart apps/lib/features/settings/ui/screens/settings_screen.dart apps/test/features/settings/data/services/settings_user_cache_test.dart apps/test/features/settings/data/services/user_profile_cache_repository_test.dart
git commit -m "refactor: merge profile cache into unified cache repository"
```
### Task 5: 路由改造为 StatefulShellRoute + Dock 切换分支
**Files:**
- Modify: `apps/lib/core/router/app_router.dart`
- Modify: `apps/lib/core/router/app_routes.dart`
- Modify: `apps/lib/features/calendar/ui/widgets/bottom_dock.dart`
- Modify: `apps/lib/features/home/ui/navigation/home_return_policy.dart`
- Test: `apps/test/core/router/app_routes_test.dart`
- Modify: `apps/test/features/home/ui/navigation/home_return_policy_test.dart`
**Step 1: Write the failing test**
```dart
test('dock home action should always resolve to goHome', () {
final action = resolveHomeReturnAction(canPop: true, isAuthEntry: false);
expect(action, HomeReturnAction.goHomeForDock);
});
```
**Step 2: Run test to verify it fails**
Run: `cd apps && flutter test test/features/home/ui/navigation/home_return_policy_test.dart`
Expected: FAIL with old behavior.
**Step 3: Write minimal implementation**
```dart
enum HomeReturnAction { pop, goHome, goHomeForDock }
```
**Step 4: Run tests to verify they pass**
Run: `cd apps && flutter test test/core/router/app_routes_test.dart test/features/home/ui/navigation/home_return_policy_test.dart`
Expected: PASS.
**Step 5: Commit**
```bash
git add apps/lib/core/router/app_router.dart apps/lib/core/router/app_routes.dart apps/lib/features/calendar/ui/widgets/bottom_dock.dart apps/lib/features/home/ui/navigation/home_return_policy.dart apps/test/core/router/app_routes_test.dart apps/test/features/home/ui/navigation/home_return_policy_test.dart
git commit -m "feat: switch main navigation to stateful shell tabs"
```
### Task 6: Calendar repository 化并移除路由监听刷新
**Files:**
- Create: `apps/lib/features/calendar/data/services/calendar_repository.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/calendar/ui/calendar_state_manager.dart`
- Create: `apps/test/features/calendar/data/services/calendar_repository_test.dart`
**Step 1: Write the failing test**
```dart
test('getDayEvents returns cache immediately and refreshes in background', () async {
// Arrange cache day key
// Assert cached list emitted before network completion
});
```
**Step 2: Run test to verify it fails**
Run: `cd apps && flutter test test/features/calendar/data/services/calendar_repository_test.dart`
Expected: FAIL.
**Step 3: Write minimal implementation**
```dart
class CalendarRepository {
Future<List<ScheduleItemModel>> getDayEvents(DateTime date, {bool forceRefresh = false}) async { ... }
}
```
**Step 4: Run tests to verify they pass**
Run: `cd apps && flutter test test/features/calendar/data/services/calendar_repository_test.dart`
Expected: PASS.
**Step 5: Commit**
```bash
git add apps/lib/features/calendar/data/services/calendar_repository.dart apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart apps/lib/features/calendar/ui/screens/calendar_month_screen.dart apps/lib/features/calendar/ui/calendar_state_manager.dart apps/test/features/calendar/data/services/calendar_repository_test.dart
git commit -m "refactor: decouple calendar screens from route-driven reload"
```
### Task 7: Todo repository 化与写后精准失效
**Files:**
- Create: `apps/lib/features/todo/data/todo_repository.dart`
- Modify: `apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart`
- Modify: `apps/lib/features/todo/data/todo_api.dart`
- Create: `apps/test/features/todo/todo_repository_test.dart`
- Modify: `apps/test/features/todo/quadrant_drag_test.dart`
**Step 1: Write the failing test**
```dart
test('complete todo should optimistically update and invalidate pending list key', () async {
// assert local list updated first, invalidator called
});
```
**Step 2: Run test to verify it fails**
Run: `cd apps && flutter test test/features/todo/todo_repository_test.dart`
Expected: FAIL.
**Step 3: Write minimal implementation**
```dart
class TodoRepository {
Future<void> completeTodo(String id) async { ... }
}
```
**Step 4: Run tests to verify they pass**
Run: `cd apps && flutter test test/features/todo/todo_repository_test.dart test/features/todo/quadrant_drag_test.dart`
Expected: PASS.
**Step 5: Commit**
```bash
git add apps/lib/features/todo/data/todo_repository.dart apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart apps/lib/features/todo/data/todo_api.dart apps/test/features/todo/todo_repository_test.dart apps/test/features/todo/quadrant_drag_test.dart
git commit -m "feat: add todo cache repository and precise invalidation"
```
### Task 8: App 生命周期与网络恢复刷新策略
**Files:**
- Create: `apps/lib/core/cache/cache_refresh_coordinator.dart`
- Modify: `apps/lib/main.dart`
- Create: `apps/test/core/cache/cache_refresh_coordinator_test.dart`
**Step 1: Write the failing test**
```dart
test('resume should trigger refresh only when min interval elapsed', () {
// Arrange last refreshed timestamp
// Assert callback invocation count
});
```
**Step 2: Run test to verify it fails**
Run: `cd apps && flutter test test/core/cache/cache_refresh_coordinator_test.dart`
Expected: FAIL.
**Step 3: Write minimal implementation**
```dart
class CacheRefreshCoordinator with WidgetsBindingObserver { ... }
```
**Step 4: Run test to verify it passes**
Run: `cd apps && flutter test test/core/cache/cache_refresh_coordinator_test.dart`
Expected: PASS.
**Step 5: Commit**
```bash
git add apps/lib/core/cache/cache_refresh_coordinator.dart apps/lib/main.dart apps/test/core/cache/cache_refresh_coordinator_test.dart
git commit -m "feat: add app lifecycle refresh coordinator"
```
### Task 9: 提醒取消实时归档与延迟归档兜底收敛
**Files:**
- Modify: `apps/lib/features/calendar/reminders/reminder_action_executor.dart`
- Modify: `apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart`
- Modify: `apps/lib/core/notifications/local_notification_service.dart`
- Modify: `apps/lib/main.dart`
- Modify: `apps/test/features/calendar/reminders/reminder_action_executor_test.dart`
- Modify: `apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart`
**Step 1: Write the failing test**
```dart
test('archive action should send remote archive immediately when app active', () async {
// Arrange active app + online gateway
// Act archive action
// Assert remote archive called once and local pending outbox not created
});
```
**Step 2: Run test to verify it fails**
Run: `cd apps && flutter test test/features/calendar/reminders/reminder_action_executor_test.dart`
Expected: FAIL with delayed-only behavior.
**Step 3: Write minimal implementation**
```dart
if (isAppActive) {
await repository.archiveNow(eventId);
} else {
await outbox.enqueueArchive(eventId);
}
```
**Step 4: Run tests to verify they pass**
Run: `cd apps && flutter test test/features/calendar/reminders/reminder_action_executor_test.dart test/features/calendar/reminders/reminder_notification_bridge_test.dart`
Expected: PASS.
**Step 5: Commit**
```bash
git add apps/lib/features/calendar/reminders/reminder_action_executor.dart apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart apps/lib/core/notifications/local_notification_service.dart apps/lib/main.dart apps/test/features/calendar/reminders/reminder_action_executor_test.dart apps/test/features/calendar/reminders/reminder_notification_bridge_test.dart
git commit -m "fix: prioritize realtime reminder archive with cold-start fallback"
```
### Task 10: 全量验证与文档同步
**Files:**
- Modify: `docs/protocols/*`(仅当路由/数据契约文档需更新时)
- Modify: `docs/plans/2026-03-20-navigation-cache-decoupling-design.md`(回填最终参数)
**Step 1: Run focused tests**
Run:
```bash
cd apps && flutter test test/core/cache test/features/settings/data/services/settings_user_cache_test.dart test/features/calendar test/features/todo test/features/home/ui/navigation/home_return_policy_test.dart test/core/router/app_routes_test.dart
```
Expected: PASS.
**Step 2: Run app-level verification**
Run: `cd apps && flutter test`
Expected: PASS.
**Step 3: Static checks**
Run: `cd apps && flutter analyze`
Expected: No errors.
**Step 4: Manual verification checklist**
1. 冷启动先显示缓存,随后静默更新。
2. Home/Calendar/Todo 来回切换不重建主页面。
3. 日/月切换不触发无必要请求。
4. Dock Home 始终回主页。
5. 写后数据可见一致,失败可回滚提示。
6. 二级页面侧滑返回只回 Home,不直接退出。
7. 提醒点击取消时立刻归档;仅在 App 不可用时走 pending 兜底。
**Step 5: Commit**
```bash
git add docs/plans/2026-03-20-navigation-cache-decoupling-design.md docs/protocols
git commit -m "docs: finalize navigation decoupling and unified cache rollout"
```
## 实施顺序约束
1. 必须先完成 Task 0-3 再改业务页面(否则会出现重复实现)。
2. Task 0(分级返回)与 Task 5(路由壳层)要分开提交,便于单独回滚。
3. 每个 Task 的测试必须在本 Task 完成后立即执行,避免堆积回归。
4. 不允许在未通过 focused tests 的情况下进入全量验证。
## 回滚策略
1. 若返回语义回归:先回滚 Task 0 提交,再评估 Task 5。
2. 若缓存一致性异常:优先回滚 Task 6/7 的 repository 接入提交。
3. 若生命周期刷新过于频繁:关闭 Task 8 coordinator 挂载,保留手动刷新兜底。
4. 若提醒实时归档异常:回滚 Task 9,仅保留 outbox 兜底路径。
## Done 定义
1. 所有测试与 analyze 通过。
2. 主页按钮行为稳定,无“返回上一页”误行为。
3. 切换页面请求数明显下降,写后一致性符合设计预期。
4. 统一缓存已接管用户信息、日历、待办三域。
5. 二级页面不再可直接侧滑退出 App。
6. 提醒归档满足“实时优先、关闭兜底”策略。
@@ -1,221 +0,0 @@
# Reminder Overlay 设计文档
## 概述
重构日历提醒机制,简化前台/后台判断逻辑,将所有提醒交互统一到独立的 ReminderOverlay 组件处理。
## 背景
当前实现复杂,涉及:
- App 启动状态判断(前台/后台)
- 离线归档请求队列 + 指数退避重试
- 通知权限降级(Android Timer 模拟)
- 聚合通知批量操作
新方案利用 iOS/Android 原生通知分组能力,实现:
- 每条通知独立 payload,点击哪条处理哪条
- 统一的 ReminderOverlay 处理所有用户交互
- 操作完成后 app 退到后台
## 设计决策
| 决策项 | 选择 |
|--------|------|
| 关闭 overlay 后的行为 | 回到首页,保持缓存状态 |
| 同分钟多条通知处理 | 按点击顺序处理当前,剩余按时间排序 |
| iOS 冷启动 payload 传递 | UserDefaultsApp Groups 方案) |
| Android 通知展示 | Full-screen intent(锁屏也弹窗) |
| 稍后提醒时间选项 | 5 分钟、15 分钟(下拉选项) |
| "完成"按钮行为 | 归档 + 关闭 + 退后台 |
| "稍后提醒"按钮行为 | 弹出选项 + 延后通知 + 关闭 + 退后台 |
| UI 组件 | 新建 ReminderOverlay(不复用现有) |
## 核心流程
```
通知到达 → 用户点击通知 →
├─ App 已运行 → 恢复前台 → 直接收到 payload → 打开 ReminderOverlay
└─ App 未运行 →
├─ iOS: 原生层写入 UserDefaults → Flutter 启动时读取
└─ Android: full-screen intent 启动 → Flutter 收到 payload
ReminderOverlay 显示:
- 日程标题
- 当前时间
- [稍后提醒 ▼] | [完成]
用户操作:
├─ 完成 → 归档请求 → 关闭 overlay → 退后台
└─ 稍后提醒 → 选择时间 → 取消当前通知 + 注册新通知 → 关闭 overlay → 退后台
处理完当前 → 检查同分钟是否有多条 →
├─ 有 → 打开下一条的 ReminderOverlay
└─ 无 → 保持退后台状态
```
## 移除的组件
| 组件 | 文件路径 | 移除原因 |
|------|----------|----------|
| ReminderColdStartQueue | `lib/features/calendar/reminders/reminder_cold_start_queue.dart` | 不需要后台重放机制 |
| ReminderOutboxStore | `lib/features/calendar/reminders/reminder_outbox_store.dart` | 不需要离线归档队列 |
| ReminderForegroundPresenter | `lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart` | 不需要前台判断 |
| ReminderPresentationCoordinator | `lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart` | 不需要防重复展示 |
| ReminderActionDedupeStore | `lib/features/calendar/reminders/reminder_action_dedupe_store.dart` | 通知原生幂等 |
| ReminderOverlapPolicy | `lib/features/calendar/reminders/reminder_overlap_policy.dart` | 改为原生分组 |
| Android Timer 模拟逻辑 | `LocalNotificationService` 内 | 不需要权限降级 |
## 新增组件
### ReminderOverlay
独立的状态管理页面,处理提醒交互。
**职责**
- 显示日程标题和当前时间
- 提供"稍后提醒"下拉选项(5分钟/15分钟)
- 提供"完成"按钮(归档)
- 处理完成后关闭 overlay
**文件位置**`lib/features/calendar/reminders/ui/reminder_overlay.dart`
### ReminderQueueManager
管理同分钟多条通知的处理队列。
**职责**
- 存储同分钟的通知列表
- 按点击顺序跟踪当前处理项
- 处理完当前后调度下一项
**文件位置**`lib/features/calendar/reminders/reminder_queue_manager.dart`
### IOSNotificationPayloadBridge
iOS 冷启动时从 UserDefaults 读取 notification payload。
**职责**
- App 启动时检查是否有待处理的通知 launch
- 读取 payload 并打开对应的 ReminderOverlay
- 处理完成后清理 UserDefaults
**文件位置**`lib/core/notifications/ios_notification_payload_bridge.dart`
## 平台差异处理
### iOS
1. **通知点击启动 App**
- 配置 `setPluginRegistrantCallback`(已有)
- iOS 原生层将 payload 写入 UserDefaults
- Flutter 启动时 `IOSNotificationPayloadBridge` 读取数据
2. **通知分组**
- 使用 `threadIdentifier` 分组
- 同一分钟的通知使用相同的 `threadIdentifier`
### Android
1. **Full-screen intent**
- `AndroidNotificationDetails` 设置 `fullScreenIntent: true`
- 锁屏时直接弹出全屏 overlay
2. **通知分组**
- 使用 `groupKey` 分组
- 同一分钟的通知使用相同的 `groupKey`
## API 变化
### 归档请求
仍然使用现有的 `CalendarService.archiveEvent()`,但不再需要失败重试逻辑。
```
POST /api/v1/calendar/events/{eventId}/archive
```
### 通知 Payload
```json
{
"eventId": "evt_xxx",
"title": "日程标题",
"startAt": "2026-03-20T10:00:00Z",
"endAt": "2026-03-20T11:00:00Z",
"timezone": "Asia/Shanghai",
"mode": "single",
"fireTimeBucket": 1774060800000
}
```
## 数据流
### 通知发送流程(不变)
```
CalendarService.upsertEventReminder()
→ LocalNotificationService.upsertEventReminder()
→ flutter_local_notifications.zonedSchedule()
```
### 通知点击处理流程
```
用户点击通知
├─ App 运行中 → onDidReceiveNotificationResponse(payload)
└─ App 未运行
├─ iOS → 原生写入 UserDefaults → Flutter 启动 → 读取 → 打开 overlay
└─ Android → full-screen intent → Flutter 收到 payload → 打开 overlay
ReminderOverlay 打开
├─ 用户点击"完成" → archiveEvent() → 关闭 → 检查队列 → 有下一条则打开下一条
└─ 用户点击"稍后提醒" → cancelNotification() + scheduleReminderAt() → 关闭 → 检查队列 → 有下一条则打开下一条
```
## 错误处理
| 场景 | 处理方式 |
|------|----------|
| 归档请求失败 | 显示 toast 提示用户,操作已完成(下次打开 app 时同步) |
| 延后通知注册失败 | 显示 toast 提示用户,当前提醒已取消 |
| 同分钟多条处理时其中一条失败 | 跳过该条,处理下一条 |
## 文件变更清单
### 删除
- `lib/features/calendar/reminders/reminder_cold_start_queue.dart`
- `lib/features/calendar/reminders/reminder_outbox_store.dart`
- `lib/features/calendar/reminders/reminder_action_dedupe_store.dart`
- `lib/features/calendar/reminders/reminder_overlap_policy.dart`
- `lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart`
- `lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart`
- `lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart`
- 相关测试文件
### 新增
- `lib/features/calendar/reminders/ui/reminder_overlay.dart`
- `lib/features/calendar/reminders/reminder_queue_manager.dart`
- `lib/core/notifications/ios_notification_payload_bridge.dart`
### 修改
- `lib/core/notifications/local_notification_service.dart`(移除权限降级逻辑)
- `lib/main.dart`(集成 IOSNotificationPayloadBridge
- 相关测试文件
## 测试策略
### 单元测试
- ReminderQueueManager: 队列排序、下一条调度
- IOSNotificationPayloadBridge: payload 读写
### 集成测试
- 通知点击 → overlay 打开 → 操作 → 关闭
- 同分钟多条通知顺序处理
### 手动测试
- iOS 冷启动点击通知
- Android 锁屏点击 full-screen intent 通知
- 稍后提醒 5 分钟/15 分钟验证
@@ -1,760 +0,0 @@
# Reminder Overlay Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 简化日历提醒机制,用 ReminderOverlay 统一处理所有用户交互,移除前台/后台判断逻辑。
**Architecture:** 每条通知独立 payload,点击通知后打开 ReminderOverlay 处理用户操作(完成/稍后提醒)。同分钟多条通知按时间排序依次处理。操作完成后 app 退到后台。iOS 冷启动通过 UserDefaults 传递 payloadAndroid 使用 full-screen intent。
**Tech Stack:** Flutter, flutter_local_notifications, Provider/Bloc (状态管理)
---
## Task 1: 创建 ReminderQueueManager
**Files:**
- Create: `apps/lib/features/calendar/reminders/reminder_queue_manager.dart`
- Test: `apps/test/features/calendar/reminders/reminder_queue_manager_test.dart`
**Step 1: 编写测试**
```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/calendar/reminders/reminder_queue_manager.dart';
import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart';
void main() {
group('ReminderQueueManager', () {
test('按点击顺序处理,第一条处理完后处理剩余的按时间排序', () {
final manager = ReminderQueueManager();
final event1 = ReminderPayload(eventId: '1', title: 'Event 1', startAt: DateTime(2026, 3, 20, 10, 1), mode: ReminderPayloadMode.single);
final event2 = ReminderPayload(eventId: '2', title: 'Event 2', startAt: DateTime(2026, 3, 20, 10, 2), mode: ReminderPayloadMode.single);
final event3 = ReminderPayload(eventId: '3', title: 'Event 3', startAt: DateTime(2026, 3, 20, 10, 3), mode: ReminderPayloadMode.single);
// 用户点击 event2
manager.enqueueFromClick(event2);
// 剩余 event1 和 event3 按时间排序: event1 -> event3
manager.enqueuePending([event1, event3]);
expect(manager.currentPayload?.eventId, '2');
manager.dequeueCurrent();
expect(manager.currentPayload?.eventId, '1');
manager.dequeueCurrent();
expect(manager.currentPayload?.eventId, '3');
manager.dequeueCurrent();
expect(manager.isEmpty, true);
});
test('单条通知处理完直接清空', () {
final manager = ReminderQueueManager();
final event = ReminderPayload(eventId: '1', title: 'Event 1', startAt: DateTime.now(), mode: ReminderPayloadMode.single);
manager.enqueueFromClick(event);
expect(manager.isEmpty, false);
manager.dequeueCurrent();
expect(manager.isEmpty, true);
});
});
}
```
**Step 2: 运行测试验证失败**
Run: `cd apps && flutter test test/features/calendar/reminders/reminder_queue_manager_test.dart`
Expected: FAIL - ReminderQueueManager not defined
**Step 3: 编写最小实现**
```dart
import 'reminder_payload.dart';
class ReminderQueueManager {
ReminderPayload? _currentPayload;
final List<ReminderPayload> _pending = [];
void enqueueFromClick(ReminderPayload payload) {
_currentPayload = payload;
}
void enqueuePending(List<ReminderPayload> payloads) {
payloads.sort((a, b) => a.startAt.compareTo(b.startAt));
_pending.addAll(payloads);
}
ReminderPayload? get currentPayload => _currentPayload;
bool get isEmpty => _currentPayload == null && _pending.isEmpty;
void dequeueCurrent() {
_currentPayload = null;
if (_pending.isNotEmpty) {
_currentPayload = _pending.removeAt(0);
}
}
void clear() {
_currentPayload = null;
_pending.clear();
}
}
```
**Step 4: 运行测试验证通过**
Run: `cd apps && flutter test test/features/calendar/reminders/reminder_queue_manager_test.dart`
Expected: PASS
**Step 5: 提交**
```bash
git add apps/lib/features/calendar/reminders/reminder_queue_manager.dart apps/test/features/calendar/reminders/reminder_queue_manager_test.dart
git commit -m "feat(calendar): add ReminderQueueManager for handling multiple notifications"
```
---
## Task 2: 创建 IOSNotificationPayloadBridge
**Files:**
- Create: `apps/lib/core/notifications/ios_notification_payload_bridge.dart`
- Test: `apps/test/core/notifications/ios_notification_payload_bridge_test.dart`
**Step 1: 编写测试**
```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:social_app/core/notifications/ios_notification_payload_bridge.dart';
import 'dart:convert';
void main() {
group('IOSNotificationPayloadBridge', () {
test('启动时读取待处理的 notification payload', () async {
SharedPreferences.setMockInitialValues({
'pending_notification_payload': jsonEncode({
'eventId': 'evt_123',
'title': 'Test Event',
'startAt': '2026-03-20T10:00:00Z',
'mode': 'single',
}),
});
final prefs = await SharedPreferences.getInstance();
final bridge = IOSNotificationPayloadBridge(prefs);
final payload = await bridge.getPendingPayload();
expect(payload?.eventId, 'evt_123');
expect(payload?.title, 'Test Event');
});
test('处理完成后清理 UserDefaults', () async {
SharedPreferences.setMockInitialValues({
'pending_notification_payload': jsonEncode({
'eventId': 'evt_123',
'title': 'Test Event',
'startAt': '2026-03-20T10:00:00Z',
'mode': 'single',
}),
});
final prefs = await SharedPreferences.getInstance();
final bridge = IOSNotificationPayloadBridge(prefs);
await bridge.clearPendingPayload();
final remaining = prefs.getString('pending_notification_payload');
expect(remaining, isNull);
});
});
}
```
**Step 2: 运行测试验证失败**
Run: `cd apps && flutter test test/core/notifications/ios_notification_payload_bridge_test.dart`
Expected: FAIL - IOSNotificationPayloadBridge not defined
**Step 3: 编写最小实现**
```dart
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../../features/calendar/reminders/models/reminder_payload.dart';
class IOSNotificationPayloadBridge {
static const String _key = 'pending_notification_payload';
final SharedPreferences _prefs;
IOSNotificationPayloadBridge(this._prefs);
Future<ReminderPayload?> getPendingPayload() async {
final raw = _prefs.getString(_key);
if (raw == null || raw.isEmpty) {
return null;
}
try {
final json = Map<String, dynamic>.from(jsonDecode(raw) as Map);
return ReminderPayload.fromJson(json);
} catch (_) {
return null;
}
}
Future<void> clearPendingPayload() async {
await _prefs.remove(_key);
}
}
```
**Step 4: 运行测试验证通过**
Run: `cd apps && flutter test test/core/notifications/ios_notification_payload_bridge_test.dart`
Expected: PASS
**Step 5: 提交**
```bash
git add apps/lib/core/notifications/ios_notification_payload_bridge.dart apps/test/core/notifications/ios_notification_payload_bridge_test.dart
git commit -m "feat(notifications): add IOSNotificationPayloadBridge for cold start handling"
```
---
## Task 3: 创建 ReminderOverlay UI 组件
**Files:**
- Create: `apps/lib/features/calendar/reminders/ui/reminder_overlay.dart`
- Test: `apps/test/features/calendar/reminders/reminder_overlay_test.dart`
**Step 1: 编写测试**
```dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:social_app/features/calendar/reminders/ui/reminder_overlay.dart';
import 'package:social_app/features/calendar/reminders/reminder_queue_manager.dart';
import 'package:social_app/core/notifications/local_notification_service.dart';
import 'package:social_app/core/notifications/ios_notification_payload_bridge.dart';
import 'package:social_app/features/calendar/data/services/calendar_service.dart';
import 'package:social_app/features/calendar/reminders/reminder_queue_manager.dart';
import 'package:social_app/features/calendar/reminders/models/reminder_payload.dart';
void main() {
group('ReminderOverlay', () {
late ReminderQueueManager queueManager;
setUp(() {
SharedPreferences.setMockInitialValues({});
queueManager = ReminderQueueManager();
});
testWidgets('显示日程标题和当前时间', (tester) async {
final payload = ReminderPayload(
eventId: '1',
title: 'Test Meeting',
startAt: DateTime(2026, 3, 20, 10, 0),
mode: ReminderPayloadMode.single,
);
queueManager.enqueueFromClick(payload);
await tester.pumpWidget(
MaterialApp(
home: ReminderOverlay(
queueManager: queueManager,
onComplete: () {},
onSnooze: (minutes) {},
onArchive: () {},
),
),
);
expect(find.text('Test Meeting'), findsOneWidget);
// 当前时间显示在界面上
expect(find.textContaining(':'), findsWidgets);
});
testWidgets('点击完成按钮触发归档', (tester) async {
bool archiveCalled = false;
final payload = ReminderPayload(
eventId: '1',
title: 'Test Meeting',
startAt: DateTime(2026, 3, 20, 10, 0),
mode: ReminderPayloadMode.single,
);
queueManager.enqueueFromClick(payload);
await tester.pumpWidget(
MaterialApp(
home: ReminderOverlay(
queueManager: queueManager,
onComplete: () {},
onSnooze: (minutes) {},
onArchive: () => archiveCalled = true,
),
),
);
await tester.tap(find.text('完成'));
await tester.pump();
expect(archiveCalled, true);
});
testWidgets('点击稍后提醒显示下拉选项', (tester) async {
final payload = ReminderPayload(
eventId: '1',
title: 'Test Meeting',
startAt: DateTime(2026, 3, 20, 10, 0),
mode: ReminderPayloadMode.single,
);
queueManager.enqueueFromClick(payload);
await tester.pumpWidget(
MaterialApp(
home: ReminderOverlay(
queueManager: queueManager,
onComplete: () {},
onSnooze: (minutes) {},
onArchive: () {},
),
),
);
await tester.tap(find.text('稍后提醒'));
await tester.pumpAndSettle();
expect(find.text('5 分钟'), findsOneWidget);
expect(find.text('15 分钟'), findsOneWidget);
});
});
}
```
**Step 2: 运行测试验证失败**
Run: `cd apps && flutter test test/features/calendar/reminders/reminder_overlay_test.dart`
Expected: FAIL - ReminderOverlay not defined
**Step 3: 编写最小实现**
```dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../reminders/reminder_queue_manager.dart';
import '../../reminders/models/reminder_payload.dart';
class ReminderOverlay extends StatefulWidget {
const ReminderOverlay({
super.key,
required this.queueManager,
required this.onComplete,
required this.onSnooze,
required this.onArchive,
});
final ReminderQueueManager queueManager;
final VoidCallback onComplete;
final void Function(int minutes) onSnooze;
final VoidCallback onArchive;
@override
State<ReminderOverlay> createState() => _ReminderOverlayState();
}
class _ReminderOverlayState extends State<ReminderOverlay> {
bool _showSnoozeOptions = false;
final LayerLink _layerLink = LayerLink();
OverlayEntry? _overlayEntry;
ReminderPayload? get _currentPayload => widget.queueManager.currentPayload;
@override
void dispose() {
_hideSnoozeOptions();
super.dispose();
}
void _hideSnoozeOptions() {
_overlayEntry?.remove();
_overlayEntry = null;
setState(() {
_showSnoozeOptions = false;
});
}
void _showSnoozeDropdown() {
_hideSnoozeOptions();
_overlayEntry = OverlayEntry(
builder: (context) => Positioned(
width: 120,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: AppColors.borderSecondary),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_SnoozeOption(
label: '5 分钟',
onTap: () {
_hideSnoozeOptions();
widget.onSnooze(5);
},
),
Divider(height: 1, color: AppColors.borderSecondary),
_SnoozeOption(
label: '15 分钟',
onTap: () {
_hideSnoozeOptions();
widget.onSnooze(15);
},
),
],
),
),
),
),
);
Overlay.of(context).insert(_overlayEntry!);
setState(() {
_showSnoozeOptions = true;
});
}
void _handleComplete() {
widget.onArchive();
widget.queueManager.dequeueCurrent();
if (!widget.queueManager.isEmpty) {
// 下一条会通过外部状态管理打开新的 overlay
}
widget.onComplete();
}
void _handleSnooze(int minutes) {
widget.onSnooze(minutes);
widget.queueManager.dequeueCurrent();
if (!widget.queueManager.isEmpty) {
// 下一条会通过外部状态管理打开新的 overlay
}
widget.onComplete();
}
@override
Widget build(BuildContext context) {
final payload = _currentPayload;
if (payload == null) {
return const SizedBox.shrink();
}
return Container(
color: AppColors.white,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
payload.title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: AppColors.slate900,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.sm),
Text(
DateFormat('HH:mm').format(DateTime.now()),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppColors.slate500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.xl),
Row(
children: [
Expanded(
child: CompositedTransformTarget(
link: _layerLink,
child: AppButton(
text: '稍后提醒',
isOutlined: true,
onPressed: _showSnoozeDropdown,
),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: AppButton(
text: '完成',
onPressed: _handleComplete,
),
),
],
),
],
),
),
),
);
}
}
class _SnoozeOption extends StatelessWidget {
const _SnoozeOption({
required this.label,
required this.onTap,
});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.slate900,
),
),
),
);
}
}
```
**Step 4: 运行测试验证通过**
Run: `cd apps && flutter test test/features/calendar/reminders/reminder_overlay_test.dart`
Expected: PASS
**Step 5: 提交**
```bash
git add apps/lib/features/calendar/reminders/ui/reminder_overlay.dart apps/test/features/calendar/reminders/reminder_overlay_test.dart
git commit -m "feat(calendar): add ReminderOverlay UI component"
```
---
## Task 4: 修改 LocalNotificationService - 移除权限降级逻辑
**Files:**
- Modify: `apps/lib/core/notifications/local_notification_service.dart`
**Step 1: 阅读现有代码确认移除范围**
Read: `apps/lib/core/notifications/local_notification_service.dart`
**Step 2: 移除以下逻辑**
1. 移除 `_canDeliverSystemNotification` 相关判断
2. 移除 `_scheduleInAppFallbackRemindersFrom` 方法
3. 移除 `_scheduleInAppFallbackPayload` 方法
4. 移除 `_inAppFallbackTimersByEventId` 及相关方法
5. 移除 `_trackFallback` 方法
6. 移除 `rebuildUpcomingReminders` 中的降级分支
**Step 3: 验证 flutter analyze 通过**
Run: `cd apps && flutter analyze lib/core/notifications/local_notification_service.dart`
Expected: 无错误
**Step 4: 提交**
```bash
git add apps/lib/core/notifications/local_notification_service.dart
git commit -m "refactor(notifications): remove permission fallback logic"
```
---
## Task 5: 修改通知发送逻辑 - 使用原生分组
**Files:**
- Modify: `apps/lib/core/notifications/local_notification_service.dart`
**Step 1: 添加 threadIdentifier/groupKey 到通知详情**
`_buildNotificationDetails` 方法中:
- iOS: 添加 `threadIdentifier: _getThreadIdentifier(fireAt)`
- Android: 添加 `groupKey: _getGroupKey(fireAt)`
**Step 2: 实现分组 key 方法**
```dart
String _getThreadIdentifier(DateTime fireAt) {
final bucket = fireAt.millisecondsSinceEpoch ~/ const Duration(minutes: 1).inMilliseconds;
return 'calendar_reminder_$bucket';
}
String _getGroupKey(DateTime fireAt) {
final bucket = fireAt.millisecondsSinceEpoch ~/ const Duration(minutes: 1).inMilliseconds;
return 'com.socialapp.calendar.$bucket';
}
```
**Step 3: 验证 flutter analyze 通过**
Run: `cd apps && flutter analyze lib/core/notifications/local_notification_service.dart`
Expected: 无错误
**Step 4: 提交**
```bash
git add apps/lib/core/notifications/local_notification_service.dart
git commit -m "feat(notifications): add native notification grouping by time bucket"
```
---
## Task 6: 修改 main.dart - 集成 iOS payload bridge
**Files:**
- Modify: `apps/lib/main.dart`
**Step 1: 在 main() 中添加启动时检查**
`runApp` 之前或 app 初始化时:
1. 创建 IOSNotificationPayloadBridge 实例
2. 调用 `getPendingPayload()`
3. 如果有 payload,通过 ReminderQueueManager 处理
**Step 2: 验证 flutter analyze 通过**
Run: `cd apps && flutter analyze lib/main.dart`
Expected: 无错误
**Step 3: 提交**
```bash
git add apps/lib/main.dart
git commit -m "feat(ios): integrate IOSNotificationPayloadBridge for cold start handling"
```
---
## Task 7: 删除废弃组件
**Files:**
- Delete: `apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart`
- Delete: `apps/lib/features/calendar/reminders/reminder_outbox_store.dart`
- Delete: `apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart`
- Delete: `apps/lib/features/calendar/reminders/reminder_overlap_policy.dart`
- Delete: `apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart`
- Delete: `apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart`
- Delete: `apps/lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart`
**Step 1: 删除文件**
```bash
git rm apps/lib/features/calendar/reminders/reminder_cold_start_queue.dart
git rm apps/lib/features/calendar/reminders/reminder_outbox_store.dart
git rm apps/lib/features/calendar/reminders/reminder_action_dedupe_store.dart
git rm apps/lib/features/calendar/reminders/reminder_overlap_policy.dart
git rm apps/lib/features/calendar/reminders/ui/reminder_foreground_presenter.dart
git rm apps/lib/features/calendar/reminders/ui/reminder_presentation_coordinator.dart
git rm apps/lib/features/calendar/reminders/ui/widgets/reminder_action_sheet.dart
```
**Step 2: 删除测试文件**
```bash
git rm apps/test/features/calendar/reminders/reminder_cold_start_queue_test.dart
git rm apps/test/features/calendar/reminders/reminder_outbox_store_test.dart
git rm apps/test/features/calendar/reminders/reminder_action_dedupe_store_test.dart
git rm apps/test/features/calendar/reminders/reminder_overlap_policy_test.dart
git rm apps/test/features/calendar/reminders/reminder_foreground_presenter_test.dart
git rm apps/test/features/calendar/reminders/reminder_presentation_coordinator_test.dart
git rm apps/test/features/calendar/reminders/reminder_action_sheet_test.dart
```
**Step 3: 运行 flutter analyze 验证无引用错误**
Run: `cd apps && flutter analyze`
Expected: 无错误(可能有 deprecated warnings 可以忽略)
**Step 4: 提交**
```bash
git commit -m "refactor(calendar): remove deprecated reminder components"
```
---
## Task 8: iOS 原生层配置 (AppDelegate.swift)
**Files:**
- Modify: `apps/ios/Runner/AppDelegate.swift`
**Step 1: 添加 UserDefaults 写入逻辑**
`userNotificationCenter(_, didReceive, withCompletionHandler)` 中:
1. 获取 notification 的 `userInfo`
2. 将 payload 写入 `UserDefaults.standard`
3. Key: `pending_notification_payload`
**Step 2: 验证 Xcode build**
Run: `cd apps && flutter build ios --simulator --no-codesign 2>&1 | head -50`
Expected: Build 成功
**Step 3: 提交**
```bash
git add apps/ios/Runner/AppDelegate.swift
git commit -m "feat(ios): write notification payload to UserDefaults on cold start"
```
---
## 验证清单
完成所有任务后,运行以下验证:
```bash
# 1. Flutter analyze
cd apps && flutter analyze
# 2. 测试
cd apps && flutter test test/features/calendar/reminders/
# 3. iOS build
cd apps && flutter build ios --simulator --no-codesign
# 4. Android build
cd apps && flutter build apk --debug
```
---
## Plan Complete
**Plan saved to:** `docs/plans/2026-03-20-reminder-overlay-implementation-plan.md`
**Two execution options:**
**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration
**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints
**Which approach?**
+151
View File
@@ -0,0 +1,151 @@
# 可见性掩码重构方案
> 日期:2026-03-22
> 状态:待执行
## 背景
现有可见性系统存在以下问题:
- `UI_REALTIME` 定义但从未使用
- `visibility_consumer_bit` 语义模糊,用于 context 过滤但无法正确区分 chat/automation
- stage bits (16/17/18) 在 chat/automation 永不共享 thread 的设计下无意义
- 无法正确实现:automation user_message 不进 /history、不进 contextautomation agent_reply 进 /history 但不进 context
## 设计目标
| runtime_mode | 消息 | /history 可见 | context_messages 组装 |
|-------------|------|:-------------:|:-------------------:|
| chat | user_message | ✅ | ✅ |
| chat | agent_reply | ✅ | ✅ |
| automation | user_message | ❌ | ❌ |
| automation | agent_reply | ✅ | ❌ |
## 前提条件
- chat 和 automation **永不共享 thread_id**(已确认的设计约束)
- memory == automation,无需单独处理
---
## Bit 定义
```
BIT 0 → UI_HISTORY → /history API 可见
BIT 1 → CONTEXT_ASSEMBLY → 组装进 context_messages
```
> `UI_REALTIME` 废弃,删除。
> `visibility_consumer_bit` 废弃,删除。
> Stage bits (16/17/18) 废弃,删除。
---
## 消息 Mask 矩阵
| 消息 | runtime_mode | UI_HISTORY | CONTEXT_ASSEMBLY | Mask |
|------|-------------|:----------:|:---------------:|:----:|
| user_message | chat | 1 | 1 | **3** |
| user_message | automation | 0 | 0 | **0** |
| agent_reply | chat | 1 | 1 | **3** |
| agent_reply | automation | 1 | 0 | **1** |
---
## 查询设计
| 查询 | Mask | 匹配规则 |
|------|------|---------|
| `/history` | `UI_HISTORY = 1` | `(message_mask & 1) != 0` |
| `context_messages` | `CONTEXT_ASSEMBLY = 2` | `(message_mask & 2) != 0` |
---
## 查询结果验证
| 消息 | Mask | `/history & 1` | `/history` | `context & 2` | `context` |
|------|------|:-------------:|:----------:|:-------------:|:---------:|
| chat user_message | 3 | 1 ✅ | ✅ | 1 ✅ | ✅ |
| chat agent_reply | 3 | 1 ✅ | ✅ | 1 ✅ | ✅ |
| automation user_message | 0 | 0 ❌ | ❌ | 0 ❌ | ❌ |
| automation agent_reply | 1 | 1 ✅ | ✅ | 0 ❌ | ❌ |
---
## 变更清单
### 1. `schemas/agent/visibility.py`
- [ ] 删除 `UI_REALTIME = 1``SystemVisibilityBit`
- [ ] 删除 `VisibilityBitRef`
- [ ] 保留 `bit_mask()` 函数
- [ ] 保留 `VisibilityMask` 类(其他模块可能使用)
### 2. `schemas/agent/system_agent.py`
- [ ] 删除 `SystemAgentLLMConfig.visibility_consumer_bit` 字段
### 3. `core/config/static/database/system_agents.yaml`
- [ ] 删除 `router.visibility_consumer_bit`
- [ ] 删除 `worker.visibility_consumer_bit`
### 4. `v1/agent/service.py`
- [ ] 重写 `_resolve_user_message_visibility_mask`
```python
async def _resolve_user_message_visibility_mask(
self, *, runtime_mode: RuntimeMode
) -> int:
if runtime_mode == RuntimeMode.CHAT:
return UI_HISTORY | CONTEXT_ASSEMBLY # = 3
return 0 # automation user_message
```
### 5. `core/agentscope/events/store.py`
- [ ] 重写 `_resolve_stage_visibility_mask`
- chat stage (router/worker) → `UI_HISTORY | CONTEXT_ASSEMBLY` = 3
- automation stage (memory) → `UI_HISTORY` = 1
- [ ] 删除 `_load_stage_visibility_bit_map` 中对 `visibility_consumer_bit` 的依赖
- [ ] 删除 `system_agents.yaml` 配置加载逻辑
### 6. `core/agentscope/runtime/context_service.py`
- [ ] `load_context_messages` 查询 mask 改为 `CONTEXT_ASSEMBLY = 2`
```python
visibility_mask = bit_mask(bit=int(SystemVisibilityBit.CONTEXT_ASSEMBLY))
```
### 7. `core/agentscope/runtime/tasks.py`
- [ ] 删除 `_build_recent_context_messages` 中 memory job 的特殊处理
- [ ] memory mode 改用 `runtime_mode=automation` 语义
### 8. `core/agentscope/runtime/runner.py`
- [ ] 删除硬编码 `visibility_consumer_bit=18` 的 `SystemAgentLLMConfig`
- [ ] memory agent 配置改用 automation 语义
### 9. 清理迁移
- [ ] 更新 `schemas/agent/__init__.py` 导出(删除 `visibility_consumer_bit` 相关)
- [ ] 更新所有引用 `visibility_consumer_bit` 的文件
- [ ] 运行测试验证 /history 和 context 组装行为
---
## 实施顺序
1. 新增 `CONTEXT_ASSEMBLY = 1` bit,更新 `service.py`
2. 更新 `events/store.py` 可见性逻辑
3. 更新 `context_service.py` 查询 mask
4. 清理废弃配置和字段
5. 运行测试验证
---
## 风险
- `VisibilityBitRef` 可能在其他未知位置使用(需全面搜索)
- `visibility_consumer_bit` 被 `runner.py` 硬编码,修改可能影响 memory pipeline
- 测试覆盖不足可能导致 regression
+4 -5
View File
@@ -27,8 +27,7 @@ Base URL: `/api/v1/agent`
- Body: `RunAgentInput` - Body: `RunAgentInput`
- 详细结构见 `docs/protocols/agent/run-agent-input.md` - 详细结构见 `docs/protocols/agent/run-agent-input.md`
- `forwardedProps.agent_type` 必填,由调用方透传,task 不做默认赋值 - `forwardedProps.runtime_mode` 必填,值为 `"chat"``"automation"`
- `agent_type=memory` 仅用于自动化调度内部触发,API 入口返回 422
### Response ### Response
@@ -83,9 +82,9 @@ Base URL: `/api/v1/agent`
当前阶段执行说明: 当前阶段执行说明:
- `worker` 模式采用两阶段:`router` -> `worker` - `chat` 模式采用两阶段:`router` -> `worker`
- `memory` 模式保持单阶段:`memory` - `automation` 模式由后端业务逻辑决定具体 Agent 类型
- 因此阶段事件可能出现 `router` / `worker` / `memory` - 因此阶段事件可能出现 `router` / `worker`
### 错误码 ### 错误码
+17 -11
View File
@@ -185,13 +185,13 @@ interface Context {
--- ---
## forwardedProps Schema(支持 agent_type + client_time ## forwardedProps Schema(支持 runtime_mode + client_time
`RunAgentInput.forwardedProps` 支持透传运行模式与客户端时间上下文。日历相关能力必须使用以下结构: `RunAgentInput.forwardedProps` 支持透传运行模式与客户端时间上下文。日历相关能力必须使用以下结构:
```typescript ```typescript
interface ForwardedProps { interface ForwardedProps {
agent_type: string; // 必填,运行模式(如 "worker" / "memory" runtime_mode: "chat" | "automation"; // 必填,运行模式
client_time?: { client_time?: {
device_timezone: string; // IANA 时区,例如 "America/Los_Angeles" device_timezone: string; // IANA 时区,例如 "America/Los_Angeles"
client_now_iso: string; // RFC3339 带偏移时间,例如 "2026-03-16T09:12:33-07:00" client_now_iso: string; // RFC3339 带偏移时间,例如 "2026-03-16T09:12:33-07:00"
@@ -200,6 +200,13 @@ interface ForwardedProps {
} }
``` ```
### 运行模式说明
| runtime_mode | 说明 | 后端 Pipeline |
|--------------|------|---------------|
| `chat` | 标准对话模式 | `router` -> `worker` |
| `automation` | 自动化任务模式 | 由后端业务逻辑决定具体 Agent 类型 |
### 时间来源优先级(固定) ### 时间来源优先级(固定)
后端在运行时按以下顺序解析事件时区: 后端在运行时按以下顺序解析事件时区:
@@ -214,9 +221,8 @@ interface ForwardedProps {
- `device_timezone` 必须是有效 IANA 时区。 - `device_timezone` 必须是有效 IANA 时区。
- `client_now_iso` 必须是 RFC3339 且包含时区偏移。 - `client_now_iso` 必须是 RFC3339 且包含时区偏移。
- `client_epoch_ms` 必须是整数毫秒时间戳。 - `client_epoch_ms` 必须是整数毫秒时间戳。
- `forwardedProps.agent_type` 必填,必须匹配后端已注册的 agent type - `forwardedProps.runtime_mode` 必填,必须`"chat"``"automation"`
- `agent_type=memory` 为自动化任务内部模式,HTTP `/agent/runs` 入口不接受该值 - `forwardedProps` 仅允许 `runtime_mode` `client_time`,额外字段会触发 `422 invalid RunAgentInput.forwardedProps`
- `forwardedProps` 仅允许 `agent_type``client_time`,额外字段会触发 `422 invalid RunAgentInput.forwardedProps`
- 业务代码不得使用服务器本地时区作为事件语义时区。 - 业务代码不得使用服务器本地时区作为事件语义时区。
### 说明 ### 说明
@@ -238,7 +244,7 @@ Backend 实现了以下验证规则:
| runId 最大 128 字符 | `runId exceeds length limit` | | runId 最大 128 字符 | `runId exceeds length limit` |
| messages ≤ 200 条 | `RunAgentInput.messages exceeds limit` | | messages ≤ 200 条 | `RunAgentInput.messages exceeds limit` |
| user text ≤ 10,000 字符 | `RunAgentInput user message text exceeds limit` | | user text ≤ 10,000 字符 | `RunAgentInput user message text exceeds limit` |
| forwardedProps.agent_type 必填 | `invalid RunAgentInput.forwardedProps` | | forwardedProps.runtime_mode 必填 | `invalid RunAgentInput.forwardedProps` |
| **恰好 1 条 user message** | `RunAgentInput.messages must contain exactly one user message` | | **恰好 1 条 user message** | `RunAgentInput.messages must contain exactly one user message` |
| user message 必须在第一条 | `RunAgentInput.messages[0].role must be user` | | user message 必须在第一条 | `RunAgentInput.messages[0].role must be user` |
| binary 必须是 image/* | `binary content requires image mimeType` | | binary 必须是 image/* | `binary content requires image mimeType` |
@@ -277,7 +283,7 @@ Backend 实现了以下验证规则:
"tools": [], "tools": [],
"context": [], "context": [],
"forwardedProps": { "forwardedProps": {
"agent_type": "worker" "runtime_mode": "chat"
} }
} }
``` ```
@@ -309,7 +315,7 @@ Backend 实现了以下验证规则:
"tools": [], "tools": [],
"context": [], "context": [],
"forwardedProps": { "forwardedProps": {
"agent_type": "worker" "runtime_mode": "chat"
} }
} }
``` ```
@@ -346,7 +352,7 @@ Backend 实现了以下验证规则:
], ],
"context": [], "context": [],
"forwardedProps": { "forwardedProps": {
"agent_type": "worker" "runtime_mode": "chat"
} }
} }
``` ```
@@ -368,7 +374,7 @@ Backend 实现了以下验证规则:
"tools": [], "tools": [],
"context": [], "context": [],
"forwardedProps": { "forwardedProps": {
"agent_type": "worker", "runtime_mode": "chat",
"client_time": { "client_time": {
"device_timezone": "America/Los_Angeles", "device_timezone": "America/Los_Angeles",
"client_now_iso": "2026-03-16T09:12:33-07:00", "client_now_iso": "2026-03-16T09:12:33-07:00",
@@ -531,5 +537,5 @@ interface UiSchemaRenderer {
- `tools` 是前端工具通道字段;当前后端运行时不基于该字段构造后端工具 prompt - `tools` 是前端工具通道字段;当前后端运行时不基于该字段构造后端工具 prompt
- `RunAgentInput` 同时接受 camelCase 与 snake_case 别名输入(推荐统一使用 camelCase) - `RunAgentInput` 同时接受 camelCase 与 snake_case 别名输入(推荐统一使用 camelCase)
- 日历能力依赖 `forwardedProps.client_time` 透传设备时间上下文;缺失时回退用户 profile 时区 - 日历能力依赖 `forwardedProps.client_time` 透传设备时间上下文;缺失时回退用户 profile 时区
- `forwardedProps.agent_type` 是受控路由字段,必须由调用方显式传入;后端 task 不做默认赋值 - `forwardedProps.runtime_mode` 是受控路由字段,必须由调用方显式传入;后端 task 不做默认赋值
- tool 消息在存储层用于运行时上下文续接,不在 `/history` 对外返回 - tool 消息在存储层用于运行时上下文续接,不在 `/history` 对外返回