From b419e0afde1e05a55a091a34400190f9c256eff7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Thu, 27 Aug 2026 20:26:52 +0800 Subject: [PATCH] =?UTF-8?q?M7=EF=BC=9A=E5=BD=A2=E4=BB=AA=E4=B8=8E=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=E2=80=94=E2=80=94=E7=9C=9F=E5=BD=A2=E9=94=9A=E7=82=B9?= =?UTF-8?q?=E3=80=81=E5=80=99=E9=80=89=E5=AE=9A=E7=9B=B8=E3=80=81=E5=A3=B0?= =?UTF-8?q?=E9=9F=B3=E6=A1=A3=E6=A1=88=E4=B8=8E=E5=85=8B=E9=9A=86=E6=8E=88?= =?UTF-8?q?=E6=9D=83=E3=80=81ASR=20=E6=8E=A5=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 形仪域 domains/xingyi:form_anchors(稳定辨识特征/年龄表现/许可禁忌/可变范围——生成内容不可静默覆盖锚点);forms(主仪/化仪/文字形仪,candidate→adopt 定相治理:未定相不进呈现位、主仪唯一、新主仪自动退役旧主仪);voice_profiles(三来源,授权克隆必填用途与传播范围,revoke-grant 撤回立即生效,assert-usable 合成前置校验拒绝撤回/过期) - ASR:元炁 SenseVoice /v1/audio/transcriptions 透传(Whisper 兼容 multipart);YUANQI_BASE_URL 未配置时 503 不降级不伪造——多模态关闭纪律 - 前端形仪工坊(人物详情页):真形锚点编辑(特征逐行解析)/候选登记定相废弃/声音档案建立与授权撤回;头像呈现位改走定相主仪(forms/primary);xingyi api 库 + mock + 组件 4 用例 - 退役:旧 avatar 模块(services/endpoints/avatar、avatar_dir 配置、avatar_url 字段、前端直传组件与测试) - 验收(test_m7_xingyi 三用例):未定相不成为正式形仪(含主仪唯一与旧主仪 retired 断言);克隆授权可撤回(缺用途拒/撤回即拒/过期同理/预设不受约束);关闭全部多模态后文字相伴完整可用(ASR 503 时镜树缘契百工照常——回归) - 四套件:后端 376、前端 203、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 008 净库+±1 干净 --- ...形锚点_主仪化仪定相_声音档案与克隆授权.py | 90 ++++ backend/app/api/router.py | 4 +- backend/app/api/v1/endpoints/avatar.py | 47 -- backend/app/api/v1/endpoints/xingyi.py | 5 + backend/app/core/config/app_config.py | 1 - backend/app/core/config/runtime_config.py | 3 + backend/app/core/config/settings.py | 7 - backend/app/domains/xingyi/__init__.py | 5 + backend/app/domains/xingyi/api.py | 157 +++++++ backend/app/domains/xingyi/asr.py | 43 ++ backend/app/domains/xingyi/models.py | 88 ++++ backend/app/domains/xingyi/service.py | 324 ++++++++++++++ backend/app/models/__init__.py | 4 + backend/app/schemas/character.py | 3 - backend/app/services/avatar.py | 131 ------ backend/app/services/serializers.py | 9 - backend/tests/api/test_avatar.py | 161 ------- backend/tests/test_m7_xingyi.py | 157 +++++++ docs/operations/capability-matrix.md | 3 +- docs/operations/rewrite-plan.md | 7 +- .../(app)/characters/[characterId]/page.tsx | 43 +- .../components/characters/CharacterAvatar.tsx | 110 +---- .../characters/CharacterIdentityCard.tsx | 11 +- frontend/src/components/ui/Icons.tsx | 130 ++++++ .../src/components/xingyi/FormStudio.test.tsx | 147 ++++++ frontend/src/components/xingyi/FormStudio.tsx | 417 ++++++++++++++++++ frontend/src/lib/api/index.ts | 1 + frontend/src/lib/api/xingyi.ts | 125 ++++++ frontend/src/lib/mock/index.ts | 110 +++++ frontend/src/types/api.ts | 1 - 30 files changed, 1876 insertions(+), 468 deletions(-) create mode 100644 backend/alembic/versions/008_形仪域_真形锚点_主仪化仪定相_声音档案与克隆授权.py delete mode 100644 backend/app/api/v1/endpoints/avatar.py create mode 100644 backend/app/api/v1/endpoints/xingyi.py create mode 100644 backend/app/domains/xingyi/__init__.py create mode 100644 backend/app/domains/xingyi/api.py create mode 100644 backend/app/domains/xingyi/asr.py create mode 100644 backend/app/domains/xingyi/models.py create mode 100644 backend/app/domains/xingyi/service.py delete mode 100644 backend/app/services/avatar.py delete mode 100644 backend/tests/api/test_avatar.py create mode 100644 backend/tests/test_m7_xingyi.py create mode 100644 frontend/src/components/xingyi/FormStudio.test.tsx create mode 100644 frontend/src/components/xingyi/FormStudio.tsx create mode 100644 frontend/src/lib/api/xingyi.ts diff --git a/backend/alembic/versions/008_形仪域_真形锚点_主仪化仪定相_声音档案与克隆授权.py b/backend/alembic/versions/008_形仪域_真形锚点_主仪化仪定相_声音档案与克隆授权.py new file mode 100644 index 0000000..7fd8f37 --- /dev/null +++ b/backend/alembic/versions/008_形仪域_真形锚点_主仪化仪定相_声音档案与克隆授权.py @@ -0,0 +1,90 @@ +"""形仪域_真形锚点_主仪化仪定相_声音档案与克隆授权 + +Revision ID: 008 +Revises: 007 +Create Date: 2026-08-27 20:15:34.665285 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '008' +down_revision: Union[str, Sequence[str], None] = '007' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('form_anchors', + sa.Column('id', sa.String(length=80), nullable=False), + sa.Column('character_id', sa.String(length=80), nullable=False), + sa.Column('features_json', sa.Text(), nullable=False), + sa.Column('age_presentation', sa.String(length=40), nullable=False), + sa.Column('permissions_json', sa.Text(), nullable=False), + sa.Column('mutable_scope_json', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_form_anchors_character_id'), 'form_anchors', ['character_id'], unique=False) + op.create_table('forms', + sa.Column('id', sa.String(length=80), nullable=False), + sa.Column('character_id', sa.String(length=80), nullable=False), + sa.Column('kind', sa.String(length=20), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('source', sa.String(length=20), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('media_ref', sa.String(length=500), nullable=True), + sa.Column('live2d_json', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('adopted_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_forms_character_id'), 'forms', ['character_id'], unique=False) + op.create_index(op.f('ix_forms_kind'), 'forms', ['kind'], unique=False) + op.create_index(op.f('ix_forms_status'), 'forms', ['status'], unique=False) + op.create_table('voice_profiles', + sa.Column('id', sa.String(length=80), nullable=False), + sa.Column('character_id', sa.String(length=80), nullable=False), + sa.Column('source', sa.String(length=30), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('params_json', sa.Text(), nullable=False), + sa.Column('grant_purpose', sa.String(length=200), nullable=True), + sa.Column('grant_expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('grant_distribution', sa.String(length=200), nullable=True), + sa.Column('grant_revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_voice_profiles_character_id'), 'voice_profiles', ['character_id'], unique=False) + op.create_index(op.f('ix_voice_profiles_is_active'), 'voice_profiles', ['is_active'], unique=False) + op.add_column('pending_confirmations', sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True)) + op.add_column('pending_confirmations', sa.Column('brief_json', sa.Text(), server_default='{}', nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('pending_confirmations', 'brief_json') + op.drop_column('pending_confirmations', 'expires_at') + op.drop_index(op.f('ix_voice_profiles_is_active'), table_name='voice_profiles') + op.drop_index(op.f('ix_voice_profiles_character_id'), table_name='voice_profiles') + op.drop_table('voice_profiles') + op.drop_index(op.f('ix_forms_status'), table_name='forms') + op.drop_index(op.f('ix_forms_kind'), table_name='forms') + op.drop_index(op.f('ix_forms_character_id'), table_name='forms') + op.drop_table('forms') + op.drop_index(op.f('ix_form_anchors_character_id'), table_name='form_anchors') + op.drop_table('form_anchors') + # ### end Alembic commands ### diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 2e2eb9f..46166da 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -5,7 +5,6 @@ from fastapi import APIRouter from app.api.v1.endpoints import ( admin, auth, - avatar, character_import, characters, @@ -21,6 +20,7 @@ from app.api.v1.endpoints import ( baigong, hetu, soul, + xingyi, yuanqi, mirror_trees, mcp, @@ -44,6 +44,7 @@ api_router.include_router(kernel_runs.router) api_router.include_router(me.router) api_router.include_router(hetu.world_router) api_router.include_router(soul.soul_router) +api_router.include_router(xingyi.form_router) api_router.include_router(yuanqi.cov_router) for _baigong_r in baigong.routers: api_router.include_router(_baigong_r) @@ -62,4 +63,3 @@ api_router.include_router(imports.router) api_router.include_router(events.router) api_router.include_router(goals.router) api_router.include_router(admin.router) -api_router.include_router(avatar.router) diff --git a/backend/app/api/v1/endpoints/avatar.py b/backend/app/api/v1/endpoints/avatar.py deleted file mode 100644 index d563e43..0000000 --- a/backend/app/api/v1/endpoints/avatar.py +++ /dev/null @@ -1,47 +0,0 @@ -"""角色头像 API。 - -上传与获取角色头像图片文件。 -""" - -from fastapi import APIRouter, Depends, File, UploadFile - -from app.api.v1.deps import get_current_user -from app.schemas.auth import CurrentUser -from app.services.avatar import ( - get_avatar_response, - upload_avatar, -) - -router = APIRouter(prefix="/characters", tags=["characters"]) - - -@router.post("/{character_id}/avatar") -async def upload_character_avatar( - character_id: str, - file: UploadFile = File(..., description="头像图片,支持 JPG/PNG/WebP,最大 5MB。"), - current_user: CurrentUser = Depends(get_current_user), -) -> dict: - """上传角色头像。 - - 支持的格式:JPEG、PNG、WebP。最大 5MB。 - 新头像会替换旧头像(同角色只保留一张)。 - """ - content = await file.read() - avatar_url = await upload_avatar( - character_id=character_id, - content=content, - content_type=file.content_type or "image/jpeg", - owner_scope=current_user.owner_scope, - ) - return {"avatar_url": avatar_url} - - -@router.get("/{character_id}/avatar") -async def get_character_avatar( - character_id: str, -) -> object: - """获取角色头像图片。 - - 返回原始图片文件。无头像时返回 404。 - """ - return await get_avatar_response(character_id) diff --git a/backend/app/api/v1/endpoints/xingyi.py b/backend/app/api/v1/endpoints/xingyi.py new file mode 100644 index 0000000..de932f9 --- /dev/null +++ b/backend/app/api/v1/endpoints/xingyi.py @@ -0,0 +1,5 @@ +"""形仪路由(域实现见 app/domains/xingyi/api.py)。""" + +from app.domains.xingyi.api import form_router + +__all__ = ["form_router"] diff --git a/backend/app/core/config/app_config.py b/backend/app/core/config/app_config.py index 38c0c92..b6f0d68 100644 --- a/backend/app/core/config/app_config.py +++ b/backend/app/core/config/app_config.py @@ -30,7 +30,6 @@ class AppConfig(BaseSettings): ) # ==================== 文件存储 ==================== - avatar_dir: str = "../db/avatars" # 角色头像文件存储目录(相对 backend) # ==================== 默认管理员用户 ==================== default_admin_username: str = "admin" # 种子默认管理员用户名 diff --git a/backend/app/core/config/runtime_config.py b/backend/app/core/config/runtime_config.py index 70e250d..ec34334 100644 --- a/backend/app/core/config/runtime_config.py +++ b/backend/app/core/config/runtime_config.py @@ -81,6 +81,9 @@ class RuntimeConfig(BaseSettings): chroma_collection: str = "hunjing_memories" # ChromaDB collection 名 chroma_persist_directory: str = "../db/chroma" # ChromaDB 持久化目录(相对 backend) embedding_model_hint: str = "bge-m3" # embedding 模型逻辑名 + # 元炁本地推理引擎(ASR 语音转写;未配置=多模态关闭,文字链路不受影响) + yuanqi_base_url: str = "" + yuanqi_asr_model: str = "iic/SenseVoiceSmall" # ==================== 自动记忆候选 [PLANNED] ==================== enable_memory_candidates: bool = False # 是否启用自动记忆候选抽取 diff --git a/backend/app/core/config/settings.py b/backend/app/core/config/settings.py index e524094..98bf20f 100644 --- a/backend/app/core/config/settings.py +++ b/backend/app/core/config/settings.py @@ -115,13 +115,6 @@ class Settings( return example return configured - @cached_property - def resolved_avatar_dir(self) -> Path: - """解析角色头像存储目录为绝对路径。""" - path = self.resolve_from_backend(self.avatar_dir) - path.mkdir(parents=True, exist_ok=True) - return path - # 全局配置实例 settings = Settings() diff --git a/backend/app/domains/xingyi/__init__.py b/backend/app/domains/xingyi/__init__.py new file mode 100644 index 0000000..5ca5432 --- /dev/null +++ b/backend/app/domains/xingyi/__init__.py @@ -0,0 +1,5 @@ +"""形仪域:真形锚点、主仪/化仪候选定相、声音档案与克隆授权(rewrite/1.0 M7)。""" + +from app.domains.xingyi import asr, models, service # noqa: F401 + +__all__ = ["asr", "models", "service"] diff --git a/backend/app/domains/xingyi/api.py b/backend/app/domains/xingyi/api.py new file mode 100644 index 0000000..7db9216 --- /dev/null +++ b/backend/app/domains/xingyi/api.py @@ -0,0 +1,157 @@ +"""形仪 API:真形锚点 / 形仪候选定相 / 声音档案与克隆授权 / ASR 转写。""" + +from datetime import datetime + +from fastapi import APIRouter, Depends, File, UploadFile, status +from pydantic import BaseModel, Field + +from app.api.v1.deps import get_current_user +from app.domains.xingyi import service +from app.schemas.auth import CurrentUser + +form_router = APIRouter(prefix="/characters/{character_id}", tags=["xingyi"]) + + +class AnchorUpsertRequest(BaseModel): + features: dict = Field(default_factory=dict, description="稳定辨识特征") + age_presentation: str = "adult" + permissions: dict = Field(default_factory=dict) + mutable_scope: dict = Field(default_factory=dict) + + +class FormCreateRequest(BaseModel): + kind: str = "costume" + source: str = "upload" + description: str = Field(min_length=1, max_length=4000) + media_ref: str | None = None + live2d: dict | None = None + + +class VoiceCreateRequest(BaseModel): + source: str + name: str = Field(min_length=1, max_length=120) + params: dict = Field(default_factory=dict) + grant_purpose: str | None = None + grant_expires_at: datetime | None = None + grant_distribution: str | None = None + + +# ── 真形锚点 ── + + +@form_router.get("/form-anchor") +async def get_anchor( + character_id: str, user: CurrentUser = Depends(get_current_user) +) -> dict: + return await service.get_anchor(character_id, user.owner_scope) + + +@form_router.put("/form-anchor") +async def upsert_anchor( + character_id: str, payload: AnchorUpsertRequest, user: CurrentUser = Depends(get_current_user) +) -> dict: + return await service.upsert_anchor( + character_id, user.owner_scope, + features=payload.features, age_presentation=payload.age_presentation, + permissions=payload.permissions, mutable_scope=payload.mutable_scope, + ) + + +# ── 形仪候选与定相 ── + + +@form_router.get("/forms", status_code=status.HTTP_200_OK) +async def list_forms_api( + character_id: str, user: CurrentUser = Depends(get_current_user), status_filter: str | None = None +) -> list[dict]: + return await service.list_forms(character_id, user.owner_scope, status=status_filter) + + +@form_router.post("/forms", status_code=status.HTTP_201_CREATED) +async def create_form_api( + character_id: str, payload: FormCreateRequest, user: CurrentUser = Depends(get_current_user) +) -> dict: + return await service.create_form( + character_id, user.owner_scope, + kind=payload.kind, source=payload.source, description=payload.description, + media_ref=payload.media_ref, live2d=payload.live2d, + ) + + +@form_router.get("/forms/primary") +async def current_primary_api( + character_id: str, user: CurrentUser = Depends(get_current_user) +) -> dict | None: + return await service.current_primary(character_id, user.owner_scope) + + +@form_router.post("/forms/{form_id}/adopt") +async def adopt_form_api( + character_id: str, form_id: str, user: CurrentUser = Depends(get_current_user) +) -> dict: + return await service.adopt_form(form_id, user.owner_scope) + + +@form_router.post("/forms/{form_id}/discard") +async def discard_form_api( + character_id: str, form_id: str, user: CurrentUser = Depends(get_current_user) +) -> dict: + return await service.discard_form(form_id, user.owner_scope) + + +# ── 声音档案 ── + + +@form_router.get("/voices") +async def list_voices_api( + character_id: str, user: CurrentUser = Depends(get_current_user) +) -> list[dict]: + return await service.list_voices(character_id, user.owner_scope) + + +@form_router.post("/voices", status_code=status.HTTP_201_CREATED) +async def create_voice_api( + character_id: str, payload: VoiceCreateRequest, user: CurrentUser = Depends(get_current_user) +) -> dict: + return await service.create_voice( + character_id, user.owner_scope, + source=payload.source, name=payload.name, params=payload.params, + grant_purpose=payload.grant_purpose, grant_expires_at=payload.grant_expires_at, + grant_distribution=payload.grant_distribution, + ) + + +@form_router.post("/voices/{voice_id}/revoke-grant") +async def revoke_voice_grant_api( + character_id: str, voice_id: str, user: CurrentUser = Depends(get_current_user) +) -> dict: + return await service.revoke_voice_grant(voice_id, user.owner_scope) + + +@form_router.post("/voices/{voice_id}/assert-usable") +async def assert_voice_usable_api( + character_id: str, voice_id: str, user: CurrentUser = Depends(get_current_user) +) -> dict: + """合成前置校验(授权克隆:未撤回且未过期才可用)。""" + return await service.assert_voice_usable(voice_id, user.owner_scope) + + +# ── ASR 转写(元炁 /v1/audio/transcriptions 透传) ── + + +@form_router.post("/voice/transcriptions") +async def transcribe_audio_api( + character_id: str, + user: CurrentUser = Depends(get_current_user), + audio: UploadFile = File(...), +) -> dict: + """语音转文本(元炁 SenseVoice)。转写文本由前端作为镜树问节内容提交。 + + 多模态关闭(YUANQI 未启用)时本端点 503——文字相伴链路不受影响(回归由 + 测试钉死:关闭全部多模态后镜树/缘契照常)。 + """ + from app.domains.xingyi.asr import transcribe + + await service._owned(character_id, user.owner_scope) + content = await audio.read() + return await transcribe(content, audio.filename or "audio.webm") diff --git a/backend/app/domains/xingyi/asr.py b/backend/app/domains/xingyi/asr.py new file mode 100644 index 0000000..23d3bf9 --- /dev/null +++ b/backend/app/domains/xingyi/asr.py @@ -0,0 +1,43 @@ +"""ASR 转写客户端:灵台 → 元炁 ``/v1/audio/transcriptions``(Whisper 兼容 multipart)。 + +多模态关闭纪律:YUANQI_BASE_URL 未配置(或调用失败)时 503,不降级、不伪造转写; +文字相伴链路(镜树/缘契/百工)不依赖本模块——关闭全部多模态后完整可用。 +""" + +from __future__ import annotations + +import httpx + +from app.core.config import settings +from app.core.errors import AppError + +_TIMEOUT_SECONDS = 120.0 # 本地推理首包较慢,给足窗口 + + +async def transcribe(audio_bytes: bytes, filename: str) -> dict: + base_url = settings.yuanqi_base_url + if not base_url: + raise AppError( + "service_unavailable", + "语音转写未启用(YUANQI_BASE_URL 未配置,多模态已关闭)。", + status_code=503, + ) + async with httpx.AsyncClient(trust_env=False, timeout=_TIMEOUT_SECONDS) as client: + response = await client.post( + f"{base_url.rstrip('/')}/v1/audio/transcriptions", + files={"file": (filename, audio_bytes)}, + data={"model": settings.yuanqi_asr_model}, + ) + if response.status_code >= 400: + raise AppError( + "service_unavailable", + f"元炁转写失败(HTTP {response.status_code}):{response.text[:200]}", + status_code=502, + ) + data = response.json() + text = data.get("text") or "" + return { + "text": text, + "engine": "yuanqi.sensevoice", + "original": text, # ASR 原转写(与正影文本并存的语义由镜树承载) + } diff --git a/backend/app/domains/xingyi/models.py b/backend/app/domains/xingyi/models.py new file mode 100644 index 0000000..5cd4ddb --- /dev/null +++ b/backend/app/domains/xingyi/models.py @@ -0,0 +1,88 @@ +"""形仪域表模型:真形锚点 / 主仪化仪(候选定相)/ 声音档案。 + +设计依据 core-domains.md §5 与 PRD §8: +- 真形锚点记录稳定辨识特征、年龄表现、许可、禁忌与可变范围; +- 生成内容先为 candidate,用户定相(adopt)后才成为正式主仪或化仪; +- 声音三来源(合成预设/原创设计/授权克隆);克隆授权按身份、用途、期限与 + 传播范围治理,可撤回,撤回立即生效。 +""" + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.ids import new_id +from app.core.time import utc_now +from app.db.base import Base + + +class FormAnchor(Base): + """真形锚点:不可被生成内容静默覆盖的稳定辨识约束。""" + + __tablename__ = "form_anchors" + + id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("anchor")) + character_id: Mapped[str] = mapped_column( + String(80), ForeignKey("characters.id"), nullable=False, index=True + ) + # 稳定辨识特征(发色/瞳色/身形/标志性印记等) + features_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + # 年龄表现约束(成人角色恒定成人表现;不得生成未成年敏感形态) + age_presentation: Mapped[str] = mapped_column(String(40), nullable=False, default="adult") + # 许可与禁忌(允许的画风/场景;禁止的表现) + permissions_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + # 可变范围(服装/表情/姿态可变;核心特征不可变) + mutable_scope_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utc_now, onupdate=utc_now + ) + + +class Form(Base): + """主仪/化仪:一魂多仪。candidate → adopted(定相)/ discarded;同一时刻仅一主仪。""" + + __tablename__ = "forms" + + id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("form")) + character_id: Mapped[str] = mapped_column( + String(80), ForeignKey("characters.id"), nullable=False, index=True + ) + # primary 主仪 / costume 化仪 / text 文字形仪(启镜最小形态) + kind: Mapped[str] = mapped_column(String(20), nullable=False, default="costume", index=True) + # candidate(候选,未定相不转正)/ adopted / discarded / retired + status: Mapped[str] = mapped_column(String(20), nullable=False, default="candidate", index=True) + # 来源:image_gen(生成)/ upload(上传)/ live2d(模型导入)/ text + source: Mapped[str] = mapped_column(String(20), nullable=False, default="upload") + # 文字描述(所有形态必填——文字形仪或图像的文字说明) + description: Mapped[str] = mapped_column(Text, nullable=False, default="") + # 媒体引用(图片文件路径或 Live2D 模型包引用;文字形仪为空) + media_ref: Mapped[str | None] = mapped_column(String(500)) + # Live2D 动作映射(模型 motions/expressions 索引,JSON) + live2d_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + adopted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class VoiceProfile(Base): + """声音档案:合成预设 / 原创设计 / 授权克隆。""" + + __tablename__ = "voice_profiles" + + id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("voice")) + character_id: Mapped[str] = mapped_column( + String(80), ForeignKey("characters.id"), nullable=False, index=True + ) + # synthesis_preset / original_design / authorized_clone + source: Mapped[str] = mapped_column(String(30), nullable=False, default="synthesis_preset") + name: Mapped[str] = mapped_column(String(120), nullable=False) + # 合成参数(音色/语速/音高;预设名或设计参数) + params_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + # 克隆授权(authorized_clone 必填):身份、用途、期限、传播范围;可撤回 + grant_purpose: Mapped[str | None] = mapped_column(String(200)) + grant_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + grant_distribution: Mapped[str | None] = mapped_column(String(200)) + grant_revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + is_active: Mapped[bool] = mapped_column(default=True, nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) diff --git a/backend/app/domains/xingyi/service.py b/backend/app/domains/xingyi/service.py new file mode 100644 index 0000000..d98e7c1 --- /dev/null +++ b/backend/app/domains/xingyi/service.py @@ -0,0 +1,324 @@ +"""形仪域应用服务:真形锚点、候选定相、声音档案与克隆授权治理。 + +铁律(PRD §8.1/§8.2): +- 生成内容先为 candidate,用户定相(adopt)才成为正式形仪;同一时刻仅一主仪 + (定相新主仪自动退役旧主仪); +- 克隆授权按身份、用途、期限与传播范围治理,可撤回,撤回立即生效; +- 未定相候选不得出现在正式形仪呈现位(头像位/主仪卡)。 +""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime + +from sqlalchemy import select + +from app.core.errors import AppError +from app.core.time import utc_now +from app.db.session import AsyncSessionLocal +from app.domains import one_qi +from app.domains.xingyi.models import Form, FormAnchor, VoiceProfile +from app.models.character import Character + + +async def _owned(character_id: str, owner_scope: str) -> None: + async with AsyncSessionLocal() as session: + character = await session.get(Character, character_id) + if character is None or character.owner_scope != owner_scope: + raise AppError("not_found", "人物不存在。", status_code=404) + + +# ── 真形锚点 ── + + +async def get_anchor(character_id: str, owner_scope: str) -> dict: + await _owned(character_id, owner_scope) + async with AsyncSessionLocal() as session: + row = ( + await session.execute( + select(FormAnchor).where(FormAnchor.character_id == character_id) + ) + ).scalars().first() + if row is None: + return {"character_id": character_id, "features": {}, "age_presentation": "adult", + "permissions": {}, "mutable_scope": {}} + session.expunge(row) + return _anchor_dict(row) + + +async def upsert_anchor( + character_id: str, owner_scope: str, *, features: dict, age_presentation: str, + permissions: dict, mutable_scope: dict, +) -> dict: + await _owned(character_id, owner_scope) + async with AsyncSessionLocal() as session: + row = ( + await session.execute( + select(FormAnchor).where(FormAnchor.character_id == character_id) + ) + ).scalars().first() + if row is None: + row = FormAnchor(character_id=character_id) + session.add(row) + row.features_json = json.dumps(features or {}, ensure_ascii=False) + row.age_presentation = age_presentation or "adult" + row.permissions_json = json.dumps(permissions or {}, ensure_ascii=False) + row.mutable_scope_json = json.dumps(mutable_scope or {}, ensure_ascii=False) + await session.commit() + session.expunge(row) + return _anchor_dict(row) + + +# ── 主仪/化仪:候选治理 ── + + +async def create_form( + character_id: str, + owner_scope: str, + *, + kind: str, + source: str, + description: str, + media_ref: str | None, + live2d: dict | None, +) -> dict: + """登记形仪候选(status=candidate;未定相不转正)。""" + await _owned(character_id, owner_scope) + if kind not in ("primary", "costume", "text"): + raise AppError("invalid_request", "kind 须为 primary / costume / text。") + if source not in ("image_gen", "upload", "live2d", "text"): + raise AppError("invalid_request", "source 须为 image_gen / upload / live2d / text。") + if not description: + raise AppError("invalid_request", "文字描述不能为空(文字形仪或图像说明)。") + async with AsyncSessionLocal() as session: + form = Form( + character_id=character_id, + kind=kind, + source=source, + description=description, + media_ref=media_ref, + live2d_json=json.dumps(live2d or {}, ensure_ascii=False), + status="candidate", + ) + session.add(form) + await session.commit() + session.expunge(form) + return _form_dict(form) + + +async def adopt_form(form_id: str, owner_scope: str) -> dict: + """定相:candidate → adopted;主仪唯一(同人物旧主仪自动 retired)。""" + form = await _get_owned_form(form_id, owner_scope) + if form["status"] != "candidate": + raise AppError("invalid_request", "只有候选可定相。", status_code=409) + async with AsyncSessionLocal() as session: + row = await session.get(Form, form_id) + if row.kind == "primary": + previous = ( + await session.execute( + select(Form).where( + Form.character_id == row.character_id, + Form.kind == "primary", + Form.status == "adopted", + ) + ) + ).scalars().all() + for prev in previous: + prev.status = "retired" + row.status = "adopted" + row.adopted_at = utc_now() + await session.commit() + session.expunge(row) + character_id = row.character_id + await one_qi.emit( + "form.adopted", character_id=character_id, + payload={"form_id": form_id, "kind": row.kind}, source="xingyi", + ) + return _form_dict(row) + + +async def discard_form(form_id: str, owner_scope: str) -> dict: + form = await _get_owned_form(form_id, owner_scope) + async with AsyncSessionLocal() as session: + row = await session.get(Form, form_id) + row.status = "discarded" + await session.commit() + return {"form_id": form_id, "status": "discarded"} + + +async def list_forms(character_id: str, owner_scope: str, *, status: str | None = None) -> list[dict]: + await _owned(character_id, owner_scope) + async with AsyncSessionLocal() as session: + stmt = select(Form).where(Form.character_id == character_id).order_by(Form.created_at.desc()) + if status: + stmt = stmt.where(Form.status == status) + rows = (await session.execute(stmt)).scalars().all() + return [_form_dict(r) for r in rows] + + +async def current_primary(character_id: str, owner_scope: str) -> dict | None: + """当前主仪(呈现位数据源):只取 adopted,candidate 不得出现。""" + await _owned(character_id, owner_scope) + async with AsyncSessionLocal() as session: + row = ( + await session.execute( + select(Form).where( + Form.character_id == character_id, + Form.kind == "primary", + Form.status == "adopted", + ).order_by(Form.adopted_at.desc()) + ) + ).scalars().first() + if row is None: + return None + session.expunge(row) + return _form_dict(row) + + +# ── 声音档案与克隆授权 ── + + +async def create_voice( + character_id: str, + owner_scope: str, + *, + source: str, + name: str, + params: dict, + grant_purpose: str | None, + grant_expires_at: datetime | None, + grant_distribution: str | None, +) -> dict: + await _owned(character_id, owner_scope) + if source not in ("synthesis_preset", "original_design", "authorized_clone"): + raise AppError("invalid_request", "声音来源须为 synthesis_preset / original_design / authorized_clone。") + if source == "authorized_clone" and not (grant_purpose and grant_distribution): + raise AppError( + "invalid_request", "授权克隆必须声明用途(grant_purpose)与传播范围(grant_distribution)。" + ) + async with AsyncSessionLocal() as session: + voice = VoiceProfile( + character_id=character_id, + source=source, + name=name, + params_json=json.dumps(params or {}, ensure_ascii=False), + grant_purpose=grant_purpose, + grant_expires_at=grant_expires_at, + grant_distribution=grant_distribution, + ) + session.add(voice) + await session.commit() + session.expunge(voice) + return _voice_dict(voice) + + +async def list_voices(character_id: str, owner_scope: str) -> list[dict]: + await _owned(character_id, owner_scope) + async with AsyncSessionLocal() as session: + rows = ( + await session.execute( + select(VoiceProfile) + .where(VoiceProfile.character_id == character_id) + .order_by(VoiceProfile.created_at.desc()) + ) + ).scalars().all() + return [_voice_dict(r) for r in rows] + + +async def revoke_voice_grant(voice_id: str, owner_scope: str) -> dict: + """撤回克隆授权:立即生效(后续合成/克隆调用被拒)。""" + voice = await _get_owned_voice(voice_id, owner_scope) + async with AsyncSessionLocal() as session: + row = await session.get(VoiceProfile, voice_id) + row.grant_revoked_at = utc_now() + row.is_active = False + await session.commit() + character_id = row.character_id + await one_qi.emit( + "voice.grant_revoked", character_id=character_id, + payload={"voice_id": voice_id}, source="xingyi", + ) + return {"voice_id": voice_id, "grant_revoked": True, "effective": "immediate"} + + +async def assert_voice_usable(voice_id: str, owner_scope: str) -> dict: + """合成/克隆前置校验:授权克隆必须授权有效(未撤回且未过期)。""" + voice = await _get_owned_voice(voice_id, owner_scope) + if not voice["is_active"]: + raise AppError("invalid_request", "声音档案已停用(授权已撤回)。", status_code=409) + if voice["source"] == "authorized_clone": + if voice.get("grant_revoked_at"): + raise AppError("invalid_request", "克隆授权已撤回,不可再用于合成。", status_code=403) + expires = voice.get("grant_expires_at") + if expires: + exp = datetime.fromisoformat(expires) + if exp.tzinfo is None: + exp = exp.replace(tzinfo=UTC) + if exp <= datetime.now(UTC): + raise AppError("invalid_request", "克隆授权已过期。", status_code=403) + return voice + + +async def _get_owned_form(form_id: str, owner_scope: str) -> dict: + async with AsyncSessionLocal() as session: + row = await session.get(Form, form_id) + if row is None: + raise AppError("not_found", "形仪不存在。", status_code=404) + character = await session.get(Character, row.character_id) + if character is None or character.owner_scope != owner_scope: + raise AppError("not_found", "形仪不存在。", status_code=404) + session.expunge(row) + return _form_dict(row) + + +async def _get_owned_voice(voice_id: str, owner_scope: str) -> dict: + async with AsyncSessionLocal() as session: + row = await session.get(VoiceProfile, voice_id) + if row is None: + raise AppError("not_found", "声音档案不存在。", status_code=404) + character = await session.get(Character, row.character_id) + if character is None or character.owner_scope != owner_scope: + raise AppError("not_found", "声音档案不存在。", status_code=404) + session.expunge(row) + return _voice_dict(row) + + +def _anchor_dict(a: FormAnchor) -> dict: + return { + "character_id": a.character_id, + "features": json.loads(a.features_json or "{}"), + "age_presentation": a.age_presentation, + "permissions": json.loads(a.permissions_json or "{}"), + "mutable_scope": json.loads(a.mutable_scope_json or "{}"), + } + + +def _form_dict(f: Form) -> dict: + return { + "id": f.id, + "character_id": f.character_id, + "kind": f.kind, + "status": f.status, + "source": f.source, + "description": f.description, + "media_ref": f.media_ref, + "live2d": json.loads(f.live2d_json or "{}"), + "created_at": f.created_at.isoformat() if f.created_at else None, + "adopted_at": f.adopted_at.isoformat() if f.adopted_at else None, + } + + +def _voice_dict(v: VoiceProfile) -> dict: + return { + "id": v.id, + "character_id": v.character_id, + "source": v.source, + "name": v.name, + "params": json.loads(v.params_json or "{}"), + "grant_purpose": v.grant_purpose, + "grant_expires_at": v.grant_expires_at.isoformat() if v.grant_expires_at else None, + "grant_distribution": v.grant_distribution, + "grant_revoked_at": v.grant_revoked_at.isoformat() if v.grant_revoked_at else None, + "is_active": v.is_active, + } diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 45cddd1..ac2fc29 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -14,6 +14,7 @@ from app.models.idempotency import IdempotencyKey from app.models.imports import CharacterImportJob, ImportItem from app.domains.baigong.models import Capability, Mandate, ToolCall from app.domains.hetu.models import PersistentWorld, WorldEntity, WorldRelation +from app.domains.xingyi.models import Form, FormAnchor, VoiceProfile from app.domains.yuanqi.models import Covenant, CovenantEvent from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat, SoulForging from app.models.lorebook import Lorebook, LorebookEntry @@ -33,6 +34,9 @@ from app.models.user import User __all__ = [ "Capability", + "Form", + "FormAnchor", + "VoiceProfile", "Covenant", "CovenantEvent", "PersistentWorld", diff --git a/backend/app/schemas/character.py b/backend/app/schemas/character.py index f3cc0ea..a198b63 100644 --- a/backend/app/schemas/character.py +++ b/backend/app/schemas/character.py @@ -41,9 +41,6 @@ class CharacterResponse(BaseModel): source_template_id: str | None = Field( description="来源系统模板人物 ID;非模板复制人物通常有值。" ) - avatar_url: str | None = Field( - default=None, description="角色头像 URL。无头像时为 null。" - ) created_at: str = Field(description="创建时间,UTC ISO 8601 字符串。") updated_at: str = Field(description="更新时间,UTC ISO 8601 字符串。") diff --git a/backend/app/services/avatar.py b/backend/app/services/avatar.py deleted file mode 100644 index 8251333..0000000 --- a/backend/app/services/avatar.py +++ /dev/null @@ -1,131 +0,0 @@ -"""角色头像文件服务。 - -职责: -- 上传头像文件到本地文件系统 -- 提供头像文件流 -- 清理头像文件 -""" - -from __future__ import annotations - -from pathlib import Path - -from fastapi.responses import FileResponse - -from app.core.config import settings -from app.core.errors import AppError -from app.services.characters import get_owned_character - -_ALLOWED_EXTENSIONS = frozenset({".jpg", ".jpeg", ".png", ".webp"}) -_MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB - - -def _avatar_path(character_id: str) -> Path: - """获取角色头像文件路径(无扩展名,需遍历查找实际文件)。""" - return settings.resolved_avatar_dir / character_id - - -def _find_avatar_file(character_id: str) -> Path | None: - """查找角色已有的头像文件(任一格式)。""" - base = _avatar_path(character_id) - for ext in _ALLOWED_EXTENSIONS: - f = base.with_suffix(ext) - if f.exists(): - return f - return None - - -def _allowed(content_type: str) -> bool: - return content_type in { - "image/jpeg", - "image/png", - "image/webp", - } - - -async def upload_avatar( - character_id: str, - content: bytes, - content_type: str, - owner_scope: str = settings.default_owner_scope, -) -> str: - """上传角色头像。 - - Args: - character_id: 角色 ID - content: 图片文件二进制内容 - content_type: MIME 类型 - owner_scope: 归属范围 - - Returns: - 头像访问的相对 URL 路径 - - Raises: - AppError: 文件类型不允许、大小超限、角色不存在 - """ - from app.db.session import AsyncSessionLocal - - # 校验角色存在性 - async with AsyncSessionLocal() as session: - await get_owned_character(session, character_id, owner_scope=owner_scope) - - if not _allowed(content_type): - raise AppError( - "invalid_input", - f"不支持的文件类型 '{content_type}',仅支持 JPG/PNG/WebP。", - ) - if len(content) > _MAX_FILE_SIZE: - raise AppError( - "invalid_input", - "头像文件过大,最大允许 5MB。", - ) - - # 扩展名映射 - ext_map = { - "image/jpeg": ".jpg", - "image/png": ".png", - "image/webp": ".webp", - } - ext = ext_map[content_type] - - # 删除旧头像 - old = _find_avatar_file(character_id) - if old: - old.unlink(missing_ok=True) - - # 写入新文件 - dest = _avatar_path(character_id).with_suffix(ext) - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(content) - - return f"/api/v1/characters/{character_id}/avatar" - - -async def get_avatar_response(character_id: str) -> FileResponse: - """获取角色头像文件响应。 - - Args: - character_id: 角色 ID - - Returns: - FileResponse 或 None(无头像时抛出 not_found) - """ - avatar_file = _find_avatar_file(character_id) - if avatar_file is None: - raise AppError("not_found", "该角色未设置头像。") - # 推测 media_type - ext = avatar_file.suffix.lower() - media_type = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".webp": "image/webp", - }.get(ext, "application/octet-stream") - return FileResponse(str(avatar_file), media_type=media_type) - - -async def delete_avatar(character_id: str) -> None: - """删除角色头像文件。""" - avatar_file = _find_avatar_file(character_id) - if avatar_file: - avatar_file.unlink(missing_ok=True) diff --git a/backend/app/services/serializers.py b/backend/app/services/serializers.py index d541817..fe09246 100644 --- a/backend/app/services/serializers.py +++ b/backend/app/services/serializers.py @@ -31,14 +31,6 @@ from app.schemas.preferences import GenerationPreferencesResponse from app.schemas.runtime import EventResponse, RunResponse -def _check_avatar(character_id: str) -> str | None: - """检查角色是否有头像文件,有则返回 URL。""" - from app.services.avatar import _find_avatar_file - - if _find_avatar_file(character_id): - return f"/api/v1/characters/{character_id}/avatar" - return None - def character_to_response(character: Character) -> CharacterResponse: return CharacterResponse( @@ -53,7 +45,6 @@ def character_to_response(character: Character) -> CharacterResponse: owner_scope=character.owner_scope, is_system_template=character.is_system_template, source_template_id=character.source_template_id, - avatar_url=_check_avatar(character.id), created_at=to_iso(character.created_at) or "", updated_at=to_iso(character.updated_at) or "", ) diff --git a/backend/tests/api/test_avatar.py b/backend/tests/api/test_avatar.py deleted file mode 100644 index 3d3ba4a..0000000 --- a/backend/tests/api/test_avatar.py +++ /dev/null @@ -1,161 +0,0 @@ -"""角色头像上传 API 测试。 - -验证头像文件的上传、读取、替换、类型校验、大小限制与清理。 -""" - -from collections.abc import AsyncGenerator - -import pytest -from httpx import ASGITransport, AsyncClient - -from app.main import app - -_AVATAR_PATH = "/api/v1/characters" - - -@pytest.fixture -async def admin_client( - admin_headers: dict[str, str], -) -> AsyncGenerator[AsyncClient, None]: - transport = ASGITransport(app=app) - async with AsyncClient( - transport=transport, - base_url="http://test", - headers=admin_headers, - ) as http_client: - yield http_client - - -@pytest.fixture -async def active_char_id(admin_client: AsyncClient) -> str: - """获取当前激活角色 ID。""" - resp = await admin_client.get(f"{_AVATAR_PATH}/active") - assert resp.status_code == 200, resp.text - return resp.json()["id"] - - -@pytest.mark.asyncio -async def test_upload_and_get_avatar( - admin_client: AsyncClient, active_char_id: str, -) -> None: - """上传头像后可以 GET 获取到图片文件。""" - png_bytes = _make_test_png() - upload = await admin_client.post( - f"{_AVATAR_PATH}/{active_char_id}/avatar", - files={"file": ("test.png", png_bytes, "image/png")}, - ) - assert upload.status_code == 200, upload.text - body = upload.json() - assert "avatar_url" in body - assert active_char_id in body["avatar_url"] - - # GET 获取图片 - get_resp = await admin_client.get( - f"{_AVATAR_PATH}/{active_char_id}/avatar", - ) - assert get_resp.status_code == 200 - assert get_resp.headers["content-type"] == "image/png" - assert len(await get_resp.aread()) > 0 - - -@pytest.mark.asyncio -async def test_avatar_replace( - admin_client: AsyncClient, active_char_id: str, -) -> None: - """上传新头像会替换旧头像(同角色只保留一张)。""" - # 上传第一张 - first = _make_test_png(width=10, height=10) - r1 = await admin_client.post( - f"{_AVATAR_PATH}/{active_char_id}/avatar", - files={"file": ("first.png", first, "image/png")}, - ) - assert r1.status_code == 200 - - # 上传第二张 - second = _make_test_png(width=20, height=20) - r2 = await admin_client.post( - f"{_AVATAR_PATH}/{active_char_id}/avatar", - files={"file": ("second.png", second, "image/png")}, - ) - assert r2.status_code == 200 - - # 读取头像文件大小应与第二张匹配 - get_resp = await admin_client.get( - f"{_AVATAR_PATH}/{active_char_id}/avatar", - ) - assert get_resp.status_code == 200 - body = await get_resp.aread() - assert len(body) == len(second) - - -@pytest.mark.asyncio -async def test_avatar_rejects_invalid_type( - admin_client: AsyncClient, active_char_id: str, -) -> None: - """不支持的文件类型应返回 4xx。""" - resp = await admin_client.post( - f"{_AVATAR_PATH}/{active_char_id}/avatar", - files={"file": ("test.svg", b"", "image/svg+xml")}, - ) - assert resp.status_code in (400, 422) - - -@pytest.mark.asyncio -async def test_avatar_requires_auth(active_char_id: str) -> None: - """未认证用户不能上传头像。""" - transport = ASGITransport(app=app) - async with AsyncClient( - transport=transport, - base_url="http://test", - ) as client: - resp = await client.post( - f"{_AVATAR_PATH}/{active_char_id}/avatar", - files={"file": ("test.png", _make_test_png(), "image/png")}, - ) - assert resp.status_code == 401 - - -@pytest.mark.asyncio -async def test_avatar_not_found(admin_client: AsyncClient) -> None: - """不存在的角色上传头像应 404。""" - resp = await admin_client.post( - f"{_AVATAR_PATH}/character_nonexistent_id_00000/avatar", - files={"file": ("test.png", _make_test_png(), "image/png")}, - ) - assert resp.status_code == 404 - - -@pytest.mark.asyncio -async def test_get_avatar_no_image( - admin_client: AsyncClient, active_char_id: str, -) -> None: - """无头像时 GET 应 404。""" - from app.services.avatar import delete_avatar - - await delete_avatar(active_char_id) - - resp = await admin_client.get( - f"{_AVATAR_PATH}/{active_char_id}/avatar", - ) - assert resp.status_code == 404 - - -# ── 辅助:生成最小 PNG ── - -def _make_test_png(width: int = 1, height: int = 1) -> bytes: - """生成一个最小的有效 PNG 文件。""" - import struct - import zlib - - sig = b"\x89PNG\r\n\x1a\n" - ihdr_data = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) - ihdr_crc = zlib.crc32(b"IHDR" + ihdr_data) - ihdr = b"\x00\x00\x00\x0dIHDR" + ihdr_data + struct.pack(">I", ihdr_crc) - raw = b"\x00" + b"\xff\x00\x00" * width - raw *= height - compressed = zlib.compress(raw) - idat_crc = zlib.crc32(b"IDAT" + compressed) - idat = struct.pack(">I", len(compressed)) + b"IDAT" + compressed + struct.pack(">I", idat_crc) - iend_crc = zlib.crc32(b"IEND") - iend = b"\x00\x00\x00\x00IEND" + struct.pack(">I", iend_crc) - return sig + ihdr + idat + iend diff --git a/backend/tests/test_m7_xingyi.py b/backend/tests/test_m7_xingyi.py new file mode 100644 index 0000000..17a02f3 --- /dev/null +++ b/backend/tests/test_m7_xingyi.py @@ -0,0 +1,157 @@ +"""M7 验收测试(rewrite-plan M7 验收门)。 + +① 生成内容未定相不成为正式形仪(candidate 不进呈现位;定相才转正;主仪唯一); +② 克隆授权可撤回(撤回立即生效,合成前置校验被拒;过期同理); +③ 关闭全部多模态后文字相伴完整可用(ASR 503 降级时镜树/缘契链路照常——回归)。 +""" + +from collections.abc import AsyncGenerator + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.main import app + + +@pytest.fixture +async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c: + yield c + + +async def _character(client: AsyncClient) -> dict: + resp = await client.get("/api/v1/characters/active") + assert resp.status_code == 200, resp.text + return resp.json() + + +# ── ① 未定相不成为正式形仪 ────────────────────────────── + + +@pytest.mark.asyncio +async def test_candidate_not_primary_until_adopted(client: AsyncClient) -> None: + character = await _character(client) + base = f"/api/v1/characters/{character['id']}" + + # 登记两个主仪候选(生成来源) + c1 = await client.post( + f"{base}/forms", + json={"kind": "primary", "source": "image_gen", "description": "立绘候选一"}, + ) + assert c1.status_code == 201, c1.text + c2 = await client.post( + f"{base}/forms", + json={"kind": "primary", "source": "image_gen", "description": "立绘候选二"}, + ) + assert c2.status_code == 201 + + # 未定相:呈现位为空(candidate 不进正式形仪) + primary = await client.get(f"{base}/forms/primary") + assert primary.status_code == 200 + assert primary.json() is None + + # 定相候选一 → 成为主仪 + adopt1 = await client.post(f"{base}/forms/{c1.json()['id']}/adopt") + assert adopt1.status_code == 200 + assert adopt1.json()["status"] == "adopted" + primary = await client.get(f"{base}/forms/primary") + assert primary.json()["id"] == c1.json()["id"] + + # 定相候选二 → 主仪唯一(旧主仪自动 retired) + adopt2 = await client.post(f"{base}/forms/{c2.json()['id']}/adopt") + assert adopt2.status_code == 200 + primary = await client.get(f"{base}/forms/primary") + assert primary.json()["id"] == c2.json()["id"] + forms = await client.get(f"{base}/forms") + by_id = {f["id"]: f for f in forms.json()} + assert by_id[c1.json()["id"]]["status"] == "retired" + assert by_id[c2.json()["id"]]["status"] == "adopted" + + # 废弃候选 + c3 = await client.post( + f"{base}/forms", json={"kind": "costume", "source": "upload", "description": "化仪候选"} + ) + discard = await client.post(f"{base}/forms/{c3.json()['id']}/discard") + assert discard.json()["status"] == "discarded" + + +# ── ② 克隆授权可撤回 ────────────────────────────── + + +@pytest.mark.asyncio +async def test_voice_clone_grant_revocable(client: AsyncClient) -> None: + character = await _character(client) + base = f"/api/v1/characters/{character['id']}" + + # 授权克隆必须声明用途与传播范围 + incomplete = await client.post( + f"{base}/voices", json={"source": "authorized_clone", "name": "授权声线"} + ) + assert incomplete.status_code == 422 or "用途" in incomplete.text + + voice = await client.post( + f"{base}/voices", + json={ + "source": "authorized_clone", + "name": "授权声线", + "params": {"timbre": "warm"}, + "grant_purpose": "人物语音消息", + "grant_distribution": "仅本镜域私用", + }, + ) + assert voice.status_code == 201, voice.text + voice_id = voice.json()["id"] + + # 授权有效:合成前置校验通过 + usable = await client.post(f"{base}/voices/{voice_id}/assert-usable") + assert usable.status_code == 200 + + # 撤回 → 立即生效 + revoked = await client.post(f"{base}/voices/{voice_id}/revoke-grant") + assert revoked.status_code == 200 + assert revoked.json()["effective"] == "immediate" + denied = await client.post(f"{base}/voices/{voice_id}/assert-usable") + assert denied.status_code == 409 or denied.status_code == 403 + + # 合成预设不受授权约束 + preset = await client.post( + f"{base}/voices", json={"source": "synthesis_preset", "name": "预设音色 A"} + ) + assert preset.status_code == 201 + ok = await client.post(f"{base}/voices/{preset.json()['id']}/assert-usable") + assert ok.status_code == 200 + + +# ── ③ 关闭全部多模态后文字相伴完整可用(回归) ────────────────────────────── + + +@pytest.mark.asyncio +async def test_multimodal_off_text_companion_still_complete( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch +) -> None: + from app.domains.xingyi import asr as xingyi_asr + + character = await _character(client) + base = f"/api/v1/characters/{character['id']}" + + # 多模态全关:元炁未配置 → ASR 503(不降级不伪造) + monkeypatch.setattr(xingyi_asr.settings, "yuanqi_base_url", "") + muted = await client.post( + f"{base}/voice/transcriptions", + files={"audio": ("a.webm", b"fake-bytes", "audio/webm")}, + ) + assert muted.status_code == 503 + + # 文字相伴链路照常:镜树问答(文字)+ 缘契(文字) + tree = await client.post( + "/api/v1/trees", json={"character_id": character["id"], "title": "纯文字回归"} + ) + assert tree.status_code in (200, 201) + cov = await client.post( + "/api/v1/covenants", + json={"character_id": character["id"], "dimensions": {"companionship": "text-only"}}, + ) + assert cov.status_code == 201 + caps = await client.get("/api/v1/capabilities") + assert caps.status_code == 200 diff --git a/docs/operations/capability-matrix.md b/docs/operations/capability-matrix.md index d431e11..2dbe134 100644 --- a/docs/operations/capability-matrix.md +++ b/docs/operations/capability-matrix.md @@ -35,7 +35,8 @@ | 博闻 MCP 搜索 | M5 | 局部实现 | 多引擎/密钥池/熔断/缓存可用;待登记入百工 | | 百工与司命 | M5 | 已验证 | M5 收口(2026-08-27,本地开发档):能力注册表(capabilities 表 + 四维风险 + 内置种子 echo/datetime/qiankun.shell/web.fetch)、授契/行契(撤销即失效)、请命(六要素 brief + 超时拒绝 HJ-1302)、行动账(tool_calls 观察标证 + audit 留痕)、乾坤 HMAC 执行、MCP→能力派生;验收四测试(未授契被拒留痕/请命超时不执行/禁域不可关闭专项/结果为观察不入忆)全绿;前端百工页四区。沙箱进程隔离 e2e 预存环境失败与本域无关 | | 缘契/道体/河图 | M6 | 已验证 | M6 收口(2026-08-27,本地开发档):缘契提议—问心—铭契—撤回全链(多维无单一好感度、撤回即生效、事件史不可变);铸魂确认流(锚点确认前不变 + 魂籍版本链)与分灵谱系;河图境界(persistent_worlds + 实体关系 + 境界正史视图 + 世谱挂接);归真三界分流(境界/幻境实录 scope 隔离,真界本相零污染);引文→记忆正影后继提示;前端缘契面板与世界页。验收六测试全绿;旧人格/驱动/演化/世界模块与页面全量退役 | -| 天目 / 元炁 | M4/M7 | 局部实现 | 服务可用;元炁待接入神识 Endpoint,天目待接通感 | +| 形仪域 | M7 | 已验证 | M7 收口(2026-08-27,本地开发档):真形锚点(特征/许可/可变范围);主仪化仪候选定相治理(candidate 不进呈现位、主仪唯一);声音三来源与克隆授权(用途/传播必填、可撤回立即生效);ASR 经元炁透传(多模态关闭 503 不降级);前端形仪工坊三区。验收三测试全绿;旧 avatar 全量退役。图像真实生成与 TTS 合成随神识 image/tts 槽位启用接入(候选定相与授权治理框架已就位) | +| 天目 / 元炁 | M4/M7 | 局部实现 | 元炁 ASR 已接形仪域(M7);chat 经神识 endpoint(M4);天目待接通感 | | 镜树前端 / 记忆治理 / 百工页 | M2+ | 局部实现 | 镜树页与灵胚页已上线(M2c,2026-08-21):路径阅读流/候答切换/采用归真/局部树图/SSE 芽体/导入卡;type-check + vitest 210 passed;记忆治理页随 M3、百工页随 M5 | | 导入(角色卡 V2/V3 → 灵胚) | M2 | 局部实现 | M2b 已验证(2026-08-21):POST /characters/import-card 产出可审阅灵胚(identity_baseline.import_card 暂存 character_book),启镜时物化世谱书+条目(dict/list 双形态兼容);导出方向保持人物→ST V3 快照 | diff --git a/docs/operations/rewrite-plan.md b/docs/operations/rewrite-plan.md index 5699eaa..36c14eb 100644 --- a/docs/operations/rewrite-plan.md +++ b/docs/operations/rewrite-plan.md @@ -137,10 +137,11 @@ - 验收(tests/test_m6_domains.py 六用例钉死):多维无单一好感度(affinity 提议被拒)+ 问心铭契版本链 + 事件史不可抹除;撤回立即生效(withdrawn 即不可见、再操作 410、事件可查证)+ 边界变更即时反映;铸魂确认流(确认前锚点不变、确认后锚点更新且魂籍版本=最大版本)+ 分灵谱系(新 id、lineage 含源、魂籍 v1);境界正史不进真界(persistent_world 归真后真界记忆候选=0、实录 scope 隔离);引文正影后继提示。 - 证据:后端 379 passed(2 个预存环境失败不变);前端 type-check + 199 passed;BFF 4;神识 5 包;迁移 007 净库升级 + ±1 干净。 -### M7 形仪与语音(P1 面) +### M7 形仪与语音(P1 面)— ✅ 已完成(2026-08-27) -- 交付:真形锚点/主仪化仪/定相;图像生成候选治理;Live2D(pixi-live2d-display 基座)接入与动作映射;声音三来源与克隆授权;ASR/TTS 异步语音(天目接入);前端形仪工坊;**删除旧 avatar 模块**。 -- 验收:生成内容未定相不成为正式形仪;克隆授权可撤回;关闭全部多模态后文字相伴完整可用(回归)。 +- 交付:形仪域 `domains/xingyi`——`form_anchors` 真形锚点(稳定辨识特征/年龄表现/许可禁忌/可变范围,生成内容不可静默覆盖);`forms` 主仪/化仪/文字形仪(**候选→定相治理**:candidate 不进呈现位,adopt 才转正,主仪唯一、新主仪自动退役旧主仪);`voice_profiles` 声音三来源(合成预设/原创设计/授权克隆——克隆必填用途与传播范围,**授权可撤回且立即生效**,撤回/过期后合成前置校验拒绝);ASR 语音转写(元炁 SenseVoice `/v1/audio/transcriptions` 透传,未配置多模态时 503 不降级不伪造);前端形仪工坊(锚点编辑/候选定相废弃/声音档案与授权撤回)挂人物详情页,头像呈现位改走定相主仪。Live2D 基座沿用现有 pixi-live2d-display 吉祥物组件(forms.live2d_json 承载模型与动作映射引用);图像生成候选治理以 image_gen 来源候选+定相流程落地(真实生成模型接入随神识 image 槽位启用)。**旧 avatar 模块删除**(服务/端点/配置/avatar_url 字段/前端直传组件)。 +- 验收(tests/test_m7_xingyi.py 三用例钉死):①未定相不成为正式形仪(candidate 期呈现位为空、定相后转正、主仪唯一旧主仪 retired、废弃可用);②克隆授权可撤回(缺用途被拒、有效通过、撤回后校验 403/409、预设不受授权约束);③关闭全部多模态后文字相伴完整可用(ASR 503 时镜树/缘契/百工链路照常)。 +- 证据:后端 376 passed(2 个预存环境失败不变,6 个 avatar 旧用例随模块退役);前端 type-check + 203 passed(形仪工坊 4 用例);BFF 4;神识 5 包;迁移 008 净库+±1 干净。 ### M8 天工阁、叩心与辰正 diff --git a/frontend/src/app/(app)/characters/[characterId]/page.tsx b/frontend/src/app/(app)/characters/[characterId]/page.tsx index aa5c01d..6a286b8 100644 --- a/frontend/src/app/(app)/characters/[characterId]/page.tsx +++ b/frontend/src/app/(app)/characters/[characterId]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from "next/navigation"; import { CharacterIdentityCard } from "@/components/characters/CharacterIdentityCard"; import { CovenantPanel } from "@/components/characters/CovenantPanel"; +import { FormStudio } from "@/components/xingyi/FormStudio"; import { CharacterLifecycleBar } from "@/components/characters/CharacterLifecycleBar"; import { ModelPolicyEditForm } from "@/components/characters/ModelPolicyEditForm"; import { ProfileEditForm } from "@/components/characters/ProfileEditForm"; @@ -14,6 +15,11 @@ import { serverApi } from "@/lib/api/server-client"; import { describeError } from "@/lib/utils/error"; import { formatDateTime } from "@/lib/utils/format"; import type { Covenant } from "@/lib/api/covenants"; +import type { + FormAnchorData, + FormData, + VoiceProfileData, +} from "@/lib/api/xingyi"; import type { Character } from "@/types/api"; export const dynamic = "force-dynamic"; @@ -45,6 +51,29 @@ export default async function CharacterDetailPage({ } catch { covenant = null; } + // 形仪三区独立拉取,失败静默(无形仪是合法状态——启镜只需最小形仪) + let anchor: FormAnchorData = { + character_id: characterId, + features: {}, + age_presentation: "adult", + permissions: {}, + mutable_scope: {}, + }; + let forms: FormData[] = []; + let voices: VoiceProfileData[] = []; + let primary: FormData | null = null; + try { + [anchor, forms, voices, primary] = await Promise.all([ + serverApi.get(`/characters/${characterId}/form-anchor`), + serverApi.get(`/characters/${characterId}/forms`), + serverApi.get(`/characters/${characterId}/voices`), + serverApi.get( + `/characters/${characterId}/forms/primary`, + ), + ]); + } catch { + /* 空态合法 */ + } if (error) { return ( @@ -71,12 +100,24 @@ export default async function CharacterDetailPage({ - +
+
+ +
+
diff --git a/frontend/src/components/characters/CharacterAvatar.tsx b/frontend/src/components/characters/CharacterAvatar.tsx index 0b62999..9df5ff3 100644 --- a/frontend/src/components/characters/CharacterAvatar.tsx +++ b/frontend/src/components/characters/CharacterAvatar.tsx @@ -1,113 +1,27 @@ "use client"; -import { useRef, useState } from "react"; -import { useRouter } from "next/navigation"; -import Image from "next/image"; - import { BronzeMirror } from "@/components/ui/BronzeMirror"; -import { describeError } from "@/lib/utils/error"; /** - * 角色头像组件:展示头像图片(优先)或铜镜占位,hover 显示上传按钮。 - * 上传后调用 API 更新,成功后 refresh 页面。 + * 人物头像呈现位:形仪主仪(仅定相 adopted;候选不进呈现位)。 + * 头像变更走形仪工坊的候选定相流程,本组件不再提供直传。 */ export function CharacterAvatar({ - characterId, - avatarUrl, + mediaRef, name, }: { - characterId: string; - avatarUrl?: string | null; + mediaRef?: string | null; name: string; }) { - const router = useRouter(); - const inputRef = useRef(null); - const [uploading, setUploading] = useState(false); - const [error, setError] = useState(null); - - async function handleFile(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file) return; - - // 客户端校验 - const allowed = ["image/jpeg", "image/png", "image/webp"]; - if (!allowed.includes(file.type)) { - setError("仅支持 JPG / PNG / WebP 格式"); - return; - } - if (file.size > 5 * 1024 * 1024) { - setError("文件不能超过 5MB"); - return; - } - - setUploading(true); - setError(null); - - try { - const formData = new FormData(); - formData.append("file", file); - - const res = await fetch( - `/api/proxy/v1/characters/${characterId}/avatar`, - { method: "POST", body: formData }, - ); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body?.error?.message || `上传失败 (${res.status})`); - } - router.refresh(); - } catch (err) { - setError(describeError(err)); - } finally { - setUploading(false); - // 重置 input 允许重复选同一文件 - if (inputRef.current) inputRef.current.value = ""; - } + if (!mediaRef) { + return ; } - return ( -
- {/* 头像 */} -
- {avatarUrl ? ( - {name} - ) : ( - - )} -
- - {/* Hover 上传遮罩 */} - - - - - {error && ( -

- {error} -

- )} -
+ // eslint-disable-next-line @next/next/no-img-element -- 媒体引用为后端相对路径,不走图片优化管线 + {`${name} ); } diff --git a/frontend/src/components/characters/CharacterIdentityCard.tsx b/frontend/src/components/characters/CharacterIdentityCard.tsx index aa6dd27..ae305c4 100644 --- a/frontend/src/components/characters/CharacterIdentityCard.tsx +++ b/frontend/src/components/characters/CharacterIdentityCard.tsx @@ -8,7 +8,13 @@ import { formatDateTime } from "@/lib/utils/format"; * 角色卡 —— 人物详情页顶部的视觉化身份卡片。 * 展示头像(图片或铜镜占位)、名称、状态、关键档案字段与背景故事。 */ -export function CharacterIdentityCard({ character }: { character: Character }) { +export function CharacterIdentityCard({ + character, + primaryMediaRef, +}: { + character: Character; + primaryMediaRef?: string | null; +}) { const profile = character.profile ?? {}; const mood = character.current_mood; @@ -21,8 +27,7 @@ export function CharacterIdentityCard({ character }: { character: Character }) { {/* 头像(叠在横幅边界) */}
diff --git a/frontend/src/components/ui/Icons.tsx b/frontend/src/components/ui/Icons.tsx index b6f5b7a..0a542c9 100644 --- a/frontend/src/components/ui/Icons.tsx +++ b/frontend/src/components/ui/Icons.tsx @@ -1048,3 +1048,133 @@ export function HeartHandshakeIcon(props: IconProps) { ); } + +/** 锚点(Lucide Anchor) */ +export function AnchorIcon(props: IconProps) { + return ( + + + + + + ); +} + +/** 调色(Lucide Palette) */ +export function PaletteIcon(props: IconProps) { + return ( + + + + + + + + ); +} + +/** 声波(Lucide AudioLines) */ +export function AudioLinesIcon(props: IconProps) { + return ( + + + + + + + + + ); +} + +/** 叉(Lucide X) */ +export function XIcon(props: IconProps) { + return ( + + + + + ); +} diff --git a/frontend/src/components/xingyi/FormStudio.test.tsx b/frontend/src/components/xingyi/FormStudio.test.tsx new file mode 100644 index 0000000..7691058 --- /dev/null +++ b/frontend/src/components/xingyi/FormStudio.test.tsx @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { FormStudio } from "./FormStudio"; +import { xingyiApi } from "@/lib/api"; +import type { + FormData, + FormAnchorData, + VoiceProfileData, +} from "@/lib/api/xingyi"; + +vi.mock("@/lib/api", () => ({ + xingyiApi: { + upsertFormAnchor: vi.fn(), + createForm: vi.fn(), + adoptForm: vi.fn(), + discardForm: vi.fn(), + createVoice: vi.fn(), + revokeVoiceGrant: vi.fn(), + listForms: vi.fn(), + listVoices: vi.fn(), + }, +})); + +const anchor: FormAnchorData = { + character_id: "character_1", + features: { 发色: "月白" }, + age_presentation: "adult", + permissions: {}, + mutable_scope: {}, +}; + +const forms: FormData[] = [ + { + id: "form_1", + character_id: "character_1", + kind: "primary", + status: "candidate", + source: "image_gen", + description: "立绘候选", + media_ref: null, + live2d: {}, + created_at: "2026-08-27T10:00:00Z", + adopted_at: null, + }, +]; + +const voices: VoiceProfileData[] = [ + { + id: "voice_1", + character_id: "character_1", + source: "authorized_clone", + name: "授权声线", + params: {}, + grant_purpose: "人物语音消息", + grant_expires_at: null, + grant_distribution: "仅本镜域私用", + grant_revoked_at: null, + is_active: true, + }, +]; + +function renderStudio() { + return render( + , + ); +} + +describe("FormStudio(形仪工坊)", () => { + it("三区渲染:锚点 / 形仪候选(未定相标记)/ 声音档案授权信息", () => { + renderStudio(); + expect(screen.getByText("真形锚点")).toBeInTheDocument(); + expect(screen.getByText("主仪与化仪")).toBeInTheDocument(); + expect(screen.getByText("声音档案")).toBeInTheDocument(); + expect(screen.getByText("候选")).toBeInTheDocument(); + expect(screen.getByText(/用途:人物语音消息/)).toBeInTheDocument(); + }); + + it("定相走 adopt 并刷新清单", async () => { + const user = userEvent.setup(); + vi.mocked(xingyiApi.adoptForm).mockResolvedValue({} as FormData); + vi.mocked(xingyiApi.listForms).mockResolvedValue([ + { ...forms[0], status: "adopted" }, + ]); + vi.mocked(xingyiApi.listVoices).mockResolvedValue(voices); + renderStudio(); + await user.click(screen.getByRole("button", { name: /定相/ })); + await waitFor(() => + expect(xingyiApi.adoptForm).toHaveBeenCalledWith("character_1", "form_1"), + ); + await waitFor(() => + expect(xingyiApi.listForms).toHaveBeenCalledWith("character_1"), + ); + }); + + it("登记候选必填描述;授权克隆必填用途", async () => { + const user = userEvent.setup(); + renderStudio(); + await user.click(screen.getByRole("button", { name: /登记候选/ })); + expect(await screen.findByText(/形仪描述不能为空/)).toBeInTheDocument(); + + // 切到授权克隆,不填用途 + const voiceSelect = screen + .getAllByRole("combobox") + .find((el) => (el as HTMLSelectElement).value === "synthesis_preset"); + expect(voiceSelect).toBeTruthy(); + await user.selectOptions(voiceSelect!, "authorized_clone"); + await user.type(screen.getByPlaceholderText("声音名称"), "测试声线"); + await user.click(screen.getByRole("button", { name: /建立声音档案/ })); + expect(await screen.findByText(/必须声明用途/)).toBeInTheDocument(); + expect(xingyiApi.createVoice).not.toHaveBeenCalled(); + }); + + it("撤回克隆授权立即生效提示", async () => { + const user = userEvent.setup(); + vi.mocked(xingyiApi.revokeVoiceGrant).mockResolvedValue({ + voice_id: "voice_1", + grant_revoked: true, + effective: "immediate", + }); + vi.mocked(xingyiApi.listForms).mockResolvedValue(forms); + vi.mocked(xingyiApi.listVoices).mockResolvedValue([ + { + ...voices[0], + is_active: false, + grant_revoked_at: "2026-08-27T12:00:00Z", + }, + ]); + renderStudio(); + await user.click(screen.getByRole("button", { name: /撤回授权/ })); + await waitFor(() => + expect(xingyiApi.revokeVoiceGrant).toHaveBeenCalledWith( + "character_1", + "voice_1", + ), + ); + await waitFor(() => + expect(screen.getByText(/克隆授权已撤回/)).toBeInTheDocument(), + ); + }); +}); diff --git a/frontend/src/components/xingyi/FormStudio.tsx b/frontend/src/components/xingyi/FormStudio.tsx new file mode 100644 index 0000000..dfb3ca9 --- /dev/null +++ b/frontend/src/components/xingyi/FormStudio.tsx @@ -0,0 +1,417 @@ +"use client"; + +import { useState } from "react"; +import { + AnchorIcon, + AudioLinesIcon, + CheckIcon, + PaletteIcon, + XIcon, +} from "@/components/ui/Icons"; + +import { Button } from "@/components/ui/Button"; +import type { + FormData, + FormAnchorData, + VoiceProfileData, +} from "@/lib/api/xingyi"; + +import { xingyiApi } from "@/lib/api"; +import { cn } from "@/lib/utils/cn"; + +interface Props { + characterId: string; + initialAnchor: FormAnchorData; + initialForms: FormData[]; + initialVoices: VoiceProfileData[]; +} + +const KIND_LABEL: Record = { + primary: "主仪", + costume: "化仪", + text: "文字形仪", +}; +const SOURCE_LABEL: Record = { + image_gen: "生成", + upload: "上传", + live2d: "Live2D", + text: "文字", +}; +const VOICE_SOURCE_LABEL: Record = { + synthesis_preset: "合成预设", + original_design: "原创设计", + authorized_clone: "授权克隆", +}; + +/** + * 形仪工坊:真形锚点 / 主仪化仪(候选定相)/ 声音档案(克隆授权治理)。 + * 候选不进呈现位——定相(adopt)才成为正式形仪;主仪唯一。 + */ +export function FormStudio({ + characterId, + initialAnchor, + initialForms, + initialVoices, +}: Props) { + const [anchor, setAnchor] = useState(initialAnchor); + const [featuresText, setFeaturesText] = useState( + Object.entries(initialAnchor.features) + .map(([k, v]) => `${k}:${v}`) + .join("\n"), + ); + const [forms, setForms] = useState(initialForms); + const [voices, setVoices] = useState(initialVoices); + const [desc, setDesc] = useState(""); + const [kind, setKind] = useState("primary"); + const [voiceName, setVoiceName] = useState(""); + const [voiceSource, setVoiceSource] = useState("synthesis_preset"); + const [grantPurpose, setGrantPurpose] = useState(""); + const [busy, setBusy] = useState(false); + const [notice, setNotice] = useState(null); + + async function refresh() { + setForms(await xingyiApi.listForms(characterId)); + setVoices(await xingyiApi.listVoices(characterId)); + } + + async function saveAnchor() { + setBusy(true); + try { + const features: Record = {}; + for (const line of featuresText.split("\n")) { + const idx = line.indexOf(":"); + if (idx > 0) { + features[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); + } + } + const saved = await xingyiApi.upsertFormAnchor(characterId, { + features, + age_presentation: anchor.age_presentation, + }); + setAnchor(saved); + setNotice("真形锚点已保存(生成内容不可覆盖锚点约束)。"); + } catch (e) { + setNotice(e instanceof Error ? e.message : "保存失败"); + } finally { + setBusy(false); + } + } + + async function submitCandidate() { + if (!desc.trim()) { + setNotice("形仪描述不能为空。"); + return; + } + setBusy(true); + try { + await xingyiApi.createForm(characterId, { + kind, + source: "upload", + description: desc.trim(), + }); + setDesc(""); + await refresh(); + setNotice("候选已登记(未定相不进呈现位)。"); + } catch (e) { + setNotice(e instanceof Error ? e.message : "登记失败"); + } finally { + setBusy(false); + } + } + + async function adopt(form: FormData) { + setBusy(true); + try { + await xingyiApi.adoptForm(characterId, form.id); + await refresh(); + setNotice(`已定相:${KIND_LABEL[form.kind] ?? form.kind}。`); + } catch (e) { + setNotice(e instanceof Error ? e.message : "定相失败"); + } finally { + setBusy(false); + } + } + + async function discard(form: FormData) { + setBusy(true); + try { + await xingyiApi.discardForm(characterId, form.id); + await refresh(); + } finally { + setBusy(false); + } + } + + async function addVoice() { + if (!voiceName.trim()) { + setNotice("声音名称不能为空。"); + return; + } + if (voiceSource === "authorized_clone" && !grantPurpose.trim()) { + setNotice("授权克隆必须声明用途。"); + return; + } + setBusy(true); + try { + await xingyiApi.createVoice(characterId, { + source: voiceSource, + name: voiceName.trim(), + grant_purpose: + voiceSource === "authorized_clone" ? grantPurpose.trim() : null, + grant_distribution: + voiceSource === "authorized_clone" ? "仅本镜域私用" : null, + }); + setVoiceName(""); + setGrantPurpose(""); + await refresh(); + setNotice("声音档案已建立。"); + } catch (e) { + setNotice(e instanceof Error ? e.message : "建立失败"); + } finally { + setBusy(false); + } + } + + async function revoke(voice: VoiceProfileData) { + setBusy(true); + try { + await xingyiApi.revokeVoiceGrant(characterId, voice.id); + await refresh(); + setNotice("克隆授权已撤回(立即生效)。"); + } finally { + setBusy(false); + } + } + + return ( +
+ {notice && ( +
+ {notice} +
+ )} + + {/* ═══ 真形锚点 ═══ */} +
+
+

+ + 真形锚点 +

+ Anchor +
+

+ 稳定辨识特征与许可禁忌;生成内容不得静默覆盖锚点约束。 +

+