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 }} + + + 与第 1 步相同,人物生成在后台跑 LLM;本步界面最长约 {{ WIZARD_STEP_TIMEOUT_SECONDS }} 秒,请耐心等待。超时或失败时可稍后在 Bible 中补全。 @@ -135,8 +144,17 @@
- - {{ 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 @@
+ +
+ + + +
+
@@ -95,6 +102,7 @@ const props = defineProps<{ defineEmits<{ 'open-settings': [] + 'open-wizard': [] }>() const message = useMessage() diff --git a/frontend/src/composables/useWorkbench.ts b/frontend/src/composables/useWorkbench.ts index 27da2b458..d66b26b6e 100644 --- a/frontend/src/composables/useWorkbench.ts +++ b/frontend/src/composables/useWorkbench.ts @@ -41,6 +41,8 @@ export function useWorkbench(options: UseWorkbenchOptions) { const bookTitle = ref('') const chapters = ref<{ id: number; number: number; title: string; word_count: number }[]>([]) const bookMeta = ref({}) + const targetChapters = ref(100) + const novelId = ref('') const pageLoading = ref(true) const currentChapterId = ref(null) const chapterContent = ref('') @@ -69,7 +71,8 @@ export function useWorkbench(options: UseWorkbenchOptions) { bookTitle.value = novelData.title || slug - // Map ChapterDTO[] to the format expected by the UI + novelId.value = novelData.id || slug + targetChapters.value = novelData.target_chapters || 100 chapters.value = chaptersData.map(ch => ({ id: ch.number, number: ch.number, @@ -182,6 +185,8 @@ export function useWorkbench(options: UseWorkbenchOptions) { biblePanelKey, pageLoading, bookMeta, + targetChapters, + novelId, currentJobId, currentChapterId, chapterContent, diff --git a/frontend/src/views/Workbench.vue b/frontend/src/views/Workbench.vue index 2438403ab..5de3a772d 100644 --- a/frontend/src/views/Workbench.vue +++ b/frontend/src/views/Workbench.vue @@ -1,6 +1,6 @@
- +
@@ -59,6 +59,17 @@ + +
@@ -75,6 +86,7 @@ import WorkArea from '../components/workbench/WorkArea.vue' import SettingsPanel from '../components/workbench/SettingsPanel.vue' import ActPlanningModal from '../components/workbench/ActPlanningModal.vue' import LLMSettingsModal from '../components/LLMSettingsModal.vue' +import NovelSetupGuide from '../components/onboarding/NovelSetupGuide.vue' const route = useRoute() const message = useMessage() @@ -118,6 +130,8 @@ const { biblePanelKey, pageLoading, bookMeta, + targetChapters, + novelId, currentJobId, currentChapterId, chapterContent, @@ -129,6 +143,8 @@ const { handleChapterSelect, } = useWorkbench({ slug }) +const showSetupWizard = ref(false) + const currentChapter = computed(() => { if (!currentChapterId.value) return null return chapters.value.find(ch => ch.id === currentChapterId.value) || null diff --git a/infrastructure/ai/providers/openai_provider.py b/infrastructure/ai/providers/openai_provider.py index a708a3c4e..13248073f 100644 --- a/infrastructure/ai/providers/openai_provider.py +++ b/infrastructure/ai/providers/openai_provider.py @@ -63,12 +63,12 @@ async def generate( if use_responses: try: return await self._generate_via_responses(prompt, config) - except (openai.NotFoundError, openai.BadRequestError, RuntimeError) as e: + except (openai.NotFoundError, openai.BadRequestError, openai.InternalServerError, RuntimeError) as e: logger.info(f"Responses API unsupported for {base_url}, falling back to chat completions: {str(e)}") self.__class__._fallback_to_chat_cache.add(base_url) except Exception as e: # 某些网关在路径错误时可能不抛严格的 404 而是抛出其他错误,如果消息含有明确路径错误也尝试降级 - if "404" in str(e) or "Not Found" in str(e) or "400" in str(e) or "Account invalid" in str(e) or "INVALID_ARGUMENT" in str(e): + if "404" in str(e) or "Not Found" in str(e) or "400" in str(e) or "500" in str(e) or "Internal Server Error" in str(e) or "Account invalid" in str(e) or "INVALID_ARGUMENT" in str(e): logger.info(f"Gateway returned error for Responses API ({base_url}), falling back: {str(e)}") self.__class__._fallback_to_chat_cache.add(base_url) else: @@ -123,11 +123,11 @@ async def stream_generate( if content: yield content return # 正常完成则结束 generator - except (openai.NotFoundError, openai.BadRequestError): + except (openai.NotFoundError, openai.BadRequestError, openai.InternalServerError): self.__class__._fallback_to_chat_cache.add(base_url) logger.info(f"Stream: Responses API unsupported for {base_url}, falling back.") except Exception as e: - if "404" in str(e) or "Not Found" in str(e) or "400" in str(e) or "Account invalid" in str(e) or "INVALID_ARGUMENT" in str(e): + if "404" in str(e) or "Not Found" in str(e) or "400" in str(e) or "500" in str(e) or "Internal Server Error" in str(e) or "Account invalid" in str(e) or "INVALID_ARGUMENT" in str(e): self.__class__._fallback_to_chat_cache.add(base_url) logger.info(f"Stream: Gateway returned error for Responses API ({base_url}), falling back.") else: diff --git a/interfaces/api/v1/core/settings.py b/interfaces/api/v1/core/settings.py index 8a5ca9041..e2a3a793f 100644 --- a/interfaces/api/v1/core/settings.py +++ b/interfaces/api/v1/core/settings.py @@ -172,7 +172,7 @@ def update_embedding_config(body: EmbeddingConfigUpdate): use_gpu=body.use_gpu, model_path=body.model_path, ) - return updated.to_api_dict() + return updated.to_dict() @embedding_router.post("/fetch-models") diff --git a/interfaces/api/v1/engine/generation.py b/interfaces/api/v1/engine/generation.py index e6250ac52..0dbecc1df 100644 --- a/interfaces/api/v1/engine/generation.py +++ b/interfaces/api/v1/engine/generation.py @@ -226,7 +226,8 @@ class HostedWriteStreamRequest(BaseModel): async def generate_chapter_stream( novel_id: str, request: GenerateChapterRequest, - workflow: AutoNovelGenerationWorkflow = Depends(get_auto_workflow) + workflow: AutoNovelGenerationWorkflow = Depends(get_auto_workflow), + novel_service=Depends(get_novel_service), ): """流式生成章节(SSE) @@ -240,6 +241,9 @@ async def generate_chapter_stream( logger.info(f" 章节号: {request.chapter_number}") logger.info(f" 大纲长度: {len(request.outline)} 字符") + novel = novel_service.get_novel(novel_id) + target_words = getattr(novel, "target_words_per_chapter", 2500) or 2500 + async def event_gen(): # 转换 scene_director_result 为 SceneDirectorAnalysis(如果提供) scene_director = None @@ -250,7 +254,8 @@ async def event_gen(): novel_id=novel_id, chapter_number=request.chapter_number, outline=request.outline, - scene_director=scene_director + scene_director=scene_director, + target_words=target_words, ): yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n" diff --git a/tests/unit/application/workflows/test_auto_novel_generation_workflow.py b/tests/unit/application/workflows/test_auto_novel_generation_workflow.py index 799fd0183..d3cd9a7b1 100644 --- a/tests/unit/application/workflows/test_auto_novel_generation_workflow.py +++ b/tests/unit/application/workflows/test_auto_novel_generation_workflow.py @@ -34,7 +34,7 @@ def mock_context_builder(): "total": 9250 } } - # 不再需要 estimate_tokens 方法 + builder.magnify_outline_to_beats.return_value = [] return builder @@ -571,12 +571,17 @@ async def test_generate_chapter_injects_fingerprint_summary( # 验证 LLM 被调用 assert mock_llm_service.generate.called - # 获取传递给 LLM 的 prompt - call_args = mock_llm_service.generate.call_args - prompt = call_args[0][0] - - # 验证 prompt 包含风格指纹摘要 - assert "形容词密度" in prompt.system or "平均句长" in prompt.system + found_fingerprint = False + for call in mock_llm_service.generate.call_args_list: + args = call[0] + if args: + prompt = args[0] + else: + prompt = call[1].get('prompt', '') + if prompt and ("形容词密度" in getattr(prompt, 'system', str(prompt)) or "平均句长" in getattr(prompt, 'system', str(prompt))): + found_fingerprint = True + break + assert found_fingerprint, "未在 LLM prompt 中找到风格指纹摘要" # 验证指纹仓储被调用 mock_fingerprint_repo.get_by_novel.assert_called_once_with("novel-1", pov_character_id=None) @@ -645,4 +650,4 @@ async def test_generate_chapter_stream_includes_style_warnings( assert "style_warnings" in done_event assert len(done_event["style_warnings"]) == 1 assert done_event["style_warnings"][0]["pattern"] == "熊熊系列" - assert done_event["style_warnings"][0]["text"] == "熊熊烈火" + assert done_event["style_warnings"][0]["text"] == "熊熊烈火" \ No newline at end of file