Ver código fonte

fix(writing): 在写作工作流时间线中展现联网搜索 (#76)

章节写作(尤其严格模式实体补搜)原先只把联网结果注入上下文,
工作流面板看不到查询词、来源和失败。现在把 web_search / read_web_page
发成与大纲相同的 tool-call 时间线条目,并补上中文描述与测试。

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: darknessomi <darknessomi@users.noreply.github.com>
darknessomi 3 semanas atrás
pai
commit
580a0342a1

+ 71 - 1
src/components/chat/agent-tool-call-message.spec.tsx

@@ -571,6 +571,72 @@ describe("AgentToolCallMessage", () => {
     expect(toolItemButton?.getAttribute("aria-expanded")).toBe("false")
     expect(host.textContent).not.toContain("第一章内容")
   })
+
+  it("renders writing workflow web search and webpage reads with query, sources, and failure", async () => {
+    await act(async () => {
+      root.render(
+        <AgentToolCallMessage
+          toolCalls={[
+            {
+              id: "workflow-1:web_search",
+              parentCallId: "workflow-1",
+              name: "web_search",
+              params: {
+                query: "黄蓉",
+                sources: ["黄蓉简介 https://example.test/hr"],
+              },
+              result: "已搜索:黄蓉\n- 黄蓉简介 https://example.test/hr",
+              status: "done",
+              startedAt: 100,
+              finishedAt: 140,
+            },
+            {
+              id: "workflow-1:read_web_page",
+              parentCallId: "workflow-1",
+              name: "read_web_page",
+              params: { url: "https://example.test/hr" },
+              result: JSON.stringify({
+                status: "ok",
+                url: "https://example.test/hr",
+                title: "黄蓉简介",
+                content: "黄蓉是郭靖的妻子。",
+              }),
+              status: "done",
+              startedAt: 150,
+              finishedAt: 180,
+            },
+            {
+              id: "workflow-1:web_search-failed",
+              parentCallId: "workflow-1",
+              name: "web_search",
+              params: { query: "李鸿章" },
+              result: "搜索「李鸿章」失败:网络超时",
+              status: "error",
+              startedAt: 190,
+              finishedAt: 200,
+            },
+          ]}
+        />,
+      )
+    })
+
+    expect(host.textContent).toContain("联网搜索「黄蓉」")
+    expect(host.textContent).toContain("读取网页「黄蓉简介」")
+    expect(host.textContent).toContain("联网搜索「李鸿章」失败")
+    expect(host.textContent).toContain("失败")
+    expect(host.textContent).toContain("已完成")
+    expect(host.textContent).not.toContain("web_search")
+    expect(host.textContent).not.toContain("read_web_page")
+
+    const searchButton = Array.from(host.querySelectorAll("button"))
+      .find((button) => button.textContent?.includes("联网搜索「黄蓉」"))
+    expect(searchButton).toBeDefined()
+    await act(async () => {
+      searchButton?.click()
+    })
+    expect(host.textContent).toContain("https://example.test/hr")
+    expect(host.textContent).toContain("黄蓉简介")
+  })
 })
 
 describe("getToolCallDescription", () => {
@@ -591,13 +657,17 @@ describe("getToolCallDescription", () => {
       "write_outline_node",
       "write_memory",
       "apply_skill",
+      "web_search",
+      "read_web_page",
     ] as const
 
     for (const name of names) {
-      const desc = getToolCallDescription(name, { name: "测试", keyword: "测试", skillName: "测试" })
+      const desc = getToolCallDescription(name, { name: "测试", keyword: "测试", skillName: "测试", query: "测试", url: "https://example.test" })
       expect(desc).not.toBe(name)
       expect(desc.length).toBeGreaterThan(0)
     }
+    expect(getToolCallDescription("web_search", { query: "黄蓉" })).toBe("联网搜索「黄蓉」")
+    expect(getToolCallDescription("read_web_page", { url: "https://example.test/hr" })).toBe("读取网页「https://example.test/hr」")
   })
 
   it("falls back to tool name for unknown tools", () => {

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

@@ -3,8 +3,8 @@ import { getWorkflowToolDescription } 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"
-import { createStreamingEventBuilder, compareToolCallsByStartedAt, filterToolCallsForDisplay } from "@/components/common/timeline-types"
-import type { ToolCallEventItem, TimelineToolCategory } from "@/components/common/timeline-types"
+import { createStreamingEventBuilder, compareToolCallsByStartedAt, filterToolCallsForDisplay, getTimelineToolCategory } from "@/components/common/timeline-types"
+import type { ToolCallEventItem } from "@/components/common/timeline-types"
 import { EventStream } from "@/components/common/event-stream"
 
 type ToolCallRecord = AgentRunRecord["toolCalls"][number]
@@ -20,29 +20,13 @@ interface AgentWorkflowPanelProps {
   onReject?: (call: ToolCallRecord & { preview?: string }) => void
 }
 
-const WRITE_TOOLS = new Set(["write_chapter", "write_outline_node", "write_memory"])
-const READ_TOOLS = new Set([
-  "list_chapters", "list_outlines", "list_memories", "list_deductions",
-  "read_chapter", "read_outline", "read_memory", "read_deduction",
-  "read_chat_history", "read_outline_history", "search_chapters",
-  "chapter_context", "chapter_previous_analysis", "load_context", "trim_context",
-])
-const ACTION_TOOLS = new Set(["route_task", "apply_skill"])
-
-function getToolCallCategory(name: string): TimelineToolCategory {
-  if (WRITE_TOOLS.has(name)) return "write"
-  if (READ_TOOLS.has(name)) return "read"
-  if (ACTION_TOOLS.has(name)) return "action"
-  return "virtual"
-}
-
 function adaptToolCall(call: ToolCallRecord): ToolCallEventItem {
   const isError = call.status === "error"
   return {
     id: call.id,
     name: call.name,
     description: getWorkflowToolDescription(call),
-    category: getToolCallCategory(call.name),
+    category: getTimelineToolCategory(call.name),
     status: call.status,
     params: call.params,
     result: isError ? undefined : call.result,

+ 14 - 0
src/components/chat/tool-call-timeline.tsx

@@ -24,6 +24,8 @@ const READ_TOOLS = new Set([
   "list_outlines",
   "list_memories",
   "list_deductions",
+  "web_search",
+  "read_web_page",
 ])
 
 const WRITE_TOOLS = new Set([
@@ -78,6 +80,18 @@ export function getToolCallDescription(name: string, params: Record<string, unkn
       return `读取大纲会话「${params.conversationId}」`
     case "search_chapters":
       return `搜索章节关键词「${params.keyword}」`
+    case "web_search": {
+      const query = params.query || params.keyword
+      return query ? `联网搜索「${query}」` : "联网搜索"
+    }
+    case "read_web_page": {
+      const url = params.url || params.href
+      return url ? `读取网页「${url}」` : "读取网页"
+    }
+    case "summarize_search_results": {
+      const query = params.query
+      return query ? `整理「${query}」的搜索摘要` : "整理搜索结果摘要"
+    }
     case "list_chapters":
       return "列出所有章节"
     case "list_outlines":

+ 10 - 0
src/components/common/timeline-types.spec.ts

@@ -3,6 +3,7 @@ import {
   compareToolCallsByStartedAt,
   createStreamingEventBuilder,
   filterToolCallsForDisplay,
+  getTimelineToolCategory,
   interleaveThinkingWithToolCalls,
   type ToolCallEventItem,
 } from "./timeline-types"
@@ -38,6 +39,15 @@ describe("compareToolCallsByStartedAt", () => {
   })
 })
 
+describe("getTimelineToolCategory", () => {
+  it("classifies web research tools as read, matching outline/writing timelines", () => {
+    expect(getTimelineToolCategory("web_search")).toBe("read")
+    expect(getTimelineToolCategory("read_web_page")).toBe("read")
+    expect(getTimelineToolCategory("read_chapter")).toBe("read")
+    expect(getTimelineToolCategory("summarize_search_results")).toBe("action")
+  })
+})
+
 describe("filterToolCallsForDisplay", () => {
   it("hides parent tools once child steps exist", () => {
     const calls = [

+ 22 - 0
src/components/common/timeline-types.ts

@@ -2,6 +2,28 @@ import type { ToolCallStatus } from "@/lib/agent/types"
 
 export type TimelineToolCategory = "read" | "write" | "action" | "virtual"
 
+const WRITE_TOOLS = new Set([
+  "write_chapter",
+  "write_outline_node",
+  "write_memory",
+  "write_chapter_outline",
+])
+const READ_TOOLS = new Set([
+  "list_chapters", "list_outlines", "list_memories", "list_deductions",
+  "read_chapter", "read_outline", "read_memory", "read_deduction",
+  "read_chat_history", "read_outline_history", "search_chapters",
+  "chapter_context", "chapter_previous_analysis", "load_context", "trim_context",
+  "web_search", "read_web_page",
+])
+const ACTION_TOOLS = new Set(["route_task", "apply_skill", "summarize_search_results"])
+
+export function getTimelineToolCategory(name: string): TimelineToolCategory {
+  if (WRITE_TOOLS.has(name)) return "write"
+  if (READ_TOOLS.has(name)) return "read"
+  if (ACTION_TOOLS.has(name)) return "action"
+  return "virtual"
+}
+
 export function compareToolCallsByStartedAt(
   a: { id: string; startedAt?: number },
   b: { id: string; startedAt?: number },

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

@@ -1,7 +1,7 @@
 import React, { useMemo, useRef, useEffect } from "react"
 import type { ToolCallRecord } from "@/lib/agent/tool-events"
-import type { ToolCallEventItem, TimelineToolCategory } from "@/components/common/timeline-types"
-import { compareToolCallsByStartedAt, createStreamingEventBuilder, filterToolCallsForDisplay } from "@/components/common/timeline-types"
+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"
@@ -12,22 +12,6 @@ interface OutlineWorkflowStagesProps {
   isStreaming: boolean
 }
 
-const WRITE_TOOLS = new Set(["write_chapter", "write_outline_node", "write_memory", "write_chapter_outline"])
-const READ_TOOLS = new Set([
-  "list_chapters", "list_outlines", "list_memories", "list_deductions",
-  "read_chapter", "read_outline", "read_memory", "read_deduction",
-  "read_chat_history", "read_outline_history", "search_chapters",
-  "chapter_context", "chapter_previous_analysis", "load_context", "trim_context",
-])
-const ACTION_TOOLS = new Set(["route_task", "apply_skill"])
-
-function getToolCallCategory(name: string): TimelineToolCategory {
-  if (WRITE_TOOLS.has(name)) return "write"
-  if (READ_TOOLS.has(name)) return "read"
-  if (ACTION_TOOLS.has(name)) return "action"
-  return "virtual"
-}
-
 function adaptToolCall(call: ToolCallRecord): ToolCallEventItem {
   const isError = call.status === "error"
   const callAny = call as any
@@ -40,7 +24,7 @@ function adaptToolCall(call: ToolCallRecord): ToolCallEventItem {
       result: call.result,
       status: call.status,
     } as Parameters<typeof getWorkflowToolDescription>[0]),
-    category: getToolCallCategory(call.name),
+    category: getTimelineToolCategory(call.name),
     status: call.status,
     params: call.params as Record<string, unknown>,
     result: isError ? undefined : call.result,

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

@@ -1,5 +1,6 @@
 import { describe, expect, it } from "vitest"
 import {
+  activityEventFromToolEvent,
   appendAgentActivityEvent,
   applyAgentActivityEvent,
   createAgentActivityEvent,
@@ -291,4 +292,35 @@ describe("activity trace", () => {
     ])
     expect(getDefaultOpenAgentStageId(stages)).toBe("final_polish")
   })
+
+  it("places read_web_page and web_search on the external_search stage", () => {
+    const page = activityEventFromToolEvent({
+      type: "call_started",
+      callId: "page-1",
+      name: "read_web_page",
+      params: { url: "https://example.test/hr" },
+      timestamp: 100,
+    })
+    expect(page).toMatchObject({
+      stageId: "external_search",
+      kind: "web_search",
+    })
+
+    const search = activityEventFromToolEvent({
+      type: "result",
+      callId: "search-1",
+      name: "web_search",
+      params: { query: "黄蓉" },
+      result: JSON.stringify({
+        status: "ok",
+        resultCount: 1,
+        results: [{ title: "黄蓉", url: "https://example.test/hr", snippet: "", source: "example.test" }],
+      }),
+      timestamp: 120,
+    })
+    expect(search.stageId).toBe("external_search")
+    expect(search.sourceRefs).toEqual([
+      { title: "黄蓉", path: "https://example.test/hr", type: "web" },
+    ])
+  })
 })

+ 71 - 1
src/lib/agent/activity-trace.ts

@@ -183,11 +183,76 @@ export function activityEventFromToolEvent(event: AgentToolEvent): AgentActivity
     kind,
     title: `${statusText}:${event.name}`,
     content: event.result || event.preview || formatParams(event.params),
+    sourceRefs: inferSourceRefsFromToolEvent(event),
     toolCallId: event.callId,
     timestamp: event.timestamp,
   })
 }
 
+function inferSourceRefsFromToolEvent(event: AgentToolEvent): AgentActivityEvent["sourceRefs"] {
+  const fromParams = sourceRefsFromUnknown(event.params.sources)
+  if (fromParams.length > 0) return fromParams
+
+  const parsed = parseJsonObject(event.result)
+  if (!parsed) return undefined
+
+  if (Array.isArray(parsed.results)) {
+    const refs = parsed.results.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" }]
+    })
+    return refs.length > 0 ? refs : undefined
+  }
+
+  const url = typeof parsed.url === "string" ? parsed.url.trim() : ""
+  const title = typeof parsed.title === "string" ? parsed.title.trim() : ""
+  if (!url && !title) return undefined
+  return [{ title: title || url, path: url || undefined, type: "web" }]
+}
+
+function sourceRefsFromUnknown(value: unknown): NonNullable<AgentActivityEvent["sourceRefs"]> {
+  if (!Array.isArray(value)) return []
+  const refs: NonNullable<AgentActivityEvent["sourceRefs"]> = []
+  for (const item of value) {
+    if (typeof item === "string" && item.trim()) {
+      const [title, path] = splitSourceLabel(item.trim())
+      refs.push({ title, path, type: "web" })
+      continue
+    }
+    if (!item || typeof item !== "object") continue
+    const record = item as Record<string, unknown>
+    const title = typeof record.title === "string" ? record.title.trim() : ""
+    const path = typeof record.url === "string"
+      ? record.url.trim()
+      : typeof record.path === "string" ? record.path.trim() : ""
+    if (!title && !path) continue
+    refs.push({ title: title || path, path: path || undefined, type: typeof record.type === "string" ? record.type : "web" })
+  }
+  return refs
+}
+
+function splitSourceLabel(label: string): [string, string | undefined] {
+  const match = label.match(/^(.*?)(?:\s+[—-]\s+|\s+)(https?:\/\/\S+)$/)
+  if (match) return [match[1].trim() || match[2], match[2]]
+  if (/^https?:\/\//.test(label)) return [label, label]
+  return [label, undefined]
+}
+
+function parseJsonObject(text: string | undefined): Record<string, unknown> | null {
+  if (!text?.trim()) return null
+  try {
+    const parsed = JSON.parse(text) as unknown
+    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null
+    return parsed as Record<string, unknown>
+  } catch {
+    return null
+  }
+}
+
 function createStageFromEvent(event: AgentActivityEvent): AgentStageTrace {
   return {
     id: event.stageId,
@@ -281,6 +346,9 @@ function formatParams(params: Record<string, unknown>): string {
 }
 
 function inferStageIdFromToolName(name: string): string {
+  if (name === "web_search" || name === "read_web_page" || name === "summarize_search_results") {
+    return "external_search"
+  }
   if (/read|context|chapter_context/.test(name)) return "read_context"
   if (/skill/.test(name)) return "capability_selection"
   if (/web|search/.test(name)) return "external_search"
@@ -319,7 +387,9 @@ function inferStageTitle(event: AgentActivityEvent): string {
 function inferActivityKindFromToolName(name: string): AgentActivityKind {
   if (/skill/.test(name)) return "skill_used"
   if (/mcp/.test(name)) return "mcp_call"
-  if (/web|search/.test(name)) return "web_search"
+  if (name === "web_search" || name === "read_web_page" || name === "summarize_search_results" || /web|search/.test(name)) {
+    return "web_search"
+  }
   if (/read|context/.test(name)) return "read_source"
   return "tool_call"
 }

+ 51 - 0
src/lib/agent/tool-events.spec.ts

@@ -200,4 +200,55 @@ describe("applyAgentToolEvent", () => {
       status: "running",
     })
   })
+
+  it("maps web_search and read_web_page into the external_search stage with sources", () => {
+    const started = applyAgentToolActivityEvent(undefined, {
+      type: "call_started",
+      callId: "workflow-1:web_search",
+      parentCallId: "workflow-1",
+      name: "web_search",
+      params: { query: "黄蓉", title: "联网搜索" },
+      timestamp: 100,
+    })
+    expect(started[0]).toMatchObject({
+      id: "external_search",
+      title: "外部检索",
+      status: "running",
+    })
+    expect(started[0].events[0].title).toContain("联网搜索「黄蓉」")
+
+    const completed = applyAgentToolActivityEvent(started, {
+      type: "result",
+      callId: "workflow-1:web_search",
+      parentCallId: "workflow-1",
+      name: "web_search",
+      params: { query: "黄蓉", sources: ["黄蓉简介 https://example.test/hr"] },
+      result: JSON.stringify({
+        status: "ok",
+        query: "黄蓉",
+        resultCount: 1,
+        results: [{ title: "黄蓉简介", url: "https://example.test/hr", snippet: "摘要", source: "example.test" }],
+      }),
+      timestamp: 160,
+    })
+    expect(completed[0].events.at(-1)?.kind).toBe("web_search")
+    expect(completed[0].events.at(-1)?.sourceRefs).toEqual([
+      { title: "黄蓉简介", path: "https://example.test/hr", type: "web" },
+    ])
+
+    const page = activityEventFromAgentToolEvent({
+      type: "error",
+      callId: "read-page-1",
+      name: "read_web_page",
+      params: { url: "https://example.test/missing" },
+      result: JSON.stringify({ status: "error", url: "https://example.test/missing", message: "HTTP 状态码:404" }),
+      timestamp: 200,
+    })
+    expect(page).toMatchObject({
+      stageId: "external_search",
+      kind: "error",
+    })
+    expect(page?.title).toContain("读取网页")
+    expect(page?.title).toContain("失败")
+  })
 })

+ 18 - 0
src/lib/agent/tool-events.ts

@@ -1,5 +1,6 @@
 import type { AgentRunRecord, AgentStageTrace, AgentToolEvent } from "./types"
 import { activityEventFromToolEvent, applyAgentActivityEvent } from "./activity-trace"
+import { getWorkflowToolDescription, isWebResearchToolName } from "./workflow-trace"
 
 export type ToolCallRecord = AgentRunRecord["toolCalls"][number]
 type SettledToolCallStatus = "done" | "error" | "cancelled"
@@ -79,6 +80,23 @@ export function activityEventFromAgentToolEvent(event: AgentToolEvent) {
   const activity = activityEventFromToolEvent(event)
   const titleFromParams = typeof event.params.title === "string" ? event.params.title : ""
 
+  if (isWebResearchToolName(event.name)) {
+    const status = event.type === "error" ? "error" as const : event.type === "call_started" ? "running" as const : "done" as const
+    return {
+      ...activity,
+      stageId: "external_search",
+      kind: event.type === "error" ? "error" as const : "web_search" as const,
+      title: getWorkflowToolDescription({
+        id: event.callId,
+        name: event.name,
+        params: event.params,
+        result: event.result,
+        status,
+        startedAt: event.timestamp,
+      }),
+    }
+  }
+
   if (event.name.startsWith("chapter_")) {
     return {
       ...activity,

+ 46 - 0
src/lib/agent/workflow-trace.spec.ts

@@ -146,4 +146,50 @@ describe("getWorkflowToolDescription", () => {
     expect(getWorkflowToolDescription(call({ id: "deviation-repair", name: "chapter_plan_deviation_repair" }))).toBe("返修章节计划偏离点")
     expect(getWorkflowToolDescription(call({ id: "deviation-recheck", name: "chapter_plan_deviation_recheck" }))).toBe("复检章节计划履约度")
   })
+
+  it("describes web search and read webpage with query, sources, or failure", () => {
+    expect(getWorkflowToolDescription(call({
+      id: "search-running",
+      name: "web_search",
+      params: { query: "黄蓉" },
+      status: "running",
+    }))).toBe("联网搜索「黄蓉」")
+
+    expect(getWorkflowToolDescription(call({
+      id: "search-ok",
+      name: "web_search",
+      params: { query: "黄蓉" },
+      result: JSON.stringify({ status: "ok", query: "黄蓉", resultCount: 2, results: [] }),
+    }))).toBe("联网搜索「黄蓉」(2 条来源)")
+
+    expect(getWorkflowToolDescription(call({
+      id: "search-error",
+      name: "web_search",
+      params: { query: "郭靖" },
+      result: "搜索「郭靖」失败:timeout",
+      status: "error",
+    }))).toBe("联网搜索「郭靖」失败")
+
+    expect(getWorkflowToolDescription(call({
+      id: "page",
+      name: "read_web_page",
+      params: { url: "https://example.test/hr" },
+      result: JSON.stringify({ status: "ok", url: "https://example.test/hr", title: "黄蓉", content: "正文" }),
+    }))).toBe("读取网页「黄蓉」")
+
+    expect(getWorkflowToolDescription(call({
+      id: "page-error",
+      name: "read_web_page",
+      params: { url: "https://example.test/missing" },
+      status: "error",
+      result: JSON.stringify({ status: "error", url: "https://example.test/missing", message: "HTTP 状态码:404" }),
+    }))).toContain("读取网页")
+    expect(getWorkflowToolDescription(call({
+      id: "page-error",
+      name: "read_web_page",
+      params: { url: "https://example.test/missing" },
+      status: "error",
+      result: JSON.stringify({ status: "error", url: "https://example.test/missing", message: "HTTP 状态码:404" }),
+    }))).toContain("失败")
+  })
 })

+ 81 - 0
src/lib/agent/workflow-trace.ts

@@ -52,6 +52,7 @@ interface BuildAgentWorkflowStepsInput {
 }
 
 const LIST_TOOLS = new Set(["list_chapters", "list_outlines", "list_memories", "list_deductions"])
+const WEB_RESEARCH_TOOLS = new Set(["web_search", "read_web_page", "summarize_search_results"])
 const READ_TOOLS = new Set([
   "read_chapter",
   "read_outline",
@@ -60,6 +61,8 @@ const READ_TOOLS = new Set([
   "read_chat_history",
   "read_outline_history",
   "search_chapters",
+  "web_search",
+  "read_web_page",
 ])
 const WRITE_TOOLS = new Set(["write_chapter", "write_outline_node", "write_memory"])
 const SKILL_TOOLS = new Set(["apply_skill"])
@@ -111,6 +114,76 @@ function getPathLabel(value: string): string {
   return normalized.split("/").filter(Boolean).pop() || value
 }
 
+export function isWebResearchToolName(name: string): boolean {
+  return WEB_RESEARCH_TOOLS.has(name)
+}
+
+function clipDescription(value: string, maxLength = 80): string {
+  const normalized = value.replace(/\s+/g, " ").trim()
+  if (normalized.length <= maxLength) return normalized
+  return `${normalized.slice(0, maxLength)}…`
+}
+
+function parseJsonObject(text: string | undefined): Record<string, unknown> | null {
+  if (!text?.trim()) return null
+  try {
+    const parsed = JSON.parse(text) as unknown
+    if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null
+    return parsed as Record<string, unknown>
+  } catch {
+    return null
+  }
+}
+
+function shortenWebUrl(url: string): string {
+  if (!url) return ""
+  try {
+    const parsed = new URL(url)
+    const host = parsed.hostname.replace(/^www\./, "")
+    const path = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, "")
+    return `${host}${path}`
+  } catch {
+    return url
+  }
+}
+
+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 message = typeof parsed?.message === "string" ? parsed.message.trim() : ""
+  const status = typeof parsed?.status === "string" ? parsed.status : ""
+
+  if (call.status === "error" || status === "error") {
+    if (query && message) return `联网搜索「${query}」失败:${clipDescription(message)}`
+    if (query) return `联网搜索「${query}」失败`
+    return message ? `联网搜索失败:${clipDescription(message)}` : "联网搜索失败"
+  }
+  if (status === "not_configured") {
+    return query ? `联网搜索「${query}」未执行:未配置外部搜索` : "联网搜索未执行:未配置外部搜索"
+  }
+  if (query && resultCount != null) return `联网搜索「${query}」(${resultCount} 条来源)`
+  if (query) return `联网搜索「${query}」`
+  if (resultCount != null) return `联网搜索(${resultCount} 条来源)`
+  return "联网搜索"
+}
+
+function describeReadWebPageCall(call: WorkflowToolCall): string {
+  const url = getStringParam(call.params, "url", "href")
+  const parsed = parseJsonObject(call.result)
+  const pageTitle = typeof parsed?.title === "string" ? parsed.title.trim() : ""
+  const label = pageTitle || shortenWebUrl(url) || url
+  const message = typeof parsed?.message === "string" ? parsed.message.trim() : ""
+  const status = typeof parsed?.status === "string" ? parsed.status : ""
+
+  if (call.status === "error" || status === "error") {
+    if (label && message) return `读取网页「${label}」失败:${clipDescription(message)}`
+    if (label) return `读取网页「${label}」失败`
+    return message ? `读取网页失败:${clipDescription(message)}` : "读取网页失败"
+  }
+  return label ? `读取网页「${label}」` : "读取网页"
+}
+
 export function getWorkflowToolDescription(call: WorkflowToolCall): string {
   switch (call.name) {
     case "read_chapter": {
@@ -141,6 +214,14 @@ export function getWorkflowToolDescription(call: WorkflowToolCall): string {
       const keyword = getStringParam(call.params, "keyword", "query")
       return keyword ? `搜索章节关键词「${keyword}」` : "搜索章节资料"
     }
+    case "web_search":
+      return describeWebSearchCall(call)
+    case "read_web_page":
+      return describeReadWebPageCall(call)
+    case "summarize_search_results": {
+      const query = getStringParam(call.params, "query")
+      return query ? `整理「${query}」的搜索摘要` : "整理搜索结果摘要"
+    }
     case "list_chapters":
     case "list_outlines":
     case "list_memories":

+ 53 - 0
src/lib/novel/deep-chapter-generation.spec.ts

@@ -1573,6 +1573,59 @@ describe("runDeepChapterGeneration", () => {
     expect(seenPacks.some((pack) => pack.searchResults.includes(research))).toBe(true)
   })
 
+  it("emits web_search workflow events when strict mode finds external sources", async () => {
+    const events: Array<{ type: string; name: string; result?: string; params?: Record<string, unknown> }> = []
+    await runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "strict" },
+      { onWorkflowEvent: (event) => events.push(event) },
+      {
+        ...createDeps(),
+        collectWritingEntityWebSearch: vi.fn(async () => ({
+          markdown: "## 外部检索\n\n### 黄蓉\n- 黄蓉简介 https://example.test/hr",
+          searchedNames: ["黄蓉"],
+          notes: [],
+          items: [{
+            name: "黄蓉",
+            results: [{
+              title: "黄蓉简介",
+              url: "https://example.test/hr",
+              snippet: "公开摘要",
+              source: "example.test",
+            }],
+          }],
+        })),
+      },
+    )
+
+    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"]))
+  })
+
+  it("emits failed web_search workflow events when entity search errors", async () => {
+    const events: Array<{ type: string; name: string; result?: string; params?: Record<string, unknown> }> = []
+    await runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "strict" },
+      { onWorkflowEvent: (event) => events.push(event) },
+      {
+        ...createDeps(),
+        collectWritingEntityWebSearch: vi.fn(async () => ({
+          markdown: "",
+          searchedNames: [] as string[],
+          notes: ["搜索「李鸿章」失败:网络超时"],
+          items: [],
+        })),
+      },
+    )
+
+    const failed = events.find((event) => event.name === "web_search" && event.type === "error")
+    expect(failed).toBeDefined()
+    expect(failed?.result).toContain("网络超时")
+  })
+
   it("emits visible workflow events for the chapter multi-task loop", async () => {
     const deps = createDeps()
     const events: Array<{ type: string; id: string; name: string; title: string; result?: string }> = []

+ 51 - 14
src/lib/novel/deep-chapter-generation.ts

@@ -31,6 +31,8 @@ import {
 } from "./context-engine";
 import {
   collectWritingEntityWebSearch,
+  formatWritingEntitySearchWorkflowResult,
+  writingEntitySearchSourceLabels,
   type CollectWritingEntityWebSearchInput,
   type WritingEntityWebSearchResult,
 } from "./writing-entity-web-search";
@@ -2378,6 +2380,19 @@ async function maybeInjectWritingEntityWebSearch(args: {
   signal?: AbortSignal;
 }): Promise<ContextPack> {
   const collect = args.deps.collectWritingEntityWebSearch ?? collectWritingEntityWebSearch;
+  const stepSpec: ChapterWorkflowStepSpec = {
+    name: "web_search",
+    title: "联网搜索",
+  };
+  let searchStarted = false;
+  const startVisibleSearch = (query: string) => {
+    if (searchStarted) return;
+    searchStarted = true;
+    startChapterWorkflowStep(args.callbacks, {
+      ...stepSpec,
+      params: { query, title: "联网搜索" },
+    });
+  };
   try {
     args.callbacks.onThinking?.(
       formatStageThinking("联网搜索", "正在核对本库实体,必要时联网补搜..."),
@@ -2395,21 +2410,11 @@ async function maybeInjectWritingEntityWebSearch(args: {
       searchApiConfig: useWikiStore.getState().searchApiConfig,
       signal: args.signal,
       onRequestTrace: args.callbacks.onRequestTrace,
+      onSearchStart: (queries) => {
+        startVisibleSearch(queries.join("、"));
+      },
     });
-    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"),
-      });
-    }
+    emitWritingEntityWebSearchWorkflow(args.callbacks, stepSpec, result, startVisibleSearch);
     if (!result.markdown.trim()) return args.contextPack;
     return {
       ...args.contextPack,
@@ -2419,11 +2424,43 @@ async function maybeInjectWritingEntityWebSearch(args: {
     };
   } catch (error) {
     rethrowIfUserAbort(error, args.signal);
+    if (searchStarted) {
+      errorChapterWorkflowStep(args.callbacks, stepSpec, error);
+    }
     console.error("[deep-chapter-generation] 实体联网补搜失败:", error);
     return args.contextPack;
   }
 }
 
+function emitWritingEntityWebSearchWorkflow(
+  callbacks: DeepChapterGenerationCallbacks,
+  spec: ChapterWorkflowStepSpec,
+  result: WritingEntityWebSearchResult,
+  startVisibleSearch: (query: string) => void,
+): void {
+  const query = result.searchedNames.join("、");
+  const notes = result.notes.filter((note) => note !== "未配置外部搜索");
+  const hasVisibleSearch = Boolean(
+    query || result.markdown.trim() || notes.length > 0 || (result.items?.length ?? 0) > 0,
+  );
+  if (!hasVisibleSearch) return;
+
+  startVisibleSearch(query);
+  const sources = writingEntitySearchSourceLabels(result.items);
+  const params = {
+    query,
+    title: "联网搜索",
+    ...(sources.length > 0 ? { sources } : {}),
+  };
+  const output = formatWritingEntitySearchWorkflowResult(result);
+  const failed = result.searchedNames.length === 0 && notes.length > 0;
+  if (failed) {
+    errorChapterWorkflowStep(callbacks, { ...spec, params }, output || notes.join("\n"));
+    return;
+  }
+  completeChapterWorkflowStep(callbacks, spec, output, params);
+}
+
 async function safeBuildChapterContextPack(
   deps: DeepChapterGenerationDeps,
   projectPath: string,

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

@@ -6,11 +6,13 @@ import {
   buildLocalWritingCorpus,
   collectWritingEntityWebSearch,
   formatWritingEntitySearchMarkdown,
+  formatWritingEntitySearchWorkflowResult,
   isLocallyResolvedEntity,
   isWebSearchConfigured,
   parseExtractedEntityNames,
   parseNeedExternalNames,
   selectUnresolvedEntities,
+  writingEntitySearchSourceLabels,
   WRITING_ENTITY_SEARCH_HEADING,
 } from "./writing-entity-web-search"
 
@@ -211,11 +213,38 @@ describe("collectWritingEntityWebSearch", () => {
     })
     expect(search).toHaveBeenCalledWith("降龙十八掌", configuredSearch, 4)
     expect(result.searchedNames).toEqual(["降龙十八掌"])
+    expect(result.items?.[0]?.name).toBe("降龙十八掌")
     expect(result.markdown).toContain(WRITING_ENTITY_SEARCH_HEADING)
     expect(result.markdown).toContain("降龙十八掌")
     expect(result.markdown).toContain("公开资料摘要")
   })
 
+  it("notifies onSearchStart with the queries it is about to run", async () => {
+    const onSearchStart = vi.fn()
+    const search = vi.fn(async () => [{
+      title: "李鸿章",
+      url: "https://example.test/ljz",
+      snippet: "摘要",
+      source: "example.test",
+    }])
+    await collectWritingEntityWebSearch({
+      projectPath: "/project",
+      userRequest: "写一章李鸿章出场",
+      contextPack: pack,
+      streamChat: streamChatReturning([
+        '{"entities":["李鸿章"]}',
+        '{"needExternal":["李鸿章"]}',
+      ]),
+      llmConfig,
+      searchApiConfig: configuredSearch,
+      listEntityNames: async () => ["黄蓉"],
+      readPreviousBodies: async () => [],
+      search,
+      onSearchStart,
+    })
+    expect(onSearchStart).toHaveBeenCalledWith(["李鸿章"])
+  })
+
   it("does not search original names the model can invent", async () => {
     const search = vi.fn()
     const result = await collectWritingEntityWebSearch({
@@ -241,4 +270,20 @@ describe("formatWritingEntitySearchMarkdown", () => {
   it("returns empty string without results", () => {
     expect(formatWritingEntitySearchMarkdown([])).toBe("")
   })
+
+  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"])
+  })
 })

+ 50 - 4
src/lib/novel/writing-entity-web-search.ts

@@ -16,6 +16,7 @@ export interface WritingEntityWebSearchResult {
   markdown: string
   searchedNames: string[]
   notes: string[]
+  items?: Array<{ name: string; results: WebSearchResult[] }>
 }
 
 export interface CollectWritingEntityWebSearchInput {
@@ -37,6 +38,7 @@ export interface CollectWritingEntityWebSearchInput {
   searchApiConfig?: SearchApiConfig | null
   signal?: AbortSignal
   onRequestTrace?: StreamCallbacks["onRequestTrace"]
+  onSearchStart?: (queries: string[]) => void
   listEntityNames?: typeof listLocalEntityNames
   readPreviousBodies?: typeof readPreviousChapterBodies
   search?: typeof webSearch
@@ -190,12 +192,53 @@ export function formatWritingEntitySearchMarkdown(
   return [`## ${WRITING_ENTITY_SEARCH_HEADING}`, ...sections].join("\n\n")
 }
 
+export function formatWritingEntitySearchWorkflowResult(
+  result: Pick<WritingEntityWebSearchResult, "searchedNames" | "notes" | "items" | "markdown">,
+): string {
+  const lines: string[] = []
+  if (result.searchedNames.length > 0) {
+    lines.push(`已搜索:${result.searchedNames.join("、")}`)
+  }
+  for (const item of result.items ?? []) {
+    if (item.results.length === 0) {
+      lines.push(`「${item.name}」无可用结果`)
+      continue
+    }
+    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}` : ""}`)
+    }
+  }
+  lines.push(...result.notes)
+  if (lines.length === 0 && result.markdown.trim()) return result.markdown.trim()
+  return lines.join("\n")
+}
+
+export function writingEntitySearchSourceLabels(
+  items: Array<{ name: string; results: WebSearchResult[] }> | undefined,
+): string[] {
+  const labels: string[] = []
+  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
+      if (!label || seen.has(label)) continue
+      seen.add(label)
+      labels.push(label)
+    }
+  }
+  return labels
+}
+
 export async function collectWritingEntityWebSearch(
   input: CollectWritingEntityWebSearchInput,
 ): Promise<WritingEntityWebSearchResult> {
   const notes: string[] = []
   if (!isWebSearchConfigured(input.searchApiConfig)) {
-    return { markdown: "", searchedNames: [], notes: ["未配置外部搜索"] }
+    return { markdown: "", searchedNames: [], notes: ["未配置外部搜索"], items: [] }
   }
 
   throwIfAborted(input.signal)
@@ -221,15 +264,17 @@ export async function collectWritingEntityWebSearch(
     const extracted = await extractEntityNames(input)
     const unresolved = selectUnresolvedEntities(extracted, corpus, entityNames)
     if (unresolved.length === 0) {
-      return { markdown: "", searchedNames: [], notes }
+      return { markdown: "", searchedNames: [], notes, items: [] }
     }
 
     const needExternal = await judgeNeedExternal(input, unresolved)
     const queries = needExternal.slice(0, MAX_SEARCH_QUERIES)
     if (queries.length === 0) {
-      return { markdown: "", searchedNames: [], notes }
+      return { markdown: "", searchedNames: [], notes, items: [] }
     }
 
+    input.onSearchStart?.(queries)
+
     const items: Array<{ name: string; results: WebSearchResult[] }> = []
     for (const name of queries) {
       throwIfAborted(input.signal)
@@ -246,11 +291,12 @@ export async function collectWritingEntityWebSearch(
       markdown: formatWritingEntitySearchMarkdown(items),
       searchedNames: items.map((item) => item.name),
       notes,
+      items,
     }
   } catch (error) {
     rethrowIfUserAbort(error, input.signal)
     notes.push(`实体补搜失败:${error instanceof Error ? error.message : String(error)}`)
-    return { markdown: "", searchedNames: [], notes }
+    return { markdown: "", searchedNames: [], notes, items: [] }
   }
 }