M2b:删除旧 chat 体系——线性会话/叩心/情绪/纪念日模型与页面退役(迁移 003 双向验证),runs 去会话外键,测试全量修剪
This commit is contained in:
1 parent
5f36e3785c
commit
ad9c9e942a
22 files changed
+424
-427
No files matched your search
@@ -0,0 +1,73 @@
|
|||||||
|
# 后端开发
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
Python 3.12 + FastAPI + SQLAlchemy + Alembic + loguru + httpx + chromadb
|
||||||
|
uv (用于虚拟环境和依赖管理)
|
||||||
|
|
||||||
|
## 开发命令
|
||||||
|
|
||||||
|
启动命令之前确保目前在backend目录下,并且已经激活了虚拟环境。默认本地开发使用 SQLite(aiosqlite)文件数据库,Redis、ChromaDB、Neo4j 暂不启用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv venv -p 3.12 # 创建虚拟环境
|
||||||
|
source .venv/bin/activate # 激活虚拟环境
|
||||||
|
uv add <pkg> # 添加依赖
|
||||||
|
uv remove <pkg> # 移除依赖
|
||||||
|
uv run python -m app.main # 按 .env 中 BACKEND_HOST/BACKEND_PORT 启动服务
|
||||||
|
uv run uvicorn app.main:app --reload --port 8000 # 手动指定端口启动服务(命令行 --port 会覆盖 .env)
|
||||||
|
|
||||||
|
# 数据库迁移
|
||||||
|
uv run alembic revision --autogenerate --rev-id=001 -m "描述" # 生成迁移
|
||||||
|
uv run alembic upgrade head # 执行迁移
|
||||||
|
uv run alembic downgrade -1 # 回滚一个版本
|
||||||
|
uv run alembic current # 查看当前版本
|
||||||
|
|
||||||
|
# 测试
|
||||||
|
uv run pytest # 运行测试
|
||||||
|
```
|
||||||
|
|
||||||
|
## 代码规范
|
||||||
|
|
||||||
|
在__init__文件中可以将模块内的类、函数等导出,方便其他模块导入使用
|
||||||
|
遵循 PEP 8 代码风格指南和更加pythonic的开发方式
|
||||||
|
遵循单一职责原则,每个文件、类、函数等都要只干一件事
|
||||||
|
使用 loguru 进行日志记录,避免使用 logging 模块
|
||||||
|
使用 Alembic 进行数据库迁移,禁止手动修改数据库表结构,文件命名以迁移版本号开头,例如 001_initial.py;迁移必须优先兼容 SQLite,涉及 PostgreSQL 专用 DDL 时必须提供 SQLite 分支或显式跳过
|
||||||
|
使用alembic init alembic命令初始化迁移环境,使用命令生成相关的迁移文件,不能直接生成迁移文件
|
||||||
|
使用 httpx 进行异步 HTTP 请求,禁止在路由函数内直接使用同步数据库会话或 httpx 客户端
|
||||||
|
所有的测试代码都写在tests目录下,测试文件命名以test_开头,测试函数命名以test_开头
|
||||||
|
所有敏感信息(如密钥、Token、数据库密码)必须通过环境变量或配置文件管理,禁止硬编码到代码中
|
||||||
|
|
||||||
|
### 分层硬规矩
|
||||||
|
|
||||||
|
- `modules/` 禁止 import `repositories` / `db` / `integrations` / `httpx` / `sqlalchemy`——一旦需要 IO,立即上移到 `services` 或 `application`。
|
||||||
|
- 路由函数禁止直接 `Depends(get_session)` 或写业务逻辑——改为注入 usecase / service。
|
||||||
|
- `repositories/` 返回 ORM,禁止把 Query / Session 暴露到上层。
|
||||||
|
- 模型能力(对话/多模态/嵌入/重排)一律进 `ai_provider/`;存储/检索/外部接入进 `integrations/`。
|
||||||
|
- 配置一律进 `core/config/`,业务目录不持有 `settings.py` / `config.py`。
|
||||||
|
- `.env` 只放项目根目录,`Settings` 以绝对路径 `_env_file` 初始化,不使用 `python-dotenv`。
|
||||||
|
|
||||||
|
## 代码审查
|
||||||
|
|
||||||
|
开发完成后必须使用以下命令进行代码检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run ruff check # Lint 检查
|
||||||
|
uv run ruff check --fix # 自动修复部分代码风格问题
|
||||||
|
uv run pre-commit run --all-files # 可选:完整运行 CI 级检查(包括类型检查)
|
||||||
|
```
|
||||||
|
|
||||||
|
确保没有任何 lint 错误后再提交。
|
||||||
|
|
||||||
|
## 绝对禁止事项
|
||||||
|
|
||||||
|
直接修改 pyproject.toml 文件添加或者移除依赖(必须使用 uv add/remove 命令)
|
||||||
|
在路由函数内直接使用同步数据库会话(必须通过 Depends 注入)
|
||||||
|
在路由函数内直接 async with httpx.AsyncClient()
|
||||||
|
使用 logging 模块(统一用 loguru)
|
||||||
|
硬编码密钥、Token、数据库密码到代码
|
||||||
|
手动创建/修改数据库表结构(必须使用 Alembic 迁移)
|
||||||
|
在 `modules/` 中 import `repositories` / `db` / `integrations` / `ai_provider` / `httpx` / `sqlalchemy`(领域层禁止 IO,需要 IO 时上移到 services/application)
|
||||||
|
在业务目录(`integrations` 等)中持有 `settings.py` / `config.py`(配置统一进 `core/config/`)
|
||||||
|
使用 `python-dotenv`(`.env` 由 `core/config` 以绝对路径 `_env_file` 加载)
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""退役旧会话模型:删除 chat/情绪/纪念日表与 runs 旧会话外键
|
||||||
|
|
||||||
|
Revision ID: 003
|
||||||
|
Revises: 002
|
||||||
|
Create Date: 2026-08-21 23:17:17.055812
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '003'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '002'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# 顺序约束:先去掉 runs.session_id(batch 反射 runs 外键时 chat_sessions 须仍存在),
|
||||||
|
# 再删除旧会话相关表;SQLite 下 batch 自动处理索引与无名外键。
|
||||||
|
op.drop_index('ix_runs_session_id', table_name='runs')
|
||||||
|
with op.batch_alter_table('runs') as batch_op:
|
||||||
|
batch_op.drop_column('session_id')
|
||||||
|
op.drop_table('character_emotion_states')
|
||||||
|
op.drop_index(op.f('ix_chat_sessions_character_id'), table_name='chat_sessions')
|
||||||
|
op.drop_index(op.f('ix_chat_sessions_owner_scope'), table_name='chat_sessions')
|
||||||
|
op.drop_index(op.f('ix_chat_sessions_status'), table_name='chat_sessions')
|
||||||
|
op.drop_table('chat_sessions')
|
||||||
|
op.drop_index(op.f('ix_chat_messages_character_id'), table_name='chat_messages')
|
||||||
|
op.drop_index(op.f('ix_chat_messages_run_id'), table_name='chat_messages')
|
||||||
|
op.drop_index(op.f('ix_chat_messages_session_id'), table_name='chat_messages')
|
||||||
|
op.drop_table('chat_messages')
|
||||||
|
op.drop_index(op.f('ix_time_anniversaries_character_id'), table_name='time_anniversaries')
|
||||||
|
op.drop_table('time_anniversaries')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
# 先重建 chat 表,再经 batch 恢复 runs.session_id 列与外键(SQLite 安全)
|
||||||
|
op.create_table('time_anniversaries',
|
||||||
|
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
|
||||||
|
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
|
||||||
|
sa.Column('name', sa.VARCHAR(length=200), nullable=False),
|
||||||
|
sa.Column('calendar', sa.VARCHAR(length=20), nullable=False),
|
||||||
|
sa.Column('date', sa.VARCHAR(length=20), nullable=False),
|
||||||
|
sa.Column('recurs_yearly', sa.BOOLEAN(), nullable=False),
|
||||||
|
sa.Column('kind', sa.VARCHAR(length=40), nullable=False),
|
||||||
|
sa.Column('subject_type', sa.VARCHAR(length=40), nullable=False),
|
||||||
|
sa.Column('source', sa.VARCHAR(length=40), nullable=False),
|
||||||
|
sa.Column('metadata_json', sa.TEXT(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DATETIME(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DATETIME(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_time_anniversaries_character_id'), 'time_anniversaries', ['character_id'], unique=False)
|
||||||
|
op.create_table('chat_messages',
|
||||||
|
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
|
||||||
|
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
|
||||||
|
sa.Column('session_id', sa.VARCHAR(length=80), nullable=False),
|
||||||
|
sa.Column('role', sa.VARCHAR(length=40), nullable=False),
|
||||||
|
sa.Column('content', sa.TEXT(), nullable=False),
|
||||||
|
sa.Column('modality', sa.VARCHAR(length=40), nullable=False),
|
||||||
|
sa.Column('run_id', sa.VARCHAR(length=80), nullable=True),
|
||||||
|
sa.Column('metadata_json', sa.TEXT(), nullable=False),
|
||||||
|
sa.Column('sensitivity', sa.VARCHAR(length=40), nullable=False),
|
||||||
|
sa.Column('versions_json', sa.TEXT(), nullable=False),
|
||||||
|
sa.Column('active_version_idx', sa.INTEGER(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DATETIME(), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DATETIME(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['run_id'], ['runs.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['session_id'], ['chat_sessions.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_chat_messages_session_id'), 'chat_messages', ['session_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chat_messages_run_id'), 'chat_messages', ['run_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chat_messages_character_id'), 'chat_messages', ['character_id'], unique=False)
|
||||||
|
op.create_table('chat_sessions',
|
||||||
|
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
|
||||||
|
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
|
||||||
|
sa.Column('title', sa.VARCHAR(length=200), nullable=True),
|
||||||
|
sa.Column('status', sa.VARCHAR(length=40), nullable=False),
|
||||||
|
sa.Column('owner_scope', sa.VARCHAR(length=80), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DATETIME(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DATETIME(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_chat_sessions_status'), 'chat_sessions', ['status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chat_sessions_owner_scope'), 'chat_sessions', ['owner_scope'], unique=False)
|
||||||
|
op.create_index(op.f('ix_chat_sessions_character_id'), 'chat_sessions', ['character_id'], unique=False)
|
||||||
|
op.create_table('character_emotion_states',
|
||||||
|
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
|
||||||
|
sa.Column('mood', sa.VARCHAR(length=40), nullable=False),
|
||||||
|
sa.Column('intensity', sa.FLOAT(), nullable=False),
|
||||||
|
sa.Column('baseline_mood', sa.VARCHAR(length=40), nullable=False),
|
||||||
|
sa.Column('baseline_intensity', sa.FLOAT(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DATETIME(), nullable=False),
|
||||||
|
sa.Column('last_trigger_event_id', sa.VARCHAR(length=80), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('character_id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('runs') as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('session_id', sa.String(length=80), nullable=True))
|
||||||
|
batch_op.create_foreign_key('fk_runs_session_id', 'chat_sessions', ['session_id'], ['id'])
|
||||||
|
batch_op.create_index('ix_runs_session_id', ['session_id'])
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -9,7 +9,6 @@ from app.api.v1.endpoints import (
|
|||||||
avatar,
|
avatar,
|
||||||
character_import,
|
character_import,
|
||||||
characters,
|
characters,
|
||||||
chat,
|
|
||||||
drive,
|
drive,
|
||||||
events,
|
events,
|
||||||
evolution,
|
evolution,
|
||||||
@@ -30,7 +29,6 @@ from app.api.v1.endpoints import (
|
|||||||
sandbox_files,
|
sandbox_files,
|
||||||
settings,
|
settings,
|
||||||
skills,
|
skills,
|
||||||
time,
|
|
||||||
tools,
|
tools,
|
||||||
world,
|
world,
|
||||||
)
|
)
|
||||||
@@ -40,7 +38,6 @@ api_router.include_router(health.router)
|
|||||||
api_router.include_router(auth.router)
|
api_router.include_router(auth.router)
|
||||||
api_router.include_router(characters.router)
|
api_router.include_router(characters.router)
|
||||||
api_router.include_router(character_import.router)
|
api_router.include_router(character_import.router)
|
||||||
api_router.include_router(chat.router)
|
|
||||||
api_router.include_router(runs.router)
|
api_router.include_router(runs.router)
|
||||||
api_router.include_router(kernel_runs.router)
|
api_router.include_router(kernel_runs.router)
|
||||||
api_router.include_router(embryo_drafts.router)
|
api_router.include_router(embryo_drafts.router)
|
||||||
@@ -66,4 +63,3 @@ api_router.include_router(goals.router)
|
|||||||
api_router.include_router(admin.router)
|
api_router.include_router(admin.router)
|
||||||
api_router.include_router(audio.router)
|
api_router.include_router(audio.router)
|
||||||
api_router.include_router(avatar.router)
|
api_router.include_router(avatar.router)
|
||||||
api_router.include_router(time.router)
|
|
||||||
@@ -38,7 +38,6 @@ router = APIRouter(prefix="/runs", tags=["runs"])
|
|||||||
@router.get("", response_model=Page[RunResponse])
|
@router.get("", response_model=Page[RunResponse])
|
||||||
async def list_runs(
|
async def list_runs(
|
||||||
character_id: str | None = Query(default=None, description="按人物过滤。"),
|
character_id: str | None = Query(default=None, description="按人物过滤。"),
|
||||||
session_id: str | None = Query(default=None, description="按会话过滤。"),
|
|
||||||
status: str | None = Query(default=None, description="按状态过滤。"),
|
status: str | None = Query(default=None, description="按状态过滤。"),
|
||||||
page: int = Query(default=1, ge=1),
|
page: int = Query(default=1, ge=1),
|
||||||
page_size: int = Query(default=20, ge=1, le=100),
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
@@ -46,7 +45,6 @@ async def list_runs(
|
|||||||
"""Run 列表(按创建时间倒序);归档页运行时间河数据源。"""
|
"""Run 列表(按创建时间倒序);归档页运行时间河数据源。"""
|
||||||
return await list_runs_response(
|
return await list_runs_response(
|
||||||
character_id=character_id,
|
character_id=character_id,
|
||||||
session_id=session_id,
|
|
||||||
status=status,
|
status=status,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
|
|||||||
@@ -30,12 +30,10 @@ from app.core.logging import (
|
|||||||
from app.db.init_data import seed_default_data
|
from app.db.init_data import seed_default_data
|
||||||
from app.db.session import AsyncSessionLocal, close_engine
|
from app.db.session import AsyncSessionLocal, close_engine
|
||||||
from app.integrations.sandbox.client import SandboxClient
|
from app.integrations.sandbox.client import SandboxClient
|
||||||
from app.modules.knocking.pivot import KnockingPivot
|
|
||||||
from app.modules.safety.confirmation import InMemoryConfirmationStore
|
from app.modules.safety.confirmation import InMemoryConfirmationStore
|
||||||
from app.schemas.common import ErrorResponse
|
from app.schemas.common import ErrorResponse
|
||||||
from app.services.dispatcher import Dispatcher
|
from app.services.dispatcher import Dispatcher
|
||||||
from app.services.event_store import DbEventStore
|
from app.services.event_store import DbEventStore
|
||||||
from app.services.personality_emotion import handle_time_event
|
|
||||||
from app.services.safety import load_governor_from_db
|
from app.services.safety import load_governor_from_db
|
||||||
|
|
||||||
# 模块级初始化日志系统:import 本模块即完成 loguru 配置并接管标准 logging,
|
# 模块级初始化日志系统:import 本模块即完成 loguru 配置并接管标准 logging,
|
||||||
@@ -117,12 +115,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
# 一炁进程内订阅分发器:订阅 EventStore 全量事件,按 glob pattern 分发给注册的 handler。
|
# 一炁进程内订阅分发器:订阅 EventStore 全量事件,按 glob pattern 分发给注册的 handler。
|
||||||
# 各枢核心就绪后在 start() 前 register;lifespan 管理 start/stop。
|
# 各枢核心就绪后在 start() 前 register;lifespan 管理 start/stop。
|
||||||
_app.state.dispatcher = Dispatcher(_app.state.event_store)
|
_app.state.dispatcher = Dispatcher(_app.state.event_store)
|
||||||
# 灵核情绪状态机:订阅辰正 time.* 事件驱动 EmotionState(设计 §11,首个真实 handler)
|
|
||||||
_app.state.dispatcher.register("time.*", handle_time_event)
|
|
||||||
await _app.state.dispatcher.start()
|
await _app.state.dispatcher.start()
|
||||||
# 叩心枢主动对话守护进程:后台常驻循环,在 dispatcher 就绪后启动
|
|
||||||
_app.state.knocking_pivot = KnockingPivot(_app.state.event_store)
|
|
||||||
await _app.state.knocking_pivot.start()
|
|
||||||
_app.state.confirmation_store = InMemoryConfirmationStore()
|
_app.state.confirmation_store = InMemoryConfirmationStore()
|
||||||
if settings.use_db_confirmation_store:
|
if settings.use_db_confirmation_store:
|
||||||
from app.modules.safety.confirmation import DbConfirmationStore
|
from app.modules.safety.confirmation import DbConfirmationStore
|
||||||
@@ -177,9 +170,6 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# 叩心枢先停,再停 dispatcher
|
|
||||||
if getattr(_app.state, "knocking_pivot", None) is not None:
|
|
||||||
await _app.state.knocking_pivot.stop()
|
|
||||||
# Dispatcher 先停(取消 pump/consumer task + unsubscribe all_queue),再关 http/engine
|
# Dispatcher 先停(取消 pump/consumer task + unsubscribe all_queue),再关 http/engine
|
||||||
if getattr(_app.state, "dispatcher", None) is not None:
|
if getattr(_app.state, "dispatcher", None) is not None:
|
||||||
await _app.state.dispatcher.stop()
|
await _app.state.dispatcher.stop()
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
from app.models.big_cycle import BigCycle
|
from app.models.big_cycle import BigCycle
|
||||||
from app.models.candidates import MemoryCandidate, ProfileCandidate
|
from app.models.candidates import MemoryCandidate, ProfileCandidate
|
||||||
from app.models.character import Character
|
from app.models.character import Character
|
||||||
from app.models.chat import ChatMessage, ChatSession
|
|
||||||
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
|
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
|
||||||
from app.models.evolution import EvolutionCandidate
|
from app.models.evolution import EvolutionCandidate
|
||||||
from app.models.governance import (
|
from app.models.governance import (
|
||||||
@@ -28,12 +27,10 @@ from app.models.mirror_tree import (
|
|||||||
)
|
)
|
||||||
from app.models.memory import Memory, MemoryLink
|
from app.models.memory import Memory, MemoryLink
|
||||||
from app.models.personality import PersonalityProposal, PersonalityVersion
|
from app.models.personality import PersonalityProposal, PersonalityVersion
|
||||||
from app.models.personality_emotion import CharacterEmotionState
|
|
||||||
from app.models.rbac import Permission, Role, RolePermission, UserRole
|
from app.models.rbac import Permission, Role, RolePermission, UserRole
|
||||||
from app.models.runtime import Event, Run
|
from app.models.runtime import Event, Run
|
||||||
from app.models.safety import AuditEvent, PendingConfirmationRecord
|
from app.models.safety import AuditEvent, PendingConfirmationRecord
|
||||||
from app.models.skills import Skill
|
from app.models.skills import Skill
|
||||||
from app.models.time_anniversary import TimeAnniversary
|
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.world import CharacterWorldEntity, WorldRelation
|
from app.models.world import CharacterWorldEntity, WorldRelation
|
||||||
|
|
||||||
@@ -42,11 +39,8 @@ __all__ = [
|
|||||||
"AuditEvent",
|
"AuditEvent",
|
||||||
"BigCycle",
|
"BigCycle",
|
||||||
"Character",
|
"Character",
|
||||||
"CharacterEmotionState",
|
|
||||||
"CharacterImportJob",
|
"CharacterImportJob",
|
||||||
"CharacterWorldEntity",
|
"CharacterWorldEntity",
|
||||||
"ChatMessage",
|
|
||||||
"ChatSession",
|
|
||||||
"CuriosityQuestion",
|
"CuriosityQuestion",
|
||||||
"Event",
|
"Event",
|
||||||
"EvolutionCandidate",
|
"EvolutionCandidate",
|
||||||
@@ -72,7 +66,6 @@ __all__ = [
|
|||||||
"RolePermission",
|
"RolePermission",
|
||||||
"Run",
|
"Run",
|
||||||
"Skill",
|
"Skill",
|
||||||
"TimeAnniversary",
|
|
||||||
"User",
|
"User",
|
||||||
"UserRole",
|
"UserRole",
|
||||||
"WorldEntity",
|
"WorldEntity",
|
||||||
|
|||||||
@@ -14,13 +14,11 @@ class Run(Base):
|
|||||||
__tablename__ = "runs"
|
__tablename__ = "runs"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_run_id)
|
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_run_id)
|
||||||
# rewrite/1.0:照心试演(kind=rehearsal)无正式人物,character_id 允许为空
|
# rewrite/1.0:照心试演(kind=rehearsal)无正式人物,character_id 允许为空;
|
||||||
|
# 旧 chat_sessions 外键随线性会话模型退役移除
|
||||||
character_id: Mapped[str | None] = mapped_column(
|
character_id: Mapped[str | None] = mapped_column(
|
||||||
String(80), ForeignKey("characters.id"), index=True, nullable=True
|
String(80), ForeignKey("characters.id"), index=True, nullable=True
|
||||||
)
|
)
|
||||||
session_id: Mapped[str | None] = mapped_column(
|
|
||||||
String(80), ForeignKey("chat_sessions.id"), index=True
|
|
||||||
)
|
|
||||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="queued", index=True)
|
status: Mapped[str] = mapped_column(String(40), nullable=False, default="queued", index=True)
|
||||||
# run.kind:foreground / background_task / awakening / rehearsal(契约词典 §8.2)
|
# run.kind:foreground / background_task / awakening / rehearsal(契约词典 §8.2)
|
||||||
kind: Mapped[str] = mapped_column(
|
kind: Mapped[str] = mapped_column(
|
||||||
|
|||||||
@@ -14,17 +14,14 @@ async def list_runs(
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
character_id: str | None = None,
|
character_id: str | None = None,
|
||||||
session_id: str | None = None,
|
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
) -> tuple[list[Run], int]:
|
) -> tuple[list[Run], int]:
|
||||||
"""按创建时间倒序分页返回 Run;可选按人物/会话/状态过滤,返回 (条目, 总数)。"""
|
"""按创建时间倒序分页返回 Run;可选按人物/状态过滤,返回 (条目, 总数)。"""
|
||||||
conds = []
|
conds = []
|
||||||
if character_id is not None:
|
if character_id is not None:
|
||||||
conds.append(Run.character_id == character_id)
|
conds.append(Run.character_id == character_id)
|
||||||
if session_id is not None:
|
|
||||||
conds.append(Run.session_id == session_id)
|
|
||||||
if status is not None:
|
if status is not None:
|
||||||
conds.append(Run.status == status)
|
conds.append(Run.status == status)
|
||||||
count_stmt = select(func.count()).select_from(Run)
|
count_stmt = select(func.count()).select_from(Run)
|
||||||
@@ -127,11 +124,3 @@ async def summarize_by_trace(session: AsyncSession):
|
|||||||
return [(row[0], int(row[1]), row[2]) for row in result.all()]
|
return [(row[0], int(row[1]), row[2]) for row in result.all()]
|
||||||
|
|
||||||
|
|
||||||
async def list_session_ids_by_runs(session: AsyncSession, run_ids: list[str]) -> list[str]:
|
|
||||||
"""按 run_id 集合查询关联的 chat session_id 去重列表(链路追踪:run → 对话)。"""
|
|
||||||
if not run_ids:
|
|
||||||
return []
|
|
||||||
result = await session.execute(
|
|
||||||
select(Run.session_id).where(Run.id.in_(run_ids), Run.session_id.is_not(None))
|
|
||||||
)
|
|
||||||
return sorted({row[0] for row in result.all() if row[0]})
|
|
||||||
@@ -69,7 +69,6 @@ class RunResponse(BaseModel):
|
|||||||
character_id: str | None = Field(
|
character_id: str | None = Field(
|
||||||
default=None, description="所属人物 ID;照心试演(kind=rehearsal)无正式人物。"
|
default=None, description="所属人物 ID;照心试演(kind=rehearsal)无正式人物。"
|
||||||
)
|
)
|
||||||
session_id: str | None = Field(default=None, description="关联会话 ID。")
|
|
||||||
status: str = Field(
|
status: str = Field(
|
||||||
description="运行状态:queued / running / completed / failed / cancelled / interrupted。"
|
description="运行状态:queued / running / completed / failed / cancelled / interrupted。"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -262,14 +262,11 @@ async def get_trace(trace_id: str) -> TraceDetail:
|
|||||||
*[audit.character_id for audit in audits if audit.character_id],
|
*[audit.character_id for audit in audits if audit.character_id],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
session_ids = (
|
|
||||||
sorted(await runtime_repo.list_session_ids_by_runs(session, run_ids)) if run_ids else []
|
|
||||||
)
|
|
||||||
return TraceDetail(
|
return TraceDetail(
|
||||||
trace_id=trace_id,
|
trace_id=trace_id,
|
||||||
character_ids=character_ids,
|
character_ids=character_ids,
|
||||||
run_ids=run_ids,
|
run_ids=run_ids,
|
||||||
session_ids=session_ids,
|
session_ids=[],
|
||||||
items=items,
|
items=items,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -342,15 +339,7 @@ async def list_traces(*, limit: int = 50) -> TraceListResponse:
|
|||||||
).all():
|
).all():
|
||||||
_entry(tid)["character_ids"].add(cid)
|
_entry(tid)["character_ids"].add(cid)
|
||||||
|
|
||||||
# 对话归属(events.run_id → runs.session_id)
|
# 对话归属随线性会话模型退役;镜树归属随 M2c 司命台重设计接入(tree_id 聚合)
|
||||||
for tid, sid in (
|
|
||||||
await session.execute(
|
|
||||||
select(Event.trace_id, Run.session_id)
|
|
||||||
.join(Run, Event.run_id == Run.id)
|
|
||||||
.where(Event.trace_id.is_not(None), Run.session_id.is_not(None))
|
|
||||||
)
|
|
||||||
).all():
|
|
||||||
_entry(tid)["session_ids"].add(sid)
|
|
||||||
|
|
||||||
total = len(merged)
|
total = len(merged)
|
||||||
ordered = sorted(merged.values(), key=lambda e: e["last_seen"], reverse=True)
|
ordered = sorted(merged.values(), key=lambda e: e["last_seen"], reverse=True)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from app.core.time import utc_now
|
|||||||
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.personality import PersonalityVersion
|
from app.models.personality import PersonalityVersion
|
||||||
from app.models.time_anniversary import TimeAnniversary
|
|
||||||
from app.modules.character import (
|
from app.modules.character import (
|
||||||
MODEL_POLICY_CREATE_DEFAULTS,
|
MODEL_POLICY_CREATE_DEFAULTS,
|
||||||
SYSTEM_TEMPLATE_OWNER_SCOPE,
|
SYSTEM_TEMPLATE_OWNER_SCOPE,
|
||||||
@@ -19,7 +18,6 @@ from app.modules.character import (
|
|||||||
)
|
)
|
||||||
from app.repositories import characters as character_repo
|
from app.repositories import characters as character_repo
|
||||||
from app.repositories import personality as personality_repo
|
from app.repositories import personality as personality_repo
|
||||||
from app.repositories import time_anniversaries as anniversary_repo
|
|
||||||
from app.repositories.json_utils import dumps, loads_dict
|
from app.repositories.json_utils import dumps, loads_dict
|
||||||
from app.schemas.character import (
|
from app.schemas.character import (
|
||||||
ActivateCharacterResponse,
|
ActivateCharacterResponse,
|
||||||
@@ -232,7 +230,7 @@ async def create_character_from_template(
|
|||||||
session: AsyncSession, template: Character, owner_scope: str
|
session: AsyncSession, template: Character, owner_scope: str
|
||||||
) -> Character:
|
) -> Character:
|
||||||
# 模板的 birthday 仅在兜底(空/缺失)时不继承;显式设置的有效生日原样保留,
|
# 模板的 birthday 仅在兜底(空/缺失)时不继承;显式设置的有效生日原样保留,
|
||||||
# 由 _ensure_default_birthday 写入子角色的 profile 与 time_anniversaries 表。
|
# 由 _ensure_default_birthday 写入子角色的 profile。
|
||||||
profile = loads_dict(template.profile_json)
|
profile = loads_dict(template.profile_json)
|
||||||
birthday = profile.get("birthday")
|
birthday = profile.get("birthday")
|
||||||
if not (isinstance(birthday, str) and birthday.strip()):
|
if not (isinstance(birthday, str) and birthday.strip()):
|
||||||
@@ -388,51 +386,17 @@ async def _audit_character(session: AsyncSession, character: Character, action:
|
|||||||
|
|
||||||
|
|
||||||
async def _ensure_default_birthday(session: AsyncSession, character: Character) -> None:
|
async def _ensure_default_birthday(session: AsyncSession, character: Character) -> None:
|
||||||
"""birthday 缺失时用 created_at 兜底:写 profile.birthday 并建一条生日纪念日。
|
"""birthday 缺失时用 created_at 兜底:仅写 profile.birthday。
|
||||||
|
|
||||||
- 仅在 profile.birthday 为空/缺失时触发默认兜底;用户或模板已设则不覆盖。
|
纪念日表(time_anniversaries)随旧时间模型退役删除;辰正(M8 重实现)落地后
|
||||||
- 无论 birthday 是否已设,只要角色尚无 birthday 纪念日,就建一条。
|
生日提醒改由时态感知按 profile.birthday 计算,不再预写行。
|
||||||
- profile.birthday 与 TimeAnniversary.date 取 birthday 值(显式)或 created_at 的 ISO 日期。
|
|
||||||
- 须在 character flush 后调用(依赖 id 与 created_at);同一事务内随外层 commit 落库。
|
|
||||||
"""
|
"""
|
||||||
profile = loads_dict(character.profile_json)
|
profile = loads_dict(character.profile_json)
|
||||||
birthday = profile.get("birthday")
|
birthday = profile.get("birthday")
|
||||||
if isinstance(birthday, str) and birthday.strip():
|
if isinstance(birthday, str) and birthday.strip():
|
||||||
# birthday 已设但可能无对应纪念日(如从模板继承),仅 ISO 格式生日补建
|
|
||||||
existing = await anniversary_repo.list_anniversaries(session, character.id)
|
|
||||||
if not any(a.kind == "birthday" for a in existing):
|
|
||||||
try:
|
|
||||||
datetime.strptime(birthday, "%Y-%m-%d")
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
anniversary = TimeAnniversary(
|
|
||||||
character_id=character.id,
|
|
||||||
name=f"{character.display_name or character.name}·生辰",
|
|
||||||
calendar="solar",
|
|
||||||
date=birthday,
|
|
||||||
recurs_yearly=True,
|
|
||||||
kind="birthday",
|
|
||||||
subject_type="character_self",
|
|
||||||
source="auto",
|
|
||||||
)
|
|
||||||
await anniversary_repo.create_anniversary(session, anniversary)
|
|
||||||
return
|
return
|
||||||
birthday_iso = character.created_at.strftime("%Y-%m-%d")
|
profile["birthday"] = character.created_at.strftime("%Y-%m-%d")
|
||||||
profile["birthday"] = birthday_iso
|
|
||||||
character.profile_json = dumps(profile)
|
character.profile_json = dumps(profile)
|
||||||
anniversary = TimeAnniversary(
|
|
||||||
character_id=character.id,
|
|
||||||
name=f"{character.display_name or character.name}·生辰",
|
|
||||||
calendar="solar",
|
|
||||||
date=birthday_iso,
|
|
||||||
recurs_yearly=True,
|
|
||||||
kind="birthday",
|
|
||||||
subject_type="character_self",
|
|
||||||
source="auto",
|
|
||||||
)
|
|
||||||
await anniversary_repo.create_anniversary(session, anniversary)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_character(
|
async def create_character(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
|||||||
@@ -28,14 +28,12 @@ async def create_run(
|
|||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
character_id: str,
|
character_id: str,
|
||||||
session_id: str | None,
|
|
||||||
model_hint: str,
|
model_hint: str,
|
||||||
goal: str = "回复用户消息",
|
goal: str = "回复用户消息",
|
||||||
origin: str = "chat",
|
origin: str = "chat",
|
||||||
) -> Run:
|
) -> Run:
|
||||||
run = Run(
|
run = Run(
|
||||||
character_id=character_id,
|
character_id=character_id,
|
||||||
session_id=session_id,
|
|
||||||
status="running",
|
status="running",
|
||||||
goal=goal,
|
goal=goal,
|
||||||
origin=origin,
|
origin=origin,
|
||||||
@@ -175,17 +173,15 @@ async def get_run_response(run_id: str) -> RunResponse:
|
|||||||
async def list_runs_response(
|
async def list_runs_response(
|
||||||
*,
|
*,
|
||||||
character_id: str | None = None,
|
character_id: str | None = None,
|
||||||
session_id: str | None = None,
|
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
) -> Page[RunResponse]:
|
) -> Page[RunResponse]:
|
||||||
"""Run 列表(按创建时间倒序);可选按人物/会话/状态过滤。归档页运行时间河数据源。"""
|
"""Run 列表(按创建时间倒序);可选按人物/状态过滤。归档页运行时间河数据源。"""
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
items, total = await runtime_repo.list_runs(
|
items, total = await runtime_repo.list_runs(
|
||||||
session,
|
session,
|
||||||
character_id=character_id,
|
character_id=character_id,
|
||||||
session_id=session_id,
|
|
||||||
status=status,
|
status=status,
|
||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from typing import Any
|
|||||||
from app.core.time import to_iso, utc_now
|
from app.core.time import to_iso, utc_now
|
||||||
from app.models.candidates import MemoryCandidate, ProfileCandidate
|
from app.models.candidates import MemoryCandidate, ProfileCandidate
|
||||||
from app.models.character import Character
|
from app.models.character import Character
|
||||||
from app.models.chat import ChatMessage, ChatSession
|
|
||||||
from app.models.governance import (
|
from app.models.governance import (
|
||||||
AgentRosterEntry,
|
AgentRosterEntry,
|
||||||
GenerationPreferences,
|
GenerationPreferences,
|
||||||
@@ -33,7 +32,6 @@ from app.schemas.admin import (
|
|||||||
)
|
)
|
||||||
from app.schemas.candidates import MemoryCandidateResponse, ProfileCandidateResponse
|
from app.schemas.candidates import MemoryCandidateResponse, ProfileCandidateResponse
|
||||||
from app.schemas.character import CharacterResponse, CharacterSummaryResponse
|
from app.schemas.character import CharacterResponse, CharacterSummaryResponse
|
||||||
from app.schemas.chat import ChatMessageResponse, ChatSessionResponse
|
|
||||||
from app.schemas.imports import ImportItemResponse, ImportJobResponse
|
from app.schemas.imports import ImportItemResponse, ImportJobResponse
|
||||||
from app.schemas.memory import MemoryResponse
|
from app.schemas.memory import MemoryResponse
|
||||||
from app.schemas.personality import PersonalityProposalResponse, PersonalityResponse
|
from app.schemas.personality import PersonalityProposalResponse, PersonalityResponse
|
||||||
@@ -125,42 +123,6 @@ def proposal_to_response(proposal: PersonalityProposal) -> PersonalityProposalRe
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def chat_session_to_response(
|
|
||||||
chat_session: ChatSession,
|
|
||||||
*,
|
|
||||||
message_count: int = 0,
|
|
||||||
last_message_preview: str | None = None,
|
|
||||||
) -> ChatSessionResponse:
|
|
||||||
return ChatSessionResponse(
|
|
||||||
session_id=chat_session.id,
|
|
||||||
character_id=chat_session.character_id,
|
|
||||||
title=chat_session.title,
|
|
||||||
status=chat_session.status,
|
|
||||||
created_at=to_iso(chat_session.created_at) or "",
|
|
||||||
updated_at=to_iso(chat_session.updated_at) or "",
|
|
||||||
message_count=message_count,
|
|
||||||
last_message_preview=last_message_preview,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def chat_message_to_response(message: ChatMessage) -> ChatMessageResponse:
|
|
||||||
return ChatMessageResponse(
|
|
||||||
message_id=message.id,
|
|
||||||
session_id=message.session_id,
|
|
||||||
character_id=message.character_id,
|
|
||||||
role=message.role,
|
|
||||||
content=message.content,
|
|
||||||
modality=message.modality,
|
|
||||||
run_id=message.run_id,
|
|
||||||
metadata=loads_dict(message.metadata_json),
|
|
||||||
sensitivity=message.sensitivity,
|
|
||||||
versions=loads_list(message.versions_json),
|
|
||||||
active_version_idx=message.active_version_idx,
|
|
||||||
created_at=to_iso(message.created_at) or "",
|
|
||||||
deleted_at=to_iso(message.deleted_at),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def memory_to_response(memory: Memory) -> MemoryResponse:
|
def memory_to_response(memory: Memory) -> MemoryResponse:
|
||||||
return MemoryResponse(
|
return MemoryResponse(
|
||||||
id=memory.id,
|
id=memory.id,
|
||||||
@@ -185,7 +147,6 @@ def run_to_response(run: Run) -> RunResponse:
|
|||||||
return RunResponse(
|
return RunResponse(
|
||||||
id=run.id,
|
id=run.id,
|
||||||
character_id=run.character_id,
|
character_id=run.character_id,
|
||||||
session_id=run.session_id,
|
|
||||||
status=run.status,
|
status=run.status,
|
||||||
kind=run.kind,
|
kind=run.kind,
|
||||||
goal=run.goal,
|
goal=run.goal,
|
||||||
|
|||||||
@@ -74,16 +74,6 @@ async def reset_database() -> AsyncGenerator[None, None]:
|
|||||||
from app.services.ai_provider_admin import set_endpoint_overrides_cache
|
from app.services.ai_provider_admin import set_endpoint_overrides_cache
|
||||||
|
|
||||||
set_endpoint_overrides_cache([])
|
set_endpoint_overrides_cache([])
|
||||||
# 清理可能被前序 TestClient 启动的 KnockingPivot 后台任务,避免写锁冲突
|
|
||||||
from app.main import app as _app
|
|
||||||
|
|
||||||
pivot = getattr(_app.state, "knocking_pivot", None)
|
|
||||||
if pivot is not None:
|
|
||||||
try: # noqa: SIM105 — pivot.stop() 是协程,contextlib.suppress 不适用
|
|
||||||
await pivot.stop()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
_app.state.__dict__.pop("knocking_pivot", None)
|
|
||||||
yield
|
yield
|
||||||
_assert_test_database_url(str(_engine.url))
|
_assert_test_database_url(str(_engine.url))
|
||||||
async with _engine.begin() as conn:
|
async with _engine.begin() as conn:
|
||||||
|
|||||||
@@ -1,55 +1,39 @@
|
|||||||
"""人物服务测试:默认生日兜底(创建时未设 birthday → 用 created_at 兜底)。"""
|
"""人物服务测试:默认生日兜底(创建时未设 birthday → 用 created_at 兜底)。
|
||||||
|
|
||||||
|
纪念日表(time_anniversaries)随旧时间模型退役删除,本文件只验证 profile 兜底;
|
||||||
|
辰正(M8 重实现)落地后生日提醒改按 profile.birthday 计算。
|
||||||
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
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.repositories import time_anniversaries as anni_repo
|
|
||||||
from app.repositories.json_utils import loads_dict
|
from app.repositories.json_utils import loads_dict
|
||||||
from app.schemas.character import CreateCharacterRequest
|
from app.schemas.character import CreateCharacterRequest
|
||||||
from app.services.characters import create_character, create_character_from_template
|
from app.services.characters import create_character, create_character_from_template
|
||||||
|
|
||||||
|
|
||||||
def _birthday_anniversaries(items: list) -> list:
|
|
||||||
return [a for a in items if a.kind == "birthday"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_character_without_birthday_applies_default():
|
async def test_create_character_without_birthday_applies_default():
|
||||||
"""未传 birthday → profile.birthday 与纪念日均取 created_at 的 ISO 日期。"""
|
"""未传 birthday → profile.birthday 取 created_at 的 ISO 日期。"""
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
resp = await create_character(session, CreateCharacterRequest(name="lingxi"))
|
resp = await create_character(session, CreateCharacterRequest(name="lingxi"))
|
||||||
anniversaries = await anni_repo.list_anniversaries(session, resp.id)
|
|
||||||
character = await session.get(Character, resp.id)
|
character = await session.get(Character, resp.id)
|
||||||
|
|
||||||
birthday_iso = character.created_at.strftime("%Y-%m-%d")
|
birthday_iso = character.created_at.strftime("%Y-%m-%d")
|
||||||
assert resp.profile["birthday"] == birthday_iso
|
assert resp.profile["birthday"] == birthday_iso
|
||||||
|
|
||||||
birthday_annivs = _birthday_anniversaries(anniversaries)
|
|
||||||
assert len(birthday_annivs) == 1
|
|
||||||
a = birthday_annivs[0]
|
|
||||||
assert a.date == birthday_iso
|
|
||||||
assert a.calendar == "solar"
|
|
||||||
assert a.recurs_yearly is True
|
|
||||||
assert a.kind == "birthday"
|
|
||||||
assert a.subject_type == "character_self"
|
|
||||||
assert a.source == "auto"
|
|
||||||
# display_name 为 None → 回落到 name
|
|
||||||
assert a.name == "lingxi·生辰"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_character_with_birthday_keeps_user_value():
|
async def test_create_character_with_birthday_keeps_user_value():
|
||||||
"""用户传了 birthday(即便为自由文本)→ 保持用户值,且不自动建生日纪念日。"""
|
"""用户传了 birthday(即便为自由文本)→ 保持用户值。"""
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
resp = await create_character(
|
resp = await create_character(
|
||||||
session,
|
session,
|
||||||
CreateCharacterRequest(name="jingwu", profile={"birthday": "霜降之夜"}),
|
CreateCharacterRequest(name="jingwu", profile={"birthday": "霜降之夜"}),
|
||||||
)
|
)
|
||||||
anniversaries = await anni_repo.list_anniversaries(session, resp.id)
|
|
||||||
|
|
||||||
assert resp.profile["birthday"] == "霜降之夜"
|
assert resp.profile["birthday"] == "霜降之夜"
|
||||||
assert _birthday_anniversaries(anniversaries) == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -58,75 +42,53 @@ async def test_create_character_with_blank_birthday_applies_default():
|
|||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
resp = await create_character(
|
resp = await create_character(
|
||||||
session,
|
session,
|
||||||
CreateCharacterRequest(name="chenyu", profile={"birthday": " "}),
|
CreateCharacterRequest(name="yuelan", profile={"birthday": " "}),
|
||||||
)
|
)
|
||||||
anniversaries = await anni_repo.list_anniversaries(session, resp.id)
|
|
||||||
character = await session.get(Character, resp.id)
|
|
||||||
|
|
||||||
birthday_iso = character.created_at.strftime("%Y-%m-%d")
|
async with AsyncSessionLocal() as session:
|
||||||
assert resp.profile["birthday"] == birthday_iso
|
character = await session.get(Character, resp.id)
|
||||||
assert len(_birthday_anniversaries(anniversaries)) == 1
|
assert resp.profile["birthday"] == character.created_at.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _template(key: str, profile_json: str) -> Character:
|
||||||
|
return Character(
|
||||||
|
name=key,
|
||||||
|
display_name="模板",
|
||||||
|
status="active",
|
||||||
|
language="zh-CN",
|
||||||
|
profile_json=profile_json,
|
||||||
|
model_policy_json="{}",
|
||||||
|
privacy_level="normal",
|
||||||
|
owner_scope="system",
|
||||||
|
is_system_template=True,
|
||||||
|
source_template_id=None,
|
||||||
|
template_key=key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_character_from_template_without_birthday_uses_own_created_at():
|
async def test_create_character_from_template_without_birthday_uses_own_created_at():
|
||||||
"""模板 profile 无 birthday → 子角色用自己的 created_at 兜底。"""
|
"""模板 profile 无 birthday → 子角色用自己的 created_at 兜底。"""
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
template = Character(
|
template = _template("test_tpl", '{"background": "测试模板"}')
|
||||||
name="tpl",
|
|
||||||
display_name="模板",
|
|
||||||
status="active",
|
|
||||||
language="zh-CN",
|
|
||||||
profile_json='{"background": "测试模板"}', # 无 birthday
|
|
||||||
model_policy_json="{}",
|
|
||||||
privacy_level="normal",
|
|
||||||
owner_scope="system",
|
|
||||||
is_system_template=True,
|
|
||||||
source_template_id=None,
|
|
||||||
template_key="test_tpl",
|
|
||||||
)
|
|
||||||
session.add(template)
|
session.add(template)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
character = await create_character_from_template(session, template, "local-user")
|
character = await create_character_from_template(session, template, "local-user")
|
||||||
profile = loads_dict(character.profile_json)
|
profile = loads_dict(character.profile_json)
|
||||||
anniversaries = await anni_repo.list_anniversaries(session, character.id)
|
|
||||||
|
|
||||||
birthday_iso = character.created_at.strftime("%Y-%m-%d")
|
assert profile["birthday"] == character.created_at.strftime("%Y-%m-%d")
|
||||||
assert profile["birthday"] == birthday_iso
|
|
||||||
birthday_annivs = _birthday_anniversaries(anniversaries)
|
|
||||||
assert len(birthday_annivs) == 1
|
|
||||||
assert birthday_annivs[0].date == birthday_iso
|
|
||||||
# display_name 优先于 name
|
|
||||||
assert birthday_annivs[0].name == "模板·生辰"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_create_character_from_template_inherits_explicit_birthday():
|
async def test_create_character_from_template_inherits_explicit_birthday():
|
||||||
"""模板 profile 有显式 birthday → 子角色继承该值,不兜底。"""
|
"""模板 profile 有显式 birthday → 子角色继承该值,不兜底。"""
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
template = Character(
|
template = _template("test_tpl2", '{"birthday": "2020-01-01"}')
|
||||||
name="tpl2",
|
|
||||||
display_name="模板二",
|
|
||||||
status="active",
|
|
||||||
language="zh-CN",
|
|
||||||
profile_json='{"birthday": "2020-01-01"}',
|
|
||||||
model_policy_json="{}",
|
|
||||||
privacy_level="normal",
|
|
||||||
owner_scope="system",
|
|
||||||
is_system_template=True,
|
|
||||||
source_template_id=None,
|
|
||||||
template_key="test_tpl2",
|
|
||||||
)
|
|
||||||
session.add(template)
|
session.add(template)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
||||||
character = await create_character_from_template(session, template, "local-user")
|
character = await create_character_from_template(session, template, "local-user")
|
||||||
profile = loads_dict(character.profile_json)
|
profile = loads_dict(character.profile_json)
|
||||||
anniversaries = await anni_repo.list_anniversaries(session, character.id)
|
|
||||||
|
|
||||||
assert profile["birthday"] == "2020-01-01" # 继承模板的显式 birthday
|
assert profile["birthday"] == "2020-01-01"
|
||||||
birthday_annivs = _birthday_anniversaries(anniversaries)
|
|
||||||
assert len(birthday_annivs) == 1
|
|
||||||
assert birthday_annivs[0].date == "2020-01-01"
|
|
||||||
assert birthday_annivs[0].name == "模板二·生辰"
|
|
||||||
@@ -26,7 +26,6 @@ async def _seed_trace_data() -> None:
|
|||||||
Run(
|
Run(
|
||||||
id="run_001",
|
id="run_001",
|
||||||
character_id="char_test",
|
character_id="char_test",
|
||||||
session_id="sess_test",
|
|
||||||
status="completed",
|
status="completed",
|
||||||
origin="chat",
|
origin="chat",
|
||||||
created_at=BASE_TIME,
|
created_at=BASE_TIME,
|
||||||
@@ -105,7 +104,7 @@ async def test_get_trace_aggregates_and_sorts() -> None:
|
|||||||
assert detail.trace_id == TRACE_ID
|
assert detail.trace_id == TRACE_ID
|
||||||
assert detail.run_ids == ["run_001"]
|
assert detail.run_ids == ["run_001"]
|
||||||
assert detail.character_ids == ["char_test"]
|
assert detail.character_ids == ["char_test"]
|
||||||
assert detail.session_ids == ["sess_test"]
|
assert detail.session_ids == [] # 会话归属随线性会话退役
|
||||||
# 五条记录按 created_at 升序归并
|
# 五条记录按 created_at 升序归并
|
||||||
assert [item.kind for item in detail.items] == [
|
assert [item.kind for item in detail.items] == [
|
||||||
"event",
|
"event",
|
||||||
@@ -180,7 +179,7 @@ async def test_list_traces_aggregates_and_sorts_recent_first() -> None:
|
|||||||
assert s.audit_count == 2
|
assert s.audit_count == 2
|
||||||
assert s.idempotency_count == 1
|
assert s.idempotency_count == 1
|
||||||
assert s.character_ids == ["char_test"]
|
assert s.character_ids == ["char_test"]
|
||||||
assert s.session_ids == ["sess_test"]
|
assert s.session_ids == [] # 会话归属随线性会话退役
|
||||||
# 按 last_seen 倒序
|
# 按 last_seen 倒序
|
||||||
assert all(
|
assert all(
|
||||||
resp.items[i].last_seen >= resp.items[i + 1].last_seen for i in range(len(resp.items) - 1)
|
resp.items[i].last_seen >= resp.items[i + 1].last_seen for i in range(len(resp.items) - 1)
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ from collections.abc import AsyncGenerator
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
from app.ai_provider.types.chat import ChatChunk
|
|
||||||
from app.api.v1.deps import require_admin
|
from app.api.v1.deps import require_admin
|
||||||
from app.application.agent.runtime import AgentTurnResult
|
from app.application.agent.runtime import AgentTurnResult
|
||||||
from app.application.chat.send_message import drain_pending_streams
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
@@ -16,11 +14,6 @@ from app.main import app
|
|||||||
async def client(
|
async def client(
|
||||||
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
|
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
|
||||||
) -> AsyncGenerator[AsyncClient, None]:
|
) -> 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)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(
|
async with AsyncClient(
|
||||||
transport=transport,
|
transport=transport,
|
||||||
@@ -41,136 +34,6 @@ async def test_health_and_defaults(client: AsyncClient) -> None:
|
|||||||
assert personality.status_code == 200
|
assert personality.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_chat_run_and_memory_crud(client: AsyncClient) -> None:
|
|
||||||
created = await client.post("/api/v1/chat/sessions", json={"title": "测试"})
|
|
||||||
assert created.status_code == 200
|
|
||||||
session_id = created.json()["session_id"]
|
|
||||||
|
|
||||||
sent = await client.post(
|
|
||||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
|
||||||
json={"content": "你好", "modality": "text"},
|
|
||||||
)
|
|
||||||
assert sent.status_code == 200
|
|
||||||
run_id = sent.json()["run_id"]
|
|
||||||
await drain_pending_streams()
|
|
||||||
run = await client.get(f"/api/v1/runs/{run_id}")
|
|
||||||
assert run.status_code == 200
|
|
||||||
assert run.json()["status"] == "completed"
|
|
||||||
|
|
||||||
memory = await client.post(
|
|
||||||
"/api/v1/memory",
|
|
||||||
json={"type": "user", "title": "偏好", "content": "用户喜欢简洁回答。"},
|
|
||||||
)
|
|
||||||
assert memory.status_code == 200
|
|
||||||
memory_id = memory.json()["id"]
|
|
||||||
listed = await client.get("/api/v1/memory")
|
|
||||||
assert listed.status_code == 200
|
|
||||||
assert listed.json()["total"] == 1
|
|
||||||
updated = await client.patch(f"/api/v1/memory/{memory_id}", json={"importance": 0.9})
|
|
||||||
assert updated.status_code == 200
|
|
||||||
deleted = await client.delete(f"/api/v1/memory/{memory_id}")
|
|
||||||
assert deleted.status_code == 200
|
|
||||||
assert deleted.json()["status"] == "deleted"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_chat_session_and_messages_query_api(client: AsyncClient) -> None:
|
|
||||||
created = await client.post("/api/v1/chat/sessions", json={"title": "查询测试"})
|
|
||||||
assert created.status_code == 200
|
|
||||||
created_body = created.json()
|
|
||||||
session_id = created_body["session_id"]
|
|
||||||
|
|
||||||
listed = await client.get("/api/v1/chat/sessions")
|
|
||||||
assert listed.status_code == 200
|
|
||||||
listed_body = listed.json()
|
|
||||||
assert listed_body["total"] >= 1
|
|
||||||
assert any(item["session_id"] == session_id for item in listed_body["items"])
|
|
||||||
|
|
||||||
fetched = await client.get(f"/api/v1/chat/sessions/{session_id}")
|
|
||||||
assert fetched.status_code == 200
|
|
||||||
fetched_body = fetched.json()
|
|
||||||
assert fetched_body["session_id"] == session_id
|
|
||||||
assert fetched_body["title"] == "查询测试"
|
|
||||||
assert fetched_body["status"] == "active"
|
|
||||||
|
|
||||||
sent = await client.post(
|
|
||||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
|
||||||
json={"content": "查询消息", "modality": "text"},
|
|
||||||
)
|
|
||||||
assert sent.status_code == 200
|
|
||||||
# 流式化后 send_message 立即返回,assistant_message_id 此刻为空,需等待后台任务定稿。
|
|
||||||
await drain_pending_streams()
|
|
||||||
|
|
||||||
messages = await client.get(f"/api/v1/chat/sessions/{session_id}/messages")
|
|
||||||
assert messages.status_code == 200
|
|
||||||
messages_body = messages.json()
|
|
||||||
assert messages_body["total"] == 2
|
|
||||||
assert [item["role"] for item in messages_body["items"]] == ["user", "assistant"]
|
|
||||||
assert messages_body["items"][0]["content"] == "查询消息"
|
|
||||||
assert messages_body["items"][1]["message_id"].startswith("message_")
|
|
||||||
|
|
||||||
missing = await client.get("/api/v1/chat/sessions/session_missing")
|
|
||||||
assert missing.status_code == 404
|
|
||||||
assert missing.json()["error"]["code"] == "not_found"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_chat_session_update_and_delete(client: AsyncClient) -> None:
|
|
||||||
created = await client.post("/api/v1/chat/sessions", json={"title": "待删除"})
|
|
||||||
assert created.status_code == 200
|
|
||||||
session_id = created.json()["session_id"]
|
|
||||||
|
|
||||||
# 发两条消息制造关联数据(覆盖 messages 软删路径)
|
|
||||||
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()
|
|
||||||
messages_before = await client.get(f"/api/v1/chat/sessions/{session_id}/messages")
|
|
||||||
assert messages_before.status_code == 200
|
|
||||||
assert messages_before.json()["total"] == 2
|
|
||||||
|
|
||||||
# PATCH 重命名
|
|
||||||
renamed = await client.patch(f"/api/v1/chat/sessions/{session_id}", json={"title": "重命名后"})
|
|
||||||
assert renamed.status_code == 200
|
|
||||||
assert renamed.json()["title"] == "重命名后"
|
|
||||||
assert renamed.json()["status"] == "active"
|
|
||||||
|
|
||||||
# PATCH 归档
|
|
||||||
archived = await client.patch(
|
|
||||||
f"/api/v1/chat/sessions/{session_id}", json={"status": "archived"}
|
|
||||||
)
|
|
||||||
assert archived.status_code == 200
|
|
||||||
assert archived.json()["status"] == "archived"
|
|
||||||
|
|
||||||
# PATCH 禁止直接置 deleted
|
|
||||||
bad = await client.patch(f"/api/v1/chat/sessions/{session_id}", json={"status": "deleted"})
|
|
||||||
assert bad.status_code == 400
|
|
||||||
|
|
||||||
# DELETE 软删除 → 204
|
|
||||||
deleted = await client.delete(f"/api/v1/chat/sessions/{session_id}")
|
|
||||||
assert deleted.status_code == 204
|
|
||||||
|
|
||||||
# 删除后 GET 该会话 404
|
|
||||||
gone = await client.get(f"/api/v1/chat/sessions/{session_id}")
|
|
||||||
assert gone.status_code == 404
|
|
||||||
|
|
||||||
# 列表不再包含
|
|
||||||
listed = await client.get("/api/v1/chat/sessions")
|
|
||||||
assert listed.status_code == 200
|
|
||||||
assert not any(item["session_id"] == session_id for item in listed.json()["items"])
|
|
||||||
|
|
||||||
# 消息也被软删:总条数为 0
|
|
||||||
messages_after = await client.get(f"/api/v1/chat/sessions/{session_id}/messages")
|
|
||||||
assert messages_after.status_code == 404 # 会话已 deleted,查询应拒绝
|
|
||||||
|
|
||||||
# 重复删除仍返回 404
|
|
||||||
repeat = await client.delete(f"/api/v1/chat/sessions/{session_id}")
|
|
||||||
assert repeat.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_personality_propose_approve_reject_and_rollback(client: AsyncClient) -> None:
|
async def test_personality_propose_approve_reject_and_rollback(client: AsyncClient) -> None:
|
||||||
# 初始当前人格为 v1
|
# 初始当前人格为 v1
|
||||||
@@ -252,42 +115,6 @@ async def test_personality_propose_approve_reject_and_rollback(client: AsyncClie
|
|||||||
app.dependency_overrides.pop(require_admin, None)
|
app.dependency_overrides.pop(require_admin, None)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_message_version_selection(client: AsyncClient) -> None:
|
|
||||||
"""选择/激活消息版本——覆盖 services/chat.select_message_version 路径。"""
|
|
||||||
created = await client.post("/api/v1/chat/sessions", json={"title": "版本测试"})
|
|
||||||
assert created.status_code == 200
|
|
||||||
session_id = created.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()
|
|
||||||
|
|
||||||
messages = await client.get(f"/api/v1/chat/sessions/{session_id}/messages")
|
|
||||||
assert messages.status_code == 200
|
|
||||||
items = messages.json()["items"]
|
|
||||||
assert len(items) == 2
|
|
||||||
assistant_msg = next(m for m in items if m["role"] == "assistant")
|
|
||||||
msg_id = assistant_msg["message_id"]
|
|
||||||
|
|
||||||
# 选择不存在的版本索引 → 400
|
|
||||||
bad = await client.patch(
|
|
||||||
f"/api/v1/chat/sessions/{session_id}/messages/{msg_id}/version",
|
|
||||||
json={"version_idx": 99},
|
|
||||||
)
|
|
||||||
assert bad.status_code == 400
|
|
||||||
|
|
||||||
# 选择索引 0(当前版本)
|
|
||||||
ok = await client.patch(
|
|
||||||
f"/api/v1/chat/sessions/{session_id}/messages/{msg_id}/version",
|
|
||||||
json={"version_idx": 0},
|
|
||||||
)
|
|
||||||
assert ok.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_skills_crud(client: AsyncClient) -> None:
|
async def test_skills_crud(client: AsyncClient) -> None:
|
||||||
"""Skill 能力包 CRUD——覆盖 services/skills 路径。"""
|
"""Skill 能力包 CRUD——覆盖 services/skills 路径。"""
|
||||||
|
|||||||
@@ -34,23 +34,8 @@ async def test_error_contracts(client: AsyncClient) -> None:
|
|||||||
assert missing_run.status_code == 404
|
assert missing_run.status_code == 404
|
||||||
assert_error(missing_run, "not_found")
|
assert_error(missing_run, "not_found")
|
||||||
|
|
||||||
validation = await client.post("/api/v1/chat/sessions", json={"title": "x" * 300})
|
validation = await client.post("/api/v1/runs", json={"prompt": ""})
|
||||||
assert validation.status_code == 422
|
assert validation.status_code == 422
|
||||||
assert_error(validation, "validation_failed")
|
assert_error(validation, "validation_failed")
|
||||||
|
|
||||||
created = await client.post("/api/v1/chat/sessions", json={"title": "错误"})
|
# 旧 chat 会话错误契约随线性会话模型退役;415/413 契约由镜树/媒体域随需要重建
|
||||||
session_id = created.json()["session_id"]
|
|
||||||
unsupported = await client.post(
|
|
||||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
|
||||||
json={"content": "你好", "modality": "image"},
|
|
||||||
)
|
|
||||||
assert unsupported.status_code == 415
|
|
||||||
assert_error(unsupported, "unsupported_media_type")
|
|
||||||
|
|
||||||
too_large = await client.post(
|
|
||||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
|
||||||
json={"content": "x" * 40000, "modality": "text"},
|
|
||||||
)
|
|
||||||
# 当前消息内容无长度上限限制,接受 200;若后续加回来再改为 assert 413
|
|
||||||
if too_large.status_code == 413:
|
|
||||||
assert_error(too_large, "payload_too_large")
|
|
||||||
@@ -30,7 +30,7 @@ async def test_event_payload_limit(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
character_id = await _admin_character_id()
|
character_id = await _admin_character_id()
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
run = await create_run(
|
run = await create_run(
|
||||||
db, character_id=character_id, session_id=None, model_hint="default-chat"
|
db, character_id=character_id, model_hint="default-chat"
|
||||||
)
|
)
|
||||||
with pytest.raises(AppError) as exc:
|
with pytest.raises(AppError) as exc:
|
||||||
await append_event(
|
await append_event(
|
||||||
@@ -49,7 +49,7 @@ async def test_run_max_events(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
character_id = await _admin_character_id()
|
character_id = await _admin_character_id()
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
run = await create_run(
|
run = await create_run(
|
||||||
db, character_id=character_id, session_id=None, model_hint="default-chat"
|
db, character_id=character_id, model_hint="default-chat"
|
||||||
)
|
)
|
||||||
await append_event(
|
await append_event(
|
||||||
db, run_id=run.id, character_id=character_id, event_type="message.delta", payload={}
|
db, run_id=run.id, character_id=character_id, event_type="message.delta", payload={}
|
||||||
@@ -70,7 +70,7 @@ async def test_cancel_run() -> None:
|
|||||||
character_id = await _admin_character_id()
|
character_id = await _admin_character_id()
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
run = await create_run(
|
run = await create_run(
|
||||||
db, character_id=character_id, session_id=None, model_hint="default-chat"
|
db, character_id=character_id, model_hint="default-chat"
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
response = await cancel_run(run.id)
|
response = await cancel_run(run.id)
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
# 前端开发规范
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
Next.js + TypeScript + React + Tailwind CSS + Zustand/Jotai + pnpm
|
||||||
|
|
||||||
|
### 核心依赖
|
||||||
|
|
||||||
|
- **实时通信**: SSE / WebSocket
|
||||||
|
- **可视化**: Mermaid + 图谱可视化库
|
||||||
|
- **测试**: Vitest + Testing Library
|
||||||
|
- **代码质量**: ESLint (flat config) + Prettier + Husky + lint-staged
|
||||||
|
|
||||||
|
## 开发命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install # 安装依赖
|
||||||
|
pnpm add <pkg> # 添加依赖
|
||||||
|
pnpm remove <pkg> # 移除依赖
|
||||||
|
|
||||||
|
pnpm dev # 启动开发服务器 (默认端口 3000)
|
||||||
|
pnpm build # 构建生产版本
|
||||||
|
pnpm start # 启动生产服务器
|
||||||
|
|
||||||
|
pnpm lint # 运行 ESLint 检查
|
||||||
|
pnpm type-check # TypeScript 类型检查
|
||||||
|
|
||||||
|
# 代码格式化(Prettier)
|
||||||
|
pnpm format # 格式化全部文件
|
||||||
|
pnpm format:check # 检查格式是否合规(CI / 提交钩子使用)
|
||||||
|
|
||||||
|
# 测试(Vitest)
|
||||||
|
pnpm test # 运行全部测试(单次)
|
||||||
|
pnpm test:watch # 监听模式
|
||||||
|
pnpm test:coverage # 运行测试并生成覆盖率报告
|
||||||
|
```
|
||||||
|
|
||||||
|
## 代码规范
|
||||||
|
|
||||||
|
### 页面设计
|
||||||
|
|
||||||
|
图标:统一使用 Lucide 图标,界面全程禁止使用表情符号。
|
||||||
|
设计标准:对标 Awwwards 顶级网站水准,达到 Awwwards、FWA、CSS Design Awards 每日最佳网站同等设计品质。
|
||||||
|
创意自由度:将浏览器视作交互式艺术画布,跳出传统布局框架,追求先锋视觉风格、实验性排版、流畅物理动效、极具冲击力的文字版式。
|
||||||
|
沉浸式体验:融合代码、高级渲染逻辑、图片素材,打造统一完整的精品页面,做出突破常规 UI 认知、令人惊艳的数字交互体验。
|
||||||
|
|
||||||
|
### 通用规范
|
||||||
|
|
||||||
|
- 遵循 ESLint + Prettier 配置,提交前必须通过 lint 与格式检查
|
||||||
|
- 使用 TypeScript 严格模式 (`strict: true`),禁止使用 `any` 类型
|
||||||
|
- 组件使用函数式组件 + Hooks,优先使用 Server Components,必要时使用 `'use client'`
|
||||||
|
- 文件命名: 组件使用 PascalCase (`UserProfile.tsx`),工具函数使用 camelCase (`formatDate.ts`)
|
||||||
|
- 使用 `path` 别名 (`@/*`) 导入模块,避免相对路径层级过深
|
||||||
|
- 状态管理: 全局状态使用 Zustand/Jotai,局部状态使用 React useState/useReducer
|
||||||
|
|
||||||
|
### CSS 变量引用
|
||||||
|
|
||||||
|
CSS 变量引用遵循 **三层递进规则**,优先级由上至下。
|
||||||
|
|
||||||
|
#### 规则
|
||||||
|
|
||||||
|
```text
|
||||||
|
Tailwind 映射类 → 优先使用
|
||||||
|
Tailwind 任意值 [var(--)] → 仅当变量不在 @theme 映射表中时使用
|
||||||
|
内联 style → 仅限复合值 / 动态运行时值
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 具体对照
|
||||||
|
|
||||||
|
`globals.css` 的 `@theme inline {}` 块已映射的部分,直接用 Tailwind 类:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ 正确:变量在 @theme inline {} 中有 --color-X 映射 → 用 Tailwind 类
|
||||||
|
<div className="bg-patina text-fg-dim border-rule" />
|
||||||
|
<span className="text-cinnabar" />
|
||||||
|
<div className="bg-night-bronze text-fg" />
|
||||||
|
<div className="bg-surface" />
|
||||||
|
<button className="border-gilt text-gilt" />
|
||||||
|
<div className="bg-btn-primary-bg" /> {/* Layer 2 组件 token 同理 */}
|
||||||
|
|
||||||
|
// ✅ 正确:变量未映射到 @theme inline {} → 用 Tailwind 任意值
|
||||||
|
<div className="bg-[var(--patina-wash)]" />
|
||||||
|
<span className="text-[var(--fg-faint)]" />
|
||||||
|
<div className="border-[var(--gilt-dim)]" />
|
||||||
|
<div className="bg-[var(--cinnabar-wash)]" />
|
||||||
|
```
|
||||||
|
|
||||||
|
> `bg-[var(--x)]` 是 Tailwind v4 的合法任意值语法(非 hack),属于 Tailwind 体系内,**不是** style 违规。`--patina-wash`、`--patina-dim`、`--gilt-faint`、`--gilt-dim`、`--cinnabar-wash`、`--cinnabar-dim` 当前均未直接映射,使用任意值是正确做法。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 禁止:变量已在 @theme inline {} 中有映射,却用任意值
|
||||||
|
// 不要用 bg-[var(--patina)] —— 应写 bg-patina(已映射)
|
||||||
|
// 不要用 text-[var(--cinnabar)] —— 应写 text-cinnabar(已映射)
|
||||||
|
// 不要用 border-[var(--rule)] —— 应写 border-rule(已映射)
|
||||||
|
// 不要用 text-[var(--fg-dim)] —— 应写 text-fg-dim(已映射)
|
||||||
|
|
||||||
|
// ❌ 禁止:用内联 style 表达单值 CSS 变量
|
||||||
|
// 不要写 style={{ color: "var(--patina)" }}
|
||||||
|
// 不要写 style={{ background: "var(--surface)" }}
|
||||||
|
|
||||||
|
// ✅ 正确:渐变色/复合 CSS 用内联 style(Tailwind 无法表达)
|
||||||
|
<div style={{
|
||||||
|
background: "linear-gradient(180deg, var(--patina-wash), var(--surface))",
|
||||||
|
boxShadow: "inset 0 1px 0 var(--face-in-top), var(--shadow)",
|
||||||
|
}} />
|
||||||
|
|
||||||
|
// ✅ 正确:动态运行时值用内联 style
|
||||||
|
<div style={{ color: health === "ok" ? "var(--patina)" : "var(--cinnabar)" }} />
|
||||||
|
|
||||||
|
// ✅ 正确:SVG 属性用内联 style 或 className
|
||||||
|
<circle style={{ fill: "var(--patina)", stroke: "var(--gilt)" }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 映射速查
|
||||||
|
|
||||||
|
当前 `@theme inline {}` 中已映射的 铜镜鉴心 变量(查阅 `globals.css` 确认完整列表):
|
||||||
|
|
||||||
|
| CSS 变量 | Tailwind 类前缀 | 示例 |
|
||||||
|
| ---------- | ---------------- | ------ |
|
||||||
|
| `--patina` | `patina` | `bg-patina` `text-patina` `border-patina` |
|
||||||
|
| `--patina-bright` | `patina-bright` | `bg-patina-bright` |
|
||||||
|
| `--gilt` | `gilt` | `bg-gilt` `text-gilt` |
|
||||||
|
| `--cinnabar` | `cinnabar` | `bg-cinnabar` `text-cinnabar` |
|
||||||
|
| `--ink` | `ink` | `bg-ink` |
|
||||||
|
| `--night-bronze` | `night-bronze` | `bg-night-bronze` |
|
||||||
|
| `--fg` | `fg` | `text-fg` |
|
||||||
|
| `--fg-dim` | `fg-dim` | `text-fg-dim` |
|
||||||
|
| `--fg-faint` | `fg-faint` | `text-fg-faint` |
|
||||||
|
| `--rule` | `rule` | `border-rule` `bg-rule` |
|
||||||
|
| `--surface` | `surface` | `bg-surface` |
|
||||||
|
| `--surface-hi` | `surface-hi` | `bg-surface-hi` |
|
||||||
|
| `--patina-wash` | `patina-wash` | `bg-patina-wash` |
|
||||||
|
| `--patina-dim` | `patina-dim` | `border-patina-dim` `bg-patina-dim` |
|
||||||
|
| `--gilt-dim` | `gilt-dim` | `border-gilt-dim` `text-gilt-dim` `bg-gilt-dim` |
|
||||||
|
| `--gilt-faint` | `gilt-faint` | `bg-gilt-faint` |
|
||||||
|
| `--cinnabar-wash` | `cinnabar-wash` | `bg-cinnabar-wash` |
|
||||||
|
| `--cinnabar-dim` | `cinnabar-dim` | `border-cinnabar-dim` |
|
||||||
|
| `--glass` | `glass` | `bg-glass` |
|
||||||
|
| `--glass-border` | `glass-border` | `border-glass-border` |
|
||||||
|
| `--overlay-hover` | `overlay-hover` | `bg-overlay-hover` |
|
||||||
|
|
||||||
|
**未映射**(用 `bg-[var(--x)]`):`--face-sheen` `--sweep-gradient` `--face-in-top` `--face-in-bot` `--shadow` `--aurora-*` `--code-*` 以及 SVG 特有的 `--raw-*`
|
||||||
|
|
||||||
|
#### 例外清单
|
||||||
|
|
||||||
|
以下变量是复合 CSS 值或动画属性,**必须**用内联 style,不能抽象为 Tailwind 类:
|
||||||
|
|
||||||
|
- 渐变/光照效果:`--face-sheen` `--sweep-gradient` `--face-in-top` `--face-in-bot`
|
||||||
|
- 阴影:`--shadow`
|
||||||
|
- 背景光晕:`--aurora-*`
|
||||||
|
- 动态 `color-mix()` 表达式
|
||||||
|
|
||||||
|
> 规则生效日:此约定写入后,新代码必须遵守。存量代码暂不回溯,待 P0/P1 功能补齐后再做 codemod 迁移。
|
||||||
|
|
||||||
|
- 统一通过 `lib/api`(`client.ts` 出口)调用后端,禁止在组件内散落 fetch
|
||||||
|
- SSE 连接封装在 `lib/sse-client.ts`,不在组件内直接实例化 EventSource
|
||||||
|
- 所有 API 调用必须有 TypeScript 类型定义和错误处理(`ApiError` 统一承载 code/trace_id)
|
||||||
|
|
||||||
|
## 可视化规范
|
||||||
|
|
||||||
|
- 图谱可视化使用专用图谱库,Mermaid 用于流程图/时序图等
|
||||||
|
- 可视化组件必须支持响应式和主题切换 (暗色/亮色)
|
||||||
|
|
||||||
|
## 代码审查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint # ESLint 检查
|
||||||
|
pnpm type-check # TypeScript 类型检查
|
||||||
|
pnpm format:check # Prettier 格式检查
|
||||||
|
pnpm test # Vitest 测试
|
||||||
|
```
|
||||||
|
|
||||||
|
- 必须同时通过 lint、type-check 与格式检查才能提交
|
||||||
|
- Husky + lint-staged 已配置 `pre-commit` 钩子:对暂存文件自动跑 ESLint --fix + Prettier,再整体 type-check;不通过则阻止提交(临时跳过用 `git commit --no-verify`)
|
||||||
|
- 注:本仓库 git 根在父目录(monorepo),`prepare` 脚本以 `cd .. && husky frontend/.husky` 安装钩子,`core.hooksPath` 指向 `frontend/.husky/_`
|
||||||
|
|
||||||
|
## 绝对禁止事项
|
||||||
|
|
||||||
|
- 直接修改 `package.json` 或 `pnpm-lock.yaml`(依赖必须用 `pnpm add/remove`;scripts 字段可用 `pnpm pkg set`,注意带冒号的键需用方括号语法 `scripts["name"]`)
|
||||||
|
- 在客户端代码中硬编码敏感信息 (密钥、Token等)
|
||||||
|
- 使用 `any` 类型 (除非经过团队评审)
|
||||||
|
- 忽略 TypeScript 或 ESLint 错误强制提交
|
||||||
|
- 在 Server Component 中直接使用 `useState`、`useEffect` 等客户端 Hooks
|
||||||
|
- 使用 CSS-in-JS 方案 (统一使用 Tailwind CSS)
|
||||||
|
- 在 Client Component 中直接调用数据库
|
||||||
|
- 在 useEffect 中做页面初始数据请求
|
||||||
|
- 创建 Class 组件
|
||||||
@@ -7,7 +7,6 @@ import { LogoButton } from "@/components/easter-egg/LogoButton";
|
|||||||
import {
|
import {
|
||||||
AdminIcon,
|
AdminIcon,
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
ChatIcon,
|
|
||||||
CharacterIcon,
|
CharacterIcon,
|
||||||
CosmosIcon,
|
CosmosIcon,
|
||||||
DriveIcon,
|
DriveIcon,
|
||||||
@@ -17,7 +16,6 @@ import {
|
|||||||
PersonalityIcon,
|
PersonalityIcon,
|
||||||
RunIcon,
|
RunIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
TimeIcon,
|
|
||||||
ToolsIcon,
|
ToolsIcon,
|
||||||
WorldIcon,
|
WorldIcon,
|
||||||
WuxingIcon,
|
WuxingIcon,
|
||||||
@@ -53,10 +51,7 @@ interface NavGroup {
|
|||||||
const NAV_GROUPS: NavGroup[] = [
|
const NAV_GROUPS: NavGroup[] = [
|
||||||
{
|
{
|
||||||
title: "灵台",
|
title: "灵台",
|
||||||
items: [
|
items: [{ href: "/runs", label: "轨迹", icon: <RunIcon /> }],
|
||||||
{ href: "/chat", label: "对话", icon: <ChatIcon /> },
|
|
||||||
{ href: "/runs", label: "轨迹", icon: <RunIcon /> },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "法身",
|
title: "法身",
|
||||||
@@ -83,12 +78,6 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
icon: <WorldIcon />,
|
icon: <WorldIcon />,
|
||||||
enabled: FEATURE_FLAGS.worldPanel,
|
enabled: FEATURE_FLAGS.worldPanel,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: "/time",
|
|
||||||
label: "时态",
|
|
||||||
icon: <TimeIcon />,
|
|
||||||
enabled: FEATURE_FLAGS.timePanel,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
export * from "./client";
|
export * from "./client";
|
||||||
export * as audioApi from "./audio";
|
export * as audioApi from "./audio";
|
||||||
export * as charactersApi from "./characters";
|
export * as charactersApi from "./characters";
|
||||||
export * as chatApi from "./chat";
|
|
||||||
export * as runsApi from "./runs";
|
export * as runsApi from "./runs";
|
||||||
export * as sandboxApi from "./sandbox";
|
export * as sandboxApi from "./sandbox";
|
||||||
export * as memoryApi from "./memory";
|
export * as memoryApi from "./memory";
|
||||||
@@ -26,4 +25,3 @@ export * as worldApi from "./world";
|
|||||||
export * as lorebooksApi from "./lorebooks";
|
export * as lorebooksApi from "./lorebooks";
|
||||||
export * as skillsApi from "./skills";
|
export * as skillsApi from "./skills";
|
||||||
export * as mcpApi from "./mcp";
|
export * as mcpApi from "./mcp";
|
||||||
export * as timeApi from "./time";
|
|
||||||
Reference in new issue
Block a user