From 533375e8caf054564cad0c7ed5c2bd03b91c3bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B2=E6=82=A6?= Date: Mon, 27 Oct 2025 11:04:00 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4=E4=BA=86=E6=95=B4=E4=B8=AA?= =?UTF-8?q?=E6=A1=86=E6=9E=B6=EF=BC=8C=E6=A8=A1=E5=9D=97=E5=8C=96=E8=A7=A3?= =?UTF-8?q?=E8=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.html | 21 +- js/apiclient.js | 172 ++- js/app.js | 1526 +-------------------------- js/core/app-shell.js | 975 +++++++++++++++++ js/core/module-registry.js | 36 + js/core/module-runtime.js | 181 ++++ js/modules/echarts.js | 78 ++ js/modules/product-canvas.js | 43 + js/modules/swot.js | 39 + js/services/conversation-service.js | 118 +++ js/services/storage-service.js | 84 ++ prompts/echarts-prompt.txt | 7 + 12 files changed, 1722 insertions(+), 1558 deletions(-) create mode 100644 js/core/app-shell.js create mode 100644 js/core/module-registry.js create mode 100644 js/core/module-runtime.js create mode 100644 js/modules/echarts.js create mode 100644 js/modules/product-canvas.js create mode 100644 js/modules/swot.js create mode 100644 js/services/conversation-service.js create mode 100644 js/services/storage-service.js create mode 100644 prompts/echarts-prompt.txt diff --git a/index.html b/index.html index 6ed2897..ecd284a 100644 --- a/index.html +++ b/index.html @@ -29,17 +29,9 @@ - 点击切换模式 + 点击切换模块 - - - +
@@ -206,6 +198,15 @@ + + + + + + + + + diff --git a/js/apiclient.js b/js/apiclient.js index a7db5f9..81b8edf 100644 --- a/js/apiclient.js +++ b/js/apiclient.js @@ -9,12 +9,22 @@ class APIClient { key: '', model: '' }; - this.prompts = { - canvas: '', - swot: '' + this.promptMap = {}; + this.promptFiles = { + canvas: 'prompts/canvas-prompt.txt', + swot: 'prompts/swot-prompt.txt', + echarts: 'prompts/echarts-prompt.txt' + }; + this.promptFallbacks = { + canvas: '你是一个专业的产品战略分析师,擅长创建产品画布。', + swot: '你是一个专业的商业战略分析师,擅长进行SWOT分析。', + echarts: + '你是一个资深的数据可视化专家,精通将自然语言需求转化为 ECharts 配置对象,请输出结构化 JSON option。', + default: + '你是一个可靠的智能助手,请直接回答用户的问题并提供结构化输出。' }; this.loadConfig(); - this.loadPrompts(); + this.preloadPrompts(Object.keys(this.promptFiles)); } // 加载API配置 @@ -25,21 +35,45 @@ class APIClient { } } - // 加载系统提示词 - async loadPrompts() { + preloadPrompts(keys = []) { + keys.forEach((key) => { + this.ensurePrompt(key).catch((error) => + console.warn(`预加载提示词 ${key} 失败:`, error) + ); + }); + } + + async ensurePrompt(promptKey) { + if (!promptKey) return ''; + if (this.promptMap[promptKey]) { + return this.promptMap[promptKey]; + } + const prompt = await this.fetchPrompt(promptKey); + this.promptMap[promptKey] = prompt; + return prompt; + } + + async fetchPrompt(promptKey) { + const filePath = this.promptFiles[promptKey]; + const fallback = + this.promptFallbacks[promptKey] || + '你是一个可靠的智能助手,请直接回答用户问题。'; + + if (!filePath) { + console.warn(`未找到提示词 ${promptKey} 对应的文件配置`); + return fallback; + } + try { - // 加载产品画布提示词 - const canvasResponse = await fetch('prompts/canvas-prompt.txt'); - this.prompts.canvas = await canvasResponse.text(); - - // 加载SWOT分析提示词 - const swotResponse = await fetch('prompts/swot-prompt.txt'); - this.prompts.swot = await swotResponse.text(); + const response = await fetch(filePath); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const text = await response.text(); + return text.trim() || fallback; } catch (error) { - console.error('加载提示词失败:', error); - // 使用默认提示词 - this.prompts.canvas = '你是一个专业的产品战略分析师,擅长创建产品画布。'; - this.prompts.swot = '你是一个专业的商业战略分析师,擅长进行SWOT分析。'; + console.warn(`加载提示词 ${promptKey} 失败:`, error); + return fallback; } } @@ -76,6 +110,49 @@ class APIClient { } } + async buildMessagesForModule(manifest, userMessage, contextMessages = []) { + const prompt = + (manifest && manifest.promptKey + ? await this.ensurePrompt(manifest.promptKey) + : null) || this.promptFallbacks.default; + + return [ + { role: 'system', content: prompt }, + ...contextMessages, + { role: 'user', content: userMessage } + ]; + } + + async generateModuleCompletion( + manifest, + userMessage, + contextMessages = [], + options = {} + ) { + const messages = await this.buildMessagesForModule( + manifest, + userMessage, + contextMessages + ); + return this.sendChatMessage(messages, options); + } + + async generateModuleStream( + manifest, + userMessage, + contextMessages = [], + onChunk, + onComplete, + options = {} + ) { + const messages = await this.buildMessagesForModule( + manifest, + userMessage, + contextMessages + ); + return this.sendChatMessageStream(messages, options, onChunk, onComplete); + } + // 发送聊天请求 async sendChatMessage(messages, options = {}) { if (!this.isConfigValid()) { @@ -127,46 +204,46 @@ class APIClient { // 生成产品画布的专用方法 async generateProductCanvas(userRequest, context = []) { - const messages = [ - { role: 'system', content: this.prompts.canvas }, - ...context, - { role: 'user', content: userRequest } - ]; - - return await this.sendChatMessage(messages, { maxTokens: 18000 }); + return this.generateModuleCompletion( + { promptKey: 'canvas' }, + userRequest, + context, + { maxTokens: 18000 } + ); } // 生成SWOT分析的专用方法 async generateSWOTAnalysis(userRequest, context = []) { - const messages = [ - { role: 'system', content: this.prompts.swot }, - ...context, - { role: 'user', content: userRequest } - ]; - - return await this.sendChatMessage(messages, { maxTokens: 18000 }); + return this.generateModuleCompletion( + { promptKey: 'swot' }, + userRequest, + context, + { maxTokens: 18000 } + ); } // 流式生成产品画布 async generateProductCanvasStream(userRequest, context = [], onChunk, onComplete) { - const messages = [ - { role: 'system', content: this.prompts.canvas }, - ...context, - { role: 'user', content: userRequest } - ]; - - return this.sendChatMessageStream(messages, { maxTokens: 13000 }, onChunk, onComplete); + return this.generateModuleStream( + { promptKey: 'canvas' }, + userRequest, + context, + onChunk, + onComplete, + { maxTokens: 13000 } + ); } // 流式生成SWOT分析 async generateSWOTAnalysisStream(userRequest, context = [], onChunk, onComplete) { - const messages = [ - { role: 'system', content: this.prompts.swot }, - ...context, - { role: 'user', content: userRequest } - ]; - - return this.sendChatMessageStream(messages, { maxTokens: 13000 }, onChunk, onComplete); + return this.generateModuleStream( + { promptKey: 'swot' }, + userRequest, + context, + onChunk, + onComplete, + { maxTokens: 13000 } + ); } // 流式发送聊天请求 @@ -233,7 +310,8 @@ class APIClient { throw new Error('没有找到用户消息'); } - const mode = Utils.storage.get('currentMode', 'canvas'); + const activeModuleId = Utils.storage.get('tool-engine:activeModuleId', 'product-canvas'); + const mode = activeModuleId === 'swot' ? 'swot' : 'canvas'; if (mode === 'canvas') { return await this.generateProductCanvas(lastUserMessage.content, contextMessages.slice(0, -1)); @@ -296,4 +374,4 @@ class APIClient { } // 创建全局API客户端实例 -window.apiClient = new APIClient(); \ No newline at end of file +window.apiClient = new APIClient(); diff --git a/js/app.js b/js/app.js index 14d5a73..43ee46a 100644 --- a/js/app.js +++ b/js/app.js @@ -1,1503 +1,27 @@ -/** - * 应用核心逻辑 - */ +(function () { + 'use strict'; -// 配置Markdown解析器 -if (typeof marked !== 'undefined') { - marked.setOptions({ - breaks: true, // 支持换行 - gfm: true, // 支持GitHub风格的Markdown - sanitize: false, // 允许HTML(因为我们自己处理SVG) - smartLists: true, // 智能列表 - smartypants: true // 智能标点 + document.addEventListener('DOMContentLoaded', () => { + if (!window.apiClient) { + console.error('APIClient 未初始化,无法启动应用'); + return; + } + + try { + const storageService = new StorageService('tool-engine'); + const conversationService = new ConversationService(storageService); + const moduleRuntime = new ModuleRuntime({ + registry: ModuleRegistry, + storageService, + conversationService + }); + + window.app = new AppShell({ + apiClient: window.apiClient, + moduleRuntime + }); + } catch (error) { + console.error('初始化应用失败:', error); + } }); -} - -class ProductCanvasApp { - constructor() { - this.currentMode = 'canvas'; // 'canvas' 或 'swot' - this.svgStorage = {}; - this.currentSvgId = null; - this.conversationHistory = {}; - this.isProcessing = false; - this.activeStreamHandle = null; - this.svgZoom = { canvas: 1, swot: 1 }; - this.activeSvgPlaceholder = null; - this.pendingSvgId = null; - this.pendingCancel = false; - this.copyClipboardSupported = typeof ClipboardItem !== 'undefined' && !!navigator.clipboard; - const deviceScale = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1; - this.imageExportScale = Math.min(4, Math.max(2, deviceScale)); - - this.initElements(); - this.initEventListeners(); - this.loadSavedData(); - this.updateModeUI(); - this.setSendButtonState('idle'); - } - - getModeDisplayName(mode = this.currentMode) { - return mode === 'canvas' ? '产品画布' : 'SWOT分析'; - } - - // 初始化DOM元素引用 - initElements() { - // 模式切换按钮 - this.canvasBtn = document.getElementById('canvas-mode-btn'); - this.swotBtn = document.getElementById('swot-mode-btn'); - this.pageTitle = document.getElementById('page-title'); - - // 对话相关 - this.chatInput = document.getElementById('chat-input'); - this.sendButton = document.getElementById('send-button'); - this.clearHistoryBtn = document.getElementById('clear-history-btn'); - this.chatHistory = document.getElementById('chat-history'); - - // SVG显示 - this.svgViewer = document.getElementById('svg-viewer'); - this.placeholderText = this.svgViewer.querySelector('#placeholder-text'); - - // 底部操作按钮 - this.zoomOutBtn = document.getElementById('zoom-out-btn'); - this.zoomInBtn = document.getElementById('zoom-in-btn'); - this.zoomResetBtn = document.getElementById('zoom-reset-btn'); - this.downloadSvgBtn = document.getElementById('download-svg-btn'); - this.copyImageBtn = document.getElementById('copy-image-btn'); - this.exportImageBtn = document.getElementById('export-image-btn'); - this.viewCodeBtn = document.getElementById('view-code-btn'); - - // API配置模态窗 - this.settingsBtn = document.getElementById('settings-btn'); - this.configModal = document.getElementById('config-modal'); - this.closeModalBtn = document.getElementById('close-modal-btn'); - this.apiUrlInput = document.getElementById('api-url'); - this.apiKeyInput = document.getElementById('api-key'); - this.apiModelInput = document.getElementById('api-model'); - this.testApiBtn = document.getElementById('test-api-btn'); - this.saveConfigBtn = document.getElementById('save-config-btn'); - this.configStatus = document.getElementById('config-status'); - this.statusText = document.getElementById('status-text'); - } - - // 初始化事件监听器 - initEventListeners() { - // 模式切换 - this.canvasBtn.addEventListener('click', () => this.switchMode('canvas')); - this.swotBtn.addEventListener('click', () => this.switchMode('swot')); - - // 发送消息 - this.sendButton.addEventListener('click', () => { - if (this.isProcessing) { - this.cancelActiveStream(); - } else { - this.sendMessage(); - } - }); - this.clearHistoryBtn.addEventListener('click', () => this.clearCurrentConversation()); - - // 输入框事件 - this.chatInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - if (!this.isProcessing) { - this.sendMessage(); - } - } - }); - - // 自动调整输入框高度 - this.chatInput.addEventListener('input', () => { - Utils.autoResizeTextarea(this.chatInput); - }); - - // 底部操作按钮 - if (this.zoomOutBtn) this.zoomOutBtn.addEventListener('click', () => this.adjustSvgZoom(-0.25)); - if (this.zoomInBtn) this.zoomInBtn.addEventListener('click', () => this.adjustSvgZoom(0.25)); - if (this.zoomResetBtn) this.zoomResetBtn.addEventListener('click', () => this.resetSvgZoom()); - this.downloadSvgBtn.addEventListener('click', () => this.downloadSVG()); - if (this.copyImageBtn) { - if (this.copyClipboardSupported) { - this.copyImageBtn.addEventListener('click', () => this.copySvgToClipboard()); - } else { - this.copyImageBtn.disabled = true; - this.copyImageBtn.title = '当前浏览器不支持复制图片到剪贴板'; - } - } - this.exportImageBtn.addEventListener('click', () => this.exportAsImage()); - this.viewCodeBtn.addEventListener('click', () => this.viewSVGCode()); - - // API配置模态窗 - this.settingsBtn.addEventListener('click', () => this.openConfigModal()); - this.closeModalBtn.addEventListener('click', () => this.closeConfigModal()); - this.configModal.addEventListener('click', (e) => { - if (e.target === this.configModal) { - this.closeConfigModal(); - } - }); - - this.testApiBtn.addEventListener('click', () => this.testAPIConnection()); - this.saveConfigBtn.addEventListener('click', () => this.saveAPIConfig()); - } - - // 加载保存的数据 - loadSavedData() { - // 加载模式 - const savedMode = Utils.storage.get('currentMode', 'canvas'); - this.currentMode = savedMode; - - // 加载对话历史(按模式分别存储) - const savedCanvasHistory = Utils.storage.get('canvasHistory', []); - const savedSwotHistory = Utils.storage.get('swotHistory', []); - this.conversationHistory = { - canvas: savedCanvasHistory, - swot: savedSwotHistory - }; - this.renderConversationHistory(); - - // 加载SVG存储(按模式分别存储) - const savedCanvasSVGs = Utils.storage.get('canvasSVGs', {}); - const savedSwotSVGs = Utils.storage.get('swotSVGs', {}); - this.svgStorage = { - canvas: savedCanvasSVGs, - swot: savedSwotSVGs - }; - - this.renderSvgViewerForMode(); - this.setSendButtonState('idle'); - - // 加载API配置 - const apiConfig = window.apiClient.getConfig(); - this.apiUrlInput.value = apiConfig.url || ''; - this.apiKeyInput.value = apiConfig.key || ''; - this.apiModelInput.value = apiConfig.model || ''; - } - - // 切换模式 - switchMode(mode) { - if (this.currentMode === mode) return; - - this.currentMode = mode; - Utils.storage.set('currentMode', mode); - this.currentSvgId = null; - this.showSvgPlaceholder(); - this.updateModeUI(); - this.renderConversationHistory(); - this.renderSvgViewerForMode(); - } - - // 更新模式UI - updateModeUI() { - const isCanvas = this.currentMode === 'canvas'; - - if (isCanvas) { - this.canvasBtn.classList.add('mode-btn-active'); - this.canvasBtn.classList.remove('mode-btn-inactive'); - this.swotBtn.classList.remove('mode-btn-active'); - this.swotBtn.classList.add('mode-btn-inactive'); - } else { - this.swotBtn.classList.add('mode-btn-active'); - this.swotBtn.classList.remove('mode-btn-inactive'); - this.canvasBtn.classList.remove('mode-btn-active'); - this.canvasBtn.classList.add('mode-btn-inactive'); - } - - this.pageTitle.textContent = isCanvas ? '产品画布' : 'SWOT分析'; - - if (!this.currentSvgId) { - const placeholder = this.svgViewer.querySelector('#placeholder-text'); - if (placeholder) { - placeholder.textContent = `生成的${this.getModeDisplayName()}将在此处显示`; - } - } - } - - setSendButtonState(state) { - this.sendButtonState = state; - if (!this.sendButton) return; - - if (state === 'streaming') { - this.sendButton.innerHTML = ` - - - 终止 - - `; - this.sendButton.classList.add('terminate-mode'); - this.sendButton.title = '终止当前生成'; - } else if (state === 'terminating') { - this.sendButton.innerHTML = ` - - - 终止中 - - `; - this.sendButton.classList.add('terminate-mode'); - this.sendButton.title = '正在终止生成'; - } else if (state === 'busy') { - this.sendButton.innerHTML = ` - - - 处理中 - - `; - this.sendButton.classList.add('terminate-mode'); - this.sendButton.title = '正在处理请求'; - } else { - this.sendButton.innerHTML = ''; - this.sendButton.classList.remove('terminate-mode'); - this.sendButton.title = '发送'; - } - } - - showSvgPlaceholder() { - const label = this.getModeDisplayName(); - this.currentSvgId = null; - this.svgViewer.innerHTML = ` -
- -

生成的${label}将在此处显示

-
- `; - this.placeholderText = this.svgViewer.querySelector('#placeholder-text'); - this.setActivePlaceholder(null); - this.updateZoomButtons(); - } - - renderSvgViewerForMode() { - const svgStore = this.svgStorage[this.currentMode] || {}; - const history = this.conversationHistory[this.currentMode] || []; - - let latestSvgId = null; - for (let i = history.length - 1; i >= 0; i--) { - const message = history[i]; - if (message.type !== 'ai') continue; - for (const [svgId, svg] of Object.entries(svgStore)) { - if (svg.messageId === message.id) { - latestSvgId = svgId; - break; - } - } - if (latestSvgId) break; - } - - if (latestSvgId && svgStore[latestSvgId]) { - this.currentSvgId = latestSvgId; - this.mountSvgMarkup(svgStore[latestSvgId].content); - this.setActivePlaceholder(latestSvgId); - } else { - this.currentSvgId = null; - this.showSvgPlaceholder(); - } - - this.updateZoomButtons(); - } - - adjustSvgZoom(delta) { - const current = this.svgZoom[this.currentMode] || 1; - const next = Math.min(3, Math.max(0.25, parseFloat((current + delta).toFixed(2)))); - this.svgZoom[this.currentMode] = next; - this.applySvgZoom(); - } - - resetSvgZoom() { - this.svgZoom[this.currentMode] = 1; - this.applySvgZoom(); - } - - applySvgZoom() { - const zoom = this.svgZoom[this.currentMode] || 1; - const wrapper = this.svgViewer.querySelector('.svg-content-wrapper'); - if (wrapper) { - wrapper.style.transform = `scale(${zoom})`; - wrapper.style.transformOrigin = 'center top'; - } - this.updateZoomButtons(); - } - - updateZoomButtons() { - if (!this.zoomInBtn || !this.zoomOutBtn || !this.zoomResetBtn) return; - const hasActiveSvg = !!this.currentSvgId && !!(this.svgStorage[this.currentMode] || {})[this.currentSvgId]; - const zoom = this.svgZoom[this.currentMode] || 1; - - const disableControls = !hasActiveSvg; - this.zoomInBtn.disabled = disableControls || zoom >= 3; - this.zoomOutBtn.disabled = disableControls || zoom <= 0.25; - this.zoomResetBtn.disabled = disableControls || Math.abs(zoom - 1) < 0.01; - - if (!hasActiveSvg) { - if (this.copyImageBtn) this.copyImageBtn.disabled = true; - this.downloadSvgBtn.disabled = true; - this.exportImageBtn.disabled = true; - this.viewCodeBtn.disabled = true; - } else { - if (this.copyImageBtn) this.copyImageBtn.disabled = !this.copyClipboardSupported; - this.downloadSvgBtn.disabled = false; - this.exportImageBtn.disabled = false; - this.viewCodeBtn.disabled = false; - } - } - - setActivePlaceholder(svgId) { - const previousActive = this.chatHistory.querySelectorAll('.svg-placeholder-active'); - previousActive.forEach(el => el.classList.remove('svg-placeholder-active')); - if (svgId) { - const next = this.chatHistory.querySelector(`.svg-placeholder-block[data-svg-id="${svgId}"], .svg-drawing-placeholder[data-svg-id="${svgId}"]`); - if (next) { - next.classList.add('svg-placeholder-active'); - this.activeSvgPlaceholder = svgId; - } else { - this.activeSvgPlaceholder = null; - } - } else { - this.activeSvgPlaceholder = null; - } - } - - mountSvgMarkup(svgMarkup, temporary = false) { - this.svgViewer.innerHTML = ''; - const wrapper = document.createElement('div'); - wrapper.className = 'svg-content-wrapper'; - wrapper.innerHTML = svgMarkup; - this.svgViewer.appendChild(wrapper); - this.placeholderText = null; - if (!temporary) { - this.applySvgZoom(); - } else { - const zoom = this.svgZoom[this.currentMode] || 1; - wrapper.style.transform = `scale(${zoom})`; - wrapper.style.transformOrigin = 'center top'; - } - } - - renderSvgContent(svgId) { - const store = this.svgStorage[this.currentMode] || {}; - if (!svgId || !store[svgId]) { - this.showSvgPlaceholder(); - return; - } - this.currentSvgId = svgId; - this.mountSvgMarkup(store[svgId].content); - this.setActivePlaceholder(svgId); - this.applySvgZoom(); - } - - renderTemporarySvg(svgMarkup) { - this.mountSvgMarkup(svgMarkup, true); - } - - buildContextForUserMessage(userIndex) { - const history = this.conversationHistory[this.currentMode] || []; - if (userIndex < 0 || userIndex >= history.length) { - return null; - } - - const target = history[userIndex]; - if (!target || target.type !== 'user') { - return null; - } - - const start = Math.max(0, userIndex - 10); - const contextSlice = history.slice(start, userIndex); - const contextMessages = contextSlice - .filter(msg => msg.type === 'user' || msg.type === 'ai') - .map(msg => ({ - role: msg.type === 'user' ? 'user' : 'assistant', - content: msg.content - })); - - return { - userMessage: target.content, - contextMessages - }; - } - - // 发送消息 - async sendMessage() { - const message = this.chatInput.value.trim(); - if (!message || this.isProcessing) return; - - if (!window.apiClient.isConfigValid()) { - alert('⚠️ 请先配置API设置!点击右上角齿轮图标进行配置。'); - this.openConfigModal(); - return; - } - - this.pendingCancel = false; - this.isProcessing = true; - - this.addUserMessage(message); - this.chatInput.value = ''; - Utils.autoResizeTextarea(this.chatInput); - - const history = this.conversationHistory[this.currentMode] || []; - const targetIndex = history.length - 1; - const payload = this.buildContextForUserMessage(targetIndex); - if (!payload) { - console.warn('无法构建上下文,终止发送'); - this.isProcessing = false; - this.setSendButtonState('idle'); - return; - } - - this.setSendButtonState('streaming'); - this.sendButton.disabled = false; - - try { - await this.startStreamingMessage(payload.userMessage, payload.contextMessages); - } catch (error) { - console.error('发送消息失败:', error); - this.addErrorMessage(error.message); - this.isProcessing = false; - this.setSendButtonState('idle'); - this.activeStreamHandle = null; - this.sendButton.disabled = false; - } - } - - // 开始流式接收消息 - async startStreamingMessage(userMessage, contextMessages) { - const messageId = Utils.generateId('msg'); - const messageContainer = this.createStreamingMessageContainer(messageId); - this.chatHistory.appendChild(messageContainer); - Utils.scrollToBottom(this.chatHistory); - - let fullContent = ''; - let svgStarted = false; - let svgContent = ''; - let svgId = null; - let beforeText = ''; - - let streamClosed = false; - this.activeStreamHandle = null; - - const finalizeStream = (info = {}) => { - if (streamClosed) return; - streamClosed = true; - - const trimmedContent = fullContent.trim(); - if (!trimmedContent && !svgId) { - const bubble = this.chatHistory.querySelector(`[data-message-id="${messageId}"]`); - if (bubble) { - const wrapper = bubble.closest('.flex'); - if (wrapper) wrapper.remove(); - } - } else { - this.finalizeStreamingMessage(messageId, fullContent, svgId); - } - - this.isProcessing = false; - this.setSendButtonState('idle'); - this.sendButton.disabled = false; - this.activeStreamHandle = null; - this.pendingCancel = false; - }; - - const onChunk = (chunk) => { - if ( - streamClosed || - !chunk || - !chunk.choices || - !chunk.choices[0] || - !chunk.choices[0].delta - ) { - return; - } - - const content = chunk.choices[0].delta.content || ''; - if (!content) return; - - fullContent += content; - - if (!svgStarted) { - const svgStartMatch = fullContent.match(/```(?:svg)?\s*/i); - if (svgStartMatch) { - svgStarted = true; - svgId = svgId || Utils.generateId('svg'); - this.pendingSvgId = svgId; - - const svgStartIndex = svgStartMatch.index; - beforeText = fullContent.substring(0, svgStartIndex); - - this.updateStreamingMessageWithPlaceholder(messageContainer, beforeText, svgId); - this.setActivePlaceholder(svgId); - - this.svgViewer.innerHTML = ` -
-
- -

正在绘制${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'}...

-
-
- `; - this.updateZoomButtons(); - } - } - - if (svgStarted) { - if (fullContent.includes('')) { - const svgEndIndex = fullContent.indexOf('') + 6; - const svgStartMatch = fullContent.match(/```(?:svg)?\s*/i); - if (svgStartMatch) { - const svgStartIndex = svgStartMatch.index; - let svgWithMarkers = fullContent.substring(svgStartIndex, svgEndIndex); - - svgContent = svgWithMarkers.replace(/```(?:svg)?\s*/, '').replace(/```$/, '').trim(); - if (!svgContent.endsWith('')) { - svgContent += ''; - } - - this.svgStorage[this.currentMode][svgId] = { - content: svgContent, - messageId, - mode: this.currentMode, - timestamp: new Date().toISOString() - }; - - this.pendingSvgId = null; - this.renderSvgContent(svgId); - this.updatePlaceholderToClickable(messageContainer, svgId); - - svgStarted = false; - const afterText = fullContent.substring(svgEndIndex); - this.updateStreamingMessageAfterSVG(messageContainer, beforeText, svgId, afterText); - } - } else { - const svgStartMatch = fullContent.match(/```(?:svg)?\s*/i); - if (svgStartMatch) { - const svgStartIndex = svgStartMatch.index; - let svgWithMarkers = fullContent.substring(svgStartIndex); - - svgContent = svgWithMarkers.replace(/```(?:svg)?\s*/, '').replace(/```$/, '').trim(); - let tempSvgContent = svgContent; - if (!tempSvgContent.endsWith('')) { - tempSvgContent += ''; - } - - this.renderTemporarySvg(tempSvgContent); - } - } - } else { - this.updateStreamingMessage(messageContainer, fullContent); - } - }; - - const onComplete = (info = {}) => { - finalizeStream(info); - }; - - try { - const streamHandle = await ( - this.currentMode === 'canvas' - ? window.apiClient.generateProductCanvasStream(userMessage, contextMessages, onChunk, onComplete) - : window.apiClient.generateSWOTAnalysisStream(userMessage, contextMessages, onChunk, onComplete) - ); - - this.activeStreamHandle = streamHandle; - - if (this.pendingCancel) { - const shouldCancel = this.pendingCancel; - this.pendingCancel = false; - this.cancelActiveStream(); - } - - await streamHandle.finished; - - if (!streamClosed) { - finalizeStream({ aborted: false }); - } - } catch (error) { - streamClosed = true; - this.activeStreamHandle = null; - this.isProcessing = false; - this.setSendButtonState('idle'); - this.sendButton.disabled = false; - this.pendingCancel = false; - - const bubble = this.chatHistory.querySelector(`[data-message-id="${messageId}"]`); - if (bubble) { - const wrapper = bubble.closest('.flex'); - if (wrapper) wrapper.remove(); - } - - if (error && error.name === 'AbortError') { - return; - } - - console.error('发送消息失败:', error); - this.addErrorMessage(error.message || '生成失败'); - } - } - - cancelActiveStream() { - if (!this.activeStreamHandle || typeof this.activeStreamHandle.cancel !== 'function') { - this.pendingCancel = true; - this.setSendButtonState('terminating'); - return; - } - - this.pendingCancel = false; - this.setSendButtonState('terminating'); - try { - this.activeStreamHandle.cancel(); - } catch (error) { - console.warn('终止流式请求失败:', error); - } - } - - // 创建流式消息容器 - createStreamingMessageContainer(messageId) { - const messageDiv = document.createElement('div'); - messageDiv.className = 'flex justify-start'; - messageDiv.dataset.messageId = messageId; - messageDiv.innerHTML = ` -
-
-
- `; - return messageDiv; - } - - // 更新流式消息内容 - updateStreamingMessage(container, content) { - const contentDiv = container.querySelector('.typing-cursor'); - if (contentDiv) { - // 使用Markdown解析内容 - if (typeof marked !== 'undefined') { - contentDiv.innerHTML = marked.parse(content); - } else { - contentDiv.textContent = content; - } - Utils.scrollToBottom(this.chatHistory); - } - } - - // 更新流式消息内容并显示SVG占位符 - updateStreamingMessageWithPlaceholder(container, beforeText, svgId) { - // 使用Markdown解析beforeText - const parsedBeforeText = typeof marked !== 'undefined' ? marked.parse(beforeText) : Utils.escapeHtml(beforeText); - - container.innerHTML = ` -
-
- ${parsedBeforeText} -
- 🎨 正在绘制${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'}... -
-
-
-
- `; - Utils.scrollToBottom(this.chatHistory); - } - - // 更新占位符为可点击状态 - updatePlaceholderToClickable(container, svgId) { - const placeholder = container.querySelector('.svg-drawing-placeholder'); - if (placeholder) { - placeholder.classList.remove('svg-drawing-placeholder'); - placeholder.classList.add('svg-placeholder-block'); - placeholder.setAttribute('data-svg-id', svgId); - placeholder.innerHTML = `📊 点击查看${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'} SVG`; - placeholder.setAttribute('onclick', `app.viewSVG('${svgId}')`); - if (this.currentSvgId === svgId) { - placeholder.classList.add('svg-placeholder-active'); - } - } - } - - // 更新SVG后的消息内容 - updateStreamingMessageAfterSVG(container, beforeText, svgId, afterText) { - const parsedBeforeText = typeof marked !== 'undefined' ? marked.parse(beforeText) : Utils.escapeHtml(beforeText); - const parsedAfterText = typeof marked !== 'undefined' ? marked.parse(afterText) : Utils.escapeHtml(afterText); - const placeholderClass = this.currentSvgId === svgId ? 'svg-placeholder-block svg-placeholder-active' : 'svg-placeholder-block'; - - container.innerHTML = ` -
-
- ${parsedBeforeText} -
- 📊 点击查看${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'} SVG -
-
${parsedAfterText}
-
-
- `; - Utils.scrollToBottom(this.chatHistory); - } - - buildActionToolbar(messageId, { allowRegenerate = false, allowRollback = true } = {}) { - const actions = []; - - if (allowRollback) { - actions.push(` - - `); - } - - if (allowRegenerate) { - actions.push(` - - `); - } - - if (!actions.length) { - return ''; - } - - return ` -
- ${actions.join('')} -
- `; - } - - buildDeleteButtonHtml(messageId) { - return ` - - `; - } - - // 组装标准化的SVG消息字符串 - buildSVGMessageContent(beforeText = '', svgBody = '', afterText = '') { - const segments = []; - const trimmedBefore = (beforeText || '').trim(); - const trimmedAfter = (afterText || '').trim(); - const trimmedSvg = (svgBody || '').trim(); - - if (trimmedBefore) { - segments.push(trimmedBefore); - } - - if (trimmedSvg) { - segments.push('```svg'); - segments.push(trimmedSvg); - segments.push('```'); - } - - if (trimmedAfter) { - segments.push(trimmedAfter); - } - - return segments.join('\n\n').trim(); - } - - // 完成流式消息 - finalizeStreamingMessage(messageId, fullContent, svgId = null) { - this.pendingSvgId = null; - const parsed = Utils.parseSVGResponse(fullContent); - const timestamp = new Date().toISOString(); - - const message = { - id: messageId, - type: 'ai', - content: '', - timestamp - }; - - let targetSvgId = svgId || null; - - if (parsed.svgContent && parsed.svgContent.includes('') - ? parsed.svgContent.trim() - : `${parsed.svgContent.trim()} -`; - - if (!targetSvgId || !this.svgStorage[this.currentMode][targetSvgId]) { - targetSvgId = targetSvgId || Utils.generateId('svg'); - } - - this.svgStorage[this.currentMode][targetSvgId] = { - content: svgBody, - messageId, - mode: this.currentMode, - timestamp - }; - - this.currentSvgId = targetSvgId; - this.svgViewer.innerHTML = svgBody; - - message.content = this.buildSVGMessageContent(parsed.beforeText, svgBody, parsed.afterText); - } else { - const sanitizedText = fullContent.replace(/^[\s`]+/, '').replace(/[\s`]+$/, '').trim(); - message.content = sanitizedText; - } - - this.conversationHistory[this.currentMode].push(message); - - Utils.storage.set('canvasHistory', this.conversationHistory.canvas); - Utils.storage.set('swotHistory', this.conversationHistory.swot); - Utils.storage.set('canvasSVGs', this.svgStorage.canvas); - Utils.storage.set('swotSVGs', this.svgStorage.swot); - - this.renderConversationHistory(); - this.renderSvgViewerForMode(); - Utils.scrollToBottom(this.chatHistory); - } - - // 清空当前对话 - clearCurrentConversation() { - if (!confirm(`确定要清空当前的${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'}对话吗?`)) { - return; - } - - // 清空当前模式的对话历史 - this.conversationHistory[this.currentMode] = []; - - // 清空当前模式的SVG存储 - this.svgStorage[this.currentMode] = {}; - this.svgZoom[this.currentMode] = 1; - this.showSvgPlaceholder(); - - // 保存数据 - Utils.storage.set('canvasHistory', this.conversationHistory.canvas); - Utils.storage.set('swotHistory', this.conversationHistory.swot); - Utils.storage.set('canvasSVGs', this.svgStorage.canvas); - Utils.storage.set('swotSVGs', this.svgStorage.swot); - - // 重新渲染对话历史 - this.renderConversationHistory(); - this.renderSvgViewerForMode(); - } - - confirmDeleteMessage(messageId) { - const history = this.conversationHistory[this.currentMode] || []; - const targetMessage = history.find(msg => msg.id === messageId); - if (!targetMessage) { - alert('未找到要删除的消息,请刷新后重试。'); - return; - } - - const typeLabel = targetMessage.type === 'user' - ? '这条用户消息' - : targetMessage.type === 'ai' - ? '这条AI回复' - : '这条提示'; - - const confirmed = confirm(`${typeLabel}删除后无法恢复,确定要删除吗?`); - if (!confirmed) { - return; - } - - this.deleteMessagePermanently(messageId); - } - - deleteMessagePermanently(messageId) { - const history = this.conversationHistory[this.currentMode] || []; - const messageIndex = history.findIndex(msg => msg.id === messageId); - if (messageIndex === -1) { - return; - } - - history.splice(messageIndex, 1); - - const svgStore = this.svgStorage[this.currentMode] || {}; - let viewerShouldReset = false; - for (const [svgId, meta] of Object.entries(svgStore)) { - if (meta.messageId === messageId) { - delete svgStore[svgId]; - if (this.currentSvgId === svgId) { - viewerShouldReset = true; - } - } - } - - if (viewerShouldReset) { - this.currentSvgId = null; - this.showSvgPlaceholder(); - } - - Utils.storage.set('canvasHistory', this.conversationHistory.canvas || []); - Utils.storage.set('swotHistory', this.conversationHistory.swot || []); - Utils.storage.set('canvasSVGs', this.svgStorage.canvas || {}); - Utils.storage.set('swotSVGs', this.svgStorage.swot || {}); - - this.renderConversationHistory(); - this.renderSvgViewerForMode(); - } - - // 添加用户消息 - addUserMessage(text) { - const messageId = Utils.generateId('msg'); - const message = { - id: messageId, - type: 'user', - content: text, - timestamp: new Date().toISOString() - }; - - this.conversationHistory[this.currentMode].push(message); - this.renderMessage(message); - Utils.scrollToBottom(this.chatHistory); - Utils.storage.set('canvasHistory', this.conversationHistory.canvas); - Utils.storage.set('swotHistory', this.conversationHistory.swot); - } - - // 添加AI消息(非流式,保留用于错误情况) - addAIMessage(text) { - const messageId = Utils.generateId('msg'); - const parsed = Utils.parseSVGResponse(text); - - const message = { - id: messageId, - type: 'ai', - content: text, - timestamp: new Date().toISOString() - }; - - this.conversationHistory[this.currentMode].push(message); - - let svgId = null; - if (parsed.svgContent) { - svgId = Utils.generateId('svg'); - this.svgStorage[this.currentMode][svgId] = { - content: parsed.svgContent, - messageId, - mode: this.currentMode, - timestamp: new Date().toISOString() - }; - this.viewSVG(svgId); - } - - Utils.storage.set('canvasHistory', this.conversationHistory.canvas); - Utils.storage.set('swotHistory', this.conversationHistory.swot); - Utils.storage.set('canvasSVGs', this.svgStorage.canvas); - Utils.storage.set('swotSVGs', this.svgStorage.swot); - - this.renderConversationHistory(); - this.renderSvgViewerForMode(); - } - - // 添加错误消息 - addErrorMessage(errorText) { - const messageId = Utils.generateId('msg'); - const message = { - id: messageId, - type: 'error', - content: errorText, - timestamp: new Date().toISOString() - }; - - this.conversationHistory[this.currentMode].push(message); - this.renderMessage(message); - Utils.scrollToBottom(this.chatHistory); - Utils.storage.set('canvasHistory', this.conversationHistory.canvas); - Utils.storage.set('swotHistory', this.conversationHistory.swot); - } - - // 渲染消息 - renderMessage(message, options = {}) { - const { allowRegenerate = false, allowRollback = message.type === 'ai' } = options; - const messageDiv = document.createElement('div'); - - if (message.type === 'user') { - messageDiv.className = 'flex justify-end'; - const deleteButton = this.buildDeleteButtonHtml(message.id); - messageDiv.innerHTML = ` -
-
${Utils.escapeHtml(message.content)}
- ${deleteButton} -
- `; - } else if (message.type === 'error') { - messageDiv.className = 'flex justify-start'; - const deleteButton = this.buildDeleteButtonHtml(message.id); - messageDiv.innerHTML = ` -
- ${deleteButton} -
- - ${Utils.escapeHtml(message.content)} -
-
- `; - } else if (message.type === 'ai') { - const parsedContent = typeof marked !== 'undefined' ? marked.parse(message.content) : Utils.escapeHtml(message.content); - const actions = this.buildActionToolbar(message.id, { allowRegenerate, allowRollback }); - const deleteButton = this.buildDeleteButtonHtml(message.id); - messageDiv.className = 'flex justify-start'; - messageDiv.innerHTML = ` -
- ${deleteButton} -
- ${parsedContent} -
- ${actions} -
- `; - } - - this.chatHistory.appendChild(messageDiv); - } - - // 渲染包含SVG的消息 - renderMessageWithSVG(message, parsed, svgId, options = {}) { - const { allowRegenerate = false, allowRollback = true } = options; - const messageDiv = document.createElement('div'); - const beforeHtml = parsed.beforeText ? (typeof marked !== 'undefined' ? marked.parse(parsed.beforeText) : Utils.escapeHtml(parsed.beforeText)) : ''; - const afterHtml = parsed.afterText ? (typeof marked !== 'undefined' ? marked.parse(parsed.afterText) : Utils.escapeHtml(parsed.afterText)) : ''; - const actions = this.buildActionToolbar(message.id, { allowRegenerate, allowRollback }); - const deleteButton = this.buildDeleteButtonHtml(message.id); - - messageDiv.className = 'flex justify-start'; - const placeholderClass = this.currentSvgId === svgId ? 'svg-placeholder-block svg-placeholder-active' : 'svg-placeholder-block'; - messageDiv.innerHTML = ` -
- ${deleteButton} -
- ${beforeHtml} -
- 📊 点击查看 ${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'} SVG -
- ${afterHtml} -
- ${actions} -
- `; - - this.chatHistory.appendChild(messageDiv); - } - - // 渲染对话历史 - renderConversationHistory() { - this.chatHistory.innerHTML = ''; - - // 获取当前模式的对话历史 - const currentHistory = this.conversationHistory[this.currentMode] || []; - const currentSvgStorage = this.svgStorage[this.currentMode] || {}; - let hasStorageUpdate = false; - let hasHistoryUpdate = false; - - let lastAiMessageId = null; - for (let i = currentHistory.length - 1; i >= 0; i--) { - if (currentHistory[i].type === 'ai') { - lastAiMessageId = currentHistory[i].id; - break; - } - } - - for (const message of currentHistory) { - if (message.type === 'ai') { - const parsed = Utils.parseSVGResponse(message.content); - - // 查找或补建对应的SVG - let svgId = null; - for (const [id, svg] of Object.entries(currentSvgStorage)) { - if (svg.messageId === message.id) { - svgId = id; - break; - } - } - - const hasSvgContent = parsed.svgContent && parsed.svgContent.includes('') - ? parsed.svgContent.trim() - : `${parsed.svgContent.trim()}\n`; - svgId = Utils.generateId('svg'); - currentSvgStorage[svgId] = { - content: normalizedSvg, - messageId: message.id, - mode: this.currentMode, - timestamp: message.timestamp || new Date().toISOString() - }; - parsed.svgContent = normalizedSvg; - message.content = this.buildSVGMessageContent(parsed.beforeText, normalizedSvg, parsed.afterText); - hasStorageUpdate = true; - hasHistoryUpdate = true; - } - this.renderMessageWithSVG(message, parsed, svgId, { allowRegenerate: message.id === lastAiMessageId }); - continue; - } - - this.renderMessage(message, { allowRegenerate: message.id === lastAiMessageId }); - } else { - this.renderMessage(message); - } - } - - if (hasStorageUpdate) { - this.svgStorage[this.currentMode] = currentSvgStorage; - Utils.storage.set('canvasSVGs', this.svgStorage.canvas || {}); - Utils.storage.set('swotSVGs', this.svgStorage.swot || {}); - } - if (hasHistoryUpdate) { - Utils.storage.set('canvasHistory', this.conversationHistory.canvas || []); - Utils.storage.set('swotHistory', this.conversationHistory.swot || []); - } - - this.setActivePlaceholder(this.currentSvgId); - Utils.scrollToBottom(this.chatHistory); - } - - // 显示SVG - viewSVG(svgId) { - const store = this.svgStorage[this.currentMode] || {}; - if (!store[svgId]) { - console.error('SVG not found:', svgId); - return; - } - - this.renderSvgContent(svgId); - } - - // 退回到指定消息 - rollbackToMessage(messageId) { - const messageIndex = this.conversationHistory[this.currentMode].findIndex(msg => msg.id === messageId); - if (messageIndex === -1) return; - - // 删除指定消息之后的所有消息 - const messagesToRemove = this.conversationHistory[this.currentMode].slice(messageIndex + 1); - - // 删除相关的SVG - for (const message of messagesToRemove) { - for (const [svgId, svg] of Object.entries(this.svgStorage[this.currentMode])) { - if (svg.messageId === message.id) { - delete this.svgStorage[this.currentMode][svgId]; - - // 如果当前显示的是被删除的SVG,清空显示 - if (this.currentSvgId === svgId) { - this.showSvgPlaceholder(); - } - } - } - } - - // 更新对话历史 - this.conversationHistory[this.currentMode] = this.conversationHistory[this.currentMode].slice(0, messageIndex + 1); - - // 保存数据 - Utils.storage.set('canvasHistory', this.conversationHistory.canvas); - Utils.storage.set('swotHistory', this.conversationHistory.swot); - Utils.storage.set('canvasSVGs', this.svgStorage.canvas); - Utils.storage.set('swotSVGs', this.svgStorage.swot); - - // 重新渲染对话历史 - this.renderConversationHistory(); - this.renderSvgViewerForMode(); - } - - // 重新生成消息 - async regenerateMessage(messageId) { - if (this.isProcessing) return; - - const history = this.conversationHistory[this.currentMode] || []; - const targetIndex = history.findIndex(msg => msg.id === messageId && msg.type === 'ai'); - if (targetIndex === -1) { - console.warn('未找到可重新生成的消息'); - return; - } - - let userIndex = -1; - for (let i = targetIndex - 1; i >= 0; i--) { - if (history[i].type === 'user') { - userIndex = i; - break; - } - } - - if (userIndex === -1) { - alert('未找到对应的用户消息,无法重新生成'); - return; - } - - const payload = this.buildContextForUserMessage(userIndex); - if (!payload) { - alert('无法构建对话上下文,请稍后重试'); - return; - } - - this.pendingCancel = false; - this.isProcessing = true; - this.setSendButtonState('streaming'); - this.sendButton.disabled = false; - - try { - await this.startStreamingMessage(payload.userMessage, payload.contextMessages); - } catch (error) { - console.error('重新生成失败:', error); - this.addErrorMessage(error.message); - this.isProcessing = false; - this.setSendButtonState('idle'); - this.activeStreamHandle = null; - this.sendButton.disabled = false; - } - } - - getActiveSvgRecord(showWarning = true) { - const store = this.svgStorage[this.currentMode] || {}; - if (this.currentSvgId && store[this.currentSvgId]) { - return { id: this.currentSvgId, ...store[this.currentSvgId] }; - } - - if (showWarning) { - alert('请先生成并选择一个图表'); - } - return null; - } - - parseSvgDimensions(svgContent) { - try { - const parser = new DOMParser(); - const doc = parser.parseFromString(svgContent, 'image/svg+xml'); - const svgEl = doc.querySelector('svg'); - if (!svgEl) { - return { width: 1024, height: 768 }; - } - - const parseLength = (value) => { - if (!value) return null; - const match = /([0-9.]+)/.exec(value); - return match ? parseFloat(match[1]) : null; - }; - - let width = parseLength(svgEl.getAttribute('width')); - let height = parseLength(svgEl.getAttribute('height')); - const viewBox = svgEl.getAttribute('viewBox'); - - if ((!width || !height) && viewBox) { - const parts = viewBox.split(/\s+/).map(Number).filter(n => !Number.isNaN(n)); - if (parts.length === 4) { - width = width || parts[2]; - height = height || parts[3]; - } - } - - return { - width: width || 1024, - height: height || 768 - }; - } catch (error) { - console.warn('解析SVG尺寸失败:', error); - return { width: 1024, height: 768 }; - } - } - - loadImageFromUrl(url) { - return new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => resolve(img); - img.onerror = (err) => reject(err); - img.src = url; - }); - } - - async convertSvgToPngBlob(svgContent, options = {}) { - const { width, height } = this.parseSvgDimensions(svgContent); - const svgBlob = new Blob([svgContent], { type: 'image/svg+xml' }); - const url = URL.createObjectURL(svgBlob); - - try { - const img = await this.loadImageFromUrl(url); - const canvas = document.createElement('canvas'); - const baseWidth = Math.max(1, img.naturalWidth || width || 1024); - const baseHeight = Math.max(1, img.naturalHeight || height || 768); - const preferredScale = options.scale || this.imageExportScale || 1; - const exportScale = Math.min(4, Math.max(1, preferredScale)); - canvas.width = Math.round(baseWidth * exportScale); - canvas.height = Math.round(baseHeight * exportScale); - - const ctx = canvas.getContext('2d'); - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.drawImage(img, 0, 0, canvas.width, canvas.height); - - return await new Promise((resolve, reject) => { - canvas.toBlob((blob) => { - if (blob) { - resolve(blob); - } else { - reject(new Error('无法生成PNG图像')); - } - }, 'image/png'); - }); - } finally { - URL.revokeObjectURL(url); - } - } - - async copySvgToClipboard() { - const record = this.getActiveSvgRecord(); - if (!record) return; - - if (!navigator.clipboard || typeof ClipboardItem === 'undefined') { - alert('当前浏览器不支持复制图片到剪贴板,请使用下载功能。'); - return; - } - - try { - const blob = await this.convertSvgToPngBlob(record.content); - await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); - alert('图像已复制到剪贴板'); - } catch (error) { - console.error('复制图片失败:', error); - alert('复制失败,请稍后重试。'); - } - } - - // 下载SVG - downloadSVG() { - const record = this.getActiveSvgRecord(); - if (!record) return; - - const filename = `${this.currentMode}-${Utils.formatDateTime().replace(/[/:]/g, '-')}.svg`; - Utils.downloadFile(record.content, filename, 'image/svg+xml'); - } - - // 导出为图片 - async exportAsImage() { - const record = this.getActiveSvgRecord(); - if (!record) return; - - try { - const blob = await this.convertSvgToPngBlob(record.content); - const filename = `${this.currentMode}-${Utils.formatDateTime().replace(/[/:]/g, '-')}.png`; - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = filename; - link.click(); - URL.revokeObjectURL(url); - } catch (error) { - console.error('导出图片失败:', error); - alert('导出图片失败,请稍后重试。'); - } - } - - // 查看SVG代码 - viewSVGCode() { - const record = this.getActiveSvgRecord(); - if (!record) return; - - const svgContent = record.content; - - // 创建代码查看模态窗 - const modal = document.createElement('div'); - modal.className = 'modal-overlay active'; - modal.innerHTML = ` - - `; - - document.body.appendChild(modal); - - // 关闭模态窗 - const closeModal = () => { - document.body.removeChild(modal); - }; - - modal.querySelector('.close-modal').addEventListener('click', closeModal); - modal.addEventListener('click', (e) => { - if (e.target === modal) { - closeModal(); - } - }); - - // 复制代码 - modal.querySelector('.copy-btn').addEventListener('click', () => { - navigator.clipboard.writeText(svgContent).then(() => { - const btn = modal.querySelector('.copy-btn'); - const originalHTML = btn.innerHTML; - btn.innerHTML = ' 已复制'; - btn.classList.remove('bg-blue-500', 'hover:bg-blue-600'); - btn.classList.add('bg-green-500', 'hover:bg-green-600'); - - setTimeout(() => { - btn.innerHTML = originalHTML; - btn.classList.remove('bg-green-500', 'hover:bg-green-600'); - btn.classList.add('bg-blue-500', 'hover:bg-blue-600'); - }, 2000); - }); - }); - } - - // 打开API配置模态窗 - openConfigModal() { - this.configModal.classList.add('active'); - const apiConfig = window.apiClient.getConfig(); - this.apiUrlInput.value = apiConfig.url || ''; - this.apiKeyInput.value = apiConfig.key || ''; - this.apiModelInput.value = apiConfig.model || ''; - } - - // 关闭API配置模态窗 - closeConfigModal() { - this.configModal.classList.remove('active'); - } - - // 保存API配置 - saveAPIConfig() { - const config = { - url: this.apiUrlInput.value.trim(), - key: this.apiKeyInput.value.trim(), - model: this.apiModelInput.value.trim() - }; - - if (!config.url || !config.key || !config.model) { - Utils.showStatus(this.configStatus, '⚠️ 请填写所有字段', 'error'); - return; - } - - window.apiClient.saveConfig(config); - Utils.showStatus(this.configStatus, '✅ 配置已保存成功!', 'success'); - - setTimeout(() => { - this.closeConfigModal(); - }, 1500); - } - - // 测试API连接 - async testAPIConnection() { - const config = { - url: this.apiUrlInput.value.trim(), - key: this.apiKeyInput.value.trim(), - model: this.apiModelInput.value.trim() - }; - - if (!config.url || !config.key || !config.model) { - Utils.showStatus(this.configStatus, '⚠️ 请先填写所有字段', 'error'); - return; - } - - Utils.showStatus(this.configStatus, '🔄 正在测试连接...', 'loading'); - - try { - // 临时保存配置进行测试 - window.apiClient.saveConfig(config); - await window.apiClient.testConnection(); - Utils.showStatus(this.configStatus, '✅ 连接测试成功!', 'success'); - } catch (error) { - Utils.showStatus(this.configStatus, `❌ 连接失败: ${error.message}`, 'error'); - } - } -} - -// 页面加载完成后初始化应用 -document.addEventListener('DOMContentLoaded', () => { - window.app = new ProductCanvasApp(); -}); +})(); diff --git a/js/core/app-shell.js b/js/core/app-shell.js new file mode 100644 index 0000000..3caf3e0 --- /dev/null +++ b/js/core/app-shell.js @@ -0,0 +1,975 @@ +(function (global) { + 'use strict'; + + const STREAM_DEFAULT_OPTIONS = { + maxTokens: 13000, + temperature: 0.7 + }; + + class AppShell { + constructor({ apiClient, moduleRuntime }) { + if (!apiClient) throw new Error('AppShell 初始化失败:缺少 apiClient'); + if (!moduleRuntime) + throw new Error('AppShell 初始化失败:缺少 moduleRuntime'); + + this.apiClient = apiClient; + this.runtime = moduleRuntime; + this.conversationService = moduleRuntime.getConversationService(); + + this.el = {}; + this.moduleButtons = new Map(); + this.copyClipboardSupported = + typeof ClipboardItem !== 'undefined' && !!navigator.clipboard; + + const deviceScale = + typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1; + this.imageExportScale = Math.min(4, Math.max(2, deviceScale)); + + this.isProcessing = false; + this.activeStreamHandle = null; + this.pendingCancel = false; + this.streamState = null; + this.echartsInstance = null; + + this.globalStore = moduleRuntime.storageService.global(); + this.activeModuleId = null; + + this.initElements(); + this.bindGlobalEvents(); + this.setupModuleSwitcher(); + this.restoreLastModule(); + this.setSendButtonState('idle'); + } + + initElements() { + this.el.pageTitle = document.getElementById('page-title'); + this.el.moduleButtonGroup = document.getElementById( + 'module-button-group' + ); + + // 对话相关 + this.el.chatInput = document.getElementById('chat-input'); + this.el.sendButton = document.getElementById('send-button'); + this.el.clearHistoryBtn = document.getElementById('clear-history-btn'); + this.el.chatHistory = document.getElementById('chat-history'); + + // 视图区域 + this.el.viewer = document.getElementById('svg-viewer'); + this.el.placeholderText = + this.el.viewer && this.el.viewer.querySelector('#placeholder-text'); + + // 工具栏 + this.el.zoomOutBtn = document.getElementById('zoom-out-btn'); + this.el.zoomInBtn = document.getElementById('zoom-in-btn'); + this.el.zoomResetBtn = document.getElementById('zoom-reset-btn'); + this.el.downloadSvgBtn = document.getElementById('download-svg-btn'); + this.el.copyImageBtn = document.getElementById('copy-image-btn'); + this.el.exportImageBtn = document.getElementById('export-image-btn'); + this.el.viewCodeBtn = document.getElementById('view-code-btn'); + + // 配置模态框 + this.el.settingsBtn = document.getElementById('settings-btn'); + this.el.configModal = document.getElementById('config-modal'); + this.el.closeModalBtn = document.getElementById('close-modal-btn'); + this.el.apiUrlInput = document.getElementById('api-url'); + this.el.apiKeyInput = document.getElementById('api-key'); + this.el.apiModelInput = document.getElementById('api-model'); + this.el.testApiBtn = document.getElementById('test-api-btn'); + this.el.saveConfigBtn = document.getElementById('save-config-btn'); + this.el.configStatus = document.getElementById('config-status'); + this.el.statusText = document.getElementById('status-text'); + + // 复制按钮可用性 + if (this.el.copyImageBtn && !this.copyClipboardSupported) { + this.el.copyImageBtn.disabled = true; + this.el.copyImageBtn.title = '当前浏览器不支持复制图片到剪贴板'; + } + } + + bindGlobalEvents() { + if (this.el.sendButton) { + this.el.sendButton.addEventListener('click', () => { + if (this.isProcessing) { + this.cancelActiveStream(); + } else { + this.sendMessage(); + } + }); + } + + if (this.el.chatInput) { + this.el.chatInput.addEventListener('keypress', (event) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + if (!this.isProcessing) { + this.sendMessage(); + } + } + }); + + this.el.chatInput.addEventListener('input', () => { + Utils.autoResizeTextarea(this.el.chatInput); + }); + } + + if (this.el.clearHistoryBtn) { + this.el.clearHistoryBtn.addEventListener('click', () => + this.clearCurrentConversation() + ); + } + + if (this.el.zoomInBtn) { + this.el.zoomInBtn.addEventListener('click', () => this.adjustZoom(0.25)); + } + if (this.el.zoomOutBtn) { + this.el.zoomOutBtn.addEventListener('click', () => + this.adjustZoom(-0.25) + ); + } + if (this.el.zoomResetBtn) { + this.el.zoomResetBtn.addEventListener('click', () => + this.resetZoom() + ); + } + + if (this.el.downloadSvgBtn) { + this.el.downloadSvgBtn.addEventListener('click', () => + this.downloadArtifact() + ); + } + if (this.el.copyImageBtn) { + this.el.copyImageBtn.addEventListener('click', () => + this.copyArtifactImage() + ); + } + if (this.el.exportImageBtn) { + this.el.exportImageBtn.addEventListener('click', () => + this.exportArtifactAsImage() + ); + } + if (this.el.viewCodeBtn) { + this.el.viewCodeBtn.addEventListener('click', () => + this.viewArtifactCode() + ); + } + + if (this.el.settingsBtn) { + this.el.settingsBtn.addEventListener('click', () => + this.openConfigModal() + ); + } + if (this.el.closeModalBtn) { + this.el.closeModalBtn.addEventListener('click', () => + this.closeConfigModal() + ); + } + if (this.el.configModal) { + this.el.configModal.addEventListener('click', (event) => { + if (event.target === this.el.configModal) { + this.closeConfigModal(); + } + }); + } + if (this.el.testApiBtn) { + this.el.testApiBtn.addEventListener('click', () => this.testAPI()); + } + if (this.el.saveConfigBtn) { + this.el.saveConfigBtn.addEventListener('click', () => this.saveAPI()); + } + } + + setupModuleSwitcher() { + const manifests = this.runtime.listManifests(); + if (!manifests.length) { + throw new Error('未找到可用模块,请确认模块清单是否注册'); + } + + if (!this.el.moduleButtonGroup) { + console.warn('模块按钮容器缺失,无法渲染模块切换按钮'); + return; + } + this.el.moduleButtonGroup.innerHTML = ''; + this.moduleButtons.clear(); + + manifests.forEach((manifest) => { + const button = document.createElement('button'); + button.className = + 'module-btn bg-white text-gray-700 px-4 py-2 font-bold border-2 border-black hover:bg-gray-100 transition-all duration-200 flex items-center gap-1'; + button.dataset.moduleId = manifest.id; + button.innerHTML = ` + + ${manifest.label} + `; + button.addEventListener('click', () => + this.activateModule(manifest.id) + ); + this.el.moduleButtonGroup.appendChild(button); + this.moduleButtons.set(manifest.id, button); + }); + } + + restoreLastModule() { + const manifests = this.runtime.listManifests(); + const preferredId = + this.globalStore.get('activeModuleId', null) || + (manifests[0] && manifests[0].id); + const fallbackId = manifests[0].id; + this.activateModule( + this.runtime.registry.has(preferredId) ? preferredId : fallbackId + ); + } + + activateModule(moduleId) { + if (this.activeModuleId === moduleId) { + return; + } + const context = this.runtime.activate(moduleId); + this.activeModuleId = moduleId; + this.globalStore.set('activeModuleId', moduleId); + + this.highlightActiveModuleButton(); + this.updateModuleUI(context.manifest); + this.loadApiConfig(); + this.renderConversationHistory(); + this.renderActiveArtifact(); + this.updateToolbarState(); + } + + highlightActiveModuleButton() { + this.moduleButtons.forEach((button, id) => { + if (id === this.activeModuleId) { + button.classList.add('mode-btn-active'); + button.classList.remove('mode-btn-inactive'); + } else { + button.classList.add('mode-btn-inactive'); + button.classList.remove('mode-btn-active'); + } + }); + } + + updateModuleUI(manifest) { + if (this.el.pageTitle) { + this.el.pageTitle.textContent = manifest.label; + } + if (this.el.chatInput && manifest.chat?.placeholder) { + this.el.chatInput.placeholder = manifest.chat.placeholder; + } + this.showViewerPlaceholder(manifest.ui?.placeholderText || ''); + } + + showViewerPlaceholder(text) { + if (!this.el.viewer) return; + this.el.viewer.innerHTML = ` +
+ +

${text || + '生成的内容将在此处显示'}

+
+ `; + this.el.placeholderText = + this.el.viewer && this.el.viewer.querySelector('#placeholder-text'); + } + + getActiveManifest() { + return this.runtime.getManifest(this.activeModuleId); + } + + getCurrentHistory() { + return this.conversationService.getHistory(this.getActiveManifest()); + } + + renderConversationHistory() { + if (!this.el.chatHistory) return; + const history = this.getCurrentHistory(); + this.el.chatHistory.innerHTML = ''; + const manifest = this.getActiveManifest(); + + if (!history.length) { + const welcome = document.createElement('div'); + welcome.className = 'flex justify-start'; + welcome.innerHTML = ` +
+ 👋 欢迎使用 ${manifest.label} 助手!请输入需求,我会结合模块特性生成图表与分析。 +
+ `; + this.el.chatHistory.appendChild(welcome); + return; + } + + history.forEach((message) => { + const bubble = this.buildMessageBubble(message); + this.el.chatHistory.appendChild(bubble); + }); + + Utils.scrollToBottom(this.el.chatHistory); + } + + buildMessageBubble(message) { + const wrapper = document.createElement('div'); + const manifest = this.getActiveManifest(); + if (message.type === 'user') { + wrapper.className = 'flex justify-end'; + wrapper.innerHTML = ` +
+
${Utils.escapeHtml(message.content)}
+
+ `; + } else if (message.type === 'error') { + wrapper.className = 'flex justify-start'; + wrapper.innerHTML = ` +
+
+ + ${Utils.escapeHtml(message.content)} +
+
+ `; + } else { + wrapper.className = 'flex justify-start'; + const parsedContent = + typeof marked !== 'undefined' + ? marked.parse(message.content) + : Utils.escapeHtml(message.content); + const artifactHtml = message.artifactId + ? `
+ 📊 点击查看最新图表 +
` + : ''; + wrapper.innerHTML = ` +
+
${parsedContent}
+ ${artifactHtml} +
+ `; + if (message.artifactId) { + const placeholder = wrapper.querySelector('.svg-placeholder-block'); + placeholder?.addEventListener('click', () => + this.renderArtifact(message.artifactId) + ); + } + } + return wrapper; + } + + sendMessage() { + if (!this.el.chatInput) return; + const message = this.el.chatInput.value.trim(); + if (!message || this.isProcessing) return; + + if (!this.apiClient.isConfigValid()) { + alert('⚠️ 请先配置API设置!点击右上角齿轮图标进行配置。'); + this.openConfigModal(); + return; + } + + const manifest = this.getActiveManifest(); + const userMessage = { + id: Utils.generateId('msg'), + type: 'user', + content: message, + timestamp: new Date().toISOString() + }; + + this.conversationService.appendMessage(manifest, userMessage); + this.renderConversationHistory(); + this.el.chatInput.value = ''; + Utils.autoResizeTextarea(this.el.chatInput); + + const context = this.conversationService.buildContext(manifest); + if (!context) { + console.warn('无法构建上下文,终止发送'); + return; + } + + this.isProcessing = true; + this.pendingCancel = false; + this.setSendButtonState('streaming'); + this.el.sendButton.disabled = false; + + this.startStreaming(manifest, context); + } + + startStreaming(manifest, context) { + const messageId = Utils.generateId('msg'); + const container = this.createStreamingContainer(messageId); + this.el.chatHistory.appendChild(container); + Utils.scrollToBottom(this.el.chatHistory); + + let fullContent = ''; + + const finalize = ({ aborted = false } = {}) => { + if (!this.isProcessing) return; + this.isProcessing = false; + this.setSendButtonState('idle'); + this.el.sendButton.disabled = false; + this.activeStreamHandle = null; + this.pendingCancel = false; + + if (aborted) { + container.remove(); + return; + } + + this.finalizeAssistantMessage(manifest, messageId, fullContent); + }; + + const handleChunk = (chunk) => { + const delta = chunk?.choices?.[0]?.delta?.content || ''; + if (!delta) return; + fullContent += delta; + this.updateStreamingContent(container, fullContent); + }; + + const handleComplete = (info) => { + finalize(info); + }; + + this.apiClient + .generateModuleStream( + manifest, + context.userMessage.content, + context.contextMessages, + handleChunk, + handleComplete, + STREAM_DEFAULT_OPTIONS + ) + .then((streamHandle) => { + this.activeStreamHandle = streamHandle; + if (this.pendingCancel) { + this.pendingCancel = false; + this.cancelActiveStream(); + } + return streamHandle.finished; + }) + .then(() => finalize({ aborted: false })) + .catch((error) => { + console.error('发送消息失败:', error); + finalize({ aborted: true }); + this.addErrorMessage( + error.message || '生成失败,请稍后再试', + manifest + ); + }); + } + + createStreamingContainer(messageId) { + const messageDiv = document.createElement('div'); + messageDiv.className = 'flex justify-start'; + messageDiv.dataset.messageId = messageId; + messageDiv.innerHTML = ` +
+
+
+ `; + return messageDiv; + } + + updateStreamingContent(container, content) { + const cursor = container.querySelector('.typing-cursor'); + if (!cursor) return; + if (typeof marked !== 'undefined') { + cursor.innerHTML = marked.parse(content); + } else { + cursor.textContent = content; + } + Utils.scrollToBottom(this.el.chatHistory); + } + + finalizeAssistantMessage(manifest, messageId, fullContent) { + const container = this.el.chatHistory.querySelector( + `[data-message-id="${messageId}"]` + ); + if (container) { + container.remove(); + } + + const timestamp = new Date().toISOString(); + let artifactId = null; + let artifactPayload = null; + + if (manifest.artifact?.parser) { + try { + const parsed = manifest.artifact.parser(fullContent); + if (manifest.artifact.type === 'svg' && parsed.svgContent) { + artifactId = Utils.generateId('svg'); + const svgBody = parsed.svgContent.trim().endsWith('') + ? parsed.svgContent.trim() + : `${parsed.svgContent.trim()}\n`; + artifactPayload = { + id: artifactId, + type: manifest.artifact.type, + content: svgBody, + messageId, + timestamp + }; + } else if ( + manifest.artifact.type === 'echarts-option' && + parsed.option + ) { + artifactId = Utils.generateId('chart'); + artifactPayload = { + id: artifactId, + type: manifest.artifact.type, + option: parsed.option, + optionText: parsed.optionText || JSON.stringify(parsed.option), + messageId, + timestamp + }; + } + } catch (error) { + console.warn('解析助手内容失败:', error); + } + } + + const messageRecord = { + id: messageId, + type: 'ai', + content: fullContent, + timestamp, + artifactId + }; + this.conversationService.appendMessage(manifest, messageRecord); + + if (artifactId && artifactPayload) { + this.runtime.saveArtifact(manifest.id, artifactId, artifactPayload); + this.renderArtifact(artifactId); + } + + this.renderConversationHistory(); + } + + addErrorMessage(errorText, manifest) { + const message = { + id: Utils.generateId('msg'), + type: 'error', + content: errorText, + timestamp: new Date().toISOString() + }; + this.conversationService.appendMessage(manifest, message); + this.renderConversationHistory(); + } + + cancelActiveStream() { + if (!this.activeStreamHandle || typeof this.activeStreamHandle.cancel !== 'function') { + this.pendingCancel = true; + this.setSendButtonState('terminating'); + return; + } + this.pendingCancel = false; + this.setSendButtonState('terminating'); + try { + this.activeStreamHandle.cancel(); + } catch (error) { + console.warn('终止流式请求失败:', error); + } + } + + renderActiveArtifact() { + const manifest = this.getActiveManifest(); + const state = this.runtime.getState(manifest.id); + const activeId = state.currentArtifactId; + if (activeId) { + this.renderArtifact(activeId); + } else { + this.showViewerPlaceholder(manifest.ui?.placeholderText || ''); + } + } + + renderArtifact(artifactId) { + const manifest = this.getActiveManifest(); + const artifacts = this.runtime.getArtifacts(manifest.id); + const artifact = artifacts[artifactId]; + if (!artifact) { + console.warn('未找到图形资源', artifactId); + return; + } + this.runtime.setActiveArtifact(manifest.id, artifactId); + if (artifact.type === 'svg') { + this.renderSvgArtifact(artifact); + } else if (artifact.type === 'echarts-option') { + this.renderEChartsArtifact(artifact); + } + this.updateToolbarState(); + } + + renderSvgArtifact(artifact) { + if (!this.el.viewer) return; + this.el.viewer.innerHTML = ''; + const wrapper = document.createElement('div'); + wrapper.className = 'svg-content-wrapper'; + wrapper.innerHTML = artifact.content; + this.el.viewer.appendChild(wrapper); + const uiState = this.runtime.getUiState(this.activeModuleId, { + zoom: 1 + }); + wrapper.style.transform = `scale(${uiState.zoom})`; + wrapper.style.transformOrigin = 'center top'; + } + + renderEChartsArtifact(artifact) { + if (!this.el.viewer) return; + this.el.viewer.innerHTML = ''; + const chartContainer = document.createElement('div'); + chartContainer.id = 'echarts-container'; + chartContainer.style.width = '100%'; + chartContainer.style.height = '100%'; + this.el.viewer.appendChild(chartContainer); + + if (!window.echarts) { + chartContainer.innerHTML = + '

未加载 ECharts 库

'; + return; + } + + if (this.echartsInstance) { + this.echartsInstance.dispose(); + } + this.echartsInstance = window.echarts.init(chartContainer, null, { + renderer: 'canvas' + }); + this.echartsInstance.setOption(artifact.option, true); + } + + adjustZoom(delta) { + const manifest = this.getActiveManifest(); + if (manifest.artifact?.type !== 'svg') return; + const uiState = this.runtime.getUiState(manifest.id, { zoom: 1 }); + const nextZoom = Math.min( + 3, + Math.max(0.25, parseFloat((uiState.zoom + delta).toFixed(2))) + ); + this.runtime.updateUiState(manifest.id, { zoom: nextZoom }); + this.renderActiveArtifact(); + } + + resetZoom() { + const manifest = this.getActiveManifest(); + if (manifest.artifact?.type !== 'svg') return; + this.runtime.updateUiState(manifest.id, { zoom: 1 }); + this.renderActiveArtifact(); + } + + downloadArtifact() { + const manifest = this.getActiveManifest(); + const state = this.runtime.getState(manifest.id); + const id = state.currentArtifactId; + if (!id) return; + const artifact = state.artifacts[id]; + if (!artifact) return; + + if (artifact.type === 'svg') { + Utils.downloadFile(artifact.content, `${manifest.id}.svg`, 'image/svg+xml'); + } else { + alert('当前图表不支持导出 SVG,请使用导出图片功能'); + } + } + + async copyArtifactImage() { + const manifest = this.getActiveManifest(); + const state = this.runtime.getState(manifest.id); + const id = state.currentArtifactId; + if (!id) return; + const artifact = state.artifacts[id]; + if (!artifact) return; + + if (artifact.type !== 'svg') { + alert('暂不支持复制此类型图表到剪贴板'); + return; + } + + const svgBlob = new Blob([artifact.content], { + type: 'image/svg+xml' + }); + const svgUrl = URL.createObjectURL(svgBlob); + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.src = svgUrl; + + image.onload = async () => { + const canvas = document.createElement('canvas'); + canvas.width = image.width * this.imageExportScale; + canvas.height = image.height * this.imageExportScale; + const ctx = canvas.getContext('2d'); + ctx.setTransform(this.imageExportScale, 0, 0, this.imageExportScale, 0, 0); + ctx.drawImage(image, 0, 0); + + canvas.toBlob(async (blob) => { + try { + const clipboardItem = new ClipboardItem({ 'image/png': blob }); + await navigator.clipboard.write([clipboardItem]); + alert('图像已复制到剪贴板'); + } catch (error) { + console.error('复制失败:', error); + alert('复制失败,请稍后再试'); + } finally { + URL.revokeObjectURL(svgUrl); + } + }); + }; + } + + exportArtifactAsImage() { + const manifest = this.getActiveManifest(); + const state = this.runtime.getState(manifest.id); + const id = state.currentArtifactId; + if (!id) return; + const artifact = state.artifacts[id]; + if (!artifact) return; + + if (artifact.type === 'svg') { + const svgBlob = new Blob([artifact.content], { + type: 'image/svg+xml' + }); + const svgUrl = URL.createObjectURL(svgBlob); + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.src = svgUrl; + image.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = image.width * this.imageExportScale; + canvas.height = image.height * this.imageExportScale; + const ctx = canvas.getContext('2d'); + ctx.setTransform(this.imageExportScale, 0, 0, this.imageExportScale, 0, 0); + ctx.drawImage(image, 0, 0); + const pngUrl = canvas.toDataURL('image/png'); + const link = document.createElement('a'); + link.href = pngUrl; + link.download = `${manifest.id}-${Utils.formatDateTime().replace(/\\W/g, '')}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(svgUrl); + }; + } else if (artifact.type === 'echarts-option') { + if (!this.echartsInstance) { + alert('图表实例未准备好,无法导出'); + return; + } + const url = this.echartsInstance.getDataURL({ + type: 'png', + pixelRatio: 2, + backgroundColor: '#fff' + }); + const link = document.createElement('a'); + link.href = url; + link.download = `${manifest.id}-${Utils.formatDateTime().replace(/\\W/g, '')}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + } + + viewArtifactCode() { + const manifest = this.getActiveManifest(); + const state = this.runtime.getState(manifest.id); + const id = state.currentArtifactId; + if (!id) return; + const artifact = state.artifacts[id]; + if (!artifact) return; + + let content = ''; + if (artifact.type === 'svg') { + content = artifact.content; + } else if (artifact.type === 'echarts-option') { + content = artifact.optionText || JSON.stringify(artifact.option, null, 2); + } + + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + + setTimeout(() => URL.revokeObjectURL(url), 1000 * 30); + } + + setSendButtonState(state) { + if (!this.el.sendButton) return; + if (state === 'streaming') { + this.el.sendButton.innerHTML = ` + + + 终止 + + `; + this.el.sendButton.classList.add('terminate-mode'); + this.el.sendButton.title = '终止当前生成'; + } else if (state === 'terminating') { + this.el.sendButton.innerHTML = ` + + + 终止中 + + `; + this.el.sendButton.classList.add('terminate-mode'); + this.el.sendButton.title = '正在终止生成'; + } else if (state === 'busy') { + this.el.sendButton.innerHTML = ` + + + 处理中 + + `; + this.el.sendButton.classList.add('terminate-mode'); + this.el.sendButton.title = '正在处理请求'; + } else { + this.el.sendButton.innerHTML = + ''; + this.el.sendButton.classList.remove('terminate-mode'); + this.el.sendButton.title = '发送'; + } + } + + updateToolbarState() { + const manifest = this.getActiveManifest(); + const state = this.runtime.getState(manifest.id); + const hasArtifact = !!state.currentArtifactId; + + if (manifest.artifact?.type !== 'svg') { + this.el.zoomInBtn && (this.el.zoomInBtn.disabled = true); + this.el.zoomOutBtn && (this.el.zoomOutBtn.disabled = true); + this.el.zoomResetBtn && (this.el.zoomResetBtn.disabled = true); + } else { + const uiState = this.runtime.getUiState(manifest.id, { zoom: 1 }); + if (this.el.zoomInBtn) { + this.el.zoomInBtn.disabled = !hasArtifact || uiState.zoom >= 3; + } + if (this.el.zoomOutBtn) { + this.el.zoomOutBtn.disabled = !hasArtifact || uiState.zoom <= 0.25; + } + if (this.el.zoomResetBtn) { + this.el.zoomResetBtn.disabled = + !hasArtifact || Math.abs(uiState.zoom - 1) < 0.01; + } + } + + if (this.el.downloadSvgBtn) { + this.el.downloadSvgBtn.disabled = + !hasArtifact || !manifest.exports?.allowSvg; + } + if (this.el.copyImageBtn) { + this.el.copyImageBtn.disabled = + !hasArtifact || + !this.copyClipboardSupported || + manifest.artifact?.type !== 'svg' || + manifest.exports?.allowClipboard === false; + } + if (this.el.exportImageBtn) { + this.el.exportImageBtn.disabled = + !hasArtifact || !manifest.exports?.allowPng; + } + if (this.el.viewCodeBtn) { + this.el.viewCodeBtn.disabled = + !hasArtifact || !manifest.exports?.allowCode; + } + } + + clearCurrentConversation() { + const manifest = this.getActiveManifest(); + if ( + !confirm(`确定要清空当前的 ${manifest.label} 对话和图形吗?`) + ) { + return; + } + this.conversationService.clearHistory(manifest); + this.runtime.clearArtifacts(manifest.id); + if (this.echartsInstance) { + this.echartsInstance.dispose(); + this.echartsInstance = null; + } + this.renderConversationHistory(); + this.showViewerPlaceholder(manifest.ui?.placeholderText || ''); + this.updateToolbarState(); + } + + openConfigModal() { + if (!this.el.configModal) return; + this.el.configModal.classList.add('active'); + this.el.configModal.style.display = 'flex'; + } + + closeConfigModal() { + if (!this.el.configModal) return; + this.el.configModal.classList.remove('active'); + this.el.configModal.style.display = 'none'; + } + + loadApiConfig() { + const config = this.apiClient.getConfig(); + 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 || ''; + } + + async testAPI() { + try { + this.setConfigStatus('loading', '正在测试连接...'); + await this.apiClient.testConnection(); + this.setConfigStatus('success', '连接成功,可以开始生成图表'); + } catch (error) { + this.setConfigStatus('error', error.message); + } + } + + saveAPI() { + const config = { + url: this.el.apiUrlInput?.value.trim(), + key: this.el.apiKeyInput?.value.trim(), + model: this.el.apiModelInput?.value.trim() + }; + if (!config.url || !config.key || !config.model) { + this.setConfigStatus('error', '请填写完整的配置'); + return; + } + this.apiClient.saveConfig(config); + this.setConfigStatus('success', '配置已保存'); + setTimeout(() => this.closeConfigModal(), 600); + } + + setConfigStatus(type, message) { + if (!this.el.configStatus || !this.el.statusText) return; + this.el.configStatus.classList.remove('hidden'); + this.el.statusText.textContent = message; + this.el.configStatus.classList.remove( + 'border-gray-300', + 'bg-gray-50', + 'text-gray-600', + 'border-green-500', + 'bg-green-50', + 'text-green-700', + 'border-red-500', + 'bg-red-50', + 'text-red-700', + 'border-blue-500', + 'bg-blue-50', + 'text-blue-700' + ); + if (type === 'success') { + this.el.configStatus.classList.add( + 'border-green-500', + 'bg-green-50', + 'text-green-700' + ); + } else if (type === 'error') { + this.el.configStatus.classList.add( + 'border-red-500', + 'bg-red-50', + 'text-red-700' + ); + } else if (type === 'loading') { + this.el.configStatus.classList.add( + 'border-blue-500', + 'bg-blue-50', + 'text-blue-700' + ); + } else { + this.el.configStatus.classList.add( + 'border-gray-300', + 'bg-gray-50', + 'text-gray-600' + ); + } + } + } + + global.AppShell = AppShell; +})(window); diff --git a/js/core/module-registry.js b/js/core/module-registry.js new file mode 100644 index 0000000..dd3703d --- /dev/null +++ b/js/core/module-registry.js @@ -0,0 +1,36 @@ +(function (global) { + 'use strict'; + + class ModuleRegistry { + constructor() { + this.modules = new Map(); + this.order = []; + } + + register(manifest) { + if (!manifest || !manifest.id) { + throw new Error('注册模块失败:缺少 id'); + } + if (this.modules.has(manifest.id)) { + console.warn(`模块 ${manifest.id} 已存在,将被覆盖`); + } else { + this.order.push(manifest.id); + } + this.modules.set(manifest.id, manifest); + } + + get(moduleId) { + return this.modules.get(moduleId) || null; + } + + list() { + return this.order.map((id) => this.modules.get(id)); + } + + has(moduleId) { + return this.modules.has(moduleId); + } + } + + global.ModuleRegistry = new ModuleRegistry(); +})(window); diff --git a/js/core/module-runtime.js b/js/core/module-runtime.js new file mode 100644 index 0000000..fdc9f9d --- /dev/null +++ b/js/core/module-runtime.js @@ -0,0 +1,181 @@ +(function (global) { + 'use strict'; + + const DEFAULT_STORAGE_KEYS = { + history: 'history', + artifacts: 'artifacts', + ui: 'uiState' + }; + + class ModuleRuntime { + constructor({ registry, storageService, conversationService }) { + if (!registry) throw new Error('ModuleRuntime 需要 ModuleRegistry'); + if (!storageService) throw new Error('ModuleRuntime 需要 StorageService'); + if (!conversationService) + throw new Error('ModuleRuntime 需要 ConversationService'); + + this.registry = registry; + this.storageService = storageService; + this.conversationService = conversationService; + this.moduleStates = new Map(); + this.activeModuleId = null; + } + + _storageKeys(manifest) { + return { + ...DEFAULT_STORAGE_KEYS, + ...(manifest.storageKeys || {}) + }; + } + + _namespace(manifest) { + const namespace = + manifest.storageNamespace || `module:${manifest.id || 'unknown'}`; + return this.storageService.namespace(namespace); + } + + _ensureState(manifest) { + if (this.moduleStates.has(manifest.id)) { + return this.moduleStates.get(manifest.id); + } + + const store = this._namespace(manifest); + const keys = this._storageKeys(manifest); + + const state = { + artifacts: store.get(keys.artifacts, {}), + uiState: store.get(keys.ui, {}), + currentArtifactId: null + }; + if (state.uiState && state.uiState.__activeArtifact) { + state.currentArtifactId = state.uiState.__activeArtifact; + } + + this.moduleStates.set(manifest.id, state); + return state; + } + + _persistState(manifest) { + const store = this._namespace(manifest); + const keys = this._storageKeys(manifest); + const state = this._ensureState(manifest); + + if (state.uiState) { + state.uiState.__activeArtifact = state.currentArtifactId; + } + store.set(keys.artifacts, state.artifacts); + store.set(keys.ui, state.uiState); + } + + getManifest(moduleId) { + const manifest = this.registry.get(moduleId); + if (!manifest) { + throw new Error(`未找到模块 ${moduleId}`); + } + return manifest; + } + + listManifests() { + return this.registry.list(); + } + + activate(moduleId) { + const manifest = this.getManifest(moduleId); + this.activeModuleId = moduleId; + const state = this._ensureState(manifest); + const context = { + manifest, + state, + history: this.conversationService.getHistory(manifest) + }; + if (manifest.hooks && typeof manifest.hooks.onActivate === 'function') { + try { + manifest.hooks.onActivate(context); + } catch (error) { + console.warn(`执行模块 ${moduleId} onActivate 时出错:`, error); + } + } + return context; + } + + getActiveModule() { + if (!this.activeModuleId) return null; + return this.getManifest(this.activeModuleId); + } + + getState(moduleId) { + const manifest = this.getManifest(moduleId); + return this._ensureState(manifest); + } + + getArtifacts(moduleId) { + const state = this.getState(moduleId); + return state.artifacts; + } + + saveArtifact(moduleId, artifactId, payload) { + const manifest = this.getManifest(moduleId); + const state = this._ensureState(manifest); + state.artifacts[artifactId] = payload; + state.currentArtifactId = artifactId; + this._persistState(manifest); + return payload; + } + + removeArtifact(moduleId, artifactId) { + const manifest = this.getManifest(moduleId); + const state = this._ensureState(manifest); + if (state.artifacts[artifactId]) { + delete state.artifacts[artifactId]; + if (state.currentArtifactId === artifactId) { + state.currentArtifactId = null; + } + this._persistState(manifest); + } + } + + setActiveArtifact(moduleId, artifactId) { + const manifest = this.getManifest(moduleId); + const state = this._ensureState(manifest); + state.currentArtifactId = artifactId; + this._persistState(manifest); + } + + getActiveArtifactId(moduleId) { + const state = this.getState(moduleId); + return state.currentArtifactId || null; + } + + updateUiState(moduleId, patch) { + const manifest = this.getManifest(moduleId); + const state = this._ensureState(manifest); + state.uiState = { + ...state.uiState, + ...patch + }; + this._persistState(manifest); + return state.uiState; + } + + getUiState(moduleId, defaultValue = {}) { + const state = this.getState(moduleId); + const uiState = { ...(state.uiState || {}) }; + delete uiState.__activeArtifact; + return { ...defaultValue, ...uiState }; + } + + getConversationService() { + return this.conversationService; + } + + clearArtifacts(moduleId) { + const manifest = this.getManifest(moduleId); + const state = this._ensureState(manifest); + state.artifacts = {}; + state.currentArtifactId = null; + this._persistState(manifest); + } + } + + global.ModuleRuntime = ModuleRuntime; +})(window); diff --git a/js/modules/echarts.js b/js/modules/echarts.js new file mode 100644 index 0000000..9328dc8 --- /dev/null +++ b/js/modules/echarts.js @@ -0,0 +1,78 @@ +(function registerEChartsModule(global) { + 'use strict'; + + if (!global.ModuleRegistry) { + throw new Error('ModuleRegistry 未初始化'); + } + + const CODE_FENCE_REGEX = /```(?:json|js|javascript|echarts|option)?\s*([\s\S]*?)```/i; + + const parseOptionText = (text) => { + if (!text) return null; + try { + return JSON.parse(text); + } catch (error) { + try { + // 尝试处理 JS 对象语法 + // eslint-disable-next-line no-new-func + return new Function(`return (${text});`)(); + } catch (innerError) { + console.warn('解析 ECharts 配置失败:', innerError); + return null; + } + } + }; + + const parseResponse = (content) => { + const match = content.match(CODE_FENCE_REGEX); + if (match) { + const optionText = match[1].trim(); + return { + optionText, + option: parseOptionText(optionText), + beforeText: content.substring(0, match.index).trim(), + afterText: content.substring(match.index + match[0].length).trim() + }; + } + return { + optionText: '', + option: null, + beforeText: content.trim(), + afterText: '' + }; + }; + + global.ModuleRegistry.register({ + id: 'echarts', + label: 'ECharts 图表', + icon: 'ph:chart-line-up-duotone', + renderer: 'echarts', + promptKey: 'echarts', + storageNamespace: 'module:echarts', + chat: { + placeholder: '描述想生成的图表或调整需求,我会输出 ECharts 配置…', + streamStartToken: '```json', + contextWindow: 8 + }, + artifact: { + type: 'echarts-option', + fence: ['json', 'js', 'javascript', 'echarts', 'option'], + startPattern: /```(?:json|js|javascript|echarts|option)/i, + parser: parseResponse + }, + hooks: { + onActivate() { + // 预留钩子,可在此初始化额外资源 + } + }, + exports: { + allowSvg: false, + allowPng: true, + allowClipboard: false, + allowCode: true + }, + ui: { + placeholderText: '生成的 ECharts 图表将在此处显示' + } + }); +})(window); diff --git a/js/modules/product-canvas.js b/js/modules/product-canvas.js new file mode 100644 index 0000000..f0ec6f3 --- /dev/null +++ b/js/modules/product-canvas.js @@ -0,0 +1,43 @@ +(function registerProductCanvasModule(global) { + 'use strict'; + + if (!global.ModuleRegistry) { + throw new Error('ModuleRegistry 未初始化'); + } + + const parseResponse = (content) => Utils.parseSVGResponse(content); + + global.ModuleRegistry.register({ + id: 'product-canvas', + label: '产品画布', + icon: 'ph:pen-nib-duotone', + renderer: 'svg', + promptKey: 'canvas', + storageNamespace: 'module:product-canvas', + chat: { + placeholder: '描述你的产品定位、用户画像、价值主张等内容…', + streamStartToken: '```svg', + contextWindow: 10 + }, + artifact: { + type: 'svg', + fence: 'svg', + startPattern: /```(?:svg)?\s* Utils.parseSVGResponse(content); + + global.ModuleRegistry.register({ + id: 'swot', + label: 'SWOT分析', + icon: 'ph:chart-bar-duotone', + renderer: 'svg', + promptKey: 'swot', + storageNamespace: 'module:swot', + chat: { + placeholder: '输入业务背景或问题,我来生成 SWOT 分析…', + streamStartToken: '```svg', + contextWindow: 10 + }, + artifact: { + type: 'svg', + fence: 'svg', + startPattern: /```(?:svg)?\s* ({ + ...msg + })); + + this.cache.set(cacheKey, history); + return history; + } + + saveHistory(moduleConfig, history) { + const cacheKey = this._getCacheKey(moduleConfig.id); + const clonedHistory = history.map((msg) => ({ ...msg })); + this.cache.set(cacheKey, clonedHistory); + + const store = this._getNamespace(moduleConfig); + store.set(this._getHistoryKey(moduleConfig), clonedHistory); + } + + appendMessage(moduleConfig, message) { + const history = this.getHistory(moduleConfig); + history.push({ ...message }); + this.saveHistory(moduleConfig, history); + return history; + } + + replaceHistory(moduleConfig, history) { + this.saveHistory(moduleConfig, history); + } + + clearHistory(moduleConfig) { + this.saveHistory(moduleConfig, []); + } + + /** + * 构建流式上下文,为最后一个用户消息提供所需历史 + */ + buildContext(moduleConfig, tailMessages = null) { + const history = this.getHistory(moduleConfig); + if (!history.length) return null; + + let targetIndex = history.length - 1; + if (tailMessages != null) { + targetIndex = Math.max(0, history.length - tailMessages); + } + + // 确保目标是用户消息 + while (targetIndex >= 0 && history[targetIndex].type !== 'user') { + targetIndex -= 1; + } + + if (targetIndex < 0) { + return null; + } + + const contextWindow = + moduleConfig.chat?.contextWindow || this.defaultOptions.contextWindow; + const start = Math.max(0, targetIndex - contextWindow); + const contextSlice = history.slice(start, targetIndex); + + const contextMessages = contextSlice + .filter((msg) => msg.type === 'user' || msg.type === 'ai') + .map((msg) => ({ + role: msg.type === 'user' ? 'user' : 'assistant', + content: msg.content + })); + + return { + history, + userMessage: history[targetIndex], + contextMessages, + targetIndex + }; + } + } + + global.ConversationService = ConversationService; +})(window); diff --git a/js/services/storage-service.js b/js/services/storage-service.js new file mode 100644 index 0000000..e639f34 --- /dev/null +++ b/js/services/storage-service.js @@ -0,0 +1,84 @@ +(function (global) { + 'use strict'; + + /** + * 提供按命名空间隔离的本地存储封装 + * 依赖 Utils.storage 作为底层驱动 + */ + class NamespacedStorage { + constructor(namespace) { + this.namespace = namespace; + } + + _key(key) { + return `${this.namespace}:${key}`; + } + + get(key, defaultValue = null) { + return Utils.storage.get(this._key(key), defaultValue); + } + + set(key, value) { + return Utils.storage.set(this._key(key), value); + } + + remove(key) { + return Utils.storage.remove(this._key(key)); + } + + clear() { + const prefix = `${this.namespace}:`; + const toDelete = []; + for (let i = 0; i < localStorage.length; i += 1) { + const storageKey = localStorage.key(i); + if (storageKey && storageKey.startsWith(prefix)) { + toDelete.push(storageKey); + } + } + toDelete.forEach((storageKey) => localStorage.removeItem(storageKey)); + } + } + + class StorageService { + constructor(globalNamespace = 'tool-engine') { + this.globalNamespace = globalNamespace; + this.cache = new Map(); + } + + /** + * 获取全局命名空间存储 + */ + global() { + if (!this.cache.has(this.globalNamespace)) { + this.cache.set( + this.globalNamespace, + new NamespacedStorage(this.globalNamespace) + ); + } + return this.cache.get(this.globalNamespace); + } + + /** + * 获取指定命名空间存储 + */ + namespace(namespace) { + if (!namespace) { + throw new Error('Storage namespace 不能为空'); + } + if (!this.cache.has(namespace)) { + this.cache.set(namespace, new NamespacedStorage(namespace)); + } + return this.cache.get(namespace); + } + + /** + * 清除指定命名空间内容 + */ + clearNamespace(namespace) { + const store = this.namespace(namespace); + store.clear(); + } + } + + global.StorageService = StorageService; +})(window); diff --git a/prompts/echarts-prompt.txt b/prompts/echarts-prompt.txt new file mode 100644 index 0000000..cf9a3ba --- /dev/null +++ b/prompts/echarts-prompt.txt @@ -0,0 +1,7 @@ +你是一名资深的数据可视化专家,擅长使用 ECharts 将业务需求转化为可执行的配置。请遵循以下规则输出结果: + +1. 使用 JSON 对象表达完整的 ECharts option,不要包含解释说明。 +2. 保持字段命名符合 ECharts 官方文档,避免多余字段。 +3. 如需附加解读或说明,请放在 JSON 代码块之外。 +4. 如果用户没有提供数据,请生成结构清晰的示例数据并说明需要用户替换的位置。 +5. 鼓励使用易读的调色板、标题和提示信息,兼顾桌面端展示。