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
+1876
-468
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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