From ad9c9e942a1243b98054fcea977137749e36469a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Fri, 21 Aug 2026 23:43:31 +0800 Subject: [PATCH] =?UTF-8?q?M2b=EF=BC=9A=E5=88=A0=E9=99=A4=E6=97=A7=20chat?= =?UTF-8?q?=20=E4=BD=93=E7=B3=BB=E2=80=94=E2=80=94=E7=BA=BF=E6=80=A7?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D/=E5=8F=A9=E5=BF=83/=E6=83=85=E7=BB=AA/?= =?UTF-8?q?=E7=BA=AA=E5=BF=B5=E6=97=A5=E6=A8=A1=E5=9E=8B=E4=B8=8E=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E9=80=80=E5=BD=B9=EF=BC=88=E8=BF=81=E7=A7=BB=20003=20?= =?UTF-8?q?=E5=8F=8C=E5=90=91=E9=AA=8C=E8=AF=81=EF=BC=89=EF=BC=8Cruns=20?= =?UTF-8?q?=E5=8E=BB=E4=BC=9A=E8=AF=9D=E5=A4=96=E9=94=AE=EF=BC=8C=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=85=A8=E9=87=8F=E4=BF=AE=E5=89=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/AGENTS.md | 73 +++++++ ...除_chat_情绪_纪念日表与_runs_旧会话外键.py | 114 +++++++++++ backend/app/api/router.py | 4 - backend/app/api/v1/endpoints/runs.py | 2 - backend/app/main.py | 10 - backend/app/models/__init__.py | 7 - backend/app/models/runtime.py | 6 +- backend/app/repositories/runtime.py | 13 +- backend/app/schemas/runtime.py | 1 - backend/app/services/admin.py | 15 +- backend/app/services/characters.py | 46 +---- backend/app/services/runtime.py | 6 +- backend/app/services/serializers.py | 39 ---- backend/tests/conftest.py | 10 - .../tests/services/test_characters_service.py | 100 +++------- backend/tests/test_admin_trace.py | 5 +- backend/tests/test_api.py | 173 ---------------- backend/tests/test_errors.py | 19 +- backend/tests/test_runtime_limits.py | 6 +- frontend/AGENTS.md | 187 ++++++++++++++++++ frontend/src/components/layout/Sidebar.tsx | 13 +- frontend/src/lib/api/index.ts | 2 - 22 files changed, 424 insertions(+), 427 deletions(-) create mode 100644 backend/AGENTS.md create mode 100644 backend/alembic/versions/003_退役旧会话模型_删除_chat_情绪_纪念日表与_runs_旧会话外键.py create mode 100644 frontend/AGENTS.md diff --git a/backend/AGENTS.md b/backend/AGENTS.md new file mode 100644 index 0000000..2c3db52 --- /dev/null +++ b/backend/AGENTS.md @@ -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 # 添加依赖 +uv remove # 移除依赖 +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` 加载) diff --git a/backend/alembic/versions/003_退役旧会话模型_删除_chat_情绪_纪念日表与_runs_旧会话外键.py b/backend/alembic/versions/003_退役旧会话模型_删除_chat_情绪_纪念日表与_runs_旧会话外键.py new file mode 100644 index 0000000..745f447 --- /dev/null +++ b/backend/alembic/versions/003_退役旧会话模型_删除_chat_情绪_纪念日表与_runs_旧会话外键.py @@ -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 ### diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 758b222..0e35dab 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -9,7 +9,6 @@ from app.api.v1.endpoints import ( avatar, character_import, characters, - chat, drive, events, evolution, @@ -30,7 +29,6 @@ from app.api.v1.endpoints import ( sandbox_files, settings, skills, - time, tools, world, ) @@ -40,7 +38,6 @@ api_router.include_router(health.router) api_router.include_router(auth.router) api_router.include_router(characters.router) api_router.include_router(character_import.router) -api_router.include_router(chat.router) api_router.include_router(runs.router) api_router.include_router(kernel_runs.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(audio.router) api_router.include_router(avatar.router) -api_router.include_router(time.router) diff --git a/backend/app/api/v1/endpoints/runs.py b/backend/app/api/v1/endpoints/runs.py index 9aabc8b..beb5991 100644 --- a/backend/app/api/v1/endpoints/runs.py +++ b/backend/app/api/v1/endpoints/runs.py @@ -38,7 +38,6 @@ router = APIRouter(prefix="/runs", tags=["runs"]) @router.get("", response_model=Page[RunResponse]) async def list_runs( character_id: str | None = Query(default=None, description="按人物过滤。"), - session_id: str | None = Query(default=None, description="按会话过滤。"), status: str | None = Query(default=None, description="按状态过滤。"), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), @@ -46,7 +45,6 @@ async def list_runs( """Run 列表(按创建时间倒序);归档页运行时间河数据源。""" return await list_runs_response( character_id=character_id, - session_id=session_id, status=status, page=page, page_size=page_size, diff --git a/backend/app/main.py b/backend/app/main.py index 8440be5..dfb1db4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -30,12 +30,10 @@ from app.core.logging import ( from app.db.init_data import seed_default_data from app.db.session import AsyncSessionLocal, close_engine from app.integrations.sandbox.client import SandboxClient -from app.modules.knocking.pivot import KnockingPivot from app.modules.safety.confirmation import InMemoryConfirmationStore from app.schemas.common import ErrorResponse from app.services.dispatcher import Dispatcher 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 # 模块级初始化日志系统:import 本模块即完成 loguru 配置并接管标准 logging, @@ -117,12 +115,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: # 一炁进程内订阅分发器:订阅 EventStore 全量事件,按 glob pattern 分发给注册的 handler。 # 各枢核心就绪后在 start() 前 register;lifespan 管理 start/stop。 _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() - # 叩心枢主动对话守护进程:后台常驻循环,在 dispatcher 就绪后启动 - _app.state.knocking_pivot = KnockingPivot(_app.state.event_store) - await _app.state.knocking_pivot.start() _app.state.confirmation_store = InMemoryConfirmationStore() if settings.use_db_confirmation_store: from app.modules.safety.confirmation import DbConfirmationStore @@ -177,9 +170,6 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: 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 if getattr(_app.state, "dispatcher", None) is not None: await _app.state.dispatcher.stop() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ae7c8f1..b0bd434 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -3,7 +3,6 @@ from app.models.big_cycle import BigCycle from app.models.candidates import MemoryCandidate, ProfileCandidate from app.models.character import Character -from app.models.chat import ChatMessage, ChatSession from app.models.drive import CuriosityQuestion, ExploratorySubgoal from app.models.evolution import EvolutionCandidate 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.personality import PersonalityProposal, PersonalityVersion -from app.models.personality_emotion import CharacterEmotionState from app.models.rbac import Permission, Role, RolePermission, UserRole from app.models.runtime import Event, Run from app.models.safety import AuditEvent, PendingConfirmationRecord from app.models.skills import Skill -from app.models.time_anniversary import TimeAnniversary from app.models.user import User from app.models.world import CharacterWorldEntity, WorldRelation @@ -42,11 +39,8 @@ __all__ = [ "AuditEvent", "BigCycle", "Character", - "CharacterEmotionState", "CharacterImportJob", "CharacterWorldEntity", - "ChatMessage", - "ChatSession", "CuriosityQuestion", "Event", "EvolutionCandidate", @@ -72,7 +66,6 @@ __all__ = [ "RolePermission", "Run", "Skill", - "TimeAnniversary", "User", "UserRole", "WorldEntity", diff --git a/backend/app/models/runtime.py b/backend/app/models/runtime.py index 8607007..f20e342 100644 --- a/backend/app/models/runtime.py +++ b/backend/app/models/runtime.py @@ -14,13 +14,11 @@ class Run(Base): __tablename__ = "runs" 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( 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) # run.kind:foreground / background_task / awakening / rehearsal(契约词典 §8.2) kind: Mapped[str] = mapped_column( diff --git a/backend/app/repositories/runtime.py b/backend/app/repositories/runtime.py index 481d94b..cb8a78f 100644 --- a/backend/app/repositories/runtime.py +++ b/backend/app/repositories/runtime.py @@ -14,17 +14,14 @@ async def list_runs( session: AsyncSession, *, character_id: str | None = None, - session_id: str | None = None, status: str | None = None, page: int = 1, page_size: int = 20, ) -> tuple[list[Run], int]: - """按创建时间倒序分页返回 Run;可选按人物/会话/状态过滤,返回 (条目, 总数)。""" + """按创建时间倒序分页返回 Run;可选按人物/状态过滤,返回 (条目, 总数)。""" conds = [] if character_id is not None: 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: conds.append(Run.status == status) 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()] -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]}) diff --git a/backend/app/schemas/runtime.py b/backend/app/schemas/runtime.py index bbfe5be..0e3e1c7 100644 --- a/backend/app/schemas/runtime.py +++ b/backend/app/schemas/runtime.py @@ -69,7 +69,6 @@ class RunResponse(BaseModel): character_id: str | None = Field( default=None, description="所属人物 ID;照心试演(kind=rehearsal)无正式人物。" ) - session_id: str | None = Field(default=None, description="关联会话 ID。") status: str = Field( description="运行状态:queued / running / completed / failed / cancelled / interrupted。" ) diff --git a/backend/app/services/admin.py b/backend/app/services/admin.py index ecef01e..29d72e9 100644 --- a/backend/app/services/admin.py +++ b/backend/app/services/admin.py @@ -262,14 +262,11 @@ async def get_trace(trace_id: str) -> TraceDetail: *[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( trace_id=trace_id, character_ids=character_ids, run_ids=run_ids, - session_ids=session_ids, + session_ids=[], items=items, ) @@ -342,15 +339,7 @@ async def list_traces(*, limit: int = 50) -> TraceListResponse: ).all(): _entry(tid)["character_ids"].add(cid) - # 对话归属(events.run_id → runs.session_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) + # 对话归属随线性会话模型退役;镜树归属随 M2c 司命台重设计接入(tree_id 聚合) total = len(merged) ordered = sorted(merged.values(), key=lambda e: e["last_seen"], reverse=True) diff --git a/backend/app/services/characters.py b/backend/app/services/characters.py index 66fc673..f2096f8 100644 --- a/backend/app/services/characters.py +++ b/backend/app/services/characters.py @@ -10,7 +10,6 @@ from app.core.time import utc_now from app.db.session import AsyncSessionLocal from app.models.character import Character from app.models.personality import PersonalityVersion -from app.models.time_anniversary import TimeAnniversary from app.modules.character import ( MODEL_POLICY_CREATE_DEFAULTS, 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 personality as personality_repo -from app.repositories import time_anniversaries as anniversary_repo from app.repositories.json_utils import dumps, loads_dict from app.schemas.character import ( ActivateCharacterResponse, @@ -232,7 +230,7 @@ async def create_character_from_template( session: AsyncSession, template: Character, owner_scope: str ) -> Character: # 模板的 birthday 仅在兜底(空/缺失)时不继承;显式设置的有效生日原样保留, - # 由 _ensure_default_birthday 写入子角色的 profile 与 time_anniversaries 表。 + # 由 _ensure_default_birthday 写入子角色的 profile。 profile = loads_dict(template.profile_json) birthday = profile.get("birthday") 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: - """birthday 缺失时用 created_at 兜底:写 profile.birthday 并建一条生日纪念日。 + """birthday 缺失时用 created_at 兜底:仅写 profile.birthday。 - - 仅在 profile.birthday 为空/缺失时触发默认兜底;用户或模板已设则不覆盖。 - - 无论 birthday 是否已设,只要角色尚无 birthday 纪念日,就建一条。 - - profile.birthday 与 TimeAnniversary.date 取 birthday 值(显式)或 created_at 的 ISO 日期。 - - 须在 character flush 后调用(依赖 id 与 created_at);同一事务内随外层 commit 落库。 + 纪念日表(time_anniversaries)随旧时间模型退役删除;辰正(M8 重实现)落地后 + 生日提醒改由时态感知按 profile.birthday 计算,不再预写行。 """ profile = loads_dict(character.profile_json) birthday = profile.get("birthday") 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 - birthday_iso = character.created_at.strftime("%Y-%m-%d") - profile["birthday"] = birthday_iso + profile["birthday"] = character.created_at.strftime("%Y-%m-%d") 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( session: AsyncSession, diff --git a/backend/app/services/runtime.py b/backend/app/services/runtime.py index ab35b07..b36a44b 100644 --- a/backend/app/services/runtime.py +++ b/backend/app/services/runtime.py @@ -28,14 +28,12 @@ async def create_run( session: AsyncSession, *, character_id: str, - session_id: str | None, model_hint: str, goal: str = "回复用户消息", origin: str = "chat", ) -> Run: run = Run( character_id=character_id, - session_id=session_id, status="running", goal=goal, origin=origin, @@ -175,17 +173,15 @@ async def get_run_response(run_id: str) -> RunResponse: async def list_runs_response( *, character_id: str | None = None, - session_id: str | None = None, status: str | None = None, page: int = 1, page_size: int = 20, ) -> Page[RunResponse]: - """Run 列表(按创建时间倒序);可选按人物/会话/状态过滤。归档页运行时间河数据源。""" + """Run 列表(按创建时间倒序);可选按人物/状态过滤。归档页运行时间河数据源。""" async with AsyncSessionLocal() as session: items, total = await runtime_repo.list_runs( session, character_id=character_id, - session_id=session_id, status=status, page=page, page_size=page_size, diff --git a/backend/app/services/serializers.py b/backend/app/services/serializers.py index a8e9122..a21fcb4 100644 --- a/backend/app/services/serializers.py +++ b/backend/app/services/serializers.py @@ -6,7 +6,6 @@ from typing import Any from app.core.time import to_iso, utc_now from app.models.candidates import MemoryCandidate, ProfileCandidate from app.models.character import Character -from app.models.chat import ChatMessage, ChatSession from app.models.governance import ( AgentRosterEntry, GenerationPreferences, @@ -33,7 +32,6 @@ from app.schemas.admin import ( ) from app.schemas.candidates import MemoryCandidateResponse, ProfileCandidateResponse from app.schemas.character import CharacterResponse, CharacterSummaryResponse -from app.schemas.chat import ChatMessageResponse, ChatSessionResponse from app.schemas.imports import ImportItemResponse, ImportJobResponse from app.schemas.memory import MemoryResponse 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: return MemoryResponse( id=memory.id, @@ -185,7 +147,6 @@ def run_to_response(run: Run) -> RunResponse: return RunResponse( id=run.id, character_id=run.character_id, - session_id=run.session_id, status=run.status, kind=run.kind, goal=run.goal, diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 4dae473..c114d9f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -74,16 +74,6 @@ async def reset_database() -> AsyncGenerator[None, None]: from app.services.ai_provider_admin import 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 _assert_test_database_url(str(_engine.url)) async with _engine.begin() as conn: diff --git a/backend/tests/services/test_characters_service.py b/backend/tests/services/test_characters_service.py index 51e41ff..d178889 100644 --- a/backend/tests/services/test_characters_service.py +++ b/backend/tests/services/test_characters_service.py @@ -1,55 +1,39 @@ -"""人物服务测试:默认生日兜底(创建时未设 birthday → 用 created_at 兜底)。""" +"""人物服务测试:默认生日兜底(创建时未设 birthday → 用 created_at 兜底)。 + +纪念日表(time_anniversaries)随旧时间模型退役删除,本文件只验证 profile 兜底; +辰正(M8 重实现)落地后生日提醒改按 profile.birthday 计算。 +""" import pytest from app.db.session import AsyncSessionLocal 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.schemas.character import CreateCharacterRequest 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 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: resp = await create_character(session, CreateCharacterRequest(name="lingxi")) - 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") 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 async def test_create_character_with_birthday_keeps_user_value(): - """用户传了 birthday(即便为自由文本)→ 保持用户值,且不自动建生日纪念日。""" + """用户传了 birthday(即便为自由文本)→ 保持用户值。""" async with AsyncSessionLocal() as session: resp = await create_character( session, CreateCharacterRequest(name="jingwu", profile={"birthday": "霜降之夜"}), ) - anniversaries = await anni_repo.list_anniversaries(session, resp.id) assert resp.profile["birthday"] == "霜降之夜" - assert _birthday_anniversaries(anniversaries) == [] @pytest.mark.asyncio @@ -58,75 +42,53 @@ async def test_create_character_with_blank_birthday_applies_default(): async with AsyncSessionLocal() as session: resp = await create_character( 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") - assert resp.profile["birthday"] == birthday_iso - assert len(_birthday_anniversaries(anniversaries)) == 1 + async with AsyncSessionLocal() as session: + character = await session.get(Character, resp.id) + 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 async def test_create_character_from_template_without_birthday_uses_own_created_at(): """模板 profile 无 birthday → 子角色用自己的 created_at 兜底。""" async with AsyncSessionLocal() as session: - template = Character( - 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", - ) + template = _template("test_tpl", '{"background": "测试模板"}') session.add(template) await session.flush() character = await create_character_from_template(session, template, "local-user") 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"] == 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 == "模板·生辰" + assert profile["birthday"] == character.created_at.strftime("%Y-%m-%d") @pytest.mark.asyncio async def test_create_character_from_template_inherits_explicit_birthday(): """模板 profile 有显式 birthday → 子角色继承该值,不兜底。""" async with AsyncSessionLocal() as session: - template = Character( - 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", - ) + template = _template("test_tpl2", '{"birthday": "2020-01-01"}') session.add(template) await session.flush() character = await create_character_from_template(session, template, "local-user") profile = loads_dict(character.profile_json) - anniversaries = await anni_repo.list_anniversaries(session, character.id) - assert profile["birthday"] == "2020-01-01" # 继承模板的显式 birthday - birthday_annivs = _birthday_anniversaries(anniversaries) - assert len(birthday_annivs) == 1 - assert birthday_annivs[0].date == "2020-01-01" - assert birthday_annivs[0].name == "模板二·生辰" + assert profile["birthday"] == "2020-01-01" diff --git a/backend/tests/test_admin_trace.py b/backend/tests/test_admin_trace.py index eb8751f..3120f97 100644 --- a/backend/tests/test_admin_trace.py +++ b/backend/tests/test_admin_trace.py @@ -26,7 +26,6 @@ async def _seed_trace_data() -> None: Run( id="run_001", character_id="char_test", - session_id="sess_test", status="completed", origin="chat", 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.run_ids == ["run_001"] assert detail.character_ids == ["char_test"] - assert detail.session_ids == ["sess_test"] + assert detail.session_ids == [] # 会话归属随线性会话退役 # 五条记录按 created_at 升序归并 assert [item.kind for item in detail.items] == [ "event", @@ -180,7 +179,7 @@ async def test_list_traces_aggregates_and_sorts_recent_first() -> None: assert s.audit_count == 2 assert s.idempotency_count == 1 assert s.character_ids == ["char_test"] - assert s.session_ids == ["sess_test"] + assert s.session_ids == [] # 会话归属随线性会话退役 # 按 last_seen 倒序 assert all( resp.items[i].last_seen >= resp.items[i + 1].last_seen for i in range(len(resp.items) - 1) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index a7df3ad..021f829 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -5,10 +5,8 @@ from collections.abc import AsyncGenerator import pytest from httpx import ASGITransport, AsyncClient -from app.ai_provider.types.chat import ChatChunk from app.api.v1.deps import require_admin from app.application.agent.runtime import AgentTurnResult -from app.application.chat.send_message import drain_pending_streams from app.main import app @@ -16,11 +14,6 @@ from app.main import app 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, @@ -41,136 +34,6 @@ async def test_health_and_defaults(client: AsyncClient) -> None: 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 async def test_personality_propose_approve_reject_and_rollback(client: AsyncClient) -> None: # 初始当前人格为 v1 @@ -252,42 +115,6 @@ async def test_personality_propose_approve_reject_and_rollback(client: AsyncClie 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 async def test_skills_crud(client: AsyncClient) -> None: """Skill 能力包 CRUD——覆盖 services/skills 路径。""" diff --git a/backend/tests/test_errors.py b/backend/tests/test_errors.py index ff11a68..92251ce 100644 --- a/backend/tests/test_errors.py +++ b/backend/tests/test_errors.py @@ -34,23 +34,8 @@ async def test_error_contracts(client: AsyncClient) -> None: assert missing_run.status_code == 404 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_error(validation, "validation_failed") - created = await client.post("/api/v1/chat/sessions", json={"title": "错误"}) - 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") + # 旧 chat 会话错误契约随线性会话模型退役;415/413 契约由镜树/媒体域随需要重建 diff --git a/backend/tests/test_runtime_limits.py b/backend/tests/test_runtime_limits.py index 912e86e..a2cfc58 100644 --- a/backend/tests/test_runtime_limits.py +++ b/backend/tests/test_runtime_limits.py @@ -30,7 +30,7 @@ async def test_event_payload_limit(monkeypatch: pytest.MonkeyPatch) -> None: character_id = await _admin_character_id() async with AsyncSessionLocal() as db: 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: await append_event( @@ -49,7 +49,7 @@ async def test_run_max_events(monkeypatch: pytest.MonkeyPatch) -> None: character_id = await _admin_character_id() async with AsyncSessionLocal() as db: 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( 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() async with AsyncSessionLocal() as db: 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() response = await cancel_run(run.id) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 0000000..a5f2e19 --- /dev/null +++ b/frontend/AGENTS.md @@ -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 # 添加依赖 +pnpm remove # 移除依赖 + +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 类 +
+ +
+
+