feat(*): 创作工作台 - 完善媒体上传预览与连线交互并修复任务章节及日志筛选
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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("模型没有返回可用结果");
|
||||
|
||||
Reference in New Issue
Block a user