diff --git a/src/main/core/agent/__tests__/research-loop-guard.test.ts b/src/main/core/agent/__tests__/research-loop-guard.test.ts index 09b502b..307723b 100644 --- a/src/main/core/agent/__tests__/research-loop-guard.test.ts +++ b/src/main/core/agent/__tests__/research-loop-guard.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { ResearchLoopGuard } from "../research-loop-guard"; +import { ResearchLoopGuard, WebSearchRunGuard } from "../research-loop-guard"; const call = (toolName: string, args: unknown, index: number) => ({ toolName, @@ -193,3 +193,103 @@ describe("ResearchLoopGuard", () => { }); }); }); + +describe("WebSearchRunGuard", () => { + it("blocks an exact retry after a failed search and removes the tool", () => { + const guard = new WebSearchRunGuard(); + const first = call("webSearch", { query: "latest news" }, 1); + + expect(guard.beforeToolCall(first)).toEqual({ allow: true }); + guard.observeToolResult({ + ...first, + rawOutput: { + success: false, + retryable: false, + error: "Tavily 400 Bad Request", + }, + }); + + expect( + guard.beforeToolCall(call("webSearch", { query: "latest news" }, 2)), + ).toMatchObject({ + allow: false, + result: { + success: false, + blocked: true, + retryable: false, + }, + }); + expect( + guard.getStepPolicy(["readFile", "webSearch", "webFetch"]), + ).toMatchObject({ + activeTools: ["readFile", "webFetch"], + toolChoice: "auto", + message: expect.stringMatching(/disabled|failed|continue/i), + }); + }); + + it("opens the circuit after two consecutive failed searches", () => { + const guard = new WebSearchRunGuard(); + + for (let index = 1; index <= 2; index++) { + const toolCall = call( + "webSearch", + { query: `different query ${index}` }, + index, + ); + expect(guard.beforeToolCall(toolCall)).toEqual({ allow: true }); + guard.observeToolResult({ + ...toolCall, + rawOutput: { + success: false, + retryable: index === 1, + error: `failure ${index}`, + }, + }); + } + + expect( + guard.beforeToolCall( + call("webSearch", { query: "third different query" }, 3), + ), + ).toMatchObject({ + allow: false, + result: { + success: false, + blocked: true, + }, + }); + }); + + it("allows four searches and disables further search calls", () => { + const guard = new WebSearchRunGuard(); + + for (let index = 1; index <= 4; index++) { + const toolCall = call( + "webSearch", + { query: `focused query ${index}` }, + index, + ); + expect(guard.beforeToolCall(toolCall)).toEqual({ allow: true }); + guard.observeToolResult({ + ...toolCall, + rawOutput: { + results: [{ url: `https://example.com/${index}` }], + }, + }); + } + + expect(guard.getStepPolicy(["webSearch", "webFetch"])).toMatchObject({ + activeTools: ["webFetch"], + message: expect.stringMatching(/budget|four|4/i), + }); + expect( + guard.beforeToolCall( + call("webSearch", { query: "one search too many" }, 5), + ), + ).toMatchObject({ + allow: false, + result: { success: false, blocked: true }, + }); + }); +}); diff --git a/src/main/core/agent/__tests__/web-search.test.ts b/src/main/core/agent/__tests__/web-search.test.ts index d37b7b0..869e358 100644 --- a/src/main/core/agent/__tests__/web-search.test.ts +++ b/src/main/core/agent/__tests__/web-search.test.ts @@ -33,8 +33,12 @@ describe("buildWebSearchTool", () => { resolveTavilyToken: async () => null, }); const out = (await tool.execute({ query: "anything" }, fakeCtx())) as { + success?: boolean; error?: string; + retryable?: boolean; }; + expect(out.success).toBe(false); + expect(out.retryable).toBe(false); expect(out.error).toMatch(/No Tavily API key/i); expect(fetchImpl).not.toHaveBeenCalled(); }); @@ -130,7 +134,30 @@ describe("buildWebSearchTool", () => { expect(value.length).toBeLessThan(10_000); }); - it("passes Tavily recency params through to the API body (topic/days/timeRange/dates)", async () => { + it("projects structured search failures to the model", async () => { + const tool = buildWebSearchTool({ + fetchImpl: vi.fn() as never, + resolveTavilyToken: async () => "tvly-test", + }); + const projected = await tool.toModelOutput?.({ + toolCallId: "search-error", + input: { query: "latest news" }, + output: { + success: false, + status: 400, + retryable: false, + error: "Tavily 400 Bad Request: invalid filters", + hint: "Correct the parameters; do not retry the same call.", + }, + }); + + const value = (projected as { value: string }).value; + expect(value).toContain("Error: Tavily 400 Bad Request"); + expect(value).toContain("Retryable: false"); + expect(value).toContain("Correct the parameters"); + }); + + it("prefers absolute dates over relative recency filters", async () => { const fetchImpl = vi.fn(async () => jsonResponse({ answer: null, results: [] }), ); @@ -155,10 +182,10 @@ describe("buildWebSearchTool", () => { ]; const body = JSON.parse(init.body as string) as Record; expect(body.topic).toBe("news"); - expect(body.days).toBe(1); - expect(body.time_range).toBe("day"); expect(body.start_date).toBe("2026-05-18"); expect(body.end_date).toBe("2026-05-19"); + expect(body).not.toHaveProperty("days"); + expect(body).not.toHaveProperty("time_range"); }); it("does not include recency params when not provided (backward compatible)", async () => { @@ -182,17 +209,104 @@ describe("buildWebSearchTool", () => { expect(body).not.toHaveProperty("end_date"); }); - it("throws on non-2xx Tavily response", async () => { + it("returns a structured non-retryable error for Tavily 400 responses", async () => { const fetchImpl = vi.fn( async () => - new Response("rate limit", { status: 429, statusText: "Too Many" }), + new Response("invalid date filters", { + status: 400, + statusText: "Bad Request", + }), ); const tool = buildWebSearchTool({ fetchImpl: fetchImpl as never, resolveTavilyToken: async () => "tvly-test", }); - await expect(tool.execute({ query: "x" }, fakeCtx())).rejects.toThrow( - /Tavily 429/, + await expect(tool.execute({ query: "x" }, fakeCtx())).resolves.toEqual( + expect.objectContaining({ + success: false, + status: 400, + retryable: false, + error: expect.stringMatching(/Tavily 400.*invalid date filters/), + }), ); }); + + it("retries a transient Tavily failure once inside the tool", async () => { + vi.useFakeTimers(); + try { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response("temporary outage", { + status: 503, + statusText: "Unavailable", + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + answer: "Recovered", + results: [], + }), + ); + const tool = buildWebSearchTool({ + fetchImpl: fetchImpl as never, + resolveTavilyToken: async () => "tvly-test", + }); + + const resultPromise = tool.execute({ query: "x" }, fakeCtx()); + await vi.runAllTimersAsync(); + + await expect(resultPromise).resolves.toEqual( + expect.objectContaining({ answer: "Recovered" }), + ); + expect(fetchImpl).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("removes the abort listener after the retry delay completes", async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + const addEventListener = vi.spyOn(controller.signal, "addEventListener"); + const removeEventListener = vi.spyOn( + controller.signal, + "removeEventListener", + ); + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response("temporary outage", { + status: 503, + statusText: "Unavailable", + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + answer: "Recovered", + results: [], + }), + ); + const tool = buildWebSearchTool({ + fetchImpl: fetchImpl as never, + resolveTavilyToken: async () => "tvly-test", + }); + + const resultPromise = tool.execute( + { query: "x" }, + { ...fakeCtx(), signal: controller.signal }, + ); + await vi.runAllTimersAsync(); + await resultPromise; + + const abortListener = addEventListener.mock.calls.find( + ([type]) => type === "abort", + )?.[1]; + expect(abortListener).toBeDefined(); + expect(removeEventListener).toHaveBeenCalledWith("abort", abortListener); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/src/main/core/agent/research-loop-guard.ts b/src/main/core/agent/research-loop-guard.ts index 0da37dd..4ba52a5 100644 --- a/src/main/core/agent/research-loop-guard.ts +++ b/src/main/core/agent/research-loop-guard.ts @@ -155,6 +155,98 @@ const phaseTransitionResult = ( nextAction, }); +const failedToolOutput = (rawOutput: unknown): boolean => { + if (!rawOutput || typeof rawOutput !== "object") return false; + const output = rawOutput as { success?: unknown; error?: unknown }; + return output.success === false || Boolean(output.error); +}; + +const blockedSearchResult = (reason: string) => ({ + success: false, + blocked: true, + retryable: false, + error: reason, + hint: "Web search is disabled for the rest of this run. Continue from existing evidence or explain the limitation.", +}); + +export interface WebSearchRunGuardConfig { + maxCalls: number; + maxConsecutiveFailures: number; +} + +export const DEFAULT_WEB_SEARCH_RUN_GUARD_CONFIG: WebSearchRunGuardConfig = { + maxCalls: 4, + maxConsecutiveFailures: 2, +}; + +/** Per-run guard for bounded web search on the main chat path. */ +export class WebSearchRunGuard { + private readonly config: WebSearchRunGuardConfig; + private readonly failedCallSignatures = new Set(); + private calls = 0; + private consecutiveFailures = 0; + private blockedReason = ""; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_WEB_SEARCH_RUN_GUARD_CONFIG, ...config }; + } + + beforeToolCall(call: { + toolName: string; + toolCallId: string; + args: unknown; + }): BeforeToolCallDecision { + if (call.toolName !== WEB_SEARCH_TOOL) return { allow: true }; + const signature = callSignature(call.toolName, call.args); + if (this.failedCallSignatures.has(signature)) { + this.blockedReason = + "Web search was disabled because the same failed call was repeated."; + } + if (this.blockedReason) { + return { + allow: false, + reason: this.blockedReason, + result: blockedSearchResult(this.blockedReason), + }; + } + if (this.calls >= this.config.maxCalls) { + this.blockedReason = `Focused web search budget reached (${this.config.maxCalls} calls).`; + return { + allow: false, + reason: this.blockedReason, + result: blockedSearchResult(this.blockedReason), + }; + } + this.calls++; + if (this.calls >= this.config.maxCalls) { + this.blockedReason = `Focused web search budget reached (${this.config.maxCalls} calls).`; + } + return { allow: true }; + } + + observeToolResult(result: ToolObservedResult): void { + if (result.toolName !== WEB_SEARCH_TOOL) return; + if (!failedToolOutput(result.rawOutput)) { + this.consecutiveFailures = 0; + return; + } + this.failedCallSignatures.add(callSignature(result.toolName, result.args)); + this.consecutiveFailures++; + if (this.consecutiveFailures >= this.config.maxConsecutiveFailures) { + this.blockedReason = `Web search was disabled after ${this.config.maxConsecutiveFailures} consecutive failed searches.`; + } + } + + getStepPolicy(availableTools: string[]): ResearchStepPolicy | undefined { + if (!this.blockedReason) return undefined; + return { + activeTools: availableTools.filter((tool) => tool !== WEB_SEARCH_TOOL), + toolChoice: "auto", + message: `${this.blockedReason} Continue from the evidence already collected or explain the limitation; do not attempt another web search.`, + }; + } +} + /** Per-run research guard: bounded calls, exact-loop detection, and source novelty. */ export class ResearchLoopGuard { private readonly config: ResearchLoopGuardConfig; diff --git a/src/main/core/agent/tools/model-output.ts b/src/main/core/agent/tools/model-output.ts index 6279b35..f08b58d 100644 --- a/src/main/core/agent/tools/model-output.ts +++ b/src/main/core/agent/tools/model-output.ts @@ -272,6 +272,23 @@ export const projectWebSearchModelOutput = ({ return textModelOutput(`webSearch ${input.query ?? ""}\n${String(output)}`); } + const error = asString(output.error); + if (output.success === false && error) { + return textModelOutput( + [ + `webSearch ${input.query ?? ""}`.trim(), + `Error: ${error}`, + typeof output.status === "number" ? `Status: ${output.status}` : null, + typeof output.retryable === "boolean" + ? `Retryable: ${String(output.retryable)}` + : null, + asString(output.hint) ? `Hint: ${asString(output.hint)}` : null, + ] + .filter(Boolean) + .join("\n"), + ); + } + const results = Array.isArray(output.results) ? output.results : []; const images = Array.isArray(output.images) ? output.images : []; const lines = [ diff --git a/src/main/core/agent/tools/web-search.ts b/src/main/core/agent/tools/web-search.ts index 66e8700..bc9e6d3 100644 --- a/src/main/core/agent/tools/web-search.ts +++ b/src/main/core/agent/tools/web-search.ts @@ -67,24 +67,28 @@ const inputSchema = z.object({ .max(365) .optional() .describe( - "Restrict to results from the last N days. Only works with `topic: 'news'`. Examples: today/今天 → 1, this week/本周 → 7, recent month → 30.", + "Relative recency filter for `topic: 'news'`. Do not combine with `startDate` or `endDate`. Examples: today/今天 → 1, this week/本周 → 7, recent month → 30.", ), timeRange: z .enum(["day", "week", "month", "year"]) .optional() .describe( - "Generic recency window that works with any topic (use instead of `days` when topic is not 'news'). 'day' ≈ last 24h.", + "Relative recency window. Do not combine with `startDate` or `endDate`; absolute dates take precedence. 'day' ≈ last 24h.", ), startDate: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .optional() - .describe("Inclusive lower-bound date in YYYY-MM-DD."), + .describe( + "Inclusive lower-bound date in YYYY-MM-DD. Do not combine with `days` or `timeRange`.", + ), endDate: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .optional() - .describe("Inclusive upper-bound date in YYYY-MM-DD."), + .describe( + "Inclusive upper-bound date in YYYY-MM-DD. Do not combine with `days` or `timeRange`.", + ), }); interface RawTavilyResult { @@ -102,6 +106,25 @@ interface RawTavilyResponse { images?: RawTavilyImage[]; } +const TRANSIENT_RETRY_DELAY_MS = 200; + +const waitForRetry = (signal: AbortSignal): Promise => + new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + const onAbort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, TRANSIENT_RETRY_DELAY_MS); + signal.addEventListener("abort", onAbort, { once: true }); + }); + export const buildWebSearchTool = (deps: WebSearchDeps): ToolDefinition => ({ name: "webSearch", description: @@ -127,14 +150,18 @@ export const buildWebSearchTool = (deps: WebSearchDeps): ToolDefinition => ({ startDate, endDate, } = args as z.infer; + const hasAbsoluteDate = Boolean(startDate || endDate); const token = await deps.resolveTavilyToken(); if (!token) { return { + success: false, + retryable: false, error: "No Tavily API key configured. Open Settings → Credentials → Add Credential → kind: Tavily.", + hint: "Configure a Tavily credential or continue without web search; do not retry this call.", }; } - const res = await deps.fetchImpl("https://api.tavily.com/search", { + const request: RequestInit = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -147,18 +174,52 @@ export const buildWebSearchTool = (deps: WebSearchDeps): ToolDefinition => ({ include_image_descriptions: (includeImages ?? false) && (imageDescriptions ?? false), ...(topic ? { topic } : {}), - ...(days !== undefined ? { days } : {}), - ...(timeRange ? { time_range: timeRange } : {}), + ...(!hasAbsoluteDate && days !== undefined ? { days } : {}), + ...(!hasAbsoluteDate && timeRange ? { time_range: timeRange } : {}), ...(startDate ? { start_date: startDate } : {}), ...(endDate ? { end_date: endDate } : {}), }), signal: ctx.signal, - }); - if (!res.ok) { - throw new Error( - `Tavily ${res.status} ${res.statusText}: ${(await res.text()).slice(0, 200)}`, - ); + }; + let res: Response | undefined; + for (let attempt = 0; attempt < 2; attempt++) { + try { + res = await deps.fetchImpl("https://api.tavily.com/search", request); + } catch (error) { + if (ctx.signal.aborted) throw error; + if (attempt === 0) { + await waitForRetry(ctx.signal); + continue; + } + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + status: null, + retryable: true, + error: `Tavily network error: ${message}`, + hint: "The search service could not be reached after one internal retry. Continue without web search.", + }; + } + if (res.ok) break; + + const detail = (await res.text()).slice(0, 200); + const retryable = + res.status === 408 || res.status === 429 || res.status >= 500; + if (retryable && attempt === 0) { + await waitForRetry(ctx.signal); + continue; + } + return { + success: false, + status: res.status, + retryable, + error: `Tavily ${res.status} ${res.statusText}: ${detail}`, + hint: retryable + ? "The search service failed after one internal retry. Continue without web search." + : "This request will not succeed unchanged. Correct the request parameters or continue without web search; do not retry the same call.", + }; } + if (!res?.ok) throw new Error("Tavily search ended without a response"); const json = (await res.json()) as RawTavilyResponse; const images = (json.images ?? []) .map((img): { url: string; description?: string } | null => { diff --git a/src/main/ipc/__tests__/ai-handlers-media-runtime.test.ts b/src/main/ipc/__tests__/ai-handlers-media-runtime.test.ts index bd86ee5..e0c9dc2 100644 --- a/src/main/ipc/__tests__/ai-handlers-media-runtime.test.ts +++ b/src/main/ipc/__tests__/ai-handlers-media-runtime.test.ts @@ -60,6 +60,7 @@ const taskControlMock = vi.hoisted(() => ({ const agentLoopRunMock = vi.hoisted(() => vi.fn()); const agentLoopConfigs = vi.hoisted(() => [] as unknown[]); const compressContextMock = vi.hoisted(() => vi.fn()); +const toAiSdkToolsMock = vi.hoisted(() => vi.fn((_options: unknown) => ({}))); vi.mock("electron", () => ({ ipcMain: { @@ -176,7 +177,7 @@ vi.mock("../../skills-runtime", () => ({ vi.mock("../agent-tools", () => ({ buildAgentToolRegistry: vi.fn(() => ({ - toAiSdkTools: vi.fn(() => ({})), + toAiSdkTools: toAiSdkToolsMock, })), getAgentBrowserToolsDependencies: vi.fn(() => null), shouldEnableMemoryToolsForPrompt: vi.fn(() => false), @@ -419,6 +420,10 @@ describe("ai:executeTask media modality routing", () => { }) as { history?: Array<{ role: string; content: unknown }>; hooks?: { + resolveStepPolicy?: () => { + activeTools?: string[]; + message?: string; + }; transformStepContext?: ( messages: Array<{ role: "user"; content: string }>, signal: AbortSignal, @@ -428,6 +433,67 @@ describe("ai:executeTask media modality routing", () => { }; }; + it("wires the per-run web search circuit breaker into main chat", async () => { + toAiSdkToolsMock.mockReturnValueOnce({ + webFetch: {} as never, + webSearch: {} as never, + }); + const config = await runGpt55ChatTaskWithProviderCount(12_000); + const toolOptions = toAiSdkToolsMock.mock.calls.at(-1)?.[0] as + | { + beforeAnyToolCall?: ( + call: { + toolName: string; + toolCallId: string; + args: unknown; + }, + ctx: unknown, + ) => Promise<{ + allow: boolean; + result?: unknown; + }>; + onToolResult?: (result: { + toolName: string; + toolCallId: string; + args: unknown; + rawOutput: unknown; + }) => void; + } + | undefined; + expect(toolOptions?.beforeAnyToolCall).toBeTypeOf("function"); + expect(toolOptions?.onToolResult).toBeTypeOf("function"); + + const first = { + toolName: "webSearch", + toolCallId: "search-1", + args: { query: "latest news" }, + }; + await expect( + toolOptions?.beforeAnyToolCall?.(first, {} as never), + ).resolves.toEqual({ allow: true }); + toolOptions?.onToolResult?.({ + ...first, + rawOutput: { + success: false, + retryable: false, + error: "Tavily 400 Bad Request", + }, + }); + await expect( + toolOptions?.beforeAnyToolCall?.( + { ...first, toolCallId: "search-2" }, + {} as never, + ), + ).resolves.toMatchObject({ + allow: false, + result: { success: false, blocked: true }, + }); + expect(config.hooks?.resolveStepPolicy?.()).toMatchObject({ + activeTools: ["webFetch"], + message: expect.stringMatching(/disabled|failed/i), + }); + }); + it("forwards chat-level model and reasoning overrides to model resolution", async () => { const aiModels = await import("../ai-models"); diff --git a/src/main/ipc/ai-handlers.ts b/src/main/ipc/ai-handlers.ts index cf780d4..0c01ff2 100644 --- a/src/main/ipc/ai-handlers.ts +++ b/src/main/ipc/ai-handlers.ts @@ -63,6 +63,7 @@ import { createReflectionGate, defaultRules, } from "../core/agent/reflection-gate"; +import { WebSearchRunGuard } from "../core/agent/research-loop-guard"; import type { ClassifiedRetryError } from "../core/agent/retry"; import { composeBeforeToolCallHooks } from "../core/agent/tool-registry"; import type { ImagePart, VideoJobPart } from "../core/session/message-parts"; @@ -1110,14 +1111,19 @@ const handleTaskExecutionInner = async ( taskId: id, }) : undefined; + const webSearchRunGuard = new WebSearchRunGuard(); const registryTools = toolRegistry.toAiSdkTools({ ctxFactory: ({ toolCallId }) => ({ workspace, signal: controller.signal, toolCallId, }), - beforeAnyToolCall: composeBeforeToolCallHooks(browserPolicyHook), + beforeAnyToolCall: composeBeforeToolCallHooks( + browserPolicyHook, + async (call) => webSearchRunGuard.beforeToolCall(call), + ), beforeToolCall, + onToolResult: (result) => webSearchRunGuard.observeToolResult(result), // 当此任务的草稿计划在等待审批时,阻塞任何工具;无计划待审时 // 立即解决。 planGate: async () => { @@ -1256,6 +1262,8 @@ const handleTaskExecutionInner = async ( agentId: id, hooks: { reflect: reflectHook, + resolveStepPolicy: () => + webSearchRunGuard.getStepPolicy(Object.keys(agentTools)), transformStepContext: async (messages, signal) => { if (providerManagesContextCompression) { return { messages }; diff --git a/src/preload/index.ts b/src/preload/index.ts index 4b259f8..6650caa 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer, webUtils } from "electron"; +import { clipboard, contextBridge, ipcRenderer, webUtils } from "electron"; import type { TrashEntry } from "../main/core/agent/tools/trash"; import type { NativeSearchOptions, NativeSearchResult } from "../main/native"; @@ -127,6 +127,12 @@ type LlmModelDiscoveryRequest = * 这是主进程与渲染进程之间的唯一桥梁。 */ const api = { + // 系统剪贴板。通过 preload 调用 Electron 原生 API,避免宿主页面的 + // Clipboard Web API 被浏览器 guest 的 deny-by-default 权限策略拦截。 + clipboard: { + writeText: (text: string): void => clipboard.writeText(text), + }, + // 对话框 openDirectory: (defaultPath?: string) => ipcRenderer.invoke("dialog:openDirectory", defaultPath), diff --git a/src/renderer/components/ai-elements/__tests__/markdown-code-block.test.tsx b/src/renderer/components/ai-elements/__tests__/markdown-code-block.test.tsx new file mode 100644 index 0000000..e50c163 --- /dev/null +++ b/src/renderer/components/ai-elements/__tests__/markdown-code-block.test.tsx @@ -0,0 +1,64 @@ +import { parseHTML } from "linkedom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MarkdownCodeBlock } from "../markdown-code-block"; + +describe("MarkdownCodeBlock copy", () => { + let root: Root | null = null; + const preloadWriteText = vi.fn(); + const navigatorWriteText = vi.fn(); + + beforeEach(() => { + const { document, window } = parseHTML('
'); + Object.assign(window, { + filework: { + clipboard: { + writeText: preloadWriteText, + }, + }, + }); + vi.stubGlobal("window", window); + vi.stubGlobal("document", document); + vi.stubGlobal("HTMLElement", window.HTMLElement); + vi.stubGlobal("navigator", { + clipboard: { + writeText: navigatorWriteText, + }, + }); + ( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + root = createRoot(document.getElementById("root") as HTMLElement); + }); + + afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + vi.unstubAllGlobals(); + vi.clearAllMocks(); + root = null; + }); + + it("copies code through the trusted preload clipboard bridge", async () => { + await act(async () => { + root?.render( + + {"const answer = 42;\nconsole.log(answer);"} + , + ); + }); + + const copyButton = document.querySelector('button[aria-label="Copy code"]'); + expect(copyButton).not.toBeNull(); + await act(async () => { + copyButton?.dispatchEvent(new window.Event("click", { bubbles: true })); + }); + + expect(preloadWriteText).toHaveBeenCalledWith( + "const answer = 42;\nconsole.log(answer);", + ); + expect(navigatorWriteText).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/components/ai-elements/markdown-code-block.tsx b/src/renderer/components/ai-elements/markdown-code-block.tsx index 6422764..ed7ebef 100644 --- a/src/renderer/components/ai-elements/markdown-code-block.tsx +++ b/src/renderer/components/ai-elements/markdown-code-block.tsx @@ -161,10 +161,10 @@ const CopyButton = ({ raw }: { raw: string }) => { const onCopy = async () => { try { - await navigator.clipboard.writeText(raw); + await window.filework.clipboard.writeText(raw); setCopied(true); } catch { - // 剪贴板权限被拒:跳过 ✓ 闪烁;不弹出错误提示(聊天代码复制不属于关键 UX)。 + // 系统剪贴板写入失败:跳过 ✓ 闪烁;聊天代码复制不属于关键 UX。 } }; return ( diff --git a/src/renderer/components/chat/ChatPanel.tsx b/src/renderer/components/chat/ChatPanel.tsx index 1e883b4..2ca330b 100644 --- a/src/renderer/components/chat/ChatPanel.tsx +++ b/src/renderer/components/chat/ChatPanel.tsx @@ -216,10 +216,17 @@ const getLatestFormalContextTokens = ( const CopyMessageAction = ({ content }: { content: string }) => { const { LL } = useI18nContext(); const [copied, setCopied] = useState(false); - const handleCopy = () => { - void navigator.clipboard.writeText(content); - setCopied(true); - window.setTimeout(() => setCopied(false), 1500); + const handleCopy = async () => { + try { + await window.filework.clipboard.writeText(content); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch (error) { + console.warn( + "[chat] Failed to copy message:", + error instanceof Error ? error.message : error, + ); + } }; return ( ; }) => void) | null = null; +const preloadClipboardWriteText = vi.fn(); +const navigatorClipboardWriteText = vi.fn(); vi.mock("../../../i18n/i18n-react", () => ({ useI18nContext: () => ({ @@ -131,11 +133,13 @@ vi.mock("../../ai-elements/message", () => ({ MessageAction: ({ children, label, + onClick, }: { children: React.ReactNode; label?: string; + onClick?: () => void; }) => ( - ), @@ -276,6 +280,9 @@ describe("ChatPanel message rendering", () => { filework: { chatAttachBlob: vi.fn(), chatAttachFile: vi.fn(), + clipboard: { + writeText: preloadClipboardWriteText, + }, llmConfig: { get: vi.fn(() => Promise.resolve({ @@ -300,7 +307,9 @@ describe("ChatPanel message rendering", () => { vi.stubGlobal("window", window); vi.stubGlobal("document", document); vi.stubGlobal("HTMLElement", window.HTMLElement); - vi.stubGlobal("navigator", { clipboard: { writeText: vi.fn() } }); + vi.stubGlobal("navigator", { + clipboard: { writeText: navigatorClipboardWriteText }, + }); ( globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; @@ -664,6 +673,33 @@ describe("ChatPanel message rendering", () => { expect(document.querySelector('button[aria-label="复制"]')).not.toBeNull(); }); + it("copies a user message through the trusted preload clipboard bridge", async () => { + const user: ChatMessage = { + id: "user-copy", + sessionId: "session-1", + role: "user", + content: "帮我抓取 BBC 新闻并总结", + timestamp: "2026-07-31T12:00:00.000Z", + }; + + chatState.value = createChatState([user]); + await act(async () => { + root?.render(); + }); + + const copyButton = document.querySelector( + 'button[aria-label="复制"]', + ) as HTMLButtonElement | null; + expect(copyButton).not.toBeNull(); + await act(async () => { + copyButton?.click(); + }); + + expect(preloadClipboardWriteText).toHaveBeenCalledWith(user.content); + expect(navigatorClipboardWriteText).not.toHaveBeenCalled(); + expect(copyButton?.getAttribute("aria-label")).toBe("已复制"); + }); + it("keeps tool error details collapsed when opening a chat", async () => { const assistant: ChatMessage = { id: "assistant-tool-error",