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:
1 parent
7e23d6fa84
commit
b419e0afde
30 files changed
+1875
-467
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 ###
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""形仪路由(域实现见 app/domains/xingyi/api.py)。"""
|
||||
|
||||
from app.domains.xingyi.api import form_router
|
||||
|
||||
__all__ = ["form_router"]
|
||||
@@ -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 # 是否启用自动记忆候选抽取
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""形仪域:真形锚点、主仪/化仪候选定相、声音档案与克隆授权(rewrite/1.0 M7)。"""
|
||||
|
||||
from app.domains.xingyi import asr, models, service # noqa: F401
|
||||
|
||||
__all__ = ["asr", "models", "service"]
|
||||
@@ -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")
|
||||
@@ -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 原转写(与正影文本并存的语义由镜树承载)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 字符串。")
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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 "",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 快照 |
|
||||
|
||||
|
||||
@@ -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 (!mediaRef) {
|
||||
return <BronzeMirror />;
|
||||
}
|
||||
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 = "";
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
// 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"
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user