Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,10 @@

## 最新更新

### 2026-08-16
### 2026-08-21

- 自动导演发现局部章节计划失配时,会自动调整后续未生成章节的安排并继续创作,已保存正文和手动内容保持不变。
- 从重规划检查点恢复时,会直接定位到首个未生成章节,避免再次处理已经完成的正文。
- Windows 桌面版更新至 `0.4.13`,包含本次自动导演连续创作与重规划恢复改进。
- 当章节缺少正文生成所需的执行合同字段时,任务中心会直接定位到对应章节处理入口,并提供“AI 补齐本章任务单”的处理路径。
- 章节执行合同的缺失、待复核和可恢复状态会被明确展示,不再只显示无法操作的通用失败提示。

完整历史更新见 [docs/releases/release-notes.md](./docs/releases/release-notes.md)。

Expand Down
7 changes: 6 additions & 1 deletion client/src/pages/novels/NovelEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1999,6 +1999,11 @@ export default function NovelEdit() {
if (!id || activeTab !== "structured" || !activeStructuredOutlineChapterId) {
return;
}
// A task recovery URL carries the exact chapter that needs repair. Do not
// let the latest director snapshot replace an explicit user target.
if (selectedChapterId && selectedChapterId !== activeStructuredOutlineChapterId) {
return;
}
const targetVolume = normalizedVolumeDraft.find((volume) => (
volume.chapters.some((chapter) => (
chapter.id === activeStructuredOutlineChapterId
Expand All @@ -2021,7 +2026,7 @@ export default function NovelEdit() {
selectedChapterId: activeStructuredOutlineChapterId,
selectedBeatKey: "all",
});
}, [activeStructuredOutlineChapterId, activeTab, id, normalizedVolumeDraft]);
}, [activeStructuredOutlineChapterId, activeTab, id, normalizedVolumeDraft, selectedChapterId]);

useEffect(() => {
if (!id) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ export default function StructuredChapterDetailCard(props: StructuredChapterDeta
onClick={() => onGenerateChapterDetailBundle(selectedVolume.id, selectedChapter.id)}
disabled={isGeneratingChapterDetail || locked}
>
{currentBundleRunning ? "当前章细化中..." : "细化当前章"}
{currentBundleRunning ? "正在补齐本章..." : "AI 补齐本章任务单"}
</AiButton>
) : null}
<Button size="sm" variant="outline" onClick={onToggleAdvanced}>
Expand Down
127 changes: 94 additions & 33 deletions client/src/pages/tasks/TaskCenterPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { DirectorContinuationMode } from "@ai-novel/shared/types/novelDirector";
import type { TaskKind, TaskStatus, UnifiedTaskStep } from "@ai-novel/shared/types/task";
Expand Down Expand Up @@ -40,6 +40,8 @@ import {
getTaskNoticeTitle,
getTaskQueueSeverity,
getTaskQueueTone,
isChapterExecutionContractFailure,
isChapterExecutionContractReviewFailure,
isTaskFailureQualityReminder,
isTaskMustHandle,
isTaskReplanRequired,
Expand Down Expand Up @@ -69,6 +71,7 @@ export default function TaskCenterPage() {
const [keyword, setKeyword] = useState("");
const [onlyAnomaly, setOnlyAnomaly] = useState(false);
const [sortMode, setSortMode] = useState<TaskSortMode>("updated_desc");
const taskRecordsRef = useRef<HTMLDivElement | null>(null);

const selectedKind = (searchParams.get("kind") as TaskKind | null) ?? null;
const selectedId = searchParams.get("id");
Expand Down Expand Up @@ -331,6 +334,13 @@ export default function TaskCenterPage() {
);
const chapterTitleRepairMutation = useDirectorChapterTitleRepair();
const selectedTaskFailureRepairRoute = selectedTaskChapterTitleWarning?.route ?? null;
const selectedTaskChapterContractRepairRoute = selectedTask
&& isChapterExecutionContractReviewFailure(selectedTask)
? selectedTask.sourceRoute
: null;
const selectedTaskHasExactChapterContractTarget = Boolean(
selectedTask && isChapterExecutionContractFailure(selectedTask),
);
const selectedTaskHasChapterTitleFailure = Boolean(
selectedTask
&& isChapterTitleDiversitySummary(
Expand Down Expand Up @@ -488,20 +498,38 @@ export default function TaskCenterPage() {
},
}
: null;
const failureAction = selectedTask && (selectedTaskChapterTitleWarning || selectedTaskFailureRepairRoute)
const failureAction = selectedTask && (
selectedTaskChapterTitleWarning
|| selectedTaskFailureRepairRoute
|| selectedTaskChapterContractRepairRoute
)
? {
label: selectedTaskChapterTitleWarning?.label ?? "快速修复章节标题",
label: selectedTaskChapterTitleWarning?.label
?? (selectedTaskChapterContractRepairRoute
? (selectedTaskHasExactChapterContractTarget ? "打开章节并 AI 补齐" : "前往章节规划")
: "快速修复章节标题"),
disabled: chapterTitleRepairMutation.isPending,
onClick: () => {
if (selectedTaskChapterTitleWarning) {
chapterTitleRepairMutation.startRepair(selectedTask);
return;
}
if (selectedTaskFailureRepairRoute) navigate(selectedTaskFailureRepairRoute);
if (selectedTaskFailureRepairRoute) {
navigate(selectedTaskFailureRepairRoute);
return;
}
if (selectedTaskChapterContractRepairRoute) navigate(selectedTaskChapterContractRepairRoute);
},
}
: null;

const focusTaskRecords = () => {
window.requestAnimationFrame(() => {
taskRecordsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
taskRecordsRef.current?.focus({ preventScroll: true });
});
};

const listErrorMessage = listQuery.error instanceof Error ? listQuery.error.message : listQuery.isError ? "任务列表读取失败,请重试。" : null;
const overviewErrorMessage = overviewQuery.error instanceof Error
? overviewQuery.error.message
Expand All @@ -527,6 +555,36 @@ export default function TaskCenterPage() {
&& !shouldOpenFailedFilter
&& !recoveryCandidatesQuery.isLoading;
const hasRecommendedAction = Boolean(recommendedTask || shouldOpenFailedFilter || shouldRetryRecoveryLookup);
const recommendedTaskNeedsChapterContractRepair = Boolean(
recommendedTask && isChapterExecutionContractReviewFailure(recommendedTask),
);
const focusRecommendedTask = () => {
if (!recommendedTask) {
setKind("");
setKeyword("");
if (shouldOpenFailedFilter) {
setStatus("failed");
setOnlyAnomaly(false);
} else if (shouldRetryRecoveryLookup) {
void recoveryCandidatesQuery.refetch();
}
return;
}
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("kind", recommendedTask.kind);
next.set("id", recommendedTask.id);
return next;
});
focusTaskRecords();
};
const openRecommendedTaskRepair = () => {
if (recommendedTaskNeedsChapterContractRepair && recommendedTask) {
navigate(recommendedTask.sourceRoute);
return;
}
focusRecommendedTask();
};

return (
<div className="space-y-5">
Expand All @@ -552,14 +610,16 @@ export default function TaskCenterPage() {
className="rounded-2xl border-transparent px-5 py-3 shadow-none"
icon={overviewErrorMessage ? RefreshCw : hasMustHandleTask ? ShieldAlert : Activity}
tone={overviewQuery.isLoading ? "info" : overviewErrorMessage ? "danger" : hasMustHandleTask ? "danger" : waitingActionCount > 0 ? "info" : qualityReminderCount > 0 ? "warning" : runningCount + queuedCount > 0 ? "info" : allRows.length > 0 ? "success" : "neutral"}
title={overviewQuery.isLoading ? "正在读取全局任务状态" : overviewErrorMessage ? "重新读取任务概览" : hasMustHandleTask ? "先查看必须处理的任务" : waitingActionCount > 0 ? "完成等待中的操作" : qualityReminderCount > 0 ? "查看质量提醒" : runningCount + queuedCount > 0 ? "关注正在推进的任务" : allRows.length > 0 ? "当前没有阻塞任务" : "任务会在执行后汇总到这里"}
title={overviewQuery.isLoading ? "正在读取全局任务状态" : overviewErrorMessage ? "重新读取任务概览" : recommendedTaskNeedsChapterContractRepair ? "当前章节需要先补齐执行合同" : hasMustHandleTask ? "先查看必须处理的任务" : waitingActionCount > 0 ? "完成等待中的操作" : qualityReminderCount > 0 ? "查看质量提醒" : runningCount + queuedCount > 0 ? "关注正在推进的任务" : allRows.length > 0 ? "当前没有阻塞任务" : "任务会在执行后汇总到这里"}
description={overviewQuery.isLoading
? "正在汇总执行、等待操作、失败和可恢复任务,请稍候。"
: overviewErrorMessage
? `${overviewErrorMessage} 当前不会据此判断是否存在阻塞任务。`
: hasMustHandleTask
? recoveryCandidatesQuery.isLoading && !recommendedBlockingTask && failedTaskCount === 0
? "正在定位可恢复任务;读取完成后会提供对应入口。"
: recommendedTaskNeedsChapterContractRepair
? "章节执行合同缺少正文生成所需字段。打开章节处理页后,选中提示中的章节,点击“AI 补齐本章任务单”;补齐章节目标、独占事件、结束态、下章入口态、冲突/揭露强度、目标字数和禁止事项后,再回到章节执行继续生成。"
: "阻塞状态可能影响对应来源流程;先查看原因和恢复位置,再决定恢复、重试或重规划。"
: waitingActionCount > 0
? "候选确认、章节批次继续等节点需要你的操作,但不代表任务发生故障。"
Expand All @@ -574,7 +634,9 @@ export default function TaskCenterPage() {
? "只重新读取任务概览,不会恢复、重试或取消任务。"
: !overviewQuery.isLoading && hasRecommendedAction
? recommendedTask
? "只定位到推荐任务,不会自动继续、重试或取消。"
? recommendedTaskNeedsChapterContractRepair
? "只打开章节处理页,不会自动生成、重试或修改内容。"
: "只定位到推荐任务,不会自动继续、重试或取消。"
: shouldOpenFailedFilter
? "只筛选失败任务,不会自动恢复、重试或取消任务。"
: "只重新读取恢复候选,不会自动执行恢复。"
Expand All @@ -584,32 +646,27 @@ export default function TaskCenterPage() {
重新读取
</Button>
) : !overviewQuery.isLoading && hasRecommendedAction ? (
<Button
type="button"
size="sm"
variant={hasMustHandleTask ? "destructive" : "outline"}
onClick={() => {
if (!recommendedTask) {
setKind("");
setKeyword("");
if (shouldOpenFailedFilter) {
setStatus("failed");
setOnlyAnomaly(false);
} else if (shouldRetryRecoveryLookup) {
void recoveryCandidatesQuery.refetch();
}
return;
}
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("kind", recommendedTask.kind);
next.set("id", recommendedTask.id);
return next;
});
}}
>
{shouldRetryRecoveryLookup ? "重新读取恢复任务" : hasMustHandleTask ? "查看需处理任务" : "查看推荐任务"}
</Button>
<div className="flex flex-wrap justify-end gap-2">
<Button
type="button"
size="sm"
variant={hasMustHandleTask ? "destructive" : "outline"}
onClick={openRecommendedTaskRepair}
>
{shouldRetryRecoveryLookup
? "重新读取恢复任务"
: recommendedTaskNeedsChapterContractRepair
? "打开章节处理页"
: hasMustHandleTask
? "查看需处理任务"
: "查看推荐任务"}
</Button>
{recommendedTaskNeedsChapterContractRepair ? (
<Button type="button" size="sm" variant="outline" onClick={focusRecommendedTask}>
查看任务详情
</Button>
) : null}
</div>
) : undefined}
/>

Expand All @@ -633,7 +690,11 @@ export default function TaskCenterPage() {
onSortModeChange={setSortMode}
/>

<div className="grid items-start gap-5 xl:grid-cols-[minmax(0,1.25fr)_minmax(380px,0.75fr)]">
<div
ref={taskRecordsRef}
tabIndex={-1}
className="grid items-start gap-5 outline-none xl:grid-cols-[minmax(0,1.25fr)_minmax(380px,0.75fr)]"
>
<TaskCenterListPanel
tasks={visibleRows}
selectedKind={selectedKind}
Expand Down
4 changes: 3 additions & 1 deletion client/src/pages/tasks/components/TaskCenterDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,9 @@ export default function TaskCenterDetailPanel(props: TaskCenterDetailPanelProps)
compact
tone="info"
title="导演任务操作入口"
description="继续、恢复、切换模型和推进策略请回到小说页面的执行详情处理;任务中心保留状态、取消、归档和来源入口。"
description={props.failureAction
? "按上方建议先处理当前章节;完成后可在小说页面继续推进。"
: "可打开小说页面查看当前阶段和下一步操作。"}
/>
) : null}

Expand Down
22 changes: 22 additions & 0 deletions client/src/pages/tasks/taskCenterUtils.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
getTaskNoticeTitle,
getTaskQueueLevelLabel,
getTaskQueueTone,
isChapterExecutionContractFailure,
isChapterExecutionContractReviewFailure,
isTaskMustHandle,
} from "./taskCenterUtils.ts";

Expand All @@ -27,6 +29,26 @@ test("task queue uses structured failure state as blocker", () => {
assert.equal(getTaskQueueLevelLabel(task), "任务失败");
});

test("chapter execution contract failure keeps a direct repair task blocking", () => {
const task = { ...baseTask, status: "failed", failureCode: "CHAPTER_EXECUTION_CONTRACT_INCOMPLETE" };
assert.equal(isChapterExecutionContractFailure(task), true);
assert.equal(isChapterExecutionContractReviewFailure(task), true);
assert.equal(getTaskQueueTone(task), "danger");
assert.equal(isTaskMustHandle(task), true);
});

test("legacy chapter sync failure remains actionable without parsing its error text", () => {
const task = {
...baseTask,
status: "failed",
failureCode: "CHAPTER_EXECUTION_CONTRACT_REVIEW_REQUIRED",
lastError: "任意历史错误文本",
};
assert.equal(isChapterExecutionContractReviewFailure(task), true);
assert.equal(getTaskQueueTone(task), "danger");
assert.equal(isTaskMustHandle(task), true);
});

test("task queue keeps a completed task notice as quality reminder", () => {
const task = { ...baseTask, status: "succeeded", noticeCode: "PIPELINE_QUALITY_REVIEW", noticeSummary: "待局部修复" };
assert.equal(getTaskQueueTone(task), "warning");
Expand Down
11 changes: 11 additions & 0 deletions client/src/pages/tasks/taskCenterUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ type TaskQueuePresentationInput = Pick<
const PIPELINE_QUALITY_REVIEW_CODE = "PIPELINE_QUALITY_REVIEW";
const PIPELINE_REPLAN_REQUIRED_CODE = "PIPELINE_REPLAN_REQUIRED";
const CHAPTER_TITLE_DIVERSITY_CODE = "CHAPTER_TITLE_DIVERSITY";
export const CHAPTER_EXECUTION_CONTRACT_INCOMPLETE_FAILURE_CODE = "CHAPTER_EXECUTION_CONTRACT_INCOMPLETE";
export const CHAPTER_EXECUTION_CONTRACT_REVIEW_FAILURE_CODE = "CHAPTER_EXECUTION_CONTRACT_REVIEW_REQUIRED";

export function isChapterExecutionContractFailure(task: TaskQueuePresentationInput): boolean {
return task.failureCode === CHAPTER_EXECUTION_CONTRACT_INCOMPLETE_FAILURE_CODE;
}

export function isChapterExecutionContractReviewFailure(task: TaskQueuePresentationInput): boolean {
return isChapterExecutionContractFailure(task)
|| task.failureCode === CHAPTER_EXECUTION_CONTRACT_REVIEW_FAILURE_CODE;
}

export function isTaskReplanRequired(task: TaskQueuePresentationInput): boolean {
return task.checkpointType === "replan_required"
Expand Down
8 changes: 8 additions & 0 deletions docs/releases/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

## 更新历史

### 2026-08-21(章节执行合同恢复)

- 当章节缺少正文生成所需的执行合同字段时,任务中心会直接定位到对应章节处理入口,并提供“AI 补齐本章任务单”的处理路径。
- 章节执行合同的缺失、待复核和可恢复状态会被明确展示,不再只显示无法操作的通用失败提示。

### 2026-08-20(自动导演创建稳定性)

- 自动导演创建页在暂时没有可选世界样本时也能保持稳定,停留填写开书想法或继续设置时不会因选择控件异常而白屏。
### 2026-08-16(自动导演连续创作)

- 自动导演发现局部章节计划失配时,会自动调整后续未生成章节的安排并继续创作;已保存正文、已确认章节和手动内容保持不变。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,17 @@

`/volumes/sync-chapters` 保留为兼容修复和诊断入口。它的首要职责是修复章节连接和补齐执行入口,不应成为新手主流程的必要步骤。

章节执行合同在生成场景卡前必须有目标字数。旧章节列表若尚未填写该字段,服务层应优先继承同卷距离最近的已设定字数;全卷都没有字数时,使用标准章节执行预算,并将结果随本次细化保存。用户不应因为历史规划缺少内部预算字段而遇到 500 或被要求先手工补数。

执行合同门禁失败时,失败状态必须以结构化字段保留 `novelId`、`volumeId`、`chapterId` 和章序;任务投影据此构造章节定位和补齐入口,不能从面向用户的错误文字反向解析章节。

## Failure Modes

- 如果规划章节有 `chapterId`,不得因为标题相同而误绑定到其他正式章节。
- 如果正式章节已经有正文,拆章重排或连接修复默认不得清空正文或重置执行状态。
- 如果旧数据没有 `chapterId` 且章序/标题无法可靠匹配,应创建新的正式章节并写回连接,而不是静默保持悬空规划。
- 如果执行合同质量门禁不通过,应阻断连接到章节执行区,并提示具体章节缺少的规划信息。
- 如果失败任务丢失了章节定位,只能提供节奏 / 拆章的兼容入口和清楚操作提示;不得用字符串匹配猜测目标章节。

## Related Modules

Expand Down
1 change: 1 addition & 0 deletions docs/wiki/product/task-center-role.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
4. 新手引导、普通按钮和页面文案应优先引导用户留在当前创作现场;除异常、恢复或历史查询外,不要把“打开运行记录”作为正常下一步。
5. 自动导演与章节质量债的主入口仍属于小说工作台和导演跟进;运行记录只提供事实查询与来源跳转,不重新裁决创作主状态。
6. 运行记录默认采用任务收件箱心智:先展示需要处理、等待操作和正在推进的任务,再展示当前动作与可执行入口。模型、Token、心跳、完整时间、检查点和细分步骤属于诊断信息,应在选中任务后按需展开,不能长期铺在列表中迫使新手读取内部运行字段。
7. 当任务保存了章节修复定位时,运行记录应提供直达对应章节的补齐入口;执行合同缺失时,入口要明确说明“AI 补齐本章任务单”。旧记录没有章节定位时,可直达节奏 / 拆章并提示用户按任务提示选择章节。运行记录只负责定位和交接,不在此处隐式重试、重规划或修改小说内容。

## Failure Modes

Expand Down
Loading