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
9 changes: 2 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,9 @@

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

### 2026-04-28
### 2026-04-29

AI 主驾的章节自动执行和恢复更稳了。继续任务时会按真实章节结果补跑最早未完成章节,等待审批后继续不会误跳过当前章;服务重启中断的自动导演也会自动尝试续跑,减少人工恢复和跳章风险。

- 章节执行区和节奏规划一致时会保留现有数据,但继续时会补跑范围内最早未执行章节。
- 等待审批的章节批次会按普通继续处理,失败后明确允许跳过审校阻断章时才会跳过当前章。
- 服务重启后,仍在排队或运行中的自动导演会自动进入恢复续跑。
- 质量修复低风险提醒和重规划建议仍会记录通知并继续推进,非 AI 主驾链路保留人工确认边界。
AI 主驾自动执行章节会按自动修复次数推进。某章完成允许的一次自动修复后,即使复审或伏笔同步还停留在当前章,系统也会记录提醒并继续下一章,不再因为当前章未再次达标而反复停住。章节流水线会同步更新章节完成或待修复状态,并继续按模型路由选择写作、审校、修复和事实提取模型。

## 功能预览
### 功能概览中的95%以上编写都是AI完成
Expand Down
6 changes: 5 additions & 1 deletion client/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,11 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
staleTime: 30_000,
refetchInterval: (query) => {
const overview = query.state.data?.data;
return (overview?.queuedCount ?? 0) > 0 || (overview?.runningCount ?? 0) > 0 ? 4000 : false;
return (overview?.queuedCount ?? 0) > 0
|| (overview?.runningCount ?? 0) > 0
|| (overview?.waitingApprovalCount ?? 0) > 0
? 4000
: false;
},
});

Expand Down
6 changes: 5 additions & 1 deletion client/src/pages/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@ export default function Home() {
staleTime: 30_000,
refetchInterval: (query) => {
const overview = query.state.data?.data;
return (overview?.queuedCount ?? 0) > 0 || (overview?.runningCount ?? 0) > 0 ? 4000 : false;
return (overview?.queuedCount ?? 0) > 0
|| (overview?.runningCount ?? 0) > 0
|| (overview?.waitingApprovalCount ?? 0) > 0
? 4000
: false;
},
});

Expand Down
8 changes: 8 additions & 0 deletions client/src/pages/novels/NovelList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getWorkflowBadge,
getWorkflowDescription,
isWorkflowRunningInBackground,
LIVE_TASK_STATUSES,
requiresCandidateSelection,
} from "@/lib/novelWorkflowTaskUi";
import { toast } from "@/components/ui/toast";
Expand Down Expand Up @@ -75,6 +76,13 @@ export default function NovelList() {
queryKey: queryKeys.novels.list(1, 100),
queryFn: () => getNovelList({ page: 1, limit: 100 }),
staleTime: 30_000,
refetchInterval: (query) => {
const novels = query.state.data?.data?.items ?? [];
return novels.some((novel) => {
const task = novel.latestAutoDirectorTask;
return Boolean(task && LIVE_TASK_STATUSES.has(task.status));
}) ? 4000 : false;
},
});

const deleteNovelMutation = useMutation({
Expand Down
55 changes: 55 additions & 0 deletions client/tests/autoRefreshContracts.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";

const homePage = readFileSync("client/src/pages/Home.tsx", "utf8");
const novelListPage = readFileSync("client/src/pages/novels/NovelList.tsx", "utf8");
const taskCenterPage = readFileSync("client/src/pages/tasks/TaskCenterPage.tsx", "utf8");
const sidebar = readFileSync("client/src/components/layout/Sidebar.tsx", "utf8");
const novelEditPage = readFileSync("client/src/pages/novels/NovelEdit.tsx", "utf8");

function getUseQueryBlock(source, queryKeySnippet) {
const queryKeyIndex = source.indexOf(queryKeySnippet);
assert.notEqual(queryKeyIndex, -1, `${queryKeySnippet} query should exist`);
const blockStart = source.lastIndexOf("useQuery({", queryKeyIndex);
assert.notEqual(blockStart, -1, `${queryKeySnippet} query should use useQuery object syntax`);

let depth = 0;
for (let index = blockStart; index < source.length; index += 1) {
if (source[index] === "{") {
depth += 1;
} else if (source[index] === "}") {
depth -= 1;
if (depth === 0) {
return source.slice(blockStart, index + 1);
}
}
}
throw new Error(`${queryKeySnippet} query block should be closed`);
}

test("novel list keeps auto-refreshing while auto director progress is active", () => {
const block = getUseQueryBlock(novelListPage, "queryKeys.novels.list(1, 100)");

assert.match(block, /refetchInterval:\s*\(query\)\s*=>/);
assert.match(block, /latestAutoDirectorTask/);
assert.match(block, /LIVE_TASK_STATUSES\.has\(task\.status\)/);
});

test("global task overview refresh includes waiting approval work", () => {
const homeTaskBlock = getUseQueryBlock(homePage, "queryKeys.tasks.overview");
const sidebarTaskBlock = getUseQueryBlock(sidebar, "queryKeys.tasks.overview");

assert.match(homeTaskBlock, /waitingApprovalCount/);
assert.match(sidebarTaskBlock, /waitingApprovalCount/);
});

test("task center and novel editor keep active auto director detail polling", () => {
const taskListBlock = getUseQueryBlock(taskCenterPage, "queryKeys.tasks.list(listParamsKey)");
const taskDetailBlock = getUseQueryBlock(taskCenterPage, "queryKeys.tasks.detail(selectedKind ?? \"none\", selectedId ?? \"none\")");
const autoDirectorBlock = getUseQueryBlock(novelEditPage, "queryKeys.novels.autoDirectorTask(id)");

assert.match(taskListBlock, /ACTIVE_STATUSES\.has\(item\.status\)/);
assert.match(taskDetailBlock, /ACTIVE_STATUSES\.has\(task\.status\)/);
assert.match(autoDirectorBlock, /task\.status === "waiting_approval"/);
});
20 changes: 17 additions & 3 deletions docs/releases/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,29 @@

## 更新历史

### 2026-04-29

- AI 主驾章节执行会按自动修复次数推进。某章完成允许的一次自动修复后,即使复审或伏笔同步还停留在当前章,系统也会把该章作为已处理章节记录提醒,并继续下一章,避免 `nextChapterOrder` 已经前进但流程仍卡在原章节。
- 章节流水线完成审校后会同步更新章节执行状态。通过审校的章节会标记为已完成,自动修复一次后仍低于质量阈值的章节会标记为待修复;AI 主驾自动继续时不会再把这类章节当成仍在生成中,从而反复回到同一章。
- AI 主驾自动执行章节时,如果某一章已经完成一次自动修复但仍低于质量阈值,系统会记录提醒并继续下一章,不会在同一章反复启动质量修复。
- AI 主驾进入章节自动执行时会按模型路由选择写作、审校、修复和事实提取模型,不再把导演任务里临时选择的模型一路带进章节流水线;任务详情也会按当前阶段显示最新路由模型,调整路由后不用重建任务即可看到变更,减少单一模型或渠道异常导致整条执行链失败。
- 章节细化、任务单生成等结构化调用会继续遵守模型路由里的结构化输出格式。即使流程内部已经带上了路由选中的模型,也不会丢掉 `json_object`、`json_schema` 或提示词 JSON 的配置偏好。

### 2026-04-28

- 自动导演继续章节执行时会按真实章节结果重新找最早未完成章节。章节执行区和节奏规划一致时不会清空现有数据,但如果第 5 章已完成、第 6 章未生成、第 7 章曾被误触发,重新继续会先补跑第 6 章,避免跳章。
- 等待审批的章节批次继续不会再误当成“跳过当前章继续”。系统会区分普通审批继续和失败后允许跳过审校阻断章的恢复动作,减少点击继续后漏掉当前章节的情况。
- 服务重启后,被重启中断的自动导演会自动尝试继续推进。仍在排队或运行中的自动导演会进入恢复续跑,不再默认停到人工恢复列表;等待审批、失败和取消的任务仍保留人工处理边界。
- 任务中心加载恢复候选任务时会直接读取候选摘要,不再等待服务启动后的自动恢复流程全部跑完。即使后台正在续跑被重启中断的自动导演,任务页面也能更快展示。
- 自动导演外层任务和章节执行流水线的恢复状态会保持一致。若章节流水线因服务重启进入需恢复状态,任务中心会把自动导演同步显示为可继续处理,不再长期挂成假的“运行中”。
- 继续自动导演时会优先恢复已关联的章节执行流水线。系统会接上被中断的章节生成、审校或修复任务,而不是因为外层状态仍是运行中就直接返回,减少继续后没有真实推进的情况。
- 首页和小说列表读取项目时不会再触发自动导演状态修复。自动导演任务很多或正在后台恢复时,小说卡片仍会显示最近导演摘要,但列表本身会保持轻量加载。
- 自动导演跟进中心、任务详情和小说页导演进度的普通刷新会保持只读。刷新列表、打开详情或页面轮询不会顺手触发状态修复;需要重新校验时仍可以通过复核、继续、重试等明确动作处理,减少刷新页面时出现长时间等待或网络失败提示。
- 小说列表、首页和任务导航会在自动导演排队、运行或等待审批时持续刷新。后台章节推进、审批卡点和导演进度变化会更快同步到页面,不需要频繁手动重新加载。
- AI 主驾执行章节时,质量修复后仍低于阈值的低风险提醒会记录通知并继续推进。系统会先完成本章的一次自动修复;如果仍未达标,会提醒用户关注结果,但不再把整条自动执行流程卡在质量修复检查点。
- AI 主驾遇到重规划建议时会记录提醒并继续后续章节,不会自动执行重规划,也不会因为重规划建议暂停整条流程。提醒内容会明确标记为“重规划提醒已记录”,方便用户后续回看需要人工处理的方向调整
- 企业微信、钉钉和自动导演跟进中心会区分“自动通过”和“重规划提醒”。重规划场景不再显示成系统已自动通过或已自动重规划,避免用户误判后续章节已经被重新规划过
- 非 AI 主驾的人工审核链路仍会保留重规划检查点。用户选择按阶段确认或手动继续时,重规划建议仍会停在待处理状态,方便先人工确认再推进
- 质量校验不再触发重规划。章节审阅、章节流水线和自动导演质量修复都不会因为审计建议生成 `replan_required` 卡点;历史任务里残留的重规划提醒会按普通质量提醒处理,避免后续章节被重规划检查点拦住
- 企业微信、钉钉和自动导演跟进中心不再把质量校验结果展示成重规划待处理。用户仍能看到质量修复提醒,但不会误判系统已经自动重规划或还需要先处理重规划才能继续
- 历史章节标题结构异常可以被识别为待处理提醒并进入恢复处理。新章节列表仍会通过生成要求引导标题更分散,但不会把标题框架问题作为硬阻断导致自动导演停住

### 2026-04-27

Expand Down
17 changes: 11 additions & 6 deletions server/src/llm/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,12 +204,17 @@ export async function resolveLLMClientOptions(
const hasExplicitProvider = provider != null;
const hasExplicitModel = options.model != null;
const shouldUseRouteProvider = !hasExplicitProvider && !hasExplicitModel;
const route = await resolveModel(options.taskType, {
...(shouldUseRouteProvider ? {} : { provider: resolvedProvider }),
...(options.model != null ? { model: options.model } : {}),
...(options.temperature != null ? { temperature: options.temperature } : {}),
...(options.maxTokens != null ? { maxTokens: options.maxTokens } : {}),
});
const route = await resolveModel(
options.taskType,
shouldUseRouteProvider
? undefined
: {
provider: resolvedProvider,
...(options.model != null ? { model: options.model } : {}),
...(options.temperature != null ? { temperature: options.temperature } : {}),
...(options.maxTokens != null ? { maxTokens: options.maxTokens } : {}),
},
);
if (shouldUseRouteProvider) {
resolvedProvider = route.provider;
}
Expand Down
4 changes: 2 additions & 2 deletions server/src/llm/structuredInvoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ async function resolveAttemptTarget(input: {
structuredStrategy: input.structuredStrategy,
executionMode: "plain",
});
const preferredStrategy = input.structuredStrategy ?? (route
const preferredStrategy = input.structuredStrategy ?? resolved.structuredStrategy ?? (route
&& resolved.provider === route.provider
&& resolved.model === route.model
? toStructuredOutputStrategy(route.structuredResponseFormat)
Expand Down Expand Up @@ -341,7 +341,7 @@ export async function invokeStructuredLlmDetailed<T>(input: StructuredInvokeInpu
model: input.model,
apiKey: input.apiKey,
baseURL: input.baseURL,
temperature: input.temperature ?? 0.3,
temperature: input.temperature,
maxTokens: input.maxTokens,
taskType: input.taskType ?? "planner",
requestProtocol: input.requestProtocol,
Expand Down
6 changes: 4 additions & 2 deletions server/src/routes/autoDirectorFollowUps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ router.get("/", validate({ query: listQuerySchema }), async (req, res, next) =>
router.get("/:taskId", validate({ params: taskParamsSchema }), async (req, res, next) => {
try {
const { taskId } = req.params as z.infer<typeof taskParamsSchema>;
const data = await followUpService.getDetail(taskId);
const data = await followUpService.getDetail(taskId, {
heal: false,
});
if (!data) {
res.status(404).json({
success: false,
Expand All @@ -136,7 +138,7 @@ router.get("/:taskId/revalidation", validate({ params: taskParamsSchema }), asyn
try {
const { taskId } = req.params as z.infer<typeof taskParamsSchema>;
const data = await followUpService.getDetail(taskId, {
heal: false,
heal: true,
});
if (!data) {
res.status(404).json({
Expand Down
3 changes: 1 addition & 2 deletions server/src/routes/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,8 @@ router.post("/recovery-candidates/:kind/:id/resume", validate({ params: recovery
router.get("/auto-director-follow-ups/:taskId", validate({ params: autoDirectorFollowUpParamsSchema }), async (req, res, next) => {
try {
const { taskId } = req.params as z.infer<typeof autoDirectorFollowUpParamsSchema>;
const readonly = req.query.revalidate === "true";
const data = await autoDirectorFollowUpService.getDetail(taskId, {
heal: !readonly,
heal: req.query.revalidate === "true",
});
if (!data) {
res.status(404).json({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { compactText } from "./characterResourceShared";
const HIGH_RISK_EVENTS = new Set<CharacterResourceEventType>(["lost", "consumed", "destroyed", "damaged"]);
const AUTO_DIRECTOR_RESOURCE_SOURCE_TYPES = new Set(["chapter_background_sync"]);

function isAiDriverResourceUpdate(proposal: StateChangeProposal): boolean {
return proposal.sourceType === "chapter_background_sync"
&& proposal.sourceStage === "ai_driver_chapter_execution";
}

function parsePayload(proposal: StateChangeProposal): CharacterResourceUpdatePayload | null {
const parsed = characterResourceUpdatePayloadSchema.safeParse(proposal.payload);
return parsed.success ? parsed.data : null;
Expand Down Expand Up @@ -48,6 +53,13 @@ export class CharacterResourceValidationService {
|| payload.statusAfter === "lost";

if (proposal.riskLevel === "high" || lowConfidence || highImpact) {
if (isAiDriverResourceUpdate(proposal)) {
return {
...proposal,
status: "committed",
validationNotes: proposal.validationNotes.concat("auto-committed AI-driver resource update"),
};
}
return {
...proposal,
status: "pending_review",
Expand Down
Loading