feat(*): 创作工作台 - 完善媒体上传预览与连线交互并修复任务章节及日志筛选

This commit is contained in:
ddCat
2026-09-08 17:26:34 +08:00
parent 95b004ec0f
commit 068a8daea6
56 changed files with 3316 additions and 274 deletions

View File

@@ -54,7 +54,6 @@ import { SkillRuntimePicker, useSkillRuntimeCatalog } from "@/components/skills/
import { ModelPicker } from "@/components/model-picker";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { resolveProjectCanvasStyle } from "@/components/canvas/canvas-style-picker-modal";
import { normalizeCharacterName } from "@/lib/canvas/canvas-character-reference";
import { decodeNovelText, splitTextIntoChapters } from "@/lib/canvas/canvas-document";
import { navigateToSettings } from "@/lib/settings-navigation";
import {
@@ -73,7 +72,7 @@ import { listGenerationTasks, queryGenerationTask, type GenerationTask } from "@
import { formatCount, formatTime, statusLabel, type ProjectDetailViewProps } from "./shared";
import { chapterStoryboardAssets, chapterStoryboardCharacters, chapterStoryboardReplaceImpact, storyboardRowsToProjectShots } from "./chapter-storyboard-production";
import { chapterCharactersFromGenerationTask, chapterStoryboardFromGenerationTask, chapterTaskIdentity, extractChapterCharacters, generateChapterStoryboard } from "./project-chapter-ai";
import { chapterAssetsFromGenerationTask, chapterStoryboardFromGenerationTask, chapterTaskIdentity, extractChapterAssets, generateChapterStoryboard } from "./project-chapter-ai";
const CHAPTER_ROW_HEIGHT = 62;
const MAX_NOVEL_IMPORT_CHAPTERS = 2500;
@@ -349,14 +348,15 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
const markChapterOperationCompleted = (unitId: string, kind: ChapterOperationKind) => {
setCompletedChapterOperations((current) => ({ ...current, [chapterOperationKey(unitId, kind)]: true }));
};
const storeExtractedCharacters = async (unitId: string, characters: Awaited<ReturnType<typeof extractChapterCharacters>>) => {
const knownNames = new Set([
...detail.assetCandidates.filter((candidate) => candidate.category === "character").map((candidate) => normalizeCharacterName(candidate.name)),
...detail.assets.filter((asset) => asset.category === "character").map((asset) => normalizeCharacterName(asset.title)),
]);
const fresh = characters.filter((character) => ![character.name, ...character.aliases].map(normalizeCharacterName).some((name) => knownNames.has(name)));
const created = fresh.length
? await createProjectAssetCandidates(detail.project.id, fresh.map((character) => ({ unitId, name: character.name, category: "character", details: { ...character } })), "chapter_character_extract")
const storeExtractedAssets = async (unitId: string, assets: Awaited<ReturnType<typeof extractChapterAssets>>) => {
// 去重由服务端按分类、名称及别名统一完成,避免使用页面分页快照漏判。
const candidates = [
...assets.characters.map((asset) => ({ unitId, name: asset.name, category: "character" as const, details: { ...asset } })),
...assets.scenes.map((asset) => ({ unitId, name: asset.name, category: "environment" as const, details: { ...asset } })),
...assets.props.map((asset) => ({ unitId, name: asset.name, category: "prop" as const, details: { ...asset } })),
];
const created = candidates.length
? await createProjectAssetCandidates(detail.project.id, candidates, "chapter_character_extract")
: { candidates: [] };
markChapterOperationCompleted(unitId, "characters");
refreshProject();
@@ -380,16 +380,16 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
operationUnitId = input.chapterId;
setCharacterExtractOpen(false);
beginChapterOperation(operationUnitId, "characters");
message.info("角色提取任务已开始,可继续编辑或切换章节");
const characters = await extractChapterCharacters(input, { onTaskUpdate: (task) => updateChapterOperation(operationUnitId, "characters", task) });
const freshCount = await storeExtractedCharacters(operationUnitId, characters);
message.info("角色、场景与道具提取任务已开始,可继续编辑或切换章节");
const assets = await extractChapterAssets(input, { onTaskUpdate: (task) => updateChapterOperation(operationUnitId, "characters", task) });
const freshCount = await storeExtractedAssets(operationUnitId, assets);
if (!freshCount) {
message.info("本章角色已存在于待确认列表中");
message.info("没有新增资产:提取为空或资产已存在");
return;
}
message.success(`已提取 ${freshCount} 个角色,请到项目资产确认`);
message.success(`已提取 ${freshCount} 个角色、场景或道具,请到项目资产确认`);
} catch (error) {
message.error(error instanceof Error ? error.message : "角色提取失败");
message.error(error instanceof Error ? error.message : "章节资产提取失败");
} finally {
if (operationUnitId) finishChapterOperation(operationUnitId, "characters");
}
@@ -477,8 +477,8 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
recoveringTaskIdsRef.current.add(task.id);
void queryGenerationTask(task.id).then(async (completedTask) => {
if (kind === "characters") {
await storeExtractedCharacters(taskChapterId, chapterCharactersFromGenerationTask(completedTask));
message.success("已恢复刷新前完成的角色提取结果");
await storeExtractedAssets(taskChapterId, chapterAssetsFromGenerationTask(completedTask));
message.success("已恢复刷新前完成的章节资产提取结果");
} else {
await storeGeneratedStoryboard(taskChapterId, chapterStoryboardFromGenerationTask(completedTask).rows);
message.success("已恢复刷新前完成的章节分镜");
@@ -599,7 +599,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/38"><span>{dirty ? "有未保存修改" : `保存于 ${formatTime(selectedUnit.updatedAt)}`}</span><span>·</span><span>{formatCount(wordCount)} 字</span><span>·</span><span>{chapterCanvasCount(selectedUnit.id)} 个画布</span></div>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1.5">
<Button size="small" icon={<UsersRound className="size-3.5" />} disabled={!selectedUnit || dirty || Boolean(characterOperation)} loading={Boolean(characterOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setCharacterExtractOpen(true); }} aria-label={characterOperation ? `提取角色,已运行 ${formatOperationElapsed(characterOperation.startedAt, operationNow)}` : "提取角色"}>{characterOperation ? `提取角色(已运行${formatOperationElapsed(characterOperation.startedAt, operationNow)})` : charactersGenerated ? "提取角色(已生成)" : "提取角色"}</Button>
<Button size="small" icon={<UsersRound className="size-3.5" />} disabled={!selectedUnit || dirty || Boolean(characterOperation)} loading={Boolean(characterOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setCharacterExtractOpen(true); }} aria-label={characterOperation ? `提取角色、场景与道具,已运行 ${formatOperationElapsed(characterOperation.startedAt, operationNow)}` : "提取角色、场景与道具"}>{characterOperation ? `提取资产(已运行${formatOperationElapsed(characterOperation.startedAt, operationNow)})` : charactersGenerated ? "提取资产(已生成)" : "提取角色、场景与道具"}</Button>
<Button size="small" type="primary" icon={<Clapperboard className="size-3.5" />} disabled={!selectedUnit || dirty || Boolean(storyboardOperation)} loading={Boolean(storyboardOperation)} onClick={() => { setSelectedTextModel(effectiveConfig.textModel || effectiveConfig.model || effectiveConfig.textModels[0] || ""); setSelectedSkillIds([]); setStoryboardOpen(true); }} aria-label={storyboardOperation ? `生成到分镜制作,已运行 ${formatOperationElapsed(storyboardOperation.startedAt, operationNow)}` : "生成到分镜制作"}>{storyboardOperation ? `生成到分镜制作(已运行${formatOperationElapsed(storyboardOperation.startedAt, operationNow)})` : storyboardGenerated ? "生成到分镜制作(已生成)" : "生成到分镜制作"}</Button>
<Button size="small" type={dirty ? "primary" : "default"} icon={dirty ? <Save className="size-3.5" /> : <Check className="size-3.5" />} disabled={!selectedUnit || !dirty || !draftTitle.trim() || saveMutation.isPending} loading={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{dirty ? "保存" : "已保存"}</Button>
</div>
@@ -613,7 +613,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
</section>
<CreateChapterModal open={createOpen} onClose={() => setCreateOpen(false)} loading={createMutation.isPending} onSubmit={(values) => createMutation.mutate(values)} />
<ImportNovelModal open={importOpen} loading={importMutation.isPending} onClose={() => setImportOpen(false)} onImport={(chapters) => importMutation.mutate(chapters)} />
<Modal title="提取章节角色" open={characterExtractOpen} width={500} okText="开始提取" cancelText="取消" okButtonProps={{ disabled: !selectedTextModel }} onCancel={() => setCharacterExtractOpen(false)} onOk={() => void extractCharacters()} styles={{ body: { paddingTop: 12 } }}>
<Modal title="提取章节角色、场景与道具" open={characterExtractOpen} width={500} okText="开始提取" cancelText="取消" okButtonProps={{ disabled: !selectedTextModel }} onCancel={() => setCharacterExtractOpen(false)} onOk={() => void extractCharacters()} styles={{ body: { paddingTop: 12 } }}>
<div className="grid gap-4">
<div className="rounded-lg border border-border/70 bg-foreground/[.018] px-3 py-2.5">
<div className="text-[var(--fs-tiny)] text-foreground/42">当前章节</div>
@@ -622,7 +622,7 @@ export default function ProjectChaptersView({ detail, refreshProject }: ProjectD
</div>
<label className="block">
<span className="mb-1.5 block text-xs font-medium text-foreground/68">文本模型</span>
<ModelPicker config={effectiveConfig} capability="text" value={selectedTextModel} onChange={setSelectedTextModel} fullWidth placeholder="选择用于提取角色的文本模型" showSelectedPrice={false} onMissingConfig={() => navigateToSettings({ continueCreation: true })} />
<ModelPicker config={effectiveConfig} capability="text" value={selectedTextModel} onChange={setSelectedTextModel} fullWidth placeholder="选择用于提取角色、场景与道具的文本模型" showSelectedPrice={false} onMissingConfig={() => navigateToSettings({ continueCreation: true })} />
</label>
</div>
</Modal>

View File

@@ -1,6 +1,7 @@
import { runBackendCanvasGenerationTask } from "@/lib/canvas/canvas-project-generation";
import { storyboardRowsFromTask } from "@/lib/canvas/canvas-project-domain";
import { parseCharacterBreakdown, type CharacterBreakdown } from "@/lib/canvas/canvas-character-reference";
import { parseChapterAssetBreakdown, type ChapterAssetBreakdown } from "@/lib/canvas/chapter-asset-breakdown";
import { parseCharacterBreakdown } from "@/lib/canvas/canvas-character-reference";
import { backendProviderConfig, parseBackendGenerationResult } from "@/services/api/generation-task";
import { createGenerationTask, waitForGenerationTask, type GenerationTask } from "@/services/api/task-center";
import type { Skill } from "@/services/api/skills";
@@ -38,24 +39,28 @@ export function chapterTaskIdentity(task: GenerationTask): { chapterId: string;
return null;
}
export function chapterCharactersFromGenerationTask(task: GenerationTask) {
export function chapterAssetsFromGenerationTask(task: GenerationTask) {
const result = parseBackendGenerationResult(task);
if (!result.text?.trim()) throw new Error("模型没有返回可用的角色提取结果");
return parseCharacterBreakdown(result.text);
if (!result.text?.trim()) throw new Error("模型没有返回可用的章节资产提取结果");
// 历史角色任务的输出契约只有角色;仅按明确的模板操作恢复,不能给新任务缺字段兜底。
if (generationTaskMetadata(task.inputJson).promptTemplateOperation === "character_extract") {
return { characters: parseCharacterBreakdown(result.text), scenes: [], props: [] };
}
return parseChapterAssetBreakdown(result.text);
}
export function chapterStoryboardFromGenerationTask(task: GenerationTask) {
return storyboardRowsFromTask(task);
}
export async function extractChapterCharacters(input: ChapterAnalysisInput, options?: ChapterTaskOptions): Promise<CharacterBreakdown[]> {
export async function extractChapterAssets(input: ChapterAnalysisInput, options?: ChapterTaskOptions): Promise<ChapterAssetBreakdown> {
const result = await runProjectTextTask(input, "chapter_character_breakdown", {
项目名称: input.projectName,
章节名称: input.chapterTitle,
项目画风: input.projectStyle || "项目尚未指定画风,保持视觉描述中性、可执行。",
章节正文: input.sourceText,
}, options);
return parseCharacterBreakdown(result);
return parseChapterAssetBreakdown(result);
}
type ChapterStoryboardGenerationInput = {
@@ -118,9 +123,9 @@ async function runProjectTextTask(input: ChapterAnalysisInput, operation: string
projectId: input.projectId,
nodeId: `${operation}:${input.chapterId}`,
mode: "text",
prompt: "使用当前启用的角色卡提取模板。",
prompt: "使用当前启用的章节角色、场景与道具提取模板。",
config: { ...input.config, model },
metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation, promptTemplateOperation: "character_extract", promptTemplateVariables },
metadata: { domainProjectId: input.projectId, chapterId: input.chapterId, operation, promptTemplateOperation: "chapter_assets_extract", promptTemplateVariables },
onTaskCreated: options?.onTaskUpdate,
});
if (!result.text?.trim()) throw new Error("模型没有返回可用结果");