Jelajahi Sumber

feat(tool): return image attachments from webfetch (#13331)

Ryan Vogel 2 bulan lalu
induk
melakukan
ba54cee55e

+ 26 - 2
packages/opencode/src/tool/webfetch.ts

@@ -3,6 +3,7 @@ import { Tool } from "./tool"
 import TurndownService from "turndown"
 import DESCRIPTION from "./webfetch.txt"
 import { abortAfterAny } from "../util/abort"
+import { Identifier } from "../id/id"
 
 const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
 const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
@@ -87,11 +88,34 @@ export const WebFetchTool = Tool.define("webfetch", {
       throw new Error("Response too large (exceeds 5MB limit)")
     }
 
-    const content = new TextDecoder().decode(arrayBuffer)
     const contentType = response.headers.get("content-type") || ""
-
+    const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
     const title = `${params.url} (${contentType})`
 
+    // Check if response is an image
+    const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
+
+    if (isImage) {
+      const base64Content = Buffer.from(arrayBuffer).toString("base64")
+      return {
+        title,
+        output: "Image fetched successfully",
+        metadata: {},
+        attachments: [
+          {
+            id: Identifier.ascending("part"),
+            sessionID: ctx.sessionID,
+            messageID: ctx.messageID,
+            type: "file",
+            mime,
+            url: `data:${mime};base64,${base64Content}`,
+          },
+        ],
+      }
+    }
+
+    const content = new TextDecoder().decode(arrayBuffer)
+
     // Handle content based on requested format and actual content type
     switch (params.format) {
       case "markdown":

+ 97 - 0
packages/opencode/test/tool/webfetch.test.ts

@@ -0,0 +1,97 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import { Instance } from "../../src/project/instance"
+import { WebFetchTool } from "../../src/tool/webfetch"
+
+const projectRoot = path.join(import.meta.dir, "../..")
+
+const ctx = {
+  sessionID: "test",
+  messageID: "message",
+  callID: "",
+  agent: "build",
+  abort: AbortSignal.any([]),
+  messages: [],
+  metadata: () => {},
+  ask: async () => {},
+}
+
+async function withFetch(
+  mockFetch: (input: string | URL | Request, init?: RequestInit) => Promise<Response>,
+  fn: () => Promise<void>,
+) {
+  const originalFetch = globalThis.fetch
+  globalThis.fetch = mockFetch as unknown as typeof fetch
+  try {
+    await fn()
+  } finally {
+    globalThis.fetch = originalFetch
+  }
+}
+
+describe("tool.webfetch", () => {
+  test("returns image responses as file attachments", async () => {
+    const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
+    await withFetch(
+      async () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
+      async () => {
+        await Instance.provide({
+          directory: projectRoot,
+          fn: async () => {
+            const webfetch = await WebFetchTool.init()
+            const result = await webfetch.execute({ url: "https://example.com/image.png", format: "markdown" }, ctx)
+            expect(result.output).toBe("Image fetched successfully")
+            expect(result.attachments).toBeDefined()
+            expect(result.attachments?.length).toBe(1)
+            expect(result.attachments?.[0].type).toBe("file")
+            expect(result.attachments?.[0].mime).toBe("image/png")
+            expect(result.attachments?.[0].url.startsWith("data:image/png;base64,")).toBe(true)
+          },
+        })
+      },
+    )
+  })
+
+  test("keeps svg as text output", async () => {
+    const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>hello</text></svg>'
+    await withFetch(
+      async () =>
+        new Response(svg, {
+          status: 200,
+          headers: { "content-type": "image/svg+xml; charset=UTF-8" },
+        }),
+      async () => {
+        await Instance.provide({
+          directory: projectRoot,
+          fn: async () => {
+            const webfetch = await WebFetchTool.init()
+            const result = await webfetch.execute({ url: "https://example.com/image.svg", format: "html" }, ctx)
+            expect(result.output).toContain("<svg")
+            expect(result.attachments).toBeUndefined()
+          },
+        })
+      },
+    )
+  })
+
+  test("keeps text responses as text output", async () => {
+    await withFetch(
+      async () =>
+        new Response("hello from webfetch", {
+          status: 200,
+          headers: { "content-type": "text/plain; charset=utf-8" },
+        }),
+      async () => {
+        await Instance.provide({
+          directory: projectRoot,
+          fn: async () => {
+            const webfetch = await WebFetchTool.init()
+            const result = await webfetch.execute({ url: "https://example.com/file.txt", format: "text" }, ctx)
+            expect(result.output).toBe("hello from webfetch")
+            expect(result.attachments).toBeUndefined()
+          },
+        })
+      },
+    )
+  })
+})