|
|
@@ -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
|
|
|
+}
|