diff --git a/application/engine/services/autopilot_daemon.py b/application/engine/services/autopilot_daemon.py
index 66f5d72b7..e0a748205 100644
--- a/application/engine/services/autopilot_daemon.py
+++ b/application/engine/services/autopilot_daemon.py
@@ -710,7 +710,7 @@ async def _handle_writing(self, novel: Novel):
style_summary=bundle["style_summary"],
voice_anchors=voice_anchors,
)
- cfg = GenerationConfig(max_tokens=3000, temperature=0.85)
+ cfg = GenerationConfig(max_tokens=int(tw * 1.5), temperature=0.85)
beat_content = await self._stream_llm_with_stop_watch(prompt, cfg, novel=novel)
else:
beat_content = await self._stream_one_beat(
diff --git a/application/workflows/auto_novel_generation_workflow.py b/application/workflows/auto_novel_generation_workflow.py
index 2cbf7e750..c9cd6d7f2 100644
--- a/application/workflows/auto_novel_generation_workflow.py
+++ b/application/workflows/auto_novel_generation_workflow.py
@@ -305,7 +305,8 @@ async def generate_chapter(
chapter_number: int,
outline: str,
scene_director: Optional[SceneDirectorAnalysis] = None,
- enable_beats: bool = True
+ enable_beats: bool = True,
+ target_words: int = 2500,
) -> GenerationResult:
"""生成章节(完整工作流)
@@ -352,7 +353,7 @@ async def generate_chapter(
beats = []
if enable_beats:
logger.info(" → 启用节拍模式,拆分大纲为微观节拍")
- beats = self.context_builder.magnify_outline_to_beats(chapter_number, outline)
+ beats = self.context_builder.magnify_outline_to_beats(chapter_number, outline, target_chapter_words=target_words)
logger.info(f" ✓ 已拆分为 {len(beats)} 个微观节拍")
# 根据是否使用节拍选择不同的生成策略
@@ -393,6 +394,7 @@ async def generate_chapter(
plot_tension=bundle["plot_tension"],
style_summary=bundle["style_summary"],
voice_anchors=bundle.get("voice_anchors") or "",
+ target_words=target_words,
)
logger.info(f" → 发送请求到 LLM (max_tokens={config.max_tokens}, temperature={config.temperature})")
llm_result = await self.llm_service.generate(prompt, config)
@@ -442,7 +444,8 @@ async def generate_chapter_stream(
chapter_number: int,
outline: str,
scene_director: Optional[SceneDirectorAnalysis] = None,
- enable_beats: bool = True
+ enable_beats: bool = True,
+ target_words: int = 2500,
) -> AsyncIterator[Dict[str, Any]]:
"""流式生成章节:阶段事件 + 正文 token 流 + 最终 done(含一致性报告)。
@@ -481,7 +484,7 @@ async def generate_chapter_stream(
beats = []
if enable_beats:
logger.info(" → 启用节拍模式,拆分大纲为微观节拍")
- beats = self.context_builder.magnify_outline_to_beats(chapter_number, outline)
+ beats = self.context_builder.magnify_outline_to_beats(chapter_number, outline, target_chapter_words=target_words)
logger.info(f" ✓ 已拆分为 {len(beats)} 个微观节拍")
# 发送节拍信息用于前端展示
@@ -543,6 +546,7 @@ async def generate_chapter_stream(
plot_tension=bundle["plot_tension"],
style_summary=bundle["style_summary"],
voice_anchors=bundle.get("voice_anchors") or "",
+ target_words=target_words,
)
logger.info(f" → 发送流式请求到 LLM")
@@ -744,6 +748,7 @@ def build_chapter_prompt(
beat_target_words: Optional[int] = None,
voice_anchors: str = "",
chapter_draft_so_far: str = "",
+ target_words: int = 2500,
) -> Prompt:
"""构建与 HTTP 单章 / 流式 / 托管按节拍写作一致的 Prompt(对外 API)。"""
return self._build_prompt(
@@ -774,6 +779,7 @@ def _build_prompt(
beat_target_words: Optional[int] = None,
voice_anchors: str = "",
chapter_draft_so_far: str = "",
+ target_words: int = 2500,
) -> Prompt:
"""构建 LLM 提示词
@@ -821,9 +827,9 @@ def _build_prompt(
prior_in_chapter = format_prior_draft_for_prompt(chapter_draft_so_far)
# 字数控制:硬性上限,超出将被截断
length_rule = (
- f"7. 【硬性字数上限】本段最多 {beat_target_words} 字,超出将被截断,请精炼叙述。"
+ f"7. 本段约 {beat_target_words} 字(本章分多节输出之一,勿写章节标题)"
if beat_target_words
- else ("7. 章节长度:3000-4000字" if not beat_mode else "7. 按下方节拍说明控制篇幅,勿写章节标题")
+ else (f"7. 章节长度:{target_words}字左右" if not beat_mode else "7. 按下方节拍说明控制篇幅,勿写章节标题")
)
beat_extra = ""
if beat_mode and beat_index is not None and total_beats is not None and total_beats > 0:
diff --git a/frontend/src/components/onboarding/NovelSetupGuide.vue b/frontend/src/components/onboarding/NovelSetupGuide.vue
index 630e8a640..248504099 100644
--- a/frontend/src/components/onboarding/NovelSetupGuide.vue
+++ b/frontend/src/components/onboarding/NovelSetupGuide.vue
@@ -99,8 +99,17 @@
-
- {{ charactersError }}
+
+
+ {{ charactersError }}
+
+
+
+
+ 刷新检查
+
+
+
与第 1 步相同,人物生成在后台跑 LLM;本步界面最长约 {{ WIZARD_STEP_TIMEOUT_SECONDS }} 秒,请耐心等待。超时或失败时可稍后在 Bible 中补全。
@@ -135,8 +144,17 @@
-
- {{ locationsError }}
+
+
+ {{ locationsError }}
+
+
+
+
+ 刷新检查
+
+
+
地图与地点同样依赖 LLM;本步界面最长约 {{ WIZARD_STEP_TIMEOUT_SECONDS }} 秒。若卡住请先确认 API 未报错,再于工作台重试生成。
@@ -491,11 +509,13 @@ const styleConventionDisplay = computed(() => styleConventionFromBible(bibleData
const generatingCharacters = ref(false)
const charactersGenerated = ref(false)
const charactersError = ref('')
+const retryingCharacters = ref(false)
// 第3步:生成地点
const generatingLocations = ref(false)
const locationsGenerated = ref(false)
const locationsError = ref('')
+const retryingLocations = ref(false)
/** 作废第 2/3 步后台轮询(关闭向导或重置时递增) */
const step2PollEpoch = ref(0)
@@ -855,6 +875,82 @@ function stopGenerationOnClose() {
generatingBible.value = false
}
+function triggerStepGeneration(step: number) {
+ if (step === 1 && !bibleGenerated.value) {
+ void startBibleGeneration()
+ } else if (step === 2 && !charactersGenerated.value) {
+ step2PollEpoch.value += 1
+ const epoch2 = step2PollEpoch.value
+ generatingCharacters.value = true
+ charactersError.value = ''
+ bibleApi.generateBible(props.novelId, 'characters').then(() => {
+ pollBibleUntil(
+ (b) => (b.characters?.length ?? 0) > 0,
+ {
+ isStale: () =>
+ step2PollEpoch.value !== epoch2 || currentStep.value !== 2 || !generatingCharacters.value,
+ watchBackendFailure: true,
+ onSuccess: () => {
+ generatingCharacters.value = false
+ charactersGenerated.value = true
+ },
+ onTimeout: () => {
+ generatingCharacters.value = false
+ charactersError.value = `等待人物生成超时(约 ${WIZARD_STEP_TIMEOUT_SECONDS} 秒)。后台可能仍在跑——请点击「刷新检查」重试。`
+ message.warning('人物生成超时')
+ },
+ onFatal: (msg) => {
+ generatingCharacters.value = false
+ charactersError.value = msg
+ message.error(msg)
+ },
+ },
+ )
+ }).catch((error: unknown) => {
+ console.error('Resume characters failed:', error)
+ generatingCharacters.value = false
+ charactersError.value = isLikelyTimeoutError(error)
+ ? '提交人物生成超时,请检查网络与 API 后再试。'
+ : formatApiError(error) || '人物生成启动失败'
+ })
+ } else if (step === 3 && !locationsGenerated.value) {
+ step3PollEpoch.value += 1
+ const epoch3 = step3PollEpoch.value
+ generatingLocations.value = true
+ locationsError.value = ''
+ bibleApi.generateBible(props.novelId, 'locations').then(() => {
+ pollBibleUntil(
+ (b) => (b.locations?.length ?? 0) > 0,
+ {
+ isStale: () =>
+ step3PollEpoch.value !== epoch3 || currentStep.value !== 3 || !generatingLocations.value,
+ watchBackendFailure: true,
+ onSuccess: () => {
+ generatingLocations.value = false
+ locationsGenerated.value = true
+ },
+ onTimeout: () => {
+ generatingLocations.value = false
+ locationsError.value = `等待地图生成超时(约 ${WIZARD_STEP_TIMEOUT_SECONDS} 秒)。请点击「刷新检查」重试。`
+ message.warning('地图生成超时')
+ },
+ onFatal: (msg) => {
+ generatingLocations.value = false
+ locationsError.value = msg
+ message.error(msg)
+ },
+ },
+ )
+ }).catch((error: unknown) => {
+ console.error('Resume locations failed:', error)
+ generatingLocations.value = false
+ locationsError.value = isLikelyTimeoutError(error)
+ ? '提交地图生成超时,请检查网络与 API 后再试。'
+ : formatApiError(error) || '地图生成启动失败'
+ })
+ }
+}
+
watch(
() => props.show,
async (val) => {
@@ -863,10 +959,7 @@ watch(
// 检查已有进度,确定从哪一步继续
const step = await detectWizardProgress()
currentStep.value = step
- // 只有在第 1 步且世界观未生成时才启动生成
- if (step === 1 && !bibleGenerated.value) {
- void startBibleGeneration()
- }
+ triggerStepGeneration(step)
} else {
stopGenerationOnClose()
}
@@ -878,9 +971,7 @@ onMounted(async () => {
resetWizardStateForOpen()
const step = await detectWizardProgress()
currentStep.value = step
- if (step === 1 && !bibleGenerated.value) {
- void startBibleGeneration()
- }
+ triggerStepGeneration(step)
}
})
@@ -985,6 +1076,108 @@ const handleNext = async () => {
}
}
+const retryCharacters = async () => {
+ retryingCharacters.value = true
+ charactersError.value = ''
+ try {
+ const bible = await bibleApi.getBible(props.novelId, { timeout: 30_000 })
+ bibleData.value = bible
+ if ((bible.characters?.length ?? 0) > 0) {
+ charactersGenerated.value = true
+ generatingCharacters.value = false
+ message.success('人物数据已就绪,后台早已生成完毕')
+ return
+ }
+ message.info('数据尚未生成,正在重新触发生成…')
+ step2PollEpoch.value += 1
+ const epoch2 = step2PollEpoch.value
+ generatingCharacters.value = true
+ charactersGenerated.value = false
+ await bibleApi.generateBible(props.novelId, 'characters')
+ pollBibleUntil(
+ (b) => (b.characters?.length ?? 0) > 0,
+ {
+ isStale: () =>
+ step2PollEpoch.value !== epoch2 || currentStep.value !== 2 || !generatingCharacters.value,
+ watchBackendFailure: true,
+ onSuccess: () => {
+ generatingCharacters.value = false
+ charactersGenerated.value = true
+ },
+ onTimeout: () => {
+ generatingCharacters.value = false
+ charactersError.value = `等待人物生成超时(约 ${WIZARD_STEP_TIMEOUT_SECONDS} 秒)。后台可能仍在跑——请到工作台 Bible 查看;若无数据可返回上一步再进入本步重试,或在 Bible 手动生成。`
+ message.warning('人物生成超时')
+ },
+ onFatal: (msg) => {
+ generatingCharacters.value = false
+ charactersError.value = msg
+ message.error(msg)
+ },
+ },
+ )
+ } catch (error: unknown) {
+ console.error('Retry characters failed:', error)
+ generatingCharacters.value = false
+ charactersError.value = isLikelyTimeoutError(error)
+ ? '刷新请求超时,请检查网络后再试。'
+ : formatApiError(error) || '刷新检查失败'
+ } finally {
+ retryingCharacters.value = false
+ }
+}
+
+const retryLocations = async () => {
+ retryingLocations.value = true
+ locationsError.value = ''
+ try {
+ const bible = await bibleApi.getBible(props.novelId, { timeout: 30_000 })
+ bibleData.value = bible
+ if ((bible.locations?.length ?? 0) > 0) {
+ locationsGenerated.value = true
+ generatingLocations.value = false
+ message.success('地点数据已就绪,后台早已生成完毕')
+ return
+ }
+ message.info('数据尚未生成,正在重新触发生成…')
+ step3PollEpoch.value += 1
+ const epoch3 = step3PollEpoch.value
+ generatingLocations.value = true
+ locationsGenerated.value = false
+ await bibleApi.generateBible(props.novelId, 'locations')
+ pollBibleUntil(
+ (b) => (b.locations?.length ?? 0) > 0,
+ {
+ isStale: () =>
+ step3PollEpoch.value !== epoch3 || currentStep.value !== 3 || !generatingLocations.value,
+ watchBackendFailure: true,
+ onSuccess: () => {
+ generatingLocations.value = false
+ locationsGenerated.value = true
+ },
+ onTimeout: () => {
+ generatingLocations.value = false
+ locationsError.value = `等待地图生成超时(约 ${WIZARD_STEP_TIMEOUT_SECONDS} 秒)。请到工作台 Bible 查看地点是否已写入,或稍后重试。`
+ message.warning('地图生成超时')
+ },
+ onFatal: (msg) => {
+ generatingLocations.value = false
+ locationsError.value = msg
+ message.error(msg)
+ },
+ },
+ )
+ } catch (error: unknown) {
+ console.error('Retry locations failed:', error)
+ generatingLocations.value = false
+ locationsError.value = isLikelyTimeoutError(error)
+ ? '刷新请求超时,请检查网络后再试。'
+ : formatApiError(error) || '刷新检查失败'
+ } finally {
+ retryingLocations.value = false
+ }
+}
+
const handleSkip = () => {
if (!confirm('确认退出向导?当前修改将不会保存。')) return
emit('skip')
diff --git a/frontend/src/components/stats/StatsTopBar.vue b/frontend/src/components/stats/StatsTopBar.vue
index 5bbf5a286..5131acb61 100644
--- a/frontend/src/components/stats/StatsTopBar.vue
+++ b/frontend/src/components/stats/StatsTopBar.vue
@@ -71,6 +71,13 @@
+
+
+