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
2 changes: 1 addition & 1 deletion application/engine/services/autopilot_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
18 changes: 12 additions & 6 deletions application/workflows/auto_novel_generation_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""生成章节(完整工作流)

Expand Down Expand Up @@ -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)} 个微观节拍")

# 根据是否使用节拍选择不同的生成策略
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(含一致性报告)。

Expand Down Expand Up @@ -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)} 个微观节拍")

# 发送节拍信息用于前端展示
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 提示词

Expand Down Expand Up @@ -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:
Expand Down
215 changes: 204 additions & 11 deletions frontend/src/components/onboarding/NovelSetupGuide.vue
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,17 @@

<!-- Step 2: Generate Characters -->
<div v-else-if="currentStep === 2" class="step-panel">
<n-alert v-if="charactersError" type="error" style="margin-bottom: 16px; width: 100%">
{{ charactersError }}
<n-alert v-if="charactersError" type="warning" style="margin-bottom: 16px; width: 100%">
<template #header>
<span>{{ charactersError }}</span>
</template>
<template #footer>
<n-space justify="end">
<n-button size="small" type="primary" :loading="retryingCharacters" @click="retryCharacters">
刷新检查
</n-button>
</n-space>
</template>
</n-alert>
<n-alert type="info" class="wizard-hint-alert" style="margin-bottom: 16px; width: 100%">
与第 1 步相同,人物生成在后台跑 LLM;本步界面最长约 {{ WIZARD_STEP_TIMEOUT_SECONDS }} 秒,请耐心等待。超时或失败时可稍后在 Bible 中补全。
Expand Down Expand Up @@ -135,8 +144,17 @@

<!-- Step 3: Generate Locations -->
<div v-else-if="currentStep === 3" class="step-panel">
<n-alert v-if="locationsError" type="error" style="margin-bottom: 16px; width: 100%">
{{ locationsError }}
<n-alert v-if="locationsError" type="warning" style="margin-bottom: 16px; width: 100%">
<template #header>
<span>{{ locationsError }}</span>
</template>
<template #footer>
<n-space justify="end">
<n-button size="small" type="primary" :loading="retryingLocations" @click="retryLocations">
刷新检查
</n-button>
</n-space>
</template>
</n-alert>
<n-alert type="info" class="wizard-hint-alert" style="margin-bottom: 16px; width: 100%">
地图与地点同样依赖 LLM;本步界面最长约 {{ WIZARD_STEP_TIMEOUT_SECONDS }} 秒。若卡住请先确认 API 未报错,再于工作台重试生成。
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) => {
Expand All @@ -863,10 +959,7 @@ watch(
// 检查已有进度,确定从哪一步继续
const step = await detectWizardProgress()
currentStep.value = step
// 只有在第 1 步且世界观未生成时才启动生成
if (step === 1 && !bibleGenerated.value) {
void startBibleGeneration()
}
triggerStepGeneration(step)
} else {
stopGenerationOnClose()
}
Expand All @@ -878,9 +971,7 @@ onMounted(async () => {
resetWizardStateForOpen()
const step = await detectWizardProgress()
currentStep.value = step
if (step === 1 && !bibleGenerated.value) {
void startBibleGeneration()
}
triggerStepGeneration(step)
}
})

Expand Down Expand Up @@ -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')
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/components/stats/StatsTopBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@
</div>
</n-dropdown>

<!-- 设置向导按钮 -->
<div class="action-trigger" @click="$emit('open-wizard')" role="button" aria-label="打开设置向导" title="新书设置向导">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>
</svg>
</div>
Comment on lines +75 to +79
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make the new wizard trigger keyboard-accessible.

The new interactive control is mouse-only right now. Keyboard users can’t reliably trigger it.

Suggested fix
-      <div class="action-trigger" `@click`="$emit('open-wizard')" role="button" aria-label="打开设置向导" title="新书设置向导">
+      <div
+        class="action-trigger"
+        role="button"
+        tabindex="0"
+        aria-label="打开设置向导"
+        title="新书设置向导"
+        `@click`="$emit('open-wizard')"
+        `@keydown.enter.prevent`="$emit('open-wizard')"
+        `@keydown.space.prevent`="$emit('open-wizard')"
+      >
         <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18">
           <path fill="currentColor" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/>
         </svg>
       </div>
+.action-trigger:focus-visible {
+  outline: 2px solid rgba(255, 255, 255, 0.55);
+  outline-offset: 2px;
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/stats/StatsTopBar.vue` around lines 75 - 79, The
clickable div in StatsTopBar.vue (the element with class "action-trigger" and
`@click`="$emit('open-wizard')") is not keyboard-accessible; add tabindex="0" and
keyboard handlers so Enter and Space also trigger the same action (either via
`@keydown.enter` and `@keydown.space` that call $emit('open-wizard') or by adding an
onKeydown method that emits and prevents default for Space), keep role="button"
and aria-label, and ensure the handler prevents default for Space to avoid page
scrolling.


<!-- 设置按钮 -->
<div class="settings-trigger" @click="$emit('open-settings')" role="button" aria-label="打开设置">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18">
Expand All @@ -95,6 +102,7 @@ const props = defineProps<{

defineEmits<{
'open-settings': []
'open-wizard': []
}>()

const message = useMessage()
Expand Down
Loading
Loading