diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index b487988..31d450c 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/lib/ccweb-mcp-server.js b/lib/ccweb-mcp-server.js index 1b2f503..17b56e4 100644 --- a/lib/ccweb-mcp-server.js +++ b/lib/ccweb-mcp-server.js @@ -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(); diff --git a/public/app.js b/public/app.js index 8e8d570..612558a 100644 --- a/public/app.js +++ b/public/app.js @@ -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'; diff --git a/public/style.css b/public/style.css index c3b0ac1..dcc3bbe 100644 --- a/public/style.css +++ b/public/style.css @@ -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)); diff --git a/scripts/regression.js b/scripts/regression.js index db25a7f..f17a996 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -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'); diff --git a/server.js b/server.js index d78fd4e..606a318 100644 --- a/server.js +++ b/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':