feat(canvas): 画布能力 - 完善 LibTV 导入与资源预览

This commit is contained in:
ddCat
2026-08-19 07:16:07 +08:00
parent 4b9d510129
commit d2a089f1ec
36 changed files with 1722 additions and 68 deletions

View File

@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { CachedResourceImage } from "@/components/cached-resource-image";
import type { Asset } from "@/stores/use-asset-store";
type AssetMediaPreviewProps = {
@@ -32,7 +33,8 @@ export function AssetMediaPreview({ asset, alt, className = "", fallback = null
);
}
const storageKey = asset.kind === "image" ? asset.data.storageKey : undefined;
const imageUrl = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
if (!imageUrl) return fallback;
return <img src={imageUrl} alt={alt} loading="lazy" decoding="async" className={className} />;
if (!imageUrl && !storageKey) return fallback;
return <CachedResourceImage storageKey={storageKey} src={imageUrl} alt={alt} loading="lazy" decoding="async" className={className} fallback={fallback} />;
}

View File

@@ -3,6 +3,7 @@ import { Check, FileText, FolderOpen, Image as ImageIcon, LoaderCircle, Music2,
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { cn } from "@/lib/utils";
import type { Asset } from "@/stores/use-asset-store";
@@ -13,6 +14,7 @@ export type AssetLibraryPickerItem = {
kindLabel: string;
asset?: Asset;
imageUrl?: string;
imageStorageKey?: string;
imageFit?: "cover" | "contain";
description?: string;
searchText?: string;
@@ -210,7 +212,7 @@ function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem
return (
<button type="button" className={cn("asset-picker-card", selected && "is-selected", disabled && "is-disabled")} onClick={onToggle} disabled={disabled} aria-pressed={selected} title={item.disabledReason || item.title}>
<div className="asset-picker-card-media">
{item.imageUrl ? <img src={item.imageUrl} alt={item.title} loading="lazy" decoding="async" className={item.imageFit === "contain" ? "is-contain" : undefined} /> : <AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="asset-picker-card-fallback">{kindIcon(item.kindLabel)}</div>} />}
{item.imageUrl || item.imageStorageKey ? <CachedResourceImage storageKey={item.imageStorageKey} src={item.imageUrl} alt={item.title} loading="lazy" decoding="async" className={item.imageFit === "contain" ? "is-contain" : undefined} fallback={<div className="asset-picker-card-fallback">{kindIcon(item.kindLabel)}</div>} /> : <AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="asset-picker-card-fallback">{kindIcon(item.kindLabel)}</div>} />}
<span className="asset-picker-card-check"><Check /></span>
<span className="asset-picker-card-kind">{item.kindLabel}</span>
{item.disabledReason ? <span className="asset-picker-card-lock">{item.disabledReason}</span> : null}

View File

@@ -0,0 +1,75 @@
import { useEffect, useRef, useState, type ImgHTMLAttributes, type ReactNode } from "react";
import { resourceIdFromStorageKey } from "@/services/api/resources";
import { cacheResourceObjectUrl } from "@/services/resource-blob-cache";
type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
storageKey?: string;
src?: string;
fallback?: ReactNode;
eager?: boolean;
};
/**
* 资源图片优先读取按用户隔离的本地 Blob 缓存,避免刷新后再次从对象存储下载。
* 普通外链、data URL 和本地 Blob URL 不经过资源缓存,保持原有行为。
*/
export function CachedResourceImage({ storageKey, src = "", fallback = null, eager = false, onError, ...props }: CachedResourceImageProps) {
const remoteResource = Boolean(resourceIdFromStorageKey(storageKey));
const targetRef = useRef<HTMLSpanElement>(null);
const [nearViewport, setNearViewport] = useState(eager || !remoteResource);
const [cachedSrc, setCachedSrc] = useState(remoteResource ? "" : src);
const [cacheFailed, setCacheFailed] = useState(false);
useEffect(() => {
if (!remoteResource || eager) {
setNearViewport(true);
return;
}
const image = targetRef.current;
if (!image || typeof IntersectionObserver === "undefined") {
setNearViewport(true);
return;
}
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setNearViewport(true);
observer.disconnect();
}
}, { rootMargin: "240px" });
observer.observe(image);
return () => observer.disconnect();
}, [eager, remoteResource]);
useEffect(() => {
let cancelled = false;
setCacheFailed(false);
if (!remoteResource || !storageKey) {
setCachedSrc(src);
return () => { cancelled = true; };
}
if (!nearViewport) {
setCachedSrc("");
return () => { cancelled = true; };
}
setCachedSrc("");
const resolve = cacheResourceObjectUrl(storageKey);
void resolve.then((url) => {
if (!cancelled) setCachedSrc(url || src);
}).catch(() => {
if (!cancelled) {
setCacheFailed(true);
setCachedSrc(src);
}
});
return () => { cancelled = true; };
}, [nearViewport, remoteResource, src, storageKey]);
if (!remoteResource) return <img {...props} src={cachedSrc} onError={onError} />;
return (
<span ref={targetRef} className="cached-resource-image-shell">
{cachedSrc && !(cacheFailed && !src) ? <img {...props} src={cachedSrc} onError={onError} /> : fallback}
</span>
);
}

View File

@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type
import { Crosshair, FolderOpen, ImageIcon, Images, PanelLeftClose, Plus, Search, X } from "lucide-react";
import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -229,7 +230,7 @@ function AssetTrayRow({ title, imageUrl, storageKey, icon, active = false, dragg
onDragStartCapture={onDragStart}
>
<span className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-[var(--dock-item-radius)] border" style={{ background: theme.node.fill, borderColor: theme.toolbar.border }}>
{imageUrl ? <img src={imageUrl} alt="" width={36} height={36} className="size-full object-cover" draggable={false} /> : <ImageIcon className="size-3.5 opacity-55" />}
{imageUrl || storageKey ? <CachedResourceImage storageKey={storageKey} src={imageUrl} alt="" width={36} height={36} className="size-full object-cover" draggable={false} fallback={<ImageIcon className="size-3.5 opacity-55" />} /> : <ImageIcon className="size-3.5 opacity-55" />}
</span>
<span className="min-w-0">
<span className="block truncate text-[var(--fs-tiny)] font-semibold">{title}</span>

View File

@@ -6,6 +6,7 @@ import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerat
import { generationTaskShowsProgress, generationTaskStageLabel, generationTaskStatusLabel, isGenerationTaskSubmissionUncertain } from "@/lib/generation-task-display";
import { canvasRichTextHTML } from "@/lib/canvas/canvas-rich-text";
import { loadCanvasDrawingPreview } from "@/lib/canvas/canvas-drawing-storage";
import { buildLibTVImagePreviewUrl } from "@/lib/canvas/libtv-import";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { CanvasTheme } from "@/lib/canvas-theme";
import { formatBytes } from "@/lib/image-utils";
@@ -417,10 +418,11 @@ function ImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded, bat
const imageContainerRef = useRef<HTMLDivElement>(null);
const nearViewport = useNearViewport(imageContainerRef);
const { url, loading } = useNodeResourceUrl(node, nearViewport);
const importedFromLibTV = node.metadata?.importSource?.provider === "libtv";
return (
<BatchFrame batchCount={isBatchRoot ? batchCount : 0} batchExpanded={batchExpanded} batchOpening={batchOpening} batchRecovering={batchRecovering} theme={theme} onToggleBatch={onToggleBatch}>
<div ref={imageContainerRef} className="h-full w-full overflow-hidden rounded-[var(--node-radius)]">
{url ? <img src={url} alt={node.title} loading="lazy" decoding="async" draggable={false} onDragStart={(event) => event.preventDefault()} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> : <div className="grid size-full place-items-center" style={{ color: theme.node.muted }}>{loading ? <LoaderCircle className="size-5 animate-spin" /> : <ImageIcon className="size-5 opacity-45" />}</div>}
{url ? <img src={url} alt={node.title} loading={importedFromLibTV ? "eager" : "lazy"} decoding="async" draggable={false} onDragStart={(event) => event.preventDefault()} className={`pointer-events-none block h-full w-full select-none ${node.metadata?.freeResize ? "object-fill" : "object-contain"}`} /> : <div className="grid size-full place-items-center" style={{ color: theme.node.muted }}>{loading ? <LoaderCircle className="size-5 animate-spin" /> : <ImageIcon className="size-5 opacity-45" />}</div>}
</div>
</BatchFrame>
);
@@ -432,7 +434,9 @@ function DeferredMediaLoad({ icon, label, disabled, onClick }: { icon: ReactNode
function useNodeResourceUrl(node: CanvasNodeData, eager: boolean) {
const storageKey = node.metadata?.storageKey || "";
const fallback = node.metadata?.content || "";
const content = node.metadata?.content || "";
const fallback = node.metadata?.previewContent
|| (node.type === CanvasNodeType.Image && node.metadata?.importSource?.provider === "libtv" ? buildLibTVImagePreviewUrl(content) : content);
const isRemoteResource = Boolean(resourceIdFromStorageKey(storageKey));
const [url, setUrl] = useState(isRemoteResource ? "" : fallback);
const [loading, setLoading] = useState(isRemoteResource && eager);

View File

@@ -36,6 +36,7 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
kindLabel: character ? "角色卡" : media?.kind === "video" ? "视频" : media?.kind === "audio" ? "音频" : media?.kind === "text" ? "文本" : "图片",
asset: media,
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : undefined,
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
imageFit: character ? "contain" : "cover",
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : undefined,
searchText: media?.tags.join(" "),

View File

@@ -9,6 +9,7 @@ import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { resolveBackendApiUrl } from "@/stores/use-config-store";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { cn } from "@/lib/utils";
export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; onClick: () => void }) {
@@ -109,16 +110,16 @@ function ProjectPreview({ project }: { project: CanvasProject }) {
.flatMap((node) => {
if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return [];
const url = getNodeMediaUrl(node);
return isPreviewUrl(url) ? [{ node, url }] : [];
return isPreviewUrl(url) ? [{ node, url, storageKey: node.metadata?.storageKey }] : [];
});
const media = mediaNodes.find(({ node }) => node.type === CanvasNodeType.Image) || mediaNodes[0];
if (media) {
const { node, url } = media;
const { node, url, storageKey } = media;
return (
<div className="canvas-project-media size-full">
{node.type === CanvasNodeType.Video
? <div className="canvas-project-video size-full"><Video className="size-8" aria-label={node.title || "项目视频"} /></div>
: <img src={url} alt={node.title || "项目图片"} loading="lazy" decoding="async" className="size-full min-h-0 object-cover" />}
: <CachedResourceImage storageKey={storageKey} src={url} alt={node.title || "项目图片"} loading="lazy" decoding="async" className="size-full min-h-0 object-cover" />}
</div>
);
}

View File

@@ -6,7 +6,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { createCanvasShare, deleteCanvasShare, getCanvasShare, type CanvasShareStatus } from "@/services/api/canvas-share";
import { useThemeStore } from "@/stores/use-theme-store";
export function CanvasShareModal({ projectId, open, onClose, beforeCreate }: { projectId: string; open: boolean; onClose: () => void; beforeCreate: () => Promise<void> }) {
export function CanvasShareModal({ projectId, open, onClose, beforeCreate }: { projectId: string; open: boolean; onClose: () => void; beforeCreate: () => Promise<boolean | void> }) {
const { message, modal } = App.useApp();
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [share, setShare] = useState<CanvasShareStatus>({ enabled: false });
@@ -40,7 +40,8 @@ export function CanvasShareModal({ projectId, open, onClose, beforeCreate }: { p
const create = async (rotate = false) => {
setSubmitting(true);
try {
await beforeCreate();
const saved = await beforeCreate();
if (saved === false) return;
const result = await createCanvasShare(projectId, { expiresDays, rotate });
setShare(result.share);
const url = result.share.token ? `${window.location.origin}/share/canvas/${result.share.token}` : "";

View File

@@ -35,12 +35,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const options = useMemo(() => {
const filtered = selectableModelsByCapability(config, capability);
const current = value?.trim();
const currentIncluded = current ? filtered.includes(current) : true;
return Array.from(new Set([...filtered, ...(!currentIncluded && current ? [current] : [])].filter((model): model is string => Boolean(model))));
}, [capability, config, value]);
const options = useMemo(() => Array.from(new Set(selectableModelsByCapability(config, capability).filter(Boolean))), [capability, config]);
const optionGroups = useMemo(() => {
const channelGroups = config.channels
.map((channel) => ({
@@ -53,13 +48,15 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
),
}))
.filter((group) => group.models.length);
const groupedModels = new Set(channelGroups.flatMap((group) => group.models.flatMap((modelGroup) => modelGroup.models)));
const ungroupedModels = options.filter((model) => !groupedModels.has(model));
return ungroupedModels.length ? [...channelGroups, { key: "ungrouped", label: "其他模型", scope: "未指定渠道", models: groupModelsByDisplayName(config, ungroupedModels) }] : channelGroups;
// options 已由当前有效渠道重建;任何无法解析渠道的旧值都直接丢弃,
// 不再显示“其他模型 / 未指定渠道”这种不可用入口。
return channelGroups;
}, [config, options]);
const current = value || "";
const resolvedCurrent = resolveCompatibleModel(config, current, requirements) || current;
const currentPrice = modelMenuPrice(config, resolvedCurrent);
const storedCurrent = value?.trim() || "";
const resolvedCurrent = resolveCompatibleModel(config, storedCurrent, requirements) || storedCurrent;
// 旧画布可能保存过已下架或前端历史内置模型;它们不能重新进入当前可选目录。
const current = options.includes(resolvedCurrent) ? resolvedCurrent : "";
const currentPrice = modelMenuPrice(config, current);
const creationVariant = variant === "creation";
useEffect(() => {

View File

@@ -0,0 +1,36 @@
const LIBTV_PROJECT_UUID_PATTERN = /^[a-f0-9]{32}$/i;
const LIBTV_RESOURCE_HOST = "libtv-res.liblib.art";
const LIBTV_PREVIEW_WIDTH = 960;
export function parseLibTVProjectUUID(value: string) {
const trimmed = value.trim();
if (!trimmed) return "";
let candidate = trimmed;
try {
const parsed = new URL(trimmed);
candidate = parsed.searchParams.get("projectId") || parsed.searchParams.get("uuid") || parsed.pathname.split("/").filter(Boolean).at(-1) || "";
} catch {
candidate = trimmed;
}
return LIBTV_PROJECT_UUID_PATTERN.test(candidate) ? candidate.toLowerCase() : "";
}
export function formatLibTVBatchTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "未知";
const pad = (part: number) => String(part).padStart(2, "0");
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(date.getMinutes())}`;
}
export function buildLibTVImagePreviewUrl(rawUrl: string) {
try {
const url = new URL(rawUrl);
if (url.protocol !== "https:" || url.hostname !== LIBTV_RESOURCE_HOST) return rawUrl;
if (!url.searchParams.has("x-oss-process")) {
url.searchParams.set("x-oss-process", `image/resize,w_${LIBTV_PREVIEW_WIDTH}`);
}
return url.toString();
} catch {
return rawUrl;
}
}

View File

@@ -8,6 +8,7 @@ import {
HardDrive,
Home,
Infinity as InfinityIcon,
KeyRound,
Layers3,
Mail,
MessageSquareText,
@@ -72,6 +73,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
{ path: "/admin/settings/email", label: "邮件服务", description: "注册验证码 SMTP", icon: <Mail className="size-4" /> },
{ path: "/admin/settings/storage", label: "存储服务", description: "对象存储与资源存储", icon: <HardDrive className="size-4" /> },
{ path: "/admin/settings/response-interception", label: "模型响应拦截", description: "替换用户可见的上游异常", icon: <ShieldAlert className="size-4" /> },
{ path: "/admin/settings/third-party", label: "第三方参数配置", description: "集中维护第三方平台凭证", icon: <KeyRound className="size-4" /> },
],
},
];

View File

@@ -0,0 +1,143 @@
import { App, Button, Input, Popconfirm, Switch } from "antd";
import { KeyRound, Save, Trash2, Wifi } from "lucide-react";
import { useEffect, useState } from "react";
import { getAdminLibTVSetting, testAdminLibTV, updateAdminLibTVSetting, type AdminLibTVSetting } from "@/services/api/libtv";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
export default function LibTVSettingsPage() {
const { message } = App.useApp();
const [setting, setSetting] = useState<AdminLibTVSetting>({ enabled: false, hasToken: false });
const [enabled, setEnabled] = useState(false);
const [token, setToken] = useState("");
const [testUuid, setTestUuid] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [clearing, setClearing] = useState(false);
const [testing, setTesting] = useState(false);
useEffect(() => {
let cancelled = false;
void getAdminLibTVSetting()
.then(({ setting: next }) => {
if (cancelled) return;
setSetting(next);
setEnabled(next.enabled);
})
.catch((error) => {
if (!cancelled) message.error(error instanceof Error ? error.message : "读取 LibTV 配置失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [message]);
const save = async () => {
setSaving(true);
try {
const { setting: next } = await updateAdminLibTVSetting({ enabled, token: token.trim() || undefined });
setSetting(next);
setToken("");
setEnabled(next.enabled);
message.success("LibTV 配置已更新");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存 LibTV 配置失败");
} finally {
setSaving(false);
}
};
const clearToken = async () => {
setClearing(true);
try {
const { setting: next } = await updateAdminLibTVSetting({ enabled: false, clearToken: true });
setSetting(next);
setToken("");
setEnabled(false);
message.success("LibTV Token 已清空,导入功能已停用");
} catch (error) {
message.error(error instanceof Error ? error.message : "清空 LibTV Token 失败");
} finally {
setClearing(false);
}
};
const test = async () => {
if (!testUuid.trim()) {
message.error("请填写用于测试的 LibTV 画布 UUID");
return;
}
setTesting(true);
try {
await testAdminLibTV(testUuid.trim());
message.success("LibTV 连接测试成功");
} catch (error) {
message.error(error instanceof Error ? error.message : "LibTV 连接测试失败");
} finally {
setTesting(false);
}
};
return (
<AdminPageFrame title="第三方参数配置" description="集中维护外部平台的服务端凭证和连接状态,后续平台可在此继续扩展。">
<div className="pt-4">
<SettingsSectionCard
icon={<KeyRound className="size-4" />}
title="LibTV 配置"
description="Token 仅保存在服务端加密配置中,不会回传到浏览器。"
status={<AdminStatusBadge label={setting.hasToken ? "已配置" : "未配置"} tone={setting.hasToken ? "success" : "warning"} />}
footer={
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="text-xs text-foreground/50">{setting.hasToken ? "已保存 Token;输入新值可替换,留空保存会保留现有 Token。" : "请先配置 Token,再启用导入功能。"}</span>
<div className="flex items-center gap-2">
{setting.hasToken ? (
<Popconfirm title="确认清空 LibTV Token?" description="清空后将同时停用 LibTV 画布导入。" okText="清空" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={clearToken}>
<Button danger icon={<Trash2 className="size-4" />} loading={clearing} disabled={loading || saving}>
清空 Token
</Button>
</Popconfirm>
) : null}
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={loading || clearing} onClick={() => void save()}>
保存配置
</Button>
</div>
</div>
}
>
<div className="space-y-5 px-4 py-4">
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_220px]">
<div>
<label className="mb-2 block text-sm font-medium">LibTV Token</label>
<Input.Password value={token} onChange={(event) => setToken(event.target.value)} placeholder={setting.hasToken ? "已配置(输入新 Token 可替换)" : "输入 LibTV Token"} disabled={loading || saving || clearing} autoComplete="new-password" />
</div>
<div>
<label className="mb-2 block text-sm font-medium">启用导入</label>
<div className="flex h-8 items-center gap-3">
<Switch checked={enabled} onChange={setEnabled} disabled={loading || saving || clearing} />
<span className="text-sm text-foreground/60">{enabled ? "已启用" : "已停用"}</span>
</div>
</div>
</div>
<div>
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<Wifi className="size-4" />
测试连接
</div>
<div className="flex flex-wrap gap-3">
<Input className="min-w-64 flex-1" value={testUuid} onChange={(event) => setTestUuid(event.target.value)} placeholder="输入可访问的 LibTV 画布 UUID" />
<Button loading={testing} onClick={() => void test()}>
测试连接
</Button>
</div>
<div className="mt-2 text-xs text-foreground/50">用于验证当前 Token 是否可读取指定 LibTV 画布,不会向当前画布写入节点。</div>
</div>
</div>
</SettingsSectionCard>
</div>
</AdminPageFrame>
);
}

View File

@@ -1,7 +1,7 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Link } from "react-router";
import { Bot, Check, ChevronDown, Clapperboard, Coins, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
import { Bot, Check, ChevronDown, Clapperboard, Coins, CopyPlus, Focus, FolderKanban, Gauge, Home, LayoutGrid, LoaderCircle, Menu, Pencil, Plus, Redo2, Search, Settings2, Share2, Sparkles, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Modal, Tooltip } from "antd";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
@@ -28,6 +28,7 @@ type CanvasTopBarProps = {
onCreateProject: () => void;
onDeleteProject: () => void;
onImportImage: () => void;
onImportLibTV: () => void;
onUndo: () => void;
onRedo: () => void;
onShare: () => void;
@@ -58,6 +59,7 @@ export function CanvasTopBar({
onCreateProject,
onDeleteProject,
onImportImage,
onImportLibTV,
onUndo,
onRedo,
onShare,
@@ -191,6 +193,9 @@ export function CanvasTopBar({
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5">
<Button type="text" className="!hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" title="搜索画布节点" />
<Button type="text" className="canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CopyPlus className="size-4" />} onClick={onImportLibTV} aria-label="一键导入 LibTV 画布" title="一键导入 LibTV 画布">
<span className="hidden xl:inline">导入 LibTV</span>
</Button>
<Dropdown
trigger={["click"]}
menu={{

View File

@@ -0,0 +1,176 @@
import { App, Button, Input, Modal, Tag } from "antd";
import { CircleAlert, ExternalLink, Import, LoaderCircle } from "lucide-react";
import { useMemo, useState } from "react";
import { buildLibTVImagePreviewUrl, formatLibTVBatchTime, parseLibTVProjectUUID } from "@/lib/canvas/libtv-import";
import { importLibTVCanvas, type LibTVImportResult } from "@/services/api/libtv";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
type Props = {
open: boolean;
projectId: string;
viewport: ViewportTransform;
viewportSize: { width: number; height: number };
onClose: () => void;
onApply: (nodes: CanvasNodeData[], connections: CanvasConnection[]) => Promise<void>;
};
function buildCanvasNodes(result: LibTVImportResult, viewport: ViewportTransform, viewportSize: { width: number; height: number }) {
const minX = Math.min(...result.nodes.map((node) => node.x));
const minY = Math.min(...result.nodes.map((node) => node.y));
const maxX = Math.max(...result.nodes.map((node) => node.x + node.width));
const maxY = Math.max(...result.nodes.map((node) => node.y + node.height));
const offsetX = (viewportSize.width / 2 - viewport.x) / viewport.k - (minX + maxX) / 2;
const offsetY = (viewportSize.height / 2 - viewport.y) / viewport.k - (minY + maxY) / 2;
return result.nodes.map<CanvasNodeData>((node) => ({
id: node.id,
type: node.type === "video" ? CanvasNodeType.Video : CanvasNodeType.Image,
title: node.title,
position: { x: node.x + offsetX, y: node.y + offsetY },
width: node.width,
height: node.height,
metadata: {
content: node.content,
previewContent: node.type === "image" ? buildLibTVImagePreviewUrl(node.content) : undefined,
prompt: node.prompt,
model: node.model,
status: node.status || "idle",
errorDetails: node.errorDetails,
naturalWidth: node.naturalWidth,
naturalHeight: node.naturalHeight,
durationMs: node.durationMs,
mimeType: node.mimeType,
importSource: node.metadata,
},
}));
}
export function LibTVImportDialog({ open, projectId, viewport, viewportSize, onClose, onApply }: Props) {
const { message } = App.useApp();
const [value, setValue] = useState("");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<LibTVImportResult | null>(null);
const uuid = useMemo(() => parseLibTVProjectUUID(value), [value]);
const reset = () => {
setValue("");
setResult(null);
};
const close = () => {
if (loading) return;
reset();
onClose();
};
const changeValue = (nextValue: string) => {
setValue(nextValue);
setResult(null);
};
const load = async () => {
if (!uuid) {
message.error("请填写 LibTV 画布 UUID 或链接");
return;
}
setLoading(true);
try {
setResult(await importLibTVCanvas(projectId, uuid));
} catch (error) {
message.error(error instanceof Error ? error.message : "读取 LibTV 画布失败");
} finally {
setLoading(false);
}
};
const apply = async () => {
if (!result) return;
setLoading(true);
try {
await onApply(buildCanvasNodes(result, viewport, viewportSize), result.connections);
reset();
onClose();
message.success(`已导入 ${result.importedNodeCount} 个节点和 ${result.importedConnectionCount} 条连接`);
} catch (error) {
message.error(error instanceof Error ? error.message : "保存导入结果失败");
} finally {
setLoading(false);
}
};
return (
<Modal
className="workspace-modal"
open={open}
onCancel={close}
title="导入 LibTV 画布"
width={620}
footer={
result ? (
[
<Button key="close" onClick={close}>
关闭
</Button>,
<Button key="apply" type="primary" icon={<Import className="size-4" />} loading={loading} onClick={() => void apply()}>
确认导入
</Button>,
]
) : (
<Button type="primary" loading={loading} onClick={() => void load()}>
读取画布
</Button>
)
}
>
<div className="space-y-4">
<div>
<label className="mb-2 block text-sm font-medium">LibTV 画布 UUID 或链接</label>
<Input
value={value}
onChange={(event) => changeValue(event.target.value)}
placeholder="粘贴 32 位 UUID、画布链接或分享链接"
disabled={loading}
suffix={uuid && value !== uuid ? <ExternalLink className="size-4 text-foreground/35" /> : null}
/>
</div>
{loading && !result ? (
<div className="flex items-center gap-2 text-sm text-foreground/55">
<LoaderCircle className="size-4 animate-spin" />
正在读取 LibTV 画布…
</div>
) : null}
{result ? (
<div className="space-y-3">
<div className="rounded-xl p-4" style={{ background: "var(--library-surface)" }}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold">{result.projectName || "LibTV 画布"}</div>
<div className="mt-1 text-sm text-foreground/60">可导入 {result.importedNodeCount} 个节点 · {result.importedConnectionCount} 条连线</div>
</div>
<Tag color="blue">批次:{formatLibTVBatchTime(result.batchCreatedAt)}</Tag>
</div>
<div className="mt-3 text-xs leading-5 text-foreground/50">节点会保留相对位置,并整体放到当前可视区域中心。</div>
</div>
{result.skippedNodes.length || result.skippedConnections.length || result.multiResultNodeCount || result.staleNodeCount || result.reusedFailedNodeCount || result.placeholderNodeCount || result.convertedSpecialCount ? (
<div className="flex items-start gap-2 rounded-lg px-3 py-2.5 text-xs leading-5 text-foreground/60" style={{ background: "var(--surface-hover)" }}>
<CircleAlert className="mt-0.5 size-4 shrink-0" />
<div>
{result.skippedNodes.length ? <div>{result.skippedNodes.length} 个暂不支持的节点未导入,相关连线已自动忽略。</div> : null}
{!result.skippedNodes.length && result.skippedConnections.length ? <div>{result.skippedConnections.length} 条无效连线已自动忽略。</div> : null}
{result.reusedFailedNodeCount ? <div>{result.reusedFailedNodeCount} 个最近任务失败但仍有历史结果的节点已保留。</div> : null}
{result.placeholderNodeCount ? <div>{result.placeholderNodeCount} 个尚未生成结果的节点已作为占位节点保留。</div> : null}
{result.convertedSpecialCount ? <div>{result.convertedSpecialCount} 个特殊节点已转换为图片参考节点。</div> : null}
{result.multiResultNodeCount ? <div>{result.multiResultNodeCount} 个多结果节点已使用首个结果。</div> : null}
{result.staleNodeCount ? <div>{result.staleNodeCount} 个过期标记节点仍保留现有结果。</div> : null}
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<Tag>等待确认导入</Tag>
</div>
</div>
) : null}
</div>
</Modal>
);
}

View File

@@ -72,6 +72,7 @@ import { deriveStoryboardPipelineProgress } from "@/lib/canvas/canvas-storyboard
import { CanvasAgentChangeToast, CanvasMergeStatusToast, CanvasUploadStatusToast } from "./canvas-project-feedback";
import { backendProviderConfig, getGenerationCount } from "@/lib/canvas/canvas-project-generation";
import { CanvasTopBar } from "./canvas-project-top-bar";
import { LibTVImportDialog } from "./components/libtv-import-dialog";
import { CanvasFocusModeBar } from "@/components/canvas/canvas-focus-mode-bar";
import { CanvasProjectContextMenu } from "./canvas-project-context-menu";
import { CanvasProjectMediaDialogs } from "./canvas-project-media-dialogs";
@@ -210,6 +211,7 @@ function InfiniteCanvasPage() {
const [scriptScrollTopById, setScriptScrollTopById] = useState<Record<string, number>>({});
const [directorNodeId, setDirectorNodeId] = useState<string | null>(null);
const [versionCompareRootId, setVersionCompareRootId] = useState<string | null>(null);
const [libTVImportOpen, setLibTVImportOpen] = useState(false);
const codexAutoConnect = shouldAutoConnectCanvasRuntime(searchParams);
const codexCompactAgent = codexAutoConnect && readLocalRuntimeBootstrapState().legacyDeepLinkRejected;
const [titleEditing, setTitleEditing] = useState(false);
@@ -331,6 +333,28 @@ function InfiniteCanvasPage() {
cleanupAssetImages,
cleanupCanvasFiles,
});
const applyLibTVImport = useCallback(
async (importedNodes: CanvasNodeData[], importedConnections: CanvasConnection[]) => {
const previousNodes = nodesRef.current;
const previousConnections = connectionsRef.current;
const nextNodes = [...nodesRef.current, ...importedNodes];
const nextConnections = [...connectionsRef.current, ...importedConnections];
nodesRef.current = nextNodes;
connectionsRef.current = nextConnections;
setNodes(nextNodes);
setConnections(nextConnections);
const saved = await saveCanvasProject();
if (!saved) {
nodesRef.current = previousNodes;
connectionsRef.current = previousConnections;
setNodes(previousNodes);
setConnections(previousConnections);
throw new Error("画布保存失败,已撤销本次 LibTV 导入");
}
},
[saveCanvasProject, setConnections, setNodes],
);
const linkedProjectId = shortDramaEnabled ? currentProject?.projectId || "" : "";
const linkedProjectQuery = useQuery({ queryKey: ["project", linkedProjectId], queryFn: () => getProject(linkedProjectId), enabled: Boolean(linkedProjectId) });
const refetchLinkedProject = linkedProjectQuery.refetch;
@@ -1639,6 +1663,7 @@ function InfiniteCanvasPage() {
onCreateProject={createAndOpenProject}
onDeleteProject={deleteCurrentProject}
onImportImage={() => handleUploadRequest()}
onImportLibTV={() => setLibTVImportOpen(true)}
onUndo={undoCanvas}
onRedo={redoCanvas}
onShare={() => setShareModalOpen(true)}
@@ -1686,6 +1711,7 @@ function InfiniteCanvasPage() {
) : null}
<CanvasShareModal projectId={projectId} open={shareModalOpen} onClose={() => setShareModalOpen(false)} beforeCreate={saveCanvasProject} />
<LibTVImportDialog open={libTVImportOpen} projectId={projectId} viewport={viewport} viewportSize={size} onClose={() => setLibTVImportOpen(false)} onApply={applyLibTVImport} />
<CanvasStylePickerModal open={stylePickerOpen} value={activeStylePresetId} applying={styleApplying} onClose={() => setStylePickerOpen(false)} onSelect={selectCanvasStyle} />

View File

@@ -187,7 +187,7 @@ export function useCanvasProjectLifecycle({
renameProject(projectId, title);
}, [projectId, renameProject]);
const saveCanvasProject = useCallback(async () => {
const saveCanvasProject = useCallback(async (): Promise<boolean> => {
try {
updateProject(projectId, {
nodes: nodesRef.current,
@@ -202,7 +202,7 @@ export function useCanvasProjectLifecycle({
await flushCanvasStorePersistence();
} catch {
message.error("画布保存失败,请稍后重试");
return;
return false;
}
try {
await saveRemoteUserDataNow();
@@ -211,6 +211,7 @@ export function useCanvasProjectLifecycle({
const detail = error instanceof Error ? error.message : "未知错误";
message.warning(`本地画布布局已保存,云端同步失败:${detail}`);
}
return true;
}, [activeChatId, backgroundMode, chatSessions, connectionsRef, currentProject?.directorScenes, message, nodesRef, projectId, showImageInfo, updateProject, viewportRef]);
const clearCanvasFiles = useCallback(() => {

View File

@@ -109,20 +109,23 @@ export function useCanvasRenderModel({
});
return { left, top, width: Math.max(2, right - left), height: Math.max(2, bottom - top) };
}, [nodes]);
const visibleNodes = useMemo(() => {
const renderBounds = useMemo(() => {
const padding = (reduceMediaEffects ? Math.max(240, Math.max(viewportSize.width, viewportSize.height) * 0.4) : Math.max(800, Math.max(viewportSize.width, viewportSize.height) * 1.5)) / viewport.k;
const viewLeft = -viewport.x / viewport.k - padding;
const viewTop = -viewport.y / viewport.k - padding;
const viewRight = viewLeft + viewportSize.width / viewport.k + padding * 2;
const viewBottom = viewTop + viewportSize.height / viewport.k + padding * 2;
return { left: viewLeft, top: viewTop, right: viewRight, bottom: viewBottom };
}, [reduceMediaEffects, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
const visibleNodes = useMemo(() => {
const frames: CanvasNodeData[] = [];
const regular: CanvasNodeData[] = [];
nodes.forEach((node) => {
if (renderHiddenNodeIds.has(node.id) || node.position.x + node.width <= viewLeft || node.position.x >= viewRight || node.position.y + node.height <= viewTop || node.position.y >= viewBottom) return;
if (renderHiddenNodeIds.has(node.id) || node.position.x + node.width <= renderBounds.left || node.position.x >= renderBounds.right || node.position.y + node.height <= renderBounds.top || node.position.y >= renderBounds.bottom) return;
(isFrameNode(node) ? frames : regular).push(node);
});
return [...frames, ...regular];
}, [nodes, reduceMediaEffects, renderHiddenNodeIds, viewport.k, viewport.x, viewport.y, viewportSize.height, viewportSize.width]);
}, [nodes, renderBounds, renderHiddenNodeIds]);
const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image"), [assets]);
const canvasImageNodes = useMemo(() => nodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(node.metadata?.content) && !collapsedBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed)), [collapsedBatchChildIds, nodeById, nodes]);
@@ -228,8 +231,13 @@ export function useCanvasRenderModel({
if (displayFrom.id === displayTo.id) return [];
const from = dragPreview?.nodeIds.has(displayFrom.id) ? { ...displayFrom, position: { x: displayFrom.position.x + dragPreview.x, y: displayFrom.position.y + dragPreview.y } } : displayFrom;
const to = dragPreview?.nodeIds.has(displayTo.id) ? { ...displayTo, position: { x: displayTo.position.x + dragPreview.x, y: displayTo.position.y + dragPreview.y } } : displayTo;
const connectionLeft = Math.min(from.position.x, to.position.x);
const connectionTop = Math.min(from.position.y, to.position.y);
const connectionRight = Math.max(from.position.x + from.width, to.position.x + to.width);
const connectionBottom = Math.max(from.position.y + from.height, to.position.y + to.height);
if (connectionRight <= renderBounds.left || connectionLeft >= renderBounds.right || connectionBottom <= renderBounds.top || connectionTop >= renderBounds.bottom) return [];
return [{ connection, from, to }];
}), [collapsedBatchChildIds, connections, dragPreview, nodeById]);
}), [collapsedBatchChildIds, connections, dragPreview, nodeById, renderBounds]);
const configInputsById = useMemo(() => {
const map = new Map<string, NodeGenerationInput[]>();

View File

@@ -5,6 +5,7 @@ import { Box, Check, ChevronDown, Download, FileText, Image as ImageIcon, Link2,
import { WorkspaceState } from "@/components/layout/workspace-state";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
import { AssetLibraryPickerModal, type AssetLibraryPickerItem } from "@/components/assets/asset-library-picker-modal";
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
@@ -296,7 +297,7 @@ function ProjectAssetPreviewModal({ asset, personalAsset, onClose, onDownload, o
<Modal className="workspace-modal workspace-modal-wide library-modal project-asset-preview-modal" title={asset?.category === "character" ? "角色卡预览" : "资产预览"} open={Boolean(asset)} onCancel={onClose} footer={<div className="flex justify-end gap-2"><Button onClick={onClose}>关闭</Button>{asset?.category === "character" ? <Button onClick={onReplaceImage}>替换图片</Button> : null}{canDownload ? <Button type="primary" icon={<Download className="size-3.5" />} onClick={onDownload}>下载</Button> : null}</div>}>
{asset ? <div className="project-asset-preview-layout">
<div className={`project-asset-preview-stage ${previewClass}`}>
{asset.category === "character" ? characterCover ? <img src={resourceFileUrl(characterCover.resourceId)} alt={asset.title} className="project-asset-preview-media" /> : <div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div> : personalAsset?.kind === "video" ? <video src={personalAsset.data.url} controls className="project-asset-preview-media" /> : personalAsset?.kind === "audio" ? <audio src={personalAsset.data.url} controls className="project-asset-preview-audio" /> : personalAsset?.kind === "image" ? <img src={personalAsset.data.dataUrl || personalAsset.coverUrl} alt={asset.title} className="project-asset-preview-media" /> : personalAsset?.kind === "text" ? <p className="project-asset-preview-text">{personalAsset.data.content}</p> : <div className="grid min-h-48 place-items-center text-foreground/35"><MediaIcon kind={asset.mediaType} /></div>}
{asset.category === "character" ? characterCover ? <CachedResourceImage storageKey={`resource:${characterCover.resourceId}`} src={resourceFileUrl(characterCover.resourceId)} alt={asset.title} className="project-asset-preview-media" fallback={<div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div>} /> : <div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div> : personalAsset?.kind === "video" ? <video src={personalAsset.data.url} controls className="project-asset-preview-media" /> : personalAsset?.kind === "audio" ? <audio src={personalAsset.data.url} controls className="project-asset-preview-audio" /> : personalAsset?.kind === "image" ? <AssetMediaPreview asset={personalAsset} alt={asset.title} className="project-asset-preview-media" /> : personalAsset?.kind === "text" ? <p className="project-asset-preview-text">{personalAsset.data.content}</p> : <div className="grid min-h-48 place-items-center text-foreground/35"><MediaIcon kind={asset.mediaType} /></div>}
</div>
<aside className="project-asset-preview-details">
<div className="project-asset-preview-eyebrow">{asset.category === "character" ? "角色卡" : mediaLabel(asset.mediaType)}</div>

View File

@@ -2,6 +2,7 @@ import { Button, Popconfirm } from "antd";
import { Image as ImageIcon, Pencil, Sparkles, Trash2, UserRound, Volume2 } from "lucide-react";
import type { ReactNode } from "react";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { resourceFileUrl } from "@/services/api/resources";
import type { ProjectAsset } from "@/services/api/projects";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
@@ -30,7 +31,7 @@ export function ProjectCharacterCard({ asset, generating, removing, onOpen, onEd
<AssetLibraryCard className="project-character-card">
<AssetLibraryCardMedia className="relative aspect-[3/2] overflow-hidden bg-foreground/[.045]">
<button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看角色卡:${asset.title}`}>
{cover ? <img src={resourceFileUrl(cover.resourceId)} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-contain p-1" /> : <div className="grid h-full place-items-center"><span className="grid size-14 place-items-center rounded-lg border border-border/70 bg-background/75 text-foreground/24"><UserRound className="size-7" /></span></div>}
{cover ? <CachedResourceImage storageKey={`resource:${cover.resourceId}`} src={resourceFileUrl(cover.resourceId)} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-contain p-1" fallback={<div className="grid h-full place-items-center"><span className="grid size-14 place-items-center rounded-lg border border-border/70 bg-background/75 text-foreground/24"><UserRound className="size-7" /></span></div>} /> : <div className="grid h-full place-items-center"><span className="grid size-14 place-items-center rounded-lg border border-border/70 bg-background/75 text-foreground/24"><UserRound className="size-7" /></span></div>}
</button>
<div className="absolute inset-x-2 top-2 flex items-center justify-between gap-2">
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[var(--fs-micro)] font-medium text-white">角色卡 · v{character?.version || 1}</span>

View File

@@ -24,6 +24,7 @@ const RuntimePolicySettingsPage = lazy(() => import("@/pages/admin/settings/runt
const DrawingEngineSettingsPage = lazy(() => import("@/pages/admin/settings/drawing-engine-settings-page"));
const StorageSettingsPage = lazy(() => import("@/pages/admin/settings/storage-settings-page"));
const ResponseInterceptionSettingsPage = lazy(() => import("@/pages/admin/settings/response-interception-settings-page"));
const ThirdPartySettingsPage = lazy(() => import("@/pages/admin/settings/libtv-settings-page"));
const StoryboardPromptsPage = lazy(() => import("@/pages/admin/storyboard-prompts/storyboard-prompts-page"));
const UsersPage = lazy(() => import("@/pages/admin/users/users-page"));
const AssetsPage = lazy(loadAssetsPage);
@@ -149,6 +150,8 @@ export const router = createBrowserRouter([
{ path: "settings/email", element: deferred(<EmailSettingsPage />) },
{ path: "settings/storage", element: deferred(<StorageSettingsPage />) },
{ path: "settings/response-interception", element: deferred(<ResponseInterceptionSettingsPage />) },
{ path: "settings/third-party", element: deferred(<ThirdPartySettingsPage />) },
{ path: "settings/libtv", element: <Navigate to="/admin/settings/third-party" replace /> },
],
},
],

View File

@@ -0,0 +1,75 @@
import type { CanvasConnection } from "@/types/canvas";
import { apiClient, request } from "@/services/api/request";
export type AdminLibTVSetting = {
enabled: boolean;
hasToken: boolean;
updatedAt?: string;
};
export type LibTVImportIssue = { id?: string; name?: string; reason: string };
export type LibTVImportWarning = { id?: string; message: string };
export type LibTVImportNode = {
id: string;
type: "image" | "video";
title: string;
x: number;
y: number;
width: number;
height: number;
content: string;
prompt?: string;
model?: string;
naturalWidth?: number;
naturalHeight?: number;
durationMs?: number;
mimeType?: string;
status?: "idle" | "success" | "error";
errorDetails?: string;
metadata: {
provider: "libtv";
projectUuid: string;
nodeKey: string;
batchId: string;
sourceType?: string;
styleAssetUuid?: string;
styleVersionUuid?: string;
styleName?: string;
};
};
export type LibTVImportResult = {
batchId: string;
batchCreatedAt: string;
projectUuid: string;
projectName: string;
nodes: LibTVImportNode[];
connections: CanvasConnection[];
importedNodeCount: number;
importedConnectionCount: number;
skippedNodes: LibTVImportIssue[];
skippedConnections: LibTVImportIssue[];
warnings: LibTVImportWarning[];
multiResultNodeCount: number;
staleNodeCount: number;
reusedFailedNodeCount: number;
placeholderNodeCount: number;
convertedSpecialCount: number;
};
export function getAdminLibTVSetting() {
return request<{ setting: AdminLibTVSetting }>(apiClient.get("/admin/settings/libtv"));
}
export function updateAdminLibTVSetting(input: { enabled: boolean; token?: string; clearToken?: boolean }) {
return request<{ setting: AdminLibTVSetting }>(apiClient.patch("/admin/settings/libtv", input));
}
export function testAdminLibTV(uuid: string) {
return request<{ ok: boolean }>(apiClient.post("/admin/settings/libtv/test", { uuid }));
}
export function importLibTVCanvas(projectId: string, uuid: string) {
return request<LibTVImportResult>(apiClient.post(`/canvas-projects/${encodeURIComponent(projectId)}/import/libtv`, { uuid }));
}

View File

@@ -96,28 +96,20 @@ export type ModelCapability = "image" | "video" | "text" | "audio";
const CHANNEL_MODEL_SEPARATOR = "::";
const OPENAI_BASE_URL = "https://api.openai.com";
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com";
const LEGACY_DEFAULT_MODEL_NAMES = new Set(["gpt-image-2", "grok-imagine-video", "gpt-5.5", "gpt-4o-mini-tts"]);
export const defaultConfig: AiConfig = {
channelMode: "local",
baseUrl: OPENAI_BASE_URL,
apiKey: "",
apiFormat: "openai",
channels: [
{
id: "default",
name: "默认渠道",
baseUrl: OPENAI_BASE_URL,
allowLocalChannel: false,
apiKey: "",
apiFormat: "openai",
models: ["gpt-image-2", "grok-imagine-video", "gpt-5.5", "gpt-4o-mini-tts"],
},
],
model: "default::gpt-image-2",
imageModel: "default::gpt-image-2",
videoModel: "default::grok-imagine-video",
textModel: "default::gpt-5.5",
audioModel: "default::gpt-4o-mini-tts",
// 创作端模型目录只能来自后台公开逻辑模型和用户自定义渠道,不能内置供应商模型。
channels: [],
model: "",
imageModel: "",
videoModel: "",
textModel: "",
audioModel: "",
audioVoice: "alloy",
audioFormat: "mp3",
audioSpeed: "1",
@@ -127,11 +119,11 @@ export const defaultConfig: AiConfig = {
videoGenerateAudio: "true",
videoWatermark: "false",
systemPrompt: "",
models: ["default::gpt-image-2", "default::grok-imagine-video", "default::gpt-5.5", "default::gpt-4o-mini-tts"],
imageModels: ["default::gpt-image-2"],
videoModels: ["default::grok-imagine-video"],
textModels: ["default::gpt-5.5"],
audioModels: ["default::gpt-4o-mini-tts"],
models: [],
imageModels: [],
videoModels: [],
textModels: [],
audioModels: [],
quality: "auto",
size: "1:1",
transparentBackground: "false",
@@ -246,14 +238,17 @@ export function filterModelsByCapability(models: string[], capability?: ModelCap
}
export function selectableModelsByCapability(config: AiConfig, capability?: ModelCapability) {
if (!capability) return config.models;
return filterModelsByCapability(config.models, capability, config.channels);
// 选项目录只从当前有效渠道重建,不能信任旧快照里残留的 config.models。
// 这样旧版本内置模型、未绑定渠道的裸模型不会再次进入创作端。
const models = modelOptionsFromChannels(config.channels);
if (!capability) return models;
return filterModelsByCapability(models, capability, config.channels);
}
export function configuredModelMatchesCapability(config: AiConfig, model: string, capability?: ModelCapability) {
const normalized = normalizeModelOptionValue(model, config.channels);
if (!normalized || !config.models.includes(normalized)) return false;
return capability ? selectableModelsByCapability(config, capability).includes(normalized) : true;
if (!normalized) return false;
return selectableModelsByCapability(config, capability).includes(normalized);
}
function isAiConfigReady(config: AiConfig, model: string) {
@@ -327,7 +322,7 @@ export function normalizeConfigSnapshot(snapshot: ConfigStoreSnapshot | undefine
models,
model,
imageModel: normalizeSelectedModel(config.imageModel || model, channels, imageModels),
videoModel: normalizeSelectedModel(config.videoModel || "grok-imagine-video", channels, videoModels),
videoModel: normalizeSelectedModel(config.videoModel, channels, videoModels),
textModel: normalizeSelectedModel(config.textModel || model, channels, textModels),
audioModel: normalizeSelectedModel(config.audioModel || defaultConfig.audioModel, channels, audioModels),
audioVoice: config.audioVoice || defaultConfig.audioVoice,
@@ -555,8 +550,9 @@ function isEmptyDefaultChannel(channel: ModelChannel) {
const baseUrl = channel.baseUrl.trim().replace(/\/+$/, "");
const defaultBaseUrl = defaultConfig.baseUrl.trim().replace(/\/+$/, "");
if (baseUrl && baseUrl !== defaultBaseUrl) return false;
const defaultModels = new Set((defaultConfig.channels[0]?.models || []).map(modelOptionName));
return !channel.models.length || channel.models.every((model) => defaultModels.has(modelOptionName(model)));
// 只清理旧版本写入浏览器的无密钥“默认渠道”和内置模型;没有 API Key 但已填写自定义模型时仍保留,
// 让用户可以先保存模型目录再补充密钥,而不是把真实自定义配置误判为空。
return !channel.models.length || channel.models.every((model) => LEGACY_DEFAULT_MODEL_NAMES.has(modelOptionName(model)));
}
export function defaultBaseUrlForApiFormat(apiFormat: ApiCallFormat) {

View File

@@ -4269,7 +4269,7 @@
.assets-library-page .assets-library-grid > .asset-library-card > button:last-child { flex: 1 1 auto; }
.assets-library-page .asset-library-card > .library-card-media, .assets-library-page .library-create-cover { aspect-ratio: 4 / 3; flex: 0 0 auto; }
.assets-library-page .asset-library-card > .library-card-media > button:first-child { height: 100%; }
.assets-library-page .asset-library-card > .library-card-media > button:first-child > :where(img, video) { height: 100%; aspect-ratio: auto; }
.assets-library-page .asset-library-card > .library-card-media > button:first-child > :where(img, video, .cached-resource-image-shell) { height: 100%; aspect-ratio: auto; }
.assets-library-page .library-create-card { padding-bottom: var(--space-4); }
.project-assets-grid { align-items: start; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 18px; }
.project-assets-grid > .asset-library-card { min-height: 0; height: auto; }
@@ -8647,6 +8647,13 @@ body.app-spatial-overlays :where(.ant-modal-mask, .ant-drawer-mask) {
box-shadow: none !important;
}
/* Modal 关闭后会把焦点还给触发按钮;收窄该按钮的键盘焦点环,避免出现持续的粗灰边。 */
.canvas-topbar-import-button:focus-visible {
outline: 1px solid color-mix(in srgb, var(--foreground) 55%, transparent) !important;
outline-offset: 1px !important;
box-shadow: none !important;
}
:where([class*="card"], [class*="Card"]):focus:not(:focus-visible),
:where([class*="card"], [class*="Card"]):has(:focus:not(:focus-visible)) {
outline: none !important;
@@ -8742,8 +8749,9 @@ body.app-spatial-overlays :where(.ant-modal-mask, .ant-drawer-mask) {
.asset-picker-card.is-selected { box-shadow: inset 0 0 0 1px var(--library-selected-stroke); }
.asset-picker-card.is-disabled { cursor: not-allowed; opacity: .46; }
.asset-picker-card-media { position: relative; aspect-ratio: 4 / 3; overflow: hidden; background: var(--library-cover-surface); }
.asset-picker-card-media > img, .asset-picker-card-media > video { display: block; width: 100%; height: 100%; object-fit: cover; }
.asset-picker-card-media > img.is-contain { object-fit: contain; padding: 4px; }
.cached-resource-image-shell { display: grid; width: 100%; height: 100%; place-items: center; }
.asset-picker-card-media > img, .asset-picker-card-media > video, .asset-picker-card-media > .cached-resource-image-shell > img { display: block; width: 100%; height: 100%; object-fit: cover; }
.asset-picker-card-media > img.is-contain, .asset-picker-card-media > .cached-resource-image-shell > img.is-contain { object-fit: contain; padding: 4px; }
.asset-picker-card-check { position: absolute; top: 9px; right: 9px; display: grid; width: 22px; height: 22px; place-items: center; border: 1px solid rgba(255, 255, 255, .72); border-radius: var(--r-xs); background: rgba(0, 0, 0, .28); color: transparent; backdrop-filter: blur(5px); }
.asset-picker-card-check svg { width: 13px; height: 13px; }
.asset-picker-card.is-selected .asset-picker-card-check { border-color: var(--control-check-bg); background: var(--control-check-bg); color: var(--control-check-fg); }

View File

@@ -142,7 +142,18 @@ export type CanvasSkillSnapshot = {
};
export type CanvasNodeMetadata = {
importSource?: {
provider: "libtv";
projectUuid: string;
nodeKey: string;
batchId: string;
sourceType?: string;
styleAssetUuid?: string;
styleVersionUuid?: string;
styleName?: string;
};
content?: string;
previewContent?: string;
richText?: Record<string, unknown>;
composerContent?: string;
prompt?: string;