角色卡 · v{character?.version || 1}
diff --git a/web/src/router.tsx b/web/src/router.tsx
index bfca06238..056bcb75d 100644
--- a/web/src/router.tsx
+++ b/web/src/router.tsx
@@ -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() },
{ path: "settings/storage", element: deferred() },
{ path: "settings/response-interception", element: deferred() },
+ { path: "settings/third-party", element: deferred() },
+ { path: "settings/libtv", element: },
],
},
],
diff --git a/web/src/services/api/libtv.ts b/web/src/services/api/libtv.ts
new file mode 100644
index 000000000..d664ad2ad
--- /dev/null
+++ b/web/src/services/api/libtv.ts
@@ -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(apiClient.post(`/canvas-projects/${encodeURIComponent(projectId)}/import/libtv`, { uuid }));
+}
diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts
index be76725ee..6a13eff9c 100644
--- a/web/src/stores/use-config-store.ts
+++ b/web/src/stores/use-config-store.ts
@@ -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) {
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index 4a941968f..b15e6cbc3 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -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); }
diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts
index 43e9d5c0c..d971ff792 100644
--- a/web/src/types/canvas.ts
+++ b/web/src/types/canvas.ts
@@ -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;
composerContent?: string;
prompt?: string;
diff --git a/web/test/channel-model-catalog.test.ts b/web/test/channel-model-catalog.test.ts
index 9c6a833ca..3e76528cd 100644
--- a/web/test/channel-model-catalog.test.ts
+++ b/web/test/channel-model-catalog.test.ts
@@ -271,6 +271,46 @@ describe("public channel model catalog", () => {
expect(html).toContain("Omni Flash");
});
+ test("builds the creation catalog only from public backend models and user channels", () => {
+ const platform = createModelChannel({
+ id: "public-logical-models",
+ name: "平台模型",
+ scope: "system",
+ apiKey: "system",
+ models: ["frontend-image"],
+ modelCosts: [{ model: "frontend-image", capability: "image", billingMode: "fixed_request", unitPriceMicrocredits: 0 }],
+ });
+ const custom = createModelChannel({
+ id: "custom-channel",
+ name: "我的渠道",
+ baseUrl: "https://custom.example.com",
+ apiKey: "synthetic-test-key",
+ models: ["custom-image-v1"],
+ });
+ const normalized = normalizeConfigSnapshot({
+ config: {
+ ...defaultConfig,
+ channels: [
+ platform,
+ custom,
+ createModelChannel({ id: "default", name: "默认渠道", apiKey: "", models: ["gpt-image-2"] }),
+ ],
+ models: ["default::gpt-image-2", "ghost-image"],
+ imageModels: ["default::gpt-image-2", "ghost-image"],
+ imageModel: "default::gpt-image-2",
+ },
+ }).config;
+ const staleSnapshot = { ...normalized, models: [...normalized.models, "ghost-image"], imageModels: [...normalized.imageModels, "ghost-image"] };
+
+ expect(selectableModelsByCapability(staleSnapshot, "image")).toEqual([
+ "public-logical-models::frontend-image",
+ "custom-channel::custom-image-v1",
+ ]);
+ expect(staleSnapshot.channels.some((channel) => channel.id === "default")).toBe(false);
+ expect(selectableModelsByCapability(staleSnapshot, "image")).not.toContain("default::gpt-image-2");
+ expect(selectableModelsByCapability(staleSnapshot, "image")).not.toContain("ghost-image");
+ });
+
test("omits resolution_name for Omni and for auto instead of inventing 720p", async () => {
const bodies: Record[] = [];
axios.post = (async (_url: string, body: unknown) => {
diff --git a/web/test/libtv-import.test.ts b/web/test/libtv-import.test.ts
new file mode 100644
index 000000000..11033b4ce
--- /dev/null
+++ b/web/test/libtv-import.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test } from "bun:test";
+
+import { buildLibTVImagePreviewUrl, formatLibTVBatchTime, parseLibTVProjectUUID } from "../src/lib/canvas/libtv-import";
+
+describe("LibTV canvas import helpers", () => {
+ test("extracts projectId from LibTV share links", () => {
+ expect(parseLibTVProjectUUID("https://www.liblib.tv/canvas/share?spaceId=5858202&projectId=dda7684b670b4e57b6027f1334fe6056")).toBe("dda7684b670b4e57b6027f1334fe6056");
+ });
+
+ test("accepts direct UUIDs and rejects unrelated links", () => {
+ expect(parseLibTVProjectUUID("292A69D540774A9AB9872D8337C9FD0B")).toBe("292a69d540774a9ab9872d8337c9fd0b");
+ expect(parseLibTVProjectUUID("https://api.liblib.tv/api/canvas/project/detail?uuid=292a69d540774a9ab9872d8337c9fd0b")).toBe("292a69d540774a9ab9872d8337c9fd0b");
+ expect(parseLibTVProjectUUID("https://example.com/not-a-canvas")).toBe("");
+ });
+
+ test("formats the readable batch label to local yyyyMMddHHmm", () => {
+ const localTime = new Date(2026, 7, 19, 5, 2);
+ expect(formatLibTVBatchTime(localTime.toISOString())).toBe("202608190502");
+ });
+
+ test("uses a LibTV CDN thumbnail without changing the original media contract", () => {
+ const original = "https://libtv-res.liblib.art/path/example.png";
+ expect(buildLibTVImagePreviewUrl(original)).toBe("https://libtv-res.liblib.art/path/example.png?x-oss-process=image%2Fresize%2Cw_960");
+ expect(buildLibTVImagePreviewUrl("https://example.com/path/example.png")).toBe("https://example.com/path/example.png");
+ });
+});