feat: 支持 MCP 图片内联渲染

This commit is contained in:
shiyue
2026-08-06 13:37:42 +08:00
parent 3f00cfe7a4
commit 3ddf21af85
6 changed files with 237 additions and 3 deletions

View File

@@ -3,6 +3,8 @@
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const SERVER_INFO = {
name: 'ccweb',
@@ -10,6 +12,20 @@ const SERVER_INFO = {
};
const TOOLS = [
{
name: 'ccweb_display_image',
description: '在当前会话的助手气泡中显示图片。支持 http/https 图片地址、data:image/*;base64 图片或本地图片绝对路径(最大 10 MB。',
inputSchema: {
type: 'object',
properties: {
source: { type: 'string', description: '图片 URL、data:image/*;base64,... 或本地绝对路径。' },
alt: { type: 'string', maxLength: 240, description: '图片替代文本。' },
title: { type: 'string', maxLength: 240, description: '图片标题。' },
},
required: ['source'],
additionalProperties: false,
},
},
{
name: 'ccweb_list_conversations',
description: '列出当前 ccweb 中可投递消息的对话。只返回 ID、标题、Agent、运行状态和更新时间不返回对话正文。',
@@ -257,6 +273,31 @@ const TOOLS = [
},
];
function imageMimeFromPath(filePath) {
const ext = path.extname(filePath).toLowerCase();
return ({ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' })[ext] || 'application/octet-stream';
}
function prepareImagePayload(args = {}) {
const source = String(args.source || '').trim();
if (!source) return { ok: false, code: 'missing_source', message: 'source 不能为空。' };
if (/^https?:\/\//i.test(source)) return { ok: true, image: { type: 'image', url: source, mimeType: '', alt: String(args.alt || ''), title: String(args.title || '') } };
if (/^data:image\//i.test(source)) {
const match = source.match(/^data:(image\/[\w.+-]+);base64,([\s\S]+)$/i);
if (!match) return { ok: false, code: 'invalid_data_image', message: '仅支持 base64 编码的 data:image 图片。' };
if (Buffer.byteLength(match[2], 'base64') > 10 * 1024 * 1024) return { ok: false, code: 'image_too_large', message: '图片不能超过 10 MB。' };
return { ok: true, image: { type: 'image', data: match[2], mimeType: match[1], alt: String(args.alt || ''), title: String(args.title || '') } };
}
if (!path.isAbsolute(source)) return { ok: false, code: 'invalid_local_path', message: '本地图片必须使用绝对路径。' };
try {
const stat = fs.statSync(source);
if (!stat.isFile() || stat.size > 10 * 1024 * 1024) return { ok: false, code: 'image_too_large', message: '本地图片不存在或超过 10 MB。' };
const mimeType = imageMimeFromPath(source);
if (!mimeType.startsWith('image/')) return { ok: false, code: 'unsupported_image_type', message: '不支持该本地图片格式。' };
return { ok: true, image: { type: 'image', data: fs.readFileSync(source).toString('base64'), mimeType, alt: String(args.alt || path.basename(source)), title: String(args.title || path.basename(source)) } };
} catch { return { ok: false, code: 'image_read_failed', message: '无法读取本地图片。' }; }
}
function writeMessage(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
@@ -452,7 +493,7 @@ function runStdioServer() {
});
}
module.exports = { TOOLS, runStdioServer };
module.exports = { TOOLS, prepareImagePayload, runStdioServer };
if (require.main === module) {
runStdioServer();

View File

@@ -7867,6 +7867,9 @@
hydrateRenderedMarkdown(textDiv);
} else if (block.type === 'todo_list') {
bubble.appendChild(createTodoListElement(block));
} else if (block.type === 'image') {
const image = createAssistantImageElement(block);
if (image) bubble.appendChild(image);
}
});
return;
@@ -7875,6 +7878,41 @@
setRenderedMarkdown(bubble, String(content));
}
function createAssistantImageElement(block = {}) {
const mimeType = String(block.mimeType || block.media_type || 'image/png').trim();
const data = String(block.data || '').replace(/\s+/g, '');
const url = String(block.url || block.source || block.image_url?.url || block.imageUrl || '').trim()
|| (data ? `data:${mimeType};base64,${data}` : '');
if (!url || (!/^https?:\/\//i.test(url) && !/^data:image\//i.test(url))) return null;
const alt = String(block.alt || block.title || '图片').trim() || '图片';
const figure = document.createElement('figure');
figure.className = 'assistant-image';
const button = document.createElement('button');
button.type = 'button';
button.className = 'assistant-image-button';
button.setAttribute('aria-label', `放大查看:${alt}`);
const img = document.createElement('img');
img.src = url;
img.alt = alt;
img.loading = 'lazy';
img.decoding = 'async';
button.appendChild(img);
button.addEventListener('click', () => openAttachmentPreviewModal({
id: `assistant-image-${createLocalId('preview')}`,
filename: String(block.title || alt),
previewUrl: url,
size: data ? Math.floor(data.length * 0.75) : 0,
}));
figure.appendChild(button);
if (block.title) {
const caption = document.createElement('figcaption');
caption.textContent = String(block.title);
figure.appendChild(caption);
}
return figure;
}
function createTodoListElement(block) {
const container = document.createElement('div');
container.className = 'todo-list-container';
@@ -9094,7 +9132,8 @@
function isGroupableToolCall(node) {
return !!(node?.classList?.contains('tool-call')
&& node.dataset.toolKind !== 'todo_list'
&& node.dataset.toolKind !== 'collab_agent_tool_call');
&& node.dataset.toolKind !== 'collab_agent_tool_call'
&& !node.classList.contains('ccweb-display-image-tool'));
}
function rememberToolCallTarget(toolUseId, tool, element) {
@@ -9429,6 +9468,10 @@
const effectiveResult = tool.result;
const kind = toolKind(tool);
if (isCcwebDisplayImageTool(tool)) {
return createCcwebDisplayImageToolContent(tool);
}
if (kind === 'todo_list') {
let todoData = effectiveInput;
// 如果有 result 且是字符串,尝试解析
@@ -9506,8 +9549,48 @@
return content;
}
function isCcwebDisplayImageTool(tool = {}) {
const inputTool = String(tool?.input?.tool || tool?.input?.name || '');
const subtitle = String(tool?.meta?.subtitle || '');
return inputTool === 'ccweb_display_image' || /(?:^|\.)ccweb_display_image$/.test(subtitle);
}
function parseCcwebDisplayImageResult(result) {
if (result && typeof result === 'object') return result;
try { return JSON.parse(String(result || '')); } catch { return null; }
}
function createCcwebDisplayImageToolContent(tool = {}) {
const wrapper = document.createElement('div');
wrapper.className = 'tool-call-content ccweb-display-image-content';
const payload = parseCcwebDisplayImageResult(tool.result);
if (!payload?.ok) {
wrapper.textContent = payload?.message || (tool.done ? '图片渲染失败' : '正在准备图片…');
return wrapper;
}
if (payload.attachment?.id) {
wrapper.insertAdjacentHTML('beforeend', renderAttachmentPreviews([payload.attachment]));
requestAnimationFrame(() => hydrateAttachmentPreviews(wrapper, [payload.attachment]));
return wrapper;
}
const image = createAssistantImageElement(payload.image || {});
if (image) wrapper.appendChild(image);
else wrapper.textContent = '图片来源不可用';
return wrapper;
}
function createToolCallElement(toolUseId, tool, done) {
const kind = toolKind(tool);
if (isCcwebDisplayImageTool(tool)) {
const wrapper = document.createElement('div');
wrapper.className = 'tool-call ccweb-display-image-tool';
wrapper.id = `tool-node-${++toolDomSeq}`;
wrapper.dataset.toolUseId = toolUseId ? String(toolUseId) : '';
wrapper.dataset.toolName = tool.name || '';
wrapper.dataset.toolKind = kind || 'mcp_tool_call';
wrapper.appendChild(buildToolContentElement({ ...tool, done }));
return wrapper;
}
if (kind === 'collab_agent_tool_call') {
const wrapper = document.createElement('div');
wrapper.className = 'tool-call ccweb-mcp-child-agent-tool-call collab-agent-inline';

View File

@@ -2821,6 +2821,50 @@ html[data-theme='wasteland'] .advanced-search-panel {
min-width: 0;
max-width: 100%;
}
.assistant-image {
margin: 8px 0 2px;
max-width: min(100%, 680px);
}
.assistant-image-button {
display: block;
width: 100%;
padding: 0;
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
cursor: zoom-in;
}
.assistant-image-button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.assistant-image img {
display: block;
width: 100%;
max-height: 520px;
object-fit: contain;
}
.assistant-image figcaption {
margin-top: 6px;
color: var(--text-secondary);
font-size: 12px;
}
.tool-call.ccweb-display-image-tool {
margin: 8px 0 2px;
padding: 0;
overflow: visible;
border: 0;
background: transparent;
box-shadow: none;
}
.ccweb-display-image-content {
padding: 0;
background: transparent;
}
.ccweb-display-image-content .msg-attachments {
margin-top: 0;
}
.msg-attachments {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 220px));

View File

@@ -5016,6 +5016,27 @@ function assertWindowsStartupContract() {
assert(source.includes('exit /b %APP_EXIT_CODE%'), 'Windows startup should return the server exit code to the caller');
}
function assertCcwebDisplayImageContract() {
const frontend = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const styles = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
const server = fs.readFileSync(SERVER_PATH, 'utf8');
const { TOOLS, prepareImagePayload } = require(path.join(REPO_DIR, 'lib', 'ccweb-mcp-server'));
const imageHandler = server.slice(server.indexOf('function createCcwebDisplayImage'), server.indexOf('function findCcwebPromptMessage'));
assert(TOOLS.some((tool) => tool.name === 'ccweb_display_image'), 'ccweb MCP should expose the image display tool');
assert(server.includes("case 'ccweb_display_image':"), 'Internal MCP routing should handle image display calls');
assert(!imageHandler.includes("type: 'session_message'"), 'Image tool should not create a separate assistant message');
assert(frontend.includes('isCcwebDisplayImageTool'), 'Image tool results should render inside the current assistant bubble');
assert(frontend.includes('ccweb-display-image-tool'), 'Image tool should use an inline bubble component');
assert(frontend.includes("block.type === 'image'"), 'Assistant content renderer should support image blocks');
assert(frontend.includes('openAttachmentPreviewModal'), 'Assistant images should open the shared large preview');
assert(styles.includes('.assistant-image-button'), 'Assistant images should have stable bubble styling');
const remote = prepareImagePayload({ source: 'https://example.com/example.gif' });
assert(remote.ok && remote.image.url.endsWith('.gif'), 'Remote GIF URLs should be accepted');
const inline = prepareImagePayload({ source: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==' });
assert(inline.ok && inline.image.mimeType === 'image/gif', 'Base64 GIF images should be accepted');
assert(prepareImagePayload({ source: 'relative.png' }).code === 'invalid_local_path', 'Local images should require absolute paths');
}
async function main() {
const targetIndex = process.argv.indexOf('--target');
const regressionTarget = targetIndex >= 0 ? String(process.argv[targetIndex + 1] || '').trim() : String(process.env.CC_WEB_REGRESSION_TARGET || '').trim();
@@ -5111,6 +5132,11 @@ async function main() {
console.log('Windows startup regression checks passed.');
return;
}
if (regressionTarget === 'ccweb-display-image') {
assertCcwebDisplayImageContract();
console.log('ccweb display image regression checks passed.');
return;
}
if (regressionTarget === 'subagent-card-routing') {
assertCodexAppChildToolRoutingContract();
console.log('Sub-agent card routing regression checks passed.');
@@ -5147,6 +5173,7 @@ async function main() {
assertCodexAppChildToolRoutingContract();
assertMultiAgentV2CompatibilityContract();
assertWindowsStartupContract();
assertCcwebDisplayImageContract();
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
const configDir = path.join(tempRoot, 'config');

View File

@@ -12,7 +12,7 @@ const { createCodexAppRuntime } = require('./lib/codex-app-runtime');
const { createCodexRolloutStore } = require('./lib/codex-rollouts');
const { createSessionSearchIndex } = require('./lib/session-search-index');
const { createUsageStatisticsIndex, UsageStatisticsError } = require('./lib/usage-statistics');
const { TOOLS: CCWEB_MCP_TOOLS } = require('./lib/ccweb-mcp-server');
const { TOOLS: CCWEB_MCP_TOOLS, prepareImagePayload } = require('./lib/ccweb-mcp-server');
const CCWEB_MCP_SERVER_INFO = { name: 'ccweb', version: '1.0.0' };
if (process.argv.includes('--ccweb-mcp-server')) {
@@ -4902,6 +4902,43 @@ function createCcwebPromptUser(args = {}, sourceSessionId = '') {
};
}
function createCcwebDisplayImage(args = {}, sourceSessionId = '') {
const sourceId = sanitizeId(sourceSessionId || '');
const payload = prepareImagePayload(args);
if (!payload.ok) return payload;
const now = new Date();
let attachment = null;
if (payload.image.data) {
const buffer = Buffer.from(payload.image.data, 'base64');
const id = crypto.randomUUID();
const filename = safeFilename(payload.image.title || payload.image.alt || `image${extFromMime(payload.image.mimeType)}`);
const dataPath = attachmentDataPath(id, extFromMime(payload.image.mimeType));
const meta = {
id,
kind: 'image',
filename,
mime: payload.image.mimeType,
size: buffer.length,
createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + ATTACHMENT_TTL_MS).toISOString(),
path: dataPath,
};
try {
fs.writeFileSync(dataPath, buffer);
saveAttachmentMeta(meta);
} catch (err) {
try { if (fs.existsSync(dataPath)) fs.unlinkSync(dataPath); } catch {}
return mcpToolError('image_store_failed', `保存图片失败: ${err.message}`);
}
attachment = normalizeMessageAttachments([meta])[0] || null;
}
const responseImage = payload.image.data
? { type: 'image', mimeType: payload.image.mimeType, alt: payload.image.alt, title: payload.image.title, size: attachment?.size || 0 }
: payload.image;
return { ok: true, image: responseImage, attachment, status: 'ready', sourceConversationId: sourceId || null };
}
function findCcwebPromptMessage(session, promptId) {
const normalizedPromptId = String(promptId || '').trim();
if (!normalizedPromptId || !Array.isArray(session?.messages)) return null;
@@ -5430,6 +5467,8 @@ function completeCrossConversationReply(requestId, entry = {}, targetSession = n
function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) {
switch (tool) {
case 'ccweb_display_image':
return createCcwebDisplayImage(args, sourceSessionId);
case 'ccweb_list_conversations':
return listConversationSummaries(args, sanitizeId(sourceSessionId || ''));
case 'ccweb_set_title':