feat: 支持 MCP 图片内联渲染
This commit is contained in:
Binary file not shown.
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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');
|
||||
|
||||
41
server.js
41
server.js
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user