瀏覽代碼

merge: PR #47 支持省略第的数字章节写作指令

# Conflicts:
#	src-tauri/Cargo.lock
Mochocyang 1 月之前
父節點
當前提交
eb35c6c723

+ 0 - 1
.npmrc

@@ -1 +0,0 @@
-shamefully-hoist=true

+ 6 - 0
src-tauri/src/lib.rs

@@ -21,6 +21,11 @@ fn log_error(message: String, stack: String, component_stack: String) {
     }
 }
 
+#[tauri::command]
+fn log_diagnostic(message: String) {
+    eprintln!("[frontend-diagnostic] message: {}", message);
+}
+
 pub fn run() {
     tauri::Builder::default()
         .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
@@ -122,6 +127,7 @@ pub fn run() {
             commands::writing_wake_lock::release_writing_wake_lock,
             set_proxy_env,
             log_error,
+            log_diagnostic,
         ])
         .on_window_event(|window, event| {
             if let tauri::WindowEvent::CloseRequested { api, .. } = event {

+ 6 - 0
src-tauri/src/main.rs

@@ -23,6 +23,11 @@ fn log_error(message: String, stack: String, component_stack: String) {
     }
 }
 
+#[tauri::command]
+fn log_diagnostic(message: String) {
+    eprintln!("[frontend-diagnostic] message: {}", message);
+}
+
 fn main() {
     tauri::Builder::default()
         .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
@@ -124,6 +129,7 @@ fn main() {
             commands::writing_wake_lock::release_writing_wake_lock,
             set_proxy_env,
             log_error,
+            log_diagnostic,
         ])
         .on_window_event(|window, event| {
             if let tauri::WindowEvent::CloseRequested { api, .. } = event {

+ 19 - 0
src/lib/novel/task-router.spec.ts

@@ -61,6 +61,25 @@ describe("routeTask chapter generation", () => {
     }
   })
 
+  it("routes chapter write requests without 第 with high confidence", () => {
+    for (const [text, chapterNumber] of [["编写 11 章", 11], ["编写 229 章", 229], ["编写第 5 章", 5]] as const) {
+      const route = routeTask(text)
+
+      expect(route.intent).toBe("write_chapter")
+      expect(route.chapterNumber).toBe(chapterNumber)
+      expect(route.confidence).toBeGreaterThanOrEqual(0.5)
+    }
+  })
+
+  it("does not treat small bare numerals as chapter sequence numbers", () => {
+    for (const text of ["写三章", "编写 5 章", "编写 10 章"]) {
+      const route = routeTask(text)
+
+      expect(route.intent).toBe("general_chat")
+      expect(route.chapterNumber).toBeUndefined()
+    }
+  })
+
   it("keeps Chinese later chapter numbers even when the prompt mentions 开篇 hooks", () => {
     const route = routeTask("写第五章,开篇要有钩子")
 

+ 14 - 9
src/lib/novel/task-router.ts

@@ -50,14 +50,17 @@ interface IntentPattern {
 
 /** 章号:阿拉伯数字或中文数字(与 parseChineseChapterNumber 字符集对齐) */
 const CHAPTER_NUM_TOKEN = String.raw`(?:\d+|[一二三四五六七八九十百〇零两]+)`
-const CHAPTER_REF = String.raw`第\s*${CHAPTER_NUM_TOKEN}\s*章`
+// 无“第”格式仅接受大于 10 的阿拉伯数字,避免把“写 5 章”(数量)
+// 误判为“写第 5 章”(序号)。11 及以上更符合省略“第”的章节号表达。
+const BARE_CHAPTER_NUM_TOKEN = String.raw`(?:1[1-9]|[2-9]\d|[1-9]\d{2,})`
+const CHAPTER_REF = String.raw`(?:第\s*${CHAPTER_NUM_TOKEN}|${BARE_CHAPTER_NUM_TOKEN})\s*章`
 
 const INTENT_PATTERNS: IntentPattern[] = [
   {
     intent: "write_chapter",
     patterns: [
-      new RegExp(String.raw`^(写|生成|创作|撰写)\s*(${CHAPTER_REF}|新章节|下一章)`),
-      new RegExp(String.raw`^(开始|帮我)(写|创作|生成)\s*(${CHAPTER_REF}|章节)`),
+      new RegExp(String.raw`^(写|编写|生成|创作|撰写)\s*(${CHAPTER_REF}|新章节|下一章)`),
+      new RegExp(String.raw`^(开始|帮我)(写|编写|创作|生成)\s*(${CHAPTER_REF}|章节)`),
       new RegExp(String.raw`生成\s*(${CHAPTER_REF}|新的?一?章)`),
       new RegExp(String.raw`写\s*${CHAPTER_REF}`),
       new RegExp(String.raw`(根据|按照).*(${CHAPTER_REF}).*(章纲|大纲|细纲).*(生成|写|创作|撰写).*(正文|章节)?`),
@@ -221,6 +224,7 @@ const INTENT_PATTERNS: IntentPattern[] = [
 const CHAPTER_NUMBER_PATTERNS = [
   /第\s*(\d+)\s*章/,
   /第\s*([一二三四五六七八九十百〇零两]+)\s*章/,
+  new RegExp(String.raw`(${BARE_CHAPTER_NUM_TOKEN})\s*章`),
   /chapter\s*(\d+)/i,
   /ch\.?\s*(\d+)/i,
 ]
@@ -325,15 +329,16 @@ function hasNextChapterContinuationWording(text: string): boolean {
   return NEXT_CHAPTER_CONTINUATION_RE.test(text.replace(/\s+/g, ""))
 }
 
-// 全文出现明确的“第N章”(N>3)时,说明用户的目标章节并非开篇章节,
+// 全文出现明确的“第N章”或“N章”(N>3)时,说明用户的目标章节并非开篇章节,
 // 文本里顺带提到的“开篇/第一章”只是写作要求或剧情引用。
 function hasExplicitLaterChapterNumber(text: string): boolean {
   const compact = text.replace(/\s+/g, "")
-  const explicit = compact.match(/第(\d+|[一二三四五六七八九十百〇零两]+)章/)
-  if (!explicit?.[1]) return false
-  const num = /^\d+$/.test(explicit[1])
-    ? Number(explicit[1])
-    : parseChineseChapterNumber(explicit[1])
+  const explicit = compact.match(new RegExp(String.raw`(?:第(${CHAPTER_NUM_TOKEN})|(${BARE_CHAPTER_NUM_TOKEN}))章`))
+  const token = explicit?.[1] ?? explicit?.[2]
+  if (!token) return false
+  const num = /^\d+$/.test(token)
+    ? Number(token)
+    : parseChineseChapterNumber(token)
   return Number.isFinite(num) && num > 3
 }
 

+ 30 - 1
src/lib/reasoning-replay-debug.spec.ts

@@ -1,10 +1,22 @@
-import { describe, expect, it } from "vitest"
+import { afterEach, describe, expect, it, vi } from "vitest"
+
+const invokeMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined))
+
+vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }))
+vi.mock("./platform", () => ({ isTauri: () => true }))
+
 import {
   formatReasoningReplayRiskForError,
   isReasoningContentRequiredError,
+  logReasoningReplay,
   summarizeReasoningReplayRisk,
 } from "./reasoning-replay-debug"
 
+afterEach(() => {
+  invokeMock.mockClear()
+  vi.restoreAllMocks()
+})
+
 describe("reasoning replay debug", () => {
   it("detects the DeepSeek/Kimi reasoning_content 400 message", () => {
     expect(isReasoningContentRequiredError(
@@ -42,4 +54,21 @@ describe("reasoning replay debug", () => {
     expect(formatReasoningReplayRiskForError(summary)).toContain("missing=1")
     expect(formatReasoningReplayRiskForError(summary)).toContain("reasoning=MISSING")
   })
+
+  it("forwards collection diagnostics without using the frontend error channel", async () => {
+    vi.spyOn(console, "warn").mockImplementation(() => {})
+
+    logReasoningReplay("stream.collected", {
+      model: "deepseek/deepseek-v4-flash",
+      contentCharsEmitted: 499,
+      reasoningCharsObserved: 469,
+    })
+
+    await vi.waitFor(() => {
+      expect(invokeMock).toHaveBeenCalledWith("log_diagnostic", {
+        message: expect.stringContaining("[reasoning-replay] stream.collected"),
+      })
+    })
+    expect(invokeMock).not.toHaveBeenCalledWith("log_error", expect.anything())
+  })
 })

+ 1 - 3
src/lib/reasoning-replay-debug.ts

@@ -70,10 +70,8 @@ export function probeReasoningMessages(messages: ChatMessage[]): ReasoningMessag
 function emitToTauriTerminal(line: string): void {
   if (!isTauri()) return
   void import("@tauri-apps/api/core")
-    .then(({ invoke }) => invoke("log_error", {
+    .then(({ invoke }) => invoke("log_diagnostic", {
       message: line,
-      stack: "",
-      componentStack: "",
     }))
     .catch(() => {})
 }