feat(assets): 素材回收站与画布历史 - 合并 PR #370 并修复删除一致性

This commit is contained in:
ddCat
2026-09-01 18:50:30 +08:00
21 changed files with 1766 additions and 342 deletions

View File

@@ -13,7 +13,21 @@ export type InsertAssetPayload =
| { kind: "image"; dataUrl: string; title: string; url?: string; storageKey?: string; width?: number; height?: number; bytes?: number; mimeType?: string; assetId?: string }
| { kind: "video"; url: string; title: string; storageKey?: string; width?: number; height?: number; durationMs?: number; hasAudio?: boolean; bytes?: number; mimeType?: string; assetId?: string }
| { kind: "audio"; url: string; title: string; storageKey?: string; durationMs?: number; bytes?: number; mimeType?: string; assetId?: string }
| { kind: "character"; title: string; assetId: string; versionId: string; prompt: string; aliases: string[]; definition: Record<string, unknown>; coverUrl?: string; visualStatus: string; voiceStatus: string; voiceName?: string; voiceProfile?: { name: string; provider: string; language: string; timbre: string }; voiceInstructions?: string };
| {
kind: "character";
title: string;
assetId: string;
versionId: string;
prompt: string;
aliases: string[];
definition: Record<string, unknown>;
coverUrl?: string;
visualStatus: string;
voiceStatus: string;
voiceName?: string;
voiceProfile?: { name: string; provider: string; language: string; timbre: string };
voiceInstructions?: string;
};
type Props = {
open: boolean;
@@ -22,23 +36,27 @@ type Props = {
onClose: () => void;
};
const categoryLabels: Record<string, string> = { all: "全部素材", ...ASSET_CATEGORY_LABELS };
const categoryLabels: Record<string, string> = { all: "全部素材", ...ASSET_CATEGORY_LABELS, archived: "回收站" };
export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: Props) {
const assets = useAssetStore((state) => state.assets);
const externalAssetSources = useExternalAssetSources(open);
const insertableAssets = useMemo(() => assets.filter((asset): asset is InsertableAsset => asset.kind === "text" || asset.kind === "image" || asset.kind === "video" || asset.kind === "audio"), [assets]);
const items = useMemo<AssetLibraryPickerItem[]>(() => [
...insertableAssets.map((asset) => ({
id: asset.id,
title: asset.title,
category: normalizeAssetCategory(asset.category),
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
asset,
searchText: (asset.tags || []).join(" "),
})),
...externalAssetSources.items,
], [externalAssetSources.items, insertableAssets]);
const items = useMemo<AssetLibraryPickerItem[]>(
() => [
...insertableAssets.map((asset) => ({
id: asset.id,
title: asset.title,
category: normalizeAssetCategory(asset.category),
archived: asset.status === "archived",
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
asset,
searchText: (asset.tags || []).join(" "),
})),
...externalAssetSources.items,
],
[externalAssetSources.items, insertableAssets],
);
return (
<AssetLibraryPickerModal
@@ -76,7 +94,20 @@ export function assetPickerItemsToInsertPayloads(ids: string[], items: AssetLibr
function localAssetToInsertPayload(asset: InsertableAsset): InsertAssetPayload {
if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id };
if (asset.kind === "audio") return { kind: "audio", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
if (asset.kind === "video") return { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, hasAudio: asset.data.hasAudio, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
if (asset.kind === "video")
return {
kind: "video",
url: asset.data.url,
storageKey: asset.data.storageKey,
title: asset.title,
width: asset.data.width,
height: asset.data.height,
durationMs: asset.data.durationMs,
hasAudio: asset.data.hasAudio,
bytes: asset.data.bytes,
mimeType: asset.data.mimeType,
assetId: asset.id,
};
return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
}

View File

@@ -0,0 +1,243 @@
import { useMemo, useState } from "react";
import { Drawer, Tag, Button, Input, Popconfirm, Empty, Tooltip } from "antd";
import { Clock, Clock3, ExternalLink, History, Search, Trash2, X } from "lucide-react";
import { useNavigate } from "react-router";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
import { cn } from "@/lib/utils";
type TimelineItem = {
id: string;
title: string;
isDeleted: boolean;
createdAt: string;
updatedAt: string;
deletedAt?: string;
nodeCount: number;
timelineTime: string;
};
export function CanvasHistoryDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
const navigate = useNavigate();
const activeProjects = useCanvasStore((state) => state.projects);
const deletedProjects = useCanvasHistoryStore((state) => state.deletedProjects);
const removeDeletedItem = useCanvasHistoryStore((state) => state.removeDeletedHistoryItem);
const clearDeletedHistory = useCanvasHistoryStore((state) => state.clearDeletedHistory);
const [keyword, setKeyword] = useState("");
const [filter, setFilter] = useState<"all" | "active" | "deleted">("all");
const timelineItems = useMemo<TimelineItem[]>(() => {
const activeList: TimelineItem[] = activeProjects.map((p) => ({
id: p.id,
title: p.title || "未命名画布",
isDeleted: false,
createdAt: p.createdAt || p.updatedAt || new Date().toISOString(),
updatedAt: p.updatedAt || new Date().toISOString(),
nodeCount: p.nodes?.length || 0,
timelineTime: p.createdAt || p.updatedAt || new Date().toISOString(),
}));
const deletedList: TimelineItem[] = deletedProjects.map((d) => ({
id: d.id,
title: d.title || "未命名画布",
isDeleted: true,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
deletedAt: d.deletedAt,
nodeCount: d.nodeCount,
timelineTime: d.deletedAt || d.createdAt,
}));
const all = [...activeList, ...deletedList];
all.sort((a, b) => new Date(b.timelineTime).getTime() - new Date(a.timelineTime).getTime());
return all;
}, [activeProjects, deletedProjects]);
const filteredItems = useMemo(() => {
const q = keyword.trim().toLowerCase();
return timelineItems.filter((item) => {
if (filter === "active" && item.isDeleted) return false;
if (filter === "deleted" && !item.isDeleted) return false;
if (!q) return true;
return item.title.toLowerCase().includes(q);
});
}, [filter, keyword, timelineItems]);
const openProject = (id: string) => {
navigate(`/canvas/${id}`);
onClose();
};
return (
<Drawer
title={
<div className="flex items-center justify-between gap-2 pr-2">
<div className="flex items-center gap-2">
<History className="size-4 text-[var(--workspace-accent)]" />
<span className="text-sm font-semibold text-stone-900 dark:text-stone-100">画布创建与变更历史时间线</span>
</div>
{deletedProjects.length > 0 ? (
<Popconfirm title="确定清空已删除画布的历史记录?" onConfirm={clearDeletedHistory} okText="清空" okButtonProps={{ danger: true }} cancelText="取消">
<Button type="text" size="small" danger icon={<Trash2 className="size-3.5" />}>
清理删除记录
</Button>
</Popconfirm>
) : null}
</div>
}
placement="right"
width={460}
open={open}
onClose={onClose}
className="workspace-drawer canvas-history-drawer"
styles={{
header: { borderBottom: "1px solid var(--border-color, rgba(255,255,255,0.08))" },
body: { padding: "16px 20px" },
}}
>
<div className="space-y-4">
{/* 搜索与筛选 */}
<div className="space-y-3">
<Input allowClear prefix={<Search className="size-3.5 text-stone-400" />} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索画布名称..." className="text-xs" />
<div className="flex items-center gap-2 text-xs">
<button
type="button"
className={cn(
"cursor-pointer rounded-lg px-3 py-1.5 font-medium transition-all",
filter === "all"
? "!bg-stone-900 !text-white shadow-sm dark:!bg-stone-100 dark:!text-stone-900 font-semibold"
: "bg-stone-100 text-stone-700 hover:bg-stone-200 dark:bg-stone-800 dark:text-stone-300 dark:hover:bg-stone-700",
)}
onClick={() => setFilter("all")}
>
全部 ({timelineItems.length})
</button>
<button
type="button"
className={cn(
"cursor-pointer rounded-lg px-3 py-1.5 font-medium transition-all",
filter === "active"
? "!bg-emerald-600 !text-white shadow-sm font-semibold"
: "bg-emerald-50 text-emerald-800 hover:bg-emerald-100 border border-emerald-200/60 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-800/50",
)}
onClick={() => setFilter("active")}
>
活跃中 ({activeProjects.length})
</button>
<button
type="button"
className={cn(
"cursor-pointer rounded-lg px-3 py-1.5 font-medium transition-all",
filter === "deleted" ? "!bg-rose-600 !text-white shadow-sm font-semibold" : "bg-rose-50 text-rose-800 hover:bg-rose-100 border border-rose-200/60 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-800/50",
)}
onClick={() => setFilter("deleted")}
>
已删除 ({deletedProjects.length})
</button>
</div>
</div>
{/* 时间线列表 */}
{filteredItems.length === 0 ? (
<div className="py-12">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无匹配的历史记录" />
</div>
) : (
<div className="relative border-l-2 border-stone-200 dark:border-stone-800 pl-4 space-y-4 pt-1">
{filteredItems.map((item) => {
const isDeleted = item.isDeleted;
return (
<div key={item.id + (item.deletedAt || "")} className="group relative">
{/* 时间轴圆点 */}
<span className={cn("absolute -left-[23px] top-3.5 size-3 rounded-full border-2 border-background", isDeleted ? "bg-rose-500 ring-2 ring-rose-500/20" : "bg-emerald-500 ring-2 ring-emerald-500/20")} />
<div
className={cn(
"rounded-xl border p-3.5 transition-all shadow-sm",
isDeleted
? "border-rose-200/90 bg-rose-50/50 dark:border-rose-900/40 dark:bg-rose-950/20 hover:border-rose-300"
: "border-stone-200 bg-white dark:border-stone-800 dark:bg-stone-900/70 hover:border-stone-300 dark:hover:border-stone-700",
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-center gap-2">
{isDeleted ? (
<span className="truncate text-sm font-semibold text-stone-500 line-through decoration-rose-500/80 decoration-2 dark:text-stone-400" title={item.title}>
{item.title}
</span>
) : (
<button
type="button"
className="truncate text-left text-sm font-semibold text-stone-900 hover:text-blue-600 dark:text-stone-100 dark:hover:text-blue-400 transition-colors"
onClick={() => openProject(item.id)}
title={item.title}
>
{item.title}
</button>
)}
{isDeleted ? (
<Tag color="error" className="m-0 text-[11px] leading-tight px-1.5 py-0.5 font-medium">
已删除
</Tag>
) : (
<Tag color="success" className="m-0 text-[11px] leading-tight px-1.5 py-0.5 font-medium">
活跃中
</Tag>
)}
</div>
<div className="space-y-1 text-xs text-stone-600 dark:text-stone-300">
<div className="flex items-center gap-1.5">
<Clock3 className="size-3.5 text-stone-400 shrink-0" />
<span>创建时间:{formatTimelineDate(item.createdAt)}</span>
</div>
{isDeleted && item.deletedAt ? (
<div className="flex items-center gap-1.5 text-rose-600 dark:text-rose-400 font-medium">
<Trash2 className="size-3.5 text-rose-500 shrink-0" />
<span>删除时间:{formatTimelineDate(item.deletedAt)}</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-stone-500 dark:text-stone-400">
<Clock className="size-3.5 text-stone-400 shrink-0" />
<span>最近更新:{formatTimelineDate(item.updatedAt)}</span>
</div>
)}
<div className="text-[11px] text-stone-500 dark:text-stone-400 pt-0.5">包含 {item.nodeCount} 个节点</div>
</div>
</div>
<div className="flex items-center gap-1 shrink-0 pt-0.5">
{!isDeleted ? (
<Button
type="text"
size="small"
icon={<ExternalLink className="size-4 text-stone-500 hover:text-stone-800 dark:text-stone-400 dark:hover:text-stone-100" />}
onClick={() => openProject(item.id)}
title="打开画布"
/>
) : (
<Tooltip title="从历史列表中移除此条记录">
<Button type="text" size="small" danger icon={<X className="size-4" />} onClick={() => removeDeletedItem(item.id)} />
</Tooltip>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</Drawer>
);
}
function formatTimelineDate(isoString: string) {
if (!isoString) return "--";
const date = new Date(isoString);
if (!Number.isFinite(date.getTime())) return "--";
return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
}

View File

@@ -13,7 +13,23 @@ import { useAssetStore, type Asset } from "@/stores/use-asset-store";
const categoryLabels: Record<string, string> = { all: "全部资产", ...ASSET_CATEGORY_LABELS };
type ProjectPickerItem = { id: string; category: string; folderId?: string; project?: ProjectAsset; character?: ProjectAsset; media?: Asset };
export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", initialFolderId = "all", onClose, onInsert, onInsertFolder }: { open: boolean; detail?: ProjectDetail; initialCategory?: string; initialFolderId?: string; onClose: () => void; onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void; onInsertFolder?: (folderId: string) => Promise<void> | void }) {
export function CanvasProjectAssetModal({
open,
detail,
initialCategory = "all",
initialFolderId = "all",
onClose,
onInsert,
onInsertFolder,
}: {
open: boolean;
detail?: ProjectDetail;
initialCategory?: string;
initialFolderId?: string;
onClose: () => void;
onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void;
onInsertFolder?: (folderId: string) => Promise<void> | void;
}) {
const mediaAssets = useAssetStore((state) => state.assets);
const externalAssetSources = useExternalAssetSources(open);
const items = useMemo<ProjectPickerItem[]>(() => {
@@ -21,38 +37,40 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
const projectItems = (detail?.assets || []).flatMap((asset): ProjectPickerItem[] => {
if (asset.category === "character" && asset.character) return [{ id: asset.id, category: "character", folderId: asset.folderId, project: asset, character: asset }];
const media = mediaById.get(asset.id);
if (media?.status === "archived") return [];
return asset.mediaType === "model" || asset.mediaType === "entity" ? [] : [{ id: asset.id, category: normalizeAssetCategory(asset.category || media?.category), folderId: asset.folderId, project: asset, media }];
});
if (detail) return projectItems;
// 自由画布未关联项目时回退到个人素材库。
return mediaAssets
.filter((asset) => asset.kind !== "model" && asset.kind !== "entity")
.map((media): ProjectPickerItem => ({ id: media.id, category: normalizeAssetCategory(media.category), media }));
return mediaAssets.filter((asset) => asset.kind !== "model" && asset.kind !== "entity" && asset.status !== "archived").map((media): ProjectPickerItem => ({ id: media.id, category: normalizeAssetCategory(media.category), media }));
}, [detail?.assets, mediaAssets]);
const localPickerItems = useMemo<AssetLibraryPickerItem[]>(() => items.map((item) => {
const character = item.character;
const project = item.project;
const media = item.media;
const coverRepresentation = character?.character?.representations.find((representation) => representation.role === "turnaround_sheet") || character?.character?.representations.find((representation) => representation.role === "primary") || character?.character?.representations.find((representation) => representation.role === "front");
const remoteResourceId = resourceIdFromStorageKey(project?.storageKey);
return {
id: item.id,
title: character?.title || project?.title || media?.title || "未命名资产",
category: item.category,
folderId: item.folderId,
kindLabel: character ? "角色卡" : (media?.kind || project?.mediaType) === "video" ? "视频" : (media?.kind || project?.mediaType) === "audio" ? "音频" : (media?.kind || project?.mediaType) === "text" ? "文本" : "图片",
asset: media,
imageUrl: coverRepresentation
? resourceFileUrl(coverRepresentation.resourceId)
: project?.mediaType === "image" && remoteResourceId
? resourceFileUrl(remoteResourceId)
: undefined,
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
imageFit: character ? "contain" : "cover",
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText,
searchText: [media?.tags?.join(" ") || "", project?.previewText || ""].join(" "),
};
}), [items]);
const localPickerItems = useMemo<AssetLibraryPickerItem[]>(
() =>
items.map((item) => {
const character = item.character;
const project = item.project;
const media = item.media;
const coverRepresentation =
character?.character?.representations.find((representation) => representation.role === "turnaround_sheet") ||
character?.character?.representations.find((representation) => representation.role === "primary") ||
character?.character?.representations.find((representation) => representation.role === "front");
const remoteResourceId = resourceIdFromStorageKey(project?.storageKey);
return {
id: item.id,
title: character?.title || project?.title || media?.title || "未命名资产",
category: item.category,
folderId: item.folderId,
kindLabel: character ? "角色卡" : (media?.kind || project?.mediaType) === "video" ? "视频" : (media?.kind || project?.mediaType) === "audio" ? "音频" : (media?.kind || project?.mediaType) === "text" ? "文本" : "图片",
asset: media,
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : project?.mediaType === "image" && remoteResourceId ? resourceFileUrl(remoteResourceId) : undefined,
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
imageFit: character ? "contain" : "cover",
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText,
searchText: [media?.tags?.join(" ") || "", project?.previewText || ""].join(" "),
};
}),
[items],
);
const pickerItems = useMemo<AssetLibraryPickerItem[]>(() => [...localPickerItems, ...externalAssetSources.items], [externalAssetSources.items, localPickerItems]);
return (
@@ -69,18 +87,27 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
emptyTitle="此分类没有可引用资产"
emptyDescription={detail ? "先在项目角色与资产中完成角色确认或素材关联。" : "当前为自由画布,可先在素材库添加内容。"}
footerNote={externalAssetSources.error || "角色引用会在生成时解析当前角色版本"}
onFolderAction={onInsertFolder ? async (folderId) => { await onInsertFolder(folderId); onClose(); } : undefined}
onFolderAction={
onInsertFolder
? async (folderId) => {
await onInsertFolder(folderId);
onClose();
}
: undefined
}
onClose={onClose}
onConfirm={async (ids) => {
const payloads = await Promise.all(ids.map(async (id) => {
const external = externalAssetSources.items.find((item) => item.id === id)?.external;
if (external) return externalAssetToInsertPayload(external);
const item = items.find((candidate) => candidate.id === id);
if (!item) throw new Error("所选资产已不存在,请重新选择");
if (item.media || item.character || !item.project) return toInsertPayload(item);
const { asset } = await getRemoteAsset(item.project.id);
return toInsertPayload({ ...item, media: asset });
}));
const payloads = await Promise.all(
ids.map(async (id) => {
const external = externalAssetSources.items.find((item) => item.id === id)?.external;
if (external) return externalAssetToInsertPayload(external);
const item = items.find((candidate) => candidate.id === id);
if (!item) throw new Error("所选资产已不存在,请重新选择");
if (item.media || item.character || !item.project) return toInsertPayload(item);
const { asset } = await getRemoteAsset(item.project.id);
return toInsertPayload({ ...item, media: asset });
}),
);
if (!payloads.length) return;
await onInsert(payloads);
onClose();
@@ -106,8 +133,30 @@ function toInsertPayload(item: ProjectPickerItem): InsertAssetPayload {
}
if (!asset) throw new Error("项目资产不可用");
if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id };
if (asset.kind === "video") return { kind: "video", url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url), storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
if (asset.kind === "audio") return { kind: "audio", url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url), storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
if (asset.kind === "video")
return {
kind: "video",
url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url),
storageKey: asset.data.storageKey,
title: asset.title,
width: asset.data.width,
height: asset.data.height,
durationMs: asset.data.durationMs,
bytes: asset.data.bytes,
mimeType: asset.data.mimeType,
assetId: asset.id,
};
if (asset.kind === "audio")
return {
kind: "audio",
url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url),
storageKey: asset.data.storageKey,
title: asset.title,
durationMs: asset.data.durationMs,
bytes: asset.data.bytes,
mimeType: asset.data.mimeType,
assetId: asset.id,
};
if (asset.kind === "image") return { kind: "image", dataUrl: projectAssetMediaUrl(asset.data.storageKey, asset.data.dataUrl), storageKey: asset.data.storageKey, title: asset.title, assetId: asset.id };
throw new Error("当前项目资产不能直接插入画布");
}
@@ -121,7 +170,10 @@ export function projectCharacterToInsertPayload(asset: ProjectAsset): InsertAsse
if (!asset.character) throw new Error("项目角色信息不完整");
const card = asset.character;
const definition = card.definition;
const cover = card.representations.find((representation) => representation.role === "turnaround_sheet") || card.representations.find((representation) => representation.role === "primary") || card.representations.find((representation) => representation.role === "front");
const cover =
card.representations.find((representation) => representation.role === "turnaround_sheet") ||
card.representations.find((representation) => representation.role === "primary") ||
card.representations.find((representation) => representation.role === "front");
return {
kind: "character",
title: asset.title,
@@ -134,12 +186,14 @@ export function projectCharacterToInsertPayload(asset: ProjectAsset): InsertAsse
visualStatus: card.visualStatus,
voiceStatus: card.voiceStatus,
voiceName: card.voice?.profile.name,
voiceProfile: card.voice ? {
name: card.voice.profile.name,
provider: card.voice.profile.provider,
language: card.voice.profile.language,
timbre: card.voice.profile.timbre,
} : undefined,
voiceProfile: card.voice
? {
name: card.voice.profile.name,
provider: card.voice.profile.provider,
language: card.voice.profile.language,
timbre: card.voice.profile.timbre,
}
: undefined,
voiceInstructions: card.voice?.instructions,
};
}