M2b:导入改造产出可审阅灵胚(启镜物化世谱)与组装管线(符文展开/火候进入 IR),Kernel 打通采样参数链路

This commit is contained in:
chenxin committed 2026-08-21 23:43:29 +08:00
1 parent e6a3b7121f
commit 5f36e3785c
86 files changed
+421 -14452

No files matched your search

@@ -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")
-137
View File
@@ -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,
)
-113
View File
@@ -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,
)
Whitespace-only changes.
@@ -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()
+43
View File
@@ -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)
+66 -6
View File
@@ -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)
+8 -2
View File
@@ -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)
+12
View File
@@ -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,
-52
View File
@@ -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))
-32
View File
@@ -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)
-41
View File
@@ -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
)
Whitespace-only changes.
-18
View File
@@ -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"}
]
-11
View File
@@ -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", "单条文本消息超过大小限制。")
+4 -1
View File
@@ -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(
{
-40
View File
@@ -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",
]
-573
View File
@@ -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()
-99
View File
@@ -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}
-190
View File
@@ -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 = (
"你对用户保持着温和的关注,"
"平日里话不多,但每次开口都带着真诚。"
"你善于观察,能从细微之处感知对方的情绪。"
)
-366
View File
@@ -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)
-200
View File
@@ -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", "消息不存在。")
@@ -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
@@ -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
-254
View File
@@ -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)。",
)
-84
View File
@@ -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
+51 -93
View File
@@ -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 "",
)
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,
if part
)
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)
return character_to_card(character)
-252
View File
@@ -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)
-141
View File
@@ -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)
-390
View File
@@ -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
@@ -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
@@ -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)
@@ -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)
@@ -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
@@ -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)
@@ -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
-195
View File
@@ -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()
@@ -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
@@ -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"}
@@ -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
@@ -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
@@ -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"
@@ -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
@@ -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")
)
# 无抛出即通过
@@ -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}
@@ -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"
-87
View File
@@ -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
-56
View File
@@ -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
-100
View File
@@ -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
+208
View File
@@ -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
-206
View File
@@ -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
-77
View File
@@ -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"
-26
View File
@@ -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<Character>("/characters/active");
} catch {
character = null;
}
try {
gateway = await serverApi.get<ModelGatewayStatus>("/settings/models");
} catch {
gateway = null;
}
return <ChatShell initialCharacter={character} gateway={gateway} />;
}
-691
View File
@@ -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<string, number> = {
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 (
<div className={`factor ${cls}`}>
<span className="fk">{label}</span>
<div className="ftrack">
<div className="ffill" style={{ width: `${pct}%` }} />
</div>
<span className="fv">{pct}%</span>
</div>
);
}
// ============================================================
// 主页面
// ============================================================
export default function TimePage() {
// —— 后端数据 ——
const [snapshot, setSnapshot] = useState<TimeSnapshotResponse | null>(null);
const [acquaintance, setAcquaintance] = useState<AcquaintanceInfo | null>(
null,
);
const [config, setConfig] = useState<TimeConfig | null>(null);
const [currentMood, setCurrentMood] = useState<{
mood: string;
intensity: number;
} | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// —— 实时时钟 ——
const [, setNow] = useState<Date>(new Date());
const [, setPrevShichen] = useState<string | null>(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 (
<div className="mx-auto max-w-5xl px-4 py-8">
<TextSkeleton lines={1} className="mb-8 w-48" />
<div className="grid grid-cols-1 gap-6 md:grid-cols-5">
<div className="md:col-span-3">
<div className="glass rounded-2xl p-8">
<TextSkeleton lines={3} />
</div>
</div>
<div className="md:col-span-2">
<div className="glass rounded-2xl p-5">
<TextSkeleton lines={6} />
</div>
</div>
</div>
<div className="glass mt-6 rounded-2xl p-5">
<TextSkeleton lines={2} />
</div>
</div>
);
}
if (error && !snapshot) {
return <ErrorState message={error} onRetry={fetchData} />;
}
const s = snapshot?.snapshot;
const factors = snapshot?.factors;
const context_text = snapshot?.context_text;
// 实时时辰信息(优先用本地计算的,回退后端数据)
const displayShichen = currentShichen;
const displayDayPeriod = getDayPeriod(local.hour);
return (
<div className="mx-auto max-w-5xl px-4 py-8">
{/* ===== 页头 ===== */}
<header className="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="font-serif text-2xl font-semibold text-fg">辰正</h1>
<span className="rounded-full bg-gold/10 px-2.5 py-0.5 text-[10px] font-medium tracking-wider text-gold">
时态感知
</span>
{shichenChanged && (
<span className="animate-pulse rounded-full bg-gold/30 px-2 py-0.5 text-[10px] text-gold">
更时
</span>
)}
</div>
{config && (
<p className="mt-1 text-xs text-muted">
{config.default_city} · {config.user_timezone}
</p>
)}
</div>
<div className="flex items-center gap-2 text-[11px] text-muted">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-patina" />
辰光实时
</div>
</header>
{/* ===== 日晷时辰盘 + 调制因子(设计稿 .sundial-region) ===== */}
<div className="sundial-region">
{/* 日晷 */}
<SundialDisk
currentShichen={displayShichen}
hour={local.hour}
minute={local.minute}
lunarText={s?.lunar?.text}
festivals={s?.festivals}
solarTerm={s?.solar_term?.name}
/>
{/* 调制因子(设计稿 .factor-card) */}
{factors && (
<div className="factor-card">
<div className="mb-4 flex items-center gap-2">
<h3 className="font-serif text-sm font-semibold text-fg">
调制因子
</h3>
<span className="h-px flex-1 bg-rule" />
</div>
<div className="factor-list">
<FactorFactor
label="节日喜庆"
value={factors.festival_joy}
cls="f-joy"
/>
<FactorFactor
label="深夜脆弱"
value={factors.late_night_fragility}
cls="f-frag"
/>
<FactorFactor
label="重逢强度"
value={factors.reunion_intensity}
cls="f-reun"
/>
<FactorFactor
label="周末放松"
value={factors.weekend_relax}
cls="f-wknd"
/>
<FactorFactor
label="季节共鸣"
value={factors.season_resonance}
cls="f-season"
/>
<FactorFactor
label="同期共振"
value={factors.same_period_resonance}
cls="f-period"
/>
<FactorFactor
label="关怀倾向"
value={factors.care_suggest}
cls="f-care"
/>
</div>
{currentMood && (
<div className="mt-4 rounded-xl bg-glass/40 px-4 py-3">
<div className="mb-1 flex items-center gap-2">
<h3 className="font-serif text-sm font-semibold text-fg">
当前心情
</h3>
<span className="h-px flex-1 bg-rule" />
</div>
<div className="flex items-center gap-3">
<span className="text-2xl">
{currentMood.mood === "joy"
? "😊"
: currentMood.mood === "sadness"
? "😢"
: currentMood.mood === "anger"
? "😠"
: currentMood.mood === "fear"
? "😨"
: currentMood.mood === "surprise"
? "😲"
: currentMood.mood === "disgust"
? "😣"
: "😐"}
</span>
<span className="font-serif text-base font-medium text-fg">
{currentMood.mood}
</span>
<span className="font-mono text-xs text-muted">
{Math.round(currentMood.intensity * 100)}%
</span>
</div>
</div>
)}
{factors.care_kind && (
<div className="care-note">
<b>关怀</b> · {factors.care_kind}
</div>
)}
</div>
)}
</div>
{/* ===== 相识 / 离线 / 里程碑 快照格(设计稿 .snap-grid) ===== */}
{s && (
<div className="snap-grid">
<div className="snap-card">
<div className="k">相识</div>
<div className="v">
{s.acquaintance.total_days}
<small>天</small>
</div>
<div className="sub">{s.acquaintance.natural_language}</div>
</div>
<div className="snap-card">
<div className="k">离线</div>
<div className="v">
{Math.round(s.offline.seconds / 86400)}
<small>天</small>
</div>
<div className="sub">{s.offline.natural_language}</div>
</div>
<div className="snap-card milestone">
<div className="k">下个纪念日</div>
<div className="v">
{s.next_milestone?.days_until ?? "—"}
<small>天后</small>
</div>
<div className="sub">{s.next_milestone?.name ?? "无"}</div>
</div>
<div className="snap-card">
<div className="k">辰光</div>
<div className="v">
{displayShichen}
<small>· {displayDayPeriod}</small>
</div>
<div className="sub">{s.season ?? "—"}</div>
</div>
</div>
)}
{/* ===== 时态感知文本(全宽) ===== */}
{context_text && (
<Card variant="surface" className="p-5">
<div className="mb-3 flex items-center gap-2">
<h3 className="font-serif text-sm font-semibold text-fg">
时态感知
</h3>
<span className="h-px flex-1 bg-rule" />
</div>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-fg/80">
{context_text}
</p>
</Card>
)}
{/* ===== 底部双栏:相识 + 纪念日管理(设计稿 .lower-region) ===== */}
<div className="lower-region">
{acquaintance && (
<Card variant="surface" className="p-6 event-main">
<div className="mb-3 flex items-center gap-2">
<h3 className="font-serif text-sm font-semibold text-fg">相识</h3>
<span className="h-px flex-1 bg-rule" />
</div>
<p className="font-serif text-4xl font-semibold text-fg">
{acquaintance.total_days}
<span className="ml-1 text-base font-normal text-muted">天</span>
</p>
<p className="mt-1 text-sm text-muted">
{acquaintance.natural_language}
</p>
</Card>
)}
<div className="anniv-rail">
<AnniversaryManager />
</div>
</div>
</div>
);
}
// ============================================================
// 纪念日管理(保持原功能不变)
// ============================================================
function AnniversaryManager() {
const [anniversaries, setAnniversaries] = useState<Anniversary[]>([]);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<CreateAnniversaryRequest & { date: string }>(
{
name: "",
date: "",
kind: "anniversary",
calendar: "solar",
subject_type: "relationship",
source: "manual",
},
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Card variant="surface" className="p-6">
<div className="flex items-center justify-between">
<h3 className="font-serif text-sm font-semibold text-fg">
纪念日({anniversaries.length})
</h3>
{!showForm && !editingId && (
<button
type="button"
onClick={() => {
resetForm();
setShowForm(true);
}}
className="rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 新增
</button>
)}
</div>
{(showForm || editingId) && (
<div className="mt-3 space-y-2 rounded-lg border border-border-glass bg-glass/50 p-3">
<input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="名称"
className={inputCls}
/>
<input
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
type="date"
className={inputCls}
/>
<div className="flex gap-2">
<select
value={form.kind}
onChange={(e) => setForm({ ...form, kind: e.target.value })}
className={inputCls}
>
{KIND_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<select
value={form.calendar}
onChange={(e) => setForm({ ...form, calendar: e.target.value })}
className={inputCls}
>
<option value="solar">公历</option>
<option value="lunar">农历</option>
</select>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={editingId ? () => handleUpdate(editingId) : handleCreate}
disabled={busy}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
{editingId ? "保存" : "创建"}
</button>
<button
type="button"
onClick={resetForm}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
{error && <p className="text-xs text-cinnabar">{error}</p>}
</div>
)}
<div className="mt-3 space-y-2">
{anniversaries.length === 0 ? (
<p className="text-sm text-muted">暂无纪念日。</p>
) : (
anniversaries.map((a) => (
<div
key={a.id}
className="flex items-center justify-between rounded-lg bg-glass px-3 py-2"
>
<div className="min-w-0 flex-1">
<span className="text-sm text-fg">{a.name}</span>
<span className="ml-2 text-[11px] text-muted">{a.date}</span>
<span className="ml-2 rounded bg-gold/10 px-1.5 py-0.5 text-[10px] text-gold">
{a.kind}
</span>
</div>
<div className="ml-2 flex shrink-0 gap-2">
<button
type="button"
onClick={() => startEdit(a)}
className="text-[11px] text-patina hover:underline"
>
编辑
</button>
<button
type="button"
onClick={() => handleDelete(a.id)}
disabled={busy}
className="text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
</div>
</div>
))
)}
</div>
</Card>
);
}
@@ -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(<ActiveCharacterHeader character={mockCharacter} />);
expect(screen.getByText("小测")).toBeInTheDocument();
});
it("无 display_name 时回退 name", () => {
render(
<ActiveCharacterHeader
character={{ ...mockCharacter, display_name: undefined }}
/>,
);
expect(screen.getByText("测试角色")).toBeInTheDocument();
});
it("无人物时显示默认文案", () => {
render(<ActiveCharacterHeader character={null} />);
expect(screen.getByText("未加载人物")).toBeInTheDocument();
});
it("runStatus=running 渲染 Run 状态链接", () => {
useChatStore.setState({ runStatus: "running", activeRunId: "run_1" });
render(<ActiveCharacterHeader character={mockCharacter} />);
expect(screen.getByText("定策中")).toBeInTheDocument();
});
it("无 runStatus 时常驻显示静默态'静鉴'", () => {
render(<ActiveCharacterHeader character={mockCharacter} />);
// RunBadge 常驻:无 run 时呈静默"静鉴",而非消失
expect(screen.getByText("静鉴")).toBeInTheDocument();
expect(screen.queryByText("定策中")).not.toBeInTheDocument();
});
});
@@ -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<RunStatus, string> = {
queued: "起意中",
running: "定策中",
waiting_confirmation: "待确认",
completed: "已入藏",
failed: "归真",
cancelled: "已止",
};
/** 进行中态(脉冲点亮);终态静止,不再呼吸 */
const RUN_LIVE = new Set<RunStatus>([
"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 = (
<span
className={cn(
"h-[5px] w-[5px] shrink-0 rounded-full",
live ? "animate-pulse-ring bg-patina" : "bg-fg-faint/50",
)}
/>
);
if (activeRunId && runStatus) {
return (
<Link
href={`/runs/${activeRunId}`}
aria-label={`查看运行 ${label}`}
className={cls}
>
{dot}
{label}
</Link>
);
}
return (
<span className={cls} aria-label="镜面静待铭文">
{dot}
{label}
</span>
);
}
/**
* 铜镜鉴心 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 (
<header className="sticky top-0 z-40 flex items-center gap-2 border-b border-rule bg-ink/78 px-4 py-3 backdrop-blur-lg sm:gap-4 sm:px-5">
{/* 镜背轨开关(桌面,替代原浮动圆钮) */}
{onToggleRail && (
<button
type="button"
onClick={onToggleRail}
aria-label={railCollapsed ? "展开镜背轨" : "收起镜背轨"}
title={railCollapsed ? "展开镜背轨" : "收起镜背轨"}
className={cn(
"hidden h-8 w-8 shrink-0 items-center justify-center rounded-lg border transition-colors md:inline-flex",
railCollapsed
? "border-rule bg-surface text-fg-dim hover:border-gilt-dim hover:text-fg"
: "border-patina-dim bg-patina-wash text-patina",
)}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
aria-hidden
>
<rect
x="2"
y="2"
width="12"
height="12"
rx="1.5"
stroke="currentColor"
strokeWidth="1.3"
/>
<path d="M6 2v12" stroke="currentColor" strokeWidth="1.3" />
</svg>
</button>
)}
{/* 人物切换器:按钮即当前人物(设计稿紧挨 rail-toggle,左侧) */}
<CharacterSwitcher
currentCharacterId={character?.id}
currentDisplayName={displayName}
/>
<div className="min-w-0 flex-1" />
{/* 右侧 pills:运行态(常驻)+ 模型 */}
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
<RunBadge runStatus={runStatus} activeRunId={activeRunId} />
<ModelPill models={models} />
</div>
</header>
);
}
/** 模型选择:下拉框选择模型 */
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<HTMLDivElement>(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 (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
title="切换模型"
className="inline-flex max-w-24 items-center gap-1 rounded-full border border-rule bg-surface px-2.5 py-1 text-[10px] text-fg-dim transition-colors hover:border-gilt-dim hover:text-fg sm:max-w-40 sm:px-3 sm:text-[11px]"
>
<span className="truncate">{label}</span>
<svg
width="10"
height="10"
viewBox="0 0 16 16"
fill="none"
aria-hidden
className={cn("shrink-0 transition-transform", open && "rotate-180")}
>
<path
d="M4 6l4 4 4-4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-1 min-w-44 rounded-xl border border-border-glass bg-glass-strong p-1 shadow-xl backdrop-blur-xl">
{!models || models.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted">无可用模型</div>
) : (
models.map((m) => (
<button
key={m.name}
type="button"
onClick={() => {
setModelHint(m.name);
setOpen(false);
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-xs transition-colors hover:bg-fg/5"
>
<span className="flex-1 truncate text-fg-dim">
{m.display_name || m.name}
</span>
{m.name === modelHint && (
<span className="shrink-0 text-[9px] text-patina">当前</span>
)}
</button>
))
)}
</div>
)}
</div>
);
}
@@ -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(
<CharacterSwitcher
currentCharacterId="char_1"
currentDisplayName="小测"
/>,
);
expect(screen.getByText("小测")).toBeInTheDocument();
});
it("点击展开下拉菜单", async () => {
const user = userEvent.setup();
render(
<CharacterSwitcher
currentCharacterId="char_1"
currentDisplayName="小测"
/>,
);
// 当前人物名可点击
await user.click(screen.getByText("小测"));
// dropdown 应该出现——因为 listCharacters 返回空列表,
// 此处只验证下拉渲染不报错
expect(screen.getByText("小测")).toBeInTheDocument();
});
});
@@ -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 (
<span
aria-hidden
className={cn(
"flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full font-serif text-[12px] font-semibold",
active ? "bg-patina text-ink" : "bg-patina-wash text-patina",
)}
>
{ch}
</span>
);
}
/**
* 对话页人物切换器:下拉列出全部人物,选中后激活并刷新页面。
* 点击外部自动收起。
*/
export function CharacterSwitcher({
currentCharacterId,
currentDisplayName,
}: {
currentCharacterId: string | undefined;
currentDisplayName: string;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [characters, setCharacters] = useState<CharacterListItem[]>([]);
const [switching, setSwitching] = useState(false);
const ref = useRef<HTMLDivElement>(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 (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-rule py-0.5 pl-0.5 pr-2.5 text-[13px] font-medium text-fg-dim transition-colors hover:border-gilt-dim hover:text-fg",
switching && "pointer-events-none opacity-60",
)}
disabled={switching}
title="切换人物"
>
<AvatarMini name={currentDisplayName} active />
<span className="max-w-[120px] truncate">{currentDisplayName}</span>
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
aria-hidden
className={cn("transition-transform", open && "rotate-180")}
>
<path
d="M4 6l4 4 4-4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-1 w-56 rounded-xl border border-border-glass bg-glass-strong p-1 shadow-xl backdrop-blur-xl">
{characters.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted">加载中…</div>
) : (
characters.map((c) => {
const isCurrent = c.id === currentCharacterId;
return (
<button
key={c.id}
type="button"
onClick={() => handleSwitch(c.id)}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[13px] transition-colors",
isCurrent
? "bg-patina-wash text-patina"
: "text-fg-dim hover:bg-fg/5 hover:text-fg",
)}
>
<AvatarMini
name={c.display_name || c.name}
active={isCurrent}
/>
<span className="flex-1 truncate">
{c.display_name || c.name}
</span>
{c.status && (
<span className="shrink-0 rounded-full bg-surface px-1.5 py-px text-[10px] text-fg-faint">
{c.status}
</span>
)}
</button>
);
})
)}
</div>
)}
</div>
);
}
@@ -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 */}
<StatusCard character={character} />
{/* 六爻:本次运行所处的爻位(可折叠,与息壤同构) */}
<div className="shrink-0 border-b border-rule px-4 py-4">
<div className="flex items-center gap-2">
<RailLabel label="六爻" sub="本次运行" />
<button
type="button"
onClick={() => setHexOpen((v) => !v)}
aria-expanded={hexOpen}
className="ml-auto inline-flex items-center gap-1 rounded-full border border-rule bg-transparent px-2 py-0.5 text-[9px] font-sans text-fg-faint transition-colors hover:border-gilt-dim hover:text-fg"
>
<span
className={cn(
"inline-block text-[7px] transition-transform duration-250",
hexOpen && "rotate-180",
)}
>
▼
</span>
{hexOpen ? "收起" : "展开"}
</button>
</div>
<div
className={cn(
"overflow-hidden transition-all duration-[400ms] ease-[cubic-bezier(0.4,0,0.2,1)]",
hexOpen ? "mt-2 max-h-[400px] opacity-100" : "max-h-0 opacity-0",
)}
>
<div className="flex flex-col gap-1">
{hexagrams.map((hexagram) => {
const isActive = hexagram.state === "active";
const isDone = hexagram.state === "done";
return (
<div
key={hexagram.key}
className="flex items-center gap-2 py-0.5"
>
<span
className={cn(
"mono w-[18px] text-right text-[10px]",
isActive
? "text-gilt"
: isDone
? "text-fg-dim"
: "text-fg-faint",
)}
>
{["初", "二", "三", "四", "五", "上"][hexagram.order - 1]}
</span>
<span
className={cn(
"flex-1 text-[12px]",
isActive
? "font-medium text-gilt"
: isDone
? "text-fg-dim"
: "text-fg-faint",
)}
>
{hexagram.key}
</span>
{/* 爻象进度:容器虚线(阴爻·未达),fill 覆盖为阳爻(已达·铜绿 / 当爻·鎏金流光) */}
<div className="h-[5px] w-16 shrink-0 overflow-hidden rounded-[2px] [background:repeating-linear-gradient(90deg,var(--rule)_0_4px,transparent_4px_8px)]">
<div
className={cn(
"h-full rounded-[2px] transition-all duration-500",
isActive &&
"w-full animate-hex-shimmer bg-[linear-gradient(90deg,var(--gilt-dim),var(--gilt),var(--gilt-dim))] bg-[length:200%_100%]",
isDone &&
"w-full bg-[linear-gradient(90deg,var(--patina-dim),var(--patina))]",
!isActive && !isDone && "w-0",
)}
/>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* 息壤:从 memory.retrieved SSE 事件推送的记忆数据 */}
<div
className={cn(
"border-b border-rule px-4 py-4",
xirangOpen ? "flex min-h-0 flex-1 flex-col" : "shrink-0",
)}
>
<div className="flex items-center gap-2">
<RailLabel label="息壤" sub="浮现" />
<button
type="button"
onClick={handleRefresh}
disabled={refreshing}
className="ml-auto inline-flex items-center gap-1 rounded-full border border-rule bg-transparent px-2 py-0.5 text-[9px] font-sans text-fg-faint transition-colors hover:border-gilt-dim hover:text-fg disabled:opacity-40"
title="刷新记忆"
>
<span
className={cn(
"inline-block text-[10px] transition-transform duration-300",
refreshing && "animate-spin",
)}
>
↻
</span>
刷新
</button>
<button
type="button"
onClick={() => setXirangOpen((v) => !v)}
aria-expanded={xirangOpen}
className="inline-flex items-center gap-1 rounded-full border border-rule bg-transparent px-2 py-0.5 text-[9px] font-sans text-fg-faint transition-colors hover:border-gilt-dim hover:text-fg"
>
<span
className={cn(
"inline-block text-[7px] transition-transform duration-250",
xirangOpen && "rotate-180",
)}
>
▼
</span>
{xirangOpen ? "收起" : "展开"}
</button>
</div>
{xirangOpen && (
<div className="mt-2 flex-1 overflow-y-auto">
{memories.length > 0 ? (
<div className="flex flex-col gap-1">
{memories.map((m) => (
<div
key={m.id}
className="rounded-[3px] border border-rule/40 bg-surface/50 px-2.5 py-2 text-[11px] leading-relaxed text-fg-dim transition-colors hover:border-rule/70"
>
{m.title && (
<div className="mb-0.5 truncate text-[11px] font-medium text-fg">
{m.title}
</div>
)}
<div className="line-clamp-3">{m.content}</div>
<span className="mt-1 block text-[10px] text-fg-faint">
置信{" "}
<span className="font-medium text-patina">
{m.confidence.toFixed(2)}
</span>
{m.source && <> · {sourceLabel(m.source)}</>}
{m.importance > 0.7 && (
<>
{" "}
· <span className="text-gilt">重要</span>
</>
)}
</span>
</div>
))}
</div>
) : (
<div className="border-l-2 border-patina bg-patina-wash px-2.5 py-2 text-[11px] leading-relaxed text-fg-dim [border-radius:0_3px_3px_0]">
发送消息后,检索到的记忆将在此浮现。
<small className="mt-1 block text-[9px] text-fg-faint">
对话结束后自动沉淀
</small>
</div>
)}
</div>
)}
</div>
</>
);
if (embedded) return content;
return (
<aside
className={cn(
"hidden min-h-0 w-[220px] shrink-0 flex-col overflow-hidden border-r border-rule bg-sidebar md:flex",
className,
)}
>
{content}
</aside>
);
}
/**
* 人物状态卡:镜背轨顶部的"被照见者"——头像鎏金环 + 名号 + 认识天数,
* 下方两格 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<number | null>(() => {
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 (
<div className="shrink-0 border-b border-rule px-[18px] pb-3 pt-[18px]">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border-[1.5px] border-patina-dim bg-patina-wash">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-patina font-serif text-base font-bold text-ink">
{avatarChar}
</div>
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-serif text-[18px] font-bold tracking-[0.02em] text-fg">
{displayName ?? "未加载人物"}
</div>
<div className="mt-px flex items-center gap-1.5 text-[12px] text-fg-dim">
<span className="h-1.5 w-1.5 shrink-0 animate-pulse-ring rounded-full bg-patina" />
<span className="truncate">
{days ? `认识你第 ${days} 日` : "初识"} · {moodLabel}
</span>
</div>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<Stat k="情绪">
<span className="text-fg">{moodLabel}</span>
<small className="text-[11px] font-normal text-fg-dim">
{sending ? "运筹" : "平稳"}
</small>
</Stat>
<Stat k="内驱">
<span className="text-fg">{driveLabel}</span>
<small className="text-[11px] font-normal text-fg-dim">
{driveHint}
</small>
</Stat>
</div>
</div>
);
}
function Stat({ k, children }: { k: string; children: React.ReactNode }) {
return (
<div className="rounded-[5px] border border-rule bg-surface px-2.5 py-2">
<div className="text-[9px] uppercase tracking-[0.12em] text-fg-faint">
{k}
</div>
<div className="mt-0.5 flex items-baseline gap-1 text-[14px] font-medium">
{children}
</div>
</div>
);
}
/** 记忆来源中文标签 */
function sourceLabel(source: string): string {
const labels: Record<string, string> = {
user_explicit: "用户明确",
conversation: "对话推断",
material_import: "资料导入",
tool_result: "工具结果",
reflection: "反思沉淀",
};
return labels[source] ?? source;
}
function RailLabel({ label, sub }: { label: string; sub: string }) {
return (
<p className="mono text-[10px] uppercase tracking-[0.2em] text-fg-faint">
{label}
<span className="ml-1 font-sans text-[10px] font-normal normal-case text-fg-faint/60">
{sub}
</span>
</p>
);
}
@@ -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(<ChatInput onSend={() => {}} />);
expect(screen.getByPlaceholderText(PLACEHOLDER)).toBeInTheDocument();
});
it("输入文本后按 Enter 发送", async () => {
const onSend = vi.fn();
const user = userEvent.setup();
render(<ChatInput onSend={onSend} />);
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(<ChatInput onSend={onSend} />);
await user.keyboard("{Enter}");
expect(onSend).not.toHaveBeenCalled();
});
it("disabled 状态下显示不同占位且不发送", async () => {
const onSend = vi.fn();
const user = userEvent.setup();
render(<ChatInput onSend={onSend} disabled />);
expect(screen.getByPlaceholderText("请先创建会话")).toBeInTheDocument();
await user.keyboard("{Enter}");
expect(onSend).not.toHaveBeenCalled();
});
it("发送后清空输入框", async () => {
const onSend = vi.fn();
const user = userEvent.setup();
render(<ChatInput onSend={onSend} />);
const input = screen.getByPlaceholderText(PLACEHOLDER);
await user.type(input, "消息");
await user.keyboard("{Enter}");
// 发送后 input 应清空
expect(input).toHaveValue("");
});
it("sending 时显示停止按钮", () => {
useChatStore.setState({ sending: true });
render(<ChatInput onSend={() => {}} onStop={() => {}} />);
expect(
screen.getByRole("button", { name: "停止生成" }),
).toBeInTheDocument();
});
it("不存在 sending 时不显示停止按钮", () => {
render(<ChatInput onSend={() => {}} onStop={() => {}} />);
expect(
screen.queryByRole("button", { name: "停止生成" }),
).not.toBeInTheDocument();
});
it("思考模式按钮可切换", async () => {
const user = userEvent.setup();
render(<ChatInput onSend={() => {}} />);
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(<ChatInput onSend={onSend} />);
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("单条消息过长,请精简后发送。");
});
});
-368
View File
@@ -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<HTMLTextAreaElement | null>(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<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
};
const sendDisabled = disabled || sending || !text.trim();
return (
<div className="border-t border-rule px-5 py-3">
{/* 铭文输入区 */}
<div className="flex items-end gap-3">
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
placeholder={
disabled
? "请先创建会话"
: sending
? "Enter 补充指引(steer)· 点「追加」排队后续任务"
: "输入消息,Enter 发送,Shift+Enter 换行"
}
disabled={disabled}
style={{ "--textarea-max-h": TEXTAREA_MAX_H } as CSSProperties}
className={cn(
"[field-sizing:content] block w-full resize-none overflow-y-auto border-none bg-transparent px-0 py-2 text-sm text-fg outline-none placeholder:text-fg-faint disabled:opacity-50",
"min-h-[2.2rem] max-h-[var(--textarea-max-h)]",
)}
/>
{/* 语音输入:点击开始/停止录音并转写(与"呈镜"同款样式)*/}
<button
type="button"
onClick={() => void voice.toggle()}
disabled={disabled}
aria-pressed={voice.status === "recording"}
aria-label={
voice.status === "recording"
? "停止录音并转写"
: voice.status === "transcribing"
? "正在转写语音"
: "语音输入"
}
title={
voice.status === "recording"
? "停止录音并转写"
: voice.status === "transcribing"
? "正在转写…"
: "语音输入"
}
className={cn(
"font-serif inline-flex shrink-0 items-center justify-center gap-1.5 rounded border px-4 py-1.5 text-sm tracking-[0.08em] transition-colors focus-visible:outline-none focus-visible:ring-2",
voice.status === "recording"
? "border-cinnabar-dim bg-transparent text-cinnabar focus-visible:ring-cinnabar/40"
: voice.status === "transcribing"
? "border-gilt-dim bg-transparent text-gilt focus-visible:ring-gilt/40"
: "border-patina-dim bg-transparent text-patina hover:border-patina hover:text-patina-bright focus-visible:ring-patina/40",
"disabled:cursor-not-allowed disabled:border-rule disabled:text-fg-faint",
)}
>
{voice.status === "transcribing" ? (
<span className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
aria-hidden
>
<rect
x="6"
y="2"
width="4"
height="7"
rx="2"
stroke="currentColor"
strokeWidth="1.2"
/>
<path
d="M3.5 7.5a4.5 4.5 0 0 0 9 0M8 12v2.5"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</svg>
)}
{voice.status === "recording"
? "停录"
: voice.status === "transcribing"
? "转写中"
: "语音"}
</button>
{sending && onStop ? (
<>
{onFollowUp && (
<button
type="button"
onClick={sendFollowUp}
disabled={!text.trim() || stopping}
title="追加后续任务(当前任务完成后执行)"
aria-label="追加后续任务"
className={cn(
"font-serif flex h-8 shrink-0 items-center justify-center rounded border border-patina-dim bg-patina/10 px-3 text-xs tracking-[0.06em] text-patina transition-all hover:bg-patina/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-patina/40",
"disabled:cursor-not-allowed disabled:border-rule disabled:bg-transparent disabled:text-fg-faint",
)}
>
追加
</button>
)}
<button
type="button"
onClick={onStop}
disabled={stopping}
title={stopping ? "正在停止…" : "停止生成"}
aria-label="停止生成"
className={cn(
"flex h-8 w-16 shrink-0 items-center justify-center rounded border border-cinnabar-dim bg-cinnabar/10 text-cinnabar text-xs transition-all hover:bg-cinnabar/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/40",
stopping && "cursor-not-allowed opacity-50",
)}
>
{stopping ? (
<span className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
"终止"
)}
</button>
</>
) : (
<button
type="button"
onClick={send}
disabled={sendDisabled}
title="呈镜(Enter)"
aria-label="呈镜发送"
className={cn(
"font-serif inline-flex shrink-0 items-center justify-center gap-1.5 rounded border border-patina-dim bg-transparent px-4 py-1.5 text-sm tracking-[0.08em] text-patina transition-colors hover:border-patina hover:text-patina-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-patina/40",
"disabled:cursor-not-allowed disabled:border-rule disabled:text-fg-faint",
)}
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
aria-hidden
>
<circle cx="8" cy="8" r="1.3" fill="currentColor" />
<circle
cx="8"
cy="8"
r="3.3"
stroke="currentColor"
strokeWidth="1.1"
opacity="0.85"
/>
<circle
cx="8"
cy="8"
r="5.6"
stroke="currentColor"
strokeWidth="1.1"
opacity="0.5"
/>
</svg>
呈镜
</button>
)}
</div>
{/* 控制开关行 */}
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setThinkingMode(!thinkingMode)}
aria-pressed={thinkingMode}
aria-label={`思考模式${thinkingMode ? "已开启" : "已关闭"}`}
title="思考模式"
className={cn(
"inline-flex items-center gap-1.5 rounded border px-2 py-1 text-[10px] font-medium transition-colors",
thinkingMode
? "border-gilt-dim bg-gilt/10 text-gilt"
: "border-rule text-fg-faint hover:text-fg-dim",
)}
>
<svg
width="11"
height="11"
viewBox="0 0 16 16"
fill="none"
aria-hidden
>
<path
d="M8 1.5A4 4 0 0 0 5.2 8.4c.3.4.5.9.5 1.4v.2a1 1 0 0 0 1 1h2.6a1 1 0 0 0 1-1v-.2c0-.5.2-1 .5-1.4A4 4 0 0 0 8 1.5Z"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
/>
<path
d="M6.5 13h3M7 14.5h2"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</svg>
深虑
</button>
{thinkingMode && (
<div
role="radiogroup"
aria-label="思考力度"
className="inline-flex items-center gap-0.5 rounded-full border border-gilt-dim p-0.5"
>
{EFFORT_OPTIONS.map((opt) => {
const active = thinkingEffort === opt.value;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={active}
title={opt.hint}
onClick={() => setThinkingEffort(opt.value)}
className={cn(
"rounded-full px-2.5 py-0.5 font-mono text-[11px] font-medium transition-colors",
active
? "bg-gilt-faint text-gilt"
: "text-fg-faint hover:text-fg-dim",
)}
>
{opt.label}
</button>
);
})}
</div>
)}
<button
type="button"
onClick={() => setWebSearchEnabled(!webSearchEnabled)}
aria-pressed={webSearchEnabled}
className={cn(
"inline-flex items-center gap-1.5 rounded border px-2 py-1 text-[10px] font-medium transition-colors",
webSearchEnabled
? "border-patina-dim bg-patina/10 text-patina"
: "border-rule text-fg-faint hover:text-fg-dim",
)}
>
<svg
width="11"
height="11"
viewBox="0 0 16 16"
fill="none"
aria-hidden
>
<circle
cx="7"
cy="7"
r="4.5"
stroke="currentColor"
strokeWidth="1.2"
/>
<path
d="M10.5 10.5l3 3"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
博闻
</button>
</div>
</div>
);
}
-831
View File
@@ -1,831 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ActiveCharacterHeader } from "./ActiveCharacterHeader";
import { ChatContextRail } from "./ChatContextRail";
import { ChatInput } from "./ChatInput";
import { FilePreviewPanel } from "./FilePreviewPanel";
import { MessageList } from "./MessageList";
import { SessionSidebar } from "./SessionSidebar";
import { MirrorHero } from "./MirrorHero";
import { PlanPanel } from "./PlanPanel";
import { TextSkeleton } from "@/components/ui/Skeleton";
import { ErrorTraceLink } from "@/components/shared/ErrorTraceLink";
import { describeError, traceIdOf } from "@/lib/utils/error";
import { cn } from "@/lib/utils/cn";
import { charactersApi, chatApi, runsApi } from "@/lib/api";
import { useRunStream } from "@/lib/hooks/useRunStream";
import { useGlobalEventStream } from "@/lib/hooks/useGlobalEventStream";
import { useChatStore } from "@/lib/stores/chat-store";
import type { MessageVersion } from "@/lib/stores/chat-store";
import type { Character, ModelGatewayStatus, RunEvent } from "@/types/api";
import type { Plan } from "@/types/plan";
/**
* 对话编排壳:聚合 Header / 消息列表 / 输入 / 洛书计划侧栏,编排会话创建、
* 消息发送与 SSE 事件回流(含思考摘要 reasoning)。初始人物由 Server Component
* 预取后注入。
*/
export function ChatShell({
initialCharacter,
gateway,
}: {
initialCharacter: Character | null;
gateway?: ModelGatewayStatus | null;
}) {
const sessionId = useChatStore((s) => s.sessionId);
const error = useChatStore((s) => s.error);
const errorTraceId = useChatStore((s) => s.errorTraceId);
const activeRunId = useChatStore((s) => s.activeRunId);
const sending = useChatStore((s) => s.sending);
const thinkingMode = useChatStore((s) => s.thinkingMode);
const thinkingEffort = useChatStore((s) => s.thinkingEffort);
const webSearchEnabled = useChatStore((s) => s.webSearchEnabled);
const modelHint = useChatStore((s) => s.modelHint);
const setModelHint = useChatStore((s) => s.setModelHint);
const setRetrievedMemories = useChatStore((s) => s.setRetrievedMemories);
const clearRetrievedMemories = useChatStore((s) => s.clearRetrievedMemories);
const addSession = useChatStore((s) => s.addSession);
const setSessions = useChatStore((s) => s.setSessions);
const addNewSessionId = useChatStore((s) => s.addNewSessionId);
const markSessionSeen = useChatStore((s) => s.markSessionSeen);
const switchSession = useChatStore((s) => s.switchSession);
const setActiveRunId = useChatStore((s) => s.setActiveRunId);
const truncateMessagesAfter = useChatStore((s) => s.truncateMessagesAfter);
const initSession = useChatStore((s) => s.initSession);
const hydrateMessages = useChatStore((s) => s.hydrateMessages);
const pushUserMessage = useChatStore((s) => s.pushUserMessage);
const beginAssistantStream = useChatStore((s) => s.beginAssistantStream);
const appendDelta = useChatStore((s) => s.appendDelta);
const finalizeAssistant = useChatStore((s) => s.finalizeAssistant);
const appendReasoningDelta = useChatStore((s) => s.appendReasoningDelta);
const finalizeReasoning = useChatStore((s) => s.finalizeReasoning);
const beginToolCall = useChatStore((s) => s.beginToolCall);
const completeToolCall = useChatStore((s) => s.completeToolCall);
const requireToolConfirmation = useChatStore(
(s) => s.requireToolConfirmation,
);
const markToolCallResolving = useChatStore((s) => s.markToolCallResolving);
const setRunStatus = useChatStore((s) => s.setRunStatus);
const setSending = useChatStore((s) => s.setSending);
const setError = useChatStore((s) => s.setError);
const sidebarCollapsed = useChatStore((s) => s.sidebarCollapsed);
const toggleSidebar = useChatStore((s) => s.toggleSidebar);
const activePreviewIndex = useChatStore((s) => s.activePreviewIndex);
const newSessionIds = useChatStore((s) => s.newSessionIds);
const [creating, setCreating] = useState(false);
const [stopping, setStopping] = useState(false);
const [initializing, setInitializing] = useState(true);
const [mobileRailOpen, setMobileRailOpen] = useState(false);
const [railView, setRailView] = useState<"sessions" | "now">("sessions");
const [characterNameMap, setCharacterNameMap] = useState<
Record<string, string>
>({});
const [activePlan, setActivePlan] = useState<Plan | null>(null);
useRunStream(
activeRunId,
{
onEvent: handleRunEvent,
onError: () => setError("运行事件流连接中断,正在自动重连…"),
},
{
thinking: thinkingMode,
effort: thinkingEffort,
characterName: initialCharacter?.name,
},
);
// 全局一炁事件流:接收 proactive.session(叩心枢主动会话)等全局广播事件
useGlobalEventStream({
onEvent(event) {
if (event.type === "proactive.session") {
// 主动会话创建后刷新会话列表,并标记该会话为"新"(前端通知红点)
const newSessionId = event.payload?.session_id as string | undefined;
if (newSessionId) {
addNewSessionId(newSessionId);
}
chatApi
.listSessions()
.then((res) => setSessions(res.items))
.catch(() => {
/* 刷新失败静默 */
});
} else if (event.type === "proactive.message") {
// 追加到已有会话的消息:若正是当前在看的会话,立刻拉取完整消息列表
const msgSessionId = event.payload?.session_id as string | undefined;
if (
msgSessionId &&
msgSessionId === useChatStore.getState().sessionId
) {
chatApi
.listMessages(msgSessionId, { page_size: 100 })
.then((res) => hydrateMessages(res.items))
.catch(() => {});
} else if (msgSessionId) {
// 非当前会话:标记红点提示
addNewSessionId(msgSessionId);
chatApi
.listSessions()
.then((res) => setSessions(res.items))
.catch(() => {});
}
}
},
});
// 会话恢复:sessionId 来自持久化存储(localStorage),Server Component 无法预知,
// 故在客户端对齐一次最新历史。此为持久会话的恢复,非「页面初始数据请求」
// (与 useRunStream 同理);fetchedSessionRef 确保同一会话只拉一次。
const fetchedSessionRef = useRef<string | null>(null);
useEffect(() => {
if (!sessionId || fetchedSessionRef.current === sessionId) return;
fetchedSessionRef.current = sessionId;
chatApi
.listMessages(sessionId, { page_size: 100 })
.then((res) => hydrateMessages(res.items))
.catch(() => {});
}, [sessionId, hydrateMessages]);
// 模型默认值:modelHint 未设置时以模型网关默认初始化;用户已持久化的偏好则保留
useEffect(() => {
if (gateway?.default_model_hint && !modelHint) {
setModelHint(gateway.default_model_hint);
}
}, [gateway, modelHint, setModelHint]);
// 构造人物名映射(character_id → display_name),供侧栏显示会话归属
useEffect(() => {
let cancelled = false;
charactersApi
.listCharacters({ page_size: 50 })
.then((res) => {
if (cancelled) return;
const map: Record<string, string> = {};
for (const c of res.items) {
map[c.id] = c.display_name || c.name;
}
setCharacterNameMap(map);
})
.catch(() => {
/* 拉取失败静默 */
});
return () => {
cancelled = true;
};
}, []);
// 会话列表:不按人物过滤,显示当前用户全部会话。
// 恢复策略:当前持久化的会话若有对话内容(title 非空)则保留;否则按 updated_at
// 降序选最近的有内容会话;都没有则任意最近会话。关键:跳过 title 为空的空会话
// (刚建、无消息),避免落到空会话上看不到历史。
useEffect(() => {
let cancelled = false;
chatApi
.listSessions()
.then((res) => {
if (cancelled) return;
setSessions(res.items);
const sorted = [...res.items].sort((a, b) =>
(b.updated_at ?? "").localeCompare(a.updated_at ?? ""),
);
const withContent = sorted.filter((s) => s.title);
const current = useChatStore.getState().sessionId;
const currentEntry = current
? res.items.find((s) => s.session_id === current)
: undefined;
// 当前会话有内容则保留;否则取最近有内容的;都没有则取最近任意会话
const target = currentEntry?.title
? currentEntry
: (withContent[0] ?? sorted[0]);
if (target) {
if (target.session_id !== current) {
switchSession(target.session_id);
}
} else if (!currentEntry) {
// 列表为空且当前会话也已失效:新建
void handleCreateSession();
}
setInitializing(false);
})
.catch(() => {
if (cancelled) return;
if (!useChatStore.getState().sessionId) {
void handleCreateSession();
}
setInitializing(false);
});
return () => {
cancelled = true;
};
// handleCreateSession 为组件内函数(每次渲染重建),仅在人物变化时重拉
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialCharacter?.id, setSessions, switchSession]);
function handleRunEvent(event: RunEvent) {
const payload = event.payload ?? {};
const runId = event.run_id;
switch (event.type) {
case "plan.created":
setActivePlan(planFromCreatedEvent(payload));
break;
case "plan.step.started":
case "plan.step.completed":
case "plan.step.failed":
setActivePlan((plan) => updatePlanStep(plan, payload));
break;
case "reasoning.delta":
appendReasoningDelta(runId, String(payload.delta ?? ""));
break;
case "reasoning.completed":
finalizeReasoning(runId);
break;
case "message.delta":
appendDelta(runId, String(payload.delta ?? ""));
break;
case "message.completed":
finalizeAssistant(
runId,
String(payload.message_id ?? `final:${runId}`),
typeof payload.content === "string" ? payload.content : undefined,
);
break;
// —— 工具调用事件(后端统一用 completed(status=error) 承载失败,不发 failed)——
case "tool.started":
beginToolCall(runId, {
tool_name: String(payload.tool_name ?? ""),
tool_call_id: String(payload.tool_call_id ?? ""),
args: typeof payload.args === "string" ? payload.args : "",
});
break;
case "tool.completed":
completeToolCall(runId, {
tool_name: String(payload.tool_name ?? ""),
tool_call_id: String(payload.tool_call_id ?? ""),
status: (["success", "error", "denied", "skipped"].includes(
payload.status as string,
)
? payload.status
: "error") as "success" | "error" | "denied" | "skipped",
result: typeof payload.result === "string" ? payload.result : "",
});
break;
case "tool.requires_confirmation":
setActivePlan((plan) =>
updatePlanStepForTool(plan, String(payload.tool_name ?? "")),
);
requireToolConfirmation(runId, {
confirmation_id: String(payload.confirmation_id ?? ""),
tool_name: String(payload.tool_name ?? ""),
tool_call_id: String(payload.tool_call_id ?? ""),
risk_level: String(payload.risk_level ?? ""),
reason: String(payload.reason ?? ""),
args: typeof payload.args === "string" ? payload.args : "",
});
break;
case "run.started":
setRunStatus("running");
break;
case "run.completed":
setRunStatus("completed");
setSending(false);
// 终态后解除订阅:后端会在 run 终态时关闭 SSE,若不清空 activeRunId,
// 存活的 EventSource 会据规范自动重连并反复触发 onError 提示。
setActiveRunId(null);
// 首轮对话后端可能已自动重命名会话,刷新侧栏以显示新标题。
chatApi
.listSessions()
.then((res) => setSessions(res.items))
.catch(() => {
/* 刷新失败静默,不影响对话主流程 */
});
break;
case "run.failed":
finalizeAssistant(runId, `failed:${runId}`);
setRunStatus("failed");
setSending(false);
setError(
typeof payload.error_message === "string"
? payload.error_message
: "运行失败,请稍后重试。",
);
clearRetrievedMemories();
setActiveRunId(null);
break;
case "memory.retrieved":
if (Array.isArray(payload.memories)) {
setRetrievedMemories(payload.memories);
}
break;
case "run.cancelled":
finalizeAssistant(runId, `cancelled:${runId}`);
setRunStatus("cancelled");
setSending(false);
clearRetrievedMemories();
setActiveRunId(null);
break;
default:
break;
}
}
async function handleCreateSession() {
setCreating(true);
setError(null);
try {
const session = await chatApi.createSession({
character_id: initialCharacter?.id,
});
initSession(session);
addSession(session);
} catch (err) {
setError(describeError(err), traceIdOf(err));
} finally {
setCreating(false);
}
}
// 会话初始化(恢复最近会话 / 新建)由上方 listSessions useEffect 统一承担:
// sessionId 无效时优先恢复最近历史会话,仅在无任何历史会话时才新建。
async function handleSend(content: string) {
if (!sessionId) return;
// 运行中发送视为 steer:在当前工具批次后注入指引,不新建消息或 Run。
if (sending && activeRunId) {
try {
await runsApi.steerRun(activeRunId, content);
} catch (err) {
setError(describeError(err), traceIdOf(err));
}
return;
}
pushUserMessage(content);
setActivePlan(null);
setSending(true);
setError(null);
const idempotencyKey = `${sessionId}:${Date.now()}`;
try {
const res = await chatApi.sendMessage(
sessionId,
{
content,
model_hint: modelHint ?? undefined,
thinking: thinkingMode,
reasoning_effort: thinkingMode ? thinkingEffort : undefined,
web_search: webSearchEnabled,
},
idempotencyKey,
);
beginAssistantStream(res.run_id);
} catch (err) {
setSending(false);
setRunStatus("failed");
setError(describeError(err), traceIdOf(err));
}
}
async function handleStop() {
if (!activeRunId) return;
const runId = activeRunId;
setStopping(true);
try {
await runsApi.cancelRun(runId);
} catch {
/* 静默:即便取消请求失败也本地中止,定格已生成内容 */
}
finalizeAssistant(runId, `stopped:${runId}`);
setRunStatus("cancelled");
setSending(false);
setActiveRunId(null);
setStopping(false);
}
async function handleFollowUp(content: string) {
if (!activeRunId) return;
// follow-up:当前任务完成后排队追加新任务(区别于 steer 修正当前方向)。
try {
await runsApi.followUpRun(activeRunId, content);
} catch (err) {
setError(describeError(err), traceIdOf(err));
}
}
async function handleSessionSelect(
sessionId: string,
sessionCharacterId: string,
) {
const currentId = useChatStore.getState().sessionId;
if (sessionId === currentId) return;
// 切换 session 时同步激活该会话所属的人物,使后端操作不受 character_id 校验阻挡
if (sessionCharacterId && sessionCharacterId !== initialCharacter?.id) {
try {
await charactersApi.activateCharacter(sessionCharacterId);
} catch {
/* 激活失败不阻塞切换 */
}
}
markSessionSeen(sessionId);
switchSession(sessionId, sessionCharacterId);
}
async function handleRegenerate() {
if (!sessionId || sending) return;
const msgs = useChatStore.getState().messages;
let lastUserIdx = -1;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === "user") {
lastUserIdx = i;
break;
}
}
if (lastUserIdx === -1) return;
// 保留旧 assistant 的版本历史,迁入新消息:重答不是丢弃旧版本,而是叠加新版
const oldAssistant = msgs
.slice(lastUserIdx + 1)
.find((m) => m.role === "assistant" && !m.streaming);
let priorVersions: MessageVersion[] | undefined;
if (oldAssistant) {
const prev = oldAssistant.versions ?? [];
priorVersions =
prev.length > 0
? prev
: oldAssistant.content
? [
{
content: oldAssistant.content,
created_at:
oldAssistant.created_at ?? new Date().toISOString(),
},
]
: undefined;
}
const content = msgs[lastUserIdx].content;
truncateMessagesAfter(lastUserIdx);
setSending(true);
setError(null);
const idempotencyKey = `${sessionId}:regen:${Date.now()}`;
try {
const res = await chatApi.sendMessage(
sessionId,
{
content,
model_hint: modelHint ?? undefined,
thinking: thinkingMode,
reasoning_effort: thinkingMode ? thinkingEffort : undefined,
web_search: webSearchEnabled,
prior_versions: priorVersions,
},
idempotencyKey,
);
beginAssistantStream(res.run_id, { priorVersions });
} catch (err) {
setSending(false);
setRunStatus("failed");
setError(describeError(err), traceIdOf(err));
}
}
/** 确认卡片回调:批准高风险工具调用(run-scoped)。成功后状态回 running 等后续 tool.completed */
async function handleApproveToolCall(
runId: string,
confirmationId: string,
toolCallId: string,
) {
try {
await runsApi.approveRunConfirmation(runId, confirmationId);
markToolCallResolving(runId, toolCallId);
} catch (err) {
// 410 confirmation_expired / 404 not_found / 503 unavailable 等统一经 describeError
setError(describeError(err), traceIdOf(err));
}
}
/** 确认卡片回调:拒绝高风险工具调用(run-scoped)。本地不动 status,等 tool.completed(status=denied) */
async function handleDenyToolCall(
runId: string,
confirmationId: string,
toolCallId: string,
) {
try {
await runsApi.denyRunConfirmation(runId, confirmationId);
markToolCallResolving(runId, toolCallId);
} catch (err) {
setError(describeError(err), traceIdOf(err));
}
}
return (
<div className="flex min-h-0 flex-1 overflow-hidden">
<section
id="chat-region"
aria-label="镜面对话"
className="relative flex min-h-0 w-full flex-1 overflow-hidden border-rule bg-ink"
>
{/* 镜背轨:铭文往来 / 此刻 两面照见,tab 切换 */}
{!sidebarCollapsed && (
<div className="relative hidden shrink-0 md:flex">
<aside className="min-h-0 w-70 flex flex-col overflow-hidden border-r border-rule bg-sidebar">
<RailTabs view={railView} onChange={setRailView} />
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{railView === "now" ? (
sessionId ? (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<ChatContextRail embedded character={initialCharacter} />
</div>
) : null
) : (
<SessionSidebar
embedded
onCreate={handleCreateSession}
creating={creating}
characterNameMap={characterNameMap}
onSessionSelect={handleSessionSelect}
newSessionIds={newSessionIds}
/>
)}
</div>
</aside>
</div>
)}
{/* 镜面:人物、对话流与铭文输入框 */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-[linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0.006)_28%,transparent)]">
<ActiveCharacterHeader
character={initialCharacter}
models={gateway?.models}
railCollapsed={sidebarCollapsed}
onToggleRail={toggleSidebar}
/>
<div className="flex shrink-0 items-center border-b border-rule px-3 py-1.5 md:hidden">
<button
type="button"
onClick={() => setMobileRailOpen(true)}
className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-[11px] text-fg-faint transition-colors hover:bg-fg/5 hover:text-fg"
>
<ChevronRightIcon />
镜背 · 往来
{newSessionIds.length > 0 && (
<span className="h-1.5 w-1.5 rounded-full bg-cinnabar" />
)}
</button>
</div>
{error && (
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-cinnabar-dim bg-cinnabar/10 px-4 py-1.5 text-xs text-cinnabar backdrop-blur-sm">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{error}</span>
{errorTraceId && <ErrorTraceLink traceId={errorTraceId} />}
</span>
<button
type="button"
onClick={() => setError(null)}
className="shrink-0 underline"
>
关闭
</button>
</div>
)}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{!sessionId ? (
creating || initializing ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6">
<TextSkeleton lines={2} className="w-64" />
</div>
) : (
<MirrorHero character={initialCharacter} />
)
) : (
<>
<MessageList
onApproveToolCall={handleApproveToolCall}
onDenyToolCall={handleDenyToolCall}
onRegenerate={handleRegenerate}
characterName={
initialCharacter?.display_name || initialCharacter?.name
}
/>
{activePlan && <PlanPanel plan={activePlan} />}
<ChatInput
onSend={handleSend}
onFollowUp={handleFollowUp}
onStop={handleStop}
stopping={stopping}
/>
</>
)}
</div>
</div>
{/* 右侧文件预览面板:独立于聊天区 */}
{activePreviewIndex >= 0 && <FilePreviewPanel />}
{/* 移动端镜背抽屉:限制在对话区域内,不覆盖全局导航 */}
{mobileRailOpen && (
<div className="absolute inset-0 z-50 overflow-hidden md:hidden">
<button
type="button"
aria-label="关闭镜背"
onClick={() => setMobileRailOpen(false)}
className="absolute inset-0 cursor-default bg-black/50 backdrop-blur-sm"
/>
<aside className="absolute inset-y-0 left-0 flex w-[min(84vw,320px)] min-h-0 flex-col overflow-hidden border-r border-rule bg-sidebar shadow-2xl">
<div className="flex shrink-0 items-center justify-between border-b border-rule px-4 py-3">
<span className="font-serif text-sm text-fg-dim">镜背</span>
<button
type="button"
onClick={() => setMobileRailOpen(false)}
className="rounded border border-rule px-2 py-1 text-[10px] text-fg-faint hover:text-fg"
>
合镜
</button>
</div>
<RailTabs view={railView} onChange={setRailView} />
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{railView === "now" ? (
sessionId ? (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<ChatContextRail embedded character={initialCharacter} />
</div>
) : null
) : (
<SessionSidebar
embedded
onCreate={handleCreateSession}
creating={creating}
characterNameMap={characterNameMap}
onSessionSelect={(selectedSessionId, characterId) => {
setMobileRailOpen(false);
void handleSessionSelect(selectedSessionId, characterId);
}}
newSessionIds={newSessionIds}
/>
)}
</div>
</aside>
</div>
)}
</section>
</div>
);
}
function planFromCreatedEvent(payload: Record<string, unknown>): Plan {
const steps = Array.isArray(payload.steps)
? payload.steps.flatMap((value) => {
if (
!isRecord(value) ||
typeof value.id !== "string" ||
typeof value.title !== "string"
) {
return [];
}
return [
{
id: value.id,
title: value.title,
description: stringValue(value.description) ?? "",
tool: stringValue(value.tool),
status: planStepStatus(value.status),
risk_level: riskLevel(value.risk_level),
},
];
})
: [];
return {
id: stringValue(payload.task_id) ?? "active-plan",
goal_id: stringValue(payload.task_id) ?? "active-plan",
steps,
assumptions: stringItems(payload.assumptions),
risks: stringItems(payload.risks),
required_tools: steps.flatMap((step) => (step.tool ? [step.tool] : [])),
success_criteria: [],
status: "running",
};
}
function updatePlanStep(
plan: Plan | null,
payload: Record<string, unknown>,
): Plan | null {
const stepId = stringValue(payload.step_id);
if (!plan || !stepId) return plan;
return {
...plan,
steps: plan.steps.map((step) =>
step.id === stepId
? {
...step,
status: planStepStatus(payload.status),
result_summary: stringValue(payload.result_summary),
}
: step,
),
};
}
function updatePlanStepForTool(
plan: Plan | null,
toolName: string,
): Plan | null {
if (!plan || !toolName) return plan;
const step = plan.steps.find(
(item) => item.tool === toolName && item.status !== "completed",
);
if (!step) return plan;
return {
...plan,
steps: plan.steps.map((item) =>
item.id === step.id ? { ...item, status: "waiting_confirmation" } : item,
),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function stringItems(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
function planStepStatus(value: unknown): Plan["steps"][number]["status"] {
return value === "running" ||
value === "waiting_confirmation" ||
value === "completed" ||
value === "failed" ||
value === "skipped"
? value
: "pending";
}
function riskLevel(value: unknown): Plan["steps"][number]["risk_level"] {
return value === "L1" || value === "L2" || value === "L3" || value === "L4"
? value
: "L0";
}
function ChevronRightIcon() {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M6 3l5 5-5 5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
/** 镜背轨双视图切换:铭文往来(会话历史)/ 此刻(人物状态·六爻·息壤)。
* 设计稿 .rail-tab 同源——mono 字标、active 鎏金、底部短下划线。 */
function RailTabs({
view,
onChange,
}: {
view: "sessions" | "now";
onChange: (view: "sessions" | "now") => void;
}) {
const tabs: { key: "sessions" | "now"; label: string }[] = [
{ key: "sessions", label: "铭文往来" },
{ key: "now", label: "此刻" },
];
return (
<div className="flex shrink-0 border-b border-rule">
{tabs.map((tab) => {
const active = view === tab.key;
return (
<button
key={tab.key}
type="button"
onClick={() => onChange(tab.key)}
aria-pressed={active}
className={cn(
"mono relative flex-1 py-2.5 text-[11px] tracking-[0.08em] transition-colors",
active ? "text-gilt" : "text-fg-faint hover:text-fg-dim",
)}
>
{tab.label}
{active && (
<span className="absolute inset-x-[24%] -bottom-px h-[1.5px] bg-gilt" />
)}
</button>
);
})}
</div>
);
}
@@ -1,364 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { readSandboxFile } from "@/lib/api/sandbox";
import { useChatStore } from "@/lib/stores/chat-store";
import { cn } from "@/lib/utils/cn";
import { MarkdownContent } from "@/components/chat/MarkdownContent";
type ViewMode = "source" | "preview";
const MIN_PANEL_WIDTH = 280;
const MAX_PANEL_FRACTION = 0.6;
/**
* 右侧独立文件预览面板,支持多文件标签页切换。
*/
export function FilePreviewPanel() {
const previewFiles = useChatStore((s) => s.previewFiles);
const activeIndex = useChatStore((s) => s.activePreviewIndex);
const switchFile = useChatStore((s) => s.switchPreviewFile);
const closePanel = useChatStore((s) => s.closePreviewPanel);
const characterId = useChatStore((s) => s.characterId);
const file = previewFiles[activeIndex] ?? null;
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [mimeType, setMimeType] = useState("");
const [sourceText, setSourceText] = useState("");
const [viewMode, setViewMode] = useState<ViewMode>("preview");
const objectUrlRef = useRef<string | null>(null);
// 拖拽:使用 ref + 直接 DOM 操作避免 re-render 风暴;仅在 mouseup 时 commit 最终宽度
const [panelWidth, setPanelWidth] = useState(420);
const [isDragging, setIsDragging] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const dragStartXRef = useRef(0);
const dragStartWidthRef = useRef(0);
const handleDragStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
dragStartXRef.current = e.clientX;
dragStartWidthRef.current = panelWidth;
setIsDragging(true);
},
[panelWidth],
);
useEffect(() => {
if (!isDragging) return;
const el = panelRef.current;
if (!el) return;
const handleMouseMove = (e: MouseEvent) => {
const delta = dragStartXRef.current - e.clientX;
const newWidth = dragStartWidthRef.current + delta;
const clamped = Math.max(
MIN_PANEL_WIDTH,
Math.min(newWidth, window.innerWidth * MAX_PANEL_FRACTION),
);
// 直接操作 DOM 跳过 React 渲染周期
el.style.width = `${clamped}px`;
};
const handleMouseUp = () => {
// 在 mouseup 时才将最终宽度同步到 state(供下次拖拽起始宽度使用)
if (el) {
const finalWidth = parseFloat(el.style.width) || panelWidth;
setPanelWidth(finalWidth);
}
setIsDragging(false);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize";
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
};
}, [isDragging, panelWidth]);
// 当前文件详情
const displayName = file?.fileName || file?.path.split("/").pop() || "";
const ext = file?.path.split(".").pop()?.toLowerCase() ?? "";
const canRender =
mimeType === "text/html" ||
mimeType.startsWith("image/") ||
mimeType === "application/pdf" ||
mimeType === "text/markdown" ||
ext === "svg" ||
ext === "md" ||
ext === "markdown";
// 切换文件时重新加载
useEffect(() => {
if (!file) return;
let cancelled = false;
(async () => {
try {
setLoading(true);
setError(null);
setMimeType("");
setSourceText("");
setViewMode("preview");
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
setObjectUrl(null);
const blob = await readSandboxFile(file.path, {
runId: file.runId,
characterId: characterId ?? undefined,
isolated: file.isolated ?? true,
});
if (cancelled) return;
const mt = blob.type || "text/plain";
setMimeType(mt);
const url = URL.createObjectURL(blob);
objectUrlRef.current = url;
setObjectUrl(url);
if (mt.startsWith("text/") || !mt || ext === "svg") {
const text = await blob.text();
if (!cancelled) setSourceText(text);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "读取文件失败");
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
};
}, [file, characterId]); // eslint-disable-line react-hooks/exhaustive-deps
// 卸载清理
useEffect(() => {
return () => {
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
};
}, []);
if (!file) return null;
return (
<div
className="relative flex shrink-0 will-change-[width]"
ref={panelRef}
style={{ width: panelWidth }}
>
{/* 拖拽手柄 */}
<div
className={cn(
"absolute left-0 top-0 z-10 h-full w-1 cursor-col-resize transition-colors",
isDragging ? "bg-patina/50" : "hover:bg-patina/30",
)}
onMouseDown={handleDragStart}
/>
<aside className="flex min-h-0 flex-1 flex-col overflow-hidden border-l border-rule bg-night-bronze/95 backdrop-blur-sm">
{/* —— 标签栏:文件列表 + 关闭 —— */}
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-rule px-3 py-2 has-[button:only-of-type]:py-1.5">
{previewFiles.map((f, i) => {
const tabExt = f.path.split(".").pop()?.toLowerCase() ?? "";
const tabName = f.fileName || f.path.split("/").pop() || f.path;
return (
<button
key={`${f.runId ?? ""}:${f.path}`}
type="button"
onClick={() => switchFile(i)}
className={cn(
"flex shrink-0 items-center gap-1 rounded px-2 py-1 text-[11px] font-mono transition-colors",
i === activeIndex
? "bg-patina/15 text-patina"
: "text-muted hover:bg-fg/5 hover:text-fg",
)}
>
<FileIcon ext={tabExt} />
<span className="max-w-[120px] truncate">{tabName}</span>
</button>
);
})}
<div className="ml-auto flex items-center">
<button
type="button"
onClick={closePanel}
className="flex h-6 w-6 items-center justify-center rounded text-muted transition-colors hover:bg-fg/5 hover:text-fg"
aria-label="关闭预览"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path
d="M4 4l8 8M12 4l-8 8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div>
</div>
{/* —— 模式切换(当前激活文件) —— */}
{canRender && !loading && objectUrl && (
<div className="flex shrink-0 items-center justify-end gap-0.5 border-b border-rule px-4 py-1.5">
<button
type="button"
onClick={() => setViewMode("source")}
className={cn(
"rounded px-1.5 py-0.5 text-[10px] transition-colors",
viewMode === "source"
? "bg-patina text-white"
: "text-muted hover:text-fg",
)}
>
{"<"}源码{">"}
</button>
<button
type="button"
onClick={() => setViewMode("preview")}
className={cn(
"rounded px-1.5 py-0.5 text-[10px] transition-colors",
viewMode === "preview"
? "bg-patina text-white"
: "text-muted hover:text-fg",
)}
>
◉ 预览
</button>
</div>
)}
{/* —— 内容区 —— */}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{loading && (
<div className="flex min-h-0 flex-1 items-center justify-center">
<div className="flex items-center gap-2 text-xs text-muted/60">
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-muted/30 border-t-patina" />
加载中…
</div>
</div>
)}
{error && (
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
<div className="text-center text-xs text-cinnabar">{error}</div>
</div>
)}
{viewMode === "source" && !loading && sourceText && (
<pre className="min-h-0 flex-1 overflow-auto p-4 font-mono text-[12px] leading-relaxed text-fg/80">
{sourceText}
</pre>
)}
{viewMode === "preview" && !loading && objectUrl && (
<>
{mimeType === "text/html" ? (
<iframe
src={objectUrl}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals"
className="min-h-0 flex-1 w-full border-0"
title={displayName}
/>
) : mimeType.startsWith("image/") || ext === "svg" ? (
<div className="flex min-h-0 flex-1 items-center justify-center bg-fg/[0.02] p-6">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={objectUrl}
alt={displayName}
className="max-h-full max-w-full rounded object-contain shadow-sm"
/>
</div>
) : mimeType === "application/pdf" ? (
<iframe
src={objectUrl}
className="min-h-0 flex-1 w-full border-0"
title={displayName}
/>
) : ext === "md" ||
ext === "markdown" ||
mimeType === "text/markdown" ? (
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<MarkdownContent content={sourceText} />
</div>
) : (
<pre className="min-h-0 flex-1 overflow-auto p-4 font-mono text-[12px] leading-relaxed text-fg/80">
{sourceText || "(不支持预览此文件类型)"}
</pre>
)}
</>
)}
{!loading && !error && !objectUrl && !sourceText && (
<div className="flex min-h-0 flex-1 items-center justify-center">
<span className="text-xs text-muted/50">(空内容)</span>
</div>
)}
</div>
</aside>
</div>
);
}
function FileIcon({ ext }: { ext: string }) {
const icon = EXT_ICONS[ext] ?? EXT_ICONS._default;
return (
<span className="shrink-0" aria-hidden>
{icon}
</span>
);
}
const EXT_ICONS: Record<string, string> = {
html: "🌐",
htm: "🌐",
svg: "🎨",
png: "🖼",
jpg: "🖼",
jpeg: "🖼",
gif: "🖼",
webp: "🖼",
pdf: "📄",
json: "📋",
yaml: "📋",
yml: "📋",
toml: "📋",
md: "📝",
markdown: "📝",
py: "🐍",
js: "📜",
ts: "📘",
tsx: "⚛️",
jsx: "⚛️",
css: "🎨",
scss: "🎨",
rs: "🦀",
go: "🔷",
sh: "💻",
bash: "💻",
txt: "📄",
csv: "📊",
xml: "📋",
_default: "📄",
};
@@ -1,34 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { MarkdownContent } from "./MarkdownContent";
describe("MarkdownContent", () => {
it("渲染纯文本", () => {
render(<MarkdownContent content="Hello" />);
expect(screen.getByText("Hello")).toBeInTheDocument();
});
it("渲染 Markdown 加粗", () => {
render(<MarkdownContent content="**加粗**" />);
expect(screen.getByText("加粗")).toBeInTheDocument();
});
it("渲染链接", () => {
render(<MarkdownContent content="[链接](https://example.com)" />);
const link = screen.getByRole("link", { name: "链接" });
expect(link).toHaveAttribute("href", "https://example.com");
expect(link).toHaveAttribute("target", "_blank");
});
it("渲染行内代码", () => {
render(<MarkdownContent content="`code`" />);
expect(screen.getByText("code")).toBeInTheDocument();
});
it("渲染列表项", () => {
render(<MarkdownContent content={"- 项1\n- 项2"} />);
expect(screen.getByText("项1")).toBeInTheDocument();
expect(screen.getByText("项2")).toBeInTheDocument();
});
});
@@ -1,268 +0,0 @@
"use client";
import {
useEffect,
isValidElement,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import rehypeHighlight from "rehype-highlight";
import "katex/dist/katex.min.css";
import { MermaidBlock } from "./MermaidBlock";
/**
* 消息 Markdown 渲染(外层 .md-body 承载 globals.css 的排版与 hljs 配色):
* - 代码块:固定深色「代码铭版」+ 鎏金描边/标签 + 一键复制(亮 / 暗主题一致)。
* - Mermaid 块(```mermaid):渲染为图表,见 MermaidBlock。
* - 数学公式:remark-math + rehype-katex,行内 $…$ / 块级 $$…$$,色随 --fg。
* - 行内代码:铜金等宽标签。
* - 图片:点击进入 Lightbox 放大查看。
* - 链接:新标签打开。
* - 不渲染原始 HTML(react-markdown 默认安全,防 XSS)。
*/
export function MarkdownContent({ content }: { content: string }) {
return (
<div className="md-body">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath, remarkInlineMathLimits]}
rehypePlugins={[
rehypeKatex,
[rehypeHighlight, { detect: true, ignoreMissing: true }],
]}
components={{ pre: PreBlock, code: CodeBlock, a: Anchor, img: Img }}
>
{content}
</ReactMarkdown>
</div>
);
}
/** pre 组件:mermaid 走图表渲染,其余为代码铭版 */
function PreBlock({ children }: ComponentPropsWithoutRef<"pre">) {
const preRef = useRef<HTMLPreElement>(null);
const [copied, setCopied] = useState(false);
const lang = extractLang(children);
if (lang === "mermaid") {
return <MermaidBlock code={extractText(children)} />;
}
const copy = async () => {
const text = preRef.current?.textContent ?? "";
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* 剪贴板不可用时静默 */
}
};
return (
<div className="my-2.5 overflow-hidden rounded-xl border border-[var(--code-edge)] bg-[var(--code-ink)] backdrop-blur-sm">
<div className="flex items-center justify-between border-b border-[var(--code-rule)] px-3 py-1.5">
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--code-label)]">
{lang || "code"}
</span>
<button
type="button"
onClick={copy}
className="font-mono text-[10px] text-[var(--code-label-dim)] transition-colors hover:text-[var(--code-label-hi)]"
>
{copied ? "已复制" : "复制"}
</button>
</div>
<pre
ref={preRef}
className="overflow-x-auto p-3 font-mono text-xs leading-relaxed"
>
{children}
</pre>
</div>
);
}
/** 行内代码 vs 块代码:块代码由 rehype-highlight 加 hljs/language- class */
function CodeBlock({
className,
children,
...props
}: ComponentPropsWithoutRef<"code">) {
const isBlock = /hljs|language-/.test(className ?? "");
if (isBlock) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
return (
<code
className="rounded bg-fg/10 px-1.5 py-0.5 font-mono text-[0.85em] text-gold"
{...props}
>
{children}
</code>
);
}
function Anchor({ href, children, ...props }: ComponentPropsWithoutRef<"a">) {
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="text-patina underline underline-offset-2"
{...props}
>
{children}
</a>
);
}
/** 图片:内联时点击进入 Lightbox 放大 */
function Img({ src, alt, ...props }: ComponentPropsWithoutRef<"img">) {
const [open, setOpen] = useState(false);
// src 在 React 19 类型里可能为 Blob;此处仅处理 string URL(markdown 图片均为 URL)。
const srcStr = typeof src === "string" ? src : "";
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="my-2 block max-w-full cursor-zoom-in rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-patina"
aria-label={`放大查看:${alt || "图片"}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={srcStr}
alt={alt}
loading="lazy"
className="max-h-[60vh] w-auto max-w-full rounded-md border border-rule"
{...props}
/>
</button>
{open && srcStr && (
<ImageLightbox
src={srcStr}
alt={alt ?? ""}
onClose={() => setOpen(false)}
/>
)}
</>
);
}
/**
* 图片放大层:点背景 / Esc 关闭,打开期间锁 body 滚动。
* 经 createPortal 挂到 document.body,避免被气泡 overflow 截断。
*/
function ImageLightbox({
src,
alt,
onClose,
}: {
src: string;
alt: string;
onClose: () => void;
}) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [onClose]);
return createPortal(
<AnimatePresence>
<motion.div
role="dialog"
aria-modal="true"
aria-label={`图片预览:${alt || "图片"}`}
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/80 p-6 backdrop-blur-md"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.img
src={src}
alt={alt}
className="max-h-full max-w-full rounded-md object-contain shadow-2xl"
initial={{ scale: 0.96 }}
animate={{ scale: 1 }}
exit={{ scale: 0.96 }}
transition={{ duration: 0.18, ease: "easeOut" }}
onClick={(e) => e.stopPropagation()}
/>
</motion.div>
</AnimatePresence>,
document.body,
);
}
/**
* 行内公式 limits 修正:\lim / \sum / \prod 等「可叠加」算子在行内 \textstyle 下,
* KaTeX 默认把上下标放在符号右侧(数学排版规范)。阅读时更期望下标落于正下方,
* 故仅对这类算子显式注入 \limits;已写 \limits / \nolimits 的不重复处理。
* 块级 $$…$$ 本就用 display style;\int 类积分依惯例保持上下标在侧,均不在此处理。
*/
const LIMITS_RE =
/\\(limsup|liminf|lim|sum|prod|coprod|bigcup|bigcap|bigsqcup|bigvee|bigwedge|bigotimes|bigoplus|bigodot|biguplus)(?![a-zA-Z])(?!\\limits)(?!\\nolimits)/g;
function remarkInlineMathLimits() {
return (tree: unknown) => {
const walk = (node: unknown): void => {
if (!node || typeof node !== "object") return;
const n = node as {
type?: string;
value?: unknown;
children?: unknown[];
};
if (n.type === "inlineMath" && typeof n.value === "string") {
n.value = n.value.replace(LIMITS_RE, "\\$1\\limits");
}
if (Array.isArray(n.children)) n.children.forEach(walk);
};
walk(tree);
};
}
/** 从 pre 的子 code 元素提取语言标识 */
function extractLang(children: ReactNode): string {
const child = Array.isArray(children) ? children[0] : children;
if (isValidElement(child)) {
const cls = (child.props as { className?: string }).className ?? "";
const m = /language-([\w-]+)/.exec(cls);
if (m) return m[1];
}
return "";
}
/** 从 pre 的子 code 元素提取源码文本(供 mermaid 渲染) */
function extractText(children: ReactNode): string {
const child = Array.isArray(children) ? children[0] : children;
if (isValidElement(child)) {
const inner = (child.props as { children?: ReactNode }).children;
if (typeof inner === "string") return inner;
if (Array.isArray(inner)) {
return inner.map((n) => (typeof n === "string" ? n : "")).join("");
}
}
return typeof child === "string" ? child : "";
}
@@ -1,423 +0,0 @@
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { describeError } from "@/lib/utils/error";
import { resolveTheme, useSettingsStore } from "@/lib/stores/settings-store";
/**
* Mermaid 图表渲染。
* - 动态 import mermaid,避免首屏加载(仅出现图表时才拉取)。
* - 防抖 300ms,避免流式逐字时频繁重渲染。
* - 渲染失败时显示错误并支持查看源码,便于调试。
* - 图表源码来自模型输出,securityLevel 设为 loose 以支持完整渲染。
* - 内联图表可点击放大,进入 MermaidZoom 全屏交互视图。
*/
export function MermaidBlock({ code }: { code: string }) {
const [svg, setSvg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showSource, setShowSource] = useState(false);
const [zoomOpen, setZoomOpen] = useState(false);
// 图表配色跟随应用主题:亮色用 default + 琉璃镜矿物色(背景透明让镜面气泡透出),
// 暗色沿用内置 dark(已为深底调好,不再覆盖 themeVariables)。
const theme = useSettingsStore((s) => s.theme);
const isDark = resolveTheme(theme) === "dark";
useEffect(() => {
let cancelled = false;
const timer = setTimeout(async () => {
try {
const { default: mermaid } = await import("mermaid");
mermaid.initialize({
startOnLoad: false,
theme: isDark ? "dark" : "default",
securityLevel: "loose",
fontFamily: "inherit",
themeVariables: isDark
? {}
: {
background: "transparent",
primaryColor: "#f4f1e8",
primaryTextColor: "#1c2420",
primaryBorderColor: "#8a6d28",
lineColor: "#3f7d68",
secondaryColor: "#ece8dc",
tertiaryColor: "#e3dfd0",
},
});
const id = `mmd-${Math.random().toString(36).slice(2, 10)}`;
const result = await mermaid.render(id, code);
if (!cancelled) {
setSvg(result.svg);
setError(null);
}
} catch (e) {
if (!cancelled) setError(describeError(e));
}
}, 300);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [code, isDark]);
return (
<div className="glass my-2.5 overflow-hidden rounded-xl">
<div className="flex items-center justify-between border-b border-border-glass px-3 py-1.5">
<span className="font-mono text-[10px] uppercase tracking-wider text-muted">
mermaid
</span>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setZoomOpen(true)}
disabled={!svg}
className="font-mono text-[10px] text-muted transition-colors hover:text-fg disabled:cursor-not-allowed disabled:opacity-40"
>
放大
</button>
<button
type="button"
onClick={() => setShowSource((s) => !s)}
className="font-mono text-[10px] text-muted transition-colors hover:text-fg"
>
{showSource ? "隐藏源码" : "查看源码"}
</button>
</div>
</div>
<div className="p-3">
{error ? (
<p className="text-xs text-cinnabar">图表渲染失败:{error}</p>
) : svg ? (
<div
role="button"
tabIndex={0}
onClick={() => setZoomOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setZoomOpen(true);
}
}}
title="点击放大查看"
aria-label="放大查看 mermaid 图表"
className="cursor-zoom-in overflow-x-auto transition-opacity hover:opacity-90 [&_svg]:h-auto [&_svg]:max-w-full [&_svg]:bg-transparent [&_svg]:transition-transform"
>
<div dangerouslySetInnerHTML={{ __html: svg }} />
</div>
) : (
<p className="text-xs text-muted">渲染中…</p>
)}
{showSource && (
<pre className="mt-2 overflow-x-auto rounded-lg bg-fg/5 p-2 font-mono text-xs text-fg/80">
{code}
</pre>
)}
</div>
{zoomOpen && svg && (
<MermaidZoom svg={svg} code={code} onClose={() => setZoomOpen(false)} />
)}
</div>
);
}
const MIN_SCALE = 0.25;
const MAX_SCALE = 30;
const ZOOM_STEP = 1.2;
/** 滚轮指数缩放灵敏度:factor = 2^(-deltaY / WHEEL_DIVISOR),值越大越平缓 */
const WHEEL_DIVISOR = 300;
/** 打开放大视图的初始倍率(也作为「重置」的目标) */
const INITIAL_SCALE = 5;
const clamp = (v: number, lo: number, hi: number) =>
Math.min(hi, Math.max(lo, v));
interface View {
scale: number;
tx: number;
ty: number;
}
/**
* Mermaid 全屏放大视图。
* - 滚轮以光标为锚点缩放(0.25× ~ 30×),拖拽平移,双击重置。
* - ESC / 点背景关闭,打开期间锁定 body 滚动。
* - 通过 createPortal 挂到 document.body,避免被祖先 overflow / transform 截断。
*/
function MermaidZoom({
svg,
code,
onClose,
}: {
svg: string;
code: string;
onClose: () => void;
}) {
const canvasRef = useRef<HTMLDivElement>(null);
const svgWrapRef = useRef<HTMLDivElement>(null);
// svg 原始布局尺寸(scale=1 基准),mount 后测一次即可
const svgSize = useRef({ W: 0, H: 0 });
const [view, setView] = useState<View>({
scale: INITIAL_SCALE,
tx: 0,
ty: 0,
});
const [showSource, setShowSource] = useState(false);
const [dragging, setDragging] = useState(false);
const drag = useRef<{ x: number; y: number } | null>(null);
// ESC 关闭 + 锁 body 滚动
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [onClose]);
// svg 基准尺寸首测:mount 后 svg 已按 INITIAL_SCALE 渲染,
// getBoundingClientRect 受 transform 影响,故除回 INITIAL_SCALE 还原 scale=1 基准。
useEffect(() => {
const svgEl = svgWrapRef.current?.querySelector("svg");
if (!svgEl) return;
const rect = svgEl.getBoundingClientRect();
svgSize.current = {
W: rect.width / INITIAL_SCALE,
H: rect.height / INITIAL_SCALE,
};
}, []);
// 画布视口与 svg 基准尺寸
const getBounds = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
return {
cw: rect.width,
ch: rect.height,
W: svgSize.current.W,
H: svgSize.current.H,
};
}, []);
// 统一缩放:给定 anchor 时以光标为锚点(pin-to-cursor),否则围绕图片中心;
// 随后 clamp 平移量 —— 图片比视口大时必须覆盖视口、比视口小时强制居中,杜绝整体漂出。
const applyZoom = useCallback(
(factor: number, anchor?: { mx: number; my: number }) => {
const b = getBounds();
setView((v) => {
const ns = clamp(v.scale * factor, MIN_SCALE, MAX_SCALE);
if (ns === v.scale) return v;
let ntx = v.tx;
let nty = v.ty;
if (anchor) {
const ix = (anchor.mx - v.tx) / v.scale;
const iy = (anchor.my - v.ty) / v.scale;
ntx = anchor.mx - ix * ns;
nty = anchor.my - iy * ns;
}
if (b) {
const maxTx = Math.max(0, (b.W * ns - b.cw) / 2);
const maxTy = Math.max(0, (b.H * ns - b.ch) / 2);
ntx = clamp(ntx, -maxTx, maxTx);
nty = clamp(nty, -maxTy, maxTy);
}
return { scale: ns, tx: ntx, ty: nty };
});
},
[getBounds],
);
// 滚轮缩放:以光标为锚点放大(鼠标下的节点保持不动),由 applyZoom 负责防漂移
const onWheel = useCallback(
(e: WheelEvent) => {
e.preventDefault();
const rect = canvasRef.current?.getBoundingClientRect();
const anchor = rect
? {
mx: e.clientX - rect.left - rect.width / 2,
my: e.clientY - rect.top - rect.height / 2,
}
: undefined;
// 指数缩放:factor 随 deltaY 变化 —— 触控板轻划(小 delta)平滑微调,
// 鼠标一格(deltaY≈±100)约 ±1.26×,避免轻划一下就跳到极值
applyZoom(2 ** (-e.deltaY / WHEEL_DIVISOR), anchor);
},
[applyZoom],
);
useEffect(() => {
const el = canvasRef.current;
if (!el) return;
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [onWheel]);
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
drag.current = { x: e.clientX, y: e.clientY };
setDragging(true);
e.currentTarget.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!drag.current) return;
const dx = e.clientX - drag.current.x;
const dy = e.clientY - drag.current.y;
drag.current = { x: e.clientX, y: e.clientY };
const b = getBounds();
setView((v) => {
let ntx = v.tx + dx;
let nty = v.ty + dy;
if (b) {
const maxTx = Math.max(0, (b.W * v.scale - b.cw) / 2);
const maxTy = Math.max(0, (b.H * v.scale - b.ch) / 2);
ntx = clamp(ntx, -maxTx, maxTx);
nty = clamp(nty, -maxTy, maxTy);
}
return { ...v, tx: ntx, ty: nty };
});
};
const endDrag = () => {
drag.current = null;
setDragging(false);
};
const zoomBy = (factor: number) => applyZoom(factor);
const reset = () => setView({ scale: INITIAL_SCALE, tx: 0, ty: 0 });
return createPortal(
<AnimatePresence>
<motion.div
className="fixed inset-0 z-120 flex flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div
className="absolute inset-0 bg-black/80 backdrop-blur-md"
onClick={onClose}
/>
{/* 工具栏 */}
<div className="relative z-10 flex items-center justify-between gap-3 px-4 py-3">
<span className="font-mono text-[10px] uppercase tracking-wider text-white/50">
mermaid · 放大查看
</span>
<div className="flex items-center gap-1.5">
<ZoomBtn
label="缩小"
onClick={() => zoomBy(1 / ZOOM_STEP)}
disabled={view.scale <= MIN_SCALE}
>
−
</ZoomBtn>
<span className="w-12 text-center font-mono text-[10px] text-white/50">
{Math.round(view.scale * 100)}%
</span>
<ZoomBtn
label="放大"
onClick={() => zoomBy(ZOOM_STEP)}
disabled={view.scale >= MAX_SCALE}
>
+
</ZoomBtn>
<ZoomBtn label="重置视图" onClick={reset}>
重置
</ZoomBtn>
<ZoomBtn label="切换源码" onClick={() => setShowSource((s) => !s)}>
{showSource ? "隐藏源码" : "源码"}
</ZoomBtn>
<ZoomBtn label="关闭" onClick={onClose}>
✕
</ZoomBtn>
</div>
</div>
{/* 画布 */}
<div
ref={canvasRef}
className="relative z-10 flex-1 touch-none overflow-hidden"
style={{ cursor: dragging ? "grabbing" : "grab" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onDoubleClick={reset}
>
<div
className="flex min-h-full items-center justify-center"
style={{
transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.scale})`,
transition: dragging ? "none" : "transform 0.12s ease-out",
}}
>
<div
ref={svgWrapRef}
className="max-w-[88vw] [&_svg]:h-auto [&_svg]:max-w-[88vw]"
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
</div>
{showSource && (
<div className="relative z-10 max-h-[28vh] overflow-auto border-t border-white/10 bg-black/40 p-3">
<pre className="font-mono text-xs leading-relaxed text-white/70">
{code}
</pre>
</div>
)}
<div className="relative z-10 px-4 py-2 text-center font-mono text-[10px] text-white/35">
滚轮缩放 · 拖拽平移 · 双击重置 · Esc 关闭
</div>
</motion.div>
</AnimatePresence>,
document.body,
);
}
function ZoomBtn({
label,
onClick,
disabled,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
children: ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
disabled={disabled}
className="glass-strong min-w-7 rounded-md px-2 py-1 font-mono text-xs text-white/80 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-30"
>
{children}
</button>
);
}
@@ -1,137 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
// 浅层 mock 子组件——只测 MessageBubble 本身的渲染逻辑
vi.mock("./MarkdownContent", () => ({
MarkdownContent: ({ content }: { content: string }) => (
<div data-testid="markdown">{content}</div>
),
}));
vi.mock("./ThoughtBlock", () => ({
ThoughtBlock: () => <div data-testid="thought-block" />,
}));
vi.mock("./ToolCallBlock", () => ({
ToolCallBlock: () => <div data-testid="tool-call-block" />,
}));
import { MessageBubble } from "./MessageBubble";
describe("MessageBubble", () => {
it("用户消息靠右渲染文本", () => {
render(
<MessageBubble
message={{
message_id: "m1",
session_id: "s1",
role: "user",
modality: "text",
content: "你好",
}}
/>,
);
expect(screen.getByText("你好")).toBeInTheDocument();
});
it("空用户消息显示占位文案", () => {
render(
<MessageBubble
message={{
message_id: "m2",
session_id: "s1",
role: "user",
modality: "text",
content: "",
}}
/>,
);
expect(screen.getByText("(空消息)")).toBeInTheDocument();
});
it("助手消息渲染 Markdown 内容", () => {
render(
<MessageBubble
message={{
message_id: "m3",
session_id: "s1",
role: "assistant",
modality: "text",
content: "回复内容",
}}
/>,
);
expect(screen.getByTestId("markdown")).toHaveTextContent("回复内容");
});
it("助手消息带思考块时渲染", () => {
render(
<MessageBubble
message={{
message_id: "m4",
session_id: "s1",
role: "assistant",
modality: "text",
content: "回复",
reasoning: "正在思考…",
reasoningStreaming: false,
}}
/>,
);
expect(screen.getByTestId("thought-block")).toBeInTheDocument();
});
it("助手消息带工具调用时渲染 tool call 块", () => {
render(
<MessageBubble
message={{
message_id: "m5",
session_id: "s1",
role: "assistant",
modality: "text",
content: "回复",
toolCalls: [
{
toolName: "web_search",
toolCallId: "tc_1",
status: "success",
result: "ok",
},
],
}}
/>,
);
expect(screen.getByTestId("tool-call-block")).toBeInTheDocument();
});
it("system 角色居中显示", () => {
render(
<MessageBubble
message={{
message_id: "m6",
session_id: "s1",
role: "system",
modality: "text",
content: "系统消息",
}}
/>,
);
expect(screen.getByText(/系统消息/)).toBeInTheDocument();
});
it("最后一条助手消息且 canRegenerate 时显示重答按钮", () => {
render(
<MessageBubble
message={{
message_id: "m7",
session_id: "s1",
role: "assistant",
modality: "text",
content: "回复",
}}
isLastAssistant
canRegenerate
onRegenerate={() => {}}
/>,
);
expect(screen.getByRole("button", { name: "重答" })).toBeInTheDocument();
});
});
@@ -1,458 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils/cn";
import { MESSAGE_ROLE_LABEL } from "@/lib/constants/labels";
import { synthesizeSpeech } from "@/lib/api/audio";
import { useChatStore, type ChatMessageDraft } from "@/lib/stores/chat-store";
import { MarkdownContent } from "./MarkdownContent";
import { ThoughtBlock } from "./ThoughtBlock";
import { ToolCallBlock } from "./ToolCallBlock";
import { VersionPanel } from "./VersionPanel";
import type { ToolConfirmationHandler } from "./MessageList";
/** 正文超过此高度(px)才出现「展开/收起」 */
const COLLAPSE_THRESHOLD = 260;
/**
* 铜镜鉴心 v2 消息气泡:
* - Assistant → 镜面浮出(mirror-face 渐变 + gilt-dim 描边 + 内阴影)
* - User → 纸笺(无背景,右缘 gilt 发丝线,右对齐)
* - Speaker mono 眉标在上方,时间戳在眉标行右侧
* - 操作按钮(朗读 / 重答)位于气泡内底部,hover / focus 时浮现
*/
export function MessageBubble({
message,
isLastAssistant,
canRegenerate,
onApproveToolCall,
onDenyToolCall,
onRegenerate,
characterName,
}: {
message: ChatMessageDraft;
isLastAssistant?: boolean;
canRegenerate?: boolean;
onApproveToolCall?: ToolConfirmationHandler;
onDenyToolCall?: ToolConfirmationHandler;
onRegenerate?: () => void;
/** 助手说话人名称(默认回退「助手」) */
characterName?: string;
}) {
const [ttsState, setTtsState] = useState<"idle" | "loading" | "playing">(
"idle",
);
const [vpOpen, setVpOpen] = useState(false);
const browseVersion = useChatStore((s) => s.browseVersion);
const applyVersion = useChatStore((s) => s.useVersion);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, []);
const handleTts = useCallback(async () => {
if (!message.content) return;
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
setTtsState("idle");
return;
}
if (ttsState === "loading") return;
setTtsState("loading");
try {
const blob = await synthesizeSpeech({
text: message.content,
voice: "default",
format: "wav",
});
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audioRef.current = audio;
audio.onended = () => {
URL.revokeObjectURL(url);
audioRef.current = null;
setTtsState("idle");
};
audio.onerror = () => {
URL.revokeObjectURL(url);
audioRef.current = null;
setTtsState("idle");
};
await audio.play();
setTtsState("playing");
} catch {
setTtsState("idle");
}
}, [ttsState, message.content]);
if (message.role === "system" || message.role === "tool") {
return (
<div className="flex justify-center">
<span className="rounded border border-rule bg-surface px-2 py-1 text-xs text-muted">
{MESSAGE_ROLE_LABEL[message.role]}:{message.content}
</span>
</div>
);
}
const isUser = message.role === "user";
const hasReasoning =
!isUser && message.reasoning !== undefined && message.reasoning !== null;
const hasToolCalls = !isUser && !!message.toolCalls?.length;
const showRegenerate =
canRegenerate && isLastAssistant && !!onRegenerate && !message.streaming;
const speakerName = isUser ? "你" : characterName || "助手";
const timeLabel = message.created_at
? new Date(message.created_at).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})
: null;
const showActions = !isUser && !!message.content;
return (
<div className={cn("flex flex-col gap-1", isUser && "items-end")}>
{/* Speaker 眉标 */}
<div
className={cn("flex items-center gap-2", isUser && "flex-row-reverse")}
>
<span
className={cn(
"mono text-[10px] tracking-[0.18em] uppercase",
isUser ? "text-gilt" : "text-patina",
)}
>
{speakerName}
</span>
{timeLabel && (
<span className="mono text-[9px] text-fg-faint">{timeLabel}</span>
)}
</div>
{/* 消息体 */}
<div
className={cn(
"group relative",
isUser ? "w-fit max-w-[75%] min-w-[20%]" : "w-full",
)}
>
{isUser ? (
/* User:纸笺 — 与 AI 镜面对偶,透明底 + 右缘鎏金脊(镜缘),
* 设计稿说「纸笺无背景」,用透明底 + 右缘 2px 细脊替代实色卡片。
* 圆角不对称:左圆右直(方向感) */
<div className="inline-block max-w-full rounded-l-lg border border-rule border-r-2 border-r-[var(--gilt-dim)] bg-transparent py-[11px] pl-[18px] pr-4 text-right [box-shadow:inset_0_1px_0_rgba(255,255,255,0.025)]">
<span className="whitespace-pre-wrap break-words text-[15px] leading-[1.7] text-fg [word-break:break-word]">
{message.content || (message.streaming ? "" : "(空消息)")}
</span>
</div>
) : (
/* Assistant:镜面浮出 — 静镜(已完成)/ 活镜(流式中光泽流转)
* mirror-face: 抛光铜镜(渐变 + gilt-dim 描边 + 内阴影)
* mirror-sweep: 光泽漫反射(静态 opacity 0.42,is-live 时流转)
* mirror-grain: 铜面颗粒(feTurbulence 噪点)
* 圆角不对称:右圆左直(与用户对偶) */
<div
className={cn(
"mirror-face mirror-grain mirror-sweep rounded-r-lg",
message.streaming && "is-live",
"px-[22px] py-[18px]",
)}
>
<div className="relative z-[1]">
{hasReasoning && (
<ThoughtBlock
reasoning={message.reasoning ?? ""}
streaming={message.reasoningStreaming}
/>
)}
{hasToolCalls &&
message.toolCalls?.map((tc) => (
<ToolCallBlock
key={tc.toolCallId}
toolCall={tc}
runId={message.run_id}
onApprove={onApproveToolCall}
onDeny={onDenyToolCall}
/>
))}
<CollapsibleMarkdown
content={message.content}
streaming={message.streaming}
/>
{/* 操作栏:气泡内底部,hover/focus 浮现(语义始终可达) */}
{showActions && (
<div
className={cn(
"mt-2.5 flex items-center gap-0.5 border-t border-rule pt-1.5 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100",
message.streaming && "opacity-100",
)}
>
<button
type="button"
onClick={handleTts}
title={
ttsState === "loading"
? "合成中…"
: ttsState === "playing"
? "点击停止"
: "朗读回复"
}
aria-label="朗读"
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] text-fg-faint transition-colors hover:bg-fg/5 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-patina/40 disabled:opacity-50"
disabled={ttsState === "loading"}
>
{ttsState === "loading" ? (
<TtsLoadingIcon />
) : (
<SpeakerIcon active={ttsState === "playing"} />
)}
朗读
</button>
{!!message.versions && message.versions.length > 1 && (
<button
type="button"
onClick={() => setVpOpen((v) => !v)}
title={vpOpen ? "收起版本面板" : "浏览版本历史"}
className={cn(
"inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-patina/40",
vpOpen
? "bg-patina-wash text-patina"
: "text-fg-faint hover:bg-fg/5 hover:text-fg",
)}
>
<HistoryIcon />
版本
</button>
)}
{showRegenerate && (
<button
type="button"
onClick={() => onRegenerate?.()}
title="重新生成回答"
className="inline-flex items-center gap-1 rounded px-2 py-1 text-[11px] text-fg-faint transition-colors hover:bg-fg/5 hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-patina/40"
>
<RegenerateIcon />
重答
</button>
)}
</div>
)}
{/* 版本历史面板:重答留存的多版本浏览(有 >1 个版本时才展示,单版本无历史可言) */}
{vpOpen && !!message.versions && message.versions.length > 1 && (
<VersionPanel
versions={message.versions}
activeIdx={message.activeVersionIdx ?? 0}
messageId={message.message_id}
onBrowse={(idx, content) => {
browseVersion(message.message_id, idx, content);
}}
onUseVersion={(mid, idx) => {
applyVersion(mid, idx);
}}
/>
)}
</div>
</div>
)}
</div>
</div>
);
}
/** 助手正文:流式完成后,内容超过阈值可手动折叠 */
function CollapsibleMarkdown({
content,
streaming,
}: {
content: string;
streaming?: boolean;
}) {
const [expanded, setExpanded] = useState(true);
const [collapsible, setCollapsible] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => {
if (streaming) {
setCollapsible(false);
return;
}
setCollapsible(el.scrollHeight > COLLAPSE_THRESHOLD);
};
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [streaming]);
const collapsed = collapsible && !expanded;
return (
<div className="relative">
<div
ref={ref}
className={cn(
"relative break-words font-serif leading-[1.75]",
collapsed && "max-h-[260px] overflow-hidden",
)}
>
{content ? (
<MarkdownContent content={content} />
) : streaming ? null : (
"(空消息)"
)}
{streaming && (
<span className="ml-0.5 inline-block h-3.5 w-1.5 animate-pulse bg-current align-middle" />
)}
</div>
{collapsed && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-12 rounded-b-2xl bg-gradient-to-t from-[var(--glass)] to-transparent" />
)}
{collapsible && (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="mt-1 text-xs text-patina transition-opacity hover:opacity-80"
>
{expanded ? "收起" : "展开全文"}
</button>
)}
</div>
);
}
function RegenerateIcon() {
return (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2v3.5H10"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function SpeakerIcon({ active }: { active?: boolean }) {
return (
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
aria-hidden
className={active ? "text-patina" : ""}
>
<path
d="M2 5.5v5h2.5l4 3V2.5l-4 3H2Z"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
{active ? (
<>
<path
d="M11 5a4 4 0 0 1 0 6"
stroke="currentColor"
strokeWidth="1.1"
strokeLinecap="round"
/>
<path
d="M12.5 3a7 7 0 0 1 0 10"
stroke="currentColor"
strokeWidth="1.1"
strokeLinecap="round"
/>
</>
) : (
<>
<path
d="M11 6.5a2.5 2.5 0 0 1 0 3"
stroke="currentColor"
strokeWidth="1.1"
strokeLinecap="round"
/>
<path
d="M12.5 5a5 5 0 0 1 0 6"
stroke="currentColor"
strokeWidth="1.1"
strokeLinecap="round"
opacity="0.5"
/>
</>
)}
</svg>
);
}
function TtsLoadingIcon() {
return (
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
aria-hidden
className="animate-spin"
>
<circle
cx="8"
cy="8"
r="6"
stroke="currentColor"
strokeWidth="1.3"
opacity="0.3"
/>
<path
d="M8 2a6 6 0 0 1 6 6"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
);
}
/** 版本历史图标 */
function HistoryIcon() {
return (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
<circle cx="8" cy="8" r="5.5" stroke="currentColor" strokeWidth="1.3" />
<path
d="M8 5v3.5L10 10"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2.5 2.5l2 2"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
<path
d="M2 8H.5"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
);
}
@@ -1,91 +0,0 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
// Mock MessageBubble to isolate MessageList from its dependencies
vi.mock("./MessageBubble", () => ({
MessageBubble: ({
message,
isLastAssistant,
canRegenerate,
}: {
message: { role: string; content: string };
isLastAssistant: boolean;
canRegenerate: boolean;
}) => (
<div
data-testid="message-bubble"
data-role={message.role}
data-last={isLastAssistant}
data-canregen={canRegenerate}
>
{message.content || `(${message.role})`}
</div>
),
}));
import type { ChatMessageDraft } from "@/lib/stores/chat-store";
import { useChatStore } from "@/lib/stores/chat-store";
import { MessageList } from "./MessageList";
function makeMsg(overrides: Partial<ChatMessageDraft>): ChatMessageDraft {
return {
message_id: "m1",
session_id: "s1",
role: "user",
modality: "text",
content: "测试",
...overrides,
} as ChatMessageDraft;
}
describe("MessageList", () => {
afterEach(() => {
useChatStore.setState({ messages: [], sending: false });
});
it("无消息时渲染空状态", () => {
render(<MessageList />);
expect(screen.getByText("镜面空明")).toBeInTheDocument();
});
it("有消息时渲染气泡", () => {
useChatStore.setState({
messages: [makeMsg({ message_id: "m1", content: "你好" })],
});
render(<MessageList />);
expect(screen.getByText("你好")).toBeInTheDocument();
});
it("仅最后一条 assistant 标记 canRegenerate", () => {
useChatStore.setState({
sending: false,
messages: [
makeMsg({ message_id: "m1", role: "user", content: "你好" }),
makeMsg({ message_id: "m2", role: "assistant", content: "回复1" }),
makeMsg({ message_id: "m3", role: "user", content: "再问" }),
makeMsg({ message_id: "m4", role: "assistant", content: "回复2" }),
],
});
render(<MessageList />);
const bubbles = screen.getAllByTestId("message-bubble");
// assistant 索引 1 和 3,只有索引 3 是最后一条
expect(bubbles[1]).toHaveAttribute("data-last", "false");
expect(bubbles[1]).toHaveAttribute("data-canregen", "false");
expect(bubbles[3]).toHaveAttribute("data-last", "true");
expect(bubbles[3]).toHaveAttribute("data-canregen", "true");
});
it("发送中时不标记 canRegenerate", () => {
useChatStore.setState({
sending: true,
messages: [
makeMsg({ message_id: "m1", role: "user", content: "你好" }),
makeMsg({ message_id: "m2", role: "assistant", content: "回复" }),
],
});
render(<MessageList />);
const bubbles = screen.getAllByTestId("message-bubble");
expect(bubbles[1]).toHaveAttribute("data-last", "true");
expect(bubbles[1]).toHaveAttribute("data-canregen", "false");
});
});
@@ -1,85 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
import { EmptyState } from "@/components/shared/StateViews";
import { MessageBubble } from "./MessageBubble";
import { useChatStore } from "@/lib/stores/chat-store";
/** 工具确认回调签名:传 run_id / confirmation_id / tool_call_id 供端点调用 */
export type ToolConfirmationHandler = (
runId: string,
confirmationId: string,
toolCallId: string,
) => void;
/**
* 消息列表:自管理滚动到底。
*
* 计划 todo(SessionPlanCard)暂不挂载——当前阶段(implementation-status.md §8)不实现
* 自动长期任务规划,AI 单次回复内的推演已由回复气泡的「思考过程」承担。待后端
* plan.* 运行事件落地(planner-design.md §5)后,再于「AI 实际产出计划」时挂载,
* 无计划则不渲染(而非用 mock 常驻显示)。
*/
export function MessageList({
onApproveToolCall,
onDenyToolCall,
onRegenerate,
characterName,
}: {
onApproveToolCall?: ToolConfirmationHandler;
onDenyToolCall?: ToolConfirmationHandler;
onRegenerate?: () => void;
characterName?: string;
} = {}) {
const messages = useChatStore((s) => s.messages);
const sending = useChatStore((s) => s.sending);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// 空会话:不展示计划 todo,仅空状态引导
if (messages.length === 0) {
return (
<div className="flex flex-1 items-center justify-center p-6">
<EmptyState
title="镜面空明"
description="发送一条文本消息,开启与当前人物的对话。"
/>
</div>
);
}
// 找到最后一条 assistant 消息的索引:用于在该气泡下显示「重新回答」按钮。
// 发送中(sending)时正在生成新的助手消息,此时不显示该按钮——由 MessageBubble
// 内部根据 isLastAssistant && !sending 综合判定。
let lastAssistantIdx = -1;
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "assistant") {
lastAssistantIdx = i;
break;
}
}
return (
<div className="flex-1 overflow-y-auto px-4 py-6 sm:px-6">
<div className="space-y-5">
{messages.map((message, idx) => (
<MessageBubble
key={message.message_id}
message={message}
isLastAssistant={idx === lastAssistantIdx}
canRegenerate={idx === lastAssistantIdx && !sending}
onApproveToolCall={onApproveToolCall}
onDenyToolCall={onDenyToolCall}
onRegenerate={onRegenerate}
characterName={characterName}
/>
))}
<div ref={bottomRef} />
</div>
</div>
);
}
-303
View File
@@ -1,303 +0,0 @@
"use client";
import type { Character } from "@/types/api";
/**
* 铜镜鉴心 Hero —— 数字生命体征(设计稿 copper-mirror-redesign-v2.html §hero)。
*
* 当无活跃会话时,作为空状态展示:九枢星盘(活的仪器)+ 右侧人物内在状态。
* 数据取自当前激活人物及其健康度。
*/
export function MirrorHero({ character }: { character: Character | null }) {
const characterName = character?.display_name ?? character?.name ?? "青梧";
const subtitle = character?.profile?.description ?? "认识你第 41 日";
// 三魂数据(设计稿 .souls):胎光(元识)/ 爽灵(智识)/ 幽精(情识)
const souls = [
{ key: "tai", label: "胎光", sub: "元识", value: "守一" },
{
key: "shuang",
label: "爽灵",
sub: "智识",
value: "正在决定如何回应你",
hint: "定策",
},
{ key: "you", label: "幽精", sub: "情识", value: "沉静,念你" },
{ key: "neiqu", label: "内驱", sub: "好奇", value: "想弄清你的偏好" },
];
return (
<div
className="flex min-h-0 flex-1 flex-col justify-center"
style={{
padding: "clamp(20px, 4vw, 48px) clamp(16px, 4vw, 48px)",
maxWidth: 1180,
margin: "0 auto",
width: "100%",
}}
>
{/* Hero 网格:星盘 + 体征(设计稿 .hero) */}
<div className="grid items-center gap-6 pb-8 md:gap-12 grid-cols-1 lg:grid-cols-[1.05fr_0.95fr]">
{/* 左侧:九枢星盘 */}
<div className="astrolabe-wrap">
<NinePivotAstrolabe />
</div>
{/* 右侧:人物体征 */}
<div className="vitals">
<p className="mb-1.5 text-xs text-patina">{subtitle}</p>
<h1
className="font-serif font-bold leading-tight tracking-wide text-fg"
style={{
fontSize: "clamp(34px, 5vw, 52px)",
margin: "0 0 4px",
}}
>
{characterName}
</h1>
{/* 在场脉冲 */}
<div className="mb-5 flex items-center gap-2 text-sm text-fg-dim">
<span className="animate-pulse-ring inline-block h-1.5 w-1.5 rounded-full bg-patina" />
正在 三爻 定策,沉静
</div>
{/* 三魂网格(设计稿 .souls) */}
<div className="mb-5 grid grid-cols-2 overflow-hidden rounded-md border border-rule bg-night-bronze">
{souls.map((s) => (
<div key={s.key} className="px-3.5 py-3 bg-night-bronze">
<div className="font-mono text-[10px] tracking-widest uppercase text-fg-faint">
<b className="mr-1 font-serif text-xs font-semibold text-gilt">
{s.label}
</b>
{s.sub}
</div>
<div className="mt-1 font-serif text-sm text-fg">{s.value}</div>
</div>
))}
</div>
{/* 主操作行 */}
<div className="flex flex-wrap gap-3">
<button
type="button"
className="rounded-md border px-5 py-2.5 text-sm font-semibold transition-colors"
style={{
background: "var(--patina)",
borderColor: "var(--patina)",
color: "var(--ink)",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = "var(--patina-bright)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = "var(--patina)")
}
disabled
title="创建新会话以开始对话"
>
与{characterName}对话
</button>
<button
type="button"
className="rounded-md border px-5 py-2.5 text-sm font-medium transition-colors"
style={{
borderColor: "var(--rule)",
background: "var(--surface)",
color: "var(--fg)",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = "var(--surface-hi)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = "var(--surface)")
}
>
查看运行轨迹
</button>
</div>
</div>
</div>
</div>
);
}
/**
* 九枢星盘 SVG —— 活的仪器(设计稿 §astrolabe)。
* 无交互、纯展示版本,用于 Hero 空状态。
* 枢点数据为示意,真实场景应接入 pivot_health 实时数据。
*/
function NinePivotAstrolabe() {
return (
<svg
className="mx-auto block w-full"
style={{ maxWidth: 400, aspectRatio: "1" }}
viewBox="0 0 400 400"
role="img"
aria-label="九枢星盘"
>
<defs>
<radialGradient id="heroMirrorFace" cx="40%" cy="34%" r="70%">
<stop offset="0%" stopColor="var(--patina)" stopOpacity="0.16" />
<stop offset="60%" stopColor="var(--patina)" stopOpacity="0.05" />
<stop offset="100%" stopColor="var(--patina)" stopOpacity="0.01" />
</radialGradient>
</defs>
{/* 旋转外环 */}
<g className="animate-slow-rot" style={{ transformOrigin: "center" }}>
<circle cx="200" cy="200" r="190" className="astrolabe-ring" />
<circle cx="200" cy="200" r="184" className="astrolabe-ring-fine" />
</g>
{/* 刻度 */}
<circle
cx="200"
cy="200"
r="185"
className="astrolabe-tick"
strokeDasharray="1.5 22.98"
/>
{/* 内环 */}
<circle cx="200" cy="200" r="150" className="astrolabe-ring" />
<circle cx="200" cy="200" r="92" className="astrolabe-ring-patina" />
<circle cx="200" cy="200" r="58" className="astrolabe-ring-fine" />
{/* 核心辉光 */}
<circle
className="astrolabe-core-glow"
cx="200"
cy="200"
r="52"
style={{ fill: "var(--patina)", opacity: 0.08 }}
/>
{/* 道体核 */}
<circle
cx="200"
cy="200"
r="40"
fill="url(#heroMirrorFace)"
style={{
stroke: "var(--gilt)",
strokeWidth: 1,
strokeOpacity: 0.5,
}}
/>
<text
x="200"
y="197"
textAnchor="middle"
className="astrolabe-core-glyph"
>
道體
</text>
<text
x="200"
y="213"
textAnchor="middle"
className="astrolabe-core-label"
>
DAO TI
</text>
{/* 枢轴向辐条 + 枢点 */}
{PIVOT_DATA.map((p, i) => {
const ang = ((-90 + i * (360 / PIVOT_DATA.length)) * Math.PI) / 180;
const x = 200 + 150 * Math.cos(ang);
const y = 200 + 150 * Math.sin(ang);
const bx = 200 + 40 * Math.cos(ang);
const by = 200 + 40 * Math.sin(ang);
const lx = 200 + (150 + 26) * Math.cos(ang);
const ly = 200 + (150 + 26) * Math.sin(ang) + 3;
const col =
HEALTH_COLOR[p.state as keyof typeof HEALTH_COLOR] ??
HEALTH_COLOR.steady;
return (
<g key={p.en}>
{/* 辐条 */}
<line x1={bx} y1={by} x2={x} y2={y} className="astrolabe-spoke" />
{/* 枢点 */}
{p.state === "cracked" ? (
<g>
<circle
cx={x}
cy={y}
r="9"
fill="none"
className="astrolabe-alert"
style={{
stroke: col,
strokeWidth: 1.2,
transformOrigin: `${x}px ${y}px`,
}}
/>
<circle
cx={x}
cy={y}
r="5.5"
style={{
fill: col,
fillOpacity: 0.92,
stroke: "var(--gilt)",
strokeWidth: 1,
}}
/>
</g>
) : (
<circle
cx={x}
cy={y}
r="5.5"
style={{
fill: col,
fillOpacity: 0.92,
stroke: "var(--gilt)",
strokeWidth: 1,
}}
/>
)}
{/* 标签 */}
<text
x={lx}
y={ly}
textAnchor="middle"
className="astrolabe-pivot-label"
>
{p.label}
</text>
<text
x={lx}
y={ly + 11}
textAnchor="middle"
className="astrolabe-pivot-sub"
>
{p.en}
</text>
</g>
);
})}
</svg>
);
}
/** 九枢示意 */
const PIVOT_DATA = [
{ label: "灵核", en: "CORE", state: "steady" },
{ label: "太一", en: "GOAL", state: "steady" },
{ label: "司命", en: "SAFETY", state: "steady" },
{ label: "烛照", en: "PERCEPT", state: "cracked" },
{ label: "河图", en: "WORLD", state: "steady" },
{ label: "洛书", en: "PLANNER", state: "steady" },
{ label: "息壤", en: "MEMORY", state: "breathing" },
{ label: "百工", en: "TOOLS", state: "cracked" },
{ label: "天工阁", en: "AGENTS", state: "steady" },
];
const HEALTH_COLOR = {
void: "var(--fg-faint)",
steady: "var(--patina)",
breathing: "var(--gilt)",
cracked: "var(--cinnabar)",
};
@@ -1,79 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it } from "vitest";
import { ModelSelector } from "./ModelSelector";
import { useChatStore } from "@/lib/stores/chat-store";
import type { ModelCapability } from "@/types/domain";
const mockModels = [
{
name: "default-chat",
display_name: "默认对话",
provider: "openai" as const,
capabilities: ["chat"] as ModelCapability[],
routing_strategy: "round_robin" as const,
supports_streaming: true,
supports_thinking: false,
supports_structured_output: true,
context_window: 128000,
},
{
name: "deep-think",
display_name: "深度思考",
provider: "anthropic" as const,
capabilities: ["chat"] as ModelCapability[],
routing_strategy: "round_robin" as const,
supports_streaming: true,
supports_thinking: true,
supports_structured_output: true,
context_window: 200000,
},
];
describe("ModelSelector", () => {
afterEach(() => {
useChatStore.setState({ modelHint: null });
});
it("无匹配模型时显示未选择", () => {
render(<ModelSelector models={mockModels} />);
expect(screen.getByText("未选择模型")).toBeInTheDocument();
});
it("显示当前模型名", () => {
useChatStore.setState({ modelHint: "default-chat" });
render(<ModelSelector models={mockModels} />);
expect(screen.getByText("默认对话")).toBeInTheDocument();
});
it("点击展开下拉列表", async () => {
const user = userEvent.setup();
render(<ModelSelector models={mockModels} />);
const btn = screen.getByRole("button", { name: "切换模型" });
await user.click(btn);
expect(screen.getByText("深度思考")).toBeInTheDocument();
});
it("点击选项切换模型并收起", async () => {
const user = userEvent.setup();
render(<ModelSelector models={mockModels} />);
await user.click(screen.getByRole("button", { name: "切换模型" }));
await user.click(screen.getByText("深度思考"));
expect(useChatStore.getState().modelHint).toBe("deep-think");
// 切换后下拉应关闭
expect(screen.getByRole("button", { name: "切换模型" })).toHaveAttribute(
"aria-expanded",
"false",
);
});
it("点击外部关闭下拉", async () => {
const user = userEvent.setup();
render(<ModelSelector models={mockModels} />);
await user.click(screen.getByRole("button", { name: "切换模型" }));
expect(screen.getByText("深度思考")).toBeInTheDocument();
await user.click(document.body);
expect(screen.queryByText("深度思考")).not.toBeInTheDocument();
});
});
@@ -1,144 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { MODEL_PROVIDER_LABEL } from "@/lib/constants/labels";
import { useChatStore } from "@/lib/stores/chat-store";
import { cn } from "@/lib/utils/cn";
import type { LogicalModel } from "@/types/api";
/**
* 对话模型选择器:左侧纯展示当前模型名(不可点击),右侧下拉按钮点击展开切换。
* 用于对话头部(ActiveCharacterHeader 人物名下方那一行);选择写入 chat-store
* 并持久化为偏好。
*/
export function ModelSelector({ models }: { models: LogicalModel[] }) {
const modelHint = useChatStore((s) => s.modelHint);
const setModelHint = useChatStore((s) => s.setModelHint);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function onPointerDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", onPointerDown);
return () => document.removeEventListener("mousedown", onPointerDown);
}, [open]);
const current = models.find((m) => m.name === modelHint);
return (
<div ref={ref} className="relative inline-flex items-center gap-0.5">
{/* 当前模型名(纯展示) */}
<span className="inline-flex min-w-0 items-center gap-1 text-xs text-muted">
<ModelIcon />
<span className="max-w-[8rem] truncate">
{current ? current.display_name : "未选择模型"}
</span>
</span>
{/* 下拉触发按钮:点击展开切换其他模型 */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label="切换模型"
title="切换模型"
className={cn(
"flex h-5 w-5 items-center justify-center rounded transition-colors",
open
? "bg-patina/10 text-patina"
: "text-muted hover:bg-fg/10 hover:text-fg",
)}
>
<svg
width="10"
height="10"
viewBox="0 0 16 16"
fill="none"
aria-hidden
className={cn("transition-transform", open && "rotate-180")}
>
<path
d="M4 6l4 4 4-4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{open && (
<ul
role="listbox"
aria-label="选择对话模型"
className="glass-strong absolute top-full left-0 z-30 mt-1 max-h-72 w-64 overflow-y-auto rounded-xl p-1 shadow-lg shadow-black/10"
>
{models.map((m) => {
const active = m.name === modelHint;
return (
<li key={m.name}>
<button
type="button"
role="option"
aria-selected={active}
onClick={() => {
setModelHint(m.name);
setOpen(false);
}}
className={cn(
"flex w-full flex-col gap-0.5 rounded-lg px-2.5 py-1.5 text-left transition-colors",
active ? "bg-patina/15" : "hover:bg-fg/5",
)}
>
<span className="flex items-center gap-1.5">
<span className="font-mono text-xs text-fg">{m.name}</span>
{m.is_default && (
<span className="rounded bg-patina/15 px-1 text-[9px] text-patina">
默认
</span>
)}
<span className="ml-auto text-[10px] text-muted">
{MODEL_PROVIDER_LABEL[m.provider]}
</span>
</span>
<span className="truncate text-[11px] text-muted">
{m.display_name}
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
function ModelIcon() {
return (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
<rect
x="4.5"
y="4.5"
width="7"
height="7"
rx="1.5"
stroke="currentColor"
strokeWidth="1.2"
/>
<path
d="M8 1.5v2.5M8 12v2.5M1.5 8H4M12 8h2.5M3.2 3.2l1.5 1.5M11.3 11.3l1.5 1.5M3.2 12.8l1.5-1.5M11.3 4.7l1.5-1.5"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</svg>
);
}
-419
View File
@@ -1,419 +0,0 @@
"use client";
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Badge, type BadgeTone } from "@/components/ui/Badge";
import { RISK_LEVEL_LABEL } from "@/lib/constants/labels";
import { mockActivePlan } from "@/lib/mock/plans";
import { cn } from "@/lib/utils/cn";
import type { Plan, PlanStatus, PlanStep, PlanStepStatus } from "@/types/plan";
/**
* 洛书计划面板(前瞻原型)。
*
* 字段与 docs/domain/core-domains.md §5 对齐,数据来自 lib/mock/plans.ts。
* 后端 plan.* 事件尚未落地(见 docs/operations/capability-matrix.md),此处仅用于
* UI 验证;接入真实数据时把 plan prop 换成 store / API,渲染层无需改动。
*/
const PLAN_STATUS_LABEL: Record<PlanStatus, string> = {
draft: "草案",
approved: "已批准",
running: "执行中",
completed: "已完成",
failed: "已失败",
};
const PLAN_STATUS_TONE: Record<PlanStatus, BadgeTone> = {
draft: "neutral",
approved: "info",
running: "accent",
completed: "success",
failed: "danger",
};
const STEP_STATUS_LABEL: Record<PlanStepStatus, string> = {
pending: "待执行",
running: "执行中",
waiting_confirmation: "等待确认",
completed: "已完成",
failed: "失败",
skipped: "已跳过",
};
/** 风险等级 → 徽章色调(L0 不展示) */
const RISK_TONE: Record<string, BadgeTone> = {
L0: "neutral",
L1: "info",
L2: "warning",
L3: "danger",
L4: "danger",
};
const STEP_STATUS_TEXT: Record<PlanStepStatus, string> = {
pending: "text-muted",
running: "text-patina",
waiting_confirmation: "text-gilt",
completed: "text-emerald-600 dark:text-emerald-400",
failed: "text-cinnabar",
skipped: "text-muted",
};
/** 步骤状态圆点 */
function StepDot({ status }: { status: PlanStepStatus }) {
if (status === "running") {
return (
<span className="relative flex h-5 w-5 items-center justify-center">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-patina/40" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-patina" />
</span>
);
}
if (status === "waiting_confirmation") {
return (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-gilt text-ink shadow-sm shadow-gilt/30">
<span className="text-xs font-bold">!</span>
</span>
);
}
if (status === "completed") {
return (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-patina text-white shadow-sm shadow-patina/30">
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
<path
d="M2.5 6.2l2.3 2.3L9.5 3.5"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
);
}
if (status === "failed") {
return (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-cinnabar text-white shadow-sm shadow-cinnabar/30">
<svg width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden>
<path
d="M3 3l6 6M9 3l-6 6"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
</span>
);
}
if (status === "skipped") {
return (
<span className="flex h-5 w-5 items-center justify-center rounded-full border border-muted/50 bg-bg text-muted">
<span className="h-0.5 w-2.5 rounded-full bg-muted" />
</span>
);
}
// pending:空心圆环,bg 底色盖住时间线连接线
return (
<span className="h-5 w-5 rounded-full border-2 border-muted/40 bg-bg" />
);
}
function StepRow({ step, last }: { step: PlanStep; last: boolean }) {
return (
<div className="relative flex gap-3 pb-4 last:pb-0">
{!last && (
<span
aria-hidden
className="absolute left-[9px] top-5 h-[calc(100%-1.25rem)] w-px bg-border-glass"
/>
)}
<div className="relative z-10 mt-0.5 shrink-0">
<StepDot status={step.status} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium leading-snug text-fg">
{step.title}
</h4>
{step.risk_level !== "L0" && (
<Badge
tone={RISK_TONE[step.risk_level] ?? "neutral"}
className="shrink-0"
>
{RISK_LEVEL_LABEL[step.risk_level]}
</Badge>
)}
</div>
<p className="mt-0.5 text-xs leading-relaxed text-muted">
{step.description}
</p>
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
<span
className={cn(
"text-[11px] font-medium",
STEP_STATUS_TEXT[step.status],
)}
>
{STEP_STATUS_LABEL[step.status]}
</span>
{step.tool && (
<>
<span className="text-[11px] text-muted/60">·</span>
<Badge tone="info" className="px-1.5 py-0 text-[10px]">
{step.tool}
</Badge>
</>
)}
</div>
{step.result_summary && (
<p className="mt-1.5 border-l-2 border-patina/40 bg-patina/5 px-2 py-1 text-[11px] leading-relaxed text-fg/75">
{step.result_summary}
</p>
)}
</div>
</div>
);
}
function DetailSection({
title,
items,
tone = "neutral",
}: {
title: string;
items: string[];
tone?: "neutral" | "warning";
}) {
if (items.length === 0) return null;
return (
<div>
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted/70">
{title}
</p>
<ul className="space-y-1">
{items.map((item) => (
<li
key={item}
className="flex gap-1.5 text-xs leading-relaxed text-fg/80"
>
<span
className={cn(
"mt-[7px] h-1 w-1 shrink-0 rounded-full",
tone === "warning" ? "bg-gold" : "bg-muted/50",
)}
/>
<span>{item}</span>
</li>
))}
</ul>
</div>
);
}
export function PlanPanel({ plan }: { plan?: Plan | null }) {
const [showDetails, setShowDetails] = useState(false);
const activePlan = plan ?? mockActivePlan;
const steps = activePlan.steps;
const total = steps.length;
const done = steps.filter(
(s) => s.status === "completed" || s.status === "skipped",
).length;
const ratio = total > 0 ? done / total : 0;
return (
<div className="flex flex-col gap-4 p-4">
{/* 状态 + 进度 */}
<div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Badge tone={PLAN_STATUS_TONE[activePlan.status]}>
{PLAN_STATUS_LABEL[activePlan.status]}
</Badge>
<Badge tone="neutral" className="text-[10px]">
模拟
</Badge>
</div>
<span className="font-mono text-xs text-muted">
{done}/{total}
</span>
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-fg/10">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-patina to-gold"
initial={{ width: 0 }}
animate={{ width: `${Math.round(ratio * 100)}%` }}
transition={{ duration: 0.7, ease: "easeOut" }}
/>
</div>
</div>
{/* 步骤时间线 */}
<motion.ul
initial="hidden"
animate="show"
variants={{
hidden: {},
show: { transition: { staggerChildren: 0.06 } },
}}
>
{steps.map((step, i) => (
<motion.li
key={step.id}
variants={{
hidden: { opacity: 0, y: 6 },
show: { opacity: 1, y: 0 },
}}
>
<StepRow step={step} last={i === steps.length - 1} />
</motion.li>
))}
</motion.ul>
{/* 计划详情(折叠) */}
<div className="border-t border-border-glass pt-3">
<button
type="button"
onClick={() => setShowDetails((v) => !v)}
className="flex w-full items-center justify-between text-xs font-medium text-muted transition-colors hover:text-fg"
>
<span>计划详情</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
aria-hidden
className={cn(
"transition-transform duration-200",
showDetails && "rotate-180",
)}
>
<path
d="M3 4.5l3 3 3-3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<AnimatePresence initial={false}>
{showDetails && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="space-y-3 pt-3">
{activePlan.required_tools.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
<span className="mr-1 text-[11px] font-semibold uppercase tracking-wide text-muted/70">
依赖工具
</span>
{activePlan.required_tools.map((t) => (
<Badge key={t} tone="info" className="text-[10px]">
{t}
</Badge>
))}
</div>
)}
<DetailSection title="假设" items={activePlan.assumptions} />
<DetailSection
title="风险"
items={activePlan.risks}
tone="warning"
/>
<DetailSection
title="成功标准"
items={activePlan.success_criteria}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
/**
* 对话页右侧计划侧栏:lg 以上常驻,可折叠为窄竖条。
* 小屏隐藏(移动端对话优先),后端计划 API 落地后可改为抽屉式。
*/
export function PlanAside({
open,
onToggle,
}: {
open: boolean;
onToggle: () => void;
}) {
if (!open) {
return (
<button
type="button"
onClick={onToggle}
aria-label="展开计划面板"
className="hidden w-11 shrink-0 items-center justify-center border-l border-border-glass text-muted transition-colors hover:bg-fg/5 hover:text-fg lg:flex"
>
<span
className="flex flex-col items-center gap-2 text-[11px]"
style={{ writingMode: "vertical-rl" }}
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
aria-hidden
>
<path
d="M9 3l-4 4 4 4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
洛书·计划
</span>
</button>
);
}
return (
<aside className="hidden w-80 shrink-0 flex-col border-l border-border-glass lg:flex">
<div className="flex items-center justify-between border-b border-border-glass px-4 py-2.5">
<span className="font-serif text-sm font-semibold">洛书·计划</span>
<button
type="button"
onClick={onToggle}
aria-label="收起计划面板"
className="rounded-md p-1 text-muted transition-colors hover:bg-fg/5 hover:text-fg"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
aria-hidden
>
<path
d="M10 3l-5 5 5 5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<PlanPanel />
</div>
</aside>
);
}
@@ -1,293 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { readSandboxFile } from "@/lib/api/sandbox";
import { useChatStore } from "@/lib/stores/chat-store";
import { cn } from "@/lib/utils/cn";
import { MarkdownContent } from "@/components/chat/MarkdownContent";
type ViewMode = "source" | "preview";
interface SandboxFilePreviewProps {
/** 文件路径(write_file 传入的 path) */
path: string;
/** 关联的 run_id(可选) */
runId?: string;
/** 显示的文件名(默认取 path 末尾) */
fileName?: string;
/** 是否默认展开 */
defaultOpen?: boolean;
/** 是否自动按 isolated_workspace 规则补全路径前缀(默认 true) */
isolated?: boolean;
}
/**
* 沙箱文件预览组件。
*
* 双模式切换(源码 / 渲染):
* - HTML 文件 → iframe sandbox 渲染预览 + 源码切换
* - 图片(SVG/PNG/JPG)→ img 展示 + 源码切换
* - 文本/代码 → 源码高亮
* - 其他 → 源码文本
*
* 样式遵循琉璃镜设计语言,与 ToolCallBlock / ThoughtBlock 视觉一致。
*/
export function SandboxFilePreview({
path,
runId,
fileName,
defaultOpen = false,
isolated = true,
}: SandboxFilePreviewProps) {
const characterId = useChatStore((s) => s.characterId);
const [open, setOpen] = useState(defaultOpen);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [mimeType, setMimeType] = useState("");
const [sourceText, setSourceText] = useState("");
const [viewMode, setViewMode] = useState<ViewMode>("preview");
const displayName = fileName || path.split("/").pop() || path;
const ext = path.split(".").pop()?.toLowerCase() ?? "";
// 可渲染的文件类型
const canRender =
mimeType === "text/html" ||
mimeType.startsWith("image/") ||
mimeType === "application/pdf" ||
mimeType === "text/markdown" ||
ext === "svg" ||
ext === "md" ||
ext === "markdown";
// 首次打开时加载文件
useEffect(() => {
if (!open || objectUrl) return;
let cancelled = false;
(async () => {
try {
setLoading(true);
setError(null);
const blob = await readSandboxFile(path, {
runId,
characterId: characterId ?? undefined,
isolated,
});
if (cancelled) return;
const mt = blob.type || "text/plain";
setMimeType(mt);
const url = URL.createObjectURL(blob);
setObjectUrl(url);
// 预读文本内容供源码模式使用(仅文本类文件)
if (mt.startsWith("text/") || !mt || ext === "svg") {
const text = await blob.text();
if (!cancelled) setSourceText(text);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "读取文件失败");
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [open, path, runId, characterId, isolated, objectUrl, ext]);
// 进入预览模式时如有文本内容则尝试更新
const previewUrl = objectUrl;
const handleClose = useCallback(() => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
setObjectUrl(null);
}
setOpen(false);
setSourceText("");
setMimeType("");
}, [objectUrl]);
const handleToggle = useCallback(() => {
if (open) {
handleClose();
} else {
setOpen(true);
}
}, [open, handleClose]);
return (
<div className="mt-2 overflow-hidden rounded-lg border border-[var(--border-glass)] bg-fg/[0.02]">
{/* 顶栏 */}
<div className="flex items-center justify-between gap-2 border-b border-[var(--border-glass)] px-3 py-1.5">
<div className="flex min-w-0 items-center gap-2">
{/* 文件图标 */}
<FileIcon ext={ext} />
{/* 文件名 */}
<span className="truncate font-mono text-[11px] text-fg/70">
{displayName}
</span>
{/* 模式切换(仅可渲染文件且已加载时显示) */}
{open && canRender && objectUrl && (
<div className="flex items-center gap-0.5 rounded bg-fg/5 p-0.5">
<button
type="button"
onClick={() => setViewMode("source")}
className={cn(
"rounded px-1.5 py-0.5 text-[10px] transition-colors",
viewMode === "source"
? "bg-patina text-white"
: "text-muted hover:text-fg",
)}
>
{"<"}源码{">"}
</button>
<button
type="button"
onClick={() => setViewMode("preview")}
className={cn(
"rounded px-1.5 py-0.5 text-[10px] transition-colors",
viewMode === "preview"
? "bg-patina text-white"
: "text-muted hover:text-fg",
)}
>
◉ 预览
</button>
</div>
)}
</div>
<button
type="button"
onClick={handleToggle}
className="shrink-0 text-[11px] text-patina transition-opacity hover:opacity-80"
>
{open ? "关闭" : "预览"}
</button>
</div>
{/* 内容区 */}
{open && (
<div className="max-h-[55vh] overflow-auto">
{/* 加载态 */}
{loading && (
<div className="flex items-center justify-center px-4 py-6">
<div className="flex items-center gap-2 text-xs text-muted/60">
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-muted/30 border-t-patina" />
加载中…
</div>
</div>
)}
{/* 错误态 */}
{error && (
<div className="px-3 py-2 text-xs text-cinnabar">{error}</div>
)}
{/* 源码模式 */}
{viewMode === "source" && !loading && sourceText && (
<pre className="overflow-x-auto p-3 font-mono text-[11px] leading-relaxed text-fg/80">
{sourceText}
</pre>
)}
{/* 预览模式 */}
{viewMode === "preview" && !loading && previewUrl && (
<>
{mimeType === "text/html" ? (
<iframe
src={previewUrl}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals"
className="h-[400px] w-full border-0"
title={displayName}
/>
) : mimeType.startsWith("image/") || ext === "svg" ? (
<div className="flex items-center justify-center bg-fg/[0.02] p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl}
alt={displayName}
className="max-h-[50vh] max-w-full rounded object-contain shadow-sm"
/>
</div>
) : mimeType === "application/pdf" ? (
<iframe
src={previewUrl}
className="h-[400px] w-full border-0"
title={displayName}
/>
) : ext === "md" ||
ext === "markdown" ||
mimeType === "text/markdown" ? (
<div className="p-3">
<MarkdownContent content={sourceText} />
</div>
) : (
<pre className="overflow-x-auto p-3 font-mono text-[11px] leading-relaxed text-fg/80">
{sourceText || "(不支持预览此文件类型)"}
</pre>
)}
</>
)}
{/* 空内容 */}
{!loading && !error && !objectUrl && !sourceText && (
<div className="px-3 py-4 text-center text-xs text-muted/50">
(空内容)
</div>
)}
</div>
)}
</div>
);
}
/** 根据扩展名返回文件图标 */
function FileIcon({ ext }: { ext: string }) {
const icon = EXT_ICONS[ext] ?? EXT_ICONS._default;
return (
<span className="shrink-0 text-muted/60" aria-hidden>
{icon}
</span>
);
}
const EXT_ICONS: Record<string, string> = {
html: "🌐",
htm: "🌐",
svg: "🎨",
png: "🖼",
jpg: "🖼",
jpeg: "🖼",
gif: "🖼",
webp: "🖼",
pdf: "📄",
json: "📋",
yaml: "📋",
yml: "📋",
toml: "📋",
md: "📝",
markdown: "📝",
py: "🐍",
js: "📜",
ts: "📘",
tsx: "⚛️",
jsx: "⚛️",
css: "🎨",
scss: "🎨",
rs: "🦀",
go: "🔷",
sh: "💻",
bash: "💻",
txt: "📄",
csv: "📊",
xml: "📋",
_default: "📄",
};
@@ -1,232 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/utils/cn";
import { mockSessionPlan } from "@/lib/mock/plans";
import type { Plan, PlanStep, PlanStepStatus } from "@/types/plan";
/**
* 会话级计划卡片:贯穿整个对话的 AI 目标 todo 清单。
*
* 与 per-message 的「思考过程」(ThoughtBlock / reasoning)并列但职责不同——思考
* 过程是单条回复的内部推演,本卡片是整个会话共享的计划进度,随对话推进持续刷新
* 步骤状态(pending → running → completed)。
*
* ⚠️ 当前未在对话页挂载——implementation-status.md §8 暂不实现自动长期任务规划,AI 单次
* 回复内的推演已由回复气泡的「思考过程」承担。本组件作为前瞻原型保留:待后端
* plan.* 运行事件落地(planner-design.md §5)后,于「AI 实际产出计划」时挂载,
* 无计划则不渲染。步骤推进现为 Mock 演示(定时器模拟),落地后替换为 store / SSE
* 订阅,渲染层无需改动。
*/
const STEP_LABEL: Record<PlanStepStatus, string> = {
pending: "待执行",
running: "进行中",
waiting_confirmation: "等待确认",
completed: "已完成",
failed: "失败",
skipped: "已跳过",
};
function StepIcon({ status }: { status: PlanStepStatus }) {
if (status === "running") {
return (
<span className="relative flex h-4 w-4 shrink-0 items-center justify-center">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-patina/40" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-patina" />
</span>
);
}
if (status === "waiting_confirmation") {
return (
<span className="h-4 w-4 shrink-0 rounded-full border-2 border-gilt bg-gilt/20" />
);
}
if (status === "completed") {
return (
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-patina text-white">
<svg width="9" height="9" viewBox="0 0 12 12" fill="none" aria-hidden>
<path
d="M2.5 6.2l2.3 2.3L9.5 3.5"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
);
}
// pending / failed / skipped:空心环
return (
<span
className={cn(
"h-4 w-4 shrink-0 rounded-full border-2 bg-bg",
status === "failed" ? "border-cinnabar" : "border-muted/40",
)}
/>
);
}
/** 推进一步:当前 running → completed,下一个 pending → running */
function advance(steps: PlanStep[]): PlanStep[] {
const next = steps.map((s) => ({ ...s }));
const runningIdx = next.findIndex((s) => s.status === "running");
if (runningIdx === -1) return next;
next[runningIdx] = { ...next[runningIdx], status: "completed" };
const nextPending = next.findIndex((s) => s.status === "pending");
if (nextPending !== -1) {
next[nextPending] = { ...next[nextPending], status: "running" };
}
return next;
}
export function SessionPlanCard({
plan = mockSessionPlan,
}: {
plan?: Plan | null;
}) {
const [steps, setSteps] = useState<PlanStep[]>(plan?.steps ?? []);
const [open, setOpen] = useState(true);
// Mock:定时推进步骤状态,演示「计划随对话刷新」。无待执行步骤后自动停止。
// 真实场景由后端 plan.* 事件驱动,届时移除此 effect。
useEffect(() => {
const timer = setInterval(() => {
setSteps((prev) => {
const hasActive = prev.some(
(s) => s.status === "running" || s.status === "pending",
);
if (!hasActive) return prev; // 引用不变,不触发渲染
return advance(prev);
});
}, 4000);
return () => clearInterval(timer);
}, []);
if (!plan) return null;
const total = steps.length;
const done = steps.filter(
(s) => s.status === "completed" || s.status === "skipped",
).length;
const ratio = total > 0 ? done / total : 0;
const allDone = total > 0 && done === total;
return (
<div className="glass-strong border-b border-border-glass">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2.5 px-4 py-2 text-left transition-colors hover:bg-fg/[0.02]"
aria-expanded={open}
>
<span className="text-gold">
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
aria-hidden
>
<rect
x="2"
y="2"
width="12"
height="12"
rx="2"
stroke="currentColor"
strokeWidth="1.3"
/>
<path
d="M5.5 8h5M8 5.5v5"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
</span>
<span className="text-xs font-medium text-fg/90">当前计划</span>
<span className="min-w-0 flex-1 truncate text-xs text-muted">
{allDone ? "已完成" : "陪伴散人梳理此刻心绪"}
</span>
<span className="font-mono text-[11px] text-muted">
{done}/{total}
</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
aria-hidden
className={cn(
"text-muted transition-transform duration-200",
open && "rotate-180",
)}
>
<path
d="M3 4.5l3 3 3-3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{/* 进度条(始终展示) */}
<div className="px-4 pb-2">
<div className="h-1 w-full overflow-hidden rounded-full bg-fg/10">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-patina to-gold"
initial={false}
animate={{ width: `${Math.round(ratio * 100)}%` }}
transition={{ duration: 0.6, ease: "easeOut" }}
/>
</div>
</div>
<AnimatePresence initial={false}>
{open && (
<motion.ul
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
className="overflow-hidden"
>
<li className="space-y-1.5 px-4 pb-3">
{steps.map((step) => {
const active = step.status === "running";
return (
<div key={step.id} className="flex items-center gap-2.5">
<StepIcon status={step.status} />
<span
className={cn(
"text-xs",
step.status === "completed"
? "text-muted"
: active
? "font-medium text-patina"
: "text-fg/85",
)}
>
{step.title}
</span>
{active && (
<span className="text-[10px] text-patina/80">
{STEP_LABEL[step.status]}
</span>
)}
</div>
);
})}
</li>
</motion.ul>
)}
</AnimatePresence>
</div>
);
}
@@ -1,57 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SessionSidebar } from "./SessionSidebar";
import { useChatStore } from "@/lib/stores/chat-store";
import type { ChatSession } from "@/types/api";
function makeSession(overrides: Partial<ChatSession> = {}): ChatSession {
return {
session_id: "s1",
character_id: "c1",
title: undefined,
status: "active",
created_at: "2026-06-27T00:00:00Z",
updated_at: "2026-06-27T00:00:00Z",
...overrides,
} as ChatSession;
}
describe("SessionSidebar 会话列表过滤", () => {
beforeEach(() => {
useChatStore.setState({ sessions: [], sessionId: null });
});
afterEach(cleanup);
it("当前活跃会话即使无标题也显示(占位「未命名会话」)", () => {
useChatStore.setState({
sessions: [makeSession({ session_id: "active-empty", title: undefined })],
sessionId: "active-empty",
});
render(<SessionSidebar />);
expect(screen.getByText("未命名会话")).toBeInTheDocument();
});
it("非当前的无标题会话仍被隐藏,避免空会话污染列表", () => {
useChatStore.setState({
sessions: [
makeSession({ session_id: "active-empty", title: undefined }),
makeSession({ session_id: "other-empty", title: undefined }),
],
sessionId: "active-empty",
});
render(<SessionSidebar />);
// 仅当前活跃空会话一条
expect(screen.getAllByText("未命名会话")).toHaveLength(1);
});
it("有标题的会话正常显示真实标题", () => {
useChatStore.setState({
sessions: [makeSession({ session_id: "s-titled", title: "你好" })],
sessionId: "s-titled",
});
render(<SessionSidebar />);
expect(screen.getByText("你好")).toBeInTheDocument();
});
});
@@ -1,568 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
import { chatApi } from "@/lib/api";
import { useChatStore } from "@/lib/stores/chat-store";
import { cn } from "@/lib/utils/cn";
import type { ChatSession } from "@/types/api";
/** 会话按时间分组:今日 / 本周 / 更早 / 归藏(设计稿 session-group 同源)。 */
function groupSessionsByTime(
sessions: ChatSession[],
): { label: string; items: ChatSession[] }[] {
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const todayMs = startOfToday.getTime();
const weekMs = todayMs - 6 * 86_400_000;
const buckets: { label: string; items: ChatSession[] }[] = [
{ label: "今日", items: [] },
{ label: "本周", items: [] },
{ label: "更早", items: [] },
{ label: "归藏", items: [] },
];
for (const s of sessions) {
if (s.status === "archived") {
buckets[3].items.push(s);
continue;
}
const t = new Date(s.updated_at ?? s.created_at).getTime();
if (Number.isNaN(t) || t < weekMs) buckets[2].items.push(s);
else if (t >= todayMs) buckets[0].items.push(s);
else buckets[1].items.push(s);
}
return buckets.filter((b) => b.items.length > 0);
}
/**
* 铭文册 · 历史会话。
*
* 嵌入镜背轨时只渲染内容,不另占一条侧栏;操作菜单在列表内展开,删除确认通过
* portal 挂到 #chat-region,因此遮罩与弹窗都被限制在对话区域内。
*/
export function SessionSidebar({
onCreate,
creating,
collapsed = false,
characterNameMap = {},
onSessionSelect,
newSessionIds = [],
embedded = false,
}: {
onCreate?: () => void;
creating?: boolean;
collapsed?: boolean;
characterNameMap?: Record<string, string>;
onSessionSelect?: (sessionId: string, characterId: string) => void;
newSessionIds?: string[];
embedded?: boolean;
}) {
const sessions = useChatStore((s) => s.sessions);
const sessionId = useChatStore((s) => s.sessionId);
const switchSession = useChatStore((s) => s.switchSession);
const patchSession = useChatStore((s) => s.patchSession);
const removeSession = useChatStore((s) => s.removeSession);
const [editingId, setEditingId] = useState<string | null>(null);
const [draftTitle, setDraftTitle] = useState("");
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ChatSession | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
useEffect(() => {
if (!menuOpenId) return;
function onDown(e: MouseEvent) {
if (!(e.target as HTMLElement).closest("[data-session-menu]")) {
setMenuOpenId(null);
}
}
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [menuOpenId]);
useEffect(() => {
if (!deleteTarget) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setDeleteTarget(null);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [deleteTarget]);
async function saveRename(id: string) {
const title = draftTitle.trim();
setEditingId(null);
if (!title) return;
setBusyId(id);
try {
const updated = await chatApi.updateSession(id, { title });
patchSession(id, updated);
} catch {
/* 保留原标题 */
} finally {
setBusyId(null);
}
}
async function toggleArchive(session: ChatSession) {
setMenuOpenId(null);
const next = session.status === "archived" ? "active" : "archived";
setBusyId(session.session_id);
try {
const updated = await chatApi.updateSession(session.session_id, {
status: next,
});
patchSession(session.session_id, updated);
} catch {
/* 保留原状态 */
} finally {
setBusyId(null);
}
}
async function confirmDelete() {
if (!deleteTarget) return;
const id = deleteTarget.session_id;
setBusyId(id);
try {
await chatApi.deleteSession(id);
const otherSessions = sessions.filter(
(session) => session.session_id !== id,
);
removeSession(id);
if (id === sessionId) {
const next = otherSessions.find(
(session) => session.status !== "archived",
);
if (next) {
switchSession(next.session_id);
} else {
switchSession(null);
onCreate?.();
}
}
setDeleteTarget(null);
} catch {
/* 删除失败时保留弹窗,允许重试 */
} finally {
setBusyId(null);
}
}
const sorted = sessions
.filter((session) => session.title || session.session_id === sessionId)
.sort((a, b) => {
if ((a.status === "archived") !== (b.status === "archived")) {
return a.status === "archived" ? 1 : -1;
}
return (b.updated_at ?? b.created_at).localeCompare(
a.updated_at ?? a.created_at,
);
});
// 底部统计卡:本月铭文数 / 活跃人物数(纯前端从会话列表推算,免新增后端接口)
const statsNow = new Date();
const statsMonthPrefix = `${statsNow.getFullYear()}-${String(
statsNow.getMonth() + 1,
).padStart(2, "0")}`;
let monthCount = 0;
const activeChars = new Set<string>();
for (const s of sessions) {
if (s.status === "archived") continue;
if ((s.updated_at ?? s.created_at ?? "").startsWith(statsMonthPrefix)) {
monthCount++;
}
if (s.character_id) activeChars.add(s.character_id);
}
const activeCharCount = activeChars.size;
const content = (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 items-center justify-between border-b border-rule px-3 py-2.5">
<span className="mono text-[10px] uppercase tracking-[0.2em] text-fg-faint">
铭文 · 往来
</span>
<button
type="button"
onClick={onCreate}
disabled={!onCreate || creating}
className="inline-flex items-center rounded border border-rule px-2 py-1 text-[10px] text-fg-faint transition-colors hover:border-gilt-dim hover:text-fg disabled:opacity-50"
>
{creating ? "启镜中" : "+ 新启"}
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 py-2 flex flex-col">
{sorted.length === 0 ? (
<p className="px-2 py-6 text-center text-[11px] text-fg-faint">
尚无往来铭文
</p>
) : (
<div className="flex flex-col gap-2">
{groupSessionsByTime(sorted).map((group) => (
<section key={group.label} className="flex flex-col">
<p className="mono px-2 pb-1 pt-1 text-[9px] uppercase tracking-[0.12em] text-fg-faint">
{group.label}
</p>
<ul className="flex flex-col gap-px">
{group.items.map((session) => {
const active = session.session_id === sessionId;
const archived = session.status === "archived";
const isEditing = editingId === session.session_id;
const menuOpen = menuOpenId === session.session_id;
const isNew = newSessionIds.includes(session.session_id);
return (
<li key={session.session_id}>
{isEditing ? (
<input
autoFocus
value={draftTitle}
onChange={(event) =>
setDraftTitle(event.target.value)
}
onBlur={() => saveRename(session.session_id)}
onKeyDown={(event) => {
if (event.key === "Enter")
saveRename(session.session_id);
if (event.key === "Escape") setEditingId(null);
}}
className="w-full rounded border border-patina-dim bg-glass px-2 py-1.5 text-sm text-fg outline-none"
/>
) : (
<div
className={cn(
"group rounded px-2 py-1.5 transition-colors",
active ? "bg-patina-wash" : "hover:bg-fg/5",
archived && !active && "opacity-60",
)}
>
<div className="flex items-start gap-1">
<button
type="button"
onClick={() =>
onSessionSelect
? onSessionSelect(
session.session_id,
session.character_id,
)
: switchSession(session.session_id)
}
className="min-w-0 flex-1 text-left"
disabled={busyId === session.session_id}
>
<span className="flex items-center gap-1.5">
{/* s-dot 状态色点:新消息朱砂(脉冲) / 当前铜绿 / 归藏灰暗 / 其余静灰 */}
{isNew ? (
<span
className="relative flex h-1.5 w-1.5 shrink-0"
title="新会话"
>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-cinnabar/60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-cinnabar" />
</span>
) : (
<span
className={cn(
"h-1.5 w-1.5 shrink-0 rounded-full",
active
? "bg-patina"
: archived
? "bg-fg-faint/40"
: "bg-fg-faint/60",
)}
/>
)}
<span
className={cn(
"truncate text-[13px]",
active
? "font-medium text-patina"
: "text-fg-dim",
)}
>
{session.title || "未命名会话"}
</span>
{archived && (
<span className="shrink-0 rounded bg-fg/10 px-1 text-[8px] text-fg-faint">
归藏
</span>
)}
</span>
<span className="mono mt-0.5 flex items-center gap-1.5 text-[9px] text-fg-faint">
{characterNameMap[session.character_id] && (
<span className="max-w-[70px] truncate">
{characterNameMap[session.character_id]}
</span>
)}
{(
session.updated_at ?? session.created_at
).slice(0, 10)}
</span>
</button>
<div data-session-menu className="shrink-0">
<button
type="button"
title="会话操作"
aria-label="会话操作"
aria-expanded={menuOpen}
onClick={() =>
setMenuOpenId(
menuOpen ? null : session.session_id,
)
}
className={cn(
"flex h-5 w-5 items-center justify-center rounded text-fg-faint transition-colors hover:bg-fg/10 hover:text-fg",
menuOpen
? "bg-fg/10 text-fg"
: "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100",
)}
>
<MoreIcon />
</button>
</div>
</div>
{/* 内联操作,不越出历史会话滚动区域 */}
{menuOpen && (
<div
data-session-menu
role="menu"
className="mt-1 grid grid-cols-3 gap-px border-t border-rule pt-1"
>
<MenuItem
icon={<PencilIcon />}
label="重命名"
onClick={() => {
setMenuOpenId(null);
setEditingId(session.session_id);
setDraftTitle(session.title ?? "");
}}
/>
<MenuItem
icon={<ArchiveIcon />}
label={archived ? "复归" : "归藏"}
onClick={() => toggleArchive(session)}
/>
<MenuItem
icon={<TrashIcon />}
label="删除"
danger
onClick={() => {
setMenuOpenId(null);
setDeleteTarget(session);
}}
/>
</div>
)}
</div>
)}
</li>
);
})}
</ul>
</section>
))}
</div>
)}
</div>
{/* 底部统计:本月铭文 / 活跃人物(前端从会话列表推算) */}
<div className="grid shrink-0 grid-cols-2 gap-2 border-t border-rule px-3 py-2">
<div className="rounded-[5px] border border-rule bg-surface px-2.5 py-2">
<div className="text-[9px] uppercase tracking-[0.1em] text-fg-faint">
本月铭文
</div>
<div className="mt-0.5 font-serif text-base font-semibold text-fg">
{monthCount}
<small className="ml-0.5 text-[10px] font-normal text-fg-dim">
通
</small>
</div>
</div>
<div className="rounded-[5px] border border-rule bg-surface px-2.5 py-2">
<div className="text-[9px] uppercase tracking-[0.1em] text-fg-faint">
活跃人物
</div>
<div className="mt-0.5 font-serif text-base font-semibold text-fg">
{activeCharCount}
<small className="ml-0.5 text-[10px] font-normal text-fg-dim">
位
</small>
</div>
</div>
</div>
</div>
);
const dialogHost =
typeof document === "undefined"
? null
: document.getElementById("chat-region");
const deleteDialog =
deleteTarget && dialogHost
? createPortal(
<div className="absolute inset-0 z-50 flex items-center justify-center overflow-hidden p-4">
<button
type="button"
aria-label="关闭删除确认"
onClick={() => setDeleteTarget(null)}
className="absolute inset-0 cursor-default bg-black/55 backdrop-blur-sm"
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="delete-session-title"
className="mirror-face relative max-h-[calc(100%-2rem)] w-full max-w-sm overflow-y-auto rounded p-5 shadow-2xl"
>
<p
id="delete-session-title"
className="font-serif text-base font-semibold text-fg"
>
擦除铭文
</p>
<p className="mt-2 text-sm leading-relaxed text-fg-dim">
确定删除「{deleteTarget.title || "未命名会话"}
」吗?此操作无法撤销。
</p>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={() => setDeleteTarget(null)}
className="rounded border border-rule bg-surface px-3 py-1.5 text-xs text-fg-dim hover:bg-surface-hi"
>
保留
</button>
<button
type="button"
onClick={confirmDelete}
disabled={busyId === deleteTarget.session_id}
className="rounded border border-cinnabar-dim bg-cinnabar/10 px-3 py-1.5 text-xs text-cinnabar hover:bg-cinnabar/20 disabled:opacity-50"
>
{busyId === deleteTarget.session_id ? "擦除中…" : "确认擦除"}
</button>
</div>
</div>
</div>,
dialogHost,
)
: null;
if (embedded) {
return (
<>
{content}
{deleteDialog}
</>
);
}
return (
<>
<aside
className={cn(
"shrink-0 border-r border-rule bg-sidebar",
collapsed ? "hidden" : "hidden md:flex md:w-56 md:flex-col",
)}
>
{content}
</aside>
{deleteDialog}
</>
);
}
function MenuItem({
icon,
label,
danger,
onClick,
}: {
icon: ReactNode;
label: string;
danger?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
role="menuitem"
onClick={onClick}
className={cn(
"flex min-w-0 items-center justify-center gap-1 rounded px-1 py-1 text-[9px] transition-colors",
danger
? "text-cinnabar hover:bg-cinnabar/10"
: "text-fg-faint hover:bg-fg/5 hover:text-fg-dim",
)}
>
<span className="flex h-3 w-3 shrink-0 items-center justify-center">
{icon}
</span>
<span className="truncate">{label}</span>
</button>
);
}
function MoreIcon() {
return (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
<circle cx="3.5" cy="8" r="1.2" fill="currentColor" />
<circle cx="8" cy="8" r="1.2" fill="currentColor" />
<circle cx="12.5" cy="8" r="1.2" fill="currentColor" />
</svg>
);
}
function PencilIcon() {
return (
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M11.5 2.5l2 2-8 8H3.5v-2l8-8z"
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
/>
</svg>
);
}
function ArchiveIcon() {
return (
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" aria-hidden>
<rect
x="2.5"
y="3"
width="11"
height="2.5"
rx="0.5"
stroke="currentColor"
strokeWidth="1.3"
/>
<path
d="M3.5 5.5v8a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-8M6.5 8.5h3"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function TrashIcon() {
return (
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M3 4.5h10M6.5 4.5V3a1 1 0 0 1 1-1h1a1 1 0 0 1 1 1v1.5M4.5 4.5l.5 8a1 1 0 0 0 1 .9h4a1 1 0 0 0 1-.9l.5-8"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -1,33 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { ThoughtBlock } from "./ThoughtBlock";
describe("ThoughtBlock", () => {
it("流式中渲染思考内容", () => {
render(<ThoughtBlock reasoning="正在思考…" streaming />);
expect(screen.getByText("正在思考…")).toBeInTheDocument();
});
it("流式中默认展开", () => {
render(<ThoughtBlock reasoning="思考中" streaming />);
const btn = screen.getByRole("button");
expect(btn).toHaveAttribute("aria-expanded", "true");
});
it("完成后默认折叠", () => {
render(<ThoughtBlock reasoning="已完成思考" />);
const btn = screen.getByRole("button");
expect(btn).toHaveAttribute("aria-expanded", "false");
});
it("点击展开/收起切换折叠状态", async () => {
const user = userEvent.setup();
render(<ThoughtBlock reasoning="可折叠的内容" />);
const btn = screen.getByRole("button");
expect(btn).toHaveAttribute("aria-expanded", "false");
await user.click(btn);
expect(btn).toHaveAttribute("aria-expanded", "true");
});
});
@@ -1,105 +0,0 @@
"use client";
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/utils/cn";
/**
* 思考过程块(reasoning / thinking summary):可折叠展示模型的内省摘要。
*
* - 流式进行中默认展开、图标脉冲;
* - 完成后默认折叠,用户可手动展开/收起;
* - 内容为纯文本(与正文 Markdown 区分),柔和 muted 色。
*
* 语义对齐 docs/architecture/service-boundaries.md §11.4
* (thinking display: summarized);raw CoT 不持久化,故仅承接流式临时态。
*/
export function ThoughtBlock({
reasoning,
streaming,
}: {
reasoning: string;
streaming?: boolean;
}) {
// 流式中默认展开;已完成(非流式)默认折叠
const [open, setOpen] = useState(streaming ?? false);
return (
<div className="mb-2 overflow-hidden rounded-xl border border-border-glass bg-fg/[0.03]">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-fg/[0.02]"
aria-expanded={open}
>
<span
className={cn(
"text-gold transition-opacity",
streaming && "animate-pulse",
)}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
aria-hidden
>
<path
d="M8 1.5A4 4 0 0 0 5.2 8.4c.3.4.5.9.5 1.4v.2a1 1 0 0 0 1 1h2.6a1 1 0 0 0 1-1v-.2c0-.5.2-1 .5-1.4A4 4 0 0 0 8 1.5Z"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
/>
<path
d="M6.5 13h3M7 14.5h2"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</svg>
</span>
<span className="text-xs font-medium text-muted">
{streaming ? "思考中…" : "思考过程"}
</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
aria-hidden
className={cn(
"ml-auto text-muted transition-transform duration-200",
open && "rotate-180",
)}
>
<path
d="M3 4.5l3 3 3-3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ scaleY: 0, opacity: 0 }}
animate={{ scaleY: 1, opacity: 1 }}
exit={{ scaleY: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="origin-top overflow-hidden"
>
<div className="border-t border-border-glass px-3 py-2">
<p className="whitespace-pre-wrap text-xs leading-relaxed text-muted">
{reasoning || (streaming ? "…" : "")}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
@@ -1,96 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/components/tools/ToolConfirmationCard", () => ({
ToolConfirmationCard: () => <div data-testid="confirmation-card" />,
}));
import { ToolCallBlock } from "./ToolCallBlock";
describe("ToolCallBlock", () => {
it("running 状态显示工具名和状态文案", () => {
render(
<ToolCallBlock
toolCall={{
toolName: "web_search",
toolCallId: "tc_1",
status: "running",
}}
/>,
);
expect(screen.getByText("web_search")).toBeInTheDocument();
expect(screen.getByText("执行中")).toBeInTheDocument();
});
it("success 状态显示已完成", () => {
render(
<ToolCallBlock
toolCall={{
toolName: "web_search",
toolCallId: "tc_1",
status: "success",
result: "搜索结果",
}}
/>,
);
expect(screen.getByText("已完成")).toBeInTheDocument();
});
it("error 状态显示失败", () => {
render(
<ToolCallBlock
toolCall={{
toolName: "shell",
toolCallId: "tc_2",
status: "error",
result: "错误信息",
}}
/>,
);
expect(screen.getByText("失败")).toBeInTheDocument();
});
it("confirming 状态显示确认卡片", () => {
render(
<ToolCallBlock
toolCall={{
toolName: "shell",
toolCallId: "tc_3",
status: "confirming",
confirmationId: "confirm_1",
riskLevel: "L3",
reason: "高风险操作",
}}
/>,
);
expect(screen.getByTestId("confirmation-card")).toBeInTheDocument();
});
it("running 时默认展开", () => {
render(
<ToolCallBlock
toolCall={{
toolName: "web_search",
toolCallId: "tc_4",
status: "running",
}}
/>,
);
const btn = screen.getByRole("button");
expect(btn).toHaveAttribute("aria-expanded", "true");
});
it("success 时默认折叠", () => {
render(
<ToolCallBlock
toolCall={{
toolName: "web_search",
toolCallId: "tc_5",
status: "success",
}}
/>,
);
const btn = screen.getByRole("button");
expect(btn).toHaveAttribute("aria-expanded", "false");
});
});
@@ -1,390 +0,0 @@
"use client";
import { useCallback, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ToolConfirmationCard } from "@/components/tools/ToolConfirmationCard";
import { Badge, type BadgeTone } from "@/components/ui/Badge";
import { cn } from "@/lib/utils/cn";
import { useChatStore } from "@/lib/stores/chat-store";
import type { ToolCallDisplay } from "@/lib/stores/chat-store";
/** 结果文本展示截断长度(超过则默认折叠) */
const RESULT_COLLAPSE_LENGTH = 280;
/** 工具调用状态 → 徽章色调 + 中文文案 */
const STATUS_META: Record<
ToolCallDisplay["status"],
{ label: string; tone: BadgeTone; pulse?: boolean }
> = {
running: { label: "执行中", tone: "warning", pulse: true },
confirming: { label: "待确认", tone: "warning", pulse: true },
success: { label: "已完成", tone: "success" },
error: { label: "失败", tone: "danger" },
denied: { label: "已拒绝", tone: "danger" },
skipped: { label: "已跳过", tone: "neutral" },
};
export interface ToolCallBlockProps {
toolCall: ToolCallDisplay;
/** 消息所属 run_id(确认端点调用需要) */
runId?: string;
onApprove?: (
runId: string,
confirmationId: string,
toolCallId: string,
) => void;
onDeny?: (runId: string, confirmationId: string, toolCallId: string) => void;
}
/**
* 工具调用块(对话流 tool.* 事件可视化)。结构与 ThoughtBlock 对齐:
* 可折叠头(工具名 + 状态徽章)+ 展开态结果文本(截断 + 展开)。
*
* 状态为 confirming 时,在结果下方嵌入 ToolConfirmationCard(复用)。
* 样式遵循琉璃镜(玻璃 + 矿物色),与 ThoughtBlock 视觉一致。
*
* 状态机:
* running(金,脉冲)→ success(铜绿)/ error(朱砂)/ denied(朱砂)/ skipped(灰)
* 中间可能进入 confirming(金,脉冲)展示确认卡片,用户操作后回 running。
*/
export function ToolCallBlock({
toolCall,
runId,
onApprove,
onDeny,
}: ToolCallBlockProps) {
const meta = STATUS_META[toolCall.status] ?? STATUS_META.running;
// running / confirming 时默认展开让用户看到实时状态;终态默认折叠
const isActive =
toolCall.status === "running" || toolCall.status === "confirming";
const [open, setOpen] = useState(isActive);
// 确认卡片需要 confirmation_id;同时需要本地 loading 态(用户点击后等待后端事件)
const [resolving, setResolving] = useState(false);
const handleApprove = () => {
if (!runId || !toolCall.confirmationId || !onApprove) return;
setResolving(true);
onApprove(runId, toolCall.confirmationId, toolCall.toolCallId);
// 不在此处清 resolving——后端推 tool.completed 时 toolCall.status 会变化,
// 此组件卸载或重渲染。若后端迟迟不推(如 410/503),用户可在错误提示后重试。
// 保守起见,等几秒自动解除 loading,避免按钮永久转圈。
setTimeout(() => setResolving(false), 4000);
};
const handleDeny = () => {
if (!runId || !toolCall.confirmationId || !onDeny) return;
setResolving(true);
onDeny(runId, toolCall.confirmationId, toolCall.toolCallId);
setTimeout(() => setResolving(false), 4000);
};
const hasResult = toolCall.result !== undefined && toolCall.result !== "";
const resultLong = (toolCall.result?.length ?? 0) > RESULT_COLLAPSE_LENGTH;
const [resultExpanded, setResultExpanded] = useState(false);
return (
<div className="mb-2 overflow-hidden rounded-xl border border-border-glass bg-fg/[0.03]">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-fg/[0.02]"
aria-expanded={open}
>
<span
className={cn(
"text-gold transition-opacity",
meta.pulse && "animate-pulse",
)}
aria-hidden
>
<WrenchIcon />
</span>
<span className="text-xs font-medium text-fg/80">
{toolCall.toolName || "工具调用"}
</span>
<Badge tone={meta.tone} className="ml-0.5">
{meta.label}
</Badge>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
aria-hidden
className={cn(
"ml-auto text-muted transition-transform duration-200",
open && "rotate-180",
)}
>
<path
d="M3 4.5l3 3 3-3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ scaleY: 0, opacity: 0 }}
animate={{ scaleY: 1, opacity: 1 }}
exit={{ scaleY: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="origin-top overflow-hidden"
>
<div className="space-y-2 border-t border-border-glass px-3 py-2">
{/* 调用参数:confirming 态由确认卡片承载 args,避免重复 */}
{toolCall.args && toolCall.status !== "confirming" && (
<ToolCallArgs
toolName={toolCall.toolName}
args={toolCall.args}
/>
)}
{/* 结果文本(success/error/denied/skipped 才有) */}
{hasResult && (
<div>
<pre
className={cn(
"whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-muted",
resultLong && !resultExpanded && "line-clamp-6",
)}
>
{toolCall.result}
</pre>
{resultLong && (
<button
type="button"
onClick={() => setResultExpanded((v) => !v)}
className="mt-1 text-xs text-patina transition-opacity hover:opacity-80"
>
{resultExpanded ? "收起结果" : "展开结果"}
</button>
)}
</div>
)}
{/* 文件预览:write_file 成功时提供独立面板预览入口 */}
{toolCall.status === "success" &&
toolCall.toolName === "write_file" && (
<FilePreviewButton args={toolCall.args} runId={runId} />
)}
{/* 执行中无结果时显示轻量提示 */}
{!hasResult && toolCall.status === "running" && (
<p className="text-xs text-muted/70">正在执行…</p>
)}
{/* 确认卡片:仅在 confirming 态且有 confirmationId 时展示 */}
{toolCall.status === "confirming" &&
toolCall.confirmationId &&
toolCall.riskLevel && (
<ToolConfirmationCard
tool={toolCall.toolName}
riskLevel={toolCall.riskLevel}
reason={toolCall.reason ?? ""}
args={toolCall.args}
onApprove={handleApprove}
onDeny={handleDeny}
loading={resolving}
/>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function WrenchIcon() {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M11.5 2.5a2.5 2.5 0 0 0-3.4 2.9L3 10.5a1.4 1.4 0 0 0 2 2l5.1-5.1a2.5 2.5 0 0 0 2.9-3.4l-1.7 1.7-1.4-.2-.2-1.4 1.8-1.6Z"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
/>
</svg>
);
}
/**
* 打开全局面板预览沙箱文件。
*/
function FilePreviewButton({
args,
runId,
}: {
args: string | undefined;
runId: string | undefined;
}) {
const openPreviewFile = useChatStore((s) => s.openPreviewFile);
const filePath = extractFilePath(args);
const handleOpen = useCallback(() => {
openPreviewFile({ path: filePath, runId });
}, [filePath, runId, openPreviewFile]);
if (!filePath) return null;
return (
<button
type="button"
onClick={handleOpen}
className="inline-flex items-center gap-1.5 rounded border border-border-glass px-2.5 py-1 text-[11px] text-patina transition-colors hover:bg-patina/10"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
<rect
x="2"
y="3"
width="12"
height="10"
rx="1.5"
stroke="currentColor"
strokeWidth="1.2"
/>
<circle cx="6" cy="7.5" r="1" fill="currentColor" />
<path
d="M2 10l3.5-2.5L9 10l2-1.5 3 2"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
预览文件
</button>
);
}
/**
* 从 write_file 的 args JSON 中提取文件路径。
* args 格式:``{"path": "output/report.html", "content": "..."}``
*/
function extractFilePath(args: string | undefined): string {
if (!args) return "";
try {
const parsed = JSON.parse(args) as Record<string, unknown>;
const p = parsed.path;
return typeof p === "string" && p.trim() ? p.trim() : "";
} catch {
return "";
}
}
/** 单个入参值的截断长度:超过则折叠为摘要(write_file 的 content 可能极长) */
const ARG_VALUE_PREVIEW = 120;
/** 解析工具调用入参 JSON 字符串;失败或非对象时回退到原始文本展示。 */
function parseToolArgs(
args: string | undefined,
): Record<string, unknown> | null {
if (!args) return null;
try {
const parsed = JSON.parse(args);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
/** 把入参值格式化为可读字符串;过长的字符串折叠为「前缀…(共 N 字符)」。 */
function previewToolArgValue(value: unknown): string {
if (typeof value === "string") {
return value.length > ARG_VALUE_PREVIEW
? `${value.slice(0, ARG_VALUE_PREVIEW)}…(共 ${value.length} 字符)`
: value;
}
if (value === null) return "null";
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
}
/**
* 入参键值对展示(dl/dt/dd)。长值经 previewToolArgValue 折叠。
*/
function ToolArgKeyValue({ entries }: { entries: [string, unknown][] }) {
return (
<dl className="mt-1 space-y-0.5 rounded-lg bg-fg/[0.04] p-2 font-mono text-xs leading-relaxed">
{entries.map(([k, v]) => (
<div key={k} className="flex gap-2">
<dt className="shrink-0 text-muted">{k}:</dt>
<dd className="break-all text-fg/80">{previewToolArgValue(v)}</dd>
</div>
))}
</dl>
);
}
/**
* 工具调用入参展示。所有状态(只要有 args)都显示,让用户能判断 AI 实际调用了什么——
* 尤其 shell 工具的 command,否则仅看执行结果无法判断命令是否正确。
*
* shell:终端样式突出 command(用户最关心的「命令」),其余键(timeout_ms/stdin)附在下方;
* 其它工具:键值对形式,长值折叠。
*/
function ToolCallArgs({
toolName,
args,
}: {
toolName: string;
args: string | undefined;
}) {
const parsed = parseToolArgs(args);
// 解析失败:回退为原始文本(args 为空则不渲染)
if (!parsed) {
if (!args) return null;
return (
<div>
<span className="text-[11px] font-medium tracking-wide text-muted/60">
调用参数
</span>
<pre className="mt-1 whitespace-pre-wrap break-words rounded-lg bg-fg/[0.04] p-2 font-mono text-xs leading-relaxed text-fg/80">
{args}
</pre>
</div>
);
}
const entries = Object.entries(parsed);
if (entries.length === 0) return null;
// shell:突出命令行,其余参数作为附属键值对
if (toolName === "shell" && typeof parsed.command === "string") {
const extras = entries.filter(([k]) => k !== "command");
return (
<div>
<span className="text-[11px] font-medium tracking-wide text-muted/60">
命令
</span>
<pre className="mt-1 whitespace-pre-wrap break-words rounded-lg border-l-2 border-patina/50 bg-fg/[0.05] p-2 font-mono text-xs leading-relaxed text-fg">
<span className="select-none text-patina">$ </span>
{parsed.command}
</pre>
{extras.length > 0 && <ToolArgKeyValue entries={extras} />}
</div>
);
}
return (
<div>
<span className="text-[11px] font-medium tracking-wide text-muted/60">
调用参数
</span>
<ToolArgKeyValue entries={entries} />
</div>
);
}
@@ -1,147 +0,0 @@
"use client";
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import type { MessageVersion } from "@/lib/stores/chat-store";
/**
* 版本历史面板(设计稿 .version-panel)。
*
* 浏览同一条助手回复的多个历史版本:
* - ◀▶ 切换预览,实时同步主消息 content
* - 「使用此版」将预览版永久置为当前回复(写入 store 的 activeVersionIdx)
* - 「关闭」收起面板
*/
export function VersionPanel({
versions,
activeIdx,
messageId,
onBrowse,
onUseVersion,
}: {
versions: MessageVersion[];
activeIdx: number;
messageId?: string;
/** ◀▶ 翻页时回调,实时同步主消息 content */
onBrowse?: (idx: number, content: string) => void;
/** 「使用此版」回调 */
onUseVersion?: (messageId: string, versionIdx: number) => void;
}) {
const multi = versions.length > 1;
const [browseIdx, setBrowseIdx] = useState(
Math.min(activeIdx, versions.length - 1),
);
const [open, setOpen] = useState(true);
const atStart = browseIdx <= 0;
const atEnd = browseIdx >= versions.length - 1;
const currentVersion = versions[browseIdx];
const handlePrev = () => {
const next = Math.max(0, browseIdx - 1);
setBrowseIdx(next);
onBrowse?.(next, versions[next].content);
};
const handleNext = () => {
const next = Math.min(versions.length - 1, browseIdx + 1);
setBrowseIdx(next);
onBrowse?.(next, versions[next].content);
};
const handleUseVersion = () => {
if (!messageId) return;
onUseVersion?.(messageId, browseIdx);
setOpen(false);
};
if (!open) return null;
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
className="relative z-[1] mt-2 border-t border-rule pt-2"
>
{/* ── 头部:版本导航 ── */}
<div className="mono flex items-center gap-1.5 text-[11px] tracking-[0.04em] text-fg-dim">
<span className="text-gilt/70">⟡</span>
版本历史
{multi && (
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={handlePrev}
disabled={atStart}
title="上一版"
aria-label="上一版"
className="flex h-6 w-6 items-center justify-center rounded-full border border-rule text-fg-faint transition-colors hover:border-gilt-dim hover:bg-fg/5 hover:text-fg disabled:pointer-events-none disabled:opacity-30"
>
<ChevronIcon dir="left" />
</button>
<span className="mono min-w-[48px] text-center text-[11px] text-fg-faint">
<span className="font-semibold text-gilt">{browseIdx + 1}</span>
/{versions.length}
</span>
<button
type="button"
onClick={handleNext}
disabled={atEnd}
title="下一版"
aria-label="下一版"
className="flex h-6 w-6 items-center justify-center rounded-full border border-rule text-fg-faint transition-colors hover:border-gilt-dim hover:bg-fg/5 hover:text-fg disabled:pointer-events-none disabled:opacity-30"
>
<ChevronIcon dir="right" />
</button>
</div>
)}
</div>
{/* ── 正文预览区:显示当前浏览版本的内容(设计稿 .vp-body) ── */}
<div className="mt-2 rounded border border-patina-dim bg-patina-wash px-3 py-2 text-[13px] leading-relaxed text-fg-dim">
{currentVersion?.content ? (
<p className="line-clamp-4">{currentVersion.content}</p>
) : (
<p className="italic text-fg-faint">(空版本)</p>
)}
</div>
{/* ── 操作按钮:使用此版 / 关闭(设计稿 .vp-actions) ── */}
<div className="mt-1.5 flex justify-end gap-2">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded border border-rule bg-transparent px-2.5 py-1 text-[11px] text-fg-faint transition-colors hover:bg-fg/5 hover:text-fg"
>
关闭
</button>
<button
type="button"
onClick={handleUseVersion}
className="rounded border border-patina-dim bg-patina-wash px-2.5 py-1 text-[11px] text-patina transition-colors hover:border-[var(--patina)] hover:bg-patina-dim hover:text-ink"
>
使用此版
</button>
</div>
</motion.div>
</AnimatePresence>
);
}
function ChevronIcon({ dir }: { dir: "left" | "right" }) {
return (
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
<path
d={dir === "left" ? "M7.5 3L4.5 6l3 3" : "M4.5 3l3 3-3 3"}
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
-68
View File
@@ -1,68 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiDelete: vi.fn(),
apiGet: vi.fn(),
apiPatch: vi.fn(),
apiPost: vi.fn(),
}));
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import {
createSession,
deleteSession,
listMessages,
listSessions,
sendMessage,
updateSession,
} from "./chat";
describe("chat api", () => {
it("createSession POST /chat/sessions", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "session_1" });
await createSession({ title: "新会话" });
expect(apiPost).toHaveBeenCalledWith("/chat/sessions", { title: "新会话" });
});
it("sendMessage POST 会话消息带 modality & idempotencyKey", async () => {
vi.mocked(apiPost).mockResolvedValue({ run_id: "run_1" });
await sendMessage("session_1", { content: "你好" }, "idem_1");
expect(apiPost).toHaveBeenCalledWith(
"/chat/sessions/session_1/messages",
{ content: "你好", modality: "text" },
{ idempotencyKey: "idem_1" },
);
});
it("listMessages GET 分页消息", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listMessages("session_1", { page: 1, page_size: 20 });
expect(apiGet).toHaveBeenCalledWith("/chat/sessions/session_1/messages", {
page: 1,
page_size: 20,
});
});
it("listSessions GET 支持 character_id 过滤", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listSessions({ character_id: "char_1", status: "active" });
expect(apiGet).toHaveBeenCalledWith("/chat/sessions", {
character_id: "char_1",
status: "active",
});
});
it("updateSession PATCH /chat/sessions/:id", async () => {
vi.mocked(apiPatch).mockResolvedValue({ id: "session_1" });
await updateSession("session_1", { title: "新标题" });
expect(apiPatch).toHaveBeenCalledWith("/chat/sessions/session_1", {
title: "新标题",
});
});
it("deleteSession DELETE /chat/sessions/:id", async () => {
vi.mocked(apiDelete).mockResolvedValue(undefined);
await deleteSession("session_1");
expect(apiDelete).toHaveBeenCalledWith("/chat/sessions/session_1");
});
});
-85
View File
@@ -1,85 +0,0 @@
/**
* 对话 API Client(对应 后端生成的 OpenAPI 契约 §7)。
* 创建会话、发送消息、拉取历史消息。
*/
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import type {
ChatMessage,
ChatSession,
CreateSessionRequest,
Paginated,
SendMessageRequest,
SendMessageResponse,
UpdateSessionRequest,
} from "@/types/api";
/** 创建会话;character_id 省略时后端使用当前激活人物 */
export function createSession(body: CreateSessionRequest = {}) {
return apiPost<ChatSession>("/chat/sessions", body);
}
/** 获取单个会话详情 */
export function getSession(sessionId: string) {
return apiGet<ChatSession>(`/chat/sessions/${sessionId}`);
}
/**
* 发送文本消息。
* 使用 Idempotency-Key 防止重复提交产生重复 Run(API §5.6)。
*/
export function sendMessage(
sessionId: string,
body: SendMessageRequest,
idempotencyKey: string,
) {
return apiPost<SendMessageResponse>(
`/chat/sessions/${sessionId}/messages`,
{ ...body, modality: body.modality ?? "text" },
{ idempotencyKey },
);
}
/** 拉取会话历史消息 */
export function listMessages(
sessionId: string,
params?: { page?: number; page_size?: number },
) {
return apiGet<Paginated<ChatMessage>>(
`/chat/sessions/${sessionId}/messages`,
params,
);
}
/** 会话列表(可选按人物 / 状态过滤) */
export function listSessions(params?: {
character_id?: string;
status?: ChatSession["status"];
}) {
return apiGet<Paginated<ChatSession>>("/chat/sessions", params);
}
/** 更新会话(重命名 / 归档 / 删除态切换) */
export function updateSession(sessionId: string, body: UpdateSessionRequest) {
return apiPatch<ChatSession>(`/chat/sessions/${sessionId}`, body);
}
/** 删除会话(软删除,status → deleted) */
export function deleteSession(sessionId: string) {
return apiDelete<void>(`/chat/sessions/${sessionId}`);
}
/** 选择/激活消息的某个历史版本。后端存储 active_version_idx,refresh 后返回 */
export function selectVersion(
sessionId: string,
messageId: string,
versionIdx: number,
) {
return apiPatch<{
message_id: string;
active_version_idx: number;
versions: import("@/types/api").MessageVersion[];
}>(`/chat/sessions/${sessionId}/messages/${messageId}/version`, {
version_idx: versionIdx,
});
}
-55
View File
@@ -1,55 +0,0 @@
/**
* 辰正时态 API Client(对应 后端生成的 OpenAPI 契约 §10)。
*
* 提供数字生命对时间流逝的感知:时态快照、纪念日、相识时长与配置。
*/
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
import type {
AcquaintanceInfo,
Anniversary,
CreateAnniversaryRequest,
TimeConfig,
TimeSnapshotResponse,
UpdateAnniversaryRequest,
} from "@/types/api";
/** 当前时态快照 + 调制因子(GET /time/snapshot) */
export function getTimeSnapshot() {
return apiGet<TimeSnapshotResponse>("/time/snapshot");
}
/** 纪念日列表(GET /time/anniversaries) */
export function listAnniversaries() {
return apiGet<Anniversary[]>("/time/anniversaries");
}
/** 新增纪念日(POST /time/anniversaries) */
export function createAnniversary(body: CreateAnniversaryRequest) {
return apiPost<Anniversary>("/time/anniversaries", body);
}
/** 单条纪念日(GET /time/anniversaries/{id}) */
export function getAnniversary(id: string) {
return apiGet<Anniversary>(`/time/anniversaries/${id}`);
}
/** 更新纪念日(PATCH /time/anniversaries/{id}) */
export function updateAnniversary(id: string, body: UpdateAnniversaryRequest) {
return apiPatch<Anniversary>(`/time/anniversaries/${id}`, body);
}
/** 删除纪念日(DELETE /time/anniversaries/{id}) */
export function deleteAnniversary(id: string) {
return apiDelete<void>(`/time/anniversaries/${id}`);
}
/** 相识时长(GET /time/acquaintance) */
export function getAcquaintance() {
return apiGet<AcquaintanceInfo>("/time/acquaintance");
}
/** 时态配置(GET /time/config,只读) */
export function getTimeConfig() {
return apiGet<TimeConfig>("/time/config");
}