瀏覽代碼

fix(writing): 实体补搜摘要按人名分组,落盘保留完整搜索结果

工作流面板改成按搜索名分组展示标题、snippet 和站点名,不再铺完整 URL。
tool result / 聊天 JSON 同时写入 content 与 items(title/url/snippet/source),旧的「已搜索 + 标题 URL」字符串仍可显示。

Co-authored-by: darknessomi <darknessomi@users.noreply.github.com>
Cursor Agent 3 周之前
父節點
當前提交
475dd9ccaf

+ 64 - 0
src/components/chat/agent-tool-call-message.spec.tsx

@@ -637,6 +637,70 @@ describe("AgentToolCallMessage", () => {
     expect(host.textContent).toContain("https://example.test/hr")
     expect(host.textContent).toContain("黄蓉简介")
   })
+
+  it("shows grouped writing search summaries without dumping persisted URLs", async () => {
+    const persisted = JSON.stringify({
+      content: [
+        "已搜索:鲁茨科伊、哈斯布拉托夫",
+        "",
+        "鲁茨科伊",
+        "- 亚历山大·弗拉基米罗维奇·鲁茨科伊 · baike.com",
+        "  俄罗斯政治家,曾任副总统。",
+        "",
+        "哈斯布拉托夫",
+        "- 无可用结果",
+      ].join("\n"),
+      searchedNames: ["鲁茨科伊", "哈斯布拉托夫"],
+      notes: [],
+      items: [
+        {
+          name: "鲁茨科伊",
+          results: [{
+            title: "亚历山大·弗拉基米罗维奇·鲁茨科伊",
+            url: "https://m.baike.com/wikiid/123",
+            snippet: "俄罗斯政治家,曾任副总统。",
+            source: "baike.com",
+          }],
+        },
+        { name: "哈斯布拉托夫", results: [] },
+      ],
+    })
+
+    await act(async () => {
+      root.render(
+        <AgentToolCallMessage
+          toolCalls={[
+            {
+              id: "workflow-1:web_search",
+              parentCallId: "workflow-1",
+              name: "web_search",
+              params: {
+                query: "鲁茨科伊、哈斯布拉托夫",
+                title: "联网搜索",
+                sources: ["亚历山大·弗拉基米罗维奇·鲁茨科伊 · baike.com"],
+              },
+              result: persisted,
+              status: "done",
+              startedAt: 100,
+              finishedAt: 140,
+            },
+          ]}
+        />,
+      )
+    })
+
+    const searchButton = Array.from(host.querySelectorAll("button"))
+      .find((button) => button.textContent?.includes("联网搜索「鲁茨科伊、哈斯布拉托夫」"))
+    expect(searchButton).toBeDefined()
+    await act(async () => {
+      searchButton?.click()
+    })
+    expect(host.textContent).toContain("亚历山大·弗拉基米罗维奇·鲁茨科伊 · baike.com")
+    expect(host.textContent).toContain("俄罗斯政治家,曾任副总统。")
+    expect(host.textContent).toContain("哈斯布拉托夫")
+    expect(host.textContent).toContain("无可用结果")
+    expect(host.textContent).not.toContain("https://m.baike.com/wikiid/123")
+  })
 })
 
 describe("getToolCallDescription", () => {

+ 4 - 3
src/components/chat/agent-workflow-panel.tsx

@@ -1,5 +1,5 @@
 import { useMemo, useRef, useEffect } from "react"
-import { getWorkflowToolDescription } from "@/lib/agent/workflow-trace"
+import { getWorkflowToolDescription, getWorkflowToolResultDisplay } from "@/lib/agent/workflow-trace"
 import type { AgentRunRecord } from "@/lib/agent/types"
 import type { ContextTrace } from "@/lib/agent/context-trace"
 import { normalizeOutlineWriteTarget } from "@/lib/agent/tools/write-outline-node"
@@ -22,6 +22,7 @@ interface AgentWorkflowPanelProps {
 
 function adaptToolCall(call: ToolCallRecord): ToolCallEventItem {
   const isError = call.status === "error"
+  const displayResult = getWorkflowToolResultDisplay(call.result)
   return {
     id: call.id,
     name: call.name,
@@ -29,8 +30,8 @@ function adaptToolCall(call: ToolCallRecord): ToolCallEventItem {
     category: getTimelineToolCategory(call.name),
     status: call.status,
     params: call.params,
-    result: isError ? undefined : call.result,
-    error: isError ? call.result : undefined,
+    result: isError ? undefined : displayResult,
+    error: isError ? displayResult : undefined,
     startedAt: call.startedAt,
     finishedAt: call.finishedAt,
   }

+ 2 - 1
src/components/chat/tool-call-timeline.tsx

@@ -1,6 +1,7 @@
 import { useMemo, useState } from "react"
 import { BookOpen, ChevronDown, ChevronRight, Loader2, CheckCircle2, Pencil, XCircle, Zap, Cpu, Minimize2, Maximize2, AlertTriangle, Search, Filter } from "lucide-react"
 import type { AgentRunRecord } from "@/lib/agent/types"
+import { getWorkflowToolResultDisplay } from "@/lib/agent/workflow-trace"
 import { cn } from "@/lib/utils"
 
 export type ToolCallRecord = AgentRunRecord["toolCalls"][number]
@@ -328,7 +329,7 @@ function TimelineItem({
                   </div>
                 </div>
               )}
-              {call.result}
+              {getWorkflowToolResultDisplay(call.result)}
               {needsApproval && isWriteTool && onConfirmSave && onReject && (
                 <div className="mt-2 flex gap-2 border-t border-amber-200/60 pt-2 dark:border-amber-900/30">
                   <button

+ 4 - 3
src/components/sources/outline-workflow-stages.tsx

@@ -4,7 +4,7 @@ import type { ToolCallEventItem } from "@/components/common/timeline-types"
 import { compareToolCallsByStartedAt, createStreamingEventBuilder, filterToolCallsForDisplay, getTimelineToolCategory } from "@/components/common/timeline-types"
 import { EventStream } from "@/components/common/event-stream"
 import { extractThinkingContent } from "@/lib/novel/outline-stage-trace"
-import { getWorkflowToolDescription } from "@/lib/agent/workflow-trace"
+import { getWorkflowToolDescription, getWorkflowToolResultDisplay } from "@/lib/agent/workflow-trace"
 
 interface OutlineWorkflowStagesProps {
   toolCalls: ToolCallRecord[]
@@ -15,6 +15,7 @@ interface OutlineWorkflowStagesProps {
 function adaptToolCall(call: ToolCallRecord): ToolCallEventItem {
   const isError = call.status === "error"
   const callAny = call as any
+  const displayResult = getWorkflowToolResultDisplay(call.result)
   return {
     id: call.id,
     name: call.name,
@@ -27,8 +28,8 @@ function adaptToolCall(call: ToolCallRecord): ToolCallEventItem {
     category: getTimelineToolCategory(call.name),
     status: call.status,
     params: call.params as Record<string, unknown>,
-    result: isError ? undefined : call.result,
-    error: isError ? call.result : undefined,
+    result: isError ? undefined : displayResult,
+    error: isError ? displayResult : undefined,
     startedAt: callAny.startedAt,
     finishedAt: callAny.finishedAt,
   }

+ 30 - 0
src/lib/agent/activity-trace.spec.ts

@@ -323,4 +323,34 @@ describe("activity trace", () => {
       { title: "黄蓉", path: "https://example.test/hr", type: "web" },
     ])
   })
+
+  it("uses the grouped writing search summary as activity content and keeps item URLs in sourceRefs", () => {
+    const search = activityEventFromToolEvent({
+      type: "result",
+      callId: "search-grouped",
+      name: "web_search",
+      params: { query: "鲁茨科伊" },
+      result: JSON.stringify({
+        content: "已搜索:鲁茨科伊\n\n鲁茨科伊\n- 简介 · baike.com\n  摘要",
+        searchedNames: ["鲁茨科伊"],
+        notes: [],
+        items: [{
+          name: "鲁茨科伊",
+          results: [{
+            title: "简介",
+            url: "https://m.baike.com/wikiid/123",
+            snippet: "摘要",
+            source: "baike.com",
+          }],
+        }],
+      }),
+      timestamp: 120,
+    })
+    expect(search.content).toContain("已搜索:鲁茨科伊")
+    expect(search.content).toContain("摘要")
+    expect(search.content).not.toContain("https://m.baike.com/wikiid/123")
+    expect(search.sourceRefs).toEqual([
+      { title: "简介", path: "https://m.baike.com/wikiid/123", type: "web" },
+    ])
+  })
 })

+ 25 - 8
src/lib/agent/activity-trace.ts

@@ -5,6 +5,7 @@ import type {
   AgentStageTrace,
   AgentToolEvent,
 } from "./types"
+import { getWorkflowToolResultDisplay } from "./workflow-trace"
 
 const EMPTY_CONTENT = "本阶段未返回可展示内容。"
 const AGGREGATE_STAGE_IDS = new Set(["chapter_workflow", "react_tools"])
@@ -182,7 +183,7 @@ export function activityEventFromToolEvent(event: AgentToolEvent): AgentActivity
     stageId: inferStageIdFromToolName(event.name),
     kind,
     title: `${statusText}:${event.name}`,
-    content: event.result || event.preview || formatParams(event.params),
+    content: getWorkflowToolResultDisplay(event.result) || event.preview || formatParams(event.params),
     sourceRefs: inferSourceRefsFromToolEvent(event),
     toolCallId: event.callId,
     timestamp: event.timestamp,
@@ -197,15 +198,17 @@ function inferSourceRefsFromToolEvent(event: AgentToolEvent): AgentActivityEvent
   if (!parsed) return undefined
 
   if (Array.isArray(parsed.results)) {
-    const refs = parsed.results.flatMap((item) => {
+    const refs = webSourceRefsFromResults(parsed.results)
+    if (refs.length > 0) return refs
+  }
+
+  if (Array.isArray(parsed.items)) {
+    const refs = parsed.items.flatMap((item) => {
       if (!item || typeof item !== "object") return []
-      const record = item as Record<string, unknown>
-      const url = typeof record.url === "string" ? record.url.trim() : ""
-      const title = typeof record.title === "string" ? record.title.trim() : ""
-      if (!url && !title) return []
-      return [{ title: title || url, path: url || undefined, type: "web" }]
+      const results = (item as { results?: unknown }).results
+      return Array.isArray(results) ? webSourceRefsFromResults(results) : []
     })
-    return refs.length > 0 ? refs : undefined
+    if (refs.length > 0) return refs
   }
 
   const url = typeof parsed.url === "string" ? parsed.url.trim() : ""
@@ -214,6 +217,20 @@ function inferSourceRefsFromToolEvent(event: AgentToolEvent): AgentActivityEvent
   return [{ title: title || url, path: url || undefined, type: "web" }]
 }
 
+function webSourceRefsFromResults(value: unknown): NonNullable<AgentActivityEvent["sourceRefs"]> {
+  if (!Array.isArray(value)) return []
+  const refs: NonNullable<AgentActivityEvent["sourceRefs"]> = []
+  for (const item of value) {
+    if (!item || typeof item !== "object") continue
+    const record = item as Record<string, unknown>
+    const url = typeof record.url === "string" ? record.url.trim() : ""
+    const title = typeof record.title === "string" ? record.title.trim() : ""
+    if (!url && !title) continue
+    refs.push({ title: title || url, path: url || undefined, type: "web" })
+  }
+  return refs
+}
+
 function sourceRefsFromUnknown(value: unknown): NonNullable<AgentActivityEvent["sourceRefs"]> {
   if (!Array.isArray(value)) return []
   const refs: NonNullable<AgentActivityEvent["sourceRefs"]> = []

+ 49 - 1
src/lib/agent/workflow-trace.spec.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest"
-import { buildAgentWorkflowSteps, getWorkflowToolDescription, type WorkflowToolCall } from "./workflow-trace"
+import { buildAgentWorkflowSteps, getWorkflowToolDescription, getWorkflowToolResultDisplay, type WorkflowToolCall } from "./workflow-trace"
 import type { ContextTrace } from "./context-trace"
 
 function call(overrides: Partial<WorkflowToolCall> & Pick<WorkflowToolCall, "id" | "name">): WorkflowToolCall {
@@ -162,6 +162,21 @@ describe("getWorkflowToolDescription", () => {
       result: JSON.stringify({ status: "ok", query: "黄蓉", resultCount: 2, results: [] }),
     }))).toBe("联网搜索「黄蓉」(2 条来源)")
 
+    expect(getWorkflowToolDescription(call({
+      id: "search-grouped",
+      name: "web_search",
+      params: { query: "鲁茨科伊、哈斯布拉托夫" },
+      result: JSON.stringify({
+        content: "鲁茨科伊\n- 简介 · baike.com\n  摘要",
+        searchedNames: ["鲁茨科伊", "哈斯布拉托夫"],
+        notes: [],
+        items: [
+          { name: "鲁茨科伊", results: [{ title: "简介", url: "https://m.baike.com/wikiid/1", snippet: "摘要", source: "baike.com" }] },
+          { name: "哈斯布拉托夫", results: [{ title: "词条", url: "https://example.test/h", snippet: "另一段", source: "example.test" }] },
+        ],
+      }),
+    }))).toBe("联网搜索「鲁茨科伊、哈斯布拉托夫」(2 条来源)")
+
     expect(getWorkflowToolDescription(call({
       id: "search-error",
       name: "web_search",
@@ -193,3 +208,36 @@ describe("getWorkflowToolDescription", () => {
     }))).toContain("失败")
   })
 })
+
+describe("getWorkflowToolResultDisplay", () => {
+  it("extracts the grouped summary from persisted writing search JSON", () => {
+    const summary = [
+      "已搜索:鲁茨科伊",
+      "",
+      "鲁茨科伊",
+      "- 亚历山大·弗拉基米罗维奇·鲁茨科伊 · baike.com",
+      "  俄罗斯政治家",
+    ].join("\n")
+    const persisted = JSON.stringify({
+      content: summary,
+      searchedNames: ["鲁茨科伊"],
+      notes: [],
+      items: [{
+        name: "鲁茨科伊",
+        results: [{
+          title: "亚历山大·弗拉基米罗维奇·鲁茨科伊",
+          url: "https://m.baike.com/wikiid/123",
+          snippet: "俄罗斯政治家",
+          source: "baike.com",
+        }],
+      }],
+    })
+    expect(getWorkflowToolResultDisplay(persisted)).toBe(summary)
+    expect(getWorkflowToolResultDisplay(persisted)).not.toContain("https://m.baike.com")
+  })
+
+  it("keeps legacy 已搜索 + title URL strings as-is", () => {
+    const legacy = "已搜索:鲁茨科伊\n- 亚历山大·弗拉基米罗维奇·鲁茨科伊 https://m.baike.com/wikiid/123"
+    expect(getWorkflowToolResultDisplay(legacy)).toBe(legacy)
+  })
+})

+ 27 - 1
src/lib/agent/workflow-trace.ts

@@ -147,10 +147,36 @@ function shortenWebUrl(url: string): string {
   }
 }
 
+export function getWorkflowToolResultDisplay(result: string | undefined): string {
+  const parsed = parseJsonObject(result)
+  if (
+    parsed
+    && typeof parsed.content === "string"
+    && parsed.content.trim()
+    && (Array.isArray(parsed.items) || Array.isArray(parsed.searchedNames))
+  ) {
+    return parsed.content.trim()
+  }
+  return (result ?? "").trim()
+}
+
+function countWebSearchHits(parsed: Record<string, unknown> | null): number | undefined {
+  if (!parsed) return undefined
+  if (typeof parsed.resultCount === "number") return parsed.resultCount
+  if (!Array.isArray(parsed.items)) return undefined
+  let count = 0
+  for (const item of parsed.items) {
+    if (!item || typeof item !== "object") continue
+    const results = (item as { results?: unknown }).results
+    if (Array.isArray(results)) count += results.length
+  }
+  return count
+}
+
 function describeWebSearchCall(call: WorkflowToolCall): string {
   const query = getStringParam(call.params, "query", "keyword", "q")
   const parsed = parseJsonObject(call.result)
-  const resultCount = typeof parsed?.resultCount === "number" ? parsed.resultCount : undefined
+  const resultCount = countWebSearchHits(parsed)
   const message = typeof parsed?.message === "string" ? parsed.message.trim() : ""
   const status = typeof parsed?.status === "string" ? parsed.status : ""
 

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

@@ -1600,9 +1600,23 @@ describe("runDeepChapterGeneration", () => {
     expect(events.some((event) => event.type === "started" && event.name === "web_search")).toBe(true)
     const completed = events.find((event) => event.type === "completed" && event.name === "web_search")
     expect(completed?.params).toMatchObject({ query: "黄蓉" })
-    expect(completed?.result).toContain("黄蓉")
-    expect(completed?.result).toContain("https://example.test/hr")
-    expect(completed?.params?.sources).toEqual(expect.arrayContaining(["黄蓉简介 https://example.test/hr"]))
+    const persisted = JSON.parse(String(completed?.result ?? "{}")) as {
+      content?: string
+      searchedNames?: string[]
+      items?: Array<{ name: string; results: Array<{ title: string; url: string; snippet: string; source: string }> }>
+    }
+    expect(persisted.searchedNames).toEqual(["黄蓉"])
+    expect(persisted.items?.[0]?.results[0]).toEqual({
+      title: "黄蓉简介",
+      url: "https://example.test/hr",
+      snippet: "公开摘要",
+      source: "example.test",
+    })
+    expect(persisted.content).toContain("黄蓉")
+    expect(persisted.content).toContain("公开摘要")
+    expect(persisted.content).toContain("example.test")
+    expect(persisted.content).not.toContain("https://example.test/hr")
+    expect(completed?.params?.sources).toEqual(["黄蓉简介 · example.test"])
   })
 
   it("emits failed web_search workflow events when entity search errors", async () => {

+ 2 - 2
src/lib/novel/deep-chapter-generation.ts

@@ -31,7 +31,7 @@ import {
 } from "./context-engine";
 import {
   collectWritingEntityWebSearch,
-  formatWritingEntitySearchWorkflowResult,
+  serializeWritingEntitySearchWorkflowResult,
   writingEntitySearchSourceLabels,
   type CollectWritingEntityWebSearchInput,
   type WritingEntityWebSearchResult,
@@ -2452,7 +2452,7 @@ function emitWritingEntityWebSearchWorkflow(
     title: "联网搜索",
     ...(sources.length > 0 ? { sources } : {}),
   };
-  const output = formatWritingEntitySearchWorkflowResult(result);
+  const output = serializeWritingEntitySearchWorkflowResult(result);
   const failed = result.searchedNames.length === 0 && notes.length > 0;
   if (failed) {
     errorChapterWorkflowStep(callbacks, { ...spec, params }, output || notes.join("\n"));

+ 93 - 14
src/lib/novel/writing-entity-web-search.spec.ts

@@ -5,13 +5,16 @@ import type { ContextPack } from "./context-engine"
 import {
   buildLocalWritingCorpus,
   collectWritingEntityWebSearch,
+  displayWritingEntitySearchWorkflowContent,
   formatWritingEntitySearchMarkdown,
   formatWritingEntitySearchWorkflowResult,
   isLocallyResolvedEntity,
   isWebSearchConfigured,
   parseExtractedEntityNames,
   parseNeedExternalNames,
+  parseWritingEntitySearchWorkflowResult,
   selectUnresolvedEntities,
+  serializeWritingEntitySearchWorkflowResult,
   writingEntitySearchSourceLabels,
   WRITING_ENTITY_SEARCH_HEADING,
 } from "./writing-entity-web-search"
@@ -270,20 +273,96 @@ describe("formatWritingEntitySearchMarkdown", () => {
   it("returns empty string without results", () => {
     expect(formatWritingEntitySearchMarkdown([])).toBe("")
   })
+})
+
+describe("writing entity search workflow display and persistence", () => {
+  const groupedItems = [
+    {
+      name: "鲁茨科伊",
+      results: [
+        {
+          title: "亚历山大·弗拉基米罗维奇·鲁茨科伊",
+          url: "https://m.baike.com/wikiid/123",
+          snippet: "俄罗斯政治家,1991年至1993年任副总统。",
+          source: "m.baike.com",
+        },
+        {
+          title: "Alexander-Rutskoy",
+          url: "http://www.bing.com/dict/Alexander-Rutskoy",
+          snippet: "英语词典释义。",
+          source: "bing.com",
+        },
+      ],
+    },
+    {
+      name: "哈斯布拉托夫",
+      results: [
+        {
+          title: "鲁斯兰·哈斯布拉托夫",
+          url: "https://zh.wikipedia.org/wiki/%E9%B2%81%E6%96%AF%E5%85%B0%C2%B7%E5%93%88%E6%96%AF%E5%B8%83%E6%8B%89%E6%89%98%E5%A4%AB",
+          snippet: "前俄罗斯最高苏维埃主席。",
+          source: "zh.wikipedia.org",
+        },
+      ],
+    },
+    {
+      name: "格拉乔夫",
+      results: [],
+    },
+  ]
+
+  const searchResult = {
+    markdown: "",
+    searchedNames: ["鲁茨科伊", "哈斯布拉托夫", "格拉乔夫"],
+    notes: ["搜索「叶利钦」失败:网络超时"],
+    items: groupedItems,
+  }
+
+  it("groups the human summary by name with title and snippet, not bare URLs", () => {
+    const summary = formatWritingEntitySearchWorkflowResult(searchResult)
+    expect(summary).toContain("已搜索:鲁茨科伊、哈斯布拉托夫、格拉乔夫")
+    expect(summary).toMatch(/鲁茨科伊\n- 亚历山大·弗拉基米罗维奇·鲁茨科伊 · m\.baike\.com\n {2}俄罗斯政治家/)
+    expect(summary).toContain("Alexander-Rutskoy · bing.com")
+    expect(summary).toContain("英语词典释义。")
+    expect(summary).toContain("哈斯布拉托夫")
+    expect(summary).toContain("前俄罗斯最高苏维埃主席。")
+    expect(summary).toContain("格拉乔夫")
+    expect(summary).toContain("- 无可用结果")
+    expect(summary).toContain("搜索「叶利钦」失败:网络超时")
+    expect(summary).not.toMatch(/https?:\/\/\S+/)
+    expect(writingEntitySearchSourceLabels(groupedItems)).toEqual([
+      "亚历山大·弗拉基米罗维奇·鲁茨科伊 · m.baike.com",
+      "Alexander-Rutskoy · bing.com",
+      "鲁斯兰·哈斯布拉托夫 · zh.wikipedia.org",
+    ])
+  })
+
+  it("serializes the full title/url/snippet/source payload and can read it back", () => {
+    const serialized = serializeWritingEntitySearchWorkflowResult(searchResult)
+    const parsed = parseWritingEntitySearchWorkflowResult(serialized)
+    expect(parsed).not.toBeNull()
+    expect(parsed?.searchedNames).toEqual(["鲁茨科伊", "哈斯布拉托夫", "格拉乔夫"])
+    expect(parsed?.notes).toEqual(["搜索「叶利钦」失败:网络超时"])
+    expect(parsed?.items[0]?.results[0]).toEqual({
+      title: "亚历山大·弗拉基米罗维奇·鲁茨科伊",
+      url: "https://m.baike.com/wikiid/123",
+      snippet: "俄罗斯政治家,1991年至1993年任副总统。",
+      source: "m.baike.com",
+    })
+    expect(parsed?.items[2]).toEqual({ name: "格拉乔夫", results: [] })
+    expect(displayWritingEntitySearchWorkflowContent(serialized)).toBe(parsed?.content)
+    expect(displayWritingEntitySearchWorkflowContent(serialized)).not.toMatch(/https?:\/\/\S+/)
+    expect(JSON.parse(serialized).items[0].results[0].url).toBe("https://m.baike.com/wikiid/123")
+  })
 
-  it("formats workflow-visible search sources and failures", () => {
-    expect(formatWritingEntitySearchWorkflowResult({
-      markdown: "",
-      searchedNames: ["黄蓉"],
-      notes: [],
-      items: [{
-        name: "黄蓉",
-        results: [{ title: "黄蓉简介", url: "https://example.test/hr", snippet: "摘要", source: "example.test" }],
-      }],
-    })).toContain("https://example.test/hr")
-    expect(writingEntitySearchSourceLabels([{
-      name: "黄蓉",
-      results: [{ title: "黄蓉简介", url: "https://example.test/hr", snippet: "摘要", source: "example.test" }],
-    }])).toEqual(["黄蓉简介 https://example.test/hr"])
+  it("still displays legacy 已搜索 + title URL strings", () => {
+    const legacy = [
+      "已搜索:鲁茨科伊、哈斯布拉托夫、格拉乔夫",
+      "- 亚历山大·弗拉基米罗维奇·鲁茨科伊 https://m.baike.com/wikiid/123",
+      "- Alexander-Rutskoy http://www.bing.com/dict/Alexander-Rutskoy",
+    ].join("\n")
+    expect(parseWritingEntitySearchWorkflowResult(legacy)).toBeNull()
+    expect(displayWritingEntitySearchWorkflowContent(legacy)).toBe(legacy)
+    expect(displayWritingEntitySearchWorkflowContent(legacy)).toContain("https://m.baike.com/wikiid/123")
   })
 })

+ 146 - 14
src/lib/novel/writing-entity-web-search.ts

@@ -174,6 +174,16 @@ export function parseNeedExternalNames(text: string, candidates: readonly string
   return selected
 }
 
+export interface WritingEntitySearchWorkflowPersisted {
+  content: string
+  searchedNames: string[]
+  notes: string[]
+  items: Array<{ name: string; results: WebSearchResult[] }>
+}
+
+const DISPLAY_TITLE_MAX = 80
+const DISPLAY_SNIPPET_MAX = 140
+
 export function formatWritingEntitySearchMarkdown(
   items: Array<{ name: string; results: WebSearchResult[] }>,
 ): string {
@@ -195,24 +205,103 @@ export function formatWritingEntitySearchMarkdown(
 export function formatWritingEntitySearchWorkflowResult(
   result: Pick<WritingEntityWebSearchResult, "searchedNames" | "notes" | "items" | "markdown">,
 ): string {
-  const lines: string[] = []
+  const blocks: string[] = []
   if (result.searchedNames.length > 0) {
-    lines.push(`已搜索:${result.searchedNames.join("、")}`)
+    blocks.push(`已搜索:${result.searchedNames.join("、")}`)
   }
   for (const item of result.items ?? []) {
+    const lines = [`${item.name}`]
     if (item.results.length === 0) {
-      lines.push(`「${item.name}」无可用结果`)
-      continue
+      lines.push("- 无可用结果")
+    } else {
+      for (const source of item.results) {
+        const title = clipDisplayText(
+          source.title.trim() || writingEntitySearchSourceHost(source) || "未命名来源",
+          DISPLAY_TITLE_MAX,
+        )
+        const host = writingEntitySearchSourceHost(source)
+        const headline = host && host !== title ? `${title} · ${host}` : title
+        const snippet = clipDisplayText(source.snippet.replace(/\s+/g, " ").trim(), DISPLAY_SNIPPET_MAX)
+        lines.push(`- ${headline}`)
+        if (snippet) lines.push(`  ${snippet}`)
+      }
     }
-    for (const source of item.results) {
-      const title = source.title.trim() || source.url.trim() || source.source.trim() || "未命名来源"
-      const url = source.url.trim()
-      lines.push(`- ${title}${url ? ` ${url}` : ""}`)
+    blocks.push(lines.join("\n"))
+  }
+  if (result.notes.length > 0) {
+    blocks.push(result.notes.join("\n"))
+  }
+  if (blocks.length === 0 && result.markdown.trim()) return result.markdown.trim()
+  return blocks.join("\n\n")
+}
+
+export function serializeWritingEntitySearchWorkflowResult(
+  result: Pick<WritingEntityWebSearchResult, "searchedNames" | "notes" | "items" | "markdown">,
+): string {
+  const items = (result.items ?? []).map((item) => ({
+    name: item.name,
+    results: item.results.map((source) => ({
+      title: source.title,
+      url: source.url,
+      snippet: source.snippet,
+      source: source.source,
+    })),
+  }))
+  const persisted: WritingEntitySearchWorkflowPersisted = {
+    content: formatWritingEntitySearchWorkflowResult({ ...result, items }),
+    searchedNames: [...result.searchedNames],
+    notes: [...result.notes],
+    items,
+  }
+  return JSON.stringify(persisted)
+}
+
+export function parseWritingEntitySearchWorkflowResult(
+  text: string | undefined,
+): WritingEntitySearchWorkflowPersisted | null {
+  if (!text?.trim()) return null
+  try {
+    const parsed = JSON.parse(text) as unknown
+    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null
+    const record = parsed as Record<string, unknown>
+    const hasItems = Array.isArray(record.items)
+    const hasSearchedNames = Array.isArray(record.searchedNames)
+    if (!hasItems && !hasSearchedNames) return null
+
+    const items = hasItems ? normalizePersistedSearchItems(record.items) : []
+    const searchedNames = hasSearchedNames
+      ? record.searchedNames.filter((name): name is string => typeof name === "string" && name.trim().length > 0)
+      : items.map((item) => item.name)
+    const notes = Array.isArray(record.notes)
+      ? record.notes.filter((note): note is string => typeof note === "string")
+      : []
+    const content = typeof record.content === "string" ? record.content : ""
+    return {
+      content,
+      searchedNames,
+      notes,
+      items,
     }
+  } catch {
+    return null
   }
-  lines.push(...result.notes)
-  if (lines.length === 0 && result.markdown.trim()) return result.markdown.trim()
-  return lines.join("\n")
+}
+
+export function displayWritingEntitySearchWorkflowContent(text: string | undefined): string {
+  const parsed = parseWritingEntitySearchWorkflowResult(text)
+  if (!parsed) return (text ?? "").trim()
+  return parsed.content.trim() || formatWritingEntitySearchWorkflowResult(parsed)
+}
+
+export function writingEntitySearchSourceHost(
+  result: Pick<WebSearchResult, "url" | "source">,
+): string {
+  const source = result.source.trim()
+  if (source) {
+    if (/^https?:\/\//i.test(source)) return hostnameFromUrl(source)
+    if (!source.includes("/")) return source
+  }
+  return hostnameFromUrl(result.url.trim())
 }
 
 export function writingEntitySearchSourceLabels(
@@ -222,9 +311,12 @@ export function writingEntitySearchSourceLabels(
   const seen = new Set<string>()
   for (const item of items ?? []) {
     for (const source of item.results) {
-      const title = source.title.trim() || source.url.trim() || source.source.trim()
-      const url = source.url.trim()
-      const label = url ? `${title} ${url}` : title
+      const title = clipDisplayText(
+        source.title.trim() || writingEntitySearchSourceHost(source) || source.url.trim(),
+        DISPLAY_TITLE_MAX,
+      )
+      const host = writingEntitySearchSourceHost(source)
+      const label = host && host !== title ? `${title} · ${host}` : title
       if (!label || seen.has(label)) continue
       seen.add(label)
       labels.push(label)
@@ -233,6 +325,46 @@ export function writingEntitySearchSourceLabels(
   return labels
 }
 
+function normalizePersistedSearchItems(value: unknown): Array<{ name: string; results: WebSearchResult[] }> {
+  if (!Array.isArray(value)) return []
+  const items: Array<{ name: string; results: WebSearchResult[] }> = []
+  for (const item of value) {
+    if (!item || typeof item !== "object") continue
+    const record = item as Record<string, unknown>
+    const name = typeof record.name === "string" ? record.name.trim() : ""
+    if (!name) continue
+    const results: WebSearchResult[] = []
+    if (Array.isArray(record.results)) {
+      for (const result of record.results) {
+        if (!result || typeof result !== "object") continue
+        const source = result as Record<string, unknown>
+        results.push({
+          title: typeof source.title === "string" ? source.title : "",
+          url: typeof source.url === "string" ? source.url : "",
+          snippet: typeof source.snippet === "string" ? source.snippet : "",
+          source: typeof source.source === "string" ? source.source : "",
+        })
+      }
+    }
+    items.push({ name, results })
+  }
+  return items
+}
+
+function hostnameFromUrl(url: string): string {
+  if (!url) return ""
+  try {
+    return new URL(url).hostname.replace(/^www\./, "")
+  } catch {
+    return ""
+  }
+}
+
+function clipDisplayText(value: string, maxLength: number): string {
+  if (!value || value.length <= maxLength) return value
+  return `${value.slice(0, maxLength)}…`
+}
+
 export async function collectWritingEntityWebSearch(
   input: CollectWritingEntityWebSearchInput,
 ): Promise<WritingEntityWebSearchResult> {