diff --git a/docs/acceptance/mcp-image-viewer-20261022/README.md b/docs/acceptance/mcp-image-viewer-20261022/README.md new file mode 100644 index 00000000..f7e865d7 --- /dev/null +++ b/docs/acceptance/mcp-image-viewer-20261022/README.md @@ -0,0 +1,43 @@ +# MCP 图片原图查看验收 + +MCP 工具返回图片后,原有页面只有行内缩略图,点击不能打开查看器。此次复用已有图片查看器,增加原图查看、缩放、复制及下载入口。 + +参考:[Claude Code 的 MCP 图片结果说明](https://code.claude.com/docs/en/mcp#images-in-tool-results)明确保留原始图片以便完整分辨率复用。UC 的查看器交互沿用项目已有附件图片能力。 + +## 版本与环境 + +- 变更前:`fc0935d6fc03ca7abccbd148ce52d26c2bc154f8`,即 PR #432 的当前分支。 +- 变更后:本 PR 中的 `mcp-content-preview` 及其图片查看器类型更新。 +- Windows / Microsoft Edge;桌面 1280×900,窄窗口 480×600。 +- 前后均使用同一生产 Go API、同一实际 MCP 工具返回记录。基线使用原生产前端包,候选使用本分支 `npm run build` 的产物。 + +## 实际数据与操作 + +使用隔离工作区和本地 HTTP MCP 服务,通过正常的服务注册、发现审批和能力审批流程,提交普通对话。测试模型依次调用 `mcp_tool_search` 和 `mcp_tool_call`,实际 MCP 服务的 `show_browser_image` 通过 JSON-RPC 返回 PNG。普通运行时验证、投影、保存结果;没有改写数据库、API 响应、浏览器请求或 DOM 来制造页面效果。 + +- 对话:`thread-run-20261021200036-135056050e4a` +- 执行:`run-20261021200036-135056050e4a` +- 工具记录:`toolu_2cf6f0ef3fc7ed12020c1bc8` +- 原图:1258×802,36,058 字节;来自本地网页的真实浏览器截图。 +- 原件与通过页面下载的文件 SHA-256 均为 `1caabbcbf77b317544d9040675eb72da092e7163b769ec64f6e218622f2562b8`。 + +在 Edge 展开工具详情,变更前点击图片没有查看器;变更后点击「查看原图」打开查看器。100% 模式实际显示 1258×802;「适应窗口」在 480×600 下完整容纳图片及操作按钮。点击复制后读取系统剪贴板,确认图片尺寸及解码后的 RGBA 像素与原图一致。Esc 关闭查看器并将焦点还给原图按钮。 + +## 检查 + +- `npx vitest run src/v2/components/mcp-content-preview.test.tsx src/v2/components/image-viewer.test.tsx`:11 项通过。 +- 新增原图入口回归测试在基线代码上失败,在修改后通过。 +- `npm run typecheck`、`npm run build`、`git diff --check` 通过。 +- 单元测试另外覆盖 GIF 原格式下载、保存内容变化时关闭旧预览并释放 Blob URL,以及桌面导航保护允许同源 Blob 下载。 +- OCR delegate 选出的 3 个产品文件全部审阅,测试文件补充检查,未发现需修复的问题。 + +## 真实截图 + +| 场景 | 变更前 | 变更后 | +| --- | --- | --- | +| 桌面点击图片 | ![变更前](before.png) | ![原图查看器](after.png) | +| 480×600 | ![窄窗口变更前](before-narrow.png) | ![窄窗口原图查看器](after-narrow.png) | + +新增的原图入口: + +![工具返回图片下方的原图入口](after-card.png) diff --git a/docs/acceptance/mcp-image-viewer-20261022/after-card.png b/docs/acceptance/mcp-image-viewer-20261022/after-card.png new file mode 100644 index 00000000..54503fab Binary files /dev/null and b/docs/acceptance/mcp-image-viewer-20261022/after-card.png differ diff --git a/docs/acceptance/mcp-image-viewer-20261022/after-narrow.png b/docs/acceptance/mcp-image-viewer-20261022/after-narrow.png new file mode 100644 index 00000000..7c16d27c Binary files /dev/null and b/docs/acceptance/mcp-image-viewer-20261022/after-narrow.png differ diff --git a/docs/acceptance/mcp-image-viewer-20261022/after.png b/docs/acceptance/mcp-image-viewer-20261022/after.png new file mode 100644 index 00000000..effc022d Binary files /dev/null and b/docs/acceptance/mcp-image-viewer-20261022/after.png differ diff --git a/docs/acceptance/mcp-image-viewer-20261022/before-narrow.png b/docs/acceptance/mcp-image-viewer-20261022/before-narrow.png new file mode 100644 index 00000000..b8f02cef Binary files /dev/null and b/docs/acceptance/mcp-image-viewer-20261022/before-narrow.png differ diff --git a/docs/acceptance/mcp-image-viewer-20261022/before.png b/docs/acceptance/mcp-image-viewer-20261022/before.png new file mode 100644 index 00000000..6c22a507 Binary files /dev/null and b/docs/acceptance/mcp-image-viewer-20261022/before.png differ diff --git a/web/src/v2/components/image-viewer.tsx b/web/src/v2/components/image-viewer.tsx index 08e704a6..451fe247 100644 --- a/web/src/v2/components/image-viewer.tsx +++ b/web/src/v2/components/image-viewer.tsx @@ -5,7 +5,7 @@ import { Copy, Download, Maximize2, Minus, Plus, X } from "lucide-react"; import type { WorkspaceImageAttachment } from "../../api/image-attachments"; import { useModalFocusTrap } from "../../hooks/use-modal-focus-trap"; -type PreviewImage = Pick; +type PreviewImage = Pick & { mime_type: WorkspaceImageAttachment["mime_type"] | "image/gif" }; export async function copyImageToClipboard(blob: Blob, image: PreviewImage, t: LocaleText): Promise { if (!navigator.clipboard?.write || typeof ClipboardItem === "undefined") throw new Error(t("当前浏览器不支持复制图片,请下载原图。", "This browser cannot copy images. Download the original.")); const png = clipboardPNG(blob, image, t); diff --git a/web/src/v2/components/mcp-content-preview.css b/web/src/v2/components/mcp-content-preview.css new file mode 100644 index 00000000..31e19dc9 --- /dev/null +++ b/web/src/v2/components/mcp-content-preview.css @@ -0,0 +1,5 @@ +.v2-mcp-image-preview { display:grid; justify-items:start; gap:6px; width:fit-content; max-width:100%; padding:0; border:0; background:transparent; color:inherit; font:inherit; text-align:left; cursor:zoom-in; } +.v2-mcp-image-preview > span { display:inline-flex; align-items:center; gap:5px; color:var(--v2-muted); font-size:12px; } +.v2-mcp-image-preview:disabled { cursor:default; } +.v2-mcp-image-preview:disabled > span { visibility:hidden; } +.v2-mcp-image-preview:focus-visible { outline:2px solid var(--v2-accent); outline-offset:4px; border-radius:8px; } diff --git a/web/src/v2/components/mcp-content-preview.test.tsx b/web/src/v2/components/mcp-content-preview.test.tsx index e9f22b59..f43b90a3 100644 --- a/web/src/v2/components/mcp-content-preview.test.tsx +++ b/web/src/v2/components/mcp-content-preview.test.tsx @@ -1,13 +1,76 @@ import type { ReactElement } from "react"; import type { RenderOptions } from "@testing-library/react"; import { LocaleProvider } from "../../lib/locale"; -import { fireEvent, render as renderComponent, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render as renderComponent, screen, within } from "@testing-library/react"; import { afterEach, expect, it, vi } from "vitest"; import type { APIClient } from "../../api/client"; import type { ThreadActivityToolDetailView } from "../../api/types"; import { ThreadActivityToolDetailPanel } from "./activity-detail"; +import { MCPContentPreview } from "./mcp-content-preview"; +import { installDesktopNavigationGuard } from "../../lib/desktop-navigation"; -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { cleanup(); delete window.go; vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +const pngData = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l9sAAAAASUVORK5CYII="; +function decodeImage() { + const image = screen.getByRole("img"); + Object.defineProperties(image, { naturalWidth: { value: 1 }, naturalHeight: { value: 1 } }); + fireEvent.load(image); +} + +it("opens saved MCP image bytes at full size, copies them, and permits a desktop download without fetching", async () => { + const url = `blob:${window.location.origin}/saved-mcp-image`; + vi.stubGlobal("URL", class extends URL { static createObjectURL = vi.fn(() => url); static revokeObjectURL = vi.fn(); }); + vi.stubGlobal("fetch", vi.fn()); + const clipboard: Record>[] = []; + vi.stubGlobal("ClipboardItem", class { constructor(data: Record>) { clipboard.push(data); } }); + vi.stubGlobal("navigator", Object.create(navigator, { clipboard: { value: { write: vi.fn(async () => {}) } } })); + window.go = { desktop: { DesktopBridge: { Bootstrap: vi.fn(), InstallSkillPackage: vi.fn(), SelectSkillPackage: vi.fn(), PreviewSkillPackage: vi.fn() } } }; + const removeGuard = installDesktopNavigationGuard(); + try { + render(); + const trigger = screen.getByRole("button", { name: "查看原图 result.png" }); + expect(trigger).toBeDisabled(); + decodeImage(); + expect(trigger).toBeEnabled(); + fireEvent.click(trigger); + const dialog = screen.getByRole("dialog", { name: "图片预览 result.png" }); + const view = within(dialog); + fireEvent.click(view.getByRole("button", { name: "100%" })); + expect(view.getByRole("img")).toHaveStyle({ width: "1px", height: "1px" }); + const download = view.getByRole("link", { name: "下载原图 result.png" }); + expect(download).toHaveAttribute("href", url); + expect(download).toHaveAttribute("download", "result.png"); + const allowedDownload = vi.fn((event: MouseEvent) => { expect(event.defaultPrevented).toBe(false); event.preventDefault(); }); + download.addEventListener("click", allowedDownload); + fireEvent.click(download); + expect(allowedDownload).toHaveBeenCalledTimes(1); + fireEvent.click(view.getByRole("button", { name: "复制图片" })); + expect(await screen.findByText("已复制图片")).toBeInTheDocument(); + const copied = await clipboard[0]["image/png"]; + expect(new Uint8Array(await copied.arrayBuffer())).toEqual(Uint8Array.from(atob(pngData), (character) => character.charCodeAt(0))); + expect(fetch).not.toHaveBeenCalled(); + fireEvent.keyDown(dialog, { key: "Escape" }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith(url); + } finally { removeGuard(); } +}); + +it("keeps GIF downloads in their original format and closes the old viewer when saved bytes change", () => { + const revoke = vi.fn(); + vi.stubGlobal("URL", class extends URL { static createObjectURL() { return "blob:original-gif"; } static revokeObjectURL = revoke; }); + const gif = { kind: "image" as const, mime_type: "image/gif", data_base64: "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", byte_size: 42, width: 1, height: 1 }; + const page = render(); + decodeImage(); + fireEvent.click(screen.getByRole("button", { name: "查看原图 mcp-image.gif" })); + expect(screen.getByRole("link", { name: "下载原图 mcp-image.gif" })).toHaveAttribute("download", "mcp-image.gif"); + page.rerender(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(revoke).toHaveBeenCalledWith("blob:original-gif"); + expect(screen.getByRole("button", { name: "查看原图 mcp-image.png" })).toBeDisabled(); +}); it("renders saved content in the actual activity panel without fetching resource links or executing HTML", () => { const fetchMock = vi.fn(); diff --git a/web/src/v2/components/mcp-content-preview.tsx b/web/src/v2/components/mcp-content-preview.tsx index 4467c470..dbcfe00b 100644 --- a/web/src/v2/components/mcp-content-preview.tsx +++ b/web/src/v2/components/mcp-content-preview.tsx @@ -1,7 +1,11 @@ import { useLocale } from "../../lib/locale"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { Maximize2 } from "lucide-react"; import type { ThreadActivityMCPContentView } from "../../api/types"; import { safeMCPContentLink } from "../../api/mcp-content"; +import { V2ImageViewer } from "./image-viewer"; +import "./image-input.css"; +import "./mcp-content-preview.css"; function ContentURI({ uri }: { uri: string }) { return safeMCPContentLink(uri) ? {uri} : {uri}; @@ -9,10 +13,33 @@ function ContentURI({ uri }: { uri: string }) { function EmbeddedImage({ content }: { content: ThreadActivityMCPContentView }) { const { t } = useLocale(); + const trigger = useRef(null); + const [dimensions, setDimensions] = useState<{ width: number; height: number }>(); + const [preview, setPreview] = useState<{ url: string; blob: Blob }>(); const [failed, setFailed] = useState(false); + useEffect(() => () => { if (preview) URL.revokeObjectURL(preview.url); }, [preview]); if (failed) return

{t("已保存的图片无法显示。", "Could not display the saved image.")}

; - return {content.name setFailed(true)} />; + const media = content.mime_type; + if (media !== "image/png" && media !== "image/jpeg" && media !== "image/webp" && media !== "image/gif") return null; + const url = `data:${media};base64,${content.data_base64}`; + const name = content.name || `mcp-image.${media === "image/jpeg" ? "jpg" : media.slice(6)}`; + return <> + + {preview && dimensions && setPreview(undefined)} returnFocusRef={trigger} />} + ; } function EmbeddedAudio({ content }: { content: ThreadActivityMCPContentView }) { @@ -33,7 +60,7 @@ export function MCPContentPreview({ contents, omitted = 0, truncated = false }: {(content.name || content.role) &&
{content.role ? `${content.role} · ` : ""}{content.name}
} {content.uri &&

} {(content.kind === "text" || content.kind === "resource_text") &&
{content.text}
} - {content.kind === "image" && } + {content.kind === "image" && } {content.kind === "audio" && } )} {truncated &&

{t("返回内容超过预览上限,当前显示部分内容。", "The response exceeds the preview limit. Only part is shown.")}

}