Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/acceptance/mcp-image-viewer-20261022/README.md
Original file line number Diff line number Diff line change
@@ -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)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion web/src/v2/components/image-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkspaceImageAttachment, "name" | "mime_type" | "width" | "height">;
type PreviewImage = Pick<WorkspaceImageAttachment, "name" | "width" | "height"> & { mime_type: WorkspaceImageAttachment["mime_type"] | "image/gif" };
export async function copyImageToClipboard(blob: Blob, image: PreviewImage, t: LocaleText): Promise<void> {
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);
Expand Down
5 changes: 5 additions & 0 deletions web/src/v2/components/mcp-content-preview.css
Original file line number Diff line number Diff line change
@@ -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; }
67 changes: 65 additions & 2 deletions web/src/v2/components/mcp-content-preview.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, Blob | Promise<Blob>>[] = [];
vi.stubGlobal("ClipboardItem", class { constructor(data: Record<string, Blob | Promise<Blob>>) { 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(<MCPContentPreview contents={[{ kind: "image", mime_type: "image/png", name: "result.png", data_base64: pngData,
byte_size: atob(pngData).length, width: 1, height: 1 }]} />);
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(<MCPContentPreview contents={[gif]} />);
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(<MCPContentPreview contents={[{ ...gif, mime_type: "image/png", data_base64: pngData }]} />);
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();
Expand Down
35 changes: 31 additions & 4 deletions web/src/v2/components/mcp-content-preview.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,45 @@
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) ? <a href={uri} rel="noopener noreferrer" target="_blank">{uri}</a> : <code>{uri}</code>;
}

function EmbeddedImage({ content }: { content: ThreadActivityMCPContentView }) {
const { t } = useLocale();
const trigger = useRef<HTMLButtonElement>(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 <p role="status">{t("已保存的图片无法显示。", "Could not display the saved image.")}</p>;
return <img src={`data:${content.mime_type};base64,${content.data_base64}`} alt={content.name || t("MCP 返回图片", "Image returned by MCP")}
width={content.width} height={content.height} loading="lazy" decoding="async" onError={() => 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 <>
<button ref={trigger} className="v2-mcp-image-preview" type="button" disabled={!dimensions}
aria-label={t(`查看原图 ${name}`, `View original ${name}`)} onClick={() => {
const blob = new Blob([Uint8Array.from(atob(content.data_base64!), (character) => character.charCodeAt(0))], { type: media });
setPreview({ url: URL.createObjectURL(blob), blob });
}}>
<img src={url} alt={content.name || t("MCP 返回图片", "Image returned by MCP")}
width={content.width} height={content.height} loading="lazy" decoding="async" onLoad={({ currentTarget }) => {
if (currentTarget.naturalWidth > 0 && currentTarget.naturalHeight > 0) {
setDimensions({ width: currentTarget.naturalWidth, height: currentTarget.naturalHeight });
}
}} onError={() => { setFailed(true); setPreview(undefined); }} />
<span><Maximize2 size={14} aria-hidden="true" />{t("查看原图", "View original")}</span>
</button>
{preview && dimensions && <V2ImageViewer image={{ name, mime_type: media, ...dimensions }} url={preview.url} blob={preview.blob}
onClose={() => setPreview(undefined)} returnFocusRef={trigger} />}
</>;
}

function EmbeddedAudio({ content }: { content: ThreadActivityMCPContentView }) {
Expand All @@ -33,7 +60,7 @@ export function MCPContentPreview({ contents, omitted = 0, truncated = false }:
{(content.name || content.role) && <figcaption>{content.role ? `${content.role} · ` : ""}{content.name}</figcaption>}
{content.uri && <p><ContentURI uri={content.uri} /></p>}
{(content.kind === "text" || content.kind === "resource_text") && <pre className="v2-mcp-content-text">{content.text}</pre>}
{content.kind === "image" && <EmbeddedImage content={content} />}
{content.kind === "image" && <EmbeddedImage key={content.data_base64} content={content} />}
{content.kind === "audio" && <EmbeddedAudio content={content} />}
</figure>)}
{truncated && <p className="v2-command-truncated">{t("返回内容超过预览上限,当前显示部分内容。", "The response exceeds the preview limit. Only part is shown.")}</p>}
Expand Down
Loading