M7:形仪与语音——真形锚点、候选定相、声音档案与克隆授权、ASR 接入

- 形仪域 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 干净
This commit is contained in:
chenxin committed 2026-08-27 20:26:52 +08:00
1 parent 7e23d6fa84
commit b419e0afde
30 files changed
+1876 -468

No files matched your search

@@ -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 ###
+2 -2
View File
@@ -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)
-47
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
"""形仪路由(域实现见 app/domains/xingyi/api.py)。"""
from app.domains.xingyi.api import form_router
__all__ = ["form_router"]
-1
View File
@@ -30,7 +30,6 @@ class AppConfig(BaseSettings):
)
# ==================== 文件存储 ====================
avatar_dir: str = "../db/avatars" # 角色头像文件存储目录(相对 backend)
# ==================== 默认管理员用户 ====================
default_admin_username: str = "admin" # 种子默认管理员用户名
@@ -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 # 是否启用自动记忆候选抽取
-7
View File
@@ -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()
+5
View File
@@ -0,0 +1,5 @@
"""形仪域:真形锚点、主仪/化仪候选定相、声音档案与克隆授权(rewrite/1.0 M7)。"""
from app.domains.xingyi import asr, models, service # noqa: F401
__all__ = ["asr", "models", "service"]
+157
View File
@@ -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")
+43
View File
@@ -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 原转写(与正影文本并存的语义由镜树承载)
}
+88
View File
@@ -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)
+324
View File
@@ -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,
}
+4
View File
@@ -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",
-3
View File
@@ -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 字符串。")
-131
View File
@@ -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)
-9
View File
@@ -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 "",
)
-161
View File
@@ -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"<svg></svg>", "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
+157
View File
@@ -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
+2 -1
View File
@@ -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 快照 |
+4 -3
View File
@@ -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 天工阁、叩心与辰正
@@ -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<FormAnchorData>(`/characters/${characterId}/form-anchor`),
serverApi.get<FormData[]>(`/characters/${characterId}/forms`),
serverApi.get<VoiceProfileData[]>(`/characters/${characterId}/voices`),
serverApi.get<FormData | null>(
`/characters/${characterId}/forms/primary`,
),
]);
} catch {
/* 空态合法 */
}
if (error) {
return (
@@ -71,12 +100,24 @@ export default async function CharacterDetailPage({
</Link>
</div>
<CharacterIdentityCard character={character} />
<CharacterIdentityCard
character={character}
primaryMediaRef={primary?.media_ref ?? null}
/>
<div className="mt-6">
<CovenantPanel characterId={character.id} initialCovenant={covenant} />
</div>
<div className="mt-6">
<FormStudio
characterId={character.id}
initialAnchor={anchor}
initialForms={forms}
initialVoices={voices}
/>
</div>
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
<ProfileEditForm character={character} />
<ModelPolicyEditForm character={character} />
@@ -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<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
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 <BronzeMirror />;
}
return (
<div className="group relative">
{/* 头像 */}
<div className="relative h-16 w-16 overflow-hidden rounded-full border-2 border-gilt shadow-lg shadow-gilt/20">
{avatarUrl ? (
<Image
src={avatarUrl}
alt={name}
fill
className="object-cover"
sizes="64px"
/>
) : (
<BronzeMirror size={64} state="steady" />
)}
</div>
{/* Hover 上传遮罩 */}
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100 disabled:opacity-0"
title="上传头像"
>
<span className="text-xs font-medium text-white">
{uploading ? "…" : "📷"}
</span>
</button>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleFile}
/>
{error && (
<p className="absolute left-0 top-full mt-1 whitespace-nowrap text-xs text-cinnabar">
{error}
</p>
)}
</div>
// eslint-disable-next-line @next/next/no-img-element -- 媒体引用为后端相对路径,不走图片优化管线
<img
src={mediaRef}
alt={`${name} 主仪`}
className="size-16 rounded-full border border-border-glass object-cover"
/>
);
}
@@ -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 }) {
{/* 头像(叠在横幅边界) */}
<div className="absolute -top-10 left-5">
<CharacterAvatar
characterId={character.id}
avatarUrl={character.avatar_url}
mediaRef={primaryMediaRef}
name={character.display_name || character.name}
/>
</div>
+130
View File
@@ -1048,3 +1048,133 @@ export function HeartHandshakeIcon(props: IconProps) {
</svg>
);
}
/** 锚点(Lucide Anchor) */
export function AnchorIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<circle cx="12" cy="5" r="3" stroke="currentColor" strokeWidth="1.6" />
<path
d="M12 22V8"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M5 12H3a9 9 0 0 0 18 0h-2"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
/** 调色(Lucide Palette) */
export function PaletteIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
<path
d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2"
stroke="currentColor"
strokeWidth="1.6"
/>
</svg>
);
}
/** 声波(Lucide AudioLines) */
export function AudioLinesIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<path
d="M2 10v3"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M6 6v11"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M10 3v18"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M14 8v7"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M18 5v13"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M22 10v3"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
/** 叉(Lucide X) */
export function XIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<path
d="M18 6 6 18"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="m6 6 12 12"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
@@ -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(
<FormStudio
characterId="character_1"
initialAnchor={anchor}
initialForms={forms}
initialVoices={voices}
/>,
);
}
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(),
);
});
});
@@ -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<string, string> = {
primary: "主仪",
costume: "化仪",
text: "文字形仪",
};
const SOURCE_LABEL: Record<string, string> = {
image_gen: "生成",
upload: "上传",
live2d: "Live2D",
text: "文字",
};
const VOICE_SOURCE_LABEL: Record<string, string> = {
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<string | null>(null);
async function refresh() {
setForms(await xingyiApi.listForms(characterId));
setVoices(await xingyiApi.listVoices(characterId));
}
async function saveAnchor() {
setBusy(true);
try {
const features: Record<string, string> = {};
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 (
<div className="flex flex-col gap-4">
{notice && (
<div className="glass rounded-xl px-4 py-2 text-sm text-patina">
{notice}
</div>
)}
{/* ═══ 真形锚点 ═══ */}
<div className="set-card">
<div className="set-card-head">
<h3 className="set-card-title flex items-center gap-2">
<AnchorIcon className="size-4 text-gold" />
真形锚点
</h3>
<span className="set-card-eng">Anchor</span>
</div>
<p className="set-card-desc">
稳定辨识特征与许可禁忌;生成内容不得静默覆盖锚点约束。
</p>
<textarea
className="mb-3 min-h-24 w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
placeholder={"每行一条:特征:描述\n如 发色:月白\n瞳色:琥珀"}
value={featuresText}
onChange={(e) => setFeaturesText(e.target.value)}
/>
<Button
variant="primary"
size="sm"
onClick={saveAnchor}
disabled={busy}
>
保存锚点
</Button>
</div>
{/* ═══ 主仪与化仪 ═══ */}
<div className="set-card">
<div className="set-card-head">
<h3 className="set-card-title flex items-center gap-2">
<PaletteIcon className="size-4 text-patina" />
主仪与化仪
</h3>
<span className="set-card-eng">Forms</span>
</div>
<p className="set-card-desc">
生成内容先为候选,定相后才成为正式形仪;同一时刻仅一主仪。
</p>
<div className="mb-4 grid gap-2 rounded-xl border border-rule bg-surface p-3 sm:grid-cols-[8rem_1fr_auto]">
<select
className="rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
value={kind}
onChange={(e) => setKind(e.target.value)}
>
<option value="primary">主仪</option>
<option value="costume">化仪</option>
<option value="text">文字形仪</option>
</select>
<input
className="rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
placeholder="形仪描述(或上传图说明)"
value={desc}
onChange={(e) => setDesc(e.target.value)}
/>
<Button
variant="outline"
size="sm"
onClick={submitCandidate}
disabled={busy}
>
登记候选
</Button>
</div>
{forms.length === 0 ? (
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
尚无形仪候选。
</p>
) : (
<ul className="divide-y divide-rule rounded-xl border border-rule">
{forms.map((f) => (
<li key={f.id} className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded-full border border-rule px-2 py-0.5 font-mono text-[10px] text-muted">
{KIND_LABEL[f.kind] ?? f.kind} ·{" "}
{SOURCE_LABEL[f.source] ?? f.source}
</span>
<span
className={cn(
"rounded-full border px-2 py-0.5 font-mono text-[10px]",
f.status === "adopted"
? "border-patina/40 text-patina"
: f.status === "candidate"
? "border-gold/40 text-gold"
: "border-rule text-muted",
)}
>
{f.status === "adopted"
? "已定相"
: f.status === "candidate"
? "候选"
: f.status === "retired"
? "已退役"
: "已废弃"}
</span>
</div>
<p className="mt-0.5 line-clamp-1 text-xs text-muted">
{f.description}
</p>
</div>
{f.status === "candidate" && (
<div className="flex shrink-0 gap-2">
<Button
variant="primary"
size="sm"
onClick={() => adopt(f)}
disabled={busy}
>
<CheckIcon className="mr-1 size-3.5" />
定相
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => discard(f)}
disabled={busy}
>
<XIcon className="mr-1 size-3.5" />
废弃
</Button>
</div>
)}
</li>
))}
</ul>
)}
</div>
{/* ═══ 声音档案 ═══ */}
<div className="set-card">
<div className="set-card-head">
<h3 className="set-card-title flex items-center gap-2">
<AudioLinesIcon className="size-4 text-patina" />
声音档案
</h3>
<span className="set-card-eng">Voices</span>
</div>
<p className="set-card-desc">
三来源:合成预设 / 原创设计 /
授权克隆;克隆授权按用途与传播范围治理,可撤回。
</p>
<div className="mb-4 grid gap-2 rounded-xl border border-rule bg-surface p-3">
<div className="grid gap-2 sm:grid-cols-[10rem_1fr]">
<select
className="rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
value={voiceSource}
onChange={(e) => setVoiceSource(e.target.value)}
>
<option value="synthesis_preset">合成预设</option>
<option value="original_design">原创设计</option>
<option value="authorized_clone">授权克隆</option>
</select>
<input
className="rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
placeholder="声音名称"
value={voiceName}
onChange={(e) => setVoiceName(e.target.value)}
/>
</div>
{voiceSource === "authorized_clone" && (
<input
className="rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
placeholder="授权用途(必填,如:人物语音消息)"
value={grantPurpose}
onChange={(e) => setGrantPurpose(e.target.value)}
/>
)}
<div>
<Button
variant="outline"
size="sm"
onClick={addVoice}
disabled={busy}
>
建立声音档案
</Button>
</div>
</div>
{voices.length === 0 ? (
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
尚无声音档案。
</p>
) : (
<ul className="divide-y divide-rule rounded-xl border border-rule">
{voices.map((v) => (
<li key={v.id} className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-serif text-sm text-fg">{v.name}</span>
<span className="rounded-full border border-rule px-2 py-0.5 font-mono text-[10px] text-muted">
{VOICE_SOURCE_LABEL[v.source] ?? v.source}
</span>
{v.grant_revoked_at && (
<span className="rounded-full border border-cinnabar/40 px-2 py-0.5 font-mono text-[10px] text-cinnabar">
授权已撤回
</span>
)}
</div>
{v.source === "authorized_clone" && v.grant_purpose && (
<p className="mt-0.5 text-xs text-muted">
用途:{v.grant_purpose} · 传播:{v.grant_distribution}
</p>
)}
</div>
{v.source === "authorized_clone" && v.is_active && (
<Button
variant="ghost"
size="sm"
onClick={() => revoke(v)}
disabled={busy}
>
撤回授权
</Button>
)}
</li>
))}
</ul>
)}
</div>
</div>
);
}
+1
View File
@@ -18,6 +18,7 @@ export * as meApi from "./me";
export * as baigongApi from "./baigong";
export * as covenantApi from "./covenants";
export * as worldApi from "./worlds";
export * as xingyiApi from "./xingyi";
export * as importsApi from "./imports";
export * as extractionApi from "./extraction";
export * as memoriesApi from "./memories";
+125
View File
@@ -0,0 +1,125 @@
/**
* 形仪 API Client:真形锚点 / 形仪候选定相 / 声音档案与克隆授权。
*
* 生成内容先为 candidate,定相(adopt)才成为正式形仪;
* 克隆授权可撤回,撤回立即生效。
*/
import { apiGet, apiPatch, apiPost } from "./client";
/** 真形锚点 */
export interface FormAnchorData {
character_id: string;
features: Record<string, string>;
age_presentation: string;
permissions: Record<string, unknown>;
mutable_scope: Record<string, unknown>;
}
/** 形仪(主仪/化仪/文字形仪) */
export interface FormData {
id: string;
character_id: string;
kind: "primary" | "costume" | "text" | string;
status: "candidate" | "adopted" | "discarded" | "retired" | string;
source: "image_gen" | "upload" | "live2d" | "text" | string;
description: string;
media_ref: string | null;
live2d: Record<string, unknown>;
created_at: string | null;
adopted_at: string | null;
}
/** 声音档案 */
export interface VoiceProfileData {
id: string;
character_id: string;
source: "synthesis_preset" | "original_design" | "authorized_clone" | string;
name: string;
params: Record<string, unknown>;
grant_purpose: string | null;
grant_expires_at: string | null;
grant_distribution: string | null;
grant_revoked_at: string | null;
is_active: boolean;
}
const base = (characterId: string) =>
`/characters/${encodeURIComponent(characterId)}`;
export function getFormAnchor(characterId: string) {
return apiGet<FormAnchorData>(`${base(characterId)}/form-anchor`);
}
export function upsertFormAnchor(
characterId: string,
body: Partial<FormAnchorData>,
) {
return apiPatch<FormAnchorData>(`${base(characterId)}/form-anchor`, body);
}
export function listForms(characterId: string, status?: string) {
return apiGet<FormData[]>(
`${base(characterId)}/forms`,
status ? { status_filter: status } : undefined,
);
}
export function createForm(
characterId: string,
body: {
kind: string;
source: string;
description: string;
media_ref?: string | null;
live2d?: Record<string, unknown>;
},
) {
return apiPost<FormData>(`${base(characterId)}/forms`, body);
}
export function currentPrimary(characterId: string) {
return apiGet<FormData | null>(`${base(characterId)}/forms/primary`);
}
export function adoptForm(characterId: string, formId: string) {
return apiPost<FormData>(
`${base(characterId)}/forms/${encodeURIComponent(formId)}/adopt`,
{},
);
}
export function discardForm(characterId: string, formId: string) {
return apiPost<{ form_id: string; status: string }>(
`${base(characterId)}/forms/${encodeURIComponent(formId)}/discard`,
{},
);
}
export function listVoices(characterId: string) {
return apiGet<VoiceProfileData[]>(`${base(characterId)}/voices`);
}
export function createVoice(
characterId: string,
body: {
source: string;
name: string;
params?: Record<string, unknown>;
grant_purpose?: string | null;
grant_distribution?: string | null;
},
) {
return apiPost<VoiceProfileData>(`${base(characterId)}/voices`, body);
}
export function revokeVoiceGrant(characterId: string, voiceId: string) {
return apiPost<{
voice_id: string;
grant_revoked: boolean;
effective: string;
}>(
`${base(characterId)}/voices/${encodeURIComponent(voiceId)}/revoke-grant`,
{},
);
}
+110
View File
@@ -82,6 +82,116 @@ export function resolveMock(
// —— 健康检查 ——
if (m === "GET" && path === "/health") return ok(data.health);
// —— 形仪:真形锚点 / 形仪候选 / 声音档案 ——
{
const m1 = path.match(/^\/characters\/([^/]+)\/form-anchor$/);
if (m1 && (m === "GET" || m === "PATCH" || m === "PUT")) {
return ok({
character_id: m1[1],
features: { 发色: "月白", 瞳色: "琥珀" },
age_presentation: "adult",
permissions: { 画风: "水墨" },
mutable_scope: { 服装: "可变", 表情: "可变" },
});
}
}
{
const m2 = path.match(/^\/characters\/([^/]+)\/forms\/primary$/);
if (m2 && m === "GET") {
return ok({
id: "form_mock_001",
character_id: m2[1],
kind: "primary",
status: "adopted",
source: "image_gen",
description: "月白长衫立绘",
media_ref: null,
live2d: {},
created_at: "2026-08-27T10:00:00Z",
adopted_at: "2026-08-27T10:05:00Z",
});
}
}
{
const m3 = path.match(/^\/characters\/([^/]+)\/forms$/);
if (m3 && m === "GET") {
return ok([
{
id: "form_mock_001",
character_id: m3[1],
kind: "primary",
status: "adopted",
source: "image_gen",
description: "月白长衫立绘",
media_ref: null,
live2d: {},
created_at: "2026-08-27T10:00:00Z",
adopted_at: "2026-08-27T10:05:00Z",
},
{
id: "form_mock_002",
character_id: m3[1],
kind: "costume",
status: "candidate",
source: "upload",
description: "雪夜披氅(候选)",
media_ref: null,
live2d: {},
created_at: "2026-08-27T11:00:00Z",
adopted_at: null,
},
]);
}
if (m3 && m === "POST") {
return ok({ id: "form_mock_003", status: "candidate" }, 201);
}
}
{
const m4 = path.match(
/^\/characters\/([^/]+)\/forms\/([^/]+)\/(adopt|discard)$/,
);
if (m4 && m === "POST") {
return ok({
id: m4[2],
status: m4[3] === "adopt" ? "adopted" : "discarded",
});
}
}
{
const m5 = path.match(/^\/characters\/([^/]+)\/voices$/);
if (m5 && m === "GET") {
return ok([
{
id: "voice_mock_001",
character_id: m5[1],
source: "synthesis_preset",
name: "预设音色 A",
params: { timbre: "warm" },
grant_purpose: null,
grant_expires_at: null,
grant_distribution: null,
grant_revoked_at: null,
is_active: true,
},
]);
}
if (m5 && m === "POST") {
return ok({ id: "voice_mock_002", status: "created" }, 201);
}
}
{
const m6 = path.match(
/^\/characters\/([^/]+)\/voices\/([^/]+)\/revoke-grant$/,
);
if (m6 && m === "POST") {
return ok({
voice_id: m6[2],
grant_revoked: true,
effective: "immediate",
});
}
}
// —— 百工:能力目录 / 授契 / 请命 / 行动账 ——
if (m === "GET" && path === "/capabilities") {
return ok([
-1
View File
@@ -101,7 +101,6 @@ export interface Character {
owner_scope?: string;
is_system_template?: boolean;
source_template_id?: string | null;
avatar_url?: string | null;
created_at?: string;
updated_at?: string;
}