diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4798424 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "liveServer.settings.port": 5501 +} \ No newline at end of file diff --git a/config.json b/config.json new file mode 100644 index 0000000..57cf23a --- /dev/null +++ b/config.json @@ -0,0 +1,8 @@ +{ + "enableVision": true, + "imageConfig": { + "maxCount": 4, + "maxSizeMB": 4, + "allowedTypes": ["image/jpeg", "image/png", "image/webp", "image/gif"] + } +} diff --git a/css/style.css b/css/style.css index 3425a74..7f3eab5 100644 --- a/css/style.css +++ b/css/style.css @@ -489,3 +489,213 @@ iconify-icon { } #dmermaidSvg{ height: 0px;} + +/* ========== 图片上传相关样式 ========== */ + +/* 图片上传按钮 */ +.image-upload-btn { + flex-shrink: 0; + border-radius: 8px; + transition: all 0.2s ease; +} + +.image-upload-btn:hover { + background: rgba(6, 182, 212, 0.1); + transform: scale(1.05); +} + +.image-upload-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.image-upload-btn:disabled:hover { + background: transparent; + transform: none; +} + +/* 图片缩略图容器 */ +#image-preview-container { + padding: 8px; + background: rgba(255, 255, 255, 0.8); + border: 2px dashed #d1d5db; + border-radius: 8px; +} + +#image-preview-container:not(.hidden) { + display: flex; +} + +/* 单个图片缩略图项 */ +.image-thumbnail-item { + position: relative; + width: 72px; + height: 72px; + border-radius: 8px; + overflow: hidden; + border: 2px solid #e5e7eb; + background: #f9fafb; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.image-thumbnail-item:hover { + border-color: #06b6d4; + transform: scale(1.05); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.image-thumbnail-item img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* 图片删除按钮 */ +.image-thumbnail-delete { + position: absolute; + top: 2px; + right: 2px; + width: 20px; + height: 20px; + border-radius: 50%; + background: rgba(220, 38, 38, 0.9); + color: white; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; + z-index: 10; +} + +.image-thumbnail-item:hover .image-thumbnail-delete { + opacity: 1; +} + +.image-thumbnail-delete:hover { + background: #dc2626; + transform: scale(1.1); +} + +.image-thumbnail-delete iconify-icon { + font-size: 12px; +} + +/* 图片数量限制提示 */ +.image-count-badge { + position: absolute; + bottom: 2px; + left: 2px; + background: rgba(0, 0, 0, 0.7); + color: white; + font-size: 10px; + padding: 1px 4px; + border-radius: 4px; +} + +/* 图片预览模态窗 */ +.image-preview-modal-content { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 90vw; + height: 90vh; + background: rgba(0, 0, 0, 0.95); + border-radius: 8px; +} + +.image-preview-modal-content img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 4px; +} + +/* 聊天气泡中的图片显示 */ +.chat-bubble-images { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} + +.chat-bubble-image { + width: 80px; + height: 80px; + border-radius: 6px; + overflow: hidden; + border: 2px solid rgba(255, 255, 255, 0.3); + cursor: pointer; + transition: all 0.2s ease; +} + +.chat-bubble-image:hover { + transform: scale(1.05); + border-color: rgba(255, 255, 255, 0.6); +} + +.chat-bubble-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* 图片加载中状态 */ +.image-loading { + display: flex; + align-items: center; + justify-content: center; + background: #f3f4f6; +} + +.image-loading::after { + content: ''; + width: 24px; + height: 24px; + border: 2px solid #d1d5db; + border-top-color: #06b6d4; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* 图片上传拖拽区域高亮 */ +.image-drop-active { + background: rgba(6, 182, 212, 0.1) !important; + border-color: #06b6d4 !important; + border-style: solid !important; +} + +/* 图片大小/格式错误提示 */ +.image-error-toast { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + background: #dc2626; + color: white; + padding: 12px 20px; + border-radius: 8px; + font-weight: 500; + z-index: 9999; + animation: toast-in 0.3s ease; +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(20px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 5390db0..8d1f54d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,4 +6,10 @@ services: ports: - "3000:3000" container_name: product-canvas-app - restart: unless-stopped \ No newline at end of file + restart: unless-stopped + environment: + # 是否启用图片解析功能(Vision API),默认启用 + - ENABLE_VISION=true + volumes: + # 可选:挂载自定义配置文件覆盖默认配置 + # - ./config.json:/usr/share/nginx/html/config.json:ro \ No newline at end of file diff --git a/index.html b/index.html index d320da1..cd87dc7 100644 --- a/index.html +++ b/index.html @@ -65,10 +65,19 @@
+ +
+ + + @@ -76,6 +85,11 @@
+ +
@@ -172,15 +186,30 @@ 模型 (Model) - - + + +
+
+ +
+ + 允许上传图片并由AI进行视觉理解 +
+
+ +
+ + + + diff --git a/js/apiclient.js b/js/apiclient.js index b29ab68..cbe45dc 100644 --- a/js/apiclient.js +++ b/js/apiclient.js @@ -2,13 +2,23 @@ * API客户端 - 处理与AI服务的交互 */ +// 图片上传配置常量 +const IMAGE_CONFIG = { + maxCount: 4, // 最大图片数量 + maxSizeBytes: 4 * 1024 * 1024, // 单张最大4MB + allowedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'], + allowedExtensions: ['.jpg', '.jpeg', '.png', '.webp', '.gif'] +}; + class APIClient { constructor() { this.config = { url: '', key: '', - model: '' + model: '', + enableVision: true // 默认启用图片解析 }; + this.runtimeConfig = null; // 运行时配置(从config.json加载) this.promptMap = {}; this.promptFiles = { canvas: 'prompts/canvas-prompt.txt', @@ -32,15 +42,72 @@ class APIClient { '你是一个可靠的智能助手,请直接回答用户的问题并提供结构化输出。' }; this.loadConfig(); + this.loadRuntimeConfig(); // 加载运行时配置 this.preloadPrompts(Object.keys(this.promptFiles)); } + // 加载运行时配置(从config.json,支持Docker环境变量注入) + async loadRuntimeConfig() { + try { + const response = await fetch('config.json'); + if (response.ok) { + this.runtimeConfig = await response.json(); + // 运行时配置优先级高于本地存储 + if (this.runtimeConfig.enableVision !== undefined) { + this.config.enableVision = this.runtimeConfig.enableVision; + } + console.log('运行时配置已加载:', this.runtimeConfig); + + // 触发配置更新事件,通知UI同步 + this.notifyConfigUpdated(); + } + } catch (error) { + // config.json不存在时静默失败,使用默认配置 + console.log('未找到运行时配置文件,使用默认配置'); + } + } + + // 触发配置更新事件 + notifyConfigUpdated() { + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('vision-config-updated', { + detail: { + enableVision: this.isVisionEnabled(), + isRuntimeLocked: this.runtimeConfig && this.runtimeConfig.enableVision !== undefined + } + })); + } + } + + // 检查Vision配置是否被运行时锁定 + isVisionConfigLocked() { + return this.runtimeConfig && this.runtimeConfig.enableVision !== undefined; + } + // 加载API配置 loadConfig() { const savedConfig = Utils.storage.get('apiConfig'); if (savedConfig) { this.config = { ...this.config, ...savedConfig }; } + // 确保enableVision有默认值 + if (this.config.enableVision === undefined) { + this.config.enableVision = true; + } + } + + // 检查图片解析是否启用 + isVisionEnabled() { + // 运行时配置优先级最高 + if (this.runtimeConfig && this.runtimeConfig.enableVision !== undefined) { + return this.runtimeConfig.enableVision; + } + return this.config.enableVision !== false; + } + + // 获取图片配置 + getImageConfig() { + return { ...IMAGE_CONFIG }; } preloadPrompts(keys = []) { @@ -118,29 +185,71 @@ class APIClient { } } - async buildMessagesForModule(manifest, userMessage, contextMessages = []) { + async buildMessagesForModule(manifest, userMessage, contextMessages = [], images = []) { const prompt = (manifest && manifest.promptKey ? await this.ensurePrompt(manifest.promptKey) : null) || this.promptFallbacks.default; + // 构建用户消息内容(支持图片) + const userContent = this.buildUserContent(userMessage, images); + return [ { role: 'system', content: prompt }, ...contextMessages, - { role: 'user', content: userMessage } + { role: 'user', content: userContent } ]; } + /** + * 构建用户消息内容(支持图片的OpenAI Vision API格式) + * @param {string} text - 文本内容 + * @param {Array} images - 图片数组,每项包含 { base64, mimeType } + * @returns {string|Array} - 纯文本或多模态内容数组 + */ + buildUserContent(text, images = []) { + // 如果没有图片或未启用Vision,返回纯文本 + if (!images || images.length === 0 || !this.isVisionEnabled()) { + return text; + } + + // 构建多模态内容数组(OpenAI Vision API格式) + const content = []; + + // 添加图片 + for (const img of images) { + content.push({ + type: 'image_url', + image_url: { + url: `data:${img.mimeType};base64,${img.base64}`, + detail: 'auto' // 可选: 'low', 'high', 'auto' + } + }); + } + + // 添加文本(放在图片后面) + if (text && text.trim()) { + content.push({ + type: 'text', + text: text + }); + } + + return content; + } + async generateModuleCompletion( manifest, userMessage, contextMessages = [], - options = {} + options = {}, + images = [] ) { const messages = await this.buildMessagesForModule( manifest, userMessage, - contextMessages + contextMessages, + images ); return this.sendChatMessage(messages, options); } @@ -151,12 +260,14 @@ class APIClient { contextMessages = [], onChunk, onComplete, - options = {} + options = {}, + images = [] ) { const messages = await this.buildMessagesForModule( manifest, userMessage, - contextMessages + contextMessages, + images ); return this.sendChatMessageStream(messages, options, onChunk, onComplete); } diff --git a/js/core/app-shell.js b/js/core/app-shell.js index a18361c..eea98f6 100644 --- a/js/core/app-shell.js +++ b/js/core/app-shell.js @@ -34,6 +34,9 @@ this.mermaidPanZoom = null; this.mermaidInitialized = false; + // 图片上传状态管理 + this.pendingImages = []; // 待发送的图片列表 { id, file, blobUrl, base64?, mimeType } + this.globalStore = moduleRuntime.storageService.global(); this.activeModuleId = null; @@ -57,6 +60,16 @@ this.el.clearHistoryBtn = document.getElementById('clear-history-btn'); this.el.chatHistory = document.getElementById('chat-history'); + // 图片上传相关 + this.el.imageUploadBtn = document.getElementById('image-upload-btn'); + this.el.imageFileInput = document.getElementById('image-file-input'); + this.el.imagePreviewContainer = document.getElementById('image-preview-container'); + this.el.imagePreviewModal = document.getElementById('image-preview-modal'); + this.el.imagePreviewFull = document.getElementById('image-preview-full'); + this.el.closeImagePreviewBtn = document.getElementById('close-image-preview-btn'); + this.el.imageVisionDisabledTip = document.getElementById('image-vision-disabled-tip'); + this.el.configEnableVision = document.getElementById('config-enable-vision'); + // 视图区域 this.el.viewer = document.getElementById('svg-viewer'); this.el.placeholderText = @@ -287,6 +300,101 @@ } }); } + + // 图片上传相关事件绑定 + this.bindImageUploadEvents(); + } + + /** + * 绑定图片上传相关事件 + */ + bindImageUploadEvents() { + // 图片上传按钮点击 + if (this.el.imageUploadBtn && this.el.imageFileInput) { + this.el.imageUploadBtn.addEventListener('click', () => { + if (!this.apiClient.isVisionEnabled()) { + this.showImageError('图片解析功能已禁用'); + return; + } + this.el.imageFileInput.click(); + }); + } + + // 文件选择变化 + if (this.el.imageFileInput) { + this.el.imageFileInput.addEventListener('change', (event) => { + const files = Array.from(event.target.files || []); + this.handleImageFiles(files); + event.target.value = ''; // 清空以允许重复选择同一文件 + }); + } + + // 粘贴事件(支持图片粘贴) + if (this.el.chatInput) { + this.el.chatInput.addEventListener('paste', (event) => { + if (!this.apiClient.isVisionEnabled()) return; + + const items = event.clipboardData?.items; + if (!items) return; + + const imageFiles = []; + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) imageFiles.push(file); + } + } + + if (imageFiles.length > 0) { + // 不阻止默认行为,允许同时粘贴文本 + this.handleImageFiles(imageFiles); + } + }); + } + + // 图片预览容器点击事件(删除和预览) + if (this.el.imagePreviewContainer) { + this.el.imagePreviewContainer.addEventListener('click', (event) => { + const deleteBtn = event.target.closest('.image-thumbnail-delete'); + if (deleteBtn) { + event.stopPropagation(); + const imageId = deleteBtn.dataset.imageId; + this.removeImage(imageId); + return; + } + + const thumbnailItem = event.target.closest('.image-thumbnail-item'); + if (thumbnailItem) { + const imageId = thumbnailItem.dataset.imageId; + this.openImagePreview(imageId); + } + }); + } + + // 图片预览模态窗关闭 + if (this.el.closeImagePreviewBtn) { + this.el.closeImagePreviewBtn.addEventListener('click', () => { + this.closeImagePreviewModal(); + }); + } + if (this.el.imagePreviewModal) { + this.el.imagePreviewModal.addEventListener('click', (event) => { + if (event.target === this.el.imagePreviewModal) { + this.closeImagePreviewModal(); + } + }); + } + + // 初始化图片上传UI状态 + this.updateImageUploadUI(); + + // 监听运行时配置更新事件 + if (typeof window !== 'undefined') { + window.addEventListener('vision-config-updated', (event) => { + this.updateImageUploadUI(); + this.updateVisionConfigUI(event.detail); + }); + } } setupModuleSwitcher() { @@ -461,14 +569,21 @@ const wrapper = document.createElement('div'); const manifest = this.getActiveManifest(); const actionsHtml = this.buildMessageActions(message, options); + + // 构建图片HTML(用于用户消息) + const imagesHtml = this.buildMessageImagesHtml(message.images); + if (message.type === 'user') { wrapper.className = 'flex justify-end'; wrapper.innerHTML = `
-
${Utils.escapeHtml(message.content)}
+ ${imagesHtml} +
${Utils.escapeHtml(message.content || '')}
${actionsHtml}
`; + // 绑定图片点击预览事件 + this.bindBubbleImagePreview(wrapper, message.images); } else if (message.type === 'error') { wrapper.className = 'flex justify-start'; wrapper.innerHTML = ` @@ -509,6 +624,47 @@ return wrapper; } + /** + * 构建消息中图片的HTML + * @param {Array} images - 图片数组 + * @returns {string} - HTML字符串 + */ + buildMessageImagesHtml(images) { + if (!images || images.length === 0) return ''; + + const imageItems = images.map((img, index) => { + const src = `data:${img.mimeType};base64,${img.base64}`; + return ` +
+ 图片 ${index + 1} +
+ `; + }).join(''); + + return `
${imageItems}
`; + } + + /** + * 绑定气泡中图片的点击预览事件 + * @param {HTMLElement} wrapper - 消息包装元素 + * @param {Array} images - 图片数组 + */ + bindBubbleImagePreview(wrapper, images) { + if (!images || images.length === 0) return; + + const imageElements = wrapper.querySelectorAll('.chat-bubble-image'); + imageElements.forEach((el, index) => { + el.addEventListener('click', (event) => { + event.stopPropagation(); + const img = images[index]; + if (img && this.el.imagePreviewModal && this.el.imagePreviewFull) { + this.el.imagePreviewFull.src = `data:${img.mimeType};base64,${img.base64}`; + this.el.imagePreviewModal.classList.add('active'); + } + }); + }); + } + buildMessageActions(message, options = {}) { const { allowRollback = false, @@ -692,7 +848,8 @@ .map((msg) => ({ role: msg.type === 'user' ? 'user' : 'assistant', // 使用原始内容作为 LLM 上下文,兼容无 rawContent 的旧记录 - content: msg.rawContent || msg.content || '' + content: msg.rawContent || msg.content || '', + images: msg.type === 'user' ? msg.images || [] : undefined })); this.isProcessing = true; @@ -702,7 +859,8 @@ this.beginStreaming(manifest, { userMessage, - contextMessages + contextMessages, + images: userMessage.images || [] // 传递原始用户消息的图片 }); } @@ -745,10 +903,14 @@ return nextId; } - sendMessage() { + async sendMessage() { if (!this.el.chatInput) return; const message = this.el.chatInput.value.trim(); - if (!message || this.isProcessing) return; + const hasImages = this.pendingImages.length > 0; + + // 必须有文本或图片才能发送 + if (!message && !hasImages) return; + if (this.isProcessing) return; if (!this.apiClient.isConfigValid()) { alert('⚠️ 请先配置API设置!点击右上角齿轮图标进行配置。'); @@ -756,12 +918,25 @@ return; } + // 准备图片数据(转换为base64) + let images = []; + if (hasImages && this.apiClient.isVisionEnabled()) { + try { + images = await this.prepareImagesForSend(); + } catch (error) { + console.error('图片处理失败:', error); + this.showImageError('图片处理失败,请重试'); + return; + } + } + const manifest = this.getActiveManifest(); const userMessage = { id: Utils.generateId('msg'), type: 'user', content: message, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), + images: images.length > 0 ? images : undefined // 保存图片数据到消息 }; this.conversationService.appendMessage(manifest, userMessage); @@ -769,6 +944,9 @@ this.el.chatInput.value = ''; Utils.autoResizeTextarea(this.el.chatInput); + // 清空待发送图片 + this.clearPendingImages(); + const context = this.conversationService.buildContext(manifest); if (!context) { console.warn('无法构建上下文,终止发送'); @@ -782,7 +960,8 @@ this.beginStreaming(manifest, { userMessage: context.userMessage, - contextMessages: context.contextMessages + contextMessages: context.contextMessages, + images: images // 传递图片数据 }); } @@ -858,7 +1037,8 @@ payload.contextMessages, handleChunk, handleComplete, - STREAM_DEFAULT_OPTIONS + STREAM_DEFAULT_OPTIONS, + payload.images || [] // 传递图片数据 ) .then((streamHandle) => { this.activeStreamHandle = streamHandle; @@ -2705,6 +2885,11 @@ if (this.el.apiUrlInput) this.el.apiUrlInput.value = config.url || ''; if (this.el.apiKeyInput) this.el.apiKeyInput.value = config.key || ''; if (this.el.apiModelInput) this.el.apiModelInput.value = config.model || ''; + if (this.el.configEnableVision) { + this.el.configEnableVision.checked = config.enableVision !== false; + } + // 更新图片上传UI状态 + this.updateImageUploadUI(); } async testAPI() { @@ -2721,7 +2906,8 @@ const config = { url: this.el.apiUrlInput?.value.trim(), key: this.el.apiKeyInput?.value.trim(), - model: this.el.apiModelInput?.value.trim() + model: this.el.apiModelInput?.value.trim(), + enableVision: this.el.configEnableVision?.checked !== false }; if (!config.url || !config.key || !config.model) { this.setConfigStatus('error', '请填写完整的配置'); @@ -2729,6 +2915,8 @@ } this.apiClient.saveConfig(config); this.setConfigStatus('success', '配置已保存'); + // 更新图片上传UI状态 + this.updateImageUploadUI(); //setTimeout(() => this.closeConfigModal(), 600); } @@ -2776,6 +2964,255 @@ ); } } + + // ==================== 图片上传相关方法 ==================== + + /** + * 处理图片文件(上传或粘贴) + * @param {File[]} files - 文件列表 + */ + handleImageFiles(files) { + const config = this.apiClient.getImageConfig(); + const currentCount = this.pendingImages.length; + const availableSlots = config.maxCount - currentCount; + + if (availableSlots <= 0) { + this.showImageError(`最多只能上传 ${config.maxCount} 张图片`); + return; + } + + const filesToAdd = files.slice(0, availableSlots); + + for (const file of filesToAdd) { + // 验证文件类型 + if (!config.allowedTypes.includes(file.type)) { + this.showImageError(`不支持的图片格式: ${file.type}`); + continue; + } + + // 验证文件大小 + if (file.size > config.maxSizeBytes) { + const maxSizeMB = config.maxSizeBytes / (1024 * 1024); + this.showImageError(`图片大小超过限制 (最大 ${maxSizeMB}MB)`); + continue; + } + + // 创建图片对象 + const imageObj = { + id: Utils.generateId('img'), + file: file, + blobUrl: URL.createObjectURL(file), + mimeType: file.type, + base64: null // 发送时再转换 + }; + + this.pendingImages.push(imageObj); + } + + this.renderImageThumbnails(); + + if (files.length > availableSlots) { + this.showImageError(`已达到最大图片数量限制 (${config.maxCount} 张)`); + } + } + + /** + * 渲染图片缩略图 + */ + renderImageThumbnails() { + if (!this.el.imagePreviewContainer) return; + + if (this.pendingImages.length === 0) { + this.el.imagePreviewContainer.classList.add('hidden'); + this.el.imagePreviewContainer.innerHTML = ''; + return; + } + + this.el.imagePreviewContainer.classList.remove('hidden'); + this.el.imagePreviewContainer.innerHTML = this.pendingImages.map((img, index) => ` +
+ 图片 ${index + 1} + +
+ `).join(''); + } + + /** + * 删除指定图片 + * @param {string} imageId - 图片ID + */ + removeImage(imageId) { + const index = this.pendingImages.findIndex(img => img.id === imageId); + if (index === -1) return; + + const img = this.pendingImages[index]; + // 释放Blob URL + if (img.blobUrl) { + URL.revokeObjectURL(img.blobUrl); + } + + this.pendingImages.splice(index, 1); + this.renderImageThumbnails(); + } + + /** + * 清空所有待发送图片 + */ + clearPendingImages() { + for (const img of this.pendingImages) { + if (img.blobUrl) { + URL.revokeObjectURL(img.blobUrl); + } + } + this.pendingImages = []; + this.renderImageThumbnails(); + } + + /** + * 打开图片预览模态窗 + * @param {string} imageId - 图片ID + */ + openImagePreview(imageId) { + const img = this.pendingImages.find(i => i.id === imageId); + if (!img || !this.el.imagePreviewModal || !this.el.imagePreviewFull) return; + + this.el.imagePreviewFull.src = img.blobUrl; + this.el.imagePreviewModal.classList.add('active'); + } + + /** + * 关闭图片预览模态窗 + */ + closeImagePreviewModal() { + if (!this.el.imagePreviewModal) return; + this.el.imagePreviewModal.classList.remove('active'); + if (this.el.imagePreviewFull) { + this.el.imagePreviewFull.src = ''; + } + } + + /** + * 显示图片错误提示 + * @param {string} message - 错误信息 + */ + showImageError(message) { + // 创建toast提示 + const toast = document.createElement('div'); + toast.className = 'image-error-toast'; + toast.textContent = message; + document.body.appendChild(toast); + + // 3秒后自动移除 + setTimeout(() => { + toast.remove(); + }, 3000); + } + + /** + * 更新图片上传UI状态(根据enableVision配置) + */ + updateImageUploadUI() { + const visionEnabled = this.apiClient.isVisionEnabled(); + + // 更新上传按钮状态 + if (this.el.imageUploadBtn) { + this.el.imageUploadBtn.disabled = !visionEnabled; + this.el.imageUploadBtn.title = visionEnabled + ? '上传图片 (支持粘贴)' + : '图片解析功能已禁用'; + } + + // 更新禁用提示 + if (this.el.imageVisionDisabledTip) { + this.el.imageVisionDisabledTip.classList.toggle('hidden', visionEnabled); + } + + // 如果禁用了Vision,清空待发送图片 + if (!visionEnabled && this.pendingImages.length > 0) { + this.clearPendingImages(); + } + } + + /** + * 更新Vision配置UI(处理运行时锁定状态) + * @param {Object} detail - 配置详情 { enableVision, isRuntimeLocked } + */ + updateVisionConfigUI(detail) { + if (!this.el.configEnableVision) return; + + const { enableVision, isRuntimeLocked } = detail; + + // 更新复选框状态 + this.el.configEnableVision.checked = enableVision; + + // 如果被运行时配置锁定,禁用复选框并添加提示 + if (isRuntimeLocked) { + this.el.configEnableVision.disabled = true; + + // 添加锁定提示 + const parent = this.el.configEnableVision.closest('.flex'); + if (parent) { + let lockHint = parent.querySelector('.vision-lock-hint'); + if (!lockHint) { + lockHint = document.createElement('span'); + lockHint.className = 'vision-lock-hint text-xs text-orange-600 ml-2'; + lockHint.innerHTML = ' 由部署配置锁定'; + parent.appendChild(lockHint); + } + } + } else { + this.el.configEnableVision.disabled = false; + + // 移除锁定提示 + const parent = this.el.configEnableVision.closest('.flex'); + if (parent) { + const lockHint = parent.querySelector('.vision-lock-hint'); + if (lockHint) { + lockHint.remove(); + } + } + } + } + + /** + * 将File转换为Base64 + * @param {File} file - 文件对象 + * @returns {Promise} - Base64字符串(不含data:前缀) + */ + fileToBase64(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + // 移除 "data:image/xxx;base64," 前缀 + const base64 = reader.result.split(',')[1]; + resolve(base64); + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } + + /** + * 准备图片数据用于API发送(并行转换优化) + * @returns {Promise} - 图片数据数组 [{ base64, mimeType }] + */ + async prepareImagesForSend() { + // 并行转换所有图片为base64 + const conversionPromises = this.pendingImages.map(async (img) => { + // 如果还没有转换为base64,现在转换 + if (!img.base64) { + img.base64 = await this.fileToBase64(img.file); + } + return { + base64: img.base64, + mimeType: img.mimeType + }; + }); + + return Promise.all(conversionPromises); + } } global.AppShell = AppShell;