From 5f36e3785cb77eae2b09c96709ca1f3339182a68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Fri, 21 Aug 2026 23:43:29 +0800 Subject: [PATCH] =?UTF-8?q?M2b=EF=BC=9A=E5=AF=BC=E5=85=A5=E6=94=B9?= =?UTF-8?q?=E9=80=A0=E4=BA=A7=E5=87=BA=E5=8F=AF=E5=AE=A1=E9=98=85=E7=81=B5?= =?UTF-8?q?=E8=83=9A=EF=BC=88=E5=90=AF=E9=95=9C=E7=89=A9=E5=8C=96=E4=B8=96?= =?UTF-8?q?=E8=B0=B1=EF=BC=89=E4=B8=8E=E7=BB=84=E8=A3=85=E7=AE=A1=E7=BA=BF?= =?UTF-8?q?=EF=BC=88=E7=AC=A6=E6=96=87=E5=B1=95=E5=BC=80/=E7=81=AB?= =?UTF-8?q?=E5=80=99=E8=BF=9B=E5=85=A5=20IR=EF=BC=89=EF=BC=8CKernel=20?= =?UTF-8?q?=E6=89=93=E9=80=9A=E9=87=87=E6=A0=B7=E5=8F=82=E6=95=B0=E9=93=BE?= =?UTF-8?q?=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/api/v1/endpoints/character_import.py | 38 +- backend/app/api/v1/endpoints/chat.py | 137 --- backend/app/api/v1/endpoints/time.py | 113 -- backend/app/application/chat/__init__.py | 0 backend/app/application/chat/send_message.py | 970 ------------------ backend/app/domains/jingsheng/service.py | 43 + backend/app/domains/mirror_tree/service.py | 72 +- backend/app/kernel/__init__.py | 10 +- backend/app/kernel/loop.py | 12 + backend/app/models/chat.py | 52 - backend/app/models/personality_emotion.py | 32 - backend/app/models/time_anniversary.py | 41 - backend/app/modules/chat/__init__.py | 0 backend/app/modules/chat/history.py | 18 - backend/app/modules/chat/policies.py | 11 - backend/app/modules/imports/mapping.py | 5 +- backend/app/modules/knocking/__init__.py | 40 - backend/app/modules/knocking/conditions.py | 573 ----------- backend/app/modules/knocking/creator.py | 99 -- backend/app/modules/knocking/opener.py | 190 ---- backend/app/modules/knocking/pivot.py | 366 ------- backend/app/repositories/chat.py | 200 ---- .../app/repositories/personality_emotion.py | 31 - .../app/repositories/time_anniversaries.py | 49 - backend/app/schemas/chat.py | 254 ----- backend/app/schemas/time.py | 84 -- backend/app/services/card_import.py | 150 +-- backend/app/services/chat.py | 252 ----- backend/app/services/personality_emotion.py | 141 --- backend/app/services/time_sense.py | 390 ------- .../tests/api/test_character_import_api.py | 97 -- .../application/chat/test_daoti_injection.py | 59 -- .../chat/test_emotion_injection.py | 48 - .../chat/test_lorebook_injection.py | 41 - .../chat/test_send_message_goals.py | 8 - .../application/test_send_message_temporal.py | 179 ---- backend/tests/modules/knocking/conftest.py | 195 ---- .../tests/modules/knocking/test_conditions.py | 303 ------ backend/tests/modules/knocking/test_keeper.py | 214 ---- .../test_personality_emotion_repo.py | 93 -- .../test_time_anniversaries_repo.py | 79 -- backend/tests/services/test_card_import.py | 66 -- .../test_emotion_dispatcher_integration.py | 87 -- .../test_personality_emotion_service.py | 226 ---- .../services/test_time_sense_integration.py | 112 -- .../tests/services/test_time_sense_service.py | 137 --- backend/tests/test_api_time.py | 87 -- backend/tests/test_audit.py | 56 - backend/tests/test_idempotency.py | 100 -- backend/tests/test_import_and_assembly.py | 208 ++++ backend/tests/test_send_message_agent.py | 206 ---- backend/tests/test_sse.py | 77 -- frontend/src/app/(app)/chat/page.tsx | 26 - frontend/src/app/(app)/time/page.tsx | 691 ------------- .../chat/ActiveCharacterHeader.test.tsx | 63 -- .../components/chat/ActiveCharacterHeader.tsx | 234 ----- .../chat/CharacterSwitcher.test.tsx | 43 - .../src/components/chat/CharacterSwitcher.tsx | 161 --- .../src/components/chat/ChatContextRail.tsx | 376 ------- .../src/components/chat/ChatInput.test.tsx | 101 -- frontend/src/components/chat/ChatInput.tsx | 368 ------- frontend/src/components/chat/ChatShell.tsx | 831 --------------- .../src/components/chat/FilePreviewPanel.tsx | 364 ------- .../components/chat/MarkdownContent.test.tsx | 34 - .../src/components/chat/MarkdownContent.tsx | 268 ----- frontend/src/components/chat/MermaidBlock.tsx | 423 -------- .../components/chat/MessageBubble.test.tsx | 137 --- .../src/components/chat/MessageBubble.tsx | 458 --------- .../src/components/chat/MessageList.test.tsx | 91 -- frontend/src/components/chat/MessageList.tsx | 85 -- frontend/src/components/chat/MirrorHero.tsx | 303 ------ .../components/chat/ModelSelector.test.tsx | 79 -- .../src/components/chat/ModelSelector.tsx | 144 --- frontend/src/components/chat/PlanPanel.tsx | 419 -------- .../components/chat/SandboxFilePreview.tsx | 293 ------ .../src/components/chat/SessionPlanCard.tsx | 232 ----- .../components/chat/SessionSidebar.test.tsx | 57 - .../src/components/chat/SessionSidebar.tsx | 568 ---------- .../src/components/chat/ThoughtBlock.test.tsx | 33 - frontend/src/components/chat/ThoughtBlock.tsx | 105 -- .../components/chat/ToolCallBlock.test.tsx | 96 -- .../src/components/chat/ToolCallBlock.tsx | 390 ------- frontend/src/components/chat/VersionPanel.tsx | 147 --- frontend/src/lib/api/chat.test.ts | 68 -- frontend/src/lib/api/chat.ts | 85 -- frontend/src/lib/api/time.ts | 55 - 86 files changed, 424 insertions(+), 14455 deletions(-) delete mode 100644 backend/app/api/v1/endpoints/chat.py delete mode 100644 backend/app/api/v1/endpoints/time.py delete mode 100644 backend/app/application/chat/__init__.py delete mode 100644 backend/app/application/chat/send_message.py delete mode 100644 backend/app/models/chat.py delete mode 100644 backend/app/models/personality_emotion.py delete mode 100644 backend/app/models/time_anniversary.py delete mode 100644 backend/app/modules/chat/__init__.py delete mode 100644 backend/app/modules/chat/history.py delete mode 100644 backend/app/modules/chat/policies.py delete mode 100644 backend/app/modules/knocking/__init__.py delete mode 100644 backend/app/modules/knocking/conditions.py delete mode 100644 backend/app/modules/knocking/creator.py delete mode 100644 backend/app/modules/knocking/opener.py delete mode 100644 backend/app/modules/knocking/pivot.py delete mode 100644 backend/app/repositories/chat.py delete mode 100644 backend/app/repositories/personality_emotion.py delete mode 100644 backend/app/repositories/time_anniversaries.py delete mode 100644 backend/app/schemas/chat.py delete mode 100644 backend/app/schemas/time.py delete mode 100644 backend/app/services/chat.py delete mode 100644 backend/app/services/personality_emotion.py delete mode 100644 backend/app/services/time_sense.py delete mode 100644 backend/tests/api/test_character_import_api.py delete mode 100644 backend/tests/application/chat/test_daoti_injection.py delete mode 100644 backend/tests/application/chat/test_emotion_injection.py delete mode 100644 backend/tests/application/chat/test_lorebook_injection.py delete mode 100644 backend/tests/application/chat/test_send_message_goals.py delete mode 100644 backend/tests/application/test_send_message_temporal.py delete mode 100644 backend/tests/modules/knocking/conftest.py delete mode 100644 backend/tests/modules/knocking/test_conditions.py delete mode 100644 backend/tests/modules/knocking/test_keeper.py delete mode 100644 backend/tests/repositories/test_personality_emotion_repo.py delete mode 100644 backend/tests/repositories/test_time_anniversaries_repo.py delete mode 100644 backend/tests/services/test_card_import.py delete mode 100644 backend/tests/services/test_emotion_dispatcher_integration.py delete mode 100644 backend/tests/services/test_personality_emotion_service.py delete mode 100644 backend/tests/services/test_time_sense_integration.py delete mode 100644 backend/tests/services/test_time_sense_service.py delete mode 100644 backend/tests/test_api_time.py delete mode 100644 backend/tests/test_audit.py delete mode 100644 backend/tests/test_idempotency.py create mode 100644 backend/tests/test_import_and_assembly.py delete mode 100644 backend/tests/test_send_message_agent.py delete mode 100644 backend/tests/test_sse.py delete mode 100644 frontend/src/app/(app)/chat/page.tsx delete mode 100644 frontend/src/app/(app)/time/page.tsx delete mode 100644 frontend/src/components/chat/ActiveCharacterHeader.test.tsx delete mode 100644 frontend/src/components/chat/ActiveCharacterHeader.tsx delete mode 100644 frontend/src/components/chat/CharacterSwitcher.test.tsx delete mode 100644 frontend/src/components/chat/CharacterSwitcher.tsx delete mode 100644 frontend/src/components/chat/ChatContextRail.tsx delete mode 100644 frontend/src/components/chat/ChatInput.test.tsx delete mode 100644 frontend/src/components/chat/ChatInput.tsx delete mode 100644 frontend/src/components/chat/ChatShell.tsx delete mode 100644 frontend/src/components/chat/FilePreviewPanel.tsx delete mode 100644 frontend/src/components/chat/MarkdownContent.test.tsx delete mode 100644 frontend/src/components/chat/MarkdownContent.tsx delete mode 100644 frontend/src/components/chat/MermaidBlock.tsx delete mode 100644 frontend/src/components/chat/MessageBubble.test.tsx delete mode 100644 frontend/src/components/chat/MessageBubble.tsx delete mode 100644 frontend/src/components/chat/MessageList.test.tsx delete mode 100644 frontend/src/components/chat/MessageList.tsx delete mode 100644 frontend/src/components/chat/MirrorHero.tsx delete mode 100644 frontend/src/components/chat/ModelSelector.test.tsx delete mode 100644 frontend/src/components/chat/ModelSelector.tsx delete mode 100644 frontend/src/components/chat/PlanPanel.tsx delete mode 100644 frontend/src/components/chat/SandboxFilePreview.tsx delete mode 100644 frontend/src/components/chat/SessionPlanCard.tsx delete mode 100644 frontend/src/components/chat/SessionSidebar.test.tsx delete mode 100644 frontend/src/components/chat/SessionSidebar.tsx delete mode 100644 frontend/src/components/chat/ThoughtBlock.test.tsx delete mode 100644 frontend/src/components/chat/ThoughtBlock.tsx delete mode 100644 frontend/src/components/chat/ToolCallBlock.test.tsx delete mode 100644 frontend/src/components/chat/ToolCallBlock.tsx delete mode 100644 frontend/src/components/chat/VersionPanel.tsx delete mode 100644 frontend/src/lib/api/chat.test.ts delete mode 100644 frontend/src/lib/api/chat.ts delete mode 100644 frontend/src/lib/api/time.ts diff --git a/backend/app/api/v1/endpoints/character_import.py b/backend/app/api/v1/endpoints/character_import.py index 93593bd..53e742c 100644 --- a/backend/app/api/v1/endpoints/character_import.py +++ b/backend/app/api/v1/endpoints/character_import.py @@ -1,29 +1,49 @@ -"""角色卡导入 / 导出 API。""" +"""角色卡导入 / 导出 API。 + +rewrite/1.0 M2b:导入产出**可审阅灵胚**(含 character_book 暂存),不再直接建人物; +经照心编辑与启镜(POST /embryo-drafts/{id}/activate)后才物化人物与世谱。 +""" import json from fastapi import APIRouter, Depends, File, Response, UploadFile from app.api.v1.deps import get_current_user +from app.domains import jingsheng from app.schemas.auth import CurrentUser -from app.schemas.character import ImportCardResponse -from app.services.card_import import export_character_card, import_character_card_response +from app.services.card_import import card_to_embryo_payload, export_character_card router = APIRouter(prefix="/characters", tags=["characters"]) -@router.post("/import-card", response_model=ImportCardResponse) +@router.post("/import-card", status_code=201) async def import_character_card_api( file: UploadFile = File(..., description="角色卡文件,支持 .png / .json / .charx。"), current_user: CurrentUser = Depends(get_current_user), -) -> ImportCardResponse: - """导入酒馆角色卡(V1/V2/V3),建立人物 + 世谱(character_book)。 +) -> dict: + """导入酒馆角色卡(V1/V2/V3)→ 生成可审阅灵胚。 - ``personality`` 写入道体 ``personality_summary`` 作初始值; - ``character_book`` 转为该角色的世谱书 + 条目。 + 卡内容整体进入灵胚道体草稿(identity_baseline.import_card), + ``character_book`` 在启镜时物化为该人物的世谱书与条目。 """ raw = await file.read() - return await import_character_card_response(raw, owner_scope=current_user.owner_scope) + payload = card_to_embryo_payload(raw) + book_entry_count = payload.pop("book_entry_count") + embryo = await jingsheng.create_embryo( + owner_scope=current_user.owner_scope, + source="imported", + name=payload["name"], + description=payload["description"], + identity_baseline=payload["identity_baseline"], + ) + return { + "embryo_id": embryo.id, + "status": embryo.status, + "name": embryo.name, + "source": embryo.source, + "book_entry_count": book_entry_count, + "next": "照心编辑 PATCH /embryo-drafts/{id},启镜 POST /embryo-drafts/{id}/activate", + } @router.get("/{character_id}/export-card") diff --git a/backend/app/api/v1/endpoints/chat.py b/backend/app/api/v1/endpoints/chat.py deleted file mode 100644 index f1529ac..0000000 --- a/backend/app/api/v1/endpoints/chat.py +++ /dev/null @@ -1,137 +0,0 @@ -"""聊天 API。""" - -from fastapi import APIRouter, Depends, Header, Query, Request, Response, status - -from app.api.v1.deps import get_current_user -from app.schemas.auth import CurrentUser -from app.schemas.chat import ( - ChatMessageListResponse, - ChatMessageResponse, - ChatSessionListResponse, - ChatSessionResponse, - CreateSessionRequest, - SelectVersionRequest, - SendMessageRequest, - SendMessageResponse, - UpdateSessionRequest, -) -from app.services.chat import ( - create_session, - delete_session, - get_session_response, - list_message_responses, - list_session_responses, - select_message_version, - send_message, - update_session_response, -) -from app.services.idempotency import run_idempotent - -router = APIRouter(prefix="/chat", tags=["chat"]) - - -@router.post("/sessions", response_model=ChatSessionResponse) -async def create_chat_session( - payload: CreateSessionRequest, - current_user: CurrentUser = Depends(get_current_user), -) -> ChatSessionResponse: - return await create_session(payload, owner_scope=current_user.owner_scope) - - -@router.get("/sessions", response_model=ChatSessionListResponse) -async def list_chat_sessions( - current_user: CurrentUser = Depends(get_current_user), - status: str | None = Query(default=None, description="按状态过滤:active / archived"), - limit: int = Query(default=50, ge=1, le=100), - offset: int = Query(default=0, ge=0), -) -> ChatSessionListResponse: - return await list_session_responses( - owner_scope=current_user.owner_scope, status=status, limit=limit, offset=offset - ) - - -@router.get("/sessions/{session_id}", response_model=ChatSessionResponse) -async def get_chat_session( - session_id: str, - current_user: CurrentUser = Depends(get_current_user), -) -> ChatSessionResponse: - return await get_session_response(session_id, owner_scope=current_user.owner_scope) - - -@router.patch("/sessions/{session_id}", response_model=ChatSessionResponse) -async def update_chat_session( - session_id: str, - payload: UpdateSessionRequest, - current_user: CurrentUser = Depends(get_current_user), -) -> ChatSessionResponse: - return await update_session_response(session_id, payload, owner_scope=current_user.owner_scope) - - -@router.delete("/sessions/{session_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_chat_session( - session_id: str, - current_user: CurrentUser = Depends(get_current_user), -) -> Response: - await delete_session(session_id, owner_scope=current_user.owner_scope) - return Response(status_code=status.HTTP_204_NO_CONTENT) - - -@router.post("/sessions/{session_id}/messages", response_model=SendMessageResponse) -async def send_chat_message( - session_id: str, - payload: SendMessageRequest, - request: Request, - idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"), - current_user: CurrentUser = Depends(get_current_user), -) -> SendMessageResponse: - return await run_idempotent( - key=idempotency_key, - method="POST", - path=str(request.url.path), - payload=payload, - response_type=SendMessageResponse, - resource_type="run", - handler=lambda session: send_message( - session, - session_id, - payload, - owner_scope=current_user.owner_scope, - tool_runtime=getattr(request.app.state, "tool_runtime", None), - confirmation_registry=getattr(request.app.state, "confirmation_registry", None), - confirmation_store=getattr(request.app.state, "confirmation_store", None), - event_store=getattr(request.app.state, "event_store", None), - agent_run_registry=getattr(request.app.state, "agent_run_registry", None), - python_skill_catalog=getattr(request.app.state, "python_skill_catalog", None), - ), - ) - - -@router.get("/sessions/{session_id}/messages", response_model=ChatMessageListResponse) -async def list_chat_messages( - session_id: str, - current_user: CurrentUser = Depends(get_current_user), - limit: int = Query(default=100, ge=1, le=200), - offset: int = Query(default=0, ge=0), -) -> ChatMessageListResponse: - return await list_message_responses( - session_id, owner_scope=current_user.owner_scope, limit=limit, offset=offset - ) - - -@router.patch( - "/sessions/{session_id}/messages/{message_id}/version", - response_model=ChatMessageResponse, -) -async def select_message_version_endpoint( - session_id: str, - message_id: str, - payload: SelectVersionRequest, - current_user: CurrentUser = Depends(get_current_user), -) -> ChatMessageResponse: - """选择/激活消息的某个历史版本。""" - return await select_message_version( - session_id, - message_id, - payload, - owner_scope=current_user.owner_scope, - ) diff --git a/backend/app/api/v1/endpoints/time.py b/backend/app/api/v1/endpoints/time.py deleted file mode 100644 index a4352ed..0000000 --- a/backend/app/api/v1/endpoints/time.py +++ /dev/null @@ -1,113 +0,0 @@ -"""辰正时态 API(设计 §10)。 - -挂载于 /api/v1/time。事件不走 REST(走 EventStore 订阅)。路由薄层:参数校验 → 调 -service → 转响应;session 由 service 自管(deps.py 惯例)。 -""" - -from fastapi import APIRouter, Depends, Request - -from app.api.v1.deps import get_current_user, get_event_store -from app.core.config import settings -from app.schemas.auth import CurrentUser -from app.schemas.time import ( - AcquaintanceResponse, - AnniversaryCreateRequest, - AnniversaryResponse, - AnniversaryUpdateRequest, - SnapshotResponse, - TimeConfigResponse, -) -from app.services.time_sense import ( - create_anniversary_for_active, - delete_anniversary_or_404, - get_acquaintance_for_active, - get_anniversary_or_404, - get_live_snapshot, - list_anniversaries_for_active, - update_anniversary_or_404, -) - -router = APIRouter(prefix="/time", tags=["time"]) - - -@router.get("/snapshot", response_model=SnapshotResponse) -async def get_snapshot( - request: Request, current_user: CurrentUser = Depends(get_current_user) -) -> SnapshotResponse: - """当前 TemporalSnapshot + ModulationFactors + context_text。""" - result = await get_live_snapshot(get_event_store(request), owner_scope=current_user.owner_scope) - return SnapshotResponse( - snapshot=result.snapshot, - factors=result.factors, - context_text=result.context_text, - ) - - -@router.get("/anniversaries", response_model=list[AnniversaryResponse]) -async def list_anniversaries_api( - current_user: CurrentUser = Depends(get_current_user), -) -> list[AnniversaryResponse]: - items = await list_anniversaries_for_active(owner_scope=current_user.owner_scope) - return [AnniversaryResponse.model_validate(a) for a in items] - - -@router.post("/anniversaries", response_model=AnniversaryResponse, status_code=201) -async def create_anniversary_api( - payload: AnniversaryCreateRequest, current_user: CurrentUser = Depends(get_current_user) -) -> AnniversaryResponse: - created = await create_anniversary_for_active(payload, owner_scope=current_user.owner_scope) - return AnniversaryResponse.model_validate(created) - - -@router.get("/anniversaries/{anniv_id}", response_model=AnniversaryResponse) -async def get_anniversary_api( - anniv_id: str, current_user: CurrentUser = Depends(get_current_user) -) -> AnniversaryResponse: - return AnniversaryResponse.model_validate( - await get_anniversary_or_404(anniv_id, owner_scope=current_user.owner_scope) - ) - - -@router.patch("/anniversaries/{anniv_id}", response_model=AnniversaryResponse) -async def update_anniversary_api( - anniv_id: str, - payload: AnniversaryUpdateRequest, - current_user: CurrentUser = Depends(get_current_user), -) -> AnniversaryResponse: - return AnniversaryResponse.model_validate( - await update_anniversary_or_404(anniv_id, payload, owner_scope=current_user.owner_scope) - ) - - -@router.delete("/anniversaries/{anniv_id}", status_code=204) -async def delete_anniversary_api( - anniv_id: str, current_user: CurrentUser = Depends(get_current_user) -) -> None: - await delete_anniversary_or_404(anniv_id, owner_scope=current_user.owner_scope) - - -@router.get("/acquaintance", response_model=AcquaintanceResponse) -async def get_acquaintance_api( - request: Request, current_user: CurrentUser = Depends(get_current_user) -) -> AcquaintanceResponse: - acq = await get_acquaintance_for_active( - get_event_store(request), owner_scope=current_user.owner_scope - ) - return AcquaintanceResponse(total_days=acq.total_days, natural_language=acq.natural_language) - - -@router.get("/config", response_model=TimeConfigResponse) -async def get_time_config_api( - current_user: CurrentUser = Depends(get_current_user), # noqa: ARG001 -) -> TimeConfigResponse: - return TimeConfigResponse( - user_timezone=settings.user_timezone, - home_timezone=settings.home_timezone, - default_city=settings.default_city, - offline_active_minutes=settings.offline_active_minutes, - offline_short_absence_minutes=settings.offline_short_absence_minutes, - offline_long_absence_minutes=settings.offline_long_absence_minutes, - offline_reunion_impact_minutes=settings.offline_reunion_impact_minutes, - care_enabled=settings.care_enabled, - jetlag_threshold_minutes=settings.jetlag_threshold_minutes, - ) diff --git a/backend/app/application/chat/__init__.py b/backend/app/application/chat/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/application/chat/send_message.py b/backend/app/application/chat/send_message.py deleted file mode 100644 index a2a09dd..0000000 --- a/backend/app/application/chat/send_message.py +++ /dev/null @@ -1,970 +0,0 @@ -"""发送聊天消息用例。""" - -from __future__ import annotations - -import asyncio - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.ai_provider import ProviderManager, ProviderManagerFactory -from app.ai_provider.types.chat import ChatChunk -from app.application.agent import ( - Agent, - AgentConfig, - AgentEvent, - AgentRunContext, - AgentRunRegistry, - ConfirmationRegistry, - ExtensionRegistry, - PythonSkillCatalog, - PythonSkillDefinition, - activate_python_skills, - compose_system_messages, - default_tools, - next_step_for_tool, - run_agent_turn, - step_event_payload, - task_from_deliberative_plan, - task_plan_payload, -) -from app.application.tool_runtime.runtime import ToolRuntime -from app.application.tool_runtime.view import RunScopedToolView -from app.core.config import settings -from app.core.errors import AppError, current_trace_id -from app.core.logging import logger -from app.core.time import utc_now -from app.db.session import AsyncSessionLocal -from app.models.chat import ChatMessage -from app.modules.agent import DEFAULT_CHAT_AGENT -from app.modules.chat.history import history_pairs -from app.modules.chat.policies import validate_text_message -from app.modules.goal_orchestrator import extract_session_goal, format_goal_directive -from app.modules.memory.context import build_memory_context -from app.modules.runtime.contract import RuntimeContract -from app.modules.runtime.events import EventStore -from app.modules.safety.confirmation import ConfirmationStore -from app.repositories import chat as chat_repo -from app.repositories import runtime as runtime_repo -from app.repositories.json_utils import dumps, loads_dict -from app.schemas.chat import SendMessageRequest, SendMessageResponse -from app.services import runtime as runtime_service -from app.services.audit import record_audit_event -from app.services.characters import default_model_hint, resolve_active_character -from app.services.chat import recent_chat_messages -from app.services.memory import retrieve_memories -from app.services.personality import build_personality_summary -from app.services.runtime import TERMINAL_RUN_STATUS -from app.services.safety import decide_chat_safety - -# 后台流式任务引用:asyncio.create_task 仅保留弱引用,需强引用防止中途被 GC。 -_running_stream_tasks: set[asyncio.Task[None]] = set() - -# 流式增量分片阈值:每攒够 N 字符产出一条 delta 事件,配合 sse_poll_interval(0.01s) -# 让前端看到逐段平滑增量。值越小流式越细、事件数越多(run_max_events 默认 1000)。 -_REASONING_FLUSH_CHARS = 8 -_CONTENT_FLUSH_CHARS = 8 - -_provider_manager: ProviderManager | None = None - - -def _get_provider_manager() -> ProviderManager: - global _provider_manager - if _provider_manager is None: - _provider_manager = ProviderManagerFactory.create() - return _provider_manager - - -async def drain_pending_streams() -> None: - """等待所有进行中的后台流式任务完成。 - - ``execute`` 在创建 run 后即返回,LLM 流式在后台任务中执行;调用方在需要确保 - 运行终态时(测试断言、优雅停机)await 此函数。 - """ - if _running_stream_tasks: - await asyncio.gather(*_running_stream_tasks, return_exceptions=True) - - -def _contract_system_messages(contract: RuntimeContract) -> list[str]: - messages = [*contract.system_instructions] - if contract.system_prompt: - messages.append(contract.system_prompt) # 卡的 system_prompt:道体增补层(§6) - if contract.session_goal: - messages.append(format_goal_directive(contract.session_goal)) - if contract.active_goals: - messages.append(contract.active_goals) # 在 session_goal 之后注入 - if contract.mission: - messages.append(f"核心使命:\n{contract.mission}") # 道体恒定层 - if contract.lorebook_before: - messages.append(contract.lorebook_before) - if contract.personality_summary: - messages.append(f"当前人格:\n{contract.personality_summary}") - if contract.lorebook_after: - messages.append(contract.lorebook_after) - if contract.emotion_state: - messages.append(contract.emotion_state) - if contract.memory_context: - messages.append(contract.memory_context) - if contract.temporal_context: - messages.append(contract.temporal_context) - if contract.safety_guidelines: - messages.append("行为边界:\n" + contract.safety_guidelines) # 道体安全(先于司命动态约束) - if contract.safety_constraints: - messages.append( - "安全约束:\n" + "\n".join(f"- {item}" for item in contract.safety_constraints) - ) - # 沙箱工作目录提示:避免模型在 shell 中使用 Linux 特有路径(如 /home/user、/tmp) - # 导致 macOS 上沙箱执行时 cd 失败。 - messages.append( - "当前运行在隔离沙箱中,所有文件操作(read_file/write_file/shell)的工作目录" - "是沙箱隔离的 workspace。系统并非 Linux,/home/user、/root 等路径不存在。" - "不要 cd 到这些目录;直接在 workspace 中对文件进行读写或执行命令。" - ) - return messages - - -# 首轮对话自动重命名:用默认模型生成简短标题,失败静默不影响对话主流程。 -_TITLE_MAX_CHARS = 40 -_TITLE_STRIP_CHARS = "\"'“”‘’「」『』" - - - - -async def _call_model(system_prompt: str, model_hint: str) -> str: - """调用 LLM 返回纯文本。""" - manager = _get_provider_manager() - messages = manager.to_message_list([system_prompt], [('user', system_prompt)]) - response = await manager.chat(messages, model_hint=model_hint) - return response.content - -async def _generate_session_title(user_content: str, assistant_content: str) -> str: - """用默认模型根据首轮对话内容生成简短会话标题。""" - manager = _get_provider_manager() - messages = manager.to_message_list( - [ - "你会根据一段对话生成简短的中文会话标题。" - "要求:不超过 16 个字;概括对话主题;直接输出标题文字;" - "不要加引号、书名号、句号,不要解释。" - ], - [("user", user_content), ("assistant", assistant_content)], - ) - response = await manager.chat(messages, model_hint=settings.default_model_hint) - raw = response.content.strip().split("\n", 1)[0].strip() - return raw.strip(_TITLE_STRIP_CHARS)[:_TITLE_MAX_CHARS] - - -class SendChatMessageUseCase: - """完成消息持久化与运行态记录;LLM 流式回复在后台任务中边产出边推 SSE 事件。""" - - def __init__( - self, - session: AsyncSession, - *, - owner_scope: str = settings.default_owner_scope, - tool_runtime: ToolRuntime | None = None, - confirmation_registry: ConfirmationRegistry | None = None, - confirmation_store: ConfirmationStore | None = None, - event_store: EventStore | None = None, - agent_run_registry: AgentRunRegistry | None = None, - python_skill_catalog: PythonSkillCatalog | None = None, - ) -> None: - self._session = session - self._owner_scope = owner_scope - self._tool_runtime = tool_runtime - self._confirmation_registry = confirmation_registry - self._confirmation_store = confirmation_store - self._event_store = event_store - self._agent_run_registry = agent_run_registry - self._python_skill_catalog = python_skill_catalog - - async def execute(self, session_id: str, payload: SendMessageRequest) -> SendMessageResponse: - validate_text_message(modality=payload.modality, content=payload.content) - - character = await resolve_active_character(self._session, owner_scope=self._owner_scope) - chat_session = await chat_repo.get_session( - self._session, session_id, owner_scope=self._owner_scope - ) - if chat_session is None or chat_session.status == "deleted": - raise AppError("not_found", "会话不存在。") - - if payload.prior_versions: - # 重答:复用原文用户消息,不新建 user 消息,避免刷新后出现重复的消息对。 - recent = await chat_repo.list_recent_messages( - self._session, - chat_session.id, - limit=10, - ) - existing_user = None - for msg in reversed(recent): - if msg.role == "user" and msg.deleted_at is None: - existing_user = msg - break - if existing_user is None: - raise AppError("not_found", "要重答的原始用户消息不存在。") - user_message = existing_user - else: - user_message = ChatMessage( - character_id=character.id, - session_id=chat_session.id, - role="user", - content=payload.content, - modality="text", - metadata_json=dumps({}), - sensitivity="low", - ) - self._session.add(user_message) - await self._session.flush() - - model_hint = payload.model_hint or default_model_hint(character) - session_goal = extract_session_goal(payload.content) - run = await runtime_service.create_run( - self._session, - character_id=character.id, - session_id=chat_session.id, - model_hint=model_hint, - goal=session_goal or "回复用户消息", - ) - user_message.run_id = run.id - - await runtime_service.append_event( - self._session, - run_id=run.id, - character_id=character.id, - event_type="run.started", - payload={"run_id": run.id, "session_id": chat_session.id}, - ) - # 先提交 run + run.started:HTTP 响应随后返回 run_id,前端据此建立 SSE; - # 后台流式在独立 session 中边产出边提交,前端 SSE(DB 轮询)即可实时读到增量。 - await self._session.commit() - - task = asyncio.create_task( - self._stream_assistant_response( - run_id=run.id, - character_id=character.id, - chat_session_id=chat_session.id, - payload=payload, - ) - ) - _running_stream_tasks.add(task) - task.add_done_callback(_running_stream_tasks.discard) - - from app.core.config import settings as _settings - - return SendMessageResponse( - message_id=user_message.id, - assistant_message_id=None, - run_id=run.id, - session_id=chat_session.id, - character_id=character.id, - status=run.status, - stream_url=f"{_settings.api_prefix}/runs/{run.id}/stream", - ) - - async def _stream_assistant_response( - self, - *, - run_id: str, - character_id: str, - chat_session_id: str, - payload: SendMessageRequest, - ) -> None: - """后台流式:独立 session 完成 LLM 调用与事件推送,异常定稿为 run.failed。""" - - try: - async with AsyncSessionLocal() as session: - await self._produce_assistant_stream( - session, - run_id=run_id, - character_id=character_id, - chat_session_id=chat_session_id, - payload=payload, - ) - except TimeoutError: - await self._fail_stream( - run_id=run_id, - character_id=character_id, - code="run_timeout", - message="运行超时。", - ) - except Exception: - logger.exception("chat_stream_failed", run_id=run_id) - await self._fail_stream( - run_id=run_id, - character_id=character_id, - code="run_failed", - message="运行失败,请稍后重试。", - ) - - async def _produce_assistant_stream( - self, - session: AsyncSession, - *, - run_id: str, - character_id: str, - chat_session_id: str, - payload: SendMessageRequest, - ) -> None: - character = await resolve_active_character(session, owner_scope=self._owner_scope) - model_hint = payload.model_hint or default_model_hint(character) - - run = await runtime_repo.get_run(session, run_id) - if run is None or run.status in TERMINAL_RUN_STATUS: - return - session_goal = run.goal or "" - - # 重答场景(prior_versions):在构建上下文前软删除被替换的旧 assistant 消息, - # 保留原文用户消息不动——用户消息已在 execute() 中复用,不新建、不删除。 - if payload.prior_versions: - latest = await chat_repo.list_recent_messages( - session, - chat_session_id, - limit=5, - ) - # latest 按 created_at ASC 排列且仅含未删除消息,从末尾往前找最后一个 assistant - for i in range(len(latest) - 1, -1, -1): - if latest[i].role == "assistant" and latest[i].deleted_at is None: - latest[i].deleted_at = utc_now() - break - await session.commit() - - safety = await decide_chat_safety( - session, - character_id=character_id, - run_id=run_id, - ) - personality = await build_personality_summary(session, character) - memories = await retrieve_memories( - session, - character_id, - limit=settings.memory_injection_limit, - ) - memory_context = build_memory_context(memories) - - # 息壤推送:将检索到的记忆以结构化数据推送到前端 SSE 事件流 - if memories.memories: - memory_items = [] - for m in memories.memories: - memory_items.append( - { - "id": m.id, - "title": m.title, - "content": m.content, - "source": m.source, - "confidence": m.confidence, - "importance": m.importance, - "type": m.type, - } - ) - await runtime_service.append_event( - session, - run_id=run_id, - character_id=character_id, - event_type="memory.retrieved", - payload={"memories": memory_items}, - ) - await session.commit() - - recent_messages = await recent_chat_messages(session, chat_session_id) - trace_id = current_trace_id() - - # 辰正 temporal_context(设计 §8.1):对话入口算时态快照 + 发 time.* 事件到当前 run。 - # 降级原则(§12):任何失败 → temporal_context=None,不注入,不阻塞对话。 - temporal_context: str | None = None - if self._event_store is not None: - try: - from app.modules.time_sense.modulation import ModulationInput - from app.services.time_sense import compute_live_snapshot - - time_result = await compute_live_snapshot( - session, - self._event_store, - character_id, - ModulationInput(), - run_id=run_id, - owner_scope=self._owner_scope, - ) - temporal_context = time_result.context_text or None - except Exception: # noqa: BLE001 - logger.warning("temporal_context 计算失败,已跳过", run_id=run_id) - - # 灵核情绪状态注入(设计 §11.5):拉模式 compute_emotion_prompt。 - # 降级原则(§12):任何失败 → emotion_state=None,不注入,不阻塞对话。 - emotion_state: str | None = None - try: - from app.services.personality_emotion import compute_emotion_prompt - - emotion_state = await compute_emotion_prompt(session, character_id) - except Exception: # noqa: BLE001 - logger.warning("emotion_state 计算失败,已跳过", run_id=run_id) - - # 世谱(Lorebook)注入:扫对话历史匹配命中条目,注入 before/after(设计 §7) - # 降级原则:任何失败 → 不注入,不阻塞对话。 - lorebook_before: str | None = None - lorebook_after: str | None = None - try: - from app.services.lorebook import build_lorebook_injection - - scan_parts = [payload.content] - scan_parts.extend( - m.content for m in recent_messages[-8:] if m.role in ("user", "assistant") - ) - lorebook_before, lorebook_after = await build_lorebook_injection( - session, - character_id, - self._owner_scope, - "\n".join(scan_parts), - ) - except Exception: # noqa: BLE001 - logger.warning("世谱注入失败,已跳过", run_id=run_id) - - profile_data = loads_dict(character.profile_json or "{}") - contract = RuntimeContract( - character_id=character_id, - session_id=chat_session_id, - run_id=run_id, - model_hint=model_hint, - agent=DEFAULT_CHAT_AGENT, - system_instructions=[], - system_prompt=profile_data.get("system_prompt") or None, - session_goal=session_goal or None, - personality_summary=personality.text, - mission=character.mission, - safety_guidelines=character.safety_guidelines, - lorebook_before=lorebook_before, - lorebook_after=lorebook_after, - emotion_state=emotion_state, - memory_context=memory_context, - temporal_context=temporal_context, - recent_messages=recent_messages, - safety_constraints=safety.constraints, - trace_id=trace_id, - ) - # 道体完整性校验(§6):确认恒定层未被动态注入在数据层覆盖 - from app.modules.runtime.integrity import validate_daoti_integrity - - validate_daoti_integrity( - contract, - mission=character.mission, - safety_guidelines=character.safety_guidelines, - ) - - # —— 活跃目标注入 —— - try: - from app.services.governance import load_active_goals_context - - active_goals = await load_active_goals_context(character.id) - if active_goals: - contract.active_goals = active_goals - except Exception: - logger.warning("活跃目标加载失败,跳过注入") - - await record_audit_event( - session, - action="model.request.started", - character_id=character_id, - run_id=run_id, - actor="system", - decision="auto", - input_summary=model_hint, - resource_type="run", - resource_id=run_id, - ) - system_messages = compose_system_messages( - _contract_system_messages(contract), - contract.agent, - [], - ) - - # ── 百工技能执行引擎:匹配 → prompt 注入 → 工具绑定注册 ── - from app.application.skill_executor import SkillExecutor - - tool_view = RunScopedToolView(self._tool_runtime) if self._tool_runtime is not None else None - skill_exec = SkillExecutor( - tool_view=tool_view, - event_store=self._event_store, - ) - try: - await skill_exec.match_and_activate( - payload.content, - system_messages, - run_id=run_id, - character_id=character_id, - skill_refs=contract.agent.skill_refs if contract.agent else (), - ) - except Exception: # noqa: BLE001 - logger.warning("skill_executor 匹配失败,已跳过") - - agent_task = None - completed_plan_step_ids: set[str] = set() - - # ── 洛书 Deliberative 规划 ── - if settings.enable_deliberative_planning: - from app.application.planning import ( - build_planning_prompt, - format_plan_for_prompt, - parse_plan, - should_plan, - ) - - if should_plan(payload.content): - try: - planner_prompt = build_planning_prompt( - payload.content, - memory_context=memory_context[:300] if memory_context else '', - ) - planner_raw = await _call_model(planner_prompt, model_hint) - plan = parse_plan(planner_raw) - if plan: - agent_task = task_from_deliberative_plan( - plan, - run_id=run_id, - agent_name=contract.agent.name if contract.agent else "默认 Agent", - objective=payload.content, - ) - await runtime_service.append_event( - session, - run_id=run_id, - character_id=character_id, - event_type="plan.created", - source="planner", - payload=task_plan_payload(agent_task), - ) - await session.commit() - plan_text = format_plan_for_prompt(plan) - system_messages.append( - "【当前计划】\n请按以下计划逐步执行。" - "每一步完成后标记完成,再进入下一步。" - "全部完成后回复总结。\n\n" + plan_text - ) - logger.info(f"洛书生成 deliberative plan: {plan.title} ({len(plan.steps)} 步)") - except Exception: - logger.warning('deliberative 规划生成失败,回退 ReAct') - - python_skills: tuple[PythonSkillDefinition, ...] = () - if self._python_skill_catalog is not None: - python_skills = self._python_skill_catalog.match( - payload.content, - skill_refs=contract.agent.skill_refs if contract.agent else (), - metadata={ - "run_id": run_id, - "session_id": chat_session_id, - "character_id": character_id, - "model_hint": model_hint, - }, - ) - for skill in python_skills: - if skill.instructions: - system_messages.append(f"【Python Skill:{skill.name}】\n{skill.instructions}") - - manager = _get_provider_manager() - messages = manager.to_message_list( - system_messages, - history_pairs(recent_messages), - ) - - reasoning_buf = "" - content_buf = "" - full_content = "" - had_reasoning = False - reasoning_finalized = False - last_model = model_hint - - async def flush_reasoning(*, force: bool = False) -> None: - nonlocal reasoning_buf - while reasoning_buf and (len(reasoning_buf) >= _REASONING_FLUSH_CHARS or force): - cut = ( - _REASONING_FLUSH_CHARS - if len(reasoning_buf) >= _REASONING_FLUSH_CHARS - else len(reasoning_buf) - ) - delta, reasoning_buf = reasoning_buf[:cut], reasoning_buf[cut:] - await runtime_service.append_event( - session, - run_id=run_id, - character_id=character_id, - event_type="reasoning.delta", - source="model", - payload={"delta": delta}, - ) - await session.commit() - - async def flush_content(*, force: bool = False) -> None: - nonlocal content_buf - while content_buf and (len(content_buf) >= _CONTENT_FLUSH_CHARS or force): - cut = ( - _CONTENT_FLUSH_CHARS - if len(content_buf) >= _CONTENT_FLUSH_CHARS - else len(content_buf) - ) - delta, content_buf = content_buf[:cut], content_buf[cut:] - await runtime_service.append_event( - session, - run_id=run_id, - character_id=character_id, - event_type="message.delta", - source="model", - payload={"delta": delta}, - ) - await session.commit() - - async def finalize_reasoning() -> None: - """收尾思考阶段:冲刷残余 reasoning 并推送 reasoning.completed。 - - 在第一段正文出现前调用,确保思考摘要完整先于正文;流结束兜底再调一次。 - 多轮 Agent 场景下,每轮模型输出的 content 出现前都会触发,已被 reasoning_finalized - 标记短路——即仅第一段 reasoning 完整 flush 后发 completed,后续轮次的 reasoning - 仍经 on_chunk 实时 flush,但不再发 completed(避免事件流断裂)。 - - 思考模式关闭时(payload.thinking=False)不处理任何 reasoning 事件, - 防止模型仍产出 reasoning_content 导致前端 ThoughtBlock 误展示。 - """ - nonlocal reasoning_finalized - if reasoning_finalized or not payload.thinking: - return - reasoning_finalized = True - await flush_reasoning(force=True) - if had_reasoning: - await runtime_service.append_event( - session, - run_id=run_id, - character_id=character_id, - event_type="reasoning.completed", - source="model", - payload={}, - ) - await session.commit() - - async def on_chunk(chunk: ChatChunk) -> None: - """把 Agent 流式 chunk 喂给现有 buf/flush/finalize 逻辑,事件行为与单轮流式等价。""" - nonlocal reasoning_buf, content_buf, full_content, had_reasoning - # 思考模式关闭时不处理 reasoning chunk,避免前端 ThoughtBlock 错误展示 - if payload.thinking and chunk.delta_reasoning: - had_reasoning = True - reasoning_buf += chunk.delta_reasoning - await flush_reasoning() - if chunk.delta_content: - # 正文开始前先收尾思考,保证思考摘要完整先于正文 - await finalize_reasoning() - content_buf += chunk.delta_content - full_content += chunk.delta_content - await flush_content() - - # 收集工具调用信息,最终存入消息 metadata 供前端持久化展示 - tool_calls: list[dict[str, object]] = [] - _started_tool_calls: dict[str, dict[str, object]] = {} - _plan_steps_by_tool_call = {} - - async def _reset_turn_state() -> None: - """每轮 Agent 开始前重置 reasoning 标记(多轮场景支持)。 - - Agent loop 首轮产出 reasoning → content → tool_calls,次轮可能再产出 reasoning。 - 若不重置,reasoning_finalized=True 导致 reasoning.completed 无法再发出。 - """ - nonlocal had_reasoning, reasoning_finalized - had_reasoning = False - reasoning_finalized = False - - async def on_tool_event(event_type: str, payload_dict: dict[str, object]) -> None: - """工具生命周期事件:作为 agent 事件落库 + 收集 tool_calls 入 metadata。""" - await runtime_service.append_event( - session, - run_id=run_id, - character_id=character_id, - event_type=event_type, - source="agent", - payload=payload_dict, - ) - await session.commit() - - # 收集工具调用信息供前端持久化展示 - if event_type == "tool.started": - tc_id = str(payload_dict.get("tool_call_id", "")) - tool_name = str(payload_dict.get("tool_name", "")) - if agent_task is not None: - step = next_step_for_tool(agent_task, tool_name, completed_plan_step_ids) - if step is not None: - _plan_steps_by_tool_call[tc_id] = step - await runtime_service.append_event( - session, - run_id=run_id, - character_id=character_id, - event_type="plan.step.started", - source="planner", - payload=step_event_payload(agent_task, step, status="running"), - ) - await session.commit() - _started_tool_calls[tc_id] = { - "tool_name": str(payload_dict.get("tool_name", "")), - "tool_call_id": tc_id, - "args": str(payload_dict.get("args", "")), - "status": "running", - } - elif event_type in ("tool.completed", "tool.failed"): - tc_id = str(payload_dict.get("tool_call_id", "")) - entry = _started_tool_calls.pop(tc_id, None) - step = _plan_steps_by_tool_call.pop(tc_id, None) - if step is not None and agent_task is not None: - tool_status = str(payload_dict.get("status", "error")) - step_status = "completed" if tool_status == "success" else "failed" - if step_status == "completed": - completed_plan_step_ids.add(step.id) - await runtime_service.append_event( - session, - run_id=run_id, - character_id=character_id, - event_type=f"plan.step.{step_status}", - source="planner", - payload=step_event_payload( - agent_task, - step, - status=step_status, - result_summary=str(payload_dict.get("result", ""))[:500] or None, - ), - ) - await session.commit() - if entry is not None: - entry["status"] = str(payload_dict.get("status", "error")) - entry["result"] = str(payload_dict.get("result", "")) - tool_calls.append(entry) - - extensions = ExtensionRegistry() - python_skill_activation = await activate_python_skills(extensions, python_skills) - if python_skills: - await runtime_service.append_event( - session, - run_id=run_id, - character_id=character_id, - event_type="skill.activated", - source="agent", - payload={"skills": [skill.name for skill in python_skills], "kind": "python"}, - ) - await session.commit() - - async def persist_agent_event(event: AgentEvent, _run_context: AgentRunContext) -> None: - await runtime_service.append_event( - session, - run_id=run_id, - character_id=character_id, - event_type=event.type, - source="agent", - payload=event.payload, - ) - await session.commit() - - extensions.add_event_listener(persist_agent_event) - on_confirm_timeout = None - if self._confirmation_store is not None: - confirmation_store = self._confirmation_store - - async def on_confirm_timeout(confirmation_id: str) -> None: - await confirmation_store.mark_timed_out(confirmation_id) - - agent = Agent( - AgentConfig( - tools=tuple( - default_tools( - tool_view.specs_by_name(), - allowed_tools=contract.agent.tool_policy.allowed_tools - if contract.agent - else (), - ) - if tool_view is not None - else () - ), - tool_runtime=tool_view, - model_hint=(contract.agent.model_hint or model_hint) - if contract.agent - else model_hint, - thinking_enabled=payload.thinking, - reasoning_effort=payload.reasoning_effort, - max_iterations=contract.agent.execution_policy.max_iterations - if contract.agent - else settings.agent_max_iterations, - confirmation_registry=self._confirmation_registry, - confirmation_timeout=settings.confirmation_timeout_seconds, - on_turn=_reset_turn_state, - on_chunk=on_chunk, - on_tool_event=on_tool_event, - on_confirm_timeout=on_confirm_timeout, - ), - messages=messages, - run_context=AgentRunContext( - run_id=run_id, - session_id=chat_session_id, - character_id=character_id, - trace_id=trace_id, - ), - extensions=extensions, - # 保留模块级符号作为注入点,现有测试与外部集成无需重写。 - runner=run_agent_turn, - ) - if self._agent_run_registry is not None: - await self._agent_run_registry.register(run_id, agent) - try: - run = await runtime_repo.get_run(session, run_id) - if run is None or run.status in TERMINAL_RUN_STATUS: - return - async with asyncio.timeout(settings.run_timeout_seconds): - agent_result = await agent.continue_run() - finally: - if self._agent_run_registry is not None: - await self._agent_run_registry.unregister(run_id, agent) - await python_skill_activation.close() - - # 流结束兜底:模型可能只输出思考无正文,或残余 buffer 未达阈值。 - await finalize_reasoning() - await flush_content(force=True) - - # 取消可能发生在模型返回与持久化之间;终态 Run 不再写助手消息或覆盖状态。 - run = await runtime_repo.get_run(session, run_id) - if run is None or run.status in TERMINAL_RUN_STATUS: - return - - # 以 agent_result.content 作为权威最终回复正文持久化(应与 full_content 一致; - # 不一致时以 agent 循环产出为准,因后者覆盖多轮模型最终输出)。 - final_content = agent_result.content or full_content - - version_ts = utc_now().isoformat() - # 构造版本历史:prior_versions 前置 + 当前回复作为最新版本 - prior = payload.prior_versions or [] - all_versions = [*prior, {"content": final_content, "created_at": version_ts}] - active_idx = len(all_versions) - 1 # 始终最新 - - assistant = ChatMessage( - character_id=character_id, - session_id=chat_session_id, - role="assistant", - content=final_content, - modality="text", - run_id=run_id, - metadata_json=dumps( - { - "model_hint": model_hint, - "model": last_model, - "tool_calls": tool_calls, - } - ), - sensitivity="low", - versions_json=dumps(all_versions), - active_version_idx=active_idx, - ) - session.add(assistant) - await session.flush() - await record_audit_event( - session, - action="model.response.completed", - character_id=character_id, - run_id=run_id, - actor="model", - decision="auto", - output_summary="模型响应已完成。", - resource_type="message", - resource_id=assistant.id, - ) - await runtime_service.append_event( - session, - run_id=run_id, - character_id=character_id, - event_type="message.completed", - source="model", - payload={"message_id": assistant.id, "content": final_content}, - ) - run = await runtime_repo.get_run(session, run_id) - if run is not None and run.status not in TERMINAL_RUN_STATUS: - await runtime_service.complete_run(session, run) - await runtime_service.append_event( - session, - run_id=run_id, - character_id=character_id, - event_type="run.completed", - payload={"run_id": run_id}, - ) - await session.commit() - - # 首轮对话完成后,用默认模型自动生成会话标题(失败不影响主流程) - await self._maybe_rename_session( - session, - chat_session_id=chat_session_id, - user_content=payload.content, - assistant_content=final_content, - ) - - # 每轮对话后检查大周天触发条件(失败不影响主流程) - try: - from app.application.big_cycle import maybe_trigger_big_cycle - - await maybe_trigger_big_cycle(character_id, trigger="session_turn") - except Exception: # noqa: BLE001 - logger.warning("大周天触发检查失败,已跳过") - - # 小周天每轮记忆候选 + 返照反思(后台任务,失败不影响主流程) - try: - from app.application.reflection import start_reflection_after_turn - - start_reflection_after_turn( - character_id=character_id, - session_id=chat_session_id, - user_content=payload.content, - assistant_content=final_content, - ) - except Exception: # noqa: BLE001 - logger.warning("小周天反思触发失败,已跳过") - - # —— 会话目标自动持久化 —— - try: - from app.db.session import AsyncSessionLocal as GoalSession - from app.services.goal_persist import auto_persist_session_goal - - async with GoalSession() as goal_session: - await auto_persist_session_goal(goal_session, character.id, payload.content) - await goal_session.commit() - except Exception: - logger.warning("会话目标自动持久化失败,跳过") - - async def _maybe_rename_session( - self, - session: AsyncSession, - *, - chat_session_id: str, - user_content: str, - assistant_content: str, - ) -> None: - """首轮对话完成后用 LLM 生成会话标题;已有标题或非首轮则跳过,失败静默。""" - try: - existing = await chat_repo.get_session(session, chat_session_id) - if existing is None or existing.title: - return - assistant_count = await chat_repo.count_messages_by_role( - session, chat_session_id, role="assistant" - ) - if assistant_count != 1: - return - title = await _generate_session_title(user_content, assistant_content) - if not title: - return - await chat_repo.update_session_title(session, chat_session_id, title) - await session.commit() - logger.info("session_auto_renamed", session_id=chat_session_id, title=title) - except Exception: - logger.warning("session_title_generate_failed", session_id=chat_session_id) - - async def _fail_stream( - self, - *, - run_id: str, - character_id: str, - code: str, - message: str, - ) -> None: - async with AsyncSessionLocal() as session: - run = await runtime_repo.get_run(session, run_id) - if run is None or run.status in TERMINAL_RUN_STATUS: - return - await runtime_service.fail_run(session, run, code=code, message=message) - await runtime_service.append_event( - session, - run_id=run_id, - character_id=character_id, - event_type="run.failed", - payload={"code": code, "message": message}, - ) - await session.commit() diff --git a/backend/app/domains/jingsheng/service.py b/backend/app/domains/jingsheng/service.py index 1a25b0e..503a426 100644 --- a/backend/app/domains/jingsheng/service.py +++ b/backend/app/domains/jingsheng/service.py @@ -194,6 +194,7 @@ async def activate_embryo(embryo_id: str, owner_scope: str) -> Character: session.add(Custodianship(character_id=character.id, custodian_scope=owner_scope)) session.add(RuntimeSeat(character_id=character.id, epoch=1)) + book_entry_count = await _materialize_import_book(session, baseline, character, owner_scope) record = build_identity_record( character_id=character.id, @@ -205,6 +206,7 @@ async def activate_embryo(embryo_id: str, owner_scope: str) -> Character: "activated_at": character.activated_at.isoformat(), }, runtime_state={"status": "active", "seat_epoch": 1}, + data_state={"imported_book_entries": book_entry_count}, ) session.add(record) await session.flush() # 取魂籍 ID @@ -307,3 +309,44 @@ async def _owned_character( if character is None or character.owner_scope != owner_scope: raise AppError("not_found", "人物不存在。") return character + + +async def _materialize_import_book( + session: AsyncSession, baseline: dict, character: Character, owner_scope: str +) -> int: + """启镜时把导入卡的 character_book 物化为新人物的世谱书与条目(待审语义随世谱状态治理)。""" + from app.models.lorebook import Lorebook, LorebookEntry + from app.modules.imports.mapping import character_book_to_entries + + book = (baseline.get("import_card") or {}).get("character_book") + entries = character_book_to_entries(book) + if not entries: + return 0 + lorebook = Lorebook( + character_id=character.id, + name=f"{character.name} 的世谱(导入)", + description="角色卡 character_book 启镜物化", + scope="character", + owner_scope=owner_scope, + ) + session.add(lorebook) + await session.flush() + for spec in entries: + session.add( + LorebookEntry( + lorebook_id=lorebook.id, + keys_json=json.dumps(spec["keys"], ensure_ascii=False), + content=spec["content"], + secondary_keys_json=json.dumps(spec["secondary_keys"], ensure_ascii=False), + constant=spec["constant"], + selective=spec["selective"], + enabled=spec["enabled"], + case_sensitive=spec["case_sensitive"], + use_regex=spec["use_regex"], + position=spec["position"], + insertion_order=spec["insertion_order"], + priority=spec["priority"], + comment=spec["comment"][:500], + ) + ) + return len(entries) diff --git a/backend/app/domains/mirror_tree/service.py b/backend/app/domains/mirror_tree/service.py index a2c92f8..b1d74ce 100644 --- a/backend/app/domains/mirror_tree/service.py +++ b/backend/app/domains/mirror_tree/service.py @@ -383,25 +383,80 @@ def _citation_row(node_id: str, data: dict) -> Citation: ) -async def build_generation_prompt(question: TreeNode, instructions: str | None) -> str: - """按祖先链组装投影 prompt(M2 形态:祖先路径 + 问题 + 临时指引;记忆/引文随 M3)。""" +async def build_generation_prompt( + question: TreeNode, + instructions: str | None, + *, + char_name: str | None = None, + user_name: str | None = None, +) -> str: + """按祖先链组装投影 prompt 并做符文展开({{char}}/{{user}}/…)。 + + M2 形态:祖先路径 + 问题 + 临时指引;获准记忆与显式引文随 M3 息壤接入。 + """ + from app.modules.macros.builtins import build_default_registry + from app.modules.macros.engine import render + from app.modules.macros.types import MacroContext + + ctx = MacroContext(char_name=char_name, user_name=user_name) + registry = build_default_registry() + + def expand(text: str) -> str: + return render(text, ctx, registry=registry) + chain = await ancestor_chain(question.id) lines: list[str] = [] for n in chain: if n.id == question.id: continue speaker = "用户" if n.node_type == "question" else "人物" - lines.append(f"{speaker}:{n.content}") + lines.append(f"{speaker}:{expand(n.content)}") transcript = "\n".join(lines) parts = [] if transcript: parts.append(f"【共同经历(祖先路径)】\n{transcript}") - parts.append(f"【当前问题】\n{question.content}") + parts.append(f"【当前问题】\n{expand(question.content)}") if instructions: - parts.append(f"【本次指引】\n{instructions}") + parts.append(f"【本次指引】\n{expand(instructions)}") return "\n\n".join(parts) +async def _names_for_generation(tree: MirrorTree) -> tuple[str | None, str | None]: + """人物名与执镜者名(符文上下文)。""" + from app.models.user import User + + async with AsyncSessionLocal() as session: + character = await session.get(Character, tree.character_id) + char_name = character.name if character else None + user_name = None + if character is not None: + user = await session.get(User, character.owner_scope) + user_name = user.username if user else None + return char_name, user_name + + +async def _generation_overrides(owner_scope: str) -> dict: + """火候(GenerationPreferences):本镜域默认采样参数 → IR 覆写。""" + from sqlalchemy import select + + from app.models.governance import GenerationPreferences + + async with AsyncSessionLocal() as session: + prefs = await session.get(GenerationPreferences, owner_scope) + if prefs is None: + # 兼容旧默认 owner_scope 行(历史数据) + prefs = ( + await session.execute( + select(GenerationPreferences).where( + GenerationPreferences.owner_scope == owner_scope + ) + ) + ).scalar_one_or_none() + if prefs is None: + return {} + return {"temperature": prefs.temperature, "max_tokens": prefs.max_output_tokens} + + async def spawn_answer( *, question_id: str, @@ -436,13 +491,18 @@ async def spawn_answer( source="mirror_tree", ) - prompt = await build_generation_prompt(question, instructions) + char_name, user_name = await _names_for_generation(tree) + prompt = await build_generation_prompt( + question, instructions, char_name=char_name, user_name=user_name + ) + overrides = await _generation_overrides(owner_scope) run = await kernel.start_foreground_run( character_id=tree.character_id, prompt=prompt, system_contract=None, origin="tree_answer", on_run_final=_make_finalize(tree.id, tree.character_id, node_id), + **overrides, ) async with AsyncSessionLocal() as session: node = await session.get(TreeNode, node_id) diff --git a/backend/app/kernel/__init__.py b/backend/app/kernel/__init__.py index 6bf5e26..9321600 100644 --- a/backend/app/kernel/__init__.py +++ b/backend/app/kernel/__init__.py @@ -55,13 +55,14 @@ class Kernel: system_contract: str | None = None, origin: str = "kernel", on_run_final: OnRunFinal | None = None, + temperature: float | None = None, + max_tokens: int | None = None, ) -> Run: """创建 Run 行(queued)并异步启动循环;前台席位在循环内排队获取。""" profile = resolve_profile() async with self._sessions() as session: run = Run( character_id=character_id, - session_id=None, status="queued", kind="foreground", goal=prompt[:300] if prompt else None, @@ -79,6 +80,8 @@ class Kernel: seat_key=None, system_contract=system_contract, on_final=on_run_final, + temperature=temperature, + max_tokens=max_tokens, ) async with self._sessions() as session: created = await session.get(Run, run_id) @@ -93,7 +96,6 @@ class Kernel: async with self._sessions() as session: run = Run( character_id=None, - session_id=None, status="queued", kind="rehearsal", goal=prompt[:300] if prompt else None, @@ -127,6 +129,8 @@ class Kernel: seat_key: str | None, system_contract: str | None, on_final: OnRunFinal | None, + temperature: float | None = None, + max_tokens: int | None = None, ) -> None: task = asyncio.create_task( self.loop.execute( @@ -137,6 +141,8 @@ class Kernel: seat_key=seat_key, system_contract=system_contract, on_final=on_final, + temperature=temperature, + max_tokens=max_tokens, ) ) self._tasks.add(task) diff --git a/backend/app/kernel/loop.py b/backend/app/kernel/loop.py index 7168849..b36de16 100644 --- a/backend/app/kernel/loop.py +++ b/backend/app/kernel/loop.py @@ -87,6 +87,8 @@ class KernelRunLoop: seat_key: str | None = None, system_contract: str | None = None, on_final: OnRunFinal | None = None, + temperature: float | None = None, + max_tokens: int | None = None, ) -> RunOutcome: assert character_id or seat_key, "Run 必须绑定人物或提供席位键(试演)" trace = trace_id or new_trace_id() @@ -101,6 +103,8 @@ class KernelRunLoop: seat_key=seat_key, system_contract=system_contract, outcome=outcome, + temperature=temperature, + max_tokens=max_tokens, ) except TimeoutError: outcome.status = "interrupted" @@ -128,6 +132,8 @@ class KernelRunLoop: seat_key: str | None, system_contract: str | None, outcome: RunOutcome, + temperature: float | None = None, + max_tokens: int | None = None, ) -> None: ctx = RunContext(run_id=run_id, character_id=character_id, trace_id=trace_id) try: @@ -151,6 +157,8 @@ class KernelRunLoop: prompt, system_contract or _SYSTEM_CONTRACT_PLACEHOLDER, outcome, + temperature=temperature, + max_tokens=max_tokens, ) async def _turn( @@ -161,6 +169,8 @@ class KernelRunLoop: prompt: str, system_contract: str, outcome: RunOutcome, + temperature: float | None = None, + max_tokens: int | None = None, ) -> None: ctx = RunContext( run_id=run_id, character_id=character_id, trace_id=trace_id, payload={"prompt": prompt} @@ -179,6 +189,8 @@ class KernelRunLoop: run_id=run_id, trace_id=trace_id, character_id=character_id, + temperature=temperature, + max_tokens=max_tokens, ) await self._emit( run_id, diff --git a/backend/app/models/chat.py b/backend/app/models/chat.py deleted file mode 100644 index 03c4c56..0000000 --- a/backend/app/models/chat.py +++ /dev/null @@ -1,52 +0,0 @@ -"""聊天会话与消息模型。""" - -from datetime import datetime - -from sqlalchemy import DateTime, ForeignKey, Integer, String, Text -from sqlalchemy.orm import Mapped, mapped_column - -from app.core.ids import new_message_id, new_session_id -from app.core.time import utc_now -from app.db.base import Base - - -class ChatSession(Base): - __tablename__ = "chat_sessions" - - id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_session_id) - character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True) - title: Mapped[str | None] = mapped_column(String(200)) - status: Mapped[str] = mapped_column(String(40), nullable=False, default="active", index=True) - owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, default="", index=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=utc_now, onupdate=utc_now - ) - - -class ChatMessage(Base): - __tablename__ = "chat_messages" - - id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_message_id) - character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True) - session_id: Mapped[str] = mapped_column(String(80), ForeignKey("chat_sessions.id"), index=True) - role: Mapped[str] = mapped_column(String(40), nullable=False) - content: Mapped[str] = mapped_column(Text, nullable=False) - modality: Mapped[str] = mapped_column(String(40), nullable=False, default="text") - run_id: Mapped[str | None] = mapped_column(String(80), ForeignKey("runs.id"), index=True) - metadata_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") - sensitivity: Mapped[str] = mapped_column(String(40), nullable=False, default="low") - versions_json: Mapped[str] = mapped_column( - Text, - nullable=False, - default="[]", - comment="版本历史 JSON 数组:[{content, created_at}],按生成顺序排列,末尾最新", - ) - active_version_idx: Mapped[int] = mapped_column( - Integer, - nullable=False, - default=0, - comment="当前活跃版本在 versions_json 中的索引(0-based)", - ) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) - deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/backend/app/models/personality_emotion.py b/backend/app/models/personality_emotion.py deleted file mode 100644 index 460acf1..0000000 --- a/backend/app/models/personality_emotion.py +++ /dev/null @@ -1,32 +0,0 @@ -"""灵核情绪状态持久化(设计 §11.4)。""" - -from datetime import datetime - -from sqlalchemy import DateTime, Float, ForeignKey, String -from sqlalchemy.orm import Mapped, mapped_column - -from app.core.time import utc_now -from app.db.base import Base - - -class CharacterEmotionState(Base): - """角色当前情绪状态(单行 per character,最新状态 + 触发溯源)。 - - 设计 §11.4:``mood`` / ``intensity`` 由辰正 ``time.*`` 事件驱动动态维护; - ``baseline_*`` 同步自人格版本 ``emotion_baseline``,作为衰减回归锚点。 - 同步写 ``Character.current_mood_id``(激活既有字段,前端列表无需 join)。 - """ - - __tablename__ = "character_emotion_states" - - character_id: Mapped[str] = mapped_column( - String(80), ForeignKey("characters.id"), primary_key=True - ) - mood: Mapped[str] = mapped_column(String(40), nullable=False, default="neutral") - intensity: Mapped[float] = mapped_column(Float, nullable=False, default=0.2) - baseline_mood: Mapped[str] = mapped_column(String(40), nullable=False, default="neutral") - baseline_intensity: Mapped[float] = mapped_column(Float, nullable=False, default=0.2) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=utc_now, onupdate=utc_now - ) - last_trigger_event_id: Mapped[str | None] = mapped_column(String(80), nullable=True) diff --git a/backend/app/models/time_anniversary.py b/backend/app/models/time_anniversary.py deleted file mode 100644 index 89e086a..0000000 --- a/backend/app/models/time_anniversary.py +++ /dev/null @@ -1,41 +0,0 @@ -"""时态纪念日模型(辰正 §5.3)。""" - -from datetime import datetime - -from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text -from sqlalchemy.orm import Mapped, mapped_column - -from app.core.ids import new_anniversary_id -from app.core.time import utc_now -from app.db.base import Base - - -class TimeAnniversary(Base): - """纪念日 / 特殊日子(辰正设计 §5.3)。 - - 枚举值(calendar / kind / subject_type / source)以契约字典 - ``docs/contracts/contract-glossary.md`` §11.4.1–§11.4.4 为单一事实源。 - """ - - __tablename__ = "time_anniversaries" - - id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_anniversary_id) - character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True) - name: Mapped[str] = mapped_column(String(200), nullable=False) - calendar: Mapped[str] = mapped_column(String(20), nullable=False) # solar / lunar - date: Mapped[str] = mapped_column(String(20), nullable=False) # 公历 ISO 或农历日期编码 - recurs_yearly: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) - kind: Mapped[str] = mapped_column( - String(40), nullable=False - ) # birthday / anniversary / milestone / custom - subject_type: Mapped[str] = mapped_column( - String(40), nullable=False - ) # user / character_self / relationship / custom - source: Mapped[str] = mapped_column( - String(40), nullable=False, default="manual" - ) # auto / manual / imported - metadata_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=utc_now, onupdate=utc_now - ) diff --git a/backend/app/modules/chat/__init__.py b/backend/app/modules/chat/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/modules/chat/history.py b/backend/app/modules/chat/history.py deleted file mode 100644 index 07d05f7..0000000 --- a/backend/app/modules/chat/history.py +++ /dev/null @@ -1,18 +0,0 @@ -"""聊天历史纯组装函数(IO 查询已迁 services/chat)。 - -``history_pairs`` 把已加载的消息列表转成 ``(role, content)`` 对,供上下文组装使用。 -本模块只接收已加载数据。 -""" - -from __future__ import annotations - -from app.models.chat import ChatMessage - - -def history_pairs(messages: list[ChatMessage]) -> list[tuple[str, str]]: - """把消息列表转成 (role, content) 对,供上下文组装使用。""" - return [ - (message.role, message.content) - for message in messages - if message.role in {"user", "assistant", "system"} - ] diff --git a/backend/app/modules/chat/policies.py b/backend/app/modules/chat/policies.py deleted file mode 100644 index 85ba3c2..0000000 --- a/backend/app/modules/chat/policies.py +++ /dev/null @@ -1,11 +0,0 @@ -"""聊天策略模块。""" - -from app.core.config import settings -from app.core.errors import AppError - - -def validate_text_message(*, modality: str, content: str) -> None: - if modality != "text": - raise AppError("unsupported_media_type", "当前阶段仅支持文本消息。") - if len(content.encode("utf-8")) > settings.max_text_message_bytes: - raise AppError("payload_too_large", "单条文本消息超过大小限制。") diff --git a/backend/app/modules/imports/mapping.py b/backend/app/modules/imports/mapping.py index a67044b..1493c4c 100644 --- a/backend/app/modules/imports/mapping.py +++ b/backend/app/modules/imports/mapping.py @@ -44,8 +44,11 @@ def character_book_to_entries(book: dict | None) -> list[dict]: """ST character_book entries → 世谱条目构造参数。""" if not book: return [] + raw_entries = book.get("entries") or [] + if isinstance(raw_entries, dict): # ST V2 规范:entries 以 uid 为键的对象 + raw_entries = list(raw_entries.values()) result = [] - for e in book.get("entries") or []: + for e in raw_entries: enabled_raw = e.get("enabled", True) result.append( { diff --git a/backend/app/modules/knocking/__init__.py b/backend/app/modules/knocking/__init__.py deleted file mode 100644 index b33f58f..0000000 --- a/backend/app/modules/knocking/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -"""叩心枢(KnockingPivot):人物主动对话守护模块。 - -设计见 docs/domain/core-domains.md。 - -叩心枢与辰正(时感)、灵核(情绪)并列——辰正提供时态语境, -灵核产出情绪状态,叩心判断「此时该不该开口」。 -记忆浮现、情绪波动、时光流逝 → 叩动心弦 → 自然想说话。 -""" - -from app.modules.knocking.conditions import ( - AnniversaryCondition, - BigCycleCompletedCondition, - ConditionResult, - CuriosityCondition, - EmotionChangeCondition, - EvolutionAppliedCondition, - MemoryRecallCondition, - ProactiveCondition, - RelationshipMilestoneCondition, - TimeElapsedCondition, - TimeOfDayCondition, - UserReunionCondition, -) -from app.modules.knocking.pivot import KnockingPivot - -__all__ = [ - "AnniversaryCondition", - "BigCycleCompletedCondition", - "ConditionResult", - "CuriosityCondition", - "EmotionChangeCondition", - "EvolutionAppliedCondition", - "KnockingPivot", - "MemoryRecallCondition", - "ProactiveCondition", - "RelationshipMilestoneCondition", - "TimeElapsedCondition", - "TimeOfDayCondition", - "UserReunionCondition", -] diff --git a/backend/app/modules/knocking/conditions.py b/backend/app/modules/knocking/conditions.py deleted file mode 100644 index 66dd364..0000000 --- a/backend/app/modules/knocking/conditions.py +++ /dev/null @@ -1,573 +0,0 @@ -"""叩心枢触发条件引擎:接口与实现。 - -设计 docs/domain/core-domains.md §5。 -""" - -from __future__ import annotations - -from collections.abc import Sequence -from dataclasses import dataclass, field -from datetime import UTC, datetime - -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.big_cycle import BigCycle -from app.models.chat import ChatMessage -from app.models.drive import CuriosityQuestion -from app.models.evolution import EvolutionCandidate -from app.models.memory import Memory -from app.models.personality_emotion import CharacterEmotionState -from app.models.time_anniversary import TimeAnniversary -from app.modules.personality.emotion import EmotionState - -# --------------------------------------------------------------------------- -# 条件接口 -# --------------------------------------------------------------------------- - - -@dataclass -class ConditionResult: - """条件检查结果。""" - - triggered: bool = False - """是否触发。""" - trigger_reason: str = "" - """触发理由描述(传入 opener prompt)。""" - context: dict = field(default_factory=dict) - """开场上下文数据(可选的纪念日/好奇心问题等)。""" - condition_name: str = "" - """触发条件的唯一名称(用于事件推送的 trigger 字段)。""" - - -class ProactiveCondition: - """主动触发条件检查器基类。 - - 子类重写 check() 方法,返回 ConditionResult。 - 纯逻辑,不修改 DB——叩心枢主循环在 check 通过后才执行写入。 - """ - - async def check( - self, - character_id: str, - session: AsyncSession, - *, - now: datetime | None = None, - ) -> ConditionResult: - raise NotImplementedError - - -# --------------------------------------------------------------------------- -# 第一梯队 ▸ 数据已在系统中流动 -# --------------------------------------------------------------------------- - - -class TimeElapsedCondition(ProactiveCondition): - """空闲超时:查该角色最后一条消息时间,超过 min_idle_minutes 触发。 - - 初次创建的角色(无历史消息)不触发——有意为之,避免新建角色立刻问候。 - """ - - def __init__(self, min_idle_minutes: int = 360) -> None: - self.min_idle_minutes = min_idle_minutes - - async def check( - self, - character_id: str, - session: AsyncSession, - *, - now: datetime | None = None, - ) -> ConditionResult: - now = now or datetime.now(UTC) - result = await session.execute( - select(func.max(ChatMessage.created_at)).where(ChatMessage.character_id == character_id) - ) - last_time: datetime | None = result.scalar() - if last_time is None: - return ConditionResult() # 无历史消息,不触发 - if last_time.tzinfo is None: - last_time = last_time.replace(tzinfo=UTC) - idle = (now - last_time).total_seconds() / 60 - if idle >= self.min_idle_minutes: - hours = int(idle // 60) - return ConditionResult( - triggered=True, - trigger_reason=f"距离上次对话已过去 {hours} 小时", - condition_name="idle", - ) - return ConditionResult() - - -class TimeOfDayCondition(ProactiveCondition): - """时段问候:若当前时间在配置的时段窗口内,按窗口名触发。 - - windows 配置示例:["morning", "late_night"] - 默认时段映射: - morning 06:00–09:00 - afternoon 12:00–14:00 - evening 17:00–19:00 - late_night 22:00–24:00 - """ - - _WINDOWS: dict[str, tuple[int, int]] = { - "morning": (6, 9), - "afternoon": (12, 14), - "evening": (17, 19), - "late_night": (22, 24), - } - - def __init__(self, windows: list[str] | None = None) -> None: - self.windows = windows or ["morning", "late_night"] - - async def check( - self, - _character_id: str, - _session: AsyncSession, - *, - now: datetime | None = None, - ) -> ConditionResult: - now = now or datetime.now(UTC) - hour = now.hour - matched: str | None = None - for name in self.windows: - start, end = self._WINDOWS.get(name, (0, 0)) - if start <= hour < end: - matched = name - break - if matched is None: - return ConditionResult() - label = { - "morning": "早晨", - "afternoon": "午后", - "evening": "傍晚", - "late_night": "深夜", - }.get(matched, matched) - return ConditionResult( - triggered=True, - trigger_reason=f"当前是{label}时段", - context={"time_of_day": matched}, - condition_name="time_of_day", - ) - - -class AnniversaryCondition(ProactiveCondition): - """纪念日匹配:每日首次 tick 检查 TimeAnniversary 表,匹配当天日期。 - - 已通知标记:每次触发后写一条审计事件或专用记录 key,当天不重复。 - 使用简单的轮次标记:本实现通过检查该 character 当天是否有 - 已推送的 AnniversaryCondition 触发记录来判断——非精确消费语义, - 但对 30s tick 的场景已足够(最多漏 1 个 tick,不影响)。 - """ - - def __init__(self) -> None: - self._checked_today: set[str] = set() - """{(character_id, date.isoformat())} 已检查过的组合——防止同一天多次 tick 重复推送。""" - - async def check( - self, - character_id: str, - session: AsyncSession, - *, - now: datetime | None = None, - ) -> ConditionResult: - now = now or datetime.now(UTC) - today = now.date().isoformat() - key = f"{character_id}:{today}" - if key in self._checked_today: - return ConditionResult() - self._checked_today.add(key) - - result = await session.execute( - select(TimeAnniversary).where( - TimeAnniversary.character_id == character_id, - TimeAnniversary.date == today, - ) - ) - anniversaries: Sequence[TimeAnniversary] = result.scalars().all() - if not anniversaries: - return ConditionResult() - - # 取第一个匹配的纪念日作为上下文 - ann = anniversaries[0] - kind_labels = {"birthday": "生日", "reunion": "重逢纪念日", "custom": "纪念日"} - kind_label = kind_labels.get(ann.kind, ann.kind) - return ConditionResult( - triggered=True, - trigger_reason=f"今天是{kind_label}", - context={ - "anniversary_kind": ann.kind, - "anniversary_label": ann.name or kind_label, - "anniversary_date": ann.date, - }, - condition_name="anniversary", - ) - - -class UserReunionCondition(ProactiveCondition): - """用户归来检测:通过 audit_event 或辰正事件判断用户是否刚回来。 - - 查询最近一次 time.reunion 类型审计事件,若比上次消费的时间更新则触发。 - """ - - def __init__(self) -> None: - self._last_seen_reunion: str | None = None - """上次已消费的 reunion 事件的 ID——避免重复触发。""" - - async def check( - self, - character_id: str, - session: AsyncSession, - *, - now: datetime | None = None, # noqa: ARG002 - ) -> ConditionResult: - from app.models.safety import AuditEvent - - result = await session.execute( - select(AuditEvent) - .where( - AuditEvent.character_id == character_id, - AuditEvent.action == "time.reunion", - ) - .order_by(AuditEvent.created_at.desc()) - .limit(1) - ) - event = result.scalar_one_or_none() - if event is None: - return ConditionResult() - if self._last_seen_reunion == event.id: - return ConditionResult() - self._last_seen_reunion = event.id - return ConditionResult( - triggered=True, - trigger_reason="用户回到了灵台", - condition_name="reunion", - ) - - -class EmotionChangeCondition(ProactiveCondition): - """情绪变化:对比缓存的上一轮 EmotionState 快照,强度变化超过阈值触发。 - - 同一 mood 变化后至少 recheck_cooldown_ticks 个 tick 内不再检查。 - """ - - def __init__(self, min_delta: float = 0.3, recheck_cooldown_ticks: int = 10) -> None: - self.min_delta = min_delta - self.recheck_cooldown_ticks = recheck_cooldown_ticks - self._previous: dict[str, EmotionState] = {} - self._cooldown: dict[str, int] = {} - - async def check( - self, - character_id: str, - session: AsyncSession, - *, - now: datetime | None = None, # noqa: ARG002 - ) -> ConditionResult: - # 冷却检查 - if character_id in self._cooldown: - remaining = self._cooldown[character_id] - if remaining > 0: - self._cooldown[character_id] = remaining - 1 - return ConditionResult() - del self._cooldown[character_id] - - # 读当前情绪 - result = await session.execute( - select(CharacterEmotionState).where(CharacterEmotionState.character_id == character_id) - ) - row = result.scalar_one_or_none() - if row is None: - return ConditionResult() - current = EmotionState(mood=row.mood, intensity=row.intensity) - - # 对比快照 - previous = self._previous.get(character_id) - self._previous[character_id] = current # 更新快照 - if previous is None: - return ConditionResult() # 首次无对比基线 - - if abs(current.intensity - previous.intensity) < self.min_delta: - return ConditionResult() - - # 触发 - mood_labels = { - "neutral": "平静", - "pleased": "愉悦", - "touched": "感动", - "wistful": "怅然怀念", - "concerned": "关切", - "festive": "喜庆", - "melancholy": "惆怅", - } - from_label = mood_labels.get(previous.mood, previous.mood) - to_label = mood_labels.get(current.mood, current.mood) - intensity_pct = round(current.intensity * 100) - self._cooldown[character_id] = self.recheck_cooldown_ticks - - return ConditionResult( - triggered=True, - trigger_reason=f"情绪从「{from_label}」变为「{to_label}(强度 {intensity_pct}%)」", - context={ - "from_mood": previous.mood, - "to_mood": current.mood, - "from_intensity": previous.intensity, - "to_intensity": current.intensity, - }, - condition_name="emotion", - ) - - -class CuriosityCondition(ProactiveCondition): - """好奇心触发:查该角色是否有 CuriosityQuestion(status=active)未被用户看到。 - - 取最新一条作为开场上下文。消费后应标记该问题为 'answered' 或 'seen'(由外部驱动, - 叩心枢只读不写)。 - """ - - async def check( - self, - character_id: str, - session: AsyncSession, - *, - now: datetime | None = None, # noqa: ARG002 - ) -> ConditionResult: - result = await session.execute( - select(CuriosityQuestion) - .where(CuriosityQuestion.character_id == character_id) - .order_by(CuriosityQuestion.created_at.desc()) - .limit(1) - ) - question = result.scalar_one_or_none() - if question is None: - return ConditionResult() - return ConditionResult( - triggered=True, - trigger_reason="角色有一个想问的问题", - context={ - "curiosity_question": question.question, - "curiosity_id": question.id, - }, - condition_name="curiosity", - ) - - -# --------------------------------------------------------------------------- -# 第二梯队 ▸ 需跨模块联动 -# --------------------------------------------------------------------------- - - -class EvolutionAppliedCondition(ProactiveCondition): - """演化应用:查 evolution_candidates 表,自上次检查后有新 applied 的记录则触发。 - - 取最近一条 status=applied 的记录,用 title 和 proposal 作为开场上下文。 - 这样角色可以自然地谈起自己的变化。 - """ - - def __init__(self) -> None: - self._last_seen_at: datetime | None = None - """上次已触发的演化候选的 created_at。""" - - async def check( - self, - character_id: str, - session: AsyncSession, - *, - now: datetime | None = None, # noqa: ARG002 - ) -> ConditionResult: - result = await session.execute( - select(EvolutionCandidate) - .where( - EvolutionCandidate.character_id == character_id, - EvolutionCandidate.status == "applied", - ) - .order_by(EvolutionCandidate.created_at.desc()) - .limit(1) - ) - candidate = result.scalar_one_or_none() - if candidate is None: - return ConditionResult() - if self._last_seen_at is not None and candidate.created_at <= self._last_seen_at: - return ConditionResult() - self._last_seen_at = candidate.created_at - return ConditionResult( - triggered=True, - trigger_reason=f"发生了新的变化:{candidate.title}", - context={ - "evolution_title": candidate.title, - "evolution_proposal": candidate.proposal[:200], - "evolution_level": candidate.level, - }, - condition_name="evolution_applied", - ) - - -class BigCycleCompletedCondition(ProactiveCondition): - """大周天完成:查 big_cycles 表,自上次检查后有新完成的循环则触发。 - - 取最近一条 status=completed 的记录,从 summary 提取可分享的体悟。 - """ - - def __init__(self) -> None: - self._last_seen_at: datetime | None = None - """上次已触发的循环的 completed_at。""" - - async def check( - self, - character_id: str, - session: AsyncSession, - *, - now: datetime | None = None, # noqa: ARG002 - ) -> ConditionResult: - result = await session.execute( - select(BigCycle) - .where( - BigCycle.character_id == character_id, - BigCycle.status == "completed", - BigCycle.completed_at.isnot(None), - ) - .order_by(BigCycle.completed_at.desc()) - .limit(1) - ) - cycle = result.scalar_one_or_none() - if cycle is None: - return ConditionResult() - if self._last_seen_at is not None and cycle.completed_at <= self._last_seen_at: - return ConditionResult() - self._last_seen_at = cycle.completed_at - summary = cycle.summary or "回顾了一下最近的记忆" - return ConditionResult( - triggered=True, - trigger_reason="大周天自我评估完成,有了新的体悟", - context={ - "big_cycle_summary": summary, - "big_cycle_trigger": cycle.trigger, - }, - condition_name="big_cycle_completed", - ) - - -class MemoryRecallCondition(ProactiveCondition): - """记忆唤醒:查角色的 memories 表,自上次检查后有新记忆则触发。 - - 新记忆的写入源可能是:大周天抽取、资料导入、用户主动创建。 - 每次 tick 取最新的未消费记忆作为开场素材。 - """ - - def __init__(self) -> None: - self._last_seen_at: datetime | None = None - """上次已触发的记忆的 created_at。""" - - async def check( - self, - character_id: str, - session: AsyncSession, - *, - now: datetime | None = None, # noqa: ARG002 - ) -> ConditionResult: - result = await session.execute( - select(Memory) - .where( - Memory.character_id == character_id, - Memory.status == "active", - ) - .order_by(Memory.created_at.desc()) - .limit(1) - ) - memory = result.scalar_one_or_none() - if memory is None: - return ConditionResult() - if self._last_seen_at is not None and memory.created_at <= self._last_seen_at: - return ConditionResult() - self._last_seen_at = memory.created_at - return ConditionResult( - triggered=True, - trigger_reason=f"想起了一件关于「{memory.title}」的事", - context={ - "memory_title": memory.title, - "memory_content": memory.content[:200], - "memory_type": memory.type, - }, - condition_name="memory_recall", - ) - - -class RelationshipMilestoneCondition(ProactiveCondition): - """关系里程碑:统计对话消息数与认识天数,达到阈值时触发。 - - 每个角色只触发一次同类型的里程碑(记录到 _triggered 集合去重)。 - 按 message_milestones → day_milestones 顺序检查,命中即停。 - """ - - def __init__( - self, - message_milestones: list[int] | None = None, - day_milestones: list[int] | None = None, - ) -> None: - self.message_milestones = message_milestones or [100, 500, 1000] - self.day_milestones = day_milestones or [30, 180, 365] - self._triggered: set[str] = set() - """已触发过的标记集合。格式:{character_id}:messages|days:{value}""" - - async def check( - self, - character_id: str, - session: AsyncSession, - *, - now: datetime | None = None, - ) -> ConditionResult: - now = now or datetime.now(UTC) - - # 1. 消息数里程碑 - count_result = await session.execute( - select(func.count()) - .select_from(ChatMessage) - .where(ChatMessage.character_id == character_id) - ) - total_messages: int = count_result.scalar() or 0 - for threshold in sorted(self.message_milestones): - key = f"{character_id}:messages:{threshold}" - if key in self._triggered: - continue - if total_messages >= threshold: - self._triggered.add(key) - return ConditionResult( - triggered=True, - trigger_reason=f"我们已聊了 {total_messages} 条消息", - context={ - "milestone_type": "messages", - "milestone_value": total_messages, - "milestone_threshold": threshold, - }, - condition_name="relationship_milestone", - ) - - # 2. 天数里程碑 - first_result = await session.execute( - select(ChatMessage.created_at) - .where(ChatMessage.character_id == character_id) - .order_by(ChatMessage.created_at.asc()) - .limit(1) - ) - first_msg_at: datetime | None = first_result.scalar() - if first_msg_at is not None: - if first_msg_at.tzinfo is None: - first_msg_at = first_msg_at.replace(tzinfo=UTC) - days = (now - first_msg_at).days - for threshold in sorted(self.day_milestones): - key = f"{character_id}:days:{threshold}" - if key in self._triggered: - continue - if days >= threshold: - self._triggered.add(key) - return ConditionResult( - triggered=True, - trigger_reason=f"我们已认识了 {days} 天", - context={ - "milestone_type": "days", - "milestone_value": days, - "milestone_threshold": threshold, - }, - condition_name="relationship_milestone", - ) - - return ConditionResult() diff --git a/backend/app/modules/knocking/creator.py b/backend/app/modules/knocking/creator.py deleted file mode 100644 index 56c420e..0000000 --- a/backend/app/modules/knocking/creator.py +++ /dev/null @@ -1,99 +0,0 @@ -"""叩心枢会话/消息创建与一炁事件推送。 - -设计 docs/domain/core-domains.md§7。 - -主动会话优先复用已有活跃会话,避免每次触发都新建会话。 -只有在角色无活跃会话时才会创建新会话。 -""" - -from __future__ import annotations - -from datetime import UTC, datetime -from typing import Any - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.logging import logger -from app.models.chat import ChatMessage, ChatSession -from app.modules.runtime.events import Event -from app.services.event_store import DbEventStore - - -async def create_proactive_message( - session: AsyncSession, - event_store: DbEventStore, - character_id: str, - opener_text: str, - condition_name: str, - *, - session_id: str | None = None, - now: datetime | None = None, -) -> dict[str, Any]: - """创建叩心枢主动发起的消息,可追加到现有会话或新建会话。 - - 策略: - - 若 ``session_id`` 不为空,追加消息到该会话(不创建新会话) - - 若 ``session_id`` 为空,新建会话 + 消息 - - Returns: - {"session_id": str, "message_id": str}: 实际写入的会话 ID 和消息 ID。 - """ - now = now or datetime.now(UTC) - is_new = session_id is None - - if is_new: - # 创建新会话(status 默认 'active') - chat_session = ChatSession( - character_id=character_id, - title=opener_text[:60], - ) - session.add(chat_session) - await session.flush() - session_id = chat_session.id - event_type = "proactive.session" - else: - event_type = "proactive.message" - - # 创建开场消息(无 run_id——非流式、无推理过程) - message = ChatMessage( - character_id=character_id, - session_id=session_id, - role="assistant", - modality="text", - content=opener_text, - ) - session.add(message) - await session.flush() - - # 推一炁事件 - payload: dict[str, Any] = { - "session_id": session_id, - "message_id": message.id, - "character_id": character_id, - "preview": opener_text[:100], - "trigger": condition_name, - "is_new": is_new, - } - if is_new: - payload["title_preview"] = opener_text[:60] - - event = Event( - type=event_type, - source="knocking_pivot", - character_id=character_id, - payload=payload, - visibility="public", - created_at=now.isoformat(), - ) - # 复用调用方的 session——同一事务写 events 表,避免 session A 写 chat 表、 - # session B 写 events 表时两条连接争写导致锁冲突 - await event_store.append(event, session=session) - - logger.info( - f"knocking_pivot_{'session_created' if is_new else 'message_appended'}", - character_id=character_id, - session_id=session_id, - message_id=message.id, - trigger=condition_name, - ) - return {"session_id": session_id, "message_id": message.id} diff --git a/backend/app/modules/knocking/opener.py b/backend/app/modules/knocking/opener.py deleted file mode 100644 index 85622d7..0000000 --- a/backend/app/modules/knocking/opener.py +++ /dev/null @@ -1,190 +0,0 @@ -"""叩心枢开场消息生成器。 - -设计 docs/domain/core-domains.md §6。 -""" - -from __future__ import annotations - -from dataclasses import dataclass -from datetime import UTC, datetime - -from app.ai_provider.manager import ProviderManagerFactory -from app.core.config import settings -from app.core.logging import logger -from app.modules.knocking.conditions import ConditionResult -from app.modules.personality.emotion import EmotionState, to_prompt_text - -# --------------------------------------------------------------------------- -# 回退模板 -# --------------------------------------------------------------------------- - -_FALLBACK_TEMPLATES: dict[str, str] = { - "idle": "{{name}}默默地看了你一眼。", - "time_of_day": "{{name}}正望着窗外出神。", - "anniversary": "今日似乎是个特别的日子。", - "reunion": "{{name}}注意到你回来了。", - "emotion": "{{name}}轻轻叹了口气。", - "curiosity": "{{name}}似乎有什么话想说。", - "memory_recall": "{{name}}若有所思地望向远方。", - "big_cycle_completed": "{{name}}似乎有所感悟。", - "evolution_applied": "{{name}}看上去有些不一样了。", - "relationship_milestone": "{{name}}望着你,嘴角泛起一丝笑意。", - "default": "{{name}}若有所思。", -} - -_FALLBACK = "{{name}}正望着窗外出神。" - - -@dataclass -class ChatMessageBrief: - """简略对话记录(只携带必要字段,避免依赖 ORM)。""" - - role: str = "" - content: str = "" - - -@dataclass -class OpenerResult: - """开场消息生成结果。""" - - text: str - used_fallback: bool = False - - -async def generate_opener( - character_id: str, - character_name: str, - personality_summary: str | None, - emotion: EmotionState | None, - condition_result: ConditionResult, - *, - chat_history: list[ChatMessageBrief] | None = None, - memory_context: str | None = None, - now: datetime | None = None, -) -> OpenerResult: - """根据条件触发结果生成一句开场白。 - - 依次尝试: - 1. 调用神识(LLM)生成带人格注入的开场白 - 2. 失败时使用模板化回退 - - 若提供 ``chat_history``,会将其拼入 prompt 使开场更贴合最近对话脉络。 - 若提供 ``memory_context``,会将其作为角色的长期记忆注入 prompt。 - """ - now = now or datetime.now(UTC) - timestamp = now.strftime("%Y-%m-%d %H:%M") - - # 构造 prompt - emotion_text = to_prompt_text(emotion) if emotion else "平静" - summary = personality_summary or f"你是一个名叫{character_name}的角色。{_DEFAULT_PERSONA}" - trigger_reason = condition_result.trigger_reason or "" - - context_parts = _build_context_parts(condition_result) - - system_prompt = _build_system_prompt(summary, emotion_text, timestamp) - user_prompt = _build_user_prompt( - character_name, trigger_reason, context_parts, chat_history, memory_context - ) - - try: - manager = ProviderManagerFactory.create() - result = await manager.chat( - manager.to_message_list([system_prompt], [("user", user_prompt)]), - model_hint=settings.big_cycle_model, - ) - text = result.content.strip() - if not text: - raise ValueError("模型返回空开场文本") - return OpenerResult(text=text, used_fallback=False) - except Exception as exc: - logger.warning( - "knocking_pivot_opener_fallback", - character_id=character_id, - condition=condition_result.condition_name, - error=str(exc), - ) - template = _FALLBACK_TEMPLATES.get(condition_result.condition_name, _FALLBACK) - fallback = template.replace("{{name}}", character_name) - return OpenerResult(text=fallback, used_fallback=True) - - -def _build_context_parts(condition_result: ConditionResult) -> list[str]: - """组装条件相关的上下文片段。""" - parts: list[str] = [] - if condition_result.context.get("anniversary_kind"): - kind_label = condition_result.context.get("anniversary_label", "") - parts.append(f"今天是一个特别的日子:{kind_label}。") - if condition_result.context.get("curiosity_question"): - parts.append( - f"你一直想问用户一个问题:「{condition_result.context['curiosity_question']}」" - ) - if condition_result.context.get("time_of_day") == "late_night": - parts.append("夜深了,万籁俱寂。") - if condition_result.context.get("memory_title"): - title = condition_result.context["memory_title"] - parts.append(f"你最近想起了一件事——「{title}」。") - if condition_result.context.get("big_cycle_summary"): - parts.append("你刚刚审视过自己的内心,有了一些新的思考。") - if condition_result.context.get("evolution_title"): - title = condition_result.context["evolution_title"] - parts.append(f"你感觉自己有了一些新的变化:{title}。") - if condition_result.context.get("milestone_type") == "messages": - count = condition_result.context["milestone_value"] - parts.append(f"你和用户的对话已经累积了 {count} 条。") - if condition_result.context.get("milestone_type") == "days": - days = condition_result.context["milestone_value"] - parts.append(f"从你们第一次对话至今,已经过去了 {days} 天。") - return parts - - -def _format_chat_history(history: list[ChatMessageBrief]) -> str: - """将最近对话历史格式化为文本。""" - lines = ["\n[最近对话记录]"] - for msg in history: - speaker = "你" if msg.role == "assistant" else "用户" - lines.append(f"{speaker}:{msg.content[:200]}") - return "\n".join(lines) - - -def _build_system_prompt(personality_summary: str, emotion_text: str, timestamp: str) -> str: - return ( - "你是一位角色扮演助手。以下是你的人物设定:\n" - f"{personality_summary}\n\n" - "[当前状态]\n" - f"- 当前时间: {timestamp}\n" - f"- 情绪状态: {emotion_text}\n" - ) - - -def _build_user_prompt( - character_name: str, - trigger_reason: str, - context_parts: list[str], - chat_history: list[ChatMessageBrief] | None = None, - memory_context: str | None = None, -) -> str: - lines = ["[任务]"] - lines.append(f"根据以上设定,生成一句自然、贴合{character_name}性格和当前情绪的开场白。") - lines.append("要求:") - lines.append("1. 一句话,不超过 80 字") - lines.append("2. 符合角色的表达风格和说话方式、语气") - lines.append("3. 反映当前情绪状态") - lines.append("4. 不要问「你还在吗?」之类的问题") - lines.append("5. 直接输出开场白,不要任何前缀或引号") - if trigger_reason: - lines.append(f"\n触发原因:{trigger_reason}") - if context_parts: - lines.append(f"\n{' '.join(context_parts)}") - if memory_context: - lines.append(f"\n{memory_context}") - if chat_history: - lines.append(_format_chat_history(chat_history)) - lines.append("\n注意:延续上次对话的语境自然地开启新话题,不要重复上一句的内容。") - return "\n".join(lines) - - -_DEFAULT_PERSONA = ( - "你对用户保持着温和的关注," - "平日里话不多,但每次开口都带着真诚。" - "你善于观察,能从细微之处感知对方的情绪。" -) diff --git a/backend/app/modules/knocking/pivot.py b/backend/app/modules/knocking/pivot.py deleted file mode 100644 index 546afd1..0000000 --- a/backend/app/modules/knocking/pivot.py +++ /dev/null @@ -1,366 +0,0 @@ -"""叩心枢(KnockingPivot)主循环。 - -人物主动对话守护进程:后台常驻循环(~30s tick), -遍历活跃角色 → 检查触发条件 → 生成开场 → 创建会话/消息 → 推一炁事件。 -同时订阅一炁事件流(big_cycle.* / reflection.*)实现近实时快速路径。 - -叩心枢与辰正(时感)、灵核(情绪)并列——辰正提供时态语境, -灵核产出情绪状态,叩心判断「此时该不该开口」。 - -设计 docs/domain/core-domains.md §4。 -""" - -from __future__ import annotations - -import asyncio -import contextlib -from datetime import UTC, datetime, timedelta - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker - -from app.core.logging import logger -from app.db.session import AsyncSessionLocal -from app.models.character import Character -from app.models.chat import ChatMessage, ChatSession -from app.models.personality_emotion import CharacterEmotionState -from app.modules.knocking.conditions import ( - AnniversaryCondition, - BigCycleCompletedCondition, - CuriosityCondition, - EmotionChangeCondition, - EvolutionAppliedCondition, - MemoryRecallCondition, - ProactiveCondition, - RelationshipMilestoneCondition, - TimeElapsedCondition, - TimeOfDayCondition, - UserReunionCondition, -) -from app.modules.knocking.creator import create_proactive_message -from app.modules.knocking.opener import ChatMessageBrief, generate_opener -from app.modules.personality.emotion import EmotionState -from app.modules.runtime.events import Event -from app.services.event_store import DbEventStore -from app.services.memory import memory_context as _fmt_memory_context -from app.services.personality import build_personality_summary - - -class KnockingPivot: - """叩心枢主动对话守护进程。 - - 用法: - pivot = KnockingPivot(event_store) - await pivot.start() - ... - await pivot.stop() - """ - - # 条件注册表(第一梯队 + 第二梯队 ▸ 按优先级排列) - conditions: list[ProactiveCondition] = [ - EvolutionAppliedCondition(), - BigCycleCompletedCondition(), - TimeElapsedCondition(min_idle_minutes=360), - TimeOfDayCondition(windows=["morning", "late_night"]), - AnniversaryCondition(), - UserReunionCondition(), - EmotionChangeCondition(min_delta=0.3), - MemoryRecallCondition(), - RelationshipMilestoneCondition(), - CuriosityCondition(), - ] - - def __init__( - self, - event_store: DbEventStore, - session_factory: async_sessionmaker[AsyncSession] | None = None, - *, - tick_interval: float = 30.0, - cooldown_minutes: int = 30, - daily_limit: int = 3, - quiet_hours_start: int = 23, - quiet_hours_end: int = 8, - ) -> None: - self._event_store = event_store - self._session_factory = session_factory or AsyncSessionLocal - self._tick_interval = tick_interval - self._cooldown_minutes = cooldown_minutes - self._daily_limit = daily_limit - self._quiet_hours_start = quiet_hours_start - self._quiet_hours_end = quiet_hours_end - - self._task: asyncio.Task[None] | None = None - self._event_task: asyncio.Task[None] | None = None - self._stop_event = asyncio.Event() - self._event_queue: asyncio.Queue[Event] | None = None - - # per-character 冷启动冷却(character_id → 下次可触发时间) - self._cooldowns: dict[str, datetime] = {} - # per-character 日计数(character_id → 今日日期 → 次数) - self._daily_counts: dict[str, tuple[str, int]] = {} - - # ------------------------------------------------------------------ - # 生命周期 - # ------------------------------------------------------------------ - - async def start(self) -> None: - if self._task is not None: - logger.warning("knocking_pivot_already_running") - return - self._stop_event.clear() - - # 订阅一炁事件流(大周天/小周天完成等),实现近实时快速路径 - self._event_queue = self._event_store.subscribe_by_pattern("big_cycle.*,reflection.*") - self._event_task = asyncio.create_task(self._run_event_consumer()) - - self._task = asyncio.create_task(self._run_loop()) - logger.info("knocking_pivot_started", tick_interval=self._tick_interval) - - async def stop(self) -> None: - if self._task is None: - return - self._stop_event.set() - self._task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._task - self._task = None - if self._event_task is not None: - self._event_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await self._event_task - self._event_task = None - if self._event_queue is not None: - self._event_store.unsubscribe_by_pattern("big_cycle.*,reflection.*", self._event_queue) - self._event_queue = None - logger.info("knocking_pivot_stopped") - - # ------------------------------------------------------------------ - # 主循环 - # ------------------------------------------------------------------ - - async def _run_loop(self) -> None: - while not self._stop_event.is_set(): - try: - await self._tick() - except asyncio.CancelledError: - break - except Exception as exc: - err_str = str(exc) - if "database is locked" in err_str: - logger.warning( - "knocking_pivot_db_locked_retry", - error=err_str[:120], - ) - else: - logger.error("knocking_pivot_tick_error", error=err_str) - await asyncio.sleep(self._tick_interval) - - async def _run_event_consumer(self) -> None: - """事件消费者:订阅 big_cycle.* / reflection.* 事件,近实时触发叩心检查。 - - 与 _run_loop 并行运行,事件驱动而非轮询驱动。 - 每个事件提取 character_id,立即做一次条件检查(跳过冷却/日限等节流逻辑用轮询管理)。 - """ - queue = self._event_queue - if queue is None: - return - while not self._stop_event.is_set(): - try: - event = await asyncio.wait_for(queue.get(), timeout=30) - except TimeoutError: - continue - except asyncio.CancelledError: - break - try: - await self._handle_event(event) - except Exception: # noqa: BLE001 - logger.warning("knocking_pivot_event_error", event_type=event.type) - - async def _handle_event(self, event: Event) -> None: - """响应一炁事件:提取 character_id,尝试用对应条件触发。""" - character_id = event.character_id - if not character_id: - return - now = datetime.now(UTC) - if self._in_quiet_hours(now): - return - if not self._can_trigger(character_id, now): - return - - # 大周天完成 → 快速检查 BigCycleCompletedCondition - if event.type == "big_cycle.completed": - async with self._session_factory() as session: - cond = BigCycleCompletedCondition() - result = await cond.check(character_id, session, now=now) - if result.triggered: - await self._execute_trigger(character_id, result, session, now) - self._mark_triggered(character_id, now) - return - - # 反思完成 → 快速检查 MemoryRecallCondition - if event.type == "reflection.completed": - async with self._session_factory() as session: - cond = MemoryRecallCondition() - result = await cond.check(character_id, session, now=now) - if result.triggered: - await self._execute_trigger(character_id, result, session, now) - self._mark_triggered(character_id, now) - return - - async def _tick(self) -> None: - """一次 tick:找出活跃角色 → 逐角色检查条件。""" - now = datetime.now(UTC) - if self._in_quiet_hours(now): - return - - async with self._session_factory() as session: - active_characters = await self._list_active_characters(session) - if not active_characters: - return - - for character_id in active_characters: - if not self._can_trigger(character_id, now): - continue - if await self._check_and_trigger(character_id, session, now): - # 每次 tick 只触发一个角色——命中即停 - break - - # ------------------------------------------------------------------ - # 内部 - # ------------------------------------------------------------------ - - async def _list_active_characters(self, session: AsyncSession) -> list[str]: - """返回全部 status='active' 的 character_id。""" - result = await session.execute(select(Character.id).where(Character.status == "active")) - return [row[0] for row in result.all()] - - def _in_quiet_hours(self, now: datetime) -> bool: - hour = now.hour - if self._quiet_hours_start <= self._quiet_hours_end: - return self._quiet_hours_start <= hour < self._quiet_hours_end - return hour >= self._quiet_hours_start or hour < self._quiet_hours_end - - def _can_trigger(self, character_id: str, now: datetime) -> bool: - # 冷却 - cooldown_until = self._cooldowns.get(character_id) - if cooldown_until is not None and now < cooldown_until: - return False - # 日上限 - today = now.date().isoformat() - last_date, count = self._daily_counts.get(character_id, ("", 0)) - return not (last_date == today and count >= self._daily_limit) - - def _mark_triggered(self, character_id: str, now: datetime) -> None: - self._cooldowns[character_id] = now + timedelta(minutes=self._cooldown_minutes) - today = now.date().isoformat() - last_date, count = self._daily_counts.get(character_id, ("", 0)) - if last_date == today: - self._daily_counts[character_id] = (today, count + 1) - else: - self._daily_counts[character_id] = (today, 1) - - async def _check_and_trigger( - self, character_id: str, session: AsyncSession, now: datetime - ) -> bool: - """遍历条件列表,任一触发则执行并返回 True;否则返回 False。""" - for condition in self.conditions: - result = await condition.check(character_id, session, now=now) - if not result.triggered: - continue - - await self._execute_trigger(character_id, result, session, now) - self._mark_triggered(character_id, now) - return True - return False - - async def _execute_trigger( - self, - character_id: str, - condition_result, - session: AsyncSession, - now: datetime, - ) -> None: - """条件触发后的执行链:人格注入 → 开场(带上下文)→ 追加/新建会话 → 推事件。""" - try: - # 1. 加载角色对象(供人格摘要使用) - result = await session.execute(select(Character).where(Character.id == character_id)) - character = result.scalar_one_or_none() - if character is None: - logger.warning( - "knocking_pivot_character_not_found", - character_id=character_id, - ) - return - - # 2. 人格注入 - summary = await build_personality_summary(session, character) - - # 3. 读取情绪 - emotion = await self._get_emotion(session, character_id) - - # 4. 查找该角色已有的活跃会话(优先复用) - session_result = await session.execute( - select(ChatSession) - .where( - ChatSession.character_id == character_id, - ChatSession.status == "active", - ) - .order_by(ChatSession.updated_at.desc()) - .limit(1) - ) - existing_session = session_result.scalar_one_or_none() - - # 5. 读取最近对话记录作为上下文 - chat_history: list[ChatMessageBrief] = [] - if existing_session: - msg_result = await session.execute( - select(ChatMessage) - .where(ChatMessage.session_id == existing_session.id) - .order_by(ChatMessage.created_at.desc()) - .limit(5) - ) - raw = list(reversed(msg_result.scalars().all())) - chat_history = [ChatMessageBrief(role=m.role, content=m.content) for m in raw] - - # 6. 检索角色的长期记忆作为上下文 - memory_context: str | None = None - mc = await _fmt_memory_context(session, character_id, limit=5) - memory_context = mc if mc else None - - # 7. 生成开场(携带上下文与记忆) - character_name = character.display_name or character.name or character_id - opener = await generate_opener( - character_id=character_id, - character_name=character_name, - personality_summary=summary.text if summary and summary.text else None, - emotion=emotion, - condition_result=condition_result, - chat_history=chat_history, - memory_context=memory_context, - now=now, - ) - - # 8. 追加消息(复用已有会话,或创建新会话) - existing_id = existing_session.id if existing_session else None - await create_proactive_message( - session=session, - event_store=self._event_store, - character_id=character_id, - session_id=existing_id, - opener_text=opener.text, - condition_name=condition_result.condition_name, - now=now, - ) - await session.commit() - except Exception: - await session.rollback() - raise - - async def _get_emotion(self, session: AsyncSession, character_id: str) -> EmotionState | None: - result = await session.execute( - select(CharacterEmotionState).where(CharacterEmotionState.character_id == character_id) - ) - row = result.scalar_one_or_none() - if row is None: - return None - return EmotionState(mood=row.mood, intensity=row.intensity) diff --git a/backend/app/repositories/chat.py b/backend/app/repositories/chat.py deleted file mode 100644 index e91a5fc..0000000 --- a/backend/app/repositories/chat.py +++ /dev/null @@ -1,200 +0,0 @@ -"""聊天数据访问。""" - -from sqlalchemy import func, select, update -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.errors import AppError -from app.models.chat import ChatMessage, ChatSession - - -async def get_session( - session: AsyncSession, session_id: str, *, owner_scope: str | None = None -) -> ChatSession | None: - stmt = select(ChatSession).where(ChatSession.id == session_id) - if owner_scope is not None: - stmt = stmt.where(ChatSession.owner_scope == owner_scope) - result = await session.execute(stmt) - return result.scalar_one_or_none() - - -async def list_recent_messages( - session: AsyncSession, session_id: str, *, limit: int = 12 -) -> list[ChatMessage]: - result = await session.execute( - select(ChatMessage) - .where(ChatMessage.session_id == session_id, ChatMessage.deleted_at.is_(None)) - .order_by(ChatMessage.created_at.desc()) - .limit(limit) - ) - return list(reversed(result.scalars().all())) - - -async def list_sessions( - session: AsyncSession, - character_id: str, - *, - status: str | None = None, - owner_scope: str | None = None, - limit: int = 50, - offset: int = 0, -) -> list[ChatSession]: - conditions = [ChatSession.character_id == character_id, ChatSession.status != "deleted"] - if status is not None: - conditions.append(ChatSession.status == status) - if owner_scope is not None: - conditions.append(ChatSession.owner_scope == owner_scope) - result = await session.execute( - select(ChatSession) - .where(*conditions) - .order_by(ChatSession.updated_at.desc()) - .offset(offset) - .limit(limit) - ) - return list(result.scalars().all()) - - -async def count_sessions( - session: AsyncSession, - character_id: str, - *, - status: str | None = None, - owner_scope: str | None = None, -) -> int: - conditions = [ChatSession.character_id == character_id, ChatSession.status != "deleted"] - if status is not None: - conditions.append(ChatSession.status == status) - if owner_scope is not None: - conditions.append(ChatSession.owner_scope == owner_scope) - result = await session.execute(select(func.count()).select_from(ChatSession).where(*conditions)) - return result.scalar_one() - - -async def list_messages( - session: AsyncSession, session_id: str, *, limit: int = 100, offset: int = 0 -) -> list[ChatMessage]: - result = await session.execute( - select(ChatMessage) - .where(ChatMessage.session_id == session_id, ChatMessage.deleted_at.is_(None)) - .order_by(ChatMessage.created_at.asc()) - .offset(offset) - .limit(limit) - ) - return list(result.scalars().all()) - - -async def count_messages(session: AsyncSession, session_id: str) -> int: - result = await session.execute( - select(func.count()) - .select_from(ChatMessage) - .where( - ChatMessage.session_id == session_id, - ChatMessage.deleted_at.is_(None), - ) - ) - return result.scalar_one() - - -async def count_messages_by_sessions( - session: AsyncSession, session_ids: list[str] -) -> dict[str, int]: - """批量统计多个会话的未删除消息数(GROUP BY 聚合,避免列表查询 N+1)。""" - if not session_ids: - return {} - result = await session.execute( - select(ChatMessage.session_id, func.count()) - .where( - ChatMessage.session_id.in_(session_ids), - ChatMessage.deleted_at.is_(None), - ) - .group_by(ChatMessage.session_id) - ) - return {str(sid): int(cnt) for sid, cnt in result.all()} - - -async def last_message_previews_by_sessions( - session: AsyncSession, session_ids: list[str] -) -> dict[str, str]: - """批量取每个会话最后一条未删除消息的内容(截断由调用方处理)。 - - 用 row_number() 窗口函数取每个 session_id 的最新一条,兼容 SQLite(3.25+) - 与 PostgreSQL,避免逐会话查询(N+1)。无消息的会话不在结果中。 - """ - if not session_ids: - return {} - latest = ( - select( - ChatMessage.session_id, - ChatMessage.content, - func.row_number() - .over( - partition_by=ChatMessage.session_id, - order_by=ChatMessage.created_at.desc(), - ) - .label("rn"), - ).where( - ChatMessage.session_id.in_(session_ids), - ChatMessage.deleted_at.is_(None), - ) - ).subquery() - result = await session.execute( - select(latest.c.session_id, latest.c.content).where(latest.c.rn == 1) - ) - return {str(sid): (content or "") for sid, content in result.all()} - - -async def count_messages_by_role(session: AsyncSession, session_id: str, *, role: str) -> int: - """统计会话内指定角色的未删除消息数(用于判断首轮对话)。""" - result = await session.execute( - select(func.count()) - .select_from(ChatMessage) - .where( - ChatMessage.session_id == session_id, - ChatMessage.role == role, - ChatMessage.deleted_at.is_(None), - ) - ) - return result.scalar_one() - - -async def update_session_title(session: AsyncSession, session_id: str, title: str) -> None: - """更新会话标题;仅修改托管对象,提交时机由调用方控制。""" - chat_session = await session.get(ChatSession, session_id) - if chat_session is not None: - chat_session.title = title - - -async def soft_delete_session_messages(session: AsyncSession, session_id: str) -> None: - """软删除会话内全部未删除消息;提交时机由调用方控制。""" - from app.core.time import utc_now - - await session.execute( - update(ChatMessage) - .where( - ChatMessage.session_id == session_id, - ChatMessage.deleted_at.is_(None), - ) - .values(deleted_at=utc_now()) - ) - - -async def get_message(session: AsyncSession, message_id: str) -> ChatMessage | None: - """按 ID 获取单条消息。""" - return await session.get(ChatMessage, message_id) - - -async def set_message_active_version( - session: AsyncSession, - message_id: str, - version_idx: int, - versions_json: str, -) -> None: - """更新消息的活跃版本索引和 versions_json。""" - from sqlalchemy import update - - result = await session.execute( - update(ChatMessage) - .where(ChatMessage.id == message_id) - .values(active_version_idx=version_idx, versions_json=versions_json) - ) - if result.rowcount == 0: - raise AppError("not_found", "消息不存在。") diff --git a/backend/app/repositories/personality_emotion.py b/backend/app/repositories/personality_emotion.py deleted file mode 100644 index 4fc6aba..0000000 --- a/backend/app/repositories/personality_emotion.py +++ /dev/null @@ -1,31 +0,0 @@ -"""灵核情绪状态数据访问(设计 §11.4)。""" - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.personality_emotion import CharacterEmotionState - - -async def get_state(session: AsyncSession, character_id: str) -> CharacterEmotionState | None: - """读取某角色当前情绪状态(单行 per character)。""" - return await session.get(CharacterEmotionState, character_id) - - -async def upsert_state( - session: AsyncSession, state: CharacterEmotionState -) -> CharacterEmotionState: - """写入情绪状态(存在则更新字段,不存在则插入)。 - - 跨方言(SQLite + Postgres)ORM 层 upsert,不依赖 ON CONFLICT。 - ``updated_at`` 由 SQLAlchemy ``onupdate`` 自动刷新。 - """ - existing = await session.get(CharacterEmotionState, state.character_id) - if existing is not None: - existing.mood = state.mood - existing.intensity = state.intensity - existing.baseline_mood = state.baseline_mood - existing.baseline_intensity = state.baseline_intensity - existing.last_trigger_event_id = state.last_trigger_event_id - return existing - session.add(state) - await session.flush() - return state diff --git a/backend/app/repositories/time_anniversaries.py b/backend/app/repositories/time_anniversaries.py deleted file mode 100644 index c275943..0000000 --- a/backend/app/repositories/time_anniversaries.py +++ /dev/null @@ -1,49 +0,0 @@ -"""时态纪念日数据访问(辰正 §5.3)。""" - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.time_anniversary import TimeAnniversary - - -async def create_anniversary( - session: AsyncSession, anniversary: TimeAnniversary -) -> TimeAnniversary: - session.add(anniversary) - await session.flush() - await session.refresh(anniversary) - return anniversary - - -async def get_anniversary(session: AsyncSession, anniversary_id: str) -> TimeAnniversary | None: - return await session.get(TimeAnniversary, anniversary_id) - - -async def list_anniversaries(session: AsyncSession, character_id: str) -> list[TimeAnniversary]: - result = await session.execute( - select(TimeAnniversary) - .where(TimeAnniversary.character_id == character_id) - .order_by(TimeAnniversary.date.asc()) - ) - return list(result.scalars().all()) - - -async def update_anniversary( - session: AsyncSession, anniversary_id: str, **fields: object -) -> TimeAnniversary | None: - anniversary = await session.get(TimeAnniversary, anniversary_id) - if anniversary is None: - return None - for key, value in fields.items(): - setattr(anniversary, key, value) - await session.flush() - return anniversary - - -async def delete_anniversary(session: AsyncSession, anniversary_id: str) -> bool: - anniversary = await session.get(TimeAnniversary, anniversary_id) - if anniversary is None: - return False - await session.delete(anniversary) - await session.flush() - return True diff --git a/backend/app/schemas/chat.py b/backend/app/schemas/chat.py deleted file mode 100644 index 7b29065..0000000 --- a/backend/app/schemas/chat.py +++ /dev/null @@ -1,254 +0,0 @@ -"""聊天 API schema。""" - -from typing import Any - -from pydantic import BaseModel, ConfigDict, Field - - -class CreateSessionRequest(BaseModel): - model_config = ConfigDict( - json_schema_extra={ - "examples": [ - { - "title": "大模型联调会话", - "character_id": "character_ec83d55746c54e47ab6f98d683032626", - } - ] - } - ) - - title: str | None = Field(default=None, max_length=200, description="会话标题。") - character_id: str | None = Field(default=None, description="人物 ID;为空时使用当前激活人物。") - - -class UpdateSessionRequest(BaseModel): - model_config = ConfigDict( - json_schema_extra={ - "examples": [ - {"title": "新的会话标题"}, - {"status": "archived"}, - ] - } - ) - - title: str | None = Field(default=None, max_length=200, description="新标题;为空时保持不变。") - status: str | None = Field( - default=None, - description="会话状态切换:active / archived。deleted 请走 DELETE 端点。", - ) - - -class ChatSessionResponse(BaseModel): - model_config = ConfigDict( - json_schema_extra={ - "examples": [ - { - "session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80", - "character_id": "character_ec83d55746c54e47ab6f98d683032626", - "title": "大模型联调会话", - "status": "active", - "created_at": "2026-06-19T09:06:47.515000Z", - "updated_at": "2026-06-19T09:10:52.690000Z", - } - ] - } - ) - - session_id: str = Field(description="会话 ID,前缀为 session_。") - character_id: str = Field(description="会话所属人物 ID。") - title: str | None = Field(description="会话标题。") - status: str = Field(description="会话状态:active / archived / deleted。") - created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。") - updated_at: str = Field(description="更新时间,UTC ISO 8601 字符串。") - message_count: int = Field(default=0, description="会话内未删除消息数(用户 + 助手)。") - last_message_preview: str | None = Field(default=None, description="最近一条消息的内容摘要。") - - -class ChatSessionListResponse(BaseModel): - model_config = ConfigDict( - json_schema_extra={ - "examples": [ - { - "items": [ - { - "session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80", - "character_id": "character_ec83d55746c54e47ab6f98d683032626", - "title": "大模型联调会话", - "status": "active", - "created_at": "2026-06-19T09:06:47.515000Z", - "updated_at": "2026-06-19T09:10:52.690000Z", - } - ], - "total": 1, - } - ] - } - ) - - items: list[ChatSessionResponse] = Field(description="当前页会话列表。") - total: int = Field(description="符合条件的会话总数。") - - -class SendMessageRequest(BaseModel): - model_config = ConfigDict( - json_schema_extra={ - "examples": [ - { - "content": "你好,请简单介绍一下你自己。", - "modality": "text", - "model_hint": "deepseek-v4-flash", - }, - { - "content": "请用三句话总结我们刚才聊到的内容。", - "modality": "text", - "model_hint": "default-chat", - }, - ] - } - ) - - content: str = Field(min_length=1, description="用户消息正文。") - modality: str = Field(default="text", description="消息模态;当前阶段仅支持 text。") - model_hint: str | None = Field( - default=None, - description="逻辑模型名或能力槽位,例如 default-chat、deepseek-v4-flash;为空时使用人物默认模型。", - ) - thinking: bool = Field( - default=False, description="是否开启思考模式(reasoning);模型不支持时忽略。" - ) - reasoning_effort: str | None = Field( - default=None, - description="思考力度:low / medium / high。thinking=false 时忽略,使用模型默认。", - ) - prior_versions: list[dict] | None = Field( - default=None, - description="重答时携带的历史版本列表,每项含 content 和 created_at。后端将其合并到新消息的 versions_json 中(前置插入)," - "同时软删除前一条 assistant 消息,避免刷新后出现重复消息。", - ) - - -class SendMessageResponse(BaseModel): - model_config = ConfigDict( - json_schema_extra={ - "examples": [ - { - "message_id": "message_462927da6f0e4af780dd28fd58f73314", - "assistant_message_id": "message_2f9b7bf7d3b84231b67b28bb39fbfa7a", - "run_id": "run_a2c8ecc73c0e4cc7bc2fafbaeada74e0", - "session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80", - "character_id": "character_ec83d55746c54e47ab6f98d683032626", - "status": "completed", - "stream_url": "/api/v1/runs/run_a2c8ecc73c0e4cc7bc2fafbaeada74e0/stream", - } - ] - } - ) - - message_id: str = Field(description="本次写入的用户消息 ID。") - assistant_message_id: str | None = Field(description="模型回复消息 ID;失败时可能为空。") - run_id: str = Field(description="本次对话运行 ID,可用于查询 Run 或 SSE。") - session_id: str = Field(description="会话 ID。") - character_id: str = Field(description="人物 ID。") - status: str = Field(description="运行状态。成功完成时为 completed。") - stream_url: str = Field(description="SSE 事件流 URL(前置于此,前端可直接 new EventSource)。") - - -class ChatMessageResponse(BaseModel): - model_config = ConfigDict( - json_schema_extra={ - "examples": [ - { - "message_id": "message_2f9b7bf7d3b84231b67b28bb39fbfa7a", - "session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80", - "character_id": "character_ec83d55746c54e47ab6f98d683032626", - "role": "assistant", - "content": "你好,我是魂镜中的默认人物,会结合记忆和当前会话与你对话。", - "modality": "text", - "run_id": "run_a2c8ecc73c0e4cc7bc2fafbaeada74e0", - "metadata": {"model_hint": "deepseek-v4-flash", "provider": "deepseek"}, - "sensitivity": "low", - "created_at": "2026-06-19T09:10:52.676462Z", - "deleted_at": None, - } - ] - } - ) - - message_id: str = Field(description="消息 ID,前缀为 message_。") - session_id: str = Field(description="所属会话 ID。") - character_id: str = Field(description="所属人物 ID。") - role: str = Field(description="消息角色:user / assistant / system / tool。") - content: str = Field(description="消息正文。") - modality: str = Field(description="消息模态:text / image / audio / file。") - run_id: str | None = Field(description="关联运行 ID。") - metadata: dict[str, Any] = Field(description="消息元数据,例如模型名、provider 等。") - sensitivity: str = Field(description="敏感等级:none / low / medium / high。") - versions: list[dict] = Field( - default=[], - description="版本历史列表,每项含 content 和 created_at。", - ) - active_version_idx: int = Field( - default=0, - description="当前活跃版本在 versions 中的索引。", - ) - created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。") - deleted_at: str | None = Field(description="删除时间;未删除时为空。") - - -class ChatMessageListResponse(BaseModel): - model_config = ConfigDict( - json_schema_extra={ - "examples": [ - { - "items": [ - { - "message_id": "message_462927da6f0e4af780dd28fd58f73314", - "session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80", - "character_id": "character_ec83d55746c54e47ab6f98d683032626", - "role": "user", - "content": "你好,请简单介绍一下你自己。", - "modality": "text", - "run_id": "run_a2c8ecc73c0e4cc7bc2fafbaeada74e0", - "metadata": {}, - "sensitivity": "low", - "created_at": "2026-06-19T09:10:52.660153Z", - "deleted_at": None, - }, - { - "message_id": "message_2f9b7bf7d3b84231b67b28bb39fbfa7a", - "session_id": "session_2e7c3cbd4ef8472bab59718b7d012f80", - "character_id": "character_ec83d55746c54e47ab6f98d683032626", - "role": "assistant", - "content": "你好,我是魂镜中的默认人物,会结合记忆和当前会话与你对话。", - "modality": "text", - "run_id": "run_a2c8ecc73c0e4cc7bc2fafbaeada74e0", - "metadata": {"model_hint": "deepseek-v4-flash", "provider": "deepseek"}, - "sensitivity": "low", - "created_at": "2026-06-19T09:10:52.676462Z", - "deleted_at": None, - }, - ], - "total": 2, - } - ] - } - ) - - items: list[ChatMessageResponse] = Field(description="按创建时间升序排列的消息列表。") - total: int = Field(description="符合条件的消息总数。") - - -class SelectVersionRequest(BaseModel): - model_config = ConfigDict( - json_schema_extra={ - "examples": [ - {"version_idx": 0}, - {"version_idx": 2}, - ] - } - ) - - version_idx: int = Field( - ge=0, - description="要激活的版本在消息 versions 数组中的索引(0-based)。", - ) diff --git a/backend/app/schemas/time.py b/backend/app/schemas/time.py deleted file mode 100644 index ffb474b..0000000 --- a/backend/app/schemas/time.py +++ /dev/null @@ -1,84 +0,0 @@ -"""辰正时态 API schema(设计 §10)。对外字段不带 _json 后缀。""" - -from pydantic import BaseModel, ConfigDict, Field - -from app.modules.time_sense.modulation import ModulationFactors -from app.modules.time_sense.snapshot import TemporalSnapshot - - -class SnapshotResponse(BaseModel): - """GET /time/snapshot 响应:快照 + 因子 + context_text。""" - - model_config = ConfigDict(from_attributes=True) - - snapshot: TemporalSnapshot | None - factors: ModulationFactors | None - context_text: str - - -class AnniversaryCreateRequest(BaseModel): - model_config = ConfigDict( - json_schema_extra={ - "examples": [ - { - "name": "我们的相遇日", - "calendar": "solar", - "date": "2025-09-19", - "recurs_yearly": True, - "kind": "anniversary", - "subject_type": "relationship", - "source": "manual", - } - ] - } - ) - - name: str = Field(max_length=200, description="纪念日名称。") - calendar: str = Field(description="历法:solar / lunar。") - date: str = Field(description="日期:公历 ISO 或农历编码。") - recurs_yearly: bool = Field(default=True, description="是否每年 recur。") - kind: str = Field(description="类型,取值见契约 §11.4.2。") - subject_type: str = Field(description="对象类型,取值见契约 §11.4.3。") - source: str = Field(default="manual", description="来源,取值见契约 §11.4.4。") - - -class AnniversaryUpdateRequest(BaseModel): - name: str | None = Field(default=None, max_length=200) - calendar: str | None = None - date: str | None = None - recurs_yearly: bool | None = None - kind: str | None = None - subject_type: str | None = None - source: str | None = None - - -class AnniversaryResponse(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: str - name: str - calendar: str - date: str - recurs_yearly: bool - kind: str - subject_type: str - source: str - - -class AcquaintanceResponse(BaseModel): - total_days: int - natural_language: str - - -class TimeConfigResponse(BaseModel): - """GET /time/config:当前时态相关配置(只读,v1 经 env 覆盖)。""" - - user_timezone: str - home_timezone: str - default_city: str - offline_active_minutes: float - offline_short_absence_minutes: float - offline_long_absence_minutes: float - offline_reunion_impact_minutes: float - care_enabled: bool - jetlag_threshold_minutes: int diff --git a/backend/app/services/card_import.py b/backend/app/services/card_import.py index 739aa1e..1be989d 100644 --- a/backend/app/services/card_import.py +++ b/backend/app/services/card_import.py @@ -1,117 +1,75 @@ -"""角色卡导入 / 导出服务。""" +"""角色卡导入 / 导出。 + +rewrite/1.0 M2b:导入不再直接建人物——ST V1/V2/V3 卡解析后产出**可审阅灵胚** +(产品 PRD §5.2「导入默认生成可审阅灵胚」);``character_book`` 暂存于灵胚道体草稿, +启镜时物化为新人物的世谱书与条目(见 jingsheng.service.activate_embryo)。 +导出方向保持:人物 → ST V3 卡静态快照。 +""" from __future__ import annotations -from sqlalchemy.ext.asyncio import AsyncSession - from app.core.errors import AppError from app.db.session import AsyncSessionLocal from app.models.character import Character -from app.models.lorebook import Lorebook, LorebookEntry from app.modules.imports.card_parser import parse_character_card from app.modules.imports.export_mapping import character_to_card -from app.modules.imports.mapping import ( - card_to_daoti, - card_to_profile, - character_book_to_entries, -) -from app.repositories import lorebook as lorebook_repo -from app.repositories.json_utils import dumps -from app.schemas.character import ImportCardResponse -async def import_character_card( - session: AsyncSession, raw: bytes, owner_scope: str -) -> Character: - """解析角色卡字节 → 建 Character + 世谱(character_book)。返回 Character ORM。 +def card_to_embryo_payload(raw: bytes) -> dict: + """解析角色卡字节 → 灵胚创建载荷(name/description/identity_baseline)。 - ``personality`` 写入道体 ``personality_summary`` 作初始值; - ``character_book`` 转为该角色的世谱书 + 条目。 + identity_baseline 保存卡的全部有效字段与原始 character_book, + 启镜前可经照心编辑(PATCH /embryo-drafts/{id})审阅修改。 """ card = parse_character_card(raw) - daoti = card_to_daoti(card) - - character = Character( - name=card.name or "未命名角色", - display_name=card.nickname or card.name or None, - language="zh-CN", - profile_json=dumps(card_to_profile(card)), - model_policy_json="{}", - personality_summary=daoti["personality_summary"], - privacy_level="normal", - owner_scope=owner_scope, + description = "\n\n".join( + part + for part in ( + card.description.strip(), + f"【性格】{card.personality.strip()}" if card.personality.strip() else "", + f"【场景】{card.scenario.strip()}" if card.scenario.strip() else "", + ) + if part ) - session.add(character) - await session.flush() - - entries_data = character_book_to_entries(card.character_book) - if entries_data: - book = card.character_book or {} - lorebook = Lorebook( - character_id=character.id, - name=book.get("name", "") or card.name, - description=book.get("description", ""), - scan_depth=book.get("scan_depth"), - token_budget=book.get("token_budget"), - recursive_scanning=bool(book.get("recursive_scanning", False)), - scope="character", - owner_scope=owner_scope, - ) - session.add(lorebook) - await session.flush() - for entry in entries_data: - session.add( - LorebookEntry( - lorebook_id=lorebook.id, - keys_json=dumps(entry["keys"]), - content=entry["content"], - secondary_keys_json=dumps(entry["secondary_keys"]), - constant=entry["constant"], - selective=entry["selective"], - enabled=entry["enabled"], - case_sensitive=entry["case_sensitive"], - use_regex=entry["use_regex"], - position=entry["position"], - insertion_order=entry["insertion_order"], - priority=entry["priority"], - comment=entry["comment"], - ) - ) - - await session.commit() - await session.refresh(character) - return character + baseline = { + "import_card": { + "spec_version": card.spec_version, + "description": card.description, + "personality": card.personality, + "scenario": card.scenario, + "first_mes": card.first_mes, + "mes_example": card.mes_example, + "system_prompt": card.system_prompt, + "post_history_instructions": card.post_history_instructions, + "alternate_greetings": card.alternate_greetings, + "tags": card.tags, + "creator": card.creator, + "character_version": card.character_version, + "character_book": card.character_book, + } + } + name = card.name.strip() or "未命名灵胚" + return { + "name": name[:120], + "description": description or None, + "identity_baseline": baseline, + "book_entry_count": _count_book_entries(card.character_book), + } -async def import_character_card_response( - raw: bytes, owner_scope: str -) -> ImportCardResponse: - """导入角色卡 → ImportCardResponse(供 API 调用,自管 session)。""" - async with AsyncSessionLocal() as session: - character = await import_character_card(session, raw, owner_scope) - books = await lorebook_repo.list_books(session, character.id, owner_scope) - book_id = books[0].id if books else None - entries = ( - await lorebook_repo.list_book_entries(session, book_id) if book_id else [] - ) - return ImportCardResponse( - character_id=character.id, - name=character.name, - display_name=character.display_name, - lorebook_id=book_id, - entry_count=len(entries), - ) +def _count_book_entries(book: dict | None) -> int: + if not book: + return 0 + entries = book.get("entries") + if isinstance(entries, (dict, list)): + return len(entries) + return 0 async def export_character_card(character_id: str, owner_scope: str) -> dict: - """导出角色为 ST V3 卡静态快照(丢弃演化维度)。""" + """导出人物为 ST V3 卡静态快照(丢弃演化维度)。""" async with AsyncSessionLocal() as session: character = await session.get(Character, character_id) - if character is None or character.owner_scope != owner_scope: - raise AppError("not_found", "人物不存在。") - books = await lorebook_repo.list_books(session, character_id, owner_scope) - book = books[0] if books else None - entries = ( - await lorebook_repo.list_book_entries(session, book.id) if book else [] - ) - return character_to_card(character, book, entries) + if character is None or character.owner_scope != owner_scope: + raise AppError("not_found", "人物不存在。") + return character_to_card(character) diff --git a/backend/app/services/chat.py b/backend/app/services/chat.py deleted file mode 100644 index 5ad6a33..0000000 --- a/backend/app/services/chat.py +++ /dev/null @@ -1,252 +0,0 @@ -"""聊天服务。""" - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from app.modules.safety.confirmation import ConfirmationStore - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.config import settings -from app.core.errors import AppError -from app.db.session import AsyncSessionLocal -from app.models.chat import ChatMessage, ChatSession -from app.repositories import chat as chat_repo -from app.repositories.json_utils import dumps, loads_list -from app.schemas.chat import ( - ChatMessageListResponse, - ChatMessageResponse, - ChatSessionListResponse, - ChatSessionResponse, - CreateSessionRequest, - SelectVersionRequest, - SendMessageRequest, - SendMessageResponse, - UpdateSessionRequest, -) -from app.services.characters import get_active_character_model -from app.services.serializers import chat_message_to_response, chat_session_to_response - -if TYPE_CHECKING: - from app.application.agent.confirmation import ConfirmationRegistry - from app.application.agent.python_skills import PythonSkillCatalog - from app.application.agent.run_registry import AgentRunRegistry - from app.application.tool_runtime.runtime import ToolRuntime - from app.modules.runtime.events import EventStore - - -async def create_session( - payload: CreateSessionRequest, owner_scope: str = settings.default_owner_scope -) -> ChatSessionResponse: - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session, owner_scope=owner_scope) - character_id = payload.character_id or character.id - if character_id != character.id: - raise AppError("not_found", "人物不存在。") - chat_session = ChatSession( - character_id=character_id, - title=payload.title, - status="active", - owner_scope=owner_scope, - ) - session.add(chat_session) - await session.commit() - await session.refresh(chat_session) - return chat_session_to_response(chat_session) - - -_PREVIEW_LEN = 80 - - -def _truncate_preview(content: str | None) -> str | None: - """截断消息内容为列表预览长度;空内容返回 None。""" - if not content: - return None - text = content.strip() - if len(text) <= _PREVIEW_LEN: - return text - return text[:_PREVIEW_LEN].rstrip() + "…" - - -async def list_session_responses( - *, - owner_scope: str = settings.default_owner_scope, - status: str | None = None, - limit: int = 50, - offset: int = 0, -) -> ChatSessionListResponse: - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session, owner_scope=owner_scope) - sessions = await chat_repo.list_sessions( - session, character.id, status=status, owner_scope=owner_scope, limit=limit, offset=offset - ) - total = await chat_repo.count_sessions( - session, character.id, status=status, owner_scope=owner_scope - ) - session_ids = [s.id for s in sessions] - counts = await chat_repo.count_messages_by_sessions(session, session_ids) - previews = await chat_repo.last_message_previews_by_sessions(session, session_ids) - return ChatSessionListResponse( - items=[ - chat_session_to_response( - s, - message_count=counts.get(s.id, 0), - last_message_preview=_truncate_preview(previews.get(s.id)), - ) - for s in sessions - ], - total=total, - ) - - -async def get_session_response( - session_id: str, owner_scope: str = settings.default_owner_scope -) -> ChatSessionResponse: - async with AsyncSessionLocal() as session: - chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope) - if chat_session is None or chat_session.status == "deleted": - raise AppError("not_found", "会话不存在。") - message_count = await chat_repo.count_messages(session, chat_session.id) - recent = await chat_repo.list_recent_messages(session, chat_session.id, limit=1) - preview = _truncate_preview(recent[0].content if recent else None) - return chat_session_to_response( - chat_session, - message_count=message_count, - last_message_preview=preview, - ) - - -_ALLOWED_SESSION_STATUSES = {"active", "archived"} - - -async def update_session_response( - session_id: str, payload: UpdateSessionRequest, owner_scope: str = settings.default_owner_scope -) -> ChatSessionResponse: - """更新会话标题或状态(active/archived);归属校验同 get。 - - deleted 态由 DELETE 端点承担,此处禁止直接置为 deleted。 - """ - async with AsyncSessionLocal() as session: - # owner_scope 校验已由 get_session 内化,无需重复查 character - chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope) - if chat_session is None or chat_session.status == "deleted": - raise AppError("not_found", "会话不存在。") - if payload.title is not None: - chat_session.title = payload.title - if payload.status is not None: - if payload.status not in _ALLOWED_SESSION_STATUSES: - raise AppError( - "invalid_request", - "会话状态仅可切换为 active 或 archived;删除请使用 DELETE。", - ) - chat_session.status = payload.status - await session.commit() - await session.refresh(chat_session) - message_count = await chat_repo.count_messages(session, chat_session.id) - return chat_session_to_response(chat_session, message_count=message_count) - - -async def delete_session(session_id: str, owner_scope: str = settings.default_owner_scope) -> None: - """软删除会话:session.status=deleted 并软删其全部消息。 - - Run/Event 为审计/日志型数据,保留不删;消息通过 deleted_at 软删, - 与现有查询过滤保持一致。 - """ - async with AsyncSessionLocal() as session: - # owner_scope 校验已由 get_session 内化,无需重复查 character - chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope) - if chat_session is None or chat_session.status == "deleted": - raise AppError("not_found", "会话不存在。") - chat_session.status = "deleted" - await chat_repo.soft_delete_session_messages(session, session_id) - await session.commit() - - -async def list_message_responses( - session_id: str, - *, - owner_scope: str = settings.default_owner_scope, - limit: int = 100, - offset: int = 0, -) -> ChatMessageListResponse: - async with AsyncSessionLocal() as session: - chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope) - if chat_session is None or chat_session.status == "deleted": - raise AppError("not_found", "会话不存在。") - messages = await chat_repo.list_messages(session, session_id, limit=limit, offset=offset) - total = await chat_repo.count_messages(session, session_id) - return ChatMessageListResponse( - items=[chat_message_to_response(message) for message in messages], - total=total, - ) - - -async def recent_chat_messages( - session: AsyncSession, session_id: str, *, limit: int = 12 -) -> list[ChatMessage]: - """加载会话最近消息(供上下文组装)——接收外部 session,供 send_message usecase 复用。""" - return await chat_repo.list_recent_messages(session, session_id, limit=limit) - - -async def send_message( - session: AsyncSession, - session_id: str, - payload: SendMessageRequest, - *, - owner_scope: str = settings.default_owner_scope, - tool_runtime: "ToolRuntime | None" = None, - confirmation_registry: "ConfirmationRegistry | None" = None, - confirmation_store: "ConfirmationStore | None" = None, - event_store: "EventStore | None" = None, - agent_run_registry: "AgentRunRegistry | None" = None, - python_skill_catalog: "PythonSkillCatalog | None" = None, -) -> SendMessageResponse: - """发送消息——接收外部 session(由 run_idempotent 注入,保证幂等记录与发送同事务)。""" - from app.application.chat.send_message import SendChatMessageUseCase - - return await SendChatMessageUseCase( - session, - owner_scope=owner_scope, - tool_runtime=tool_runtime, - confirmation_registry=confirmation_registry, - confirmation_store=confirmation_store, - event_store=event_store, - agent_run_registry=agent_run_registry, - python_skill_catalog=python_skill_catalog, - ).execute(session_id, payload) - - -async def select_message_version( - session_id: str, - message_id: str, - payload: SelectVersionRequest, - *, - owner_scope: str = settings.default_owner_scope, -) -> ChatMessageResponse: - """选择/激活消息的某个历史版本。""" - async with AsyncSessionLocal() as session: - # owner_scope 校验已由 get_session 内化 - chat_session = await chat_repo.get_session(session, session_id, owner_scope=owner_scope) - if chat_session is None or chat_session.status == "deleted": - raise AppError("not_found", "会话不存在。") - - message = await chat_repo.get_message(session, message_id) - if message is None or message.session_id != session_id: - raise AppError("not_found", "消息不存在。") - - versions = loads_list(message.versions_json) - if payload.version_idx < 0 or payload.version_idx >= len(versions): - raise AppError( - "invalid_request", - f"版本索引 {payload.version_idx} 超出范围 (0-{len(versions) - 1})。", - ) - - await chat_repo.set_message_active_version( - session, - message_id, - payload.version_idx, - dumps(versions), - ) - await session.commit() - await session.refresh(message) - return chat_message_to_response(message) diff --git a/backend/app/services/personality_emotion.py b/backend/app/services/personality_emotion.py deleted file mode 100644 index 51a5783..0000000 --- a/backend/app/services/personality_emotion.py +++ /dev/null @@ -1,141 +0,0 @@ -"""灵核情绪状态服务(设计 §11):拉模式计算 + 一炁订阅 time.* 处理器。 - -降级原则(设计 §12 / 辰正 §12):任何失败 → emotion_state=None,不阻塞对话/事件流。 -本模块是一炁订阅基础设施(计划 5 ``Dispatcher``)的第一个真实消费者。 -""" - -from __future__ import annotations - -from datetime import UTC, datetime - -from loguru import logger -from sqlalchemy import func, select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.session import AsyncSessionLocal -from app.models.character import Character -from app.models.chat import ChatMessage -from app.models.personality_emotion import CharacterEmotionState -from app.modules.personality.emotion import ( - EmotionState, - apply_signal, - decay, - parse_baseline, - to_prompt_text, -) -from app.modules.runtime.events import Event -from app.repositories.personality import get_latest_personality, get_personality -from app.repositories.personality_emotion import get_state, upsert_state - - -async def _resolve_baseline(session: AsyncSession, character_id: str) -> EmotionState: - """从当前人格版本 ``emotion_baseline`` 解析 baseline。 - - character 不存在 / 无人格版本 / baseline 损坏 → 默认 neutral 0.2(parse_baseline 容错)。 - """ - character = await session.get(Character, character_id) - if character is None: - return EmotionState() - pv = None - if character.current_personality_version_id: - pv = await get_personality(session, character.current_personality_version_id) - if pv is None: - pv = await get_latest_personality(session, character_id) - if pv is None: - return EmotionState() - return parse_baseline(pv.emotion_baseline) - - -async def _count_user_turns_since( - session: AsyncSession, character_id: str, since: datetime -) -> float: - """自 since 以来该角色未删除的 user 消息数(近似对话回合)。""" - result = await session.execute( - select(func.count()) - .select_from(ChatMessage) - .where( - ChatMessage.character_id == character_id, - ChatMessage.role == "user", - ChatMessage.created_at > since, - ChatMessage.deleted_at.is_(None), - ) - ) - return float(result.scalar_one()) - - -async def compute_current_state(session: AsyncSession, character_id: str) -> EmotionState | None: - """拉模式:读最新 state + baseline,按自上次更新以来的 user 回合 decay。 - - - 无 state row:返回 baseline(不 decay)。 - - 有 state row:``decay(state, baseline, elapsed_turns)``。 - - 任何异常降级返回 None。 - """ - try: - baseline = await _resolve_baseline(session, character_id) - row = await get_state(session, character_id) - if row is None: - return baseline - current = EmotionState(mood=row.mood, intensity=row.intensity) - # SQLite DateTime(timezone=True) 读回可能 naive,与 aware 比较前补 tz(同 time_sense 防御)。 - updated = row.updated_at - if updated.tzinfo is None: - updated = updated.replace(tzinfo=UTC) - elapsed = await _count_user_turns_since(session, character_id, updated) - return decay(current, baseline, elapsed) - except Exception: - logger.exception("灵核 compute_current_state 降级 character_id={}", character_id) - return None - - -async def compute_emotion_prompt(session: AsyncSession, character_id: str) -> str | None: - """拉模式 → system prompt 文本;失败返回 None(不阻塞对话)。""" - state = await compute_current_state(session, character_id) - if state is None: - return None - return to_prompt_text(state) - - -async def apply_time_event(event: Event, session: AsyncSession) -> None: - """订阅处理器核心:辰正 time.* 事件 → 更新情绪状态。 - - - 读当前 state(无则用 baseline) - - ``apply_signal`` 计算新状态 - - ``upsert_state`` + 同步 ``Character.current_mood_id`` - - 无 character_id 的事件直接跳过;DB 异常由 ``handle_time_event`` 兜底。 - """ - character_id = event.character_id - if character_id is None: - return - baseline = await _resolve_baseline(session, character_id) - row = await get_state(session, character_id) - current = EmotionState(mood=row.mood, intensity=row.intensity) if row else baseline - - new_state = apply_signal(current, event.type, event.payload, baseline) - await upsert_state( - session, - CharacterEmotionState( - character_id=character_id, - mood=new_state.mood, - intensity=new_state.intensity, - baseline_mood=baseline.mood, - baseline_intensity=baseline.intensity, - last_trigger_event_id=event.id, - ), - ) - character = await session.get(Character, character_id) - if character is not None: - character.current_mood_id = new_state.mood - - -async def handle_time_event(event: Event) -> None: - """Dispatcher ``time.*`` 处理器:自管 session + 提交,降级不抛。 - - 一炁订阅基础设施(计划 5)的第一个真实消费者,注册在 ``main.py`` lifespan。 - """ - try: - async with AsyncSessionLocal() as session: - await apply_time_event(event, session) - await session.commit() - except Exception: - logger.exception("灵核 handle_time_event 降级 event_id={} type={}", event.id, event.type) diff --git a/backend/app/services/time_sense.py b/backend/app/services/time_sense.py deleted file mode 100644 index b5118df..0000000 --- a/backend/app/services/time_sense.py +++ /dev/null @@ -1,390 +0,0 @@ -"""辰正时态服务编排(设计 §6 流 A + 流 C)。 - -职责: -- 流 A:拉数据 → compute_snapshot + compute_modulation → 组 temporal_context 文本 -- 流 C:检测 care / sentiment / reunion → 发 time.* 事件到 EventStore - -降级原则(设计 §12):任何计算失败返回最小结果,不阻塞对话。 -本 plan 不含流 B(后台 tick)——依赖未实现的 background_task 基础设施。 -""" - -from __future__ import annotations - -from datetime import UTC, datetime -from typing import Any - -from loguru import logger -from pydantic import BaseModel, Field -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.config import settings -from app.core.errors import AppError -from app.core.time import utc_now -from app.db.session import AsyncSessionLocal -from app.models.chat import ChatMessage -from app.models.time_anniversary import TimeAnniversary -from app.modules.runtime.events import Event, EventStore -from app.modules.time_sense.context_text import render_temporal_context -from app.modules.time_sense.duration import AcquaintanceDuration, OfflineThresholds -from app.modules.time_sense.modulation import ModulationFactors, ModulationInput, compute_modulation -from app.modules.time_sense.sentiment import SentimentInput, detect_sentiment -from app.modules.time_sense.snapshot import ( - AnniversaryRef, - LocationContext, - SnapshotInput, - TemporalSnapshot, - compute_snapshot, -) -from app.repositories.time_anniversaries import ( - create_anniversary, - delete_anniversary, - get_anniversary, - list_anniversaries, - update_anniversary, -) -from app.schemas.time import AnniversaryCreateRequest, AnniversaryUpdateRequest -from app.services.characters import get_active_character_model - - -class LiveSnapshotResult(BaseModel): - """compute_live_snapshot 的返回。""" - - snapshot: TemporalSnapshot | None = None - factors: ModulationFactors | None = None - context_text: str = "" - emitted_events: list[Event] = Field(default_factory=list) - - -async def emit_time_event( - store: EventStore, - event_type: str, - character_id: str, - payload: dict[str, Any], - *, - run_id: str | None = None, - source: str = "time_sense", - sensitivity: str = "low", -) -> Event: - """构造并 append 一个 time.* 事件。""" - event = Event( - type=event_type, - source=source, - payload=payload, - character_id=character_id, - run_id=run_id, - sensitivity=sensitivity, - ) - return await store.append(event) - - -def _thresholds_from_config() -> OfflineThresholds: - return OfflineThresholds( - active_minutes=settings.offline_active_minutes, - short_absence_minutes=settings.offline_short_absence_minutes, - long_absence_minutes=settings.offline_long_absence_minutes, - reunion_impact_minutes=settings.offline_reunion_impact_minutes, - ) - - -async def _resolve_last_active( - session: AsyncSession, character_id: str, fallback: datetime -) -> datetime: - """查 chat_messages 最新一条的 created_at;无记录返回 fallback。 - - DB 异常向上冒泡,由 ``compute_live_snapshot`` 降级处理(设计 §12)。 - """ - result = await session.execute( - select(ChatMessage.created_at) - .where(ChatMessage.character_id == character_id) - .order_by(ChatMessage.created_at.desc()) - .limit(1) - ) - row = result.scalar_one_or_none() - return row if row is not None else fallback - - -def _anniversary_refs(rows: list[TimeAnniversary]) -> list[AnniversaryRef]: - """TimeAnniversary ORM → AnniversaryRef(snapshot 输入)。""" - refs: list[AnniversaryRef] = [] - for a in rows: - refs.append( - AnniversaryRef( - id=a.id, - name=a.name, - calendar=a.calendar, - date_iso=a.date, - recurs_yearly=a.recurs_yearly, - kind=a.kind, - ) - ) - return refs - - -async def compute_live_snapshot( - session: AsyncSession, - store: EventStore, - character_id: str, # noqa: ARG001 v1 单人物:忽略传入 id,使用当前 active 角色(设计已注明) - signals: ModulationInput, - *, - now: datetime | None = None, - timezone: str | None = None, - run_id: str | None = None, - owner_scope: str = settings.default_owner_scope, -) -> LiveSnapshotResult: - """流 A + 流 C:算快照/因子/上下文 + 触发 time.* 事件。 - - 任何异常降级返回最小结果(context_text 含 now_utc,因子/事件空),不抛。 - """ - now = now or utc_now() - emitted: list[Event] = [] - try: - character = await get_active_character_model(session, owner_scope=owner_scope) - # v1:character_id 应与 active 一致;若不一致仍用 active(v1 单人物) - anniv_rows = await list_anniversaries(session, character.id) - anniv_refs = _anniversary_refs(anniv_rows) - - anchor = character.activated_at or character.created_at - # SQLite 的 DateTime(timezone=True) 仍按 naive 读回(Postgres 不受影响); - # 此处与 serializers.py 同样的防御式补 tzinfo,避免与 aware now 做差时 TypeError。 - if anchor.tzinfo is None: - anchor = anchor.replace(tzinfo=UTC) - last_active = await _resolve_last_active(session, character.id, fallback=now) - if last_active.tzinfo is None: - last_active = last_active.replace(tzinfo=UTC) - - location = LocationContext( - timezone=timezone or settings.user_timezone, - city=settings.default_city, - locale=settings.default_locale, - ) - inp = SnapshotInput( - now=now, - timezone=timezone or settings.user_timezone, - location=location, - anniversaries=anniv_refs, - acquaintance_anchor=anchor, - last_active_at=last_active, - home_timezone=settings.home_timezone, - offline_thresholds=_thresholds_from_config(), - ) - # v1 阈值可调(设计 §7.3):ModulationInput 阈值默认 None,此处从 settings 注入; - # 保持 modules/time_sense 纯函数(不读 settings),测试可显式传值覆盖。 - if signals.jetlag_threshold_minutes is None: - signals = signals.model_copy( - update={"jetlag_threshold_minutes": settings.jetlag_threshold_minutes} - ) - if signals.late_night_min_turns is None: - signals = signals.model_copy( - update={"late_night_min_turns": settings.late_night_min_turns} - ) - snapshot = compute_snapshot(inp) - factors = compute_modulation(snapshot, signals) - context_text = render_temporal_context(snapshot, factors) - - # 流 C 事件触发(Task 4 在此扩展) - await _trigger_events( - store, - snapshot, - factors, - signals, - character.id, - emitted, - anchor, - run_id=run_id, - ) - - return LiveSnapshotResult( - snapshot=snapshot, - factors=factors, - context_text=context_text, - emitted_events=emitted, - ) - except Exception: - # 降级:最小快照(设计 §12)——记日志便于排查,辰正永不阻塞对话 - logger.exception("辰正 compute_live_snapshot 降级 character_id={}", character_id) - return LiveSnapshotResult( - snapshot=None, - factors=None, - context_text=f"[时态上下文暂不可用] 当前 UTC:{now.astimezone(UTC).isoformat()}", - emitted_events=emitted, - ) - - -async def _trigger_events( - store: EventStore, - snapshot: TemporalSnapshot, - factors: ModulationFactors, - signals: ModulationInput, # noqa: ARG001 v1 仅 care 用 factors;signals 留 v2 内容信号扩展 - character_id: str, - emitted: list[Event], - anchor: datetime, # noqa: ARG001 留 v2 认识天数/里程碑锚点事件 - run_id: str | None = None, -) -> None: - """流 C:care / sentiment / reunion 检测 + emit。""" - # 1. 关怀(基于 compute_modulation 的 care_kind) - if factors.care_kind is not None and settings.care_enabled: - ev = await emit_time_event( - store, - "time.user_care", - character_id, - { - "care_kind": factors.care_kind, - "care_suggest": factors.care_suggest, - "late_night_fragility": factors.late_night_fragility, - "day_period": snapshot.day_period, - "local_hour": snapshot.local_hour, - }, - run_id=run_id, - ) - emitted.append(ev) - - # 2. 感慨(5 触发) - is_reunion = snapshot.offline.band == "reunion_impact" - next_milestone_days = snapshot.next_milestone.days_until if snapshot.next_milestone else None - sentiment_signals = SentimentInput( - duration_days=snapshot.acquaintance.total_days, - next_milestone_days=next_milestone_days, - has_active_milestone=bool(snapshot.active_milestones), - is_reunion=is_reunion, - has_reunion_memory=is_reunion, # v1 简化:重逢即假定有临别记忆;v2 从息壤查 - strong_solar_terms=settings.strong_solar_terms, - prewarm_days=settings.sentiment_prewarm_days, - duration_nodes=settings.duration_nodes, - ) - trigger = detect_sentiment(snapshot, sentiment_signals) - if trigger is not None: - ev = await emit_time_event( - store, - "time.sentiment", - character_id, - { - "trigger_type": trigger, - "duration_days": snapshot.acquaintance.total_days, - "day_period": snapshot.day_period, - }, - run_id=run_id, - ) - emitted.append(ev) - - # 3. 重逢 - if is_reunion: - ev = await emit_time_event( - store, - "time.reunion", - character_id, - { - "offline_minutes": round(snapshot.offline.minutes, 1), - "reunion_intensity": factors.reunion_intensity, - "offline_band": snapshot.offline.band, - }, - sensitivity="medium", # 重逢涉及久别记忆,标 medium - run_id=run_id, - ) - emitted.append(ev) - - -# ---- service 包装函数(薄 endpoint 调用;session 在此自管)---- - - -async def get_live_snapshot( - store: EventStore, owner_scope: str = settings.default_owner_scope -) -> LiveSnapshotResult: - """GET /time/snapshot:当前 active 人物的实时快照。""" - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session, owner_scope=owner_scope) - return await compute_live_snapshot( - session, - store, - character.id, - ModulationInput(), - owner_scope=owner_scope, - ) - - -async def list_anniversaries_for_active( - owner_scope: str = settings.default_owner_scope, -) -> list[TimeAnniversary]: - """GET /time/anniversaries:当前 active 人物的纪念日列表。""" - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session, owner_scope=owner_scope) - return await list_anniversaries(session, character.id) - - -async def create_anniversary_for_active( - payload: AnniversaryCreateRequest, owner_scope: str = settings.default_owner_scope -) -> TimeAnniversary: - """POST /time/anniversaries:为当前 active 人物创建纪念日。""" - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session, owner_scope=owner_scope) - anni = TimeAnniversary( - character_id=character.id, - name=payload.name, - calendar=payload.calendar, - date=payload.date, - recurs_yearly=payload.recurs_yearly, - kind=payload.kind, - subject_type=payload.subject_type, - source=payload.source, - ) - created = await create_anniversary(session, anni) - await session.commit() - return created - - -async def get_anniversary_or_404( - anniv_id: str, - owner_scope: str = settings.default_owner_scope, # noqa: ARG001 -) -> TimeAnniversary: - """GET /time/anniversaries/{id}:取单个,不存在抛 AppError(not_found)。""" - async with AsyncSessionLocal() as session: - anni = await get_anniversary(session, anniv_id) - if anni is None: - raise AppError("not_found", "纪念日不存在。") - return anni - - -async def update_anniversary_or_404( - anniv_id: str, - payload: AnniversaryUpdateRequest, - owner_scope: str = settings.default_owner_scope, # noqa: ARG001 -) -> TimeAnniversary: - """PATCH /time/anniversaries/{id}:部分更新,不存在抛 AppError(not_found)。""" - async with AsyncSessionLocal() as session: - fields = payload.model_dump(exclude_none=True) - updated = await update_anniversary(session, anniv_id, **fields) - if updated is None: - raise AppError("not_found", "纪念日不存在。") - await session.commit() - return updated - - -async def delete_anniversary_or_404( - anniv_id: str, - owner_scope: str = settings.default_owner_scope, # noqa: ARG001 -) -> None: - """DELETE /time/anniversaries/{id}:删除,不存在抛 AppError(not_found)。""" - async with AsyncSessionLocal() as session: - ok = await delete_anniversary(session, anniv_id) - if not ok: - raise AppError("not_found", "纪念日不存在。") - await session.commit() - - -async def get_acquaintance_for_active( - store: EventStore, owner_scope: str = settings.default_owner_scope -) -> AcquaintanceDuration: - """GET /time/acquaintance:当前 active 人物的认识时长。snapshot 降级时抛 503。""" - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session, owner_scope=owner_scope) - result = await compute_live_snapshot( - session, - store, - character.id, - ModulationInput(), - owner_scope=owner_scope, - ) - if result.snapshot is None: - # 契约 §15 / 设计 §12:time_context_missing → invalid_request(默认 400) - raise AppError("time_context_missing", "时态上下文不可用。") - return result.snapshot.acquaintance diff --git a/backend/tests/api/test_character_import_api.py b/backend/tests/api/test_character_import_api.py deleted file mode 100644 index 6ddec68..0000000 --- a/backend/tests/api/test_character_import_api.py +++ /dev/null @@ -1,97 +0,0 @@ -"""角色卡导入 API 测试。""" - -from __future__ import annotations - -import base64 -import json -import struct -import zlib - -import httpx - - -def _make_png_text(keyword: str, text: str) -> bytes: - def chunk(ctype: bytes, data: bytes) -> bytes: - body = ctype + data - return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) - - payload = keyword.encode("latin-1") + b"\x00" + text.encode("latin-1") - return b"\x89PNG\r\n\x1a\n" + chunk(b"tEXt", payload) + chunk(b"IEND", b"") - - -async def test_import_json_card(auth_client: httpx.AsyncClient) -> None: - card = {"spec_version": "2.0", "data": {"name": "API卡", "personality": "p"}} - resp = await auth_client.post( - "/api/v1/characters/import-card", - files={"file": ("card.json", json.dumps(card).encode("utf-8"), "application/json")}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["name"] == "API卡" - assert data["character_id"].startswith("character_") - - -async def test_import_png_card(auth_client: httpx.AsyncClient) -> None: - card = {"spec_version": "2.0", "data": {"name": "PNG卡"}} - text = base64.b64encode(json.dumps(card).encode("utf-8")).decode("ascii") - resp = await auth_client.post( - "/api/v1/characters/import-card", - files={"file": ("card.png", _make_png_text("chara", text), "image/png")}, - ) - assert resp.status_code == 200 - assert resp.json()["name"] == "PNG卡" - - -async def test_import_card_with_lorebook(auth_client: httpx.AsyncClient) -> None: - card = { - "spec_version": "2.0", - "data": { - "name": "带书", - "character_book": {"entries": [{"keys": ["a"], "content": "b"}]}, - }, - } - resp = await auth_client.post( - "/api/v1/characters/import-card", - files={"file": ("card.json", json.dumps(card).encode("utf-8"), "application/json")}, - ) - assert resp.status_code == 200 - data = resp.json() - assert data["lorebook_id"] is not None - assert data["entry_count"] == 1 - # 导入的人物可被列出 - listed = await auth_client.get("/api/v1/characters") - assert any(c["id"] == data["character_id"] for c in listed.json()["items"]) - - -async def test_export_card_round_trip(auth_client: httpx.AsyncClient) -> None: - # 导入一张带世谱的卡 - card = { - "spec_version": "2.0", - "data": { - "name": "往返", - "nickname": "返返", - "personality": "p", - "character_book": {"entries": [{"keys": ["k"], "content": "v"}]}, - }, - } - imp = await auth_client.post( - "/api/v1/characters/import-card", - files={"file": ("c.json", json.dumps(card).encode("utf-8"), "application/json")}, - ) - assert imp.status_code == 200 - cid = imp.json()["character_id"] - - # 导出 - exp = await auth_client.get(f"/api/v1/characters/{cid}/export-card") - assert exp.status_code == 200 - exported = exp.json() - assert exported["spec"] == "chara_card_v3" - assert exported["data"]["name"] == "往返" - assert exported["data"]["nickname"] == "返返" - assert exported["data"]["personality"] == "p" - assert exported["data"]["character_book"]["entries"][0]["keys"] == ["k"] - - -async def test_export_unknown_character_404(auth_client: httpx.AsyncClient) -> None: - exp = await auth_client.get("/api/v1/characters/character_missing/export-card") - assert exp.status_code == 404 diff --git a/backend/tests/application/chat/test_daoti_injection.py b/backend/tests/application/chat/test_daoti_injection.py deleted file mode 100644 index 11e10a5..0000000 --- a/backend/tests/application/chat/test_daoti_injection.py +++ /dev/null @@ -1,59 +0,0 @@ -"""道体注入与完整性校验测试。""" - -from __future__ import annotations - -import pytest - -from app.application.chat.send_message import _contract_system_messages -from app.modules.runtime.contract import RuntimeContract -from app.modules.runtime.integrity import validate_daoti_integrity - - -def _contract(**kw: object) -> RuntimeContract: - base: dict[str, object] = { - "character_id": "c", - "session_id": "s", - "run_id": "r", - "model_hint": "m", - "trace_id": "t", - } - base.update(kw) - return RuntimeContract(**base) - - -def test_system_prompt_injected() -> None: - msgs = _contract_system_messages(_contract(system_prompt="特殊系统指令")) - assert "特殊系统指令" in msgs - - -def test_mission_injected() -> None: - msgs = _contract_system_messages(_contract(mission="守护用户")) - assert any("守护用户" in m for m in msgs) - - -def test_safety_guidelines_precede_safety_constraints() -> None: - c = _contract(safety_guidelines="道体边界", safety_constraints=["司命约束"]) - msgs = _contract_system_messages(c) - assert msgs.index("行为边界:\n道体边界") < msgs.index("安全约束:\n- 司命约束") - - -def test_integrity_pass_when_consistent() -> None: - c = _contract(mission="M", safety_guidelines="S") - validate_daoti_integrity(c, mission="M", safety_guidelines="S") # 不抛即通过 - - -def test_integrity_raises_on_mission_tamper() -> None: - c = _contract(mission="M") - with pytest.raises(ValueError): - validate_daoti_integrity(c, mission="被篡改") - - -def test_integrity_raises_on_safety_tamper() -> None: - c = _contract(safety_guidelines="S") - with pytest.raises(ValueError): - validate_daoti_integrity(c, safety_guidelines="被篡改") - - -def test_integrity_skips_none_fields() -> None: - # 道体字段未设(None)时跳过校验 - validate_daoti_integrity(_contract(), mission=None, safety_guidelines=None) diff --git a/backend/tests/application/chat/test_emotion_injection.py b/backend/tests/application/chat/test_emotion_injection.py deleted file mode 100644 index 59d32e5..0000000 --- a/backend/tests/application/chat/test_emotion_injection.py +++ /dev/null @@ -1,48 +0,0 @@ -"""灵核情绪状态 system prompt 注入测试(设计 §11.5)。 - -验证 ``_contract_system_messages`` 把 ``emotion_state`` 注入到正确位置 -(personality_summary 后、memory_context 前),且 emotion_state=None 时不注入。 -""" - -from __future__ import annotations - -from app.application.chat.send_message import _contract_system_messages -from app.modules.runtime.contract import RuntimeContract - - -def _contract(**overrides) -> RuntimeContract: - base = { - "character_id": "c1", - "session_id": "s1", - "run_id": "r1", - "model_hint": "m", - "trace_id": "t", - } - base.update(overrides) - return RuntimeContract(**base) - - -def test_emotion_state_injected_between_personality_and_memory() -> None: - contract = _contract( - personality_summary="人格X", - emotion_state="[当前情绪] 感动(强度 70%)", - memory_context="记忆Y", - ) - messages = _contract_system_messages(contract) - personality_idx = next(i for i, m in enumerate(messages) if "人格X" in m) - emotion_idx = next(i for i, m in enumerate(messages) if "感动" in m) - memory_idx = next(i for i, m in enumerate(messages) if "记忆Y" in m) - assert personality_idx < emotion_idx < memory_idx - - -def test_emotion_state_omitted_when_none() -> None: - contract = _contract(personality_summary="人格X", memory_context="记忆Y") - messages = _contract_system_messages(contract) - assert all("当前情绪" not in m for m in messages) - - -def test_emotion_state_included_without_memory() -> None: - """emotion_state 独立于 memory_context 存在。""" - contract = _contract(emotion_state="[当前情绪] 关切(强度 50%)") - messages = _contract_system_messages(contract) - assert any("关切" in m for m in messages) diff --git a/backend/tests/application/chat/test_lorebook_injection.py b/backend/tests/application/chat/test_lorebook_injection.py deleted file mode 100644 index 27543b0..0000000 --- a/backend/tests/application/chat/test_lorebook_injection.py +++ /dev/null @@ -1,41 +0,0 @@ -"""世谱注入位置测试:before/after 相对 personality_summary。""" - -from __future__ import annotations - -from app.application.chat.send_message import _contract_system_messages -from app.modules.runtime.contract import RuntimeContract - - -def _contract(**kw: object) -> RuntimeContract: - base: dict[str, object] = { - "character_id": "c", - "session_id": "s", - "run_id": "r", - "model_hint": "m", - "trace_id": "t", - } - base.update(kw) - return RuntimeContract(**base) - - -def test_lorebook_before_precedes_personality() -> None: - c = _contract(personality_summary="人格X", lorebook_before="世界设定前") - msgs = _contract_system_messages(c) - assert msgs.index("世界设定前") < msgs.index("当前人格:\n人格X") - - -def test_lorebook_after_follows_personality() -> None: - c = _contract(personality_summary="人格X", lorebook_after="世界设定后") - msgs = _contract_system_messages(c) - assert msgs.index("当前人格:\n人格X") < msgs.index("世界设定后") - - -def test_no_lorebook_when_none() -> None: - msgs = _contract_system_messages(_contract()) - assert not any("世界设定" in m for m in msgs) - - -def test_lorebook_without_personality_still_injects() -> None: - c = _contract(lorebook_before="前", lorebook_after="后") - msgs = _contract_system_messages(c) - assert "前" in msgs and "后" in msgs diff --git a/backend/tests/application/chat/test_send_message_goals.py b/backend/tests/application/chat/test_send_message_goals.py deleted file mode 100644 index f60098e..0000000 --- a/backend/tests/application/chat/test_send_message_goals.py +++ /dev/null @@ -1,8 +0,0 @@ -"""测试 send_message 中的目标自动注入(集成验证)。""" - - -def test_auto_persist_session_goal_is_callable(): - """验证 auto_persist_session_goal 函数可导入且可调用。""" - from app.services.goal_persist import auto_persist_session_goal - - assert callable(auto_persist_session_goal) diff --git a/backend/tests/application/test_send_message_temporal.py b/backend/tests/application/test_send_message_temporal.py deleted file mode 100644 index 94af61f..0000000 --- a/backend/tests/application/test_send_message_temporal.py +++ /dev/null @@ -1,179 +0,0 @@ -"""对话 temporal_context 注入测试(辰正 §8.1)。""" - -from __future__ import annotations - -from collections.abc import AsyncGenerator -from typing import Any - -import pytest -from httpx import ASGITransport, AsyncClient - -from app.application.agent.runtime import AgentTurnResult -from app.application.chat.send_message import _contract_system_messages -from app.main import app -from app.modules.runtime.contract import RuntimeContract - - -def _make_contract(temporal: str | None) -> RuntimeContract: - return RuntimeContract( - character_id="char_x", - session_id="sess_x", - run_id="run_x", - model_hint="default", - system_instructions=["base"], - personality_summary="人格X", - memory_context="记忆Y", - temporal_context=temporal, - trace_id="trace_x", - ) - - -def test_contract_system_messages_includes_temporal_context(): - messages = _contract_system_messages(_make_contract("现在是子时·深夜(01:42)。")) - assert any("子时·深夜" in m for m in messages) - - -def test_contract_system_messages_no_temporal_context_when_none(): - messages = _contract_system_messages(_make_contract(None)) - assert all("子时" not in m for m in messages) - - -def test_contract_system_messages_temporal_after_memory_before_safety(): - """注入位置:记忆之后、安全约束之前(设计 §8.1)。""" - contract = RuntimeContract( - character_id="c", - session_id="s", - run_id="r", - model_hint="m", - system_instructions=["base"], - memory_context="MEMORY_MARKER", - temporal_context="TEMPORAL_MARKER", - safety_constraints=["SAFETY_MARKER"], - trace_id="t", - ) - messages = _contract_system_messages(contract) - joined = "\n".join(messages) - assert joined.index("MEMORY_MARKER") < joined.index("TEMPORAL_MARKER") - assert joined.index("TEMPORAL_MARKER") < joined.index("SAFETY_MARKER") - - -# --------------------------------------------------------------------------- -# §12 降级路径守护(设计 §12:辰正永不阻塞对话) -# 1. event_store=None → 跳过辰正调用,temporal_context 保持 None,对话照常完成 -# 2. compute_live_snapshot raise → except 捕获,temporal_context 保持 None,对话不阻塞 -# --------------------------------------------------------------------------- - - -async def _create_session(client: AsyncClient) -> str: - resp = await client.post("/api/v1/chat/sessions", json={"title": "辰正降级"}) - assert resp.status_code == 200 - return str(resp.json()["session_id"]) - - -@pytest.fixture -async def temporal_client( - auth_headers: dict[str, str], monkeypatch: pytest.MonkeyPatch -) -> AsyncGenerator[AsyncClient, None]: - """HTTP client:mock run_agent_turn,并通过 messages 捕获最终 system 消息用于断言。 - - 复用 test_send_message_agent.py 的 mock 边界(只 mock run_agent_turn,跑真实 send_message - 全链路),让降级路径在生产代码路径中被真实驱动。 - """ - captured: dict[str, Any] = {} - - async def capture_agent_turn(*, messages: Any, **_kwargs: object) -> AgentTurnResult: - captured["messages"] = messages - return AgentTurnResult(content="好的。", reasoning="") - - monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", capture_agent_turn) - - transport = ASGITransport(app=app) - async with AsyncClient( - transport=transport, base_url="http://test", headers=auth_headers - ) as http_client: - http_client._temporal_captured = captured # type: ignore[attr-defined] - yield http_client - - -def _system_text(messages: list[Any]) -> str: - return "\n".join(str(m.content) for m in messages if getattr(m, "role", "") == "system") - - -@pytest.mark.asyncio -async def test_temporal_context_skipped_when_event_store_is_none( - temporal_client: AsyncClient, monkeypatch: pytest.MonkeyPatch -) -> None: - """§12 降径 1:event_store=None 时跳过辰正调用,对话照常完成且不注入 temporal_context。""" - from app.application.chat.send_message import drain_pending_streams - - # 降径 1:明确将 event_store 置 None(route 经 getattr 默认也是 None,此处显式断言降径) - # 测试 app.state 默认无 event_store 属性,monkeypatch.setattr 对对象属性可在不存在时 - # 创建并在 teardown 时删除(恢复"无属性"原状)。 - monkeypatch.setattr(app.state, "event_store", None, raising=False) - - # spy:若被调用即标记(不应发生) - called: dict[str, bool] = {"hit": False} - - import app.services.time_sense as time_sense_mod - - real_compute = time_sense_mod.compute_live_snapshot - - async def spy_compute(*args: Any, **kwargs: Any) -> Any: - called["hit"] = True - return await real_compute(*args, **kwargs) - - monkeypatch.setattr(time_sense_mod, "compute_live_snapshot", spy_compute) - - session_id = await _create_session(temporal_client) - sent = await temporal_client.post( - f"/api/v1/chat/sessions/{session_id}/messages", - json={"content": "你好", "modality": "text"}, - ) - assert sent.status_code == 200 - await drain_pending_streams() - - # 真实行为断言:辰正未被调用 - assert called["hit"] is False - # 对话完成:run_agent_turn 被正常调用且 system 消息中无辰正上下文标记 - captured = temporal_client._temporal_captured # type: ignore[attr-defined] - sys_text = _system_text(captured["messages"]) - assert "今日命中" not in sys_text # render_temporal_context 的稳定首行尾块标记 - - -@pytest.mark.asyncio -async def test_temporal_context_swallowed_when_compute_raises( - temporal_client: AsyncClient, monkeypatch: pytest.MonkeyPatch -) -> None: - """§12 降径 2:compute_live_snapshot raise 时 except 捕获,对话不阻塞且 temporal_context=None。""" - - async def boom(*_args: Any, **_kwargs: Any) -> Any: - raise RuntimeError("辰正服务故障(模拟)") - - monkeypatch.setattr("app.services.time_sense.compute_live_snapshot", boom) - - from app.application.chat.send_message import drain_pending_streams - - session_id = await _create_session(temporal_client) - sent = await temporal_client.post( - f"/api/v1/chat/sessions/{session_id}/messages", - json={"content": "你好", "modality": "text"}, - ) - # 真实行为断言:异常被 except 吞掉,HTTP 仍 200,对话照常完成 - assert sent.status_code == 200 - run_id = str(sent.json()["run_id"]) - await drain_pending_streams() - - # 对话成功落库:run 进入终态 completed(而非 failed) - from sqlalchemy import select - - from app.db.session import AsyncSessionLocal - from app.models.runtime import Run - - async with AsyncSessionLocal() as db: - run = (await db.execute(select(Run).where(Run.id == run_id))).scalar_one() - assert run.status == "completed" - - # temporal_context 未注入:system 消息中无辰正上下文标记 - captured = temporal_client._temporal_captured # type: ignore[attr-defined] - sys_text = _system_text(captured["messages"]) - assert "子时" not in sys_text and "时态" not in sys_text diff --git a/backend/tests/modules/knocking/conftest.py b/backend/tests/modules/knocking/conftest.py deleted file mode 100644 index 1b4b293..0000000 --- a/backend/tests/modules/knocking/conftest.py +++ /dev/null @@ -1,195 +0,0 @@ -"""叩心枢测试共用 fixtures。""" - -from __future__ import annotations - -from collections.abc import AsyncGenerator -from datetime import UTC, datetime, timedelta - -import pytest_asyncio -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine -from sqlalchemy.orm import sessionmaker - -from app.db.base import Base -from app.models.big_cycle import BigCycle -from app.models.chat import ChatMessage -from app.models.drive import CuriosityQuestion -from app.models.evolution import EvolutionCandidate -from app.models.memory import Memory -from app.models.personality_emotion import CharacterEmotionState -from app.models.time_anniversary import TimeAnniversary - -# 用内存 SQLite 做测试数据库 -_TEST_DATABASE_URL = "sqlite+aiosqlite://" - - -@pytest_asyncio.fixture -async def db_session() -> AsyncGenerator[AsyncSession, None]: - engine = create_async_engine(_TEST_DATABASE_URL, echo=False) - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) - - async with async_session() as session: - yield session - - await engine.dispose() - - -@pytest_asyncio.fixture -async def seed_old_message(db_session: AsyncSession) -> None: - """插入一条 2 小时前的消息,供空闲超时条件触发。""" - now = datetime.now(UTC) - db_session.add( - ChatMessage( - character_id="char_test", - session_id="sess_old", - role="assistant", - modality="text", - content="旧消息", - created_at=now - timedelta(hours=2), - ) - ) - await db_session.commit() - - -@pytest_asyncio.fixture -async def seed_recent_message(db_session: AsyncSession) -> None: - """插入一条 1 分钟前的消息,供空闲超时条件不触发。""" - now = datetime.now(UTC) - db_session.add( - ChatMessage( - character_id="char_test", - session_id="sess_recent", - role="assistant", - modality="text", - content="新消息", - created_at=now - timedelta(minutes=1), - ) - ) - await db_session.commit() - - -@pytest_asyncio.fixture -async def seed_anniversary(db_session: AsyncSession) -> None: - """插入一条 2026-06-27 的生日纪念日。""" - db_session.add( - TimeAnniversary( - character_id="char_test", - name="测试生日", - calendar="solar", - date="2026-06-27", - kind="birthday", - subject_type="user", - ) - ) - await db_session.commit() - - -@pytest_asyncio.fixture -async def seed_emotion_state(db_session: AsyncSession) -> None: - """插入一条初始情绪状态(neutral / 0.2)。""" - db_session.add( - CharacterEmotionState( - character_id="char_test", - mood="neutral", - intensity=0.2, - ) - ) - await db_session.commit() - - -@pytest_asyncio.fixture -async def seed_curiosity(db_session: AsyncSession) -> None: - """插入一条活跃的好奇心问题。""" - db_session.add( - CuriosityQuestion( - character_id="char_test", - cycle_id="cycle_test", - question="你为什么总是沉默?", - source="drive", - ) - ) - await db_session.commit() - - -@pytest_asyncio.fixture -async def seed_applied_evolution(db_session: AsyncSession) -> None: - """插入一条已应用的演化候选。""" - db_session.add( - EvolutionCandidate( - character_id="char_test", - cycle_id="cycle_test", - type="personality", - title="语气更柔和", - proposal="调整表达风格,减少命令式语气,增加温和感", - level="L3", - status="applied", - ) - ) - await db_session.commit() - - -@pytest_asyncio.fixture -async def seed_completed_big_cycle(db_session: AsyncSession) -> None: - """插入一条已完成的大周天循环。""" - now = datetime.now(UTC) - db_session.add( - BigCycle( - character_id="char_test", - trigger="session_turn", - status="completed", - summary="发现自己最近更倾向于回忆过去,在对话中表现出怀旧倾向", - completed_at=now - timedelta(minutes=5), - ) - ) - await db_session.commit() - - -@pytest_asyncio.fixture -async def seed_new_memory(db_session: AsyncSession) -> None: - """插入一条活跃的新记忆。""" - db_session.add( - Memory( - character_id="char_test", - type="experience", - title="今天去了海边", - content="用户提到今天去了海边,看日落。角色回忆起自己也曾喜欢在海边散步。", - status="active", - ) - ) - await db_session.commit() - - -@pytest_asyncio.fixture -async def seed_many_messages(db_session: AsyncSession) -> None: - """插入多条消息,供关系里程碑(消息数)条件触发。""" - now = datetime.now(UTC) - for i in range(5): - db_session.add( - ChatMessage( - character_id="char_test", - session_id=f"sess_rel_{i}", - role="assistant" if i % 2 else "user", - modality="text", - content=f"第{i + 1}条消息", - created_at=now - timedelta(minutes=10 - i), - ) - ) - await db_session.commit() - - -@pytest_asyncio.fixture -async def seed_old_relationship(db_session: AsyncSession) -> None: - """插入一条 60 天前的消息,供关系里程碑(天数)条件触发。""" - now = datetime.now(UTC) - db_session.add( - ChatMessage( - character_id="char_test", - session_id="sess_old_rel", - role="user", - modality="text", - content="你好", - created_at=now - timedelta(days=60), - ) - ) - await db_session.commit() diff --git a/backend/tests/modules/knocking/test_conditions.py b/backend/tests/modules/knocking/test_conditions.py deleted file mode 100644 index 9218272..0000000 --- a/backend/tests/modules/knocking/test_conditions.py +++ /dev/null @@ -1,303 +0,0 @@ -"""叩心枢触发条件单元测试。""" - -# ruff: noqa: ARG002 — pytest fixture params are intentionally unused in test signatures -from __future__ import annotations - -from datetime import UTC, datetime - -import pytest -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.modules.knocking.conditions import ( - AnniversaryCondition, - BigCycleCompletedCondition, - ConditionResult, - CuriosityCondition, - EmotionChangeCondition, - EvolutionAppliedCondition, - MemoryRecallCondition, - ProactiveCondition, - RelationshipMilestoneCondition, - TimeElapsedCondition, - TimeOfDayCondition, - UserReunionCondition, -) - - -class TestTimeElapsedCondition: - """空闲超时条件测试。""" - - async def test_not_triggered_when_no_history(self, db_session: AsyncSession) -> None: - cond = TimeElapsedCondition(min_idle_minutes=360) - result = await cond.check("char_nonexistent", db_session) - assert not result.triggered - - async def test_triggered_when_idle_exceeds_threshold( - self, db_session: AsyncSession, seed_old_message: None - ) -> None: - cond = TimeElapsedCondition(min_idle_minutes=1) - result = await cond.check("char_test", db_session) - assert result.triggered - assert result.condition_name == "idle" - - async def test_not_triggered_when_recent_message( - self, db_session: AsyncSession, seed_recent_message: None - ) -> None: - cond = TimeElapsedCondition(min_idle_minutes=360) - now = datetime.now(UTC) - result = await cond.check("char_test", db_session, now=now) - assert not result.triggered - - -class TestTimeOfDayCondition: - async def test_morning_match(self) -> None: - cond = TimeOfDayCondition(windows=["morning"]) - now = datetime(2026, 6, 27, 7, 0, 0, tzinfo=UTC) - result = await cond.check("char_test", None, now=now) - assert result.triggered - assert result.condition_name == "time_of_day" - - async def test_late_night_match(self) -> None: - cond = TimeOfDayCondition(windows=["late_night"]) - now = datetime(2026, 6, 27, 23, 0, 0, tzinfo=UTC) - result = await cond.check("char_test", None, now=now) - assert result.triggered - assert result.context.get("time_of_day") == "late_night" - - async def test_no_match_outside_window(self) -> None: - cond = TimeOfDayCondition(windows=["morning"]) - now = datetime(2026, 6, 27, 14, 0, 0, tzinfo=UTC) - result = await cond.check("char_test", None, now=now) - assert not result.triggered - - -class TestAnniversaryCondition: - async def test_not_triggered_on_first_call_no_data(self, db_session: AsyncSession) -> None: - cond = AnniversaryCondition() - result = await cond.check("char_nonexistent", db_session) - assert not result.triggered - - async def test_triggered_when_anniversary_exists( - self, db_session: AsyncSession, seed_anniversary: None - ) -> None: - cond = AnniversaryCondition() - now = datetime(2026, 6, 27, 12, 0, 0, tzinfo=UTC) - result = await cond.check("char_test", db_session, now=now) - assert result.triggered - assert result.condition_name == "anniversary" - assert "birthday" in result.context.get("anniversary_kind", "") - - async def test_not_duplicated_same_day( - self, db_session: AsyncSession, seed_anniversary: None - ) -> None: - cond = AnniversaryCondition() - now = datetime(2026, 6, 27, 12, 0, 0, tzinfo=UTC) - result1 = await cond.check("char_test", db_session, now=now) - assert result1.triggered - result2 = await cond.check("char_test", db_session, now=now) - assert not result2.triggered # 同一天不重复 - - -class TestUserReunionCondition: - async def test_not_triggered_when_no_event(self, db_session: AsyncSession) -> None: - cond = UserReunionCondition() - result = await cond.check("char_nonexistent", db_session) - assert not result.triggered - - -class TestEmotionChangeCondition: - async def test_not_triggered_on_first_check( - self, db_session: AsyncSession, seed_emotion_state: None - ) -> None: - cond = EmotionChangeCondition(min_delta=0.3) - result = await cond.check("char_test", db_session) - assert not result.triggered # 首次无基线 - - async def test_triggered_when_intensity_changes( - self, db_session: AsyncSession, seed_emotion_state: None - ) -> None: - cond = EmotionChangeCondition(min_delta=0.3) - await cond.check("char_test", db_session) # 首次建立基线 - - # 更新情绪 intensity 到 0.8(变化 0.6 > 0.3) - from app.models.personality_emotion import CharacterEmotionState - - result = await db_session.execute( - select(CharacterEmotionState).where(CharacterEmotionState.character_id == "char_test") - ) - state = result.scalar_one() - state.intensity = 0.8 - state.mood = "melancholy" - await db_session.commit() - - result = await cond.check("char_test", db_session) - assert result.triggered - assert result.condition_name == "emotion" - - -class TestCuriosityCondition: - async def test_not_triggered_when_no_active_questions(self, db_session: AsyncSession) -> None: - cond = CuriosityCondition() - result = await cond.check("char_nonexistent", db_session) - assert not result.triggered - - async def test_triggered_when_active_question_exists( - self, db_session: AsyncSession, seed_curiosity: None - ) -> None: - cond = CuriosityCondition() - result = await cond.check("char_test", db_session) - assert result.triggered - assert result.condition_name == "curiosity" - assert "curiosity_question" in result.context - assert result.context["curiosity_question"] == "你为什么总是沉默?" - - -class TestEvolutionAppliedCondition: - """演化应用条件测试。""" - - async def test_not_triggered_when_no_applied(self, db_session: AsyncSession) -> None: - cond = EvolutionAppliedCondition() - result = await cond.check("char_nonexistent", db_session) - assert not result.triggered - - async def test_triggered_when_applied_exists( - self, db_session: AsyncSession, seed_applied_evolution: None - ) -> None: - cond = EvolutionAppliedCondition() - result = await cond.check("char_test", db_session) - assert result.triggered - assert result.condition_name == "evolution_applied" - assert result.context.get("evolution_title") == "语气更柔和" - assert result.context.get("evolution_level") == "L3" - assert "evolution_proposal" in result.context - - async def test_not_duplicated( - self, db_session: AsyncSession, seed_applied_evolution: None - ) -> None: - cond = EvolutionAppliedCondition() - result1 = await cond.check("char_test", db_session) - assert result1.triggered - result2 = await cond.check("char_test", db_session) - assert not result2.triggered # 同一条不重复触发 - - -class TestBigCycleCompletedCondition: - """大周天完成条件测试。""" - - async def test_not_triggered_when_no_completed(self, db_session: AsyncSession) -> None: - cond = BigCycleCompletedCondition() - result = await cond.check("char_nonexistent", db_session) - assert not result.triggered - - async def test_triggered_when_completed_exists( - self, db_session: AsyncSession, seed_completed_big_cycle: None - ) -> None: - cond = BigCycleCompletedCondition() - result = await cond.check("char_test", db_session) - assert result.triggered - assert result.condition_name == "big_cycle_completed" - assert "big_cycle_summary" in result.context - assert "big_cycle_trigger" in result.context - - async def test_not_duplicated( - self, db_session: AsyncSession, seed_completed_big_cycle: None - ) -> None: - cond = BigCycleCompletedCondition() - result1 = await cond.check("char_test", db_session) - assert result1.triggered - result2 = await cond.check("char_test", db_session) - assert not result2.triggered # 同一条不重复触发 - - -class TestMemoryRecallCondition: - """记忆唤醒条件测试。""" - - async def test_not_triggered_when_no_memory(self, db_session: AsyncSession) -> None: - cond = MemoryRecallCondition() - result = await cond.check("char_nonexistent", db_session) - assert not result.triggered - - async def test_triggered_when_new_memory_exists( - self, db_session: AsyncSession, seed_new_memory: None - ) -> None: - cond = MemoryRecallCondition() - result = await cond.check("char_test", db_session) - assert result.triggered - assert result.condition_name == "memory_recall" - assert result.context.get("memory_title") == "今天去了海边" - assert "memory_content" in result.context - assert "memory_type" in result.context - - async def test_not_duplicated(self, db_session: AsyncSession, seed_new_memory: None) -> None: - cond = MemoryRecallCondition() - result1 = await cond.check("char_test", db_session) - assert result1.triggered - result2 = await cond.check("char_test", db_session) - assert not result2.triggered # 同一条不重复触发 - - -class TestRelationshipMilestoneCondition: - """关系里程碑条件测试。""" - - async def test_not_triggered_when_no_messages(self, db_session: AsyncSession) -> None: - cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[30]) - result = await cond.check("char_nonexistent", db_session) - assert not result.triggered - - async def test_triggered_by_message_count( - self, db_session: AsyncSession, seed_many_messages: None - ) -> None: - cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[9999]) - result = await cond.check("char_test", db_session) - assert result.triggered - assert result.condition_name == "relationship_milestone" - assert result.context.get("milestone_type") == "messages" - assert result.context.get("milestone_value") == 5 - assert result.context.get("milestone_threshold") == 5 - - async def test_triggered_by_days( - self, db_session: AsyncSession, seed_old_relationship: None - ) -> None: - cond = RelationshipMilestoneCondition(message_milestones=[9999], day_milestones=[30]) - result = await cond.check("char_test", db_session) - assert result.triggered - assert result.condition_name == "relationship_milestone" - assert result.context.get("milestone_type") == "days" - assert result.context.get("milestone_value") >= 30 - assert result.context.get("milestone_threshold") == 30 - - async def test_not_duplicated(self, db_session: AsyncSession, seed_many_messages: None) -> None: - cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[9999]) - result1 = await cond.check("char_test", db_session) - assert result1.triggered - result2 = await cond.check("char_test", db_session) - assert not result2.triggered # 同一里程碑不重复触发 - - async def test_prefers_messages_over_days( - self, db_session: AsyncSession, seed_many_messages: None, seed_old_relationship: None - ) -> None: - """同时满足消息和天数里程碑时,优先触发消息里程碑。""" - cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[30]) - result = await cond.check("char_test", db_session) - assert result.triggered - assert result.context.get("milestone_type") == "messages" # 消息优先 - - -@pytest.mark.asyncio -async def test_proactive_condition_protocol() -> None: - """验证 ProactiveCondition 可作为 Protocol 使用。""" - - class DummyCondition(ProactiveCondition): - async def check( - self, - _character_id: str, - _session: AsyncSession | None = None, - *, - _now: datetime | None = None, - ) -> ConditionResult: - return ConditionResult(triggered=True, trigger_reason="测试", condition_name="dummy") - - cond: ProactiveCondition = DummyCondition() - result = await cond.check("char_test") - assert result.triggered diff --git a/backend/tests/modules/knocking/test_keeper.py b/backend/tests/modules/knocking/test_keeper.py deleted file mode 100644 index 1d8a635..0000000 --- a/backend/tests/modules/knocking/test_keeper.py +++ /dev/null @@ -1,214 +0,0 @@ -"""叩心枢(KnockingPivot)集成测试。 - -测试覆盖: -1. Tick 触发 —— 活跃角色条件匹配,执行完整触发链并推送一炁事件 -2. Tick 空跑 —— 无活跃角色时跳过 -3. 反 spam 冷却 —— 触发后冷却期内 ``_can_trigger`` 返回 False -4. 反 spam 日上限 —— 达到日上限后返回 False -5. 静默时段 —— [23:00, 08:00) 返回 True,其余返回 False -6. 活跃角色列表 —— DB 查询正确返回 ``status='active'`` 的角色 ID -""" - -from __future__ import annotations - -from collections.abc import AsyncGenerator -from datetime import UTC, datetime, timedelta -from unittest.mock import AsyncMock, patch - -import pytest -import pytest_asyncio -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine - -from app.db.base import Base - -# ── 模型注册 ────────────────────────────────────────────────────────── -# 在 DB 表创建前将模型注册到 Base.metadata,确保 create_all 创建对应表。 -# conftest.py 已注册 ChatMessage / CuriosityQuestion / CharacterEmotionState / TimeAnniversary, -# 但未注册 Character 与 ChatSession。 -from app.models.character import Character # noqa: F401 -from app.models.chat import ChatSession # noqa: F401 -from app.modules.knocking.conditions import ConditionResult -from app.modules.knocking.opener import OpenerResult -from app.modules.knocking.pivot import KnockingPivot -from app.services.personality import PersonalitySummary - -_TEST_DATABASE_URL = "sqlite+aiosqlite://" - - -@pytest_asyncio.fixture -async def keeper_session_factory() -> AsyncGenerator[async_sessionmaker[AsyncSession], None]: - """Yield an async_sessionmaker backed by an in-memory SQLite database.""" - engine = create_async_engine(_TEST_DATABASE_URL, echo=False) - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - factory: async_sessionmaker[AsyncSession] = async_sessionmaker( - bind=engine, - class_=AsyncSession, - expire_on_commit=False, - ) - yield factory - await engine.dispose() - - -@pytest_asyncio.fixture -async def dbevent_store() -> AsyncGenerator: - """In-memory event store (no DB needed, supports _events for assertion).""" - from app.modules.runtime.events import InMemoryEventStore - - yield InMemoryEventStore() - - -@pytest_asyncio.fixture -async def keeper( - keeper_session_factory: async_sessionmaker[AsyncSession], - dbevent_store, -) -> AsyncGenerator[KnockingPivot, None]: - """KnockingPivot instance backed by an in-memory SQLite database.""" - pivot = KnockingPivot( - event_store=dbevent_store, - session_factory=keeper_session_factory, - tick_interval=3600, # 测试期间不真的 tick - ) - yield pivot - - -# ====================================================================== -# 测试 -# ====================================================================== - - -@pytest.mark.asyncio -async def test_tick_triggers_for_active_character( - keeper: KnockingPivot, - keeper_session_factory: async_sessionmaker[AsyncSession], -) -> None: - """活跃角色 + 任意条件触发时,应完成完整触发链:开场 → 消息 → 事件。""" - async with keeper_session_factory() as session: - char = Character( - id="char_test_1", - name="测试人物", - status="active", - owner_scope="test", - language="zh-CN", - privacy_level="normal", - ) - session.add(char) - await session.commit() - - with ( - patch.object(keeper.conditions[0], "check", AsyncMock(return_value=ConditionResult(triggered=True, trigger_reason="测试触发", condition_name="test"))), - patch( - "app.modules.knocking.pivot.build_personality_summary", - return_value=PersonalitySummary(text="你是一个测试人物。"), - ), - patch( - "app.modules.knocking.pivot.generate_opener", - return_value=OpenerResult(text="测试开场白。"), - ), - ): - async with keeper_session_factory() as check_session: - processed = await keeper._check_and_trigger( - "char_test_1", - check_session, - now=datetime.now(UTC), - ) - - assert processed is True - - # 验证消息已创建 - async with keeper_session_factory() as session: - from sqlalchemy import select - - result = await session.execute( - select(ChatSession).where(ChatSession.character_id == "char_test_1") - ) - assert result.scalar_one_or_none() is not None - - # 验证一炁事件已推送 - events = keeper._event_store._events - assert len(events) > 0 - last_event = events[-1] - assert last_event.source == "knocking_pivot" - assert last_event.character_id == "char_test_1" - - -@pytest.mark.asyncio -async def test_tick_skips_when_no_active_characters(keeper: KnockingPivot) -> None: - """无活跃角色时 tick 不触发任何操作。""" - with ( - patch("app.modules.knocking.pivot.create_proactive_message") as mock_create, - patch("app.modules.knocking.pivot.generate_opener"), - ): - await keeper._tick() - - mock_create.assert_not_called() - - -@pytest.mark.asyncio -async def test_cooldown_prevents_retrigger(keeper: KnockingPivot) -> None: - """触发后冷却期内 _can_trigger 返回 False。""" - now = datetime.now(UTC) - assert keeper._can_trigger("char_1", now) is True - - keeper._mark_triggered("char_1", now) - assert keeper._can_trigger("char_1", now) is False - - # 冷却结束后应恢复 - after_cooldown = now + timedelta(minutes=keeper._cooldown_minutes + 1) - assert keeper._can_trigger("char_1", after_cooldown) is True - - -@pytest.mark.asyncio -async def test_daily_limit_prevents_excess(keeper: KnockingPivot) -> None: - """同一天达到上限后 _can_trigger 返回 False。""" - now = datetime.now(UTC) - assert keeper._can_trigger("char_1", now) is True - - keeper._daily_counts["char_1"] = (now.date().isoformat(), keeper._daily_limit) - assert keeper._can_trigger("char_1", now) is False - - # 第二天恢复 - next_day = now + timedelta(days=1) - assert keeper._can_trigger("char_1", next_day) is True - - -def test_quiet_hours(keeper: KnockingPivot) -> None: - """静默时段 23:00–08:00 _in_quiet_hours 返回 True。""" - keeper._quiet_hours_start = 23 - keeper._quiet_hours_end = 8 - - # 静默时段内 - assert keeper._in_quiet_hours(datetime(2026, 1, 1, 23, 30, tzinfo=UTC)) is True - assert keeper._in_quiet_hours(datetime(2026, 1, 1, 3, 0, tzinfo=UTC)) is True - assert keeper._in_quiet_hours(datetime(2026, 1, 1, 7, 59, tzinfo=UTC)) is True - - # 静默时段外 - assert keeper._in_quiet_hours(datetime(2026, 1, 1, 8, 0, tzinfo=UTC)) is False - assert keeper._in_quiet_hours(datetime(2026, 1, 1, 12, 0, tzinfo=UTC)) is False - assert keeper._in_quiet_hours(datetime(2026, 1, 1, 22, 59, tzinfo=UTC)) is False - - -@pytest.mark.asyncio -async def test_list_active_characters( - keeper: KnockingPivot, - keeper_session_factory: async_sessionmaker[AsyncSession], -) -> None: - """_list_active_characters 只返回 status='active' 的角色。""" - async with keeper_session_factory() as session: - for i, status in enumerate(["active", "inactive", "paused", "active"]): - session.add( - Character( - id=f"char_{i}", - name=f"人物{i}", - status=status, - owner_scope="test", - language="zh-CN", - privacy_level="normal", - ) - ) - await session.commit() - - async with keeper_session_factory() as session: - result = await keeper._list_active_characters(session) - - assert set(result) == {"char_0", "char_3"} diff --git a/backend/tests/repositories/test_personality_emotion_repo.py b/backend/tests/repositories/test_personality_emotion_repo.py deleted file mode 100644 index f447513..0000000 --- a/backend/tests/repositories/test_personality_emotion_repo.py +++ /dev/null @@ -1,93 +0,0 @@ -"""灵核情绪状态数据访问测试(设计 §11.4)。""" - -import pytest - -from app.db.session import AsyncSessionLocal -from app.models.personality_emotion import CharacterEmotionState -from app.repositories import personality_emotion as emotion_repo -from app.services.characters import get_active_character_model - - -def _state(character_id: str, **overrides) -> CharacterEmotionState: - base = { - "character_id": character_id, - "mood": "touched", - "intensity": 0.7, - "baseline_mood": "neutral", - "baseline_intensity": 0.2, - "last_trigger_event_id": "evt_abc", - } - base.update(overrides) - return CharacterEmotionState(**base) - - -@pytest.mark.asyncio -async def test_get_state_returns_none_when_absent() -> None: - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - assert await emotion_repo.get_state(session, character.id) is None - - -@pytest.mark.asyncio -async def test_upsert_state_inserts_new() -> None: - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - cid = character.id - created = await emotion_repo.upsert_state(session, _state(cid)) - await session.commit() - assert created.mood == "touched" - assert created.intensity == 0.7 - async with AsyncSessionLocal() as session: - fetched = await emotion_repo.get_state(session, cid) - assert fetched is not None - assert fetched.mood == "touched" - assert fetched.last_trigger_event_id == "evt_abc" - - -@pytest.mark.asyncio -async def test_upsert_state_updates_existing() -> None: - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - cid = character.id - await emotion_repo.upsert_state(session, _state(cid, mood="touched", intensity=0.7)) - await emotion_repo.upsert_state( - session, - _state( - cid, - mood="concerned", - intensity=0.5, - last_trigger_event_id="evt_def", - ), - ) - await session.commit() - async with AsyncSessionLocal() as session: - fetched = await emotion_repo.get_state(session, cid) - assert fetched is not None - assert fetched.mood == "concerned" - assert fetched.intensity == 0.5 - assert fetched.last_trigger_event_id == "evt_def" - - -@pytest.mark.asyncio -async def test_upsert_state_single_row_per_character() -> None: - """单行 per character:重复 upsert 不产生重复行。""" - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - cid = character.id - await emotion_repo.upsert_state(session, _state(cid)) - await emotion_repo.upsert_state(session, _state(cid)) - await emotion_repo.upsert_state(session, _state(cid)) - await session.commit() - async with AsyncSessionLocal() as session: - from sqlalchemy import func, select - - count = int( - ( - await session.execute( - select(func.count()) - .select_from(CharacterEmotionState) - .where(CharacterEmotionState.character_id == cid) - ) - ).scalar_one() - ) - assert count == 1 diff --git a/backend/tests/repositories/test_time_anniversaries_repo.py b/backend/tests/repositories/test_time_anniversaries_repo.py deleted file mode 100644 index 220c3f4..0000000 --- a/backend/tests/repositories/test_time_anniversaries_repo.py +++ /dev/null @@ -1,79 +0,0 @@ -"""时态纪念日数据访问测试(辰正 §5.3)。""" - -import pytest - -from app.db.session import AsyncSessionLocal -from app.models.time_anniversary import TimeAnniversary -from app.repositories import time_anniversaries as anni_repo -from app.services.characters import get_active_character_model - - -def _anniversary(character_id: str, **overrides) -> TimeAnniversary: - base = { - "character_id": character_id, - "name": "我们的相遇日", - "calendar": "solar", - "date": "2025-09-19", - "recurs_yearly": True, - "kind": "anniversary", - "subject_type": "relationship", - "source": "auto", - "metadata_json": "{}", - } - base.update(overrides) - return TimeAnniversary(**base) - - -@pytest.mark.asyncio -async def test_create_and_get_anniversary() -> None: - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - created = await anni_repo.create_anniversary(session, _anniversary(character.id)) - fetched = await anni_repo.get_anniversary(session, created.id) - assert fetched is not None - assert fetched.name == "我们的相遇日" - assert fetched.id.startswith("anniv_") - - -@pytest.mark.asyncio -async def test_list_anniversaries_by_character() -> None: - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - await anni_repo.create_anniversary(session, _anniversary(character.id, date="2025-03-01")) - await anni_repo.create_anniversary(session, _anniversary(character.id, date="2025-09-19")) - items = await anni_repo.list_anniversaries(session, character.id) - # 默认人物 seed 时已带 1 条 birthday 纪念日;此处只验证 anniversary kind 的列表行为 - anniversary_items = [a for a in items if a.kind == "anniversary"] - assert len(anniversary_items) == 2 - # list 默认按 date 升序 - assert anniversary_items[0].date < anniversary_items[1].date - - -@pytest.mark.asyncio -async def test_update_anniversary() -> None: - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - created = await anni_repo.create_anniversary(session, _anniversary(character.id)) - updated = await anni_repo.update_anniversary( - session, created.id, name="相识纪念日", source="manual" - ) - assert updated is not None - assert updated.name == "相识纪念日" - assert updated.source == "manual" - - -@pytest.mark.asyncio -async def test_update_anniversary_missing_returns_none() -> None: - async with AsyncSessionLocal() as session: - result = await anni_repo.update_anniversary(session, "anniv_nonexistent", name="x") - assert result is None - - -@pytest.mark.asyncio -async def test_delete_anniversary() -> None: - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - created = await anni_repo.create_anniversary(session, _anniversary(character.id)) - assert await anni_repo.delete_anniversary(session, created.id) is True - assert await anni_repo.get_anniversary(session, created.id) is None - assert await anni_repo.delete_anniversary(session, created.id) is False diff --git a/backend/tests/services/test_card_import.py b/backend/tests/services/test_card_import.py deleted file mode 100644 index 7998b04..0000000 --- a/backend/tests/services/test_card_import.py +++ /dev/null @@ -1,66 +0,0 @@ -"""角色卡导入服务测试(DB 集成)。""" - -from __future__ import annotations - -import json - -from app.db.session import AsyncSessionLocal -from app.repositories import lorebook as lorebook_repo -from app.services.card_import import import_character_card - - -async def test_import_creates_character_and_lorebook() -> None: - card = { - "spec": "chara_card_v2", - "spec_version": "2.0", - "data": { - "name": "狂三", - "nickname": "三三", - "personality": "腹黑优雅", - "character_book": { - "name": "狂三世谱", - "entries": [ - {"keys": ["猫"], "content": "喜欢猫。"}, - {"keys": ["时间"], "content": "时间能力。", "constant": True}, - ], - }, - }, - } - raw = json.dumps(card).encode("utf-8") - - async with AsyncSessionLocal() as session: - character = await import_character_card(session, raw, "local-user") - books = await lorebook_repo.list_books(session, character.id, "local-user") - entries = await lorebook_repo.list_book_entries(session, books[0].id) - - assert character.name == "狂三" - assert character.display_name == "三三" - assert character.personality_summary == "腹黑优雅" - assert len(books) == 1 - assert books[0].name == "狂三世谱" - assert len(entries) == 2 - assert any(e.constant for e in entries) - - -async def test_import_without_book_creates_only_character() -> None: - card = {"spec_version": "2.0", "data": {"name": "无书角色", "personality": "温和"}} - raw = json.dumps(card).encode("utf-8") - - async with AsyncSessionLocal() as session: - character = await import_character_card(session, raw, "local-user") - books = await lorebook_repo.list_books(session, character.id, "local-user") - - assert character.name == "无书角色" - assert character.personality_summary == "温和" - assert books == [] - - -async def test_import_v1_flat_card() -> None: - # V1 扁平结构,无 data 包裹 - card = {"name": "V1角色", "personality": "p"} - raw = json.dumps(card).encode("utf-8") - - async with AsyncSessionLocal() as session: - character = await import_character_card(session, raw, "local-user") - assert character.name == "V1角色" - assert character.personality_summary == "p" diff --git a/backend/tests/services/test_emotion_dispatcher_integration.py b/backend/tests/services/test_emotion_dispatcher_integration.py deleted file mode 100644 index 1eec581..0000000 --- a/backend/tests/services/test_emotion_dispatcher_integration.py +++ /dev/null @@ -1,87 +0,0 @@ -"""灵核情绪状态机端到端集成测试:Dispatcher + handle_time_event + DB。 - -验证一炁订阅基础设施(计划 5)+ 灵核情绪状态机(设计 §11)全链路: -EventStore.append(time.reunion) → Dispatcher 分发 → handle_time_event → DB 情绪状态更新。 -这是 main.py lifespan ``dispatcher.register("time.*", handle_time_event)`` 的端到端验证。 -""" - -from __future__ import annotations - -import asyncio - -import pytest - -from app.db.session import AsyncSessionLocal -from app.models.character import Character -from app.models.personality import PersonalityVersion -from app.modules.runtime.events import Event, InMemoryEventStore -from app.repositories.personality_emotion import get_state -from app.services.characters import get_active_character_model -from app.services.dispatcher import Dispatcher -from app.services.personality_emotion import handle_time_event - - -@pytest.mark.asyncio -async def test_dispatcher_drives_emotion_state_update() -> None: - """append time.reunion → dispatcher 分发 → handle_time_event 写入 DB。""" - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - cid = character.id - pv = PersonalityVersion( - character_id=cid, - identity_profile="测试身份", - emotion_baseline='{"mood": "neutral", "intensity": 0.2}', - ) - session.add(pv) - await session.flush() - character.current_personality_version_id = pv.id - await session.commit() - - store = InMemoryEventStore() - dispatcher = Dispatcher(store) - dispatcher.register("time.*", handle_time_event) - await dispatcher.start() - try: - await store.append( - Event( - type="time.reunion", - source="time_sense", - payload={"reunion_intensity": 0.6, "offline_band": "reunion_impact"}, - character_id=cid, - ) - ) - await asyncio.sleep(0.1) # 等 pump + consumer 异步完成 - finally: - await dispatcher.stop() - - async with AsyncSessionLocal() as session: - row = await get_state(session, cid) - assert row is not None - assert row.mood == "touched" - assert abs(row.intensity - 0.8) < 0.001 # 0.2 baseline + 0.6 delta - character = await session.get(Character, cid) - assert character is not None - assert character.current_mood_id == "touched" - - -@pytest.mark.asyncio -async def test_dispatcher_ignores_non_time_events() -> None: - """非 time.* 事件不触发 handle_time_event(无副作用:无 state row 写入)。""" - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - cid = character.id - await session.commit() - - store = InMemoryEventStore() - dispatcher = Dispatcher(store) - dispatcher.register("time.*", handle_time_event) - await dispatcher.start() - try: - await store.append(Event(type="run.started", source="run", character_id=cid)) - await asyncio.sleep(0.05) - finally: - await dispatcher.stop() - - async with AsyncSessionLocal() as session: - # 非 time.* 事件 → handler 不被调用 → 无 state row - assert await get_state(session, cid) is None diff --git a/backend/tests/services/test_personality_emotion_service.py b/backend/tests/services/test_personality_emotion_service.py deleted file mode 100644 index 586394e..0000000 --- a/backend/tests/services/test_personality_emotion_service.py +++ /dev/null @@ -1,226 +0,0 @@ -"""灵核情绪状态服务测试(设计 §11)。""" - -from datetime import UTC, datetime, timedelta - -import pytest - -from app.db.session import AsyncSessionLocal -from app.models.character import Character -from app.models.chat import ChatMessage, ChatSession -from app.models.personality import PersonalityVersion -from app.models.personality_emotion import CharacterEmotionState -from app.modules.personality.emotion import EmotionState -from app.modules.runtime.events import Event -from app.services.characters import get_active_character_model -from app.services.personality_emotion import ( - apply_time_event, - compute_current_state, - handle_time_event, -) - - -async def _seed_personality( - session, character_id: str, baseline_json: str = '{"mood": "neutral", "intensity": 0.2}' -) -> PersonalityVersion: - """创建人格版本并绑定到 character.current_personality_version_id。""" - pv = PersonalityVersion( - character_id=character_id, - identity_profile="测试身份", - emotion_baseline=baseline_json, - ) - session.add(pv) - await session.flush() - character = await session.get(Character, character_id) - assert character is not None - character.current_personality_version_id = pv.id - return pv - - -def _reunion_event( - character_id: str, intensity: float = 0.6, event_id: str = "event_test1" -) -> Event: - return Event( - id=event_id, - type="time.reunion", - source="time_sense", - payload={"reunion_intensity": intensity, "offline_band": "reunion_impact"}, - character_id=character_id, - ) - - -# ---- compute_current_state ---- - - -@pytest.mark.asyncio -async def test_compute_no_state_returns_baseline() -> None: - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - cid = character.id - await _seed_personality(session, cid, '{"mood": "pleased", "intensity": 0.4}') - await session.commit() - async with AsyncSessionLocal() as session: - state = await compute_current_state(session, cid) - assert state == EmotionState(mood="pleased", intensity=0.4) - - -@pytest.mark.asyncio -async def test_compute_no_state_no_personality_returns_default() -> None: - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - cid = character.id - await session.commit() - async with AsyncSessionLocal() as session: - state = await compute_current_state(session, cid) - assert state == EmotionState() # neutral 0.2 - - -@pytest.mark.asyncio -async def test_compute_with_state_zero_turns_returns_state_unchanged() -> None: - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - cid = character.id - await _seed_personality(session, cid) - session.add( - CharacterEmotionState( - character_id=cid, - mood="touched", - intensity=0.8, - baseline_mood="neutral", - baseline_intensity=0.2, - ) - ) - await session.commit() - async with AsyncSessionLocal() as session: - state = await compute_current_state(session, cid) - # 无后续 user 回合 → 不 decay - assert state is not None - assert state.mood == "touched" - assert abs(state.intensity - 0.8) < 0.001 - - -@pytest.mark.asyncio -async def test_compute_decays_after_user_messages() -> None: - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - cid = character.id - await _seed_personality(session, cid) - # 写入 touched 0.8 状态,updated_at 手动设为 1 小时前 - old = datetime.now(UTC) - timedelta(hours=1) - row = CharacterEmotionState( - character_id=cid, - mood="touched", - intensity=0.8, - baseline_mood="neutral", - baseline_intensity=0.2, - ) - session.add(row) - await session.flush() - # SQLite 读回 naive;写入时也用 naive 保持一致 - row.updated_at = old.replace(tzinfo=None) - # 6 条 user 消息 = 半衰期回合数 → intensity 衰减一半 - chat_session = ChatSession(character_id=cid) - session.add(chat_session) - await session.flush() - for i in range(6): - session.add( - ChatMessage( - character_id=cid, - session_id=chat_session.id, - role="user", - content=f"msg {i}", - ) - ) - await session.commit() - async with AsyncSessionLocal() as session: - state = await compute_current_state(session, cid) - # elapsed=6, factor=0.5 → 0.2 + (0.8-0.2)*0.5 = 0.5 - assert state is not None - assert state.mood == "touched" - assert abs(state.intensity - 0.5) < 0.01 - - -# ---- apply_time_event ---- - - -@pytest.mark.asyncio -async def test_apply_reunion_initializes_from_baseline() -> None: - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - cid = character.id - await _seed_personality(session, cid, '{"mood": "neutral", "intensity": 0.2}') - await apply_time_event(_reunion_event(cid, intensity=0.6), session) - await session.commit() - async with AsyncSessionLocal() as session: - from app.repositories.personality_emotion import get_state - - row = await get_state(session, cid) - assert row is not None - assert row.mood == "touched" - assert abs(row.intensity - 0.8) < 0.001 # 0.2 baseline + 0.6 delta - assert row.last_trigger_event_id == "event_test1" - character = await session.get(Character, cid) - assert character is not None - assert character.current_mood_id == "touched" - - -@pytest.mark.asyncio -async def test_apply_reunion_accumulates_when_same_mood() -> None: - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - cid = character.id - await _seed_personality(session, cid) - await apply_time_event(_reunion_event(cid, intensity=0.5), session) - await apply_time_event(_reunion_event(cid, intensity=0.3, event_id="event_test2"), session) - await session.commit() - async with AsyncSessionLocal() as session: - from app.repositories.personality_emotion import get_state - - row = await get_state(session, cid) - assert row is not None - assert row.mood == "touched" - # 首次 0.2+0.5=0.7;同 mood 累加 0.7+0.3=1.0 - assert abs(row.intensity - 1.0) < 0.001 - assert row.last_trigger_event_id == "event_test2" - - -@pytest.mark.asyncio -async def test_apply_event_without_character_id_is_noop() -> None: - async with AsyncSessionLocal() as session: - await apply_time_event( - Event(type="time.reunion", source="time_sense", payload={}, character_id=None), - session, - ) - await session.commit() - # 无异常即通过;无副作用可验证(无 character_id 无法写) - - -# ---- handle_time_event ---- - - -@pytest.mark.asyncio -async def test_handle_time_event_commits_via_own_session() -> None: - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - cid = character.id - await _seed_personality(session, cid) - await session.commit() - # handle_time_event 自管 session + 提交 - await handle_time_event(_reunion_event(cid, intensity=0.6)) - async with AsyncSessionLocal() as session: - from app.repositories.personality_emotion import get_state - - row = await get_state(session, cid) - assert row is not None - assert row.mood == "touched" - - -@pytest.mark.asyncio -async def test_handle_time_event_swallows_exception() -> None: - """降级原则:handler 异常不抛(不阻塞事件流)。""" - # character_id 指向不存在的角色 → _resolve_baseline 返回默认,apply 仍尝试写 - # 但更直接:用一个会触发异常的事件(如 DB 约束)。这里验证无 character_id 不抛 + 任意异常被吞。 - # 构造一个正常事件,handler 应正常完成不抛 - await handle_time_event( - Event(type="time.reunion", source="time_sense", payload={}, character_id="char_nonexistent") - ) - # 无抛出即通过 diff --git a/backend/tests/services/test_time_sense_integration.py b/backend/tests/services/test_time_sense_integration.py deleted file mode 100644 index 0a82784..0000000 --- a/backend/tests/services/test_time_sense_integration.py +++ /dev/null @@ -1,112 +0,0 @@ -"""辰正集成测试:重逢流事件链 + milestone 联动(设计 §6.3)。""" - -from datetime import UTC, datetime - -import pytest - -from app.db.session import AsyncSessionLocal -from app.models.chat import ChatMessage, ChatSession -from app.models.time_anniversary import TimeAnniversary -from app.modules.runtime.events import InMemoryEventStore -from app.modules.time_sense.modulation import ModulationInput -from app.services.characters import get_active_character_model -from app.services.time_sense import compute_live_snapshot - - -@pytest.mark.asyncio -async def test_reunion_flow_emits_reunion_and_sentiment(): - """离线 > 3 天 → time.reunion + time.sentiment(reunion_memory)。""" - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - chat_session = ChatSession(character_id=character.id, status="active") - session.add(chat_session) - await session.flush() - old_msg = ChatMessage( - character_id=character.id, - session_id=chat_session.id, - role="user", - content="5 天前的消息", - created_at=datetime(2026, 6, 29, 4, 0, tzinfo=UTC), - ) - session.add(old_msg) - await session.flush() - - store = InMemoryEventStore() - result = await compute_live_snapshot( - session, - store, - character.id, - ModulationInput(), - now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 5 天后 - ) - - assert result.snapshot is not None - assert result.snapshot.offline.band == "reunion_impact" - event_types = {e.type for e in result.emitted_events} - assert "time.reunion" in event_types - assert "time.sentiment" in event_types # reunion_memory 触发 - sentiment = next(e for e in result.emitted_events if e.type == "time.sentiment") - assert sentiment.payload["trigger_type"] == "reunion_memory" - - -@pytest.mark.asyncio -async def test_no_event_when_active(): - """active 档(无历史消息,last_active=now)→ 无 reunion/sentiment 事件。""" - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - store = InMemoryEventStore() - result = await compute_live_snapshot( - session, - store, - character.id, - ModulationInput(), - now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 上海 12:00 上午 - ) - assert result.snapshot is not None - assert result.snapshot.offline.band == "active" - event_types = {e.type for e in result.emitted_events} - assert "time.reunion" not in event_types - assert "time.sentiment" not in event_types - assert "time.user_care" not in event_types # 白天 + 无 birthday 等信号 - - -@pytest.mark.asyncio -async def test_milestone_at_late_night_emits_sentiment_overlay(): - """凌晨 + 今日命中纪念日 → active_milestones 非空 + time.sentiment(late_night_overlay)。 - - 验证 anniversary 命中 + 凌晨时段联动 sentiment 的集成路径(Task 4 未覆盖)。 - """ - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - anni = TimeAnniversary( - character_id=character.id, - name="我们的相遇日", - calendar="solar", - date="2026-07-05", # 今日(now=18:00 UTC → 上海 02:00 本地日期 07-05)命中 - recurs_yearly=True, - kind="anniversary", - subject_type="relationship", - source="manual", - ) - session.add(anni) - await session.flush() - - store = InMemoryEventStore() - result = await compute_live_snapshot( - session, - store, - character.id, - ModulationInput(), - now=datetime(2026, 7, 4, 18, 0, tzinfo=UTC), # 上海 02:00 凌晨 - ) - - assert result.snapshot is not None - assert result.snapshot.day_period == "凌晨" - assert len(result.snapshot.active_milestones) >= 1 - # duration_node(0 不在 nodes)/anniversary_prewarm(next=365>7)/reunion_memory(active 档) 都不触发 - # → late_night_overlay(凌晨 + has_active_milestone) - sentiment_events = [e for e in result.emitted_events if e.type == "time.sentiment"] - assert len(sentiment_events) == 1 - assert sentiment_events[0].payload["trigger_type"] == "late_night_overlay" - # active 档(无 chat 历史)→ 不触发 reunion - assert "time.reunion" not in {e.type for e in result.emitted_events} diff --git a/backend/tests/services/test_time_sense_service.py b/backend/tests/services/test_time_sense_service.py deleted file mode 100644 index b0cbefa..0000000 --- a/backend/tests/services/test_time_sense_service.py +++ /dev/null @@ -1,137 +0,0 @@ -"""辰正 service 测试(流 A 实时快照 + 流 C 事件触发)。""" - -from datetime import UTC, datetime, timedelta - -import pytest - -from app.db.session import AsyncSessionLocal -from app.models.chat import ChatMessage, ChatSession -from app.modules.runtime.events import InMemoryEventStore -from app.modules.time_sense.modulation import ModulationInput -from app.services.characters import get_active_character_model -from app.services.time_sense import compute_live_snapshot - - -@pytest.mark.asyncio -async def test_compute_live_snapshot_returns_snapshot_and_factors(): - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - store = InMemoryEventStore() - result = await compute_live_snapshot( - session, - store, - character.id, - ModulationInput(), - now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 上海 12:00 - ) - assert result.snapshot is not None - assert result.factors is not None - assert result.context_text # 非空 - assert "周" in result.context_text - - -@pytest.mark.asyncio -async def test_compute_live_snapshot_degrades_on_failure(): - """计算失败时降级返回最小快照(不抛异常)。""" - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - store = InMemoryEventStore() - # 传入非法时区触发降级 - result = await compute_live_snapshot( - session, - store, - character.id, - ModulationInput(), - now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), - timezone="Invalid/Zone", - ) - # 降级:snapshot 仍返回(compute_snapshot 内部降级为 UTC),不抛 - assert result.snapshot is not None - - -@pytest.mark.asyncio -async def test_emits_user_care_when_birthday(): - """user_birthday_today → care_kind=birthday → 发 time.user_care(不依赖 snapshot 时段/时区)。""" - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - store = InMemoryEventStore() - result = await compute_live_snapshot( - session, - store, - character.id, - ModulationInput(user_birthday_today=True), - now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 上海 12:00 - ) - assert result.snapshot is not None - care_events = [e for e in result.emitted_events if e.type == "time.user_care"] - assert len(care_events) == 1 - assert care_events[0].payload["care_kind"] == "birthday" - # birthday 不应触发 reunion(last_active≈now),也不命中 sentiment 节点 - assert all(e.type != "time.reunion" for e in result.emitted_events) - - -@pytest.mark.asyncio -async def test_emits_reunion_when_offline_exceeds_threshold(): - """seed 5 天前消息 → offline.band=reunion_impact → 发 time.reunion + time.sentiment。""" - now = datetime(2026, 7, 4, 4, 0, tzinfo=UTC) - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - # 先建会话(FK 约束),再 seed 一条 5 天前的消息 - chat_session = ChatSession(character_id=character.id, status="active") - session.add(chat_session) - await session.flush() - session.add( - ChatMessage( - character_id=character.id, - session_id=chat_session.id, - role="user", - content="旧消息", - created_at=now - timedelta(days=5), - ) - ) - await session.flush() - - store = InMemoryEventStore() - result = await compute_live_snapshot( - session, - store, - character.id, - ModulationInput(), - now=now, - ) - - assert result.snapshot is not None - assert result.snapshot.offline.band == "reunion_impact" - # 5 天 = 7200 min > 4320 min 阈值 → 重逢 - reunion_events = [e for e in result.emitted_events if e.type == "time.reunion"] - assert len(reunion_events) == 1 - payload = reunion_events[0].payload - assert payload["offline_band"] == "reunion_impact" - assert "reunion_intensity" in payload - assert payload["offline_minutes"] >= 4320 - assert reunion_events[0].sensitivity == "medium" - # is_reunion=True + has_reunion_memory=is_reunion → sentiment=reunion_memory - sentiment_events = [e for e in result.emitted_events if e.type == "time.sentiment"] - assert len(sentiment_events) == 1 - assert sentiment_events[0].payload["trigger_type"] == "reunion_memory" - - -@pytest.mark.asyncio -async def test_compute_live_snapshot_attaches_run_id_to_events(): - """对话入口传 run_id → 触发的 time.* 事件全部带该 run_id(落当前 run,SSE 可见)。""" - async with AsyncSessionLocal() as session: - character = await get_active_character_model(session) - store = InMemoryEventStore() - # 凌晨场景触发 late_night care / sentiment - result = await compute_live_snapshot( - session, - store, - character.id, - ModulationInput(conversation_turns_today=3), - now=datetime(2026, 7, 4, 18, 0, tzinfo=UTC), # 上海 02:00 凌晨 - timezone="Asia/Shanghai", - run_id="run_test_plan4", - ) - assert result.emitted_events, "凌晨 + 3 轮应触发至少一个 time.* 事件" - for ev in result.emitted_events: - assert ev.run_id == "run_test_plan4" diff --git a/backend/tests/test_api_time.py b/backend/tests/test_api_time.py deleted file mode 100644 index 41eaaa6..0000000 --- a/backend/tests/test_api_time.py +++ /dev/null @@ -1,87 +0,0 @@ -"""辰正 time API 端点测试。""" - -from collections.abc import AsyncGenerator - -import pytest -from httpx import ASGITransport, AsyncClient - -from app.main import app - - -@pytest.fixture -async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]: - transport = ASGITransport(app=app) - async with AsyncClient( - transport=transport, - base_url="http://test", - headers=auth_headers, - ) as http_client: - yield http_client - - -@pytest.mark.asyncio -async def test_get_config(client: AsyncClient) -> None: - response = await client.get("/api/v1/time/config") - assert response.status_code == 200 - data = response.json() - assert data["user_timezone"] == "Asia/Shanghai" - assert data["default_city"] == "上海" - assert data["care_enabled"] is True - - -@pytest.mark.asyncio -async def test_anniversaries_crud(client: AsyncClient) -> None: - create = await client.post( - "/api/v1/time/anniversaries", - json={ - "name": "测试纪念日", - "calendar": "solar", - "date": "2025-09-19", - "recurs_yearly": True, - "kind": "anniversary", - "subject_type": "relationship", - "source": "manual", - }, - ) - assert create.status_code == 201 - anniv_id = create.json()["id"] - assert anniv_id.startswith("anniv_") - - listed = await client.get("/api/v1/time/anniversaries") - assert listed.status_code == 200 - assert any(a["id"] == anniv_id for a in listed.json()) - - patched = await client.patch( - f"/api/v1/time/anniversaries/{anniv_id}", json={"name": "改后的名字"} - ) - assert patched.status_code == 200 - assert patched.json()["name"] == "改后的名字" - - deleted = await client.delete(f"/api/v1/time/anniversaries/{anniv_id}") - assert deleted.status_code == 204 - - not_found = await client.get(f"/api/v1/time/anniversaries/{anniv_id}") - assert not_found.status_code == 404 - assert not_found.json()["error"]["code"] == "not_found" - - -@pytest.mark.asyncio -async def test_get_snapshot(client: AsyncClient) -> None: - # ASGITransport 可能不触发 lifespan 装配 event_store;get_event_store 未就绪时返回 503 - response = await client.get("/api/v1/time/snapshot") - assert response.status_code in (200, 503) - if response.status_code == 200: - data = response.json() - assert "context_text" in data - assert "snapshot" in data - - -@pytest.mark.asyncio -async def test_get_acquaintance(client: AsyncClient) -> None: - # 与 /snapshot 同因:ASGITransport 可能不触发 lifespan;snapshot 降级时返回 503 - response = await client.get("/api/v1/time/acquaintance") - assert response.status_code in (200, 503) - if response.status_code == 200: - data = response.json() - assert isinstance(data["total_days"], int) - assert "natural_language" in data diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py deleted file mode 100644 index ae44083..0000000 --- a/backend/tests/test_audit.py +++ /dev/null @@ -1,56 +0,0 @@ -"""审计测试。""" - -from collections.abc import AsyncGenerator - -import pytest -from httpx import ASGITransport, AsyncClient -from sqlalchemy import select - -from app.ai_provider.types.chat import ChatChunk -from app.application.agent.runtime import AgentTurnResult -from app.application.chat.send_message import drain_pending_streams -from app.db.session import AsyncSessionLocal -from app.main import app -from app.models.safety import AuditEvent - - -@pytest.fixture -async def client( - monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str] -) -> AsyncGenerator[AsyncClient, None]: - async def fake_run_agent_turn(*, on_chunk, **_kwargs: object): - await on_chunk(ChatChunk(delta_content="你好,我是测试模型回复。")) - return AgentTurnResult(content="你好,我是测试模型回复。", reasoning="") - - monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn) - transport = ASGITransport(app=app) - async with AsyncClient( - transport=transport, - base_url="http://test", - headers=auth_headers, - ) as http_client: - yield http_client - - -@pytest.mark.asyncio -async def test_chat_and_memory_write_audit_events(client: AsyncClient) -> None: - session_response = await client.post("/api/v1/chat/sessions", json={"title": "审计"}) - session_id = session_response.json()["session_id"] - sent = await client.post( - f"/api/v1/chat/sessions/{session_id}/messages", - json={"content": "你好", "modality": "text"}, - ) - assert sent.status_code == 200 - await drain_pending_streams() - memory = await client.post( - "/api/v1/memory", - json={"type": "user", "title": "偏好", "content": "用户喜欢简洁回答。"}, - ) - assert memory.status_code == 200 - - async with AsyncSessionLocal() as db: - actions = [item.action for item in (await db.execute(select(AuditEvent))).scalars().all()] - assert "run.started" in actions - assert "model.request.started" in actions - assert "model.response.completed" in actions - assert "memory.created" in actions diff --git a/backend/tests/test_idempotency.py b/backend/tests/test_idempotency.py deleted file mode 100644 index f24b5ac..0000000 --- a/backend/tests/test_idempotency.py +++ /dev/null @@ -1,100 +0,0 @@ -"""幂等键测试。""" - -from collections.abc import AsyncGenerator - -import pytest -from httpx import ASGITransport, AsyncClient -from sqlalchemy import select - -from app.ai_provider.types.chat import ChatChunk -from app.application.agent.runtime import AgentTurnResult -from app.application.chat.send_message import drain_pending_streams -from app.db.session import AsyncSessionLocal -from app.main import app -from app.models.chat import ChatMessage -from app.models.idempotency import IdempotencyKey -from app.models.memory import Memory -from app.models.runtime import Run - - -@pytest.fixture -async def client( - monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str] -) -> AsyncGenerator[AsyncClient, None]: - async def fake_run_agent_turn(*, on_chunk, **_kwargs: object): - await on_chunk(ChatChunk(delta_content="你好,我是测试模型回复。")) - return AgentTurnResult(content="你好,我是测试模型回复。", reasoning="") - - monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn) - transport = ASGITransport(app=app) - async with AsyncClient( - transport=transport, - base_url="http://test", - headers=auth_headers, - ) as http_client: - yield http_client - - -async def _create_session(client: AsyncClient) -> str: - response = await client.post("/api/v1/chat/sessions", json={"title": "幂等测试"}) - assert response.status_code == 200 - return str(response.json()["session_id"]) - - -@pytest.mark.asyncio -async def test_send_message_idempotency_replays_same_response(client: AsyncClient) -> None: - session_id = await _create_session(client) - headers = {"Idempotency-Key": "send-same-1"} - payload = {"content": "你好", "modality": "text"} - first = await client.post( - f"/api/v1/chat/sessions/{session_id}/messages", json=payload, headers=headers - ) - second = await client.post( - f"/api/v1/chat/sessions/{session_id}/messages", json=payload, headers=headers - ) - assert first.status_code == 200 - assert second.status_code == 200 - assert second.json() == first.json() - await drain_pending_streams() - - async with AsyncSessionLocal() as db: - runs = (await db.execute(select(Run))).scalars().all() - messages = (await db.execute(select(ChatMessage))).scalars().all() - keys = (await db.execute(select(IdempotencyKey))).scalars().all() - assert len(runs) == 1 - assert len(messages) == 2 - assert len(keys) == 1 - - -@pytest.mark.asyncio -async def test_idempotency_key_conflict(client: AsyncClient) -> None: - session_id = await _create_session(client) - headers = {"Idempotency-Key": "send-conflict-1"} - first = await client.post( - f"/api/v1/chat/sessions/{session_id}/messages", - json={"content": "你好", "modality": "text"}, - headers=headers, - ) - conflict = await client.post( - f"/api/v1/chat/sessions/{session_id}/messages", - json={"content": "不同内容", "modality": "text"}, - headers=headers, - ) - assert first.status_code == 200 - assert conflict.status_code == 409 - assert conflict.json()["error"]["code"] == "conflict" - - -@pytest.mark.asyncio -async def test_memory_create_idempotency(client: AsyncClient) -> None: - headers = {"Idempotency-Key": "memory-same-1"} - payload = {"type": "user", "title": "偏好", "content": "用户喜欢简洁回答。"} - first = await client.post("/api/v1/memory", json=payload, headers=headers) - second = await client.post("/api/v1/memory", json=payload, headers=headers) - assert first.status_code == 200 - assert second.status_code == 200 - assert second.json()["id"] == first.json()["id"] - - async with AsyncSessionLocal() as db: - memories = (await db.execute(select(Memory))).scalars().all() - assert len(memories) == 1 diff --git a/backend/tests/test_import_and_assembly.py b/backend/tests/test_import_and_assembly.py new file mode 100644 index 0000000..9444936 --- /dev/null +++ b/backend/tests/test_import_and_assembly.py @@ -0,0 +1,208 @@ +"""M2b 导入改造与组装管线测试。 + +- 导入 ST 卡(JSON V2)→ 可审阅灵胚(不直接建人物);启镜后 character_book 物化为世谱; +- 组装管线:{{char}}/{{user}} 符文在投影 prompt 中展开;火候(GenerationPreferences) + 的 temperature/max_tokens 进入 llm.requested 的 IR。 +""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncGenerator + +import httpx +import pytest +from httpx import ASGITransport, AsyncClient + +from app.db.session import AsyncSessionLocal +from app.kernel import Kernel, KernelEventLog +from app.services.event_store import DbEventStore +from app.shenshi_client import EchoShenshi + +_CARD_V2 = { + "spec": "chara_card_v2", + "spec_version": "2.0", + "data": { + "name": "绯樱", + "description": "一位安静的图书馆管理员。", + "personality": "温柔、寡言", + "scenario": "午后的图书馆", + "first_mes": "……你来了。", + "mes_example": "", + "creator": "tester", + "character_version": "1.0", + "character_book": { + "entries": { + "0": { + "keys": ["图书馆", "书"], + "content": "图书馆只在午后开放。", + "enabled": True, + "insertion_order": 10, + }, + "1": { + "keys": ["绯樱"], + "content": "绯樱从不谈论自己的过去。", + "enabled": True, + }, + } + }, + }, +} + + +@pytest.fixture +async def client( + monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str] +) -> AsyncGenerator[AsyncClient, None]: + from app.main import app + + event_store = DbEventStore(AsyncSessionLocal) + app.state.kernel = Kernel( + shenshi=EchoShenshi(), + session_factory=AsyncSessionLocal, + event_log=KernelEventLog(event_store), + timeout_seconds=10.0, + ) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c: + yield c + app.state.kernel = None + + +async def test_import_card_creates_reviewable_embryo(client: AsyncClient) -> None: + files = {"file": ("card.json", json.dumps(_CARD_V2).encode(), "application/json")} + imported = await client.post("/api/v1/characters/import-card", files=files) + assert imported.status_code == 201 + body = imported.json() + assert body["source"] == "imported" + assert body["name"] == "绯樱" + assert body["status"] == "drafting" + assert body["book_entry_count"] == 2 + + # 未启镜:本用户名下无人物、无世谱(库内系统模板人物不计) + from sqlalchemy import func, select + + from app.models.character import Character + from app.models.lorebook import Lorebook + + me = (await client.get("/api/v1/auth/me")).json() + owner = me["id"] + + async def _own_counts() -> tuple[int, int]: + async with AsyncSessionLocal() as session: + characters = int( + await session.scalar( + select(func.count(Character.id)).where(Character.owner_scope == owner) + ) + ) + books = int( + await session.scalar( + select(func.count(Lorebook.id)).where(Lorebook.owner_scope == owner) + ) + ) + return characters, books + + # 导入只产灵胚:本用户人物/世谱数量不变(种子会预建一个默认人物,按差量断言) + before = await _own_counts() + assert before == await _own_counts() + + # 启镜:物化人物 + 世谱书 + 条目(dict 形 entries 兼容) + activated = await client.post(f"/api/v1/embryo-drafts/{body['embryo_id']}/activate") + assert activated.status_code == 200 + character_id = activated.json()["character_id"] + after = await _own_counts() + assert after[0] == before[0] + 1 # 启镜才产生人物 + assert after[1] == before[1] + 1 # 启镜才物化世谱书 + + from app.models.lorebook import LorebookEntry + + async with AsyncSessionLocal() as session: + books = ( + await session.execute(select(Lorebook).where(Lorebook.character_id == character_id)) + ).scalars().all() + assert len(books) == 1 + entries = ( + await session.execute( + select(LorebookEntry).where(LorebookEntry.lorebook_id == books[0].id) + ) + ).scalars().all() + assert len(entries) == 2 + by_key = {json.loads(e.keys_json)[0]: e for e in entries} + assert by_key["图书馆"].content == "图书馆只在午后开放。" + + +async def test_macro_expansion_and_preset_in_request(client: AsyncClient) -> None: + # 火候:按当前用户写一行 GenerationPreferences + from app.models.governance import GenerationPreferences + + me = (await client.get("/api/v1/auth/me")).json() + async with AsyncSessionLocal() as session: + session.add( + GenerationPreferences( + owner_scope=me["id"], + default_model_hint="chat", + temperature=0.3, + max_output_tokens=512, + ) + ) + await session.commit() + + # 建树(灵胚启镜后),问题含 {{user}} 符文;指引含 {{char}} + embryo = ( + await client.post( + "/api/v1/embryo-drafts", json={"source": "original", "name": "符文灵"} + ) + ).json() + character_id = ( + (await client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")).json()["character_id"] + ) + tree = ( + await client.post( + "/api/v1/trees", json={"character_id": character_id, "title": "符文树"} + ) + ).json() + q = ( + await client.post( + f"/api/v1/trees/{tree['id']}/nodes", + json={"content": "{{user}} 想请教 {{char}} 一个问题"}, + ) + ).json() + + spawned = ( + await client.post(f"/api/v1/nodes/{q['id']}/answers", json={"instructions": "请以 {{char}} 的口吻回答"}) + ).json() + for _ in range(300): + status = (await client.get(f"/api/v1/runs/{spawned['run_id']}")).json()["status"] + if status in ("completed", "interrupted", "failed"): + break + await asyncio.sleep(0.02) + assert status == "completed" + + # llm.requested 事件:符文已展开、火候参数已进入 IR + events = (await client.get(f"/api/v1/runs/{spawned['run_id']}/events")).json() + requested = next(e for e in events if e["type"] == "llm.requested") + ir = requested["payload"]["request"] + prompt = ir["messages"][-1]["content"] + assert "{{user}}" not in prompt and "{{char}}" not in prompt + assert "符文灵" in prompt # {{char}} 展开为人物名 + assert ir["temperature"] == 0.3 + assert ir["max_tokens"] == 512 + + +async def test_import_v1_flat_card(client: AsyncClient) -> None: + """V1 扁平卡(无 data 包装)同样产灵胚。""" + v1 = { + "name": "旧式卡", + "description": "一位老友。", + "personality": "爽朗", + "scenario": "巷口", + "first_mes": "来了?", + "mes_example": "", + } + files = {"file": ("card.json", json.dumps(v1).encode(), "application/json")} + imported = await client.post("/api/v1/characters/import-card", files=files) + assert imported.status_code == 201 + body = imported.json() + assert body["name"] == "旧式卡" + assert body["book_entry_count"] == 0 diff --git a/backend/tests/test_send_message_agent.py b/backend/tests/test_send_message_agent.py deleted file mode 100644 index 7f8754a..0000000 --- a/backend/tests/test_send_message_agent.py +++ /dev/null @@ -1,206 +0,0 @@ -"""send_message 接入 run_agent_turn 的验证测试(阶段2b 步骤B)。 - -mock 边界: -- ``app.application.chat.send_message.run_agent_turn`` —— 不真实跑 Agent 循环, - 只校验 send_message 是否正确接入(传 tools/default_tools、on_chunk、on_tool_event)。 -- ``app.application.chat.send_message.default_tools`` —— 校验被调用。 -""" - -from __future__ import annotations - -from collections.abc import AsyncGenerator -from typing import Any - -import pytest -from httpx import ASGITransport, AsyncClient -from sqlalchemy import select - -from app.ai_provider.types.chat import ChatChunk -from app.application.agent.runtime import AgentTurnResult -from app.db.session import AsyncSessionLocal -from app.main import app -from app.models.runtime import Event - - -@pytest.fixture -async def client( - monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str] -) -> AsyncGenerator[AsyncClient, None]: - # 清理前序(test_tools 等)残留的 app.state 避免污染 - - for _attr in ("tool_runtime", "confirmation_registry", "confirmation_store", "event_store"): - app.state.__dict__.pop(_attr, None) - - agent_content = "agent 最终回复正文。" - - async def fake_run_agent_turn( - *, - on_chunk: Any, - on_tool_event: Any, - **_kwargs: object, - ) -> AgentTurnResult: - # 模拟 Agent 内部流:先一段 reasoning(若 thinking 启用)、再 content chunk - await on_chunk(ChatChunk(delta_reasoning="思考摘要")) - await on_chunk(ChatChunk(delta_content=agent_content)) - # 模拟一次工具调用生命周期 - await on_tool_event("tool.started", {"tool_name": "echo", "tool_call_id": "c1"}) - await on_tool_event( - "tool.completed", {"tool_name": "echo", "tool_call_id": "c1", "status": "success"} - ) - return AgentTurnResult(content=agent_content, reasoning="思考摘要") - - monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn) - transport = ASGITransport(app=app) - async with AsyncClient( - transport=transport, base_url="http://test", headers=auth_headers - ) as http_client: - yield http_client - - -async def _create_session(client: AsyncClient) -> str: - resp = await client.post("/api/v1/chat/sessions", json={"title": "agent 接入"}) - assert resp.status_code == 200 - return str(resp.json()["session_id"]) - - -@pytest.mark.asyncio -async def test_send_message_invokes_run_agent_turn_with_tools_and_callbacks( - client: AsyncClient, monkeypatch: pytest.MonkeyPatch -) -> None: - """send_message 调用 run_agent_turn 时传了 tools=default_tools()。""" - - app.dependency_overrides.clear() - - captured: dict[str, Any] = {} - captured_count: int = 0 - - real_default_tools = __import__( - "app.application.chat.send_message", fromlist=["default_tools"] - ).default_tools - - def spy_default_tools(*args: Any, **kwargs: Any) -> list[dict[str, Any]]: - nonlocal captured_count - captured_count += 1 - captured["kwargs"] = kwargs - captured["tools"] = real_default_tools(*args, **kwargs) - return captured["tools"] - - monkeypatch.setattr("app.application.chat.send_message.default_tools", spy_default_tools) - - from app.application.chat.send_message import drain_pending_streams - - session_id = await _create_session(client) - sent = await client.post( - f"/api/v1/chat/sessions/{session_id}/messages", - json={"content": "你好", "modality": "text"}, - ) - assert sent.status_code == 200 - await drain_pending_streams() - - # default_tools 被调用,至少包含 5 个核心工具 - assert captured_count > 0, f"spy_default_tools never called, captured={captured}" - assert "tools" in captured - tool_names = {t["function"]["name"] for t in captured["tools"]} - assert tool_names >= {"datetime", "read_file", "list_dir", "write_file", "shell"} - assert captured["tools"][0]["type"] == "function" - names = {t["function"]["name"] for t in captured["tools"]} - assert {"datetime", "shell"} <= names - - -@pytest.mark.asyncio -async def test_send_message_persists_agent_result_content_and_tool_events( - client: AsyncClient, -) -> None: - """message.completed 的 content == agent_result.content;tool.* 事件落库 source=agent。""" - - from app.application.chat.send_message import drain_pending_streams - - session_id = await _create_session(client) - sent = await client.post( - f"/api/v1/chat/sessions/{session_id}/messages", - json={"content": "你好", "modality": "text", "thinking": True}, - ) - assert sent.status_code == 200 - run_id = str(sent.json()["run_id"]) - await drain_pending_streams() - - async with AsyncSessionLocal() as db: - events = (await db.execute(select(Event).where(Event.run_id == run_id))).scalars().all() - - by_type: dict[str, list[Event]] = {} - for ev in events: - by_type.setdefault(ev.type, []).append(ev) - - # 关键事件齐全 - assert "run.started" in by_type - assert "message.delta" in by_type # on_chunk 触发了 content flush - assert "message.completed" in by_type - assert "run.completed" in by_type - - # tool 事件存在且 source=agent - assert "tool.started" in by_type - assert "tool.completed" in by_type - assert all(ev.source == "agent" for ev in by_type["tool.started"]) - assert all(ev.source == "agent" for ev in by_type["tool.completed"]) - - # message.completed 的 content == agent_result.content(权威值) - completed = by_type["message.completed"][-1] - import json - - payload = json.loads(completed.payload_json) - assert payload["content"] == "agent 最终回复正文。" - - -@pytest.mark.asyncio -async def test_send_message_writes_extracted_session_goal_to_run( - client: AsyncClient, -) -> None: - """太一最小闭环:会话目标从用户消息提取写入 run.goal,取代默认值。""" - - from app.application.chat.send_message import drain_pending_streams - from app.models.runtime import Run - - session_id = await _create_session(client) - sent = await client.post( - f"/api/v1/chat/sessions/{session_id}/messages", - json={"content": "帮我整理今天的笔记", "modality": "text"}, - ) - assert sent.status_code == 200 - run_id = str(sent.json()["run_id"]) - await drain_pending_streams() - - async with AsyncSessionLocal() as db: - run = (await db.execute(select(Run).where(Run.id == run_id))).scalar_one() - - assert run.goal == "帮我整理今天的笔记" - - -@pytest.mark.asyncio -async def test_send_message_injects_session_goal_into_system_messages( - client: AsyncClient, monkeypatch: pytest.MonkeyPatch -) -> None: - """洛书最小闭环:会话目标作为 system instruction 注入 ReAct。""" - - from app.application.chat.send_message import drain_pending_streams - - captured: dict[str, Any] = {} - - async def capture_agent_turn(*, messages: Any, **_kwargs: object) -> AgentTurnResult: - captured["messages"] = messages - return AgentTurnResult(content="好的。", reasoning="") - - monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", capture_agent_turn) - - session_id = await _create_session(client) - await client.post( - f"/api/v1/chat/sessions/{session_id}/messages", - json={"content": "帮我整理笔记", "modality": "text"}, - ) - await drain_pending_streams() - - # to_langchain_messages 把每个 system message 作为独立 SystemMessage, - # 目标片段出现在某个 system message 中(非 messages[0]) - system_text = "\n".join( - str(m.content) for m in captured["messages"] if getattr(m, "role", "") == "system" - ) - assert "当前会话目标:帮我整理笔记" in system_text diff --git a/backend/tests/test_sse.py b/backend/tests/test_sse.py deleted file mode 100644 index 453e6c3..0000000 --- a/backend/tests/test_sse.py +++ /dev/null @@ -1,77 +0,0 @@ -"""SSE 契约测试。""" - -from collections.abc import AsyncGenerator - -import pytest -from httpx import ASGITransport, AsyncClient - -from app.ai_provider.types.chat import ChatChunk -from app.application.agent.runtime import AgentTurnResult -from app.core.config import settings -from app.main import app - - -@pytest.fixture -async def client( - monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str] -) -> AsyncGenerator[AsyncClient, None]: - _content = "你好,我是测试模型回复,会根据你的输入生成足够长的内容用于 SSE 分块验证。" * 4 - - async def fake_run_agent_turn(*, on_chunk, **_kwargs: object): - await on_chunk(ChatChunk(delta_content=_content)) - return AgentTurnResult(content=_content, reasoning="") - - monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn) - transport = ASGITransport(app=app) - async with AsyncClient( - transport=transport, base_url="http://test", headers=auth_headers - ) as http_client: - yield http_client - - -async def _completed_run(client: AsyncClient, content: str = "你好") -> str: - created = await client.post("/api/v1/chat/sessions", json={"title": "SSE"}) - session_id = created.json()["session_id"] - sent = await client.post( - f"/api/v1/chat/sessions/{session_id}/messages", - json={"content": content, "modality": "text"}, - ) - return str(sent.json()["run_id"]) - - -@pytest.mark.asyncio -async def test_completed_run_stream_outputs_events(client: AsyncClient) -> None: - run_id = await _completed_run(client) - response = await client.get(f"/api/v1/runs/{run_id}/stream") - assert response.status_code == 200 - text = response.text - assert "event: run.started" in text - assert "event: message.delta" in text - assert "event: message.completed" in text - assert "event: run.completed" in text - - -@pytest.mark.asyncio -async def test_completed_run_stream_outputs_chunked_message_deltas(client: AsyncClient) -> None: - run_id = await _completed_run(client, content="你好" * 80) - response = await client.get(f"/api/v1/runs/{run_id}/stream") - assert response.status_code == 200 - text = response.text - assert text.count("event: message.delta") >= 2 - assert "event: message.completed" in text - assert "event: run.completed" in text - - -@pytest.mark.asyncio -async def test_stream_not_found_and_disabled( - client: AsyncClient, monkeypatch: pytest.MonkeyPatch -) -> None: - not_found = await client.get("/api/v1/runs/run_missing/stream") - assert not_found.status_code == 404 - assert not_found.json()["error"]["code"] == "not_found" - - run_id = await _completed_run(client) - monkeypatch.setattr(settings, "enable_sse", False) - disabled = await client.get(f"/api/v1/runs/{run_id}/stream") - assert disabled.status_code == 400 - assert disabled.json()["error"]["code"] == "invalid_request" diff --git a/frontend/src/app/(app)/chat/page.tsx b/frontend/src/app/(app)/chat/page.tsx deleted file mode 100644 index 9173cf0..0000000 --- a/frontend/src/app/(app)/chat/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { ChatShell } from "@/components/chat/ChatShell"; - -import { serverApi } from "@/lib/api/server-client"; -import type { Character, ModelGatewayStatus } from "@/types/api"; - -export const dynamic = "force-dynamic"; - -/** - * 对话页:Server Component 预取当前激活人物与模型网关后注入 ChatShell。 - * 任一不可达时仍渲染界面,由用户感知后端状态。 - */ -export default async function ChatPage() { - let character: Character | null = null; - let gateway: ModelGatewayStatus | null = null; - try { - character = await serverApi.get("/characters/active"); - } catch { - character = null; - } - try { - gateway = await serverApi.get("/settings/models"); - } catch { - gateway = null; - } - return ; -} diff --git a/frontend/src/app/(app)/time/page.tsx b/frontend/src/app/(app)/time/page.tsx deleted file mode 100644 index 10331fb..0000000 --- a/frontend/src/app/(app)/time/page.tsx +++ /dev/null @@ -1,691 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; - -import { Card } from "@/components/ui/Card"; -import { TextSkeleton } from "@/components/ui/Skeleton"; -import { ErrorState } from "@/components/shared/StateViews"; -import { SundialDisk } from "@/components/time/SundialDisk"; -import { charactersApi, timeApi } from "@/lib/api"; -import { describeError } from "@/lib/utils/error"; -import type { - AcquaintanceInfo, - Anniversary, - CreateAnniversaryRequest, - TimeConfig, - TimeSnapshotResponse, -} from "@/types/api"; - -// ============================================================ -// 辰正 · 时态感知 -// 实时时间展示 + 十二时辰流 + 自动刷新的后端数据 -// ============================================================ - -// —— 十二时辰 —— -const SHICHEN_KEYS = [ - "zi", - "chou", - "yin", - "mao", - "chen", - "si", - "wu", - "wei", - "shen", - "you", - "xu", - "hai", -] as const; - -const SHICHEN_HOURS: Record = { - chou: 2, - yin: 4, - mao: 6, - chen: 8, - si: 10, - wu: 12, - wei: 14, - shen: 16, - you: 18, - xu: 20, - hai: 22, -}; - -/** 由客户端本地时辰确定时辰 key(与后端逻辑一致) */ -function shichenFromHour(hour: number): string { - if (hour >= 23 || hour < 1) return "zi"; - // 取各时辰起始值,反向查找 - let prev = "zi"; - for (const key of SHICHEN_KEYS) { - const start = SHICHEN_HOURS[key]; - if (hour >= start) prev = key; - else break; - } - return prev; -} - -/** 获取目标时区的当前时间组件 */ -function getLocalTimeInTimezone(timezone: string) { - const fmt = new Intl.DateTimeFormat("zh-CN", { - timeZone: timezone, - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - }); - const p = fmt.formatToParts(new Date()); - const g = (t: string) => - parseInt(p.find((x) => x.type === t)?.value ?? "0", 10); - return { - year: g("year"), - month: g("month"), - day: g("day"), - hour: g("hour"), - minute: g("minute"), - second: g("second"), - }; -} - -function getDayPeriod(h: number): string { - if (h >= 0 && h < 5) return "凌晨"; - if (h >= 5 && h < 7) return "清晨"; - if (h >= 7 && h < 11) return "上午"; - if (h >= 11 && h < 13) return "中午"; - if (h >= 13 && h < 17) return "下午"; - if (h >= 17 && h < 19) return "傍晚"; - if (h >= 19 && h < 22) return "夜晚"; - return "深夜"; -} - -const KIND_OPTIONS = [ - { value: "anniversary", label: "纪念日" }, - { value: "birthday", label: "生日" }, - { value: "custom", label: "自定义" }, -]; - -// ============================================================ -// 子组件 -// ============================================================ - -/** 调制因子(设计稿 .factor 渐变轨道) */ -function FactorFactor({ - label, - value, - cls, -}: { - label: string; - value: number; - cls: string; -}) { - const pct = Math.round(Math.max(0, Math.min(1, value)) * 100); - return ( -
- {label} -
-
-
- {pct}% -
- ); -} - -// ============================================================ -// 主页面 -// ============================================================ - -export default function TimePage() { - // —— 后端数据 —— - const [snapshot, setSnapshot] = useState(null); - const [acquaintance, setAcquaintance] = useState( - null, - ); - const [config, setConfig] = useState(null); - const [currentMood, setCurrentMood] = useState<{ - mood: string; - intensity: number; - } | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - // —— 实时时钟 —— - const [, setNow] = useState(new Date()); - const [, setPrevShichen] = useState(null); - const [shichenChanged, setShichenChanged] = useState(false); - const shichenRef = useRef(""); - - // 每秒 tick - useEffect(() => { - const timer = setInterval(() => setNow(new Date()), 1000); - return () => clearInterval(timer); - }, []); - - // 检测时辰变化 - const timezone = - snapshot?.snapshot?.timezone ?? config?.user_timezone ?? "Asia/Shanghai"; - const local = getLocalTimeInTimezone(timezone); - const currentShichen = shichenFromHour(local.hour); - - useEffect(() => { - if (shichenRef.current && shichenRef.current !== currentShichen) { - setPrevShichen(shichenRef.current); - setShichenChanged(true); - setTimeout(() => setShichenChanged(false), 2000); - } - shichenRef.current = currentShichen; - }, [currentShichen]); - - // —— 数据获取 —— - const fetchData = useCallback(async () => { - try { - const [s, acq, cfg] = await Promise.all([ - timeApi.getTimeSnapshot(), - timeApi.getAcquaintance(), - timeApi.getTimeConfig(), - ]); - setSnapshot(s); - setAcquaintance(acq); - setConfig(cfg); - } catch (e) { - if (!snapshot) setError(describeError(e)); - } finally { - setLoading(false); - } - }, [snapshot]); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - fetchData(); - // 获取当前人物心情 - charactersApi - .getActiveCharacter() - .then((c) => { - if (c.current_mood) setCurrentMood(c.current_mood); - }) - .catch(() => {}); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - // 自动刷新:每 60 秒重新拉取后端数据(农历/节气/节日变化) - useEffect(() => { - if (loading) return; - const timer = setInterval(fetchData, 60_000); - return () => clearInterval(timer); - }, [fetchData, loading]); - - // —— 渲染 —— - if (loading) { - return ( -
- -
-
-
- -
-
-
-
- -
-
-
-
- -
-
- ); - } - - if (error && !snapshot) { - return ; - } - - const s = snapshot?.snapshot; - const factors = snapshot?.factors; - const context_text = snapshot?.context_text; - - // 实时时辰信息(优先用本地计算的,回退后端数据) - const displayShichen = currentShichen; - const displayDayPeriod = getDayPeriod(local.hour); - - return ( -
- {/* ===== 页头 ===== */} -
-
-
-

辰正

- - 时态感知 - - {shichenChanged && ( - - 更时 - - )} -
- {config && ( -

- {config.default_city} · {config.user_timezone} -

- )} -
-
- - 辰光实时 -
-
- - {/* ===== 日晷时辰盘 + 调制因子(设计稿 .sundial-region) ===== */} -
- {/* 日晷 */} - - - {/* 调制因子(设计稿 .factor-card) */} - {factors && ( -
-
-

- 调制因子 -

- -
-
- - - - - - - -
- - {currentMood && ( -
-
-

- 当前心情 -

- -
-
- - {currentMood.mood === "joy" - ? "😊" - : currentMood.mood === "sadness" - ? "😢" - : currentMood.mood === "anger" - ? "😠" - : currentMood.mood === "fear" - ? "😨" - : currentMood.mood === "surprise" - ? "😲" - : currentMood.mood === "disgust" - ? "😣" - : "😐"} - - - {currentMood.mood} - - - {Math.round(currentMood.intensity * 100)}% - -
-
- )} - - {factors.care_kind && ( -
- 关怀 · {factors.care_kind} -
- )} -
- )} -
- - {/* ===== 相识 / 离线 / 里程碑 快照格(设计稿 .snap-grid) ===== */} - {s && ( -
-
-
相识
-
- {s.acquaintance.total_days} - 天 -
-
{s.acquaintance.natural_language}
-
-
-
离线
-
- {Math.round(s.offline.seconds / 86400)} - 天 -
-
{s.offline.natural_language}
-
-
-
下个纪念日
-
- {s.next_milestone?.days_until ?? "—"} - 天后 -
-
{s.next_milestone?.name ?? "无"}
-
-
-
辰光
-
- {displayShichen} - · {displayDayPeriod} -
-
{s.season ?? "—"}
-
-
- )} - - {/* ===== 时态感知文本(全宽) ===== */} - {context_text && ( - -
-

- 时态感知 -

- -
-

- {context_text} -

-
- )} - - {/* ===== 底部双栏:相识 + 纪念日管理(设计稿 .lower-region) ===== */} -
- {acquaintance && ( - -
-

相识

- -
-

- {acquaintance.total_days} - 天 -

-

- {acquaintance.natural_language} -

-
- )} -
- -
-
-
- ); -} - -// ============================================================ -// 纪念日管理(保持原功能不变) -// ============================================================ - -function AnniversaryManager() { - const [anniversaries, setAnniversaries] = useState([]); - const [showForm, setShowForm] = useState(false); - const [editingId, setEditingId] = useState(null); - const [form, setForm] = useState( - { - name: "", - date: "", - kind: "anniversary", - calendar: "solar", - subject_type: "relationship", - source: "manual", - }, - ); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - const fetch = useCallback(async () => { - try { - const data = await timeApi.listAnniversaries(); - setAnniversaries(data); - } catch { - /* silent */ - } - }, []); - - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - fetch(); - }, [fetch]); - - function resetForm() { - setForm({ - name: "", - date: "", - kind: "anniversary", - calendar: "solar", - subject_type: "relationship", - source: "manual", - }); - setShowForm(false); - setEditingId(null); - setError(null); - } - - async function handleCreate() { - if (!form.name.trim() || !form.date) { - setError("请填写名称与日期。"); - return; - } - setBusy(true); - setError(null); - try { - await timeApi.createAnniversary(form); - resetForm(); - await fetch(); - } catch (e) { - setError(e instanceof Error ? e.message : "创建失败。"); - } finally { - setBusy(false); - } - } - - async function handleUpdate(id: string) { - if (!form.name.trim() || !form.date) { - setError("请填写名称与日期。"); - return; - } - setBusy(true); - setError(null); - try { - await timeApi.updateAnniversary(id, form); - resetForm(); - await fetch(); - } catch (e) { - setError(e instanceof Error ? e.message : "更新失败。"); - } finally { - setBusy(false); - } - } - - async function handleDelete(id: string) { - setBusy(true); - setError(null); - try { - await timeApi.deleteAnniversary(id); - await fetch(); - } catch (e) { - setError(e instanceof Error ? e.message : "删除失败。"); - } finally { - setBusy(false); - } - } - - function startEdit(a: Anniversary) { - setEditingId(a.id); - setShowForm(false); - setForm({ - name: a.name, - date: a.date, - kind: a.kind, - calendar: a.calendar, - subject_type: a.subject_type, - source: a.source, - }); - setError(null); - } - - const inputCls = - "w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg outline-none focus:border-patina/60 focus:shadow-[0_0_0_2px] focus:shadow-patina/20"; - - return ( - -
-

- 纪念日({anniversaries.length}) -

- {!showForm && !editingId && ( - - )} -
- - {(showForm || editingId) && ( -
- setForm({ ...form, name: e.target.value })} - placeholder="名称" - className={inputCls} - /> - setForm({ ...form, date: e.target.value })} - type="date" - className={inputCls} - /> -
- - -
-
- - -
- {error &&

{error}

} -
- )} - -
- {anniversaries.length === 0 ? ( -

暂无纪念日。

- ) : ( - anniversaries.map((a) => ( -
-
- {a.name} - {a.date} - - {a.kind} - -
-
- - -
-
- )) - )} -
-
- ); -} diff --git a/frontend/src/components/chat/ActiveCharacterHeader.test.tsx b/frontend/src/components/chat/ActiveCharacterHeader.test.tsx deleted file mode 100644 index de29040..0000000 --- a/frontend/src/components/chat/ActiveCharacterHeader.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { cleanup, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), -})); - -import { ActiveCharacterHeader } from "./ActiveCharacterHeader"; -import { useChatStore } from "@/lib/stores/chat-store"; - -const mockCharacter = { - id: "char_1", - name: "测试角色", - display_name: "小测", - status: "active" as const, - language: "zh", - profile: {}, - created_at: "", - updated_at: "", -}; - -describe("ActiveCharacterHeader", () => { - afterEach(() => { - cleanup(); - useChatStore.setState({ - runStatus: null, - activeRunId: null, - modelHint: null, - }); - }); - - it("渲染人物名称", () => { - render(); - expect(screen.getByText("小测")).toBeInTheDocument(); - }); - - it("无 display_name 时回退 name", () => { - render( - , - ); - expect(screen.getByText("测试角色")).toBeInTheDocument(); - }); - - it("无人物时显示默认文案", () => { - render(); - expect(screen.getByText("未加载人物")).toBeInTheDocument(); - }); - - it("runStatus=running 渲染 Run 状态链接", () => { - useChatStore.setState({ runStatus: "running", activeRunId: "run_1" }); - render(); - expect(screen.getByText("定策中")).toBeInTheDocument(); - }); - - it("无 runStatus 时常驻显示静默态'静鉴'", () => { - render(); - // RunBadge 常驻:无 run 时呈静默"静鉴",而非消失 - expect(screen.getByText("静鉴")).toBeInTheDocument(); - expect(screen.queryByText("定策中")).not.toBeInTheDocument(); - }); -}); diff --git a/frontend/src/components/chat/ActiveCharacterHeader.tsx b/frontend/src/components/chat/ActiveCharacterHeader.tsx deleted file mode 100644 index d2273be..0000000 --- a/frontend/src/components/chat/ActiveCharacterHeader.tsx +++ /dev/null @@ -1,234 +0,0 @@ -"use client"; - -import { useEffect, useRef, useState } from "react"; -import Link from "next/link"; - -import { CharacterSwitcher } from "./CharacterSwitcher"; -import { useChatStore } from "@/lib/stores/chat-store"; -import { cn } from "@/lib/utils/cn"; -import type { Character, LogicalModel } from "@/types/api"; -import type { RunStatus } from "@/types/domain"; - -/** 运行态 → 中文爻位短语(设计稿 run-badge 文案,如 running=定策中) */ -const RUN_LABEL: Record = { - queued: "起意中", - running: "定策中", - waiting_confirmation: "待确认", - completed: "已入藏", - failed: "归真", - cancelled: "已止", -}; -/** 进行中态(脉冲点亮);终态静止,不再呼吸 */ -const RUN_LIVE = new Set([ - "queued", - "running", - "waiting_confirmation", -]); - -/** - * 右上角运行态徽标:常驻显示。 - * 有进行中 run 时呈现爻位态(脉冲点亮,可点入轨迹);无 run 或已终态时 - * 静默"静鉴",保持顶栏视觉锚点不消失(镜始终在场)。 - */ -function RunBadge({ - runStatus, - activeRunId, -}: { - runStatus: RunStatus | null; - activeRunId: string | null; -}) { - const live = !!activeRunId && !!runStatus && RUN_LIVE.has(runStatus); - const label = activeRunId && runStatus ? RUN_LABEL[runStatus] : "静鉴"; - const cls = cn( - "inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border px-2.5 py-[3px] text-[12px] transition-colors", - live - ? "border-patina-dim bg-patina-wash text-patina hover:border-patina hover:text-patina-bright" - : "border-rule bg-surface/60 text-fg-faint", - ); - const dot = ( - - ); - if (activeRunId && runStatus) { - return ( - - {dot} - {label} - - ); - } - return ( - - {dot} - {label} - - ); -} - -/** - * 铜镜鉴心 v2 顶栏(Topbar)。 - * - * 极简:左字标 ●魂鏡 + 镜背轨开关 + 当前人物 | 右 pill 组(运行态 / 模型 / 人物) - * 底部 1px rule 发丝线。Run 状态由铜镜呼吸隐式表达。 - */ -export function ActiveCharacterHeader({ - character, - models, - railCollapsed = false, - onToggleRail, -}: { - character: Character | null; - models?: LogicalModel[]; - /** 镜背轨(chat-region 内的会话/状态轨)是否收起 */ - railCollapsed?: boolean; - /** 切换镜背轨展开 / 收起 */ - onToggleRail?: () => void; -}) { - const runStatus = useChatStore( - (state) => state.runStatus, - ) as RunStatus | null; - const activeRunId = useChatStore((state) => state.activeRunId); - const displayName = - character?.display_name || character?.name || "未加载人物"; - - return ( -
- {/* 镜背轨开关(桌面,替代原浮动圆钮) */} - {onToggleRail && ( - - )} - - {/* 人物切换器:按钮即当前人物(设计稿紧挨 rail-toggle,左侧) */} - - -
- - {/* 右侧 pills:运行态(常驻)+ 模型 */} -
- - - -
-
- ); -} - -/** 模型选择:下拉框选择模型 */ -function ModelPill({ models }: { models?: LogicalModel[] }) { - const modelHint = useChatStore((s) => s.modelHint); - const setModelHint = useChatStore((s) => s.setModelHint); - const [open, setOpen] = useState(false); - const ref = useRef(null); - - const currentModel = models?.find((m) => m.name === modelHint); - const label = currentModel?.display_name || modelHint || "模型"; - - // 点击外部收起 - useEffect(() => { - if (!open) return; - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - setOpen(false); - } - } - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [open]); - - return ( -
- - - {open && ( -
- {!models || models.length === 0 ? ( -
无可用模型
- ) : ( - models.map((m) => ( - - )) - )} -
- )} -
- ); -} diff --git a/frontend/src/components/chat/CharacterSwitcher.test.tsx b/frontend/src/components/chat/CharacterSwitcher.test.tsx deleted file mode 100644 index 44522bb..0000000 --- a/frontend/src/components/chat/CharacterSwitcher.test.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; - -vi.mock("next/navigation", () => ({ - useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), -})); - -// 不触发实际的 API 调用 -vi.mock("@/lib/api", () => ({ - charactersApi: { - listCharacters: vi.fn().mockResolvedValue({ items: [], total: 0 }), - }, -})); - -import { CharacterSwitcher } from "./CharacterSwitcher"; - -describe("CharacterSwitcher", () => { - it("渲染当前人物名", () => { - render( - , - ); - expect(screen.getByText("小测")).toBeInTheDocument(); - }); - - it("点击展开下拉菜单", async () => { - const user = userEvent.setup(); - render( - , - ); - // 当前人物名可点击 - await user.click(screen.getByText("小测")); - // dropdown 应该出现——因为 listCharacters 返回空列表, - // 此处只验证下拉渲染不报错 - expect(screen.getByText("小测")).toBeInTheDocument(); - }); -}); diff --git a/frontend/src/components/chat/CharacterSwitcher.tsx b/frontend/src/components/chat/CharacterSwitcher.tsx deleted file mode 100644 index 4fcc596..0000000 --- a/frontend/src/components/chat/CharacterSwitcher.tsx +++ /dev/null @@ -1,161 +0,0 @@ -"use client"; - -import { useState, useRef, useEffect } from "react"; -import { useRouter } from "next/navigation"; - -import { charactersApi } from "@/lib/api"; -import { cn } from "@/lib/utils/cn"; -import { describeError } from "@/lib/utils/error"; -import type { CharacterListItem } from "@/types/api"; - -/** 人物缩略头像(首字):激活态实心铜绿,其余铜绿晕底。与镜背轨状态卡同源。 */ -function AvatarMini({ name, active }: { name: string; active?: boolean }) { - const ch = name?.trim()?.[0] ?? "镜"; - return ( - - {ch} - - ); -} - -/** - * 对话页人物切换器:下拉列出全部人物,选中后激活并刷新页面。 - * 点击外部自动收起。 - */ -export function CharacterSwitcher({ - currentCharacterId, - currentDisplayName, -}: { - currentCharacterId: string | undefined; - currentDisplayName: string; -}) { - const router = useRouter(); - const [open, setOpen] = useState(false); - const [characters, setCharacters] = useState([]); - const [switching, setSwitching] = useState(false); - const ref = useRef(null); - - // 打开时拉取人物列表 - useEffect(() => { - if (!open) return; - let cancelled = false; - charactersApi - .listCharacters({ page_size: 50 }) - .then((res) => { - if (!cancelled) setCharacters(res.items); - }) - .catch(() => { - /* 拉取失败静默 */ - }); - return () => { - cancelled = true; - }; - }, [open]); - - // 点击外部收起 - useEffect(() => { - if (!open) return; - function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - setOpen(false); - } - } - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [open]); - - async function handleSwitch(characterId: string) { - if (characterId === currentCharacterId) { - setOpen(false); - return; - } - setSwitching(true); - try { - await charactersApi.activateCharacter(characterId); - setOpen(false); - router.refresh(); - } catch (e) { - /* 切换失败不阻塞,静默保留当前人物 */ - console.error("切换人物失败:", describeError(e)); - setSwitching(false); - } - } - - return ( -
- - - {open && ( -
- {characters.length === 0 ? ( -
加载中…
- ) : ( - characters.map((c) => { - const isCurrent = c.id === currentCharacterId; - return ( - - ); - }) - )} -
- )} -
- ); -} diff --git a/frontend/src/components/chat/ChatContextRail.tsx b/frontend/src/components/chat/ChatContextRail.tsx deleted file mode 100644 index 0091e14..0000000 --- a/frontend/src/components/chat/ChatContextRail.tsx +++ /dev/null @@ -1,376 +0,0 @@ -"use client"; - -import { useCallback, useMemo, useState } from "react"; - -import { memoryApi } from "@/lib/api"; -import { useChatStore } from "@/lib/stores/chat-store"; -import { cn } from "@/lib/utils/cn"; -import type { Character, RetrievedMemoryItem } from "@/types/api"; -import type { RunStatus } from "@/types/domain"; - -const HEXAGRAM_STAGES = [ - { key: "起意", order: 1 }, - { key: "察势", order: 2 }, - { key: "定策", order: 3 }, - { key: "行事", order: 4 }, - { key: "归真", order: 5 }, - { key: "入藏", order: 6 }, -] as const; - -const RUN_TO_HEXAGRAM: Record< - string, - (typeof HEXAGRAM_STAGES)[number]["key"] | null -> = { - queued: "起意", - running: "定策", - waiting_confirmation: "行事", - completed: "入藏", - failed: "归真", - cancelled: "归真", -}; - -/** - * 镜背轨的实时上下文区:人物状态卡、六爻、息壤。 - * embedded 模式不再生成独立侧栏,以便与历史会话共用一条镜背轨。 - */ -export function ChatContextRail({ - character, - className, - embedded = false, -}: { - character?: Character | null; - className?: string; - embedded?: boolean; -}) { - const [xirangOpen, setXirangOpen] = useState(true); - const [hexOpen, setHexOpen] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const runStatus = useChatStore( - (state) => state.runStatus, - ) as RunStatus | null; - const memories = useChatStore((state) => state.retrievedMemories); - const setRetrievedMemories = useChatStore( - (state) => state.setRetrievedMemories, - ); - - const hexagrams = useMemo(() => { - const activeKey = runStatus ? RUN_TO_HEXAGRAM[runStatus] : null; - const activeOrder = activeKey - ? (HEXAGRAM_STAGES.find((stage) => stage.key === activeKey)?.order ?? 0) - : 0; - - return HEXAGRAM_STAGES.map((stage) => ({ - ...stage, - state: - stage.order < activeOrder - ? ("done" as const) - : stage.order === activeOrder - ? ("active" as const) - : ("future" as const), - })); - }, [runStatus]); - - const handleRefresh = useCallback(async () => { - setRefreshing(true); - try { - const data = await memoryApi.listMemories({ page_size: 10 }); - const items: RetrievedMemoryItem[] = data.items.map((m) => ({ - id: m.id, - title: m.title ?? "", - content: m.content, - source: m.source, - confidence: m.confidence ?? 1, - importance: m.importance ?? 0.5, - type: m.type, - })); - setRetrievedMemories(items); - } catch { - // 静默 - } finally { - setRefreshing(false); - } - }, [setRetrievedMemories]); - - const content = ( - <> - {/* 人物状态卡:avatar-ring + 认识天数 + 情绪/内驱 stats */} - - - {/* 六爻:本次运行所处的爻位(可折叠,与息壤同构) */} -
-
- - -
-
-
- {hexagrams.map((hexagram) => { - const isActive = hexagram.state === "active"; - const isDone = hexagram.state === "done"; - - return ( -
- - {["初", "二", "三", "四", "五", "上"][hexagram.order - 1]} - - - {hexagram.key} - - {/* 爻象进度:容器虚线(阴爻·未达),fill 覆盖为阳爻(已达·铜绿 / 当爻·鎏金流光) */} -
-
-
-
- ); - })} -
-
-
- - {/* 息壤:从 memory.retrieved SSE 事件推送的记忆数据 */} -
-
- - - -
- {xirangOpen && ( -
- {memories.length > 0 ? ( -
- {memories.map((m) => ( -
- {m.title && ( -
- {m.title} -
- )} -
{m.content}
- - 置信{" "} - - {m.confidence.toFixed(2)} - - {m.source && <> · {sourceLabel(m.source)}} - {m.importance > 0.7 && ( - <> - {" "} - · 重要 - - )} - -
- ))} -
- ) : ( -
- 发送消息后,检索到的记忆将在此浮现。 - - 对话结束后自动沉淀 - -
- )} -
- )} -
- - ); - - if (embedded) return content; - - return ( - - ); -} - -/** - * 人物状态卡:镜背轨顶部的"被照见者"——头像鎏金环 + 名号 + 认识天数, - * 下方两格 stats 呈现情绪与内驱。与设计稿 status-card 同源。 - */ -function StatusCard({ character }: { character?: Character | null }) { - const sending = useChatStore((state) => state.sending); - const displayName = character?.display_name || character?.name; - const avatarChar = (displayName || "镜").slice(0, 1); - const moodLabel = character?.current_mood?.mood ?? "沉静"; - // 认识天数:以人物创建时刻为关系起点;挂载时计算一次(会话期间不变) - const [days] = useState(() => { - const created = character?.created_at; - if (!created) return null; - const d = Math.floor( - (Date.now() - new Date(created).getTime()) / 86_400_000, - ); - return d > 0 ? d : 1; - }); - // 内驱暂无独立后端数据源,以运行态作语义占位(运筹/守候),避免呈现虚假具体值 - const driveLabel = sending ? "运筹" : "守候"; - const driveHint = sending ? "倾听" : "回应"; - - return ( -
-
-
-
- {avatarChar} -
-
-
-
- {displayName ?? "未加载人物"} -
-
- - - {days ? `认识你第 ${days} 日` : "初识"} · {moodLabel} - -
-
-
-
- - {moodLabel} - - {sending ? "运筹" : "平稳"} - - - - {driveLabel} - - {driveHint} - - -
-
- ); -} - -function Stat({ k, children }: { k: string; children: React.ReactNode }) { - return ( -
-
- {k} -
-
- {children} -
-
- ); -} - -/** 记忆来源中文标签 */ -function sourceLabel(source: string): string { - const labels: Record = { - user_explicit: "用户明确", - conversation: "对话推断", - material_import: "资料导入", - tool_result: "工具结果", - reflection: "反思沉淀", - }; - return labels[source] ?? source; -} - -function RailLabel({ label, sub }: { label: string; sub: string }) { - return ( -

- {label} - - {sub} - -

- ); -} diff --git a/frontend/src/components/chat/ChatInput.test.tsx b/frontend/src/components/chat/ChatInput.test.tsx deleted file mode 100644 index 2330ade..0000000 --- a/frontend/src/components/chat/ChatInput.test.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { ChatInput } from "./ChatInput"; -import { useChatStore } from "@/lib/stores/chat-store"; - -describe("ChatInput", () => { - afterEach(() => { - useChatStore.setState({ - sending: false, - thinkingMode: true, - thinkingEffort: "medium", - webSearchEnabled: false, - error: null, - }); - }); - - const PLACEHOLDER = "输入消息,Enter 发送,Shift+Enter 换行"; - - it("渲染输入框", () => { - render( {}} />); - expect(screen.getByPlaceholderText(PLACEHOLDER)).toBeInTheDocument(); - }); - - it("输入文本后按 Enter 发送", async () => { - const onSend = vi.fn(); - const user = userEvent.setup(); - render(); - const input = screen.getByPlaceholderText(PLACEHOLDER); - await user.type(input, "你好"); - await user.keyboard("{Enter}"); - expect(onSend).toHaveBeenCalledWith("你好"); - }); - - it("空文本按 Enter 不发送", async () => { - const onSend = vi.fn(); - const user = userEvent.setup(); - render(); - await user.keyboard("{Enter}"); - expect(onSend).not.toHaveBeenCalled(); - }); - - it("disabled 状态下显示不同占位且不发送", async () => { - const onSend = vi.fn(); - const user = userEvent.setup(); - render(); - expect(screen.getByPlaceholderText("请先创建会话")).toBeInTheDocument(); - await user.keyboard("{Enter}"); - expect(onSend).not.toHaveBeenCalled(); - }); - - it("发送后清空输入框", async () => { - const onSend = vi.fn(); - const user = userEvent.setup(); - render(); - const input = screen.getByPlaceholderText(PLACEHOLDER); - await user.type(input, "消息"); - await user.keyboard("{Enter}"); - // 发送后 input 应清空 - expect(input).toHaveValue(""); - }); - - it("sending 时显示停止按钮", () => { - useChatStore.setState({ sending: true }); - render( {}} onStop={() => {}} />); - expect( - screen.getByRole("button", { name: "停止生成" }), - ).toBeInTheDocument(); - }); - - it("不存在 sending 时不显示停止按钮", () => { - render( {}} onStop={() => {}} />); - expect( - screen.queryByRole("button", { name: "停止生成" }), - ).not.toBeInTheDocument(); - }); - - it("思考模式按钮可切换", async () => { - const user = userEvent.setup(); - render( {}} />); - const btn = screen.getByRole("button", { name: /思考模式/ }); - expect(btn).toBeInTheDocument(); - await user.click(btn); - expect(useChatStore.getState().thinkingMode).toBe(false); - }); - - it("超长消息显示错误", async () => { - const longText = "a".repeat(33000); - const onSend = vi.fn(); - const user = userEvent.setup(); - render(); - const input = screen.getByPlaceholderText(PLACEHOLDER); - // 用 click + keyboard 避免 userEvent.type 逐字符输入 33000 字符 - await user.click(input); - await user.paste(longText); - await user.keyboard("{Enter}"); - expect(onSend).not.toHaveBeenCalled(); - expect(useChatStore.getState().error).toBe("单条消息过长,请精简后发送。"); - }); -}); diff --git a/frontend/src/components/chat/ChatInput.tsx b/frontend/src/components/chat/ChatInput.tsx deleted file mode 100644 index 5157119..0000000 --- a/frontend/src/components/chat/ChatInput.tsx +++ /dev/null @@ -1,368 +0,0 @@ -"use client"; - -import { useRef, useState } from "react"; -import type { CSSProperties, KeyboardEvent } from "react"; - -import { cn } from "@/lib/utils/cn"; -import { useChatStore } from "@/lib/stores/chat-store"; -import { useVoiceInput } from "@/lib/hooks/useVoiceInput"; -import type { ThinkingEffort } from "@/types/thinking"; - -/** 单条消息字节上限(API §5.6,默认 32 KiB) */ -const MAX_TEXT_BYTES = 32 * 1024; - -/** textarea 最大高度:超出后内部滚动 */ -const TEXTAREA_MAX_H = "10rem"; - -const EFFORT_OPTIONS: { value: ThinkingEffort; label: string; hint: string }[] = - [ - { value: "low", label: "low", hint: "快速 · 简短思考" }, - { value: "medium", label: "medium", hint: "均衡 · 适度推演" }, - { value: "high", label: "high", hint: "深思 · 详尽推演" }, - ]; - -/** - * 铜镜鉴心 v2 铭文输入框(Composer)。 - * - * 铭文刻线分隔 + 透明输入框 + 呈镜按钮。思考模式/搜索开关移至顶部 - * 以 pill 容纳,此处只保留最简的发送行为。 - */ -export function ChatInput({ - onSend, - onFollowUp, - disabled, - onStop, - stopping, -}: { - onSend: (content: string) => void; - onFollowUp?: (content: string) => void; - disabled?: boolean; - onStop?: () => void; - stopping?: boolean; -}) { - const sending = useChatStore((s) => s.sending); - const thinkingMode = useChatStore((s) => s.thinkingMode); - const thinkingEffort = useChatStore((s) => s.thinkingEffort); - const setThinkingMode = useChatStore((s) => s.setThinkingMode); - const setThinkingEffort = useChatStore((s) => s.setThinkingEffort); - const webSearchEnabled = useChatStore((s) => s.webSearchEnabled); - const setWebSearchEnabled = useChatStore((s) => s.setWebSearchEnabled); - const [text, setText] = useState(""); - const textareaRef = useRef(null); - - /** 将转写文本追加到当前输入框(在已有内容后以空格分隔)。 */ - const insertTranscript = (t: string) => { - const trimmed = t.trim(); - if (!trimmed) return; - setText((prev) => (prev ? `${prev} ${trimmed}` : trimmed)); - // 转写完成后将焦点归还输入框 - requestAnimationFrame(() => textareaRef.current?.focus()); - }; - - const voice = useVoiceInput({ - language: "zh", - onResult: insertTranscript, - onError: (msg) => useChatStore.getState().setError(msg), - }); - - const send = () => { - const content = text.trim(); - // 运行中(sending)也允许发送:经 ChatShell handleSend 分流为 steer, - // 让用户能在生成过程中补充指引;disabled 仅在无会话时阻断。 - if (!content || disabled) return; - if (new Blob([content]).size > MAX_TEXT_BYTES) { - useChatStore.getState().setError("单条消息过长,请精简后发送。"); - return; - } - onSend(content); - setText(""); - }; - - const sendFollowUp = () => { - const content = text.trim(); - if (!content || disabled) return; - onFollowUp?.(content); - setText(""); - }; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - send(); - } - }; - - const sendDisabled = disabled || sending || !text.trim(); - - return ( -
- {/* 铭文输入区 */} -
-