M2b:导入改造产出可审阅灵胚(启镜物化世谱)与组装管线(符文展开/火候进入 IR),Kernel 打通采样参数链路
This commit is contained in:
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
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Response, UploadFile
|
from fastapi import APIRouter, Depends, File, Response, UploadFile
|
||||||
|
|
||||||
from app.api.v1.deps import get_current_user
|
from app.api.v1.deps import get_current_user
|
||||||
|
from app.domains import jingsheng
|
||||||
from app.schemas.auth import CurrentUser
|
from app.schemas.auth import CurrentUser
|
||||||
from app.schemas.character import ImportCardResponse
|
from app.services.card_import import card_to_embryo_payload, export_character_card
|
||||||
from app.services.card_import import export_character_card, import_character_card_response
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/characters", tags=["characters"])
|
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(
|
async def import_character_card_api(
|
||||||
file: UploadFile = File(..., description="角色卡文件,支持 .png / .json / .charx。"),
|
file: UploadFile = File(..., description="角色卡文件,支持 .png / .json / .charx。"),
|
||||||
current_user: CurrentUser = Depends(get_current_user),
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
) -> ImportCardResponse:
|
) -> dict:
|
||||||
"""导入酒馆角色卡(V1/V2/V3),建立人物 + 世谱(character_book)。
|
"""导入酒馆角色卡(V1/V2/V3)→ 生成可审阅灵胚。
|
||||||
|
|
||||||
``personality`` 写入道体 ``personality_summary`` 作初始值;
|
卡内容整体进入灵胚道体草稿(identity_baseline.import_card),
|
||||||
``character_book`` 转为该角色的世谱书 + 条目。
|
``character_book`` 在启镜时物化为该人物的世谱书与条目。
|
||||||
"""
|
"""
|
||||||
raw = await file.read()
|
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")
|
@router.get("/{character_id}/export-card")
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
@@ -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()
|
|
||||||
@@ -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(Custodianship(character_id=character.id, custodian_scope=owner_scope))
|
||||||
session.add(RuntimeSeat(character_id=character.id, epoch=1))
|
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(
|
record = build_identity_record(
|
||||||
character_id=character.id,
|
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(),
|
"activated_at": character.activated_at.isoformat(),
|
||||||
},
|
},
|
||||||
runtime_state={"status": "active", "seat_epoch": 1},
|
runtime_state={"status": "active", "seat_epoch": 1},
|
||||||
|
data_state={"imported_book_entries": book_entry_count},
|
||||||
)
|
)
|
||||||
session.add(record)
|
session.add(record)
|
||||||
await session.flush() # 取魂籍 ID
|
await session.flush() # 取魂籍 ID
|
||||||
@@ -307,3 +309,44 @@ async def _owned_character(
|
|||||||
if character is None or character.owner_scope != owner_scope:
|
if character is None or character.owner_scope != owner_scope:
|
||||||
raise AppError("not_found", "人物不存在。")
|
raise AppError("not_found", "人物不存在。")
|
||||||
return character
|
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)
|
||||||
@@ -383,25 +383,80 @@ def _citation_row(node_id: str, data: dict) -> Citation:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def build_generation_prompt(question: TreeNode, instructions: str | None) -> str:
|
async def build_generation_prompt(
|
||||||
"""按祖先链组装投影 prompt(M2 形态:祖先路径 + 问题 + 临时指引;记忆/引文随 M3)。"""
|
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)
|
chain = await ancestor_chain(question.id)
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
for n in chain:
|
for n in chain:
|
||||||
if n.id == question.id:
|
if n.id == question.id:
|
||||||
continue
|
continue
|
||||||
speaker = "用户" if n.node_type == "question" else "人物"
|
speaker = "用户" if n.node_type == "question" else "人物"
|
||||||
lines.append(f"{speaker}:{n.content}")
|
lines.append(f"{speaker}:{expand(n.content)}")
|
||||||
transcript = "\n".join(lines)
|
transcript = "\n".join(lines)
|
||||||
parts = []
|
parts = []
|
||||||
if transcript:
|
if transcript:
|
||||||
parts.append(f"【共同经历(祖先路径)】\n{transcript}")
|
parts.append(f"【共同经历(祖先路径)】\n{transcript}")
|
||||||
parts.append(f"【当前问题】\n{question.content}")
|
parts.append(f"【当前问题】\n{expand(question.content)}")
|
||||||
if instructions:
|
if instructions:
|
||||||
parts.append(f"【本次指引】\n{instructions}")
|
parts.append(f"【本次指引】\n{expand(instructions)}")
|
||||||
return "\n\n".join(parts)
|
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(
|
async def spawn_answer(
|
||||||
*,
|
*,
|
||||||
question_id: str,
|
question_id: str,
|
||||||
@@ -436,13 +491,18 @@ async def spawn_answer(
|
|||||||
source="mirror_tree",
|
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(
|
run = await kernel.start_foreground_run(
|
||||||
character_id=tree.character_id,
|
character_id=tree.character_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
system_contract=None,
|
system_contract=None,
|
||||||
origin="tree_answer",
|
origin="tree_answer",
|
||||||
on_run_final=_make_finalize(tree.id, tree.character_id, node_id),
|
on_run_final=_make_finalize(tree.id, tree.character_id, node_id),
|
||||||
|
**overrides,
|
||||||
)
|
)
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
node = await session.get(TreeNode, node_id)
|
node = await session.get(TreeNode, node_id)
|
||||||
|
|||||||
@@ -55,13 +55,14 @@ class Kernel:
|
|||||||
system_contract: str | None = None,
|
system_contract: str | None = None,
|
||||||
origin: str = "kernel",
|
origin: str = "kernel",
|
||||||
on_run_final: OnRunFinal | None = None,
|
on_run_final: OnRunFinal | None = None,
|
||||||
|
temperature: float | None = None,
|
||||||
|
max_tokens: int | None = None,
|
||||||
) -> Run:
|
) -> Run:
|
||||||
"""创建 Run 行(queued)并异步启动循环;前台席位在循环内排队获取。"""
|
"""创建 Run 行(queued)并异步启动循环;前台席位在循环内排队获取。"""
|
||||||
profile = resolve_profile()
|
profile = resolve_profile()
|
||||||
async with self._sessions() as session:
|
async with self._sessions() as session:
|
||||||
run = Run(
|
run = Run(
|
||||||
character_id=character_id,
|
character_id=character_id,
|
||||||
session_id=None,
|
|
||||||
status="queued",
|
status="queued",
|
||||||
kind="foreground",
|
kind="foreground",
|
||||||
goal=prompt[:300] if prompt else None,
|
goal=prompt[:300] if prompt else None,
|
||||||
@@ -79,6 +80,8 @@ class Kernel:
|
|||||||
seat_key=None,
|
seat_key=None,
|
||||||
system_contract=system_contract,
|
system_contract=system_contract,
|
||||||
on_final=on_run_final,
|
on_final=on_run_final,
|
||||||
|
temperature=temperature,
|
||||||
|
max_tokens=max_tokens,
|
||||||
)
|
)
|
||||||
async with self._sessions() as session:
|
async with self._sessions() as session:
|
||||||
created = await session.get(Run, run_id)
|
created = await session.get(Run, run_id)
|
||||||
@@ -93,7 +96,6 @@ class Kernel:
|
|||||||
async with self._sessions() as session:
|
async with self._sessions() as session:
|
||||||
run = Run(
|
run = Run(
|
||||||
character_id=None,
|
character_id=None,
|
||||||
session_id=None,
|
|
||||||
status="queued",
|
status="queued",
|
||||||
kind="rehearsal",
|
kind="rehearsal",
|
||||||
goal=prompt[:300] if prompt else None,
|
goal=prompt[:300] if prompt else None,
|
||||||
@@ -127,6 +129,8 @@ class Kernel:
|
|||||||
seat_key: str | None,
|
seat_key: str | None,
|
||||||
system_contract: str | None,
|
system_contract: str | None,
|
||||||
on_final: OnRunFinal | None,
|
on_final: OnRunFinal | None,
|
||||||
|
temperature: float | None = None,
|
||||||
|
max_tokens: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
task = asyncio.create_task(
|
task = asyncio.create_task(
|
||||||
self.loop.execute(
|
self.loop.execute(
|
||||||
@@ -137,6 +141,8 @@ class Kernel:
|
|||||||
seat_key=seat_key,
|
seat_key=seat_key,
|
||||||
system_contract=system_contract,
|
system_contract=system_contract,
|
||||||
on_final=on_final,
|
on_final=on_final,
|
||||||
|
temperature=temperature,
|
||||||
|
max_tokens=max_tokens,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ class KernelRunLoop:
|
|||||||
seat_key: str | None = None,
|
seat_key: str | None = None,
|
||||||
system_contract: str | None = None,
|
system_contract: str | None = None,
|
||||||
on_final: OnRunFinal | None = None,
|
on_final: OnRunFinal | None = None,
|
||||||
|
temperature: float | None = None,
|
||||||
|
max_tokens: int | None = None,
|
||||||
) -> RunOutcome:
|
) -> RunOutcome:
|
||||||
assert character_id or seat_key, "Run 必须绑定人物或提供席位键(试演)"
|
assert character_id or seat_key, "Run 必须绑定人物或提供席位键(试演)"
|
||||||
trace = trace_id or new_trace_id()
|
trace = trace_id or new_trace_id()
|
||||||
@@ -101,6 +103,8 @@ class KernelRunLoop:
|
|||||||
seat_key=seat_key,
|
seat_key=seat_key,
|
||||||
system_contract=system_contract,
|
system_contract=system_contract,
|
||||||
outcome=outcome,
|
outcome=outcome,
|
||||||
|
temperature=temperature,
|
||||||
|
max_tokens=max_tokens,
|
||||||
)
|
)
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
outcome.status = "interrupted"
|
outcome.status = "interrupted"
|
||||||
@@ -128,6 +132,8 @@ class KernelRunLoop:
|
|||||||
seat_key: str | None,
|
seat_key: str | None,
|
||||||
system_contract: str | None,
|
system_contract: str | None,
|
||||||
outcome: RunOutcome,
|
outcome: RunOutcome,
|
||||||
|
temperature: float | None = None,
|
||||||
|
max_tokens: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
ctx = RunContext(run_id=run_id, character_id=character_id, trace_id=trace_id)
|
ctx = RunContext(run_id=run_id, character_id=character_id, trace_id=trace_id)
|
||||||
try:
|
try:
|
||||||
@@ -151,6 +157,8 @@ class KernelRunLoop:
|
|||||||
prompt,
|
prompt,
|
||||||
system_contract or _SYSTEM_CONTRACT_PLACEHOLDER,
|
system_contract or _SYSTEM_CONTRACT_PLACEHOLDER,
|
||||||
outcome,
|
outcome,
|
||||||
|
temperature=temperature,
|
||||||
|
max_tokens=max_tokens,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _turn(
|
async def _turn(
|
||||||
@@ -161,6 +169,8 @@ class KernelRunLoop:
|
|||||||
prompt: str,
|
prompt: str,
|
||||||
system_contract: str,
|
system_contract: str,
|
||||||
outcome: RunOutcome,
|
outcome: RunOutcome,
|
||||||
|
temperature: float | None = None,
|
||||||
|
max_tokens: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
ctx = RunContext(
|
ctx = RunContext(
|
||||||
run_id=run_id, character_id=character_id, trace_id=trace_id, payload={"prompt": prompt}
|
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,
|
run_id=run_id,
|
||||||
trace_id=trace_id,
|
trace_id=trace_id,
|
||||||
character_id=character_id,
|
character_id=character_id,
|
||||||
|
temperature=temperature,
|
||||||
|
max_tokens=max_tokens,
|
||||||
)
|
)
|
||||||
await self._emit(
|
await self._emit(
|
||||||
run_id,
|
run_id,
|
||||||
|
|||||||
@@ -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))
|
|
||||||
@@ -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)
|
|
||||||
@@ -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.
@@ -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"}
|
|
||||||
]
|
|
||||||
@@ -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", "单条文本消息超过大小限制。")
|
|
||||||
@@ -44,8 +44,11 @@ def character_book_to_entries(book: dict | None) -> list[dict]:
|
|||||||
"""ST character_book entries → 世谱条目构造参数。"""
|
"""ST character_book entries → 世谱条目构造参数。"""
|
||||||
if not book:
|
if not book:
|
||||||
return []
|
return []
|
||||||
|
raw_entries = book.get("entries") or []
|
||||||
|
if isinstance(raw_entries, dict): # ST V2 规范:entries 以 uid 为键的对象
|
||||||
|
raw_entries = list(raw_entries.values())
|
||||||
result = []
|
result = []
|
||||||
for e in book.get("entries") or []:
|
for e in raw_entries:
|
||||||
enabled_raw = e.get("enabled", True)
|
enabled_raw = e.get("enabled", True)
|
||||||
result.append(
|
result.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -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()
|
|
||||||
@@ -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}
|
|
||||||
@@ -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 = (
|
|
||||||
"你对用户保持着温和的关注,"
|
|
||||||
"平日里话不多,但每次开口都带着真诚。"
|
|
||||||
"你善于观察,能从细微之处感知对方的情绪。"
|
|
||||||
)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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)。",
|
|
||||||
)
|
|
||||||
@@ -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
|
|
||||||
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.errors import AppError
|
from app.core.errors import AppError
|
||||||
from app.db.session import AsyncSessionLocal
|
from app.db.session import AsyncSessionLocal
|
||||||
from app.models.character import Character
|
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.card_parser import parse_character_card
|
||||||
from app.modules.imports.export_mapping import character_to_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(
|
def card_to_embryo_payload(raw: bytes) -> dict:
|
||||||
session: AsyncSession, raw: bytes, owner_scope: str
|
"""解析角色卡字节 → 灵胚创建载荷(name/description/identity_baseline)。
|
||||||
) -> Character:
|
|
||||||
"""解析角色卡字节 → 建 Character + 世谱(character_book)。返回 Character ORM。
|
|
||||||
|
|
||||||
``personality`` 写入道体 ``personality_summary`` 作初始值;
|
identity_baseline 保存卡的全部有效字段与原始 character_book,
|
||||||
``character_book`` 转为该角色的世谱书 + 条目。
|
启镜前可经照心编辑(PATCH /embryo-drafts/{id})审阅修改。
|
||||||
"""
|
"""
|
||||||
card = parse_character_card(raw)
|
card = parse_character_card(raw)
|
||||||
daoti = card_to_daoti(card)
|
description = "\n\n".join(
|
||||||
|
part
|
||||||
character = Character(
|
for part in (
|
||||||
name=card.name or "未命名角色",
|
card.description.strip(),
|
||||||
display_name=card.nickname or card.name or None,
|
f"【性格】{card.personality.strip()}" if card.personality.strip() else "",
|
||||||
language="zh-CN",
|
f"【场景】{card.scenario.strip()}" if card.scenario.strip() else "",
|
||||||
profile_json=dumps(card_to_profile(card)),
|
|
||||||
model_policy_json="{}",
|
|
||||||
personality_summary=daoti["personality_summary"],
|
|
||||||
privacy_level="normal",
|
|
||||||
owner_scope=owner_scope,
|
|
||||||
)
|
)
|
||||||
session.add(character)
|
if part
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
entries_data = character_book_to_entries(card.character_book)
|
|
||||||
if entries_data:
|
|
||||||
book = card.character_book or {}
|
|
||||||
lorebook = Lorebook(
|
|
||||||
character_id=character.id,
|
|
||||||
name=book.get("name", "") or card.name,
|
|
||||||
description=book.get("description", ""),
|
|
||||||
scan_depth=book.get("scan_depth"),
|
|
||||||
token_budget=book.get("token_budget"),
|
|
||||||
recursive_scanning=bool(book.get("recursive_scanning", False)),
|
|
||||||
scope="character",
|
|
||||||
owner_scope=owner_scope,
|
|
||||||
)
|
)
|
||||||
session.add(lorebook)
|
baseline = {
|
||||||
await session.flush()
|
"import_card": {
|
||||||
for entry in entries_data:
|
"spec_version": card.spec_version,
|
||||||
session.add(
|
"description": card.description,
|
||||||
LorebookEntry(
|
"personality": card.personality,
|
||||||
lorebook_id=lorebook.id,
|
"scenario": card.scenario,
|
||||||
keys_json=dumps(entry["keys"]),
|
"first_mes": card.first_mes,
|
||||||
content=entry["content"],
|
"mes_example": card.mes_example,
|
||||||
secondary_keys_json=dumps(entry["secondary_keys"]),
|
"system_prompt": card.system_prompt,
|
||||||
constant=entry["constant"],
|
"post_history_instructions": card.post_history_instructions,
|
||||||
selective=entry["selective"],
|
"alternate_greetings": card.alternate_greetings,
|
||||||
enabled=entry["enabled"],
|
"tags": card.tags,
|
||||||
case_sensitive=entry["case_sensitive"],
|
"creator": card.creator,
|
||||||
use_regex=entry["use_regex"],
|
"character_version": card.character_version,
|
||||||
position=entry["position"],
|
"character_book": card.character_book,
|
||||||
insertion_order=entry["insertion_order"],
|
}
|
||||||
priority=entry["priority"],
|
}
|
||||||
comment=entry["comment"],
|
name = card.name.strip() or "未命名灵胚"
|
||||||
)
|
return {
|
||||||
)
|
"name": name[:120],
|
||||||
|
"description": description or None,
|
||||||
await session.commit()
|
"identity_baseline": baseline,
|
||||||
await session.refresh(character)
|
"book_entry_count": _count_book_entries(card.character_book),
|
||||||
return character
|
}
|
||||||
|
|
||||||
|
|
||||||
async def import_character_card_response(
|
def _count_book_entries(book: dict | None) -> int:
|
||||||
raw: bytes, owner_scope: str
|
if not book:
|
||||||
) -> ImportCardResponse:
|
return 0
|
||||||
"""导入角色卡 → ImportCardResponse(供 API 调用,自管 session)。"""
|
entries = book.get("entries")
|
||||||
async with AsyncSessionLocal() as session:
|
if isinstance(entries, (dict, list)):
|
||||||
character = await import_character_card(session, raw, owner_scope)
|
return len(entries)
|
||||||
books = await lorebook_repo.list_books(session, character.id, owner_scope)
|
return 0
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def export_character_card(character_id: str, owner_scope: str) -> dict:
|
async def export_character_card(character_id: str, owner_scope: str) -> dict:
|
||||||
"""导出角色为 ST V3 卡静态快照(丢弃演化维度)。"""
|
"""导出人物为 ST V3 卡静态快照(丢弃演化维度)。"""
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
character = await session.get(Character, character_id)
|
character = await session.get(Character, character_id)
|
||||||
if character is None or character.owner_scope != owner_scope:
|
if character is None or character.owner_scope != owner_scope:
|
||||||
raise AppError("not_found", "人物不存在。")
|
raise AppError("not_found", "人物不存在。")
|
||||||
books = await lorebook_repo.list_books(session, character_id, owner_scope)
|
return character_to_card(character)
|
||||||
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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
@@ -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"
|
|
||||||
@@ -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} />;
|
|
||||||
}
|
|
||||||
@@ -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("单条消息过长,请精简后发送。");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user