Browse Source

fix(character-save): 修复兜底草稿默认未选中导致弹窗无法保存

方案A:buildFallbackCharacterDraftsFromRequests 的 selected 从 false 改为 true,保证 AiChangeReview 弹窗 reviewItems 有内容,用户可勾选/取消并确认保存。方案B:放宽 splitByCharacterHeadings 识别规则,支持 ## 张三(男主)、## 张三 - 男主、## 角色设定:张三 等 AI 常见输出格式,减少需要兜底的情况。
Mochocyang 3 months ago
parent
commit
3957d796fd

+ 1 - 1
src/components/sources/outline-chat-panel.tsx

@@ -722,7 +722,7 @@ function buildFallbackCharacterDraftsFromRequests(
       roleType,
       fileName: request.fileName,
       content: request.content,
-      selected: false,
+      selected: true,
       confidence: "low",
     };
   });

+ 58 - 1
src/lib/novel/character-save-extractor.spec.ts

@@ -103,7 +103,7 @@ describe("character-save-extractor", () => {
     expect(volumeResult.errors.join(";")).toContain("未识别到可单独保存的角色")
   })
 
-  it("无角色前缀但标题是人名且附近有角色定位时生成高置信度草稿", () => {
+  it("无角色前缀但标题是人名且附近有角色定位时生成中置信度草稿", () => {
     const result = extractCharacterSaveDrafts([
       "## 林辰",
       "- 角色定位:男主",
@@ -116,10 +116,67 @@ describe("character-save-extractor", () => {
       roleType: "男主",
       fileName: "角色-男主-林辰.md",
       selected: true,
+      confidence: "medium",
+    })
+  })
+
+  it("支持新格式标题:姓名(角色)", () => {
+    const result = extractCharacterSaveDrafts([
+      "## 林辰(男主)",
+      "- 核心动机:守住家人",
+      "",
+      "## 苏晚(女主)",
+      "- 核心动机:查清真相",
+    ].join("\n"))
+
+    expect(result.drafts).toHaveLength(2)
+    expect(result.drafts[0]).toMatchObject({
+      characterName: "林辰",
+      roleType: "男主",
+      fileName: "角色-男主-林辰.md",
+      selected: true,
+      confidence: "high",
+    })
+    expect(result.drafts[1]).toMatchObject({
+      characterName: "苏晚",
+      roleType: "女主",
+      fileName: "角色-女主-苏晚.md",
+      selected: true,
+      confidence: "high",
+    })
+  })
+
+  it("支持新格式标题:姓名 - 角色", () => {
+    const result = extractCharacterSaveDrafts([
+      "## 林辰 - 男主",
+      "- 核心动机:守住家人",
+    ].join("\n"))
+
+    expect(result.drafts).toHaveLength(1)
+    expect(result.drafts[0]).toMatchObject({
+      characterName: "林辰",
+      roleType: "男主",
+      selected: true,
       confidence: "high",
     })
   })
 
+  it("支持新格式标题:角色设定:姓名", () => {
+    const result = extractCharacterSaveDrafts([
+      "## 角色设定:林辰",
+      "- 角色定位:男主",
+      "- 核心动机:守住家人",
+    ].join("\n"))
+
+    expect(result.drafts).toHaveLength(1)
+    expect(result.drafts[0]).toMatchObject({
+      characterName: "林辰",
+      roleType: "男主",
+      selected: true,
+      confidence: "medium",
+    })
+  })
+
   it("清理角色文件名中的非法字符", () => {
     expect(buildCharacterFileName("男主", "林:辰/一号")).toBe("角色-男主-林-辰-一号.md")
   })

+ 51 - 6
src/lib/novel/character-save-extractor.ts

@@ -21,6 +21,28 @@ const ROLE_PATTERN = /(男主|女主|男配|女配|反派|导师|盟友|配角|
 const NON_CHARACTER_HEADING_PATTERN =
   /^(?:人物设定|角色设定|人物小传|角色小传|人物关系|角色关系|群像|设定总览|世界观|卷纲|章纲|细纲|大纲|设定|背景|地图|力量体系|金手指|伏笔|组织|势力|时间线|剧情线|第.{1,12}卷(?:[::].*)?)$/
 
+function extractRoleFromTrailing(text: string): string | null {
+  // 支持 "张三(男主)"、"张三(男主)"、"张三 — 男主"、"张三 - 男主"、"张三 男主"
+  const parenMatch = text.match(/[((]\s*(男主|女主|男配|女配|反派|导师|盟友|配角|主角)\s*[))]/)
+  if (parenMatch) return parenMatch[1]
+  const tailMatch = text.match(/[\s—\-–]+\s*(男主|女主|男配|女配|反派|导师|盟友|配角|主角)\s*$/)
+  if (tailMatch) return tailMatch[1]
+  const endMatch = text.match(/\s+(男主|女主|男配|女配|反派|导师|盟友|配角|主角)\s*$/)
+  if (endMatch) return endMatch[1]
+  return null
+}
+
+function extractNameFromHeading(rawTitle: string): string {
+  // 去掉前缀 "角色设定"、"角色"、"人物小传" 等
+  let name = rawTitle.replace(/^角色(?:设定|小传|简介)?[::\-\s]*/, "")
+  name = name.replace(/^人物(?:设定|小传|简介)?[::\-\s]*/, "")
+  // 去掉尾部 role 标注
+  name = name.replace(/[((]\s*(?:男主|女主|男配|女配|反派|导师|盟友|配角|主角)\s*[))]/, "")
+  name = name.replace(/[\s—\-–]+\s*(?:男主|女主|男配|女配|反派|导师|盟友|配角|主角)\s*$/, "")
+  name = name.replace(/\s+(?:男主|女主|男配|女配|反派|导师|盟友|配角|主角)\s*$/, "")
+  return cleanFieldValue(name)
+}
+
 export function buildCharacterFileName(roleType: string, characterName: string): string {
   const role = sanitizeOutlineFileNamePart(roleType) || "角色"
   const name = sanitizeOutlineFileNamePart(characterName) || "未命名"
@@ -59,24 +81,47 @@ function createDraft(
 
 function splitByCharacterHeadings(content: string): CharacterSaveDraft[] {
   const lines = content.split(/\r?\n/)
-  const ranges: Array<{ start: number; end: number; roleType: string; name: string }> = []
+  const ranges: Array<{ start: number; end: number; roleType: string; name: string; confidence: CharacterSaveConfidence }> = []
 
   for (let index = 0; index < lines.length; index++) {
-    const match = lines[index].match(/^#{1,4}\s*(?:(男主|女主|男配|女配|反派|导师|盟友|配角|主角)[::\-\s]+)?(.{1,24})\s*$/)
+    // 匹配 ## 标题,捕获标题原文
+    const match = lines[index].match(/^#{1,4}\s+(.{1,32})\s*$/)
     if (!match) continue
 
-    const name = cleanFieldValue(match[2].replace(/^角色[-::\s]*/, ""))
+    const rawTitle = match[1]
+    // 优先尝试从标题前缀提取 "男主:/女主:/..." 旧格式
+    const prefixRoleMatch = rawTitle.match(/^(男主|女主|男配|女配|反派|导师|盟友|配角|主角)[::\-\s]+(.{1,24})$/)
+
+    let name: string
+    let roleType: string | null
+
+    if (prefixRoleMatch) {
+      // 旧格式:"## 男主:张三"
+      name = extractNameFromHeading(prefixRoleMatch[2])
+      roleType = prefixRoleMatch[1]
+    } else {
+      // 新格式:"## 张三(男主)"、"## 张三 - 男主"、"## 角色设定:张三"、"## 张三"
+      name = extractNameFromHeading(rawTitle)
+      if (!name) continue
+      roleType = extractRoleFromTrailing(rawTitle)
+    }
+
     if (!name || NON_CHARACTER_HEADING_PATTERN.test(name)) continue
 
+    // 从邻近 8 行查找 "角色定位:男主" 字段
     const nearbyText = lines.slice(index, index + 8).join("\n")
     const roleFromFields = nearbyText.match(/角色定位[::]\s*(男主|女主|男配|女配|反派|导师|盟友|配角|主角)/)?.[1]
-    if (!match[1] && !roleFromFields) continue
+
+    const finalRole = roleType ?? roleFromFields
+    // 没有任何 role 信息时跳过纯名字标题,避免误识别 "## 第一章" 之类的章节
+    if (!finalRole) continue
 
     ranges.push({
       start: index,
       end: lines.length,
-      roleType: match[1] ?? roleFromFields ?? "角色",
+      roleType: finalRole,
       name,
+      confidence: roleType ? "high" : "medium",
     })
   }
 
@@ -88,7 +133,7 @@ function splitByCharacterHeadings(content: string): CharacterSaveDraft[] {
     range.roleType,
     range.name,
     lines.slice(range.start, range.end).join("\n"),
-    "high",
+    range.confidence,
   ))
 }