Kaynağa Gözat

feat(novel): 严格模式正文写作支持实体联网补搜

仅在 strict 下抽取实体;前文与实体表都未命中、且模型判定需要公开资料时才 webSearch,结果注入章节上下文。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 ay önce
ebeveyn
işleme
ab6c4fd6fe

+ 4 - 0
src/components/chat/chat-panel.spec.tsx

@@ -100,6 +100,8 @@ describe("chat-panel agent reference integration", () => {
     expect(source).toContain("description: \"完整质检")
     expect(source).toContain("快速模式像普通对话一样直接出结果")
     expect(source).toContain("读取上下文、生成任务书和正文初稿后直接完成")
+    expect(source).toContain("读取更完整上下文,执行审稿、返修、复审、去AI味和计划验收。会联网搜索。")
+    expect(source).not.toContain("前文与实体表")
     expect(source).toContain("workflowModeDropdownStyle.width")
     expect(source).toContain("routeDescription")
   })
@@ -273,8 +275,10 @@ describe("chat-panel agent reference integration", () => {
 
   it("requires external search requests to use web_search instead of pretending", () => {
     expect(source).toContain("web_search")
+    expect(source).toContain("会联网搜索")
     expect(source).toContain("不得声称已经搜索")
     expect(source).toContain("未使用联网资料")
+    expect(source).not.toContain("前文与实体表")
   })
 
   it("records web_search tool results into context trace", () => {

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

@@ -183,7 +183,7 @@ const aiWorkflowModeOptions: Array<{
     mode: "strict",
     label: "严格",
     description: "完整质检",
-    routeDescription: "读取更完整上下文,执行审稿、返修、复审、去AI味和计划验收。",
+    routeDescription: "读取更完整上下文,执行审稿、返修、复审、去AI味和计划验收。会联网搜索。",
   },
 ]
 const currentModelNotSupportMsg = "当前模型不支持工具调用,已切换为普通对话模式"
@@ -359,7 +359,7 @@ function buildChatAgentSystemPrompt(options: {
         lines.push("标准模式:读取上下文,生成任务书和正文初稿后直接完成,不做正文后审核。")
         break
       case "strict":
-        lines.push("严格模式:读取更完整上下文,执行更严格的审稿、返修和一致性检查。如果有外部搜索需求,必须使用 web_search 工具,不得声称已经搜索。未使用联网资料时,在回复末尾注明。")
+        lines.push("严格模式:读取更完整上下文,执行更严格的审稿、返修和一致性检查。会联网搜索。如果有外部搜索需求,必须使用 web_search 工具,不得声称已经搜索。未使用联网资料时,在回复末尾注明。")
         break
       }
     if (options.planExecuteEnabled && options.aiWorkflowMode !== "fast") {

+ 35 - 3
src/lib/novel/deep-chapter-generation.spec.ts

@@ -1360,7 +1360,8 @@ describe("runDeepChapterGeneration", () => {
   })
 
   it("uses fast, standard, and strict workflow routes", async () => {
-    const fastDeps = createDeps()
+    const skippedCollect = vi.fn(async () => ({ markdown: "", searchedNames: [], notes: [] }))
+    const fastDeps = { ...createDeps(), collectWritingEntityWebSearch: skippedCollect }
     await runDeepChapterGeneration(
       { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "fast" },
       {},
@@ -1368,8 +1369,9 @@ describe("runDeepChapterGeneration", () => {
     )
     expect(fastDeps.streamChat).toHaveBeenCalledTimes(2)
     expect(fastDeps.reviewChapter).not.toHaveBeenCalled()
+    expect(skippedCollect).not.toHaveBeenCalled()
 
-    const standardDeps = createDeps()
+    const standardDeps = { ...createDeps(), collectWritingEntityWebSearch: skippedCollect }
     const standardThinking: string[] = []
     await runDeepChapterGeneration(
       { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "standard" },
@@ -1380,8 +1382,14 @@ describe("runDeepChapterGeneration", () => {
     expect(standardDeps.reviewChapter).not.toHaveBeenCalled()
     expect(standardThinking.join("\n")).toContain("阶段4:标准完成")
     expect(standardThinking.join("\n")).not.toContain("快速模式")
+    expect(skippedCollect).not.toHaveBeenCalled()
 
-    const strictDeps = createDeps()
+    const collectWritingEntityWebSearch = vi.fn(async () => ({
+      markdown: "",
+      searchedNames: [] as string[],
+      notes: [] as string[],
+    }))
+    const strictDeps = { ...createDeps(), collectWritingEntityWebSearch }
     await runDeepChapterGeneration(
       { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "strict" },
       {},
@@ -1389,6 +1397,30 @@ describe("runDeepChapterGeneration", () => {
     )
     expect(strictDeps.streamChat).toHaveBeenCalledTimes(3)
     expect(strictDeps.reviewChapter).toHaveBeenCalled()
+    expect(collectWritingEntityWebSearch).toHaveBeenCalled()
+  })
+
+  it("injects strict-mode entity web search into the chapter context pack", async () => {
+    const research = "## 外部检索(仅补本地缺失实体)\n\n### 黄蓉\n- 资料 https://example.test/hr\n  公开摘要"
+    const seenPacks: ContextPack[] = []
+    const deps = createDeps()
+    vi.mocked(deps.contextPackToPrompt).mockImplementation((pack) => {
+      seenPacks.push(pack)
+      return pack.searchResults || "上下文包内容"
+    })
+    await runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "strict" },
+      {},
+      {
+        ...deps,
+        collectWritingEntityWebSearch: vi.fn(async () => ({
+          markdown: research,
+          searchedNames: ["黄蓉"],
+          notes: [],
+        })),
+      },
+    )
+    expect(seenPacks.some((pack) => pack.searchResults.includes(research))).toBe(true)
   })
 
   it("emits visible workflow events for the chapter multi-task loop", async () => {

+ 79 - 1
src/lib/novel/deep-chapter-generation.ts

@@ -27,6 +27,11 @@ import {
   contextPackToPrompt,
   type ContextPack,
 } from "./context-engine";
+import {
+  collectWritingEntityWebSearch,
+  type CollectWritingEntityWebSearchInput,
+  type WritingEntityWebSearchResult,
+} from "./writing-entity-web-search";
 import { resolveDefaultModel, resolveNovelModel } from "./model-resolver";
 import { reviewChapter, type NovelReviewResult } from "./review-adapter";
 import type { TaskRouteResult } from "./task-router";
@@ -141,6 +146,9 @@ export interface DeepChapterGenerationDeps {
     signal?: AbortSignal,
     requestOverrides?: RequestOverrides,
   ) => Promise<void>;
+  collectWritingEntityWebSearch?: (
+    input: CollectWritingEntityWebSearchInput,
+  ) => Promise<WritingEntityWebSearchResult>;
 }
 
 const defaultDeps: DeepChapterGenerationDeps = {
@@ -573,7 +581,7 @@ export async function runDeepChapterGeneration(
   }
   throwIfAborted(signal);
 
-  const contextPack = await safeBuildChapterContextPack(
+  let contextPack = await safeBuildChapterContextPack(
     deps,
     input.projectPath,
     contextRequest,
@@ -582,6 +590,20 @@ export async function runDeepChapterGeneration(
   );
   assertNotAborted(signal);
 
+  if (workflowProfile.mode === "strict") {
+    contextPack = await maybeInjectWritingEntityWebSearch({
+      input,
+      deps,
+      contextPack,
+      previousChaptersAnalysis,
+      planBlueprint,
+      workflowConfig,
+      callbacks,
+      signal,
+    });
+    assertNotAborted(signal);
+  }
+
   if (!resumeCheckpoint) {
     emitDeepChapterStageStarted(
       callbacks,
@@ -2253,6 +2275,62 @@ function resolveGoldenThreeThinkingHints(
   ];
 }
 
+async function maybeInjectWritingEntityWebSearch(args: {
+  input: DeepChapterGenerationInput;
+  deps: DeepChapterGenerationDeps;
+  contextPack: ContextPack;
+  previousChaptersAnalysis: string;
+  planBlueprint?: string;
+  workflowConfig: LlmConfig;
+  callbacks: DeepChapterGenerationCallbacks;
+  signal?: AbortSignal;
+}): Promise<ContextPack> {
+  const collect = args.deps.collectWritingEntityWebSearch ?? collectWritingEntityWebSearch;
+  try {
+    args.callbacks.onThinking?.(
+      formatStageThinking("联网搜索", "正在核对本库实体,必要时联网补搜..."),
+    );
+    const result = await collect({
+      projectPath: args.input.projectPath,
+      userRequest: args.input.userRequest,
+      outline: args.contextPack.outline,
+      planBlueprint: args.planBlueprint,
+      contextPack: args.contextPack,
+      chapterNumber: args.input.chapterNumber,
+      previousChaptersAnalysis: args.previousChaptersAnalysis,
+      streamChat: args.deps.streamChat,
+      llmConfig: args.workflowConfig,
+      searchApiConfig: useWikiStore.getState().searchApiConfig,
+      signal: args.signal,
+    });
+    if (result.searchedNames.length > 0 || result.markdown.trim()) {
+      emitDeepChapterActivity(args.callbacks, {
+        id: `deep_chapter:entity_web_search:${Date.now()}`,
+        stageId: "read_context",
+        kind: "web_search",
+        title: "联网搜索",
+        content: [
+          result.searchedNames.length > 0
+            ? `已搜索:${result.searchedNames.join("、")}`
+            : "未发起联网搜索",
+          ...result.notes,
+        ].filter(Boolean).join("\n"),
+      });
+    }
+    if (!result.markdown.trim()) return args.contextPack;
+    return {
+      ...args.contextPack,
+      searchResults: [args.contextPack.searchResults?.trim(), result.markdown.trim()]
+        .filter(Boolean)
+        .join("\n\n"),
+    };
+  } catch (error) {
+    rethrowIfUserAbort(error, args.signal);
+    console.error("[deep-chapter-generation] 实体联网补搜失败:", error);
+    return args.contextPack;
+  }
+}
+
 async function safeBuildChapterContextPack(
   deps: DeepChapterGenerationDeps,
   projectPath: string,

+ 1 - 1
src/lib/novel/mod.ts

@@ -33,7 +33,7 @@ export { exportProject, type ExportOptions, type ExportResult } from "./export"
 export { routeTask, buildTaskDirective, type NovelTaskIntent, type TaskRouteResult } from "./task-router"
 export { createDefaultNovelProjectMeta, saveNovelProjectMeta, loadNovelProjectMeta, updateNovelProjectStats, type NovelProjectMeta } from "./project-meta"
 export { buildDeAiSystemPrompt, buildDeAiRewriteMessages, injectDeAiDirective, loadCustomDeAiSkill } from "./de-ai-adapter"
-export { analyzePreviousChapters, type PreviousChapterAnalysis } from "./previous-chapters-analysis"
+export { analyzePreviousChapters, readPreviousChapterBodies, type PreviousChapterAnalysis } from "./previous-chapters-analysis"
 export { rebuildAllSnapshots, rebuildVectorIndex, type RebuildProgress, type RebuildProgressCallback } from "./rebuild"
 export { runFactCheck, verifyFactCheckLlm, type FactCheckResult, type FactCheckReport, type FactCheckOptions } from "./fact-snapshot"
 export { scoreReviewResults, CALIBRATED_DIMENSION_WEIGHTS, CALIBRATED_SEVERITY_DEDUCTION, type DimensionScore, type ReviewScoreReport, type ReviewScoringOptions } from "./review-scoring"

+ 24 - 8
src/lib/novel/previous-chapters-analysis.ts

@@ -33,20 +33,17 @@ export interface PreviousChapterAnalysis {
 }
 
 /**
- * 读取并分析前几章的完整内容
+ * 读取前 N 章正文(去 frontmatter),不做 LLM 分析。读取失败的章节跳过。
  */
-export async function analyzePreviousChapters(
+export async function readPreviousChapterBodies(
   projectPath: string,
   currentChapterNumber: number,
-  llmConfig: LlmConfig,
   analysisCount: number = 3,
   signal?: AbortSignal,
-): Promise<string> {
-  if (currentChapterNumber <= 1) return ""
+): Promise<Array<{ number: number; content: string }>> {
+  if (currentChapterNumber <= 1) return []
 
   const previousChapters: Array<{ number: number; content: string }> = []
-
-  // 读取前N章的完整内容
   for (let i = Math.max(1, currentChapterNumber - analysisCount); i < currentChapterNumber; i++) {
     if (signal?.aborted) throw new Error("已停止生成")
     try {
@@ -55,12 +52,31 @@ export async function analyzePreviousChapters(
         const content = await readFile(results[0].path)
         const bodyStart = content.indexOf("---", 4)
         const body = bodyStart >= 0 ? content.slice(bodyStart + 3).trim() : content
-        previousChapters.push({ number: i, content: body })
+        if (body) previousChapters.push({ number: i, content: body })
       }
     } catch {
       // 忽略读取失败的章节
     }
   }
+  return previousChapters
+}
+
+/**
+ * 读取并分析前几章的完整内容
+ */
+export async function analyzePreviousChapters(
+  projectPath: string,
+  currentChapterNumber: number,
+  llmConfig: LlmConfig,
+  analysisCount: number = 3,
+  signal?: AbortSignal,
+): Promise<string> {
+  const previousChapters = await readPreviousChapterBodies(
+    projectPath,
+    currentChapterNumber,
+    analysisCount,
+    signal,
+  )
 
   if (previousChapters.length === 0) return ""
 

+ 201 - 0
src/lib/novel/writing-entity-web-search.spec.ts

@@ -0,0 +1,201 @@
+import { describe, expect, it, vi } from "vitest"
+import type { LlmConfig, SearchApiConfig } from "@/stores/wiki-store"
+import type { ChatMessage, StreamCallbacks } from "@/lib/llm-client"
+import type { ContextPack } from "./context-engine"
+import {
+  buildLocalWritingCorpus,
+  collectWritingEntityWebSearch,
+  formatWritingEntitySearchMarkdown,
+  isLocallyResolvedEntity,
+  isWebSearchConfigured,
+  parseExtractedEntityNames,
+  parseNeedExternalNames,
+  selectUnresolvedEntities,
+  WRITING_ENTITY_SEARCH_HEADING,
+} from "./writing-entity-web-search"
+
+const llmConfig = {
+  provider: "custom",
+  apiKey: "test-key",
+  model: "test-model",
+  ollamaUrl: "",
+  customEndpoint: "https://example.test/v1",
+  maxContextSize: 120000,
+} satisfies LlmConfig
+
+const configuredSearch: SearchApiConfig = {
+  provider: "bocha",
+  apiKey: "search-key",
+  serpApiEngine: "google",
+  searXngUrl: "",
+  searXngCategories: ["general"],
+  providerConfigs: {},
+}
+
+const pack: ContextPack = {
+  task: "写第三章,黄蓉出场",
+  chapterGoal: "黄蓉与郭靖会合",
+  outline: "第3章:郭靖在客栈等候。",
+  recentSummaries: ["第1章:郭靖离乡。"],
+  previousChapterEnding: "客栈门帘掀开。",
+  characterStates: "郭靖刚到中原。",
+  soulDoc: "",
+  characterAuras: "",
+  storyFrameworkBinding: "",
+  cognitionStates: "",
+  foreshadowingStates: "",
+  timeline: "",
+  relatedSettings: "",
+  canonRules: "",
+  writingStyle: "",
+  searchResults: "",
+  graphSearchResults: "",
+  mustDo: "",
+  mustAvoid: "",
+  nextChapterAdvice: "",
+  revisionDirectives: "",
+}
+
+function streamChatReturning(responses: string[]) {
+  let index = 0
+  return vi.fn(async (_config: LlmConfig, _messages: ChatMessage[], callbacks: StreamCallbacks) => {
+    callbacks.onToken(responses[Math.min(index, responses.length - 1)] ?? "")
+    index += 1
+    callbacks.onDone()
+  })
+}
+
+describe("writing entity local lookup", () => {
+  it("treats entity-table or previous-text hits as resolved", () => {
+    const corpus = buildLocalWritingCorpus(pack, ["前文里出现过穆念慈。"])
+    expect(isLocallyResolvedEntity("郭靖", corpus, ["黄蓉"])).toBe(true)
+    expect(isLocallyResolvedEntity("穆念慈", corpus, [])).toBe(true)
+    expect(isLocallyResolvedEntity("黄蓉", "无关正文", ["黄蓉"])).toBe(true)
+    expect(isLocallyResolvedEntity("降龙十八掌", corpus, ["黄蓉"])).toBe(false)
+  })
+
+  it("selects only names missing from both corpus and entity table", () => {
+    const corpus = buildLocalWritingCorpus(pack)
+    expect(selectUnresolvedEntities(["郭靖", "黄蓉", "降龙十八掌"], corpus, ["黄蓉"])).toEqual([
+      "降龙十八掌",
+    ])
+  })
+})
+
+describe("writing entity parse helpers", () => {
+  it("parses extracted entity names from JSON", () => {
+    expect(parseExtractedEntityNames('{"entities":["黄蓉","降龙十八掌"]}')).toEqual(["黄蓉", "降龙十八掌"])
+    expect(parseExtractedEntityNames("```json\n[\"郭靖\"]\n```")).toEqual(["郭靖"])
+  })
+
+  it("parses needExternal names against candidates", () => {
+    expect(parseNeedExternalNames('{"needExternal":["黄蓉","原创甲"]}', ["黄蓉", "降龙十八掌"])).toEqual(["黄蓉"])
+    expect(parseNeedExternalNames('{"entities":[{"name":"黄蓉","needExternal":true},{"name":"林烬","needExternal":false}]}', ["黄蓉", "林烬"])).toEqual(["黄蓉"])
+  })
+})
+
+describe("isWebSearchConfigured", () => {
+  it("rejects missing provider or api key", () => {
+    expect(isWebSearchConfigured(null)).toBe(false)
+    expect(isWebSearchConfigured({
+      provider: "none",
+      apiKey: "",
+      searXngUrl: "",
+      searXngCategories: ["general"],
+    })).toBe(false)
+    expect(isWebSearchConfigured(configuredSearch)).toBe(true)
+  })
+})
+
+describe("collectWritingEntityWebSearch", () => {
+  it("skips search when the provider is not configured", async () => {
+    const search = vi.fn()
+    const result = await collectWritingEntityWebSearch({
+      projectPath: "/project",
+      userRequest: "写一章黄蓉出场",
+      contextPack: pack,
+      streamChat: streamChatReturning(['{"entities":["黄蓉"]}']),
+      llmConfig,
+      searchApiConfig: { provider: "none", apiKey: "", searXngUrl: "", searXngCategories: ["general"] },
+      search,
+    })
+    expect(search).not.toHaveBeenCalled()
+    expect(result.markdown).toBe("")
+    expect(result.notes).toContain("未配置外部搜索")
+  })
+
+  it("does not search names found in previous text or the entity table", async () => {
+    const search = vi.fn()
+    const result = await collectWritingEntityWebSearch({
+      projectPath: "/project",
+      userRequest: "写一章郭靖和黄蓉出场",
+      contextPack: pack,
+      streamChat: streamChatReturning([
+        '{"entities":["郭靖","黄蓉"]}',
+        '{"needExternal":["郭靖","黄蓉"]}',
+      ]),
+      llmConfig,
+      searchApiConfig: configuredSearch,
+      listEntityNames: async () => ["黄蓉"],
+      readPreviousBodies: async () => [],
+      search,
+    })
+    expect(search).not.toHaveBeenCalled()
+    expect(result.searchedNames).toEqual([])
+    expect(result.markdown).toBe("")
+  })
+
+  it("searches unresolved names the model marks as needExternal", async () => {
+    const search = vi.fn(async (query: string) => [{
+      title: `${query} 资料`,
+      url: `https://example.test/${encodeURIComponent(query)}`,
+      snippet: "公开资料摘要",
+      source: "example.test",
+    }])
+    const result = await collectWritingEntityWebSearch({
+      projectPath: "/project",
+      userRequest: "写一章降龙十八掌对决",
+      contextPack: pack,
+      streamChat: streamChatReturning([
+        '{"entities":["降龙十八掌"]}',
+        '{"needExternal":["降龙十八掌"]}',
+      ]),
+      llmConfig,
+      searchApiConfig: configuredSearch,
+      listEntityNames: async () => ["黄蓉"],
+      readPreviousBodies: async () => [],
+      search,
+    })
+    expect(search).toHaveBeenCalledWith("降龙十八掌", configuredSearch, 4)
+    expect(result.searchedNames).toEqual(["降龙十八掌"])
+    expect(result.markdown).toContain(WRITING_ENTITY_SEARCH_HEADING)
+    expect(result.markdown).toContain("降龙十八掌")
+    expect(result.markdown).toContain("公开资料摘要")
+  })
+
+  it("does not search original names the model can invent", async () => {
+    const search = vi.fn()
+    const result = await collectWritingEntityWebSearch({
+      projectPath: "/project",
+      userRequest: "写一章林烬出场",
+      contextPack: pack,
+      streamChat: streamChatReturning([
+        '{"entities":["林烬"]}',
+        '{"needExternal":[]}',
+      ]),
+      llmConfig,
+      searchApiConfig: configuredSearch,
+      listEntityNames: async () => ["黄蓉"],
+      readPreviousBodies: async () => [],
+      search,
+    })
+    expect(search).not.toHaveBeenCalled()
+    expect(result.searchedNames).toEqual([])
+  })
+})
+
+describe("formatWritingEntitySearchMarkdown", () => {
+  it("returns empty string without results", () => {
+    expect(formatWritingEntitySearchMarkdown([])).toBe("")
+  })
+})

+ 381 - 0
src/lib/novel/writing-entity-web-search.ts

@@ -0,0 +1,381 @@
+import type { LlmConfig, SearchApiConfig } from "@/stores/wiki-store"
+import type { ChatMessage, RequestOverrides, StreamCallbacks } from "@/lib/llm-client"
+import { providerRequiresApiKey, resolveSearchConfig, webSearch, type WebSearchResult } from "@/lib/web-search"
+import { rethrowIfUserAbort, throwIfAborted } from "@/lib/user-abort"
+import { listLocalEntityNames } from "./local-entity-names"
+import { readPreviousChapterBodies } from "./previous-chapters-analysis"
+import type { ContextPack } from "./context-engine"
+
+export const WRITING_ENTITY_SEARCH_HEADING = "外部检索(仅补本地缺失实体)"
+const MIN_NAME_LENGTH = 2
+const MAX_EXTRACTED_ENTITIES = 12
+const MAX_SEARCH_QUERIES = 3
+const SOURCE_TEXT_CHAR_CAP = 8000
+
+export interface WritingEntityWebSearchResult {
+  markdown: string
+  searchedNames: string[]
+  notes: string[]
+}
+
+export interface CollectWritingEntityWebSearchInput {
+  projectPath: string
+  userRequest: string
+  outline?: string
+  planBlueprint?: string
+  contextPack: ContextPack
+  chapterNumber?: number
+  previousChaptersAnalysis?: string
+  streamChat: (
+    config: LlmConfig,
+    messages: ChatMessage[],
+    callbacks: StreamCallbacks,
+    signal?: AbortSignal,
+    requestOverrides?: RequestOverrides,
+  ) => Promise<void>
+  llmConfig: LlmConfig
+  searchApiConfig?: SearchApiConfig | null
+  signal?: AbortSignal
+  listEntityNames?: typeof listLocalEntityNames
+  readPreviousBodies?: typeof readPreviousChapterBodies
+  search?: typeof webSearch
+}
+
+export function isWebSearchConfigured(
+  config: SearchApiConfig | null | undefined,
+): config is SearchApiConfig {
+  if (!config) return false
+  const resolved = resolveSearchConfig(config)
+  if (resolved.provider === "none") return false
+  if (providerRequiresApiKey(resolved.provider) && !resolved.apiKey?.trim()) return false
+  if (resolved.provider === "searxng" && !resolved.searXngUrl?.trim()) return false
+  return true
+}
+
+export function buildLocalWritingCorpus(
+  pack: Pick<
+    ContextPack,
+    | "outline"
+    | "chapterGoal"
+    | "characterStates"
+    | "characterAuras"
+    | "relatedSettings"
+    | "canonRules"
+    | "cognitionStates"
+    | "foreshadowingStates"
+    | "previousChapterEnding"
+    | "recentSummaries"
+    | "searchResults"
+    | "soulDoc"
+  >,
+  extraTexts: readonly string[] = [],
+): string {
+  return [
+    pack.outline,
+    pack.chapterGoal,
+    pack.characterStates,
+    pack.characterAuras,
+    pack.relatedSettings,
+    pack.canonRules,
+    pack.cognitionStates,
+    pack.foreshadowingStates,
+    pack.previousChapterEnding,
+    pack.searchResults,
+    pack.soulDoc,
+    ...(pack.recentSummaries ?? []),
+    ...extraTexts,
+  ]
+    .filter((item): item is string => typeof item === "string" && item.trim().length > 0)
+    .join("\n")
+}
+
+export function isLocallyResolvedEntity(
+  name: string,
+  corpus: string,
+  entityNames: readonly string[],
+): boolean {
+  const trimmed = name.trim()
+  if (trimmed.length < MIN_NAME_LENGTH) return true
+  if (corpus.includes(trimmed)) return true
+  return entityNames.some((entityName) => (
+    entityName.length >= MIN_NAME_LENGTH
+    && (trimmed.includes(entityName) || entityName.includes(trimmed))
+  ))
+}
+
+export function selectUnresolvedEntities(
+  names: readonly string[],
+  corpus: string,
+  entityNames: readonly string[],
+): string[] {
+  const unique: string[] = []
+  for (const raw of names) {
+    const name = raw.trim()
+    if (name.length < MIN_NAME_LENGTH) continue
+    if (unique.some((item) => item === name)) continue
+    if (isLocallyResolvedEntity(name, corpus, entityNames)) continue
+    unique.push(name)
+    if (unique.length >= MAX_EXTRACTED_ENTITIES) break
+  }
+  return unique
+}
+
+export function parseExtractedEntityNames(text: string): string[] {
+  const parsed = parseJsonPayload(text)
+  const names = collectNameStrings(parsed)
+  return uniqueNames(names).slice(0, MAX_EXTRACTED_ENTITIES)
+}
+
+export function parseNeedExternalNames(text: string, candidates: readonly string[]): string[] {
+  const allowed = new Set(candidates.map((name) => name.trim()).filter(Boolean))
+  const parsed = parseJsonPayload(text)
+  if (!parsed) return []
+
+  const selected: string[] = []
+  const add = (value: unknown) => {
+    const name = String(value ?? "").trim()
+    if (!name || !allowed.has(name) || selected.includes(name)) return
+    selected.push(name)
+  }
+
+  if (Array.isArray(parsed)) {
+    for (const item of parsed) {
+      if (typeof item === "string") add(item)
+      else if (item && typeof item === "object") {
+        const record = item as Record<string, unknown>
+        if (record.needExternal === false) continue
+        if (record.needExternal === true || record.search === true) add(record.name)
+      }
+    }
+    return selected
+  }
+
+  if (typeof parsed !== "object") return []
+  const record = parsed as Record<string, unknown>
+  const needExternal = record.needExternal ?? record.search ?? record.names
+  if (Array.isArray(needExternal)) {
+    for (const item of needExternal) {
+      if (typeof item === "string") add(item)
+      else if (item && typeof item === "object") {
+        const entry = item as Record<string, unknown>
+        if (entry.needExternal === false) continue
+        add(entry.name)
+      }
+    }
+  }
+  if (Array.isArray(record.entities)) {
+    for (const item of record.entities) {
+      if (!item || typeof item !== "object") continue
+      const entry = item as Record<string, unknown>
+      if (entry.needExternal === true || entry.search === true) add(entry.name)
+    }
+  }
+  return selected
+}
+
+export function formatWritingEntitySearchMarkdown(
+  items: Array<{ name: string; results: WebSearchResult[] }>,
+): string {
+  if (items.length === 0) return ""
+  const sections = items.map((item) => {
+    const lines = item.results.length > 0
+      ? item.results.map((result) => {
+        const title = result.title.trim() || result.url.trim() || result.source.trim() || "未命名来源"
+        const url = result.url.trim()
+        const snippet = result.snippet.trim()
+        return [`- ${title}${url ? ` ${url}` : ""}`, snippet ? `  ${snippet}` : ""].filter(Boolean).join("\n")
+      })
+      : ["- 无可用结果"]
+    return `### ${item.name}\n${lines.join("\n")}`
+  })
+  return [`## ${WRITING_ENTITY_SEARCH_HEADING}`, ...sections].join("\n\n")
+}
+
+export async function collectWritingEntityWebSearch(
+  input: CollectWritingEntityWebSearchInput,
+): Promise<WritingEntityWebSearchResult> {
+  const notes: string[] = []
+  if (!isWebSearchConfigured(input.searchApiConfig)) {
+    return { markdown: "", searchedNames: [], notes: ["未配置外部搜索"] }
+  }
+
+  throwIfAborted(input.signal)
+
+  try {
+    const listEntityNames = input.listEntityNames ?? listLocalEntityNames
+    const readPreviousBodies = input.readPreviousBodies ?? readPreviousChapterBodies
+    const search = input.search ?? webSearch
+
+    const [entityNames, previousBodies] = await Promise.all([
+      listEntityNames(input.projectPath),
+      input.chapterNumber && input.chapterNumber > 1
+        ? readPreviousBodies(input.projectPath, input.chapterNumber, 3, input.signal)
+        : Promise.resolve([]),
+    ])
+    throwIfAborted(input.signal)
+
+    const corpus = buildLocalWritingCorpus(input.contextPack, [
+      input.previousChaptersAnalysis ?? "",
+      ...previousBodies.map((chapter) => chapter.content),
+    ])
+
+    const extracted = await extractEntityNames(input)
+    const unresolved = selectUnresolvedEntities(extracted, corpus, entityNames)
+    if (unresolved.length === 0) {
+      return { markdown: "", searchedNames: [], notes }
+    }
+
+    const needExternal = await judgeNeedExternal(input, unresolved)
+    const queries = needExternal.slice(0, MAX_SEARCH_QUERIES)
+    if (queries.length === 0) {
+      return { markdown: "", searchedNames: [], notes }
+    }
+
+    const items: Array<{ name: string; results: WebSearchResult[] }> = []
+    for (const name of queries) {
+      throwIfAborted(input.signal)
+      try {
+        const results = await search(name, input.searchApiConfig, 4)
+        items.push({ name, results })
+      } catch (error) {
+        rethrowIfUserAbort(error, input.signal)
+        notes.push(`搜索「${name}」失败:${error instanceof Error ? error.message : String(error)}`)
+      }
+    }
+
+    return {
+      markdown: formatWritingEntitySearchMarkdown(items),
+      searchedNames: items.map((item) => item.name),
+      notes,
+    }
+  } catch (error) {
+    rethrowIfUserAbort(error, input.signal)
+    notes.push(`实体补搜失败:${error instanceof Error ? error.message : String(error)}`)
+    return { markdown: "", searchedNames: [], notes }
+  }
+}
+
+async function extractEntityNames(input: CollectWritingEntityWebSearchInput): Promise<string[]> {
+  const source = [
+    input.userRequest.trim(),
+    input.planBlueprint?.trim() ?? "",
+    input.outline?.trim() || input.contextPack.outline?.trim() || "",
+  ].filter(Boolean).join("\n\n").slice(0, SOURCE_TEXT_CHAR_CAP)
+
+  const raw = await completeText(input, [
+    {
+      role: "system",
+      content: "你提取小说写作请求里的人物名、势力名、地点名、功法或公开 IP 名。只输出 JSON。",
+    },
+    {
+      role: "user",
+      content: [
+        "从以下文本提取需要核实的专有名称,最多 12 个。",
+        "不要提取章节号、普通动词、纯原创占位词如「主角」。",
+        '只输出 JSON:{"entities":["名称"]}',
+        "",
+        source || "(无文本)",
+      ].join("\n"),
+    },
+  ])
+  return parseExtractedEntityNames(raw)
+}
+
+async function judgeNeedExternal(
+  input: CollectWritingEntityWebSearchInput,
+  unresolved: readonly string[],
+): Promise<string[]> {
+  const raw = await completeText(input, [
+    {
+      role: "system",
+      content: "你判断这些本地找不到的名字是否需要联网查公开资料。只输出 JSON。",
+    },
+    {
+      role: "user",
+      content: [
+        "下列名称在本库前文和实体表都未找到。",
+        "只把「公开 IP / 真实历史或现实设定 / 你明确理解不了或本地解释对不上」的名字放入 needExternal。",
+        "原创角色、可按大纲自编的名字不要放入。",
+        '只输出 JSON:{"needExternal":["名称"]}',
+        "",
+        unresolved.join("\n"),
+      ].join("\n"),
+    },
+  ])
+  return parseNeedExternalNames(raw, unresolved)
+}
+
+async function completeText(
+  input: CollectWritingEntityWebSearchInput,
+  messages: ChatMessage[],
+): Promise<string> {
+  let result = ""
+  await input.streamChat(
+    input.llmConfig,
+    messages,
+    {
+      onToken: (token) => { result += token },
+      onDone: () => {},
+      onError: () => {},
+    },
+    input.signal,
+  )
+  return result.trim()
+}
+
+function parseJsonPayload(text: string): unknown | null {
+  const trimmed = text.trim()
+  if (!trimmed) return null
+  const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i)
+  const candidates = [fenced?.[1]?.trim(), trimmed].filter((item): item is string => Boolean(item))
+  for (const candidate of candidates) {
+    try {
+      return JSON.parse(candidate)
+    } catch {
+      const objectMatch = candidate.match(/\{[\s\S]*\}/)
+      if (objectMatch) {
+        try {
+          return JSON.parse(objectMatch[0])
+        } catch {
+          // continue
+        }
+      }
+      const arrayMatch = candidate.match(/\[[\s\S]*\]/)
+      if (arrayMatch) {
+        try {
+          return JSON.parse(arrayMatch[0])
+        } catch {
+          // continue
+        }
+      }
+    }
+  }
+  return null
+}
+
+function collectNameStrings(parsed: unknown): string[] {
+  if (!parsed) return []
+  if (Array.isArray(parsed)) {
+    return parsed.flatMap((item) => {
+      if (typeof item === "string") return [item]
+      if (item && typeof item === "object" && "name" in item) {
+        return [String((item as { name?: unknown }).name ?? "")]
+      }
+      return []
+    })
+  }
+  if (typeof parsed !== "object") return []
+  const record = parsed as Record<string, unknown>
+  const list = record.entities ?? record.names ?? record.needExternal
+  return collectNameStrings(Array.isArray(list) ? list : [])
+}
+
+function uniqueNames(names: readonly string[]): string[] {
+  const output: string[] = []
+  for (const raw of names) {
+    const name = raw.trim()
+    if (name.length < MIN_NAME_LENGTH || output.includes(name)) continue
+    output.push(name)
+  }
+  return output
+}