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

- 形仪域 domains/xingyi:form_anchors(稳定辨识特征/年龄表现/许可禁忌/可变范围——生成内容不可静默覆盖锚点);forms(主仪/化仪/文字形仪,candidate→adopt 定相治理:未定相不进呈现位、主仪唯一、新主仪自动退役旧主仪);voice_profiles(三来源,授权克隆必填用途与传播范围,revoke-grant 撤回立即生效,assert-usable 合成前置校验拒绝撤回/过期)
- ASR:元炁 SenseVoice /v1/audio/transcriptions 透传(Whisper 兼容 multipart);YUANQI_BASE_URL 未配置时 503 不降级不伪造——多模态关闭纪律
- 前端形仪工坊(人物详情页):真形锚点编辑(特征逐行解析)/候选登记定相废弃/声音档案建立与授权撤回;头像呈现位改走定相主仪(forms/primary);xingyi api 库 + mock + 组件 4 用例
- 退役:旧 avatar 模块(services/endpoints/avatar、avatar_dir 配置、avatar_url 字段、前端直传组件与测试)
- 验收(test_m7_xingyi 三用例):未定相不成为正式形仪(含主仪唯一与旧主仪 retired 断言);克隆授权可撤回(缺用途拒/撤回即拒/过期同理/预设不受约束);关闭全部多模态后文字相伴完整可用(ASR 503 时镜树缘契百工照常——回归)
- 四套件:后端 376、前端 203、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 008 净库+±1 干净
This commit is contained in:
chenxin committed 2026-08-27 20:26:52 +08:00
1 parent 7e23d6fa84
commit b419e0afde
30 files changed
+1876 -468

No files matched your search

@@ -3,6 +3,7 @@ import { notFound } from "next/navigation";
import { CharacterIdentityCard } from "@/components/characters/CharacterIdentityCard";
import { CovenantPanel } from "@/components/characters/CovenantPanel";
import { FormStudio } from "@/components/xingyi/FormStudio";
import { CharacterLifecycleBar } from "@/components/characters/CharacterLifecycleBar";
import { ModelPolicyEditForm } from "@/components/characters/ModelPolicyEditForm";
import { ProfileEditForm } from "@/components/characters/ProfileEditForm";
@@ -14,6 +15,11 @@ import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import { formatDateTime } from "@/lib/utils/format";
import type { Covenant } from "@/lib/api/covenants";
import type {
FormAnchorData,
FormData,
VoiceProfileData,
} from "@/lib/api/xingyi";
import type { Character } from "@/types/api";
export const dynamic = "force-dynamic";
@@ -45,6 +51,29 @@ export default async function CharacterDetailPage({
} catch {
covenant = null;
}
// 形仪三区独立拉取,失败静默(无形仪是合法状态——启镜只需最小形仪)
let anchor: FormAnchorData = {
character_id: characterId,
features: {},
age_presentation: "adult",
permissions: {},
mutable_scope: {},
};
let forms: FormData[] = [];
let voices: VoiceProfileData[] = [];
let primary: FormData | null = null;
try {
[anchor, forms, voices, primary] = await Promise.all([
serverApi.get<FormAnchorData>(`/characters/${characterId}/form-anchor`),
serverApi.get<FormData[]>(`/characters/${characterId}/forms`),
serverApi.get<VoiceProfileData[]>(`/characters/${characterId}/voices`),
serverApi.get<FormData | null>(
`/characters/${characterId}/forms/primary`,
),
]);
} catch {
/* 空态合法 */
}
if (error) {
return (
@@ -71,12 +100,24 @@ export default async function CharacterDetailPage({
</Link>
</div>
<CharacterIdentityCard character={character} />
<CharacterIdentityCard
character={character}
primaryMediaRef={primary?.media_ref ?? null}
/>
<div className="mt-6">
<CovenantPanel characterId={character.id} initialCovenant={covenant} />
</div>
<div className="mt-6">
<FormStudio
characterId={character.id}
initialAnchor={anchor}
initialForms={forms}
initialVoices={voices}
/>
</div>
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
<ProfileEditForm character={character} />
<ModelPolicyEditForm character={character} />
@@ -1,113 +1,27 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { BronzeMirror } from "@/components/ui/BronzeMirror";
import { describeError } from "@/lib/utils/error";
/**
* 角色头像组件:展示头像图片(优先)或铜镜占位,hover 显示上传按钮。
* 上传后调用 API 更新,成功后 refresh 页面。
* 人物头像呈现位:形仪主仪(仅定相 adopted;候选不进呈现位)。
* 头像变更走形仪工坊的候选定相流程,本组件不再提供直传。
*/
export function CharacterAvatar({
characterId,
avatarUrl,
mediaRef,
name,
}: {
characterId: string;
avatarUrl?: string | null;
mediaRef?: string | null;
name: string;
}) {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
// 客户端校验
const allowed = ["image/jpeg", "image/png", "image/webp"];
if (!allowed.includes(file.type)) {
setError("仅支持 JPG / PNG / WebP 格式");
return;
}
if (file.size > 5 * 1024 * 1024) {
setError("文件不能超过 5MB");
return;
}
setUploading(true);
setError(null);
try {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(
`/api/proxy/v1/characters/${characterId}/avatar`,
{ method: "POST", body: formData },
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error?.message || `上传失败 (${res.status})`);
}
router.refresh();
} catch (err) {
setError(describeError(err));
} finally {
setUploading(false);
// 重置 input 允许重复选同一文件
if (inputRef.current) inputRef.current.value = "";
}
if (!mediaRef) {
return <BronzeMirror />;
}
return (
<div className="group relative">
{/* 头像 */}
<div className="relative h-16 w-16 overflow-hidden rounded-full border-2 border-gilt shadow-lg shadow-gilt/20">
{avatarUrl ? (
<Image
src={avatarUrl}
alt={name}
fill
className="object-cover"
sizes="64px"
/>
) : (
<BronzeMirror size={64} state="steady" />
)}
</div>
{/* Hover 上传遮罩 */}
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100 disabled:opacity-0"
title="上传头像"
>
<span className="text-xs font-medium text-white">
{uploading ? "…" : "📷"}
</span>
</button>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleFile}
/>
{error && (
<p className="absolute left-0 top-full mt-1 whitespace-nowrap text-xs text-cinnabar">
{error}
</p>
)}
</div>
// eslint-disable-next-line @next/next/no-img-element -- 媒体引用为后端相对路径,不走图片优化管线
<img
src={mediaRef}
alt={`${name} 主仪`}
className="size-16 rounded-full border border-border-glass object-cover"
/>
);
}
@@ -8,7 +8,13 @@ import { formatDateTime } from "@/lib/utils/format";
* 角色卡 —— 人物详情页顶部的视觉化身份卡片。
* 展示头像(图片或铜镜占位)、名称、状态、关键档案字段与背景故事。
*/
export function CharacterIdentityCard({ character }: { character: Character }) {
export function CharacterIdentityCard({
character,
primaryMediaRef,
}: {
character: Character;
primaryMediaRef?: string | null;
}) {
const profile = character.profile ?? {};
const mood = character.current_mood;
@@ -21,8 +27,7 @@ export function CharacterIdentityCard({ character }: { character: Character }) {
{/* 头像(叠在横幅边界) */}
<div className="absolute -top-10 left-5">
<CharacterAvatar
characterId={character.id}
avatarUrl={character.avatar_url}
mediaRef={primaryMediaRef}
name={character.display_name || character.name}
/>
</div>
+130
View File
@@ -1048,3 +1048,133 @@ export function HeartHandshakeIcon(props: IconProps) {
</svg>
);
}
/** 锚点(Lucide Anchor) */
export function AnchorIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<circle cx="12" cy="5" r="3" stroke="currentColor" strokeWidth="1.6" />
<path
d="M12 22V8"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M5 12H3a9 9 0 0 0 18 0h-2"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
/** 调色(Lucide Palette) */
export function PaletteIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
<path
d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2"
stroke="currentColor"
strokeWidth="1.6"
/>
</svg>
);
}
/** 声波(Lucide AudioLines) */
export function AudioLinesIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<path
d="M2 10v3"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M6 6v11"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M10 3v18"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M14 8v7"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M18 5v13"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="M22 10v3"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
/** 叉(Lucide X) */
export function XIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<path
d="M18 6 6 18"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path
d="m6 6 12 12"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
@@ -0,0 +1,147 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FormStudio } from "./FormStudio";
import { xingyiApi } from "@/lib/api";
import type {
FormData,
FormAnchorData,
VoiceProfileData,
} from "@/lib/api/xingyi";
vi.mock("@/lib/api", () => ({
xingyiApi: {
upsertFormAnchor: vi.fn(),
createForm: vi.fn(),
adoptForm: vi.fn(),
discardForm: vi.fn(),
createVoice: vi.fn(),
revokeVoiceGrant: vi.fn(),
listForms: vi.fn(),
listVoices: vi.fn(),
},
}));
const anchor: FormAnchorData = {
character_id: "character_1",
features: { 发色: "月白" },
age_presentation: "adult",
permissions: {},
mutable_scope: {},
};
const forms: FormData[] = [
{
id: "form_1",
character_id: "character_1",
kind: "primary",
status: "candidate",
source: "image_gen",
description: "立绘候选",
media_ref: null,
live2d: {},
created_at: "2026-08-27T10:00:00Z",
adopted_at: null,
},
];
const voices: VoiceProfileData[] = [
{
id: "voice_1",
character_id: "character_1",
source: "authorized_clone",
name: "授权声线",
params: {},
grant_purpose: "人物语音消息",
grant_expires_at: null,
grant_distribution: "仅本镜域私用",
grant_revoked_at: null,
is_active: true,
},
];
function renderStudio() {
return render(
<FormStudio
characterId="character_1"
initialAnchor={anchor}
initialForms={forms}
initialVoices={voices}
/>,
);
}
describe("FormStudio(形仪工坊)", () => {
it("三区渲染:锚点 / 形仪候选(未定相标记)/ 声音档案授权信息", () => {
renderStudio();
expect(screen.getByText("真形锚点")).toBeInTheDocument();
expect(screen.getByText("主仪与化仪")).toBeInTheDocument();
expect(screen.getByText("声音档案")).toBeInTheDocument();
expect(screen.getByText("候选")).toBeInTheDocument();
expect(screen.getByText(/用途:人物语音消息/)).toBeInTheDocument();
});
it("定相走 adopt 并刷新清单", async () => {
const user = userEvent.setup();
vi.mocked(xingyiApi.adoptForm).mockResolvedValue({} as FormData);
vi.mocked(xingyiApi.listForms).mockResolvedValue([
{ ...forms[0], status: "adopted" },
]);
vi.mocked(xingyiApi.listVoices).mockResolvedValue(voices);
renderStudio();
await user.click(screen.getByRole("button", { name: /定相/ }));
await waitFor(() =>
expect(xingyiApi.adoptForm).toHaveBeenCalledWith("character_1", "form_1"),
);
await waitFor(() =>
expect(xingyiApi.listForms).toHaveBeenCalledWith("character_1"),
);
});
it("登记候选必填描述;授权克隆必填用途", async () => {
const user = userEvent.setup();
renderStudio();
await user.click(screen.getByRole("button", { name: /登记候选/ }));
expect(await screen.findByText(/形仪描述不能为空/)).toBeInTheDocument();
// 切到授权克隆,不填用途
const voiceSelect = screen
.getAllByRole("combobox")
.find((el) => (el as HTMLSelectElement).value === "synthesis_preset");
expect(voiceSelect).toBeTruthy();
await user.selectOptions(voiceSelect!, "authorized_clone");
await user.type(screen.getByPlaceholderText("声音名称"), "测试声线");
await user.click(screen.getByRole("button", { name: /建立声音档案/ }));
expect(await screen.findByText(/必须声明用途/)).toBeInTheDocument();
expect(xingyiApi.createVoice).not.toHaveBeenCalled();
});
it("撤回克隆授权立即生效提示", async () => {
const user = userEvent.setup();
vi.mocked(xingyiApi.revokeVoiceGrant).mockResolvedValue({
voice_id: "voice_1",
grant_revoked: true,
effective: "immediate",
});
vi.mocked(xingyiApi.listForms).mockResolvedValue(forms);
vi.mocked(xingyiApi.listVoices).mockResolvedValue([
{
...voices[0],
is_active: false,
grant_revoked_at: "2026-08-27T12:00:00Z",
},
]);
renderStudio();
await user.click(screen.getByRole("button", { name: /撤回授权/ }));
await waitFor(() =>
expect(xingyiApi.revokeVoiceGrant).toHaveBeenCalledWith(
"character_1",
"voice_1",
),
);
await waitFor(() =>
expect(screen.getByText(/克隆授权已撤回/)).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,417 @@
"use client";
import { useState } from "react";
import {
AnchorIcon,
AudioLinesIcon,
CheckIcon,
PaletteIcon,
XIcon,
} from "@/components/ui/Icons";
import { Button } from "@/components/ui/Button";
import type {
FormData,
FormAnchorData,
VoiceProfileData,
} from "@/lib/api/xingyi";
import { xingyiApi } from "@/lib/api";
import { cn } from "@/lib/utils/cn";
interface Props {
characterId: string;
initialAnchor: FormAnchorData;
initialForms: FormData[];
initialVoices: VoiceProfileData[];
}
const KIND_LABEL: Record<string, string> = {
primary: "主仪",
costume: "化仪",
text: "文字形仪",
};
const SOURCE_LABEL: Record<string, string> = {
image_gen: "生成",
upload: "上传",
live2d: "Live2D",
text: "文字",
};
const VOICE_SOURCE_LABEL: Record<string, string> = {
synthesis_preset: "合成预设",
original_design: "原创设计",
authorized_clone: "授权克隆",
};
/**
* 形仪工坊:真形锚点 / 主仪化仪(候选定相)/ 声音档案(克隆授权治理)。
* 候选不进呈现位——定相(adopt)才成为正式形仪;主仪唯一。
*/
export function FormStudio({
characterId,
initialAnchor,
initialForms,
initialVoices,
}: Props) {
const [anchor, setAnchor] = useState(initialAnchor);
const [featuresText, setFeaturesText] = useState(
Object.entries(initialAnchor.features)
.map(([k, v]) => `${k}:${v}`)
.join("\n"),
);
const [forms, setForms] = useState(initialForms);
const [voices, setVoices] = useState(initialVoices);
const [desc, setDesc] = useState("");
const [kind, setKind] = useState("primary");
const [voiceName, setVoiceName] = useState("");
const [voiceSource, setVoiceSource] = useState("synthesis_preset");
const [grantPurpose, setGrantPurpose] = useState("");
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
async function refresh() {
setForms(await xingyiApi.listForms(characterId));
setVoices(await xingyiApi.listVoices(characterId));
}
async function saveAnchor() {
setBusy(true);
try {
const features: Record<string, string> = {};
for (const line of featuresText.split("\n")) {
const idx = line.indexOf(":");
if (idx > 0) {
features[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
}
}
const saved = await xingyiApi.upsertFormAnchor(characterId, {
features,
age_presentation: anchor.age_presentation,
});
setAnchor(saved);
setNotice("真形锚点已保存(生成内容不可覆盖锚点约束)。");
} catch (e) {
setNotice(e instanceof Error ? e.message : "保存失败");
} finally {
setBusy(false);
}
}
async function submitCandidate() {
if (!desc.trim()) {
setNotice("形仪描述不能为空。");
return;
}
setBusy(true);
try {
await xingyiApi.createForm(characterId, {
kind,
source: "upload",
description: desc.trim(),
});
setDesc("");
await refresh();
setNotice("候选已登记(未定相不进呈现位)。");
} catch (e) {
setNotice(e instanceof Error ? e.message : "登记失败");
} finally {
setBusy(false);
}
}
async function adopt(form: FormData) {
setBusy(true);
try {
await xingyiApi.adoptForm(characterId, form.id);
await refresh();
setNotice(`已定相:${KIND_LABEL[form.kind] ?? form.kind}。`);
} catch (e) {
setNotice(e instanceof Error ? e.message : "定相失败");
} finally {
setBusy(false);
}
}
async function discard(form: FormData) {
setBusy(true);
try {
await xingyiApi.discardForm(characterId, form.id);
await refresh();
} finally {
setBusy(false);
}
}
async function addVoice() {
if (!voiceName.trim()) {
setNotice("声音名称不能为空。");
return;
}
if (voiceSource === "authorized_clone" && !grantPurpose.trim()) {
setNotice("授权克隆必须声明用途。");
return;
}
setBusy(true);
try {
await xingyiApi.createVoice(characterId, {
source: voiceSource,
name: voiceName.trim(),
grant_purpose:
voiceSource === "authorized_clone" ? grantPurpose.trim() : null,
grant_distribution:
voiceSource === "authorized_clone" ? "仅本镜域私用" : null,
});
setVoiceName("");
setGrantPurpose("");
await refresh();
setNotice("声音档案已建立。");
} catch (e) {
setNotice(e instanceof Error ? e.message : "建立失败");
} finally {
setBusy(false);
}
}
async function revoke(voice: VoiceProfileData) {
setBusy(true);
try {
await xingyiApi.revokeVoiceGrant(characterId, voice.id);
await refresh();
setNotice("克隆授权已撤回(立即生效)。");
} finally {
setBusy(false);
}
}
return (
<div className="flex flex-col gap-4">
{notice && (
<div className="glass rounded-xl px-4 py-2 text-sm text-patina">
{notice}
</div>
)}
{/* ═══ 真形锚点 ═══ */}
<div className="set-card">
<div className="set-card-head">
<h3 className="set-card-title flex items-center gap-2">
<AnchorIcon className="size-4 text-gold" />
真形锚点
</h3>
<span className="set-card-eng">Anchor</span>
</div>
<p className="set-card-desc">
稳定辨识特征与许可禁忌;生成内容不得静默覆盖锚点约束。
</p>
<textarea
className="mb-3 min-h-24 w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
placeholder={"每行一条:特征:描述\n如 发色:月白\n瞳色:琥珀"}
value={featuresText}
onChange={(e) => setFeaturesText(e.target.value)}
/>
<Button
variant="primary"
size="sm"
onClick={saveAnchor}
disabled={busy}
>
保存锚点
</Button>
</div>
{/* ═══ 主仪与化仪 ═══ */}
<div className="set-card">
<div className="set-card-head">
<h3 className="set-card-title flex items-center gap-2">
<PaletteIcon className="size-4 text-patina" />
主仪与化仪
</h3>
<span className="set-card-eng">Forms</span>
</div>
<p className="set-card-desc">
生成内容先为候选,定相后才成为正式形仪;同一时刻仅一主仪。
</p>
<div className="mb-4 grid gap-2 rounded-xl border border-rule bg-surface p-3 sm:grid-cols-[8rem_1fr_auto]">
<select
className="rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
value={kind}
onChange={(e) => setKind(e.target.value)}
>
<option value="primary">主仪</option>
<option value="costume">化仪</option>
<option value="text">文字形仪</option>
</select>
<input
className="rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
placeholder="形仪描述(或上传图说明)"
value={desc}
onChange={(e) => setDesc(e.target.value)}
/>
<Button
variant="outline"
size="sm"
onClick={submitCandidate}
disabled={busy}
>
登记候选
</Button>
</div>
{forms.length === 0 ? (
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
尚无形仪候选。
</p>
) : (
<ul className="divide-y divide-rule rounded-xl border border-rule">
{forms.map((f) => (
<li key={f.id} className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="rounded-full border border-rule px-2 py-0.5 font-mono text-[10px] text-muted">
{KIND_LABEL[f.kind] ?? f.kind} ·{" "}
{SOURCE_LABEL[f.source] ?? f.source}
</span>
<span
className={cn(
"rounded-full border px-2 py-0.5 font-mono text-[10px]",
f.status === "adopted"
? "border-patina/40 text-patina"
: f.status === "candidate"
? "border-gold/40 text-gold"
: "border-rule text-muted",
)}
>
{f.status === "adopted"
? "已定相"
: f.status === "candidate"
? "候选"
: f.status === "retired"
? "已退役"
: "已废弃"}
</span>
</div>
<p className="mt-0.5 line-clamp-1 text-xs text-muted">
{f.description}
</p>
</div>
{f.status === "candidate" && (
<div className="flex shrink-0 gap-2">
<Button
variant="primary"
size="sm"
onClick={() => adopt(f)}
disabled={busy}
>
<CheckIcon className="mr-1 size-3.5" />
定相
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => discard(f)}
disabled={busy}
>
<XIcon className="mr-1 size-3.5" />
废弃
</Button>
</div>
)}
</li>
))}
</ul>
)}
</div>
{/* ═══ 声音档案 ═══ */}
<div className="set-card">
<div className="set-card-head">
<h3 className="set-card-title flex items-center gap-2">
<AudioLinesIcon className="size-4 text-patina" />
声音档案
</h3>
<span className="set-card-eng">Voices</span>
</div>
<p className="set-card-desc">
三来源:合成预设 / 原创设计 /
授权克隆;克隆授权按用途与传播范围治理,可撤回。
</p>
<div className="mb-4 grid gap-2 rounded-xl border border-rule bg-surface p-3">
<div className="grid gap-2 sm:grid-cols-[10rem_1fr]">
<select
className="rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
value={voiceSource}
onChange={(e) => setVoiceSource(e.target.value)}
>
<option value="synthesis_preset">合成预设</option>
<option value="original_design">原创设计</option>
<option value="authorized_clone">授权克隆</option>
</select>
<input
className="rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
placeholder="声音名称"
value={voiceName}
onChange={(e) => setVoiceName(e.target.value)}
/>
</div>
{voiceSource === "authorized_clone" && (
<input
className="rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
placeholder="授权用途(必填,如:人物语音消息)"
value={grantPurpose}
onChange={(e) => setGrantPurpose(e.target.value)}
/>
)}
<div>
<Button
variant="outline"
size="sm"
onClick={addVoice}
disabled={busy}
>
建立声音档案
</Button>
</div>
</div>
{voices.length === 0 ? (
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
尚无声音档案。
</p>
) : (
<ul className="divide-y divide-rule rounded-xl border border-rule">
{voices.map((v) => (
<li key={v.id} className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-serif text-sm text-fg">{v.name}</span>
<span className="rounded-full border border-rule px-2 py-0.5 font-mono text-[10px] text-muted">
{VOICE_SOURCE_LABEL[v.source] ?? v.source}
</span>
{v.grant_revoked_at && (
<span className="rounded-full border border-cinnabar/40 px-2 py-0.5 font-mono text-[10px] text-cinnabar">
授权已撤回
</span>
)}
</div>
{v.source === "authorized_clone" && v.grant_purpose && (
<p className="mt-0.5 text-xs text-muted">
用途:{v.grant_purpose} · 传播:{v.grant_distribution}
</p>
)}
</div>
{v.source === "authorized_clone" && v.is_active && (
<Button
variant="ghost"
size="sm"
onClick={() => revoke(v)}
disabled={busy}
>
撤回授权
</Button>
)}
</li>
))}
</ul>
)}
</div>
</div>
);
}
+1
View File
@@ -18,6 +18,7 @@ export * as meApi from "./me";
export * as baigongApi from "./baigong";
export * as covenantApi from "./covenants";
export * as worldApi from "./worlds";
export * as xingyiApi from "./xingyi";
export * as importsApi from "./imports";
export * as extractionApi from "./extraction";
export * as memoriesApi from "./memories";
+125
View File
@@ -0,0 +1,125 @@
/**
* 形仪 API Client:真形锚点 / 形仪候选定相 / 声音档案与克隆授权。
*
* 生成内容先为 candidate,定相(adopt)才成为正式形仪;
* 克隆授权可撤回,撤回立即生效。
*/
import { apiGet, apiPatch, apiPost } from "./client";
/** 真形锚点 */
export interface FormAnchorData {
character_id: string;
features: Record<string, string>;
age_presentation: string;
permissions: Record<string, unknown>;
mutable_scope: Record<string, unknown>;
}
/** 形仪(主仪/化仪/文字形仪) */
export interface FormData {
id: string;
character_id: string;
kind: "primary" | "costume" | "text" | string;
status: "candidate" | "adopted" | "discarded" | "retired" | string;
source: "image_gen" | "upload" | "live2d" | "text" | string;
description: string;
media_ref: string | null;
live2d: Record<string, unknown>;
created_at: string | null;
adopted_at: string | null;
}
/** 声音档案 */
export interface VoiceProfileData {
id: string;
character_id: string;
source: "synthesis_preset" | "original_design" | "authorized_clone" | string;
name: string;
params: Record<string, unknown>;
grant_purpose: string | null;
grant_expires_at: string | null;
grant_distribution: string | null;
grant_revoked_at: string | null;
is_active: boolean;
}
const base = (characterId: string) =>
`/characters/${encodeURIComponent(characterId)}`;
export function getFormAnchor(characterId: string) {
return apiGet<FormAnchorData>(`${base(characterId)}/form-anchor`);
}
export function upsertFormAnchor(
characterId: string,
body: Partial<FormAnchorData>,
) {
return apiPatch<FormAnchorData>(`${base(characterId)}/form-anchor`, body);
}
export function listForms(characterId: string, status?: string) {
return apiGet<FormData[]>(
`${base(characterId)}/forms`,
status ? { status_filter: status } : undefined,
);
}
export function createForm(
characterId: string,
body: {
kind: string;
source: string;
description: string;
media_ref?: string | null;
live2d?: Record<string, unknown>;
},
) {
return apiPost<FormData>(`${base(characterId)}/forms`, body);
}
export function currentPrimary(characterId: string) {
return apiGet<FormData | null>(`${base(characterId)}/forms/primary`);
}
export function adoptForm(characterId: string, formId: string) {
return apiPost<FormData>(
`${base(characterId)}/forms/${encodeURIComponent(formId)}/adopt`,
{},
);
}
export function discardForm(characterId: string, formId: string) {
return apiPost<{ form_id: string; status: string }>(
`${base(characterId)}/forms/${encodeURIComponent(formId)}/discard`,
{},
);
}
export function listVoices(characterId: string) {
return apiGet<VoiceProfileData[]>(`${base(characterId)}/voices`);
}
export function createVoice(
characterId: string,
body: {
source: string;
name: string;
params?: Record<string, unknown>;
grant_purpose?: string | null;
grant_distribution?: string | null;
},
) {
return apiPost<VoiceProfileData>(`${base(characterId)}/voices`, body);
}
export function revokeVoiceGrant(characterId: string, voiceId: string) {
return apiPost<{
voice_id: string;
grant_revoked: boolean;
effective: string;
}>(
`${base(characterId)}/voices/${encodeURIComponent(voiceId)}/revoke-grant`,
{},
);
}
+110
View File
@@ -82,6 +82,116 @@ export function resolveMock(
// —— 健康检查 ——
if (m === "GET" && path === "/health") return ok(data.health);
// —— 形仪:真形锚点 / 形仪候选 / 声音档案 ——
{
const m1 = path.match(/^\/characters\/([^/]+)\/form-anchor$/);
if (m1 && (m === "GET" || m === "PATCH" || m === "PUT")) {
return ok({
character_id: m1[1],
features: { 发色: "月白", 瞳色: "琥珀" },
age_presentation: "adult",
permissions: { 画风: "水墨" },
mutable_scope: { 服装: "可变", 表情: "可变" },
});
}
}
{
const m2 = path.match(/^\/characters\/([^/]+)\/forms\/primary$/);
if (m2 && m === "GET") {
return ok({
id: "form_mock_001",
character_id: m2[1],
kind: "primary",
status: "adopted",
source: "image_gen",
description: "月白长衫立绘",
media_ref: null,
live2d: {},
created_at: "2026-08-27T10:00:00Z",
adopted_at: "2026-08-27T10:05:00Z",
});
}
}
{
const m3 = path.match(/^\/characters\/([^/]+)\/forms$/);
if (m3 && m === "GET") {
return ok([
{
id: "form_mock_001",
character_id: m3[1],
kind: "primary",
status: "adopted",
source: "image_gen",
description: "月白长衫立绘",
media_ref: null,
live2d: {},
created_at: "2026-08-27T10:00:00Z",
adopted_at: "2026-08-27T10:05:00Z",
},
{
id: "form_mock_002",
character_id: m3[1],
kind: "costume",
status: "candidate",
source: "upload",
description: "雪夜披氅(候选)",
media_ref: null,
live2d: {},
created_at: "2026-08-27T11:00:00Z",
adopted_at: null,
},
]);
}
if (m3 && m === "POST") {
return ok({ id: "form_mock_003", status: "candidate" }, 201);
}
}
{
const m4 = path.match(
/^\/characters\/([^/]+)\/forms\/([^/]+)\/(adopt|discard)$/,
);
if (m4 && m === "POST") {
return ok({
id: m4[2],
status: m4[3] === "adopt" ? "adopted" : "discarded",
});
}
}
{
const m5 = path.match(/^\/characters\/([^/]+)\/voices$/);
if (m5 && m === "GET") {
return ok([
{
id: "voice_mock_001",
character_id: m5[1],
source: "synthesis_preset",
name: "预设音色 A",
params: { timbre: "warm" },
grant_purpose: null,
grant_expires_at: null,
grant_distribution: null,
grant_revoked_at: null,
is_active: true,
},
]);
}
if (m5 && m === "POST") {
return ok({ id: "voice_mock_002", status: "created" }, 201);
}
}
{
const m6 = path.match(
/^\/characters\/([^/]+)\/voices\/([^/]+)\/revoke-grant$/,
);
if (m6 && m === "POST") {
return ok({
voice_id: m6[2],
grant_revoked: true,
effective: "immediate",
});
}
}
// —— 百工:能力目录 / 授契 / 请命 / 行动账 ——
if (m === "GET" && path === "/capabilities") {
return ok([
-1
View File
@@ -101,7 +101,6 @@ export interface Character {
owner_scope?: string;
is_system_template?: boolean;
source_template_id?: string | null;
avatar_url?: string | null;
created_at?: string;
updated_at?: string;
}