فهرست منبع

修复: AI大纲自动保存失败、计划弹窗不出现、面板宽度不自适应,计划执行改名为计划

1. 自动保存失败修复: 系统提示词补充fileType/writeMode枚举值和targetFolder相对路径要求; 校验层新增防御性归一化(中文fileType映射、overwrite→create映射、绝对路径剥离)
2. 计划弹窗不出现修复: 增强extractUnmarkedChapterPlan兜底逻辑, 新增5个扩展关键词模式, 放宽匹配门槛从4个降至2个
3. AI大纲面板宽度不自适应修复: outline-workbench.tsx新增window resize监听器, 窗口缩小时clamp面板宽度到容器50%
4. UI文案: 计划执行改为计划(按钮/title/aria-label/tooltip/取消提示)
5. 测试同步更新
Mochocyang 2 ماه پیش
والد
کامیت
6d931557c7

+ 12 - 3
src/components/chat/chapter-plan-confirm-dialog.tsx

@@ -18,6 +18,14 @@ const FALLBACK_PLAN_SECTION_PATTERNS = [
   /(?:^|\n)\s*(?:#{1,4}\s*)?(?:\d+[.、]\s*)?风险与兜底/u,
 ]
 
+const EXTRA_PLAN_KEYWORD_PATTERNS = [
+  /(?:^|\n)\s*(?:#{1,4}\s*)?(?:\d+[.、]\s*)?(?:任务目标|创作目标|写作目标)/u,
+  /(?:^|\n)\s*(?:#{1,4}\s*)?(?:\d+[.、]\s*)?(?:执行步骤|执行计划|写作步骤|创作步骤)/u,
+  /(?:^|\n)\s*(?:#{1,4}\s*)?(?:\d+[.、]\s*)?(?:缺失资料|缺失信息|缺失内容)/u,
+  /(?:^|\n)\s*(?:#{1,4}\s*)?(?:\d+[.、]\s*)?(?:确认后动作|确认动作|后续动作)/u,
+  /(?:^|\n)\s*(?:#{1,4}\s*)?(?:\d+[.、]\s*)?(?:计划概览|创作计划|写作计划|章节计划)/u,
+]
+
 export function extractChapterPlan(fullContent: string): { plan: string; body: string } | null {
   const startIdx = fullContent.indexOf(CHAPTER_PLAN_MARKER_START)
   if (startIdx < 0) return extractUnmarkedChapterPlan(fullContent)
@@ -34,9 +42,10 @@ export function extractChapterPlan(fullContent: string): { plan: string; body: s
 function extractUnmarkedChapterPlan(fullContent: string): { plan: string; body: string } | null {
   const plan = fullContent.trim()
   if (!plan) return null
-  const matchedSections = FALLBACK_PLAN_SECTION_PATTERNS.filter((pattern) => pattern.test(plan)).length
-  const hasRequiredOpening = FALLBACK_PLAN_SECTION_PATTERNS[0].test(plan)
-  if (!hasRequiredOpening || matchedSections < 4) return null
+  const allPatterns = [...FALLBACK_PLAN_SECTION_PATTERNS, ...EXTRA_PLAN_KEYWORD_PATTERNS]
+  const matchedSections = allPatterns.filter((pattern) => pattern.test(plan)).length
+  // 至少匹配 2 个计划相关段落即认为是计划内容
+  if (matchedSections < 2) return null
   return { plan, body: "" }
 }
 

+ 3 - 3
src/components/chat/chat-panel.mount.spec.tsx

@@ -95,12 +95,12 @@ describe("ChatPanel mount 基础设施", () => {
     await view.unmount()
   })
 
-  it("输入工具栏单独显示计划执行开关,可与模式下拉按钮并列使用", async () => {
+  it("输入工具栏单独显示计划开关,可与模式下拉按钮并列使用", async () => {
     const view = await renderChatPanel({ activeConversation: true })
 
     expect(view.container.textContent).toContain("标准")
-    expect(view.container.textContent).toContain("计划执行")
-    expect(view.container.querySelector('[aria-label="开启计划执行模式"]')).not.toBeNull()
+    expect(view.container.textContent).toContain("计划")
+    expect(view.container.querySelector('[aria-label="开启计划模式"]')).not.toBeNull()
 
     await view.unmount()
   })

+ 3 - 3
src/components/chat/chat-panel.spec.tsx

@@ -117,7 +117,7 @@ describe("chat-panel agent reference integration", () => {
     expect(source).toContain("planExecuteEnabled")
     expect(source).toContain("setPlanExecuteEnabled")
     expect(source).toContain("aiSessionPlanExecuteLabel")
-    expect(source).toContain("计划执行")
+    expect(source).toContain("计划")
     expect(source).toContain("aria-pressed={planExecuteEnabled && aiWorkflowMode !== \"fast\"}")
     expect(source).toContain("disabled={aiWorkflowMode === \"fast\"}")
   })
@@ -290,7 +290,7 @@ describe("chat-panel agent reference integration", () => {
 
   it("settles visible tool calls when generation is cancelled from any chat confirmation path", () => {
     expect(source).toContain('agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls, "cancelled")')
-    expect(source).toContain("已取消计划执行,未进入正文生成。")
+    expect(source).toContain("已取消计划,未进入正文生成。")
     expect(source).toContain("已停止生成。")
   })
 
@@ -431,7 +431,7 @@ describe("chat-panel chapter plan confirm integration (Stage C)", () => {
 
   it("records a visible cancellation when the chapter plan dialog is cancelled", () => {
     expect(source).toContain("recordChapterPlanExecutionCancelled")
-    expect(source).toContain("已取消计划执行,未进入正文生成。")
+    expect(source).toContain("已取消计划,未进入正文生成。")
     expect(source).toContain("用户取消了章节计划确认")
     expect(source).toContain('settleRunningAgentStages(message.agentStages, "cancelled")')
     expect(source).toContain('if (action === "cancel")')

+ 8 - 8
src/components/chat/chat-panel.tsx

@@ -124,7 +124,7 @@ const shouldRunNovelPrePluginChain = false
 const taskRoute = shouldRunNovelPrePluginChain ? rawTaskRoute : null
 const selectedSkillsPrompt = ""
 const aiSessionWorkflowModeLabel = "AI 会话执行模式"
-const aiSessionPlanExecuteLabel = "计划执行模式"
+const aiSessionPlanExecuteLabel = "计划模式"
 const aiWorkflowModeOptions: Array<{
   mode: AiWorkflowMode
   label: string
@@ -409,15 +409,15 @@ function recordChapterPlanExecutionCancelled(messageId: string): void {
     id: `chapter_plan_cancelled:${messageId}:${timestamp}`,
     stageId: "write_confirmation",
     kind: "stage_output",
-    title: "已取消计划执行",
+    title: "已取消计划",
     content: "用户取消了章节计划确认,未进入正文生成。",
     timestamp,
   })
   updateAgentAssistantMessage(messageId, (message) => ({
     ...message,
     content: message.content
-      ? `${message.content}\n\n已取消计划执行,未进入正文生成。`
-      : "已取消计划执行,未进入正文生成。",
+      ? `${message.content}\n\n已取消计划,未进入正文生成。`
+      : "已取消计划,未进入正文生成。",
     agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls, "cancelled"),
     agentStages: applyAgentActivityEvent(
       settleRunningAgentStages(message.agentStages, "cancelled"),
@@ -2066,17 +2066,17 @@ export function ChatPanel() {
                                   : "border-border text-muted-foreground hover:bg-accent hover:text-foreground"
                               } disabled:cursor-not-allowed disabled:opacity-50`}
                               onClick={() => setPlanExecuteEnabled(!planExecuteEnabled)}
-                              title={aiWorkflowMode === "fast" ? "快速模式下不支持计划执行,请切换到标准或严格模式" : planExecuteEnabled ? "关闭计划执行模式" : "开启计划执行模式"}
-                              aria-label={aiWorkflowMode === "fast" ? "快速模式下不支持计划执行,请切换到标准或严格模式" : planExecuteEnabled ? "关闭计划执行模式" : "开启计划执行模式"}
+                              title={aiWorkflowMode === "fast" ? "快速模式下不支持计划,请切换到标准或严格模式" : planExecuteEnabled ? "关闭计划模式" : "开启计划模式"}
+                              aria-label={aiWorkflowMode === "fast" ? "快速模式下不支持计划,请切换到标准或严格模式" : planExecuteEnabled ? "关闭计划模式" : "开启计划模式"}
                             />
                           )}
                         >
                           <ListChecks className="mr-1 h-3.5 w-3.5" />
-                          计划执行
+                          计划
                         </TooltipTrigger>
                         <TooltipContent side="top" className="max-w-xs leading-5">
                           {aiWorkflowMode === "fast"
-                            ? "快速模式下不支持计划执行,请切换到标准或严格模式。"
+                            ? "快速模式下不支持计划,请切换到标准或严格模式。"
                             : "开启后,本次写作会先创建计划,等待确认后再执行;可与标准、严格模式组合使用。"}
                         </TooltipContent>
                       </Tooltip>

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

@@ -357,7 +357,9 @@ export function buildOutlineAgentSystemPrompt(options: {
     "Markdown 格式约束:结构化资料使用一级标题,** 必须成对,不要用代码围栏包裹全文,已有表格必须保留合法分隔行。",
     "## AI 大纲输出协议",
     "当本轮生成了可保存的大纲、卷纲、章纲、人物、设定、伏笔、组织或质量检查内容时,最终回复末尾必须附加一个 json 代码块,顶层字段为 outlineSaveRequest 或 outlineSaveRequests。",
-    "保存请求必须包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent。fileName 必须是 .md 文件,targetFolder 必须位于大纲文件树文件夹内。",
+    "保存请求必须包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent。fileName 必须是 .md 文件,targetFolder 必须是相对路径(仅文件夹名,如「大纲」「人物小传」「章纲」「设定」「伏笔」「组织」「质量检查」「卷纲」),禁止使用绝对路径(如 C:\\... 或 /Users/...)。",
+    "fileType 只能使用以下英文枚举值(禁止使用中文):outline(大纲)、volume-outline(卷纲)、chapter-outline(章纲)、character(人物小传)、setting(设定)、foreshadowing(伏笔)、organization(组织)、quality-report(质量检查)。",
+    "writeMode 只能使用以下英文枚举值(禁止使用其他值):create(新建文件)、append(追加到已有文件末尾)、replace(替换已有文件全部内容,需用户确认)、patch(局部修改,需用户确认)。禁止使用 overwrite、write、save 等其他值。",
     "content 字段说明:content 字段已废弃,不要在 JSON 中填写 content。系统会自动从你的回复正文中提取大纲内容作为保存内容,正文格式就是最终保存的文件格式。",
     "文件名规范:不同类型内容必须使用不同文件名,禁止多项内容写入同一文件。不同角色必须每人一个独立文件(如 角色-主角林风.md、角色-反官方傲.md),严禁将所有角色塞入「角色卡.md」或同一文件。不同势力、不同伏笔、不同卷纲、不同章纲也必须各自独立文件。",
     "内容完整性强制要求:所有在对话正文中展示给用户的大纲内容,系统会自动提取并保存。你必须为每个生成的大纲模块都创建对应的保存请求(outlineSaveRequest),不能遗漏。如果生成了多个模块,使用 outlineSaveRequests 数组,每个模块一个请求对象。",

+ 15 - 0
src/components/sources/outline-workbench.tsx

@@ -26,6 +26,21 @@ export function OutlineWorkbench() {
     }
   }, [outlineChatWidth])
 
+  // 窗口缩小时自动 clamp 面板宽度,避免面板超出容器 50%
+  useEffect(() => {
+    const handleResize = () => {
+      setOutlineChatWidth((prev) => {
+        if (typeof prev !== "number") return prev
+        if (!containerRef.current) return prev
+        const rect = containerRef.current.getBoundingClientRect()
+        const maxWidth = Math.max(OUTLINE_CHAT_MIN_WIDTH, Math.floor(rect.width * 0.5))
+        return prev > maxWidth ? maxWidth : prev
+      })
+    }
+    window.addEventListener("resize", handleResize)
+    return () => window.removeEventListener("resize", handleResize)
+  }, [])
+
   const startHorizontalResize = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
     event.preventDefault()
     document.body.style.cursor = "col-resize"

+ 1 - 1
src/lib/novel/outline-quality-check.ts

@@ -176,7 +176,7 @@ export function buildOutlineGenerationQualityFeedback(input: {
       "重要要求:",
       "1. 必须输出修订后的完整章纲正文(使用标准 Markdown 格式),不能只输出修改摘要或说明。",
       "2. 系统会自动从你的回复正文中提取完整内容并保存,你不需要在 JSON 中重复输出 content。",
-      "3. 在回复末尾附加 outlineSaveRequest JSON,只需包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent 等元数据。",
+      "3. 在回复末尾附加 outlineSaveRequest JSON,只需包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent 等元数据。fileType 只能用英文枚举(outline/volume-outline/chapter-outline/character/setting/foreshadowing/organization/quality-report),writeMode 只能用英文枚举(create/append/replace/patch),targetFolder 必须是相对路径(如「章纲」),禁止绝对路径。",
       "4. 正文中必须包含完整的章纲所有必填章节,不能省略未修改的部分。",
     ].join("\n"),
   };

+ 106 - 0
src/lib/novel/outline-save-request.spec.ts

@@ -173,4 +173,110 @@ describe("outline-save-request", () => {
     expect(feedback).toContain("fileName")
     expect(feedback).toContain("不会写入文件")
   })
+
+  it("将中文 fileType「大纲」归一化为 outline", () => {
+    const result = parseOutlineSaveRequests(JSON.stringify({
+      outlineSaveRequest: {
+        targetFolder: "大纲",
+        fileName: "总纲.md",
+        fileType: "大纲",
+        writeMode: "create",
+        referencedSkills: [],
+        sourceIntent: "测试",
+        content: "正文",
+      },
+    }))
+
+    expect(result.errors).toEqual([])
+    expect(result.requests).toHaveLength(1)
+    expect(result.requests[0].fileType).toBe("outline")
+  })
+
+  it("将中文 fileType「人物小传」归一化为 character", () => {
+    const result = parseOutlineSaveRequests(JSON.stringify({
+      outlineSaveRequest: {
+        targetFolder: "人物小传",
+        fileName: "角色-林风.md",
+        fileType: "人物小传",
+        writeMode: "create",
+        referencedSkills: [],
+        sourceIntent: "测试",
+        content: "正文",
+      },
+    }))
+
+    expect(result.errors).toEqual([])
+    expect(result.requests).toHaveLength(1)
+    expect(result.requests[0].fileType).toBe("character")
+  })
+
+  it("将 writeMode「overwrite」归一化为 create", () => {
+    const result = parseOutlineSaveRequests(JSON.stringify({
+      outlineSaveRequest: {
+        targetFolder: "章纲",
+        fileName: "章纲-第001章.md",
+        fileType: "chapter-outline",
+        writeMode: "overwrite",
+        referencedSkills: [],
+        sourceIntent: "测试",
+        content: "正文",
+      },
+    }))
+
+    expect(result.errors).toEqual([])
+    expect(result.requests).toHaveLength(1)
+    expect(result.requests[0].writeMode).toBe("create")
+  })
+
+  it("将 targetFolder 绝对路径剥离为相对文件夹名", () => {
+    const result = parseOutlineSaveRequests(JSON.stringify({
+      outlineSaveRequest: {
+        targetFolder: "C:/book/wiki/outlines/人物小传",
+        fileName: "角色-林风.md",
+        fileType: "character",
+        writeMode: "create",
+        referencedSkills: [],
+        sourceIntent: "测试",
+        content: "正文",
+      },
+    }))
+
+    expect(result.errors).toEqual([])
+    expect(result.requests).toHaveLength(1)
+    expect(result.requests[0].targetFolder).toBe("人物小传")
+  })
+
+  it("同时修复中文 fileType、overwrite、绝对路径三种错误", () => {
+    const result = parseOutlineSaveRequests(JSON.stringify({
+      outlineSaveRequests: [
+        {
+          targetFolder: "C:/book/wiki/outlines/大纲",
+          fileName: "总纲.md",
+          fileType: "大纲",
+          writeMode: "overwrite",
+          referencedSkills: [],
+          sourceIntent: "生成总纲",
+          content: "正文",
+        },
+        {
+          targetFolder: "C:/book/wiki/outlines/人物小传",
+          fileName: "角色-林风.md",
+          fileType: "人物小传",
+          writeMode: "overwrite",
+          referencedSkills: [],
+          sourceIntent: "生成角色",
+          content: "正文",
+        },
+      ],
+    }))
+
+    expect(result.errors).toEqual([])
+    expect(result.requests).toHaveLength(2)
+    expect(result.requests[0].fileType).toBe("outline")
+    expect(result.requests[0].writeMode).toBe("create")
+    expect(result.requests[0].targetFolder).toBe("大纲")
+    expect(result.requests[1].fileType).toBe("character")
+    expect(result.requests[1].writeMode).toBe("create")
+    expect(result.requests[1].targetFolder).toBe("人物小传")
+  })
 })

+ 52 - 3
src/lib/novel/outline-save-request.ts

@@ -63,6 +63,55 @@ const ALLOWED_WRITE_MODES = new Set<OutlineSaveRequestWriteMode>([
   "patch",
 ])
 
+const FILE_TYPE_ALIASES: Record<string, OutlineSaveRequestFileType> = {
+  "大纲": "outline",
+  "卷纲": "volume-outline",
+  "章纲": "chapter-outline",
+  "人物小传": "character",
+  "人物": "character",
+  "角色": "character",
+  "设定": "setting",
+  "伏笔": "foreshadowing",
+  "组织": "organization",
+  "势力": "organization",
+  "质量检查": "quality-report",
+}
+
+const WRITE_MODE_ALIASES: Record<string, OutlineSaveRequestWriteMode> = {
+  "overwrite": "create",
+  "write": "create",
+  "save": "create",
+  "new": "create",
+  "override": "replace",
+}
+
+function normalizeFileTypeAlias(value: string): string {
+  const trimmed = value.trim()
+  if (ALLOWED_FILE_TYPES.has(trimmed as OutlineSaveRequestFileType)) return trimmed
+  return FILE_TYPE_ALIASES[trimmed] ?? trimmed
+}
+
+function normalizeWriteModeAlias(value: string): string {
+  const trimmed = value.trim().toLowerCase()
+  if (ALLOWED_WRITE_MODES.has(trimmed as OutlineSaveRequestWriteMode)) return trimmed
+  return WRITE_MODE_ALIASES[trimmed] ?? trimmed
+}
+
+function stripAbsoluteToRelativeFolder(value: string): string {
+  const normalized = normalizePath(value).trim()
+  if (!normalized) return normalized
+  if (!normalized.startsWith("/") && !normalized.startsWith("\\") && !/^[a-zA-Z]:[\\/]/.test(normalized)) {
+    return normalized
+  }
+  const marker = "wiki/outlines/"
+  const markerIndex = normalized.toLowerCase().indexOf(marker)
+  if (markerIndex >= 0) {
+    return normalized.slice(markerIndex + marker.length)
+  }
+  const parts = normalized.split("/").filter(Boolean)
+  return parts.length > 0 ? parts[parts.length - 1] : normalized
+}
+
 function extractJsonCandidates(text: string): string[] {
   const candidates: string[] = []
   const fencePattern = /```(?:json)?\s*([\s\S]*?)```/gi
@@ -105,10 +154,10 @@ function normalizeRequest(raw: unknown, index: number): {
   }
 
   const errors: string[] = []
-  const targetFolder = String(raw.targetFolder ?? "").trim()
+  const targetFolder = stripAbsoluteToRelativeFolder(String(raw.targetFolder ?? "").trim())
   const fileName = String(raw.fileName ?? "").trim()
-  const fileType = String(raw.fileType ?? "") as OutlineSaveRequestFileType
-  const writeMode = String(raw.writeMode ?? "") as OutlineSaveRequestWriteMode
+  const fileType = normalizeFileTypeAlias(String(raw.fileType ?? "")) as OutlineSaveRequestFileType
+  const writeMode = normalizeWriteModeAlias(String(raw.writeMode ?? "")) as OutlineSaveRequestWriteMode
   const content = String(raw.content ?? "").trim()
 
   for (const [field, value] of Object.entries({