feat(*): 创作工作台 - 完善画布生成、模型定价与创建流程

This commit is contained in:
ddcat666
2026-10-10 04:40:20 +08:00
parent 508cad2b4e
commit 611949d6cc
39 changed files with 735 additions and 242 deletions

View File

@@ -439,7 +439,7 @@ export function getAdminFeatureAvailability() {
}
export function updateAdminFeatureAvailability(
features: Partial<Pick<FeatureAvailability, "welcomeEnabled" | "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers">>,
features: Partial<Pick<FeatureAvailability, "welcomeEnabled" | "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers" | "inspirationSourcesVisible">>,
) {
return http.patch<{ features: FeatureAvailability }>("/admin/settings/features", features);
}

View File

@@ -0,0 +1,26 @@
import { addSkill, getSkill, listAddedSkills, type Skill } from "@/services/api/skills";
/** 新画布 Agent 的单一默认技能;已有画布的会话选择不由此常量覆盖。 */
export const DEFAULT_CANVAS_AGENT_SKILL_ID = "yingce-short-drama-workflow";
export type AddedSkillsResult = { skills: Skill[] };
/**
* 确保默认技能既存在于用户技能库,也能作为本轮 Agent 的已选技能使用。
* 先读已安装列表,只有首次需要时才写入用户技能状态。
*/
export async function ensureDefaultCanvasAgentSkill(dependencies: {
listAdded?: typeof listAddedSkills;
get?: typeof getSkill;
add?: typeof addSkill;
} = {}): Promise<AddedSkillsResult> {
const list = dependencies.listAdded || listAddedSkills;
const get = dependencies.get || getSkill;
const add = dependencies.add || addSkill;
const added = await list();
if (added.skills.some((skill) => skill.skillId === DEFAULT_CANVAS_AGENT_SKILL_ID && skill.isAdded)) return added;
const detail = await get(DEFAULT_CANVAS_AGENT_SKILL_ID);
if (!detail.skill.isAdded) await add(DEFAULT_CANVAS_AGENT_SKILL_ID);
return list();
}

View File

@@ -403,7 +403,9 @@ export function installRemoteUserDataAutoSync() {
if (state.projects === previous.projects) return;
const before = new Map(previous.projects.map((project) => [project.id, project]));
const changed = state.projects.filter((project) => !sameCanvasContent(before.get(project.id), project));
if (!changed.length && state.projects.length === previous.projects.length) return;
// Viewport writes are local persistence only. They must never enqueue a
// cloud sync, even while the local persistence queue is being flushed.
if (!changed.length) return;
if (remoteUserDataPhase === "ready") {
for (const project of changed) {
if (sameCanvasContent(acknowledgedProjects.get(project.id), project)) continue;
@@ -704,13 +706,20 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
});
}
export async function saveRemoteUserDataNow(input?: string | readonly string[] | { force?: boolean; repairMissingResources?: boolean }) {
const projectId = typeof input === "string" || Array.isArray(input) ? (input as string | readonly string[]) : undefined;
const options = input && typeof input === "object" && !Array.isArray(input) ? (input as { force?: boolean; repairMissingResources?: boolean }) : {};
export async function saveRemoteUserDataNow(input?: string | readonly string[] | { projectId?: string | readonly string[]; force?: boolean; repairMissingResources?: boolean }) {
const projectId = typeof input === "string" || Array.isArray(input)
? (input as string | readonly string[])
: input && typeof input === "object" && !Array.isArray(input)
? (input as { projectId?: string | readonly string[] }).projectId
: undefined;
const options = input && typeof input === "object" && !Array.isArray(input)
? (input as { projectId?: string | readonly string[]; force?: boolean; repairMissingResources?: boolean })
: {};
const epoch = sessionEpoch;
if (!activeRemoteUserId) throw new Error("云端同步会话尚未建立,当前内容已保留在本地");
requireRemoteUserDataBaseline();
const assertNoConflict = () => {
if (options.force) return;
const ids = projectId === undefined ? useCanvasStore.getState().projects.map((project) => project.id) : typeof projectId === "string" ? [projectId] : projectId;
if (ids.some((id) => useSyncProgressStore.getState().syncingProjects[id]?.phase === "conflict")) {
throw new ApiError("云端画布已有更新,请保留本地草稿并加载最新版本", { status: 409 });
@@ -757,7 +766,12 @@ export async function saveRemoteUserDataNow(input?: string | readonly string[] |
* 修复媒体与素材的绑定,再按素材先于画布的顺序保存。
* 素材修复可采用远端素材基线;画布始终携带原始 revision,不能绕过版本校验。
*/
export async function forceOverwriteRemoteCanvasSync(): Promise<CanvasAssetRebindResult> {
export async function overwriteRemoteCanvasProject(projectId: string) {
if (!projectId.trim()) throw new Error("缺少画布 ID,无法覆盖云端");
await saveRemoteUserDataNow({ projectId, force: true });
}
export async function forceOverwriteRemoteCanvasSync(projectId?: string): Promise<CanvasAssetRebindResult> {
const epoch = sessionEpoch;
if (!activeRemoteUserId) throw new Error("尚未建立云端同步会话,请登录后重试");
requireRemoteUserDataBaseline();
@@ -765,11 +779,11 @@ export async function forceOverwriteRemoteCanvasSync(): Promise<CanvasAssetRebin
const rebind = await withRemoteUserDataSyncExclusive(async () => {
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存");
requireRemoteUserDataBaseline();
await repairMissingCanvasVideoPreviews();
const selectedProjectIds = projectId === undefined ? undefined : new Set([projectId]);
await repairMissingCanvasVideoPreviews(selectedProjectIds);
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存");
const projects = useCanvasStore.getState().projects;
// 服务端素材记录是 guard 实际校验的事实;本地缓存可能落后,须先取回再判定绑定一致性。
const claimedIds = [...collectCanvasMediaAssetIds(projects)];
const claimedIds = [...collectCanvasMediaAssetIds(selectedProjectIds ? projects.filter((project) => selectedProjectIds.has(project.id)) : projects)];
const remoteAssets: Asset[] = [];
for (let offset = 0; offset < claimedIds.length; offset += 100) {
const { assets } = await getRemoteAssetsByIds(claimedIds.slice(offset, offset + 100));
@@ -778,15 +792,15 @@ export async function forceOverwriteRemoteCanvasSync(): Promise<CanvasAssetRebin
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存");
const remoteById = new Map(remoteAssets.map((asset) => [asset.id, asset]));
const merged = [...remoteAssets, ...useAssetStore.getState().assets.filter((asset) => !remoteById.has(asset.id))];
const result = rebindInconsistentCanvasAssets(parseAssetRecordList(merged));
const result = rebindInconsistentCanvasAssets(parseAssetRecordList(merged), selectedProjectIds);
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
return result;
});
await saveRemoteUserDataNow({ force: true, repairMissingResources: true });
await saveRemoteUserDataNow({ projectId, force: true, repairMissingResources: true });
return rebind;
}
async function drainRemoteUserDataChanges(options: { force?: boolean; repairMissingResources?: boolean } = {}) {
async function drainRemoteUserDataChanges(options: { projectId?: string | readonly string[]; force?: boolean; repairMissingResources?: boolean } = {}) {
const uploaded = new Map<string, string>();
do {
syncQueued = false;
@@ -794,20 +808,37 @@ async function drainRemoteUserDataChanges(options: { force?: boolean; repairMiss
} while (syncQueued);
}
async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: { force?: boolean; repairMissingResources?: boolean } = {}) {
async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: { projectId?: string | readonly string[]; force?: boolean; repairMissingResources?: boolean } = {}) {
// 中央兜底:任何调用方只要把持久媒体写进画布,提交前都会先补齐素材记录与 assetId。
// 页面级入口仍主动入库,以便立即反馈;这里负责阻止遗漏入口形成远端幽灵资源。
const projectsBeforeRepair = useCanvasStore.getState().projects;
const selectedProjectIds = options.projectId === undefined
? undefined
: new Set(typeof options.projectId === "string" ? [options.projectId] : options.projectId);
const syncProjectIds = selectedProjectIds === undefined
? undefined
: new Set([...selectedProjectIds].filter((id) => projectsBeforeRepair.some((project) => project.id === id)));
const changedProjectIds = new Set(
useCanvasStore
.getState()
.projects.filter((project) => !sameCanvasContent(acknowledgedProjects.get(project.id), project))
projectsBeforeRepair
.filter((project) => (syncProjectIds === undefined || syncProjectIds.has(project.id)) && !sameCanvasContent(acknowledgedProjects.get(project.id), project))
.map((project) => project.id),
);
repairMissingCanvasAssets(changedProjectIds, incrementalSession);
const currentProjects = useCanvasStore.getState().projects;
const currentAssets = useAssetStore.getState().assets;
const dirtyProjects = currentProjects.filter((project) => !sameCanvasContent(acknowledgedProjects.get(project.id), project) && useSyncProgressStore.getState().syncingProjects[project.id]?.phase !== "conflict");
const dirtyAssets = currentAssets.filter((asset) => !sameEntitySnapshot(acknowledgedAssets.get(asset.id), asset));
const dirtyProjects = currentProjects.filter((project) => {
const dirty = !sameCanvasContent(acknowledgedProjects.get(project.id), project);
const conflicted = useSyncProgressStore.getState().syncingProjects[project.id]?.phase === "conflict";
const selected = selectedProjectIds === undefined || selectedProjectIds.has(project.id);
return selected && dirty && (options.force || !conflicted);
});
const scopedAssetIds = options.force && syncProjectIds
? new Set(collectCanvasMediaAssetIds(currentProjects.filter((project) => syncProjectIds.has(project.id))))
: undefined;
const dirtyAssets = currentAssets.filter((asset) => {
const dirty = !sameEntitySnapshot(acknowledgedAssets.get(asset.id), asset);
return dirty && (scopedAssetIds === undefined || scopedAssetIds.has(asset.id));
});
if (!dirtyProjects.length && !dirtyAssets.length) return;
const adoptedAssetIds = new Set<string>();
@@ -1006,6 +1037,17 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: {
saved = true;
continue;
}
if (options.force) {
const remoteHash = await canvasContentHash(remote);
const forced = { ...current, revision: remote.revision, remoteContentHash: remoteHash };
acknowledgedProjects.set(source.id, { ...remote, viewport: current.viewport, remoteContentHash: remoteHash });
verifiedProjects.add(source.id);
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === source.id ? forced : project)) }));
await flushCanvasStorePersistence();
candidate = forced;
useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "reconciling", message: "正在用本地版本覆盖云端" });
continue;
}
const baseline = acknowledgedProjects.get(source.id);
if (!baseline) throw error;
let rebased: CanvasProject;
@@ -1077,7 +1119,7 @@ function sameEntitySnapshot<T>(acknowledged: T | undefined, current: T) {
function sanitizeCanvasProjectForRemoteSync<T>(project: T): T {
if (!project || typeof project !== "object") return project;
const clone = { ...(project as Record<string, unknown>) };
const { viewport: _viewport, ...clone } = project as Record<string, unknown>;
if (Array.isArray(clone.chatSessions)) {
clone.chatSessions = clone.chatSessions.map((session) => {
if (!session || typeof session !== "object") return session;