From f37357096e57630a4dd19821ee34d7ff779ecbaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B2=E6=82=A6?= Date: Mon, 27 Oct 2025 11:27:52 +0800 Subject: [PATCH] =?UTF-8?q?=E2=80=A2=20-=20=E5=9C=A8=20renderConversationH?= =?UTF-8?q?istory=20=E6=9C=AB=E5=B0=BE=E4=B8=8E=20renderArtifact=20?= =?UTF-8?q?=E5=86=85=E9=83=A8=E6=96=B0=E5=A2=9E=20highlightActivePlacehold?= =?UTF-8?q?er()=EF=BC=8C=E6=AF=8F=E6=AC=A1=E6=B8=B2=E6=9F=93=E6=88=96=20?= =?UTF-8?q?=20=20=20=20=E5=88=87=E6=8D=A2=E5=9B=BE=E5=BD=A2=E5=90=8E?= =?UTF-8?q?=E9=83=BD=E4=BC=9A=E9=87=8D=E6=96=B0=E6=A0=87=E8=AE=B0=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E9=80=89=E4=B8=AD=E7=9A=84=E5=8D=A0=E4=BD=8D=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E3=80=82=20=20=20-=20=E6=96=B0=E6=96=B9=E6=B3=95?= =?UTF-8?q?=E4=BC=9A=E6=B8=85=E9=99=A4=E6=89=80=E6=9C=89=20.svg-placeholde?= =?UTF-8?q?r-block=20=E4=B8=8A=E7=9A=84=20svg-placeholder-active=EF=BC=8C?= =?UTF-8?q?=E5=86=8D=E6=A0=B9=E6=8D=AE=20ModuleRuntime=20=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E7=9A=84=20=20=20=20=20currentArtifactId=20=E4=B8=BA?= =?UTF-8?q?=E5=AF=B9=E5=BA=94=E5=8D=A0=E4=BD=8D=E5=8D=A1=E7=89=87=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E8=AF=A5=E7=B1=BB=EF=BC=88js/core/app-shell.js:321,?= =?UTF-8?q?=20673,=20729=EF=BC=89=E3=80=82=20=20=20-=20=E8=BF=99=E6=A0=B7?= =?UTF-8?q?=E6=97=A0=E8=AE=BA=E6=98=AF=E7=82=B9=E5=87=BB=E5=B7=A6=E4=BE=A7?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D=E5=88=87=E6=8D=A2=E3=80=81=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E6=B8=B2=E6=9F=93=E6=88=96=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=88=87=E6=8D=A2=EF=BC=8C=E5=8F=B3=E4=BE=A7=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E5=9B=BE=E5=BD=A2=E9=83=BD=E4=BC=9A=E5=90=8C=E6=AD=A5=E7=82=B9?= =?UTF-8?q?=E4=BA=AE=E5=AF=B9=E5=BA=94=E5=8D=A0=E4=BD=8D=E7=AC=A6=EF=BC=8C?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E8=BF=87=E5=BE=80=E7=9A=84=E9=AB=98=20=20=20?= =?UTF-8?q?=20=20=E4=BA=AE=E6=95=88=E6=9E=9C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- js/core/app-shell.js | 504 +++++++++++++++++++++++++++++++++++-- prompts/echarts-prompt.txt | 80 ++++++ 2 files changed, 565 insertions(+), 19 deletions(-) diff --git a/js/core/app-shell.js b/js/core/app-shell.js index 3caf3e0..fc07450 100644 --- a/js/core/app-shell.js +++ b/js/core/app-shell.js @@ -176,6 +176,17 @@ if (this.el.saveConfigBtn) { this.el.saveConfigBtn.addEventListener('click', () => this.saveAPI()); } + + if (this.el.chatHistory) { + this.el.chatHistory.addEventListener('click', (event) => { + const actionBtn = event.target.closest('[data-action]'); + if (!actionBtn) return; + event.preventDefault(); + const action = actionBtn.dataset.action; + const messageId = actionBtn.dataset.messageId; + this.handleMessageAction(action, messageId); + }); + } } setupModuleSwitcher() { @@ -297,22 +308,38 @@ return; } + let lastAiMessageId = null; + for (let i = history.length - 1; i >= 0; i -= 1) { + if (history[i].type === 'ai') { + lastAiMessageId = history[i].id; + break; + } + } + history.forEach((message) => { - const bubble = this.buildMessageBubble(message); + const bubble = this.buildMessageBubble(message, { + allowRollback: message.type === 'ai', + allowRegenerate: + message.type === 'ai' && message.id === lastAiMessageId, + allowDelete: true + }); this.el.chatHistory.appendChild(bubble); }); + this.highlightActivePlaceholder(); Utils.scrollToBottom(this.el.chatHistory); } - buildMessageBubble(message) { + buildMessageBubble(message, options = {}) { const wrapper = document.createElement('div'); const manifest = this.getActiveManifest(); + const actionsHtml = this.buildMessageActions(message, options); if (message.type === 'user') { wrapper.className = 'flex justify-end'; wrapper.innerHTML = `
${Utils.escapeHtml(message.content)}
+ ${actionsHtml}
`; } else if (message.type === 'error') { @@ -323,6 +350,7 @@ ${Utils.escapeHtml(message.content)} + ${actionsHtml} `; } else { @@ -331,15 +359,17 @@ typeof marked !== 'undefined' ? marked.parse(message.content) : Utils.escapeHtml(message.content); + const artifactLabel = manifest.label || '图表'; const artifactHtml = message.artifactId ? `
- 📊 点击查看最新图表 + 📊 点击查看${artifactLabel}
` : ''; wrapper.innerHTML = `
${parsedContent}
${artifactHtml} + ${actionsHtml}
`; if (message.artifactId) { @@ -352,6 +382,240 @@ return wrapper; } + buildMessageActions(message, options = {}) { + const { + allowRollback = false, + allowRegenerate = false, + allowDelete = true + } = options; + + const actions = []; + + if (allowRollback) { + actions.push(` + + `); + } + + if (allowRegenerate) { + actions.push(` + + `); + } + + if (allowDelete) { + actions.push(` + + `); + } + + if (!actions.length) { + return ''; + } + + return ` +
+ ${actions.join('')} +
+ `; + } + + handleMessageAction(action, messageId) { + if (!action || !messageId) return; + switch (action) { + case 'delete-message': + this.deleteMessage(messageId); + break; + case 'rollback-message': + this.rollbackMessage(messageId); + break; + case 'regenerate-message': + this.regenerateMessage(messageId); + break; + default: + break; + } + } + + deleteMessage(messageId) { + const manifest = this.getActiveManifest(); + const history = this.conversationService.getHistory(manifest); + const index = history.findIndex((msg) => msg.id === messageId); + if (index === -1) { + alert('未找到要删除的消息,请重试。'); + return; + } + + const target = history[index]; + const typeLabel = + target.type === 'user' + ? '这条用户消息' + : target.type === 'ai' + ? '这条AI回复' + : '这条提示'; + if ( + !confirm( + `${typeLabel}删除后无法恢复,确定要删除吗?` + ) + ) { + return; + } + + const removed = history.splice(index, 1); + this.conversationService.saveHistory(manifest, history); + this.removeArtifactsForMessages(manifest.id, removed); + this.ensureActiveArtifact(manifest); + this.renderConversationHistory(); + this.renderActiveArtifact(); + } + + rollbackMessage(messageId) { + const manifest = this.getActiveManifest(); + const history = this.conversationService.getHistory(manifest); + const index = history.findIndex((msg) => msg.id === messageId); + if (index === -1) { + alert('未找到指定消息,请重试。'); + return; + } + const target = history[index]; + if (target.type !== 'ai') { + alert('只能退回到AI生成的消息。'); + return; + } + if (index === history.length - 1) { + alert('该消息已是最新内容,无需退回。'); + return; + } + if ( + !confirm( + '退回将删除此消息之后的所有对话与图形,是否继续?' + ) + ) { + return; + } + const removed = history.splice(index + 1); + if (!removed.length) { + return; + } + this.conversationService.saveHistory(manifest, history); + this.removeArtifactsForMessages(manifest.id, removed); + this.ensureActiveArtifact(manifest); + this.renderConversationHistory(); + this.renderActiveArtifact(); + } + + regenerateMessage(messageId) { + if (this.isProcessing) { + alert('当前仍在生成中,请稍后再试。'); + return; + } + const manifest = this.getActiveManifest(); + const history = this.conversationService.getHistory(manifest); + const index = history.findIndex((msg) => msg.id === messageId); + if (index === -1) { + alert('未找到指定的AI消息。'); + return; + } + const target = history[index]; + if (target.type !== 'ai') { + alert('只能对AI回复执行重新生成。'); + return; + } + if (index !== history.length - 1) { + alert('请先使用退回功能,确保该AI回复位于对话末尾后再重新生成。'); + return; + } + + let userIndex = -1; + for (let i = index - 1; i >= 0; i -= 1) { + if (history[i].type === 'user') { + userIndex = i; + break; + } + } + if (userIndex === -1) { + alert('未找到对应的用户消息,无法重新生成。'); + return; + } + const userMessage = history[userIndex]; + + const removed = history.splice(index, 1); + this.conversationService.saveHistory(manifest, history); + this.removeArtifactsForMessages(manifest.id, removed); + this.ensureActiveArtifact(manifest); + this.renderConversationHistory(); + this.renderActiveArtifact(); + + const contextMessages = history + .slice(0, userIndex) + .filter((msg) => msg.type === 'user' || msg.type === 'ai') + .map((msg) => ({ + role: msg.type === 'user' ? 'user' : 'assistant', + content: msg.content + })); + + this.isProcessing = true; + this.pendingCancel = false; + this.setSendButtonState('streaming'); + this.el.sendButton.disabled = false; + + this.beginStreaming(manifest, { + userMessage, + contextMessages + }); + } + + removeArtifactsForMessages(moduleId, messages = []) { + if (!messages.length) return; + const artifacts = this.runtime.getArtifacts(moduleId); + const idsToRemove = new Set(); + messages.forEach((msg) => { + if (msg.artifactId && artifacts[msg.artifactId]) { + idsToRemove.add(msg.artifactId); + } + }); + + const messageIdSet = new Set(messages.map((msg) => msg.id)); + Object.entries(artifacts).forEach(([id, artifact]) => { + if (artifact.messageId && messageIdSet.has(artifact.messageId)) { + idsToRemove.add(id); + } + }); + + idsToRemove.forEach((id) => this.runtime.removeArtifact(moduleId, id)); + } + + ensureActiveArtifact(manifest) { + const state = this.runtime.getState(manifest.id); + const artifacts = state.artifacts || {}; + if (state.currentArtifactId && artifacts[state.currentArtifactId]) { + return state.currentArtifactId; + } + const history = this.conversationService.getHistory(manifest); + let nextId = null; + for (let i = history.length - 1; i >= 0; i -= 1) { + const candidateId = history[i].artifactId; + if (candidateId && artifacts[candidateId]) { + nextId = candidateId; + break; + } + } + this.runtime.setActiveArtifact(manifest.id, nextId); + return nextId; + } + sendMessage() { if (!this.el.chatInput) return; const message = this.el.chatInput.value.trim(); @@ -387,16 +651,26 @@ this.setSendButtonState('streaming'); this.el.sendButton.disabled = false; - this.startStreaming(manifest, context); + this.beginStreaming(manifest, { + userMessage: context.userMessage, + contextMessages: context.contextMessages + }); } - startStreaming(manifest, context) { + beginStreaming(manifest, payload) { const messageId = Utils.generateId('msg'); const container = this.createStreamingContainer(messageId); this.el.chatHistory.appendChild(container); Utils.scrollToBottom(this.el.chatHistory); let fullContent = ''; + const streamState = { + manifestId: manifest.id, + messageId, + container, + svg: null + }; + this.streamState = streamState; const finalize = ({ aborted = false } = {}) => { if (!this.isProcessing) return; @@ -405,13 +679,19 @@ this.el.sendButton.disabled = false; this.activeStreamHandle = null; this.pendingCancel = false; + this.streamState = null; if (aborted) { container.remove(); return; } - this.finalizeAssistantMessage(manifest, messageId, fullContent); + this.finalizeAssistantMessage( + manifest, + messageId, + fullContent, + streamState + ); }; const handleChunk = (chunk) => { @@ -419,6 +699,9 @@ if (!delta) return; fullContent += delta; this.updateStreamingContent(container, fullContent); + if (manifest.artifact?.type === 'svg') { + this.processSvgStreamChunk(manifest, fullContent, streamState); + } }; const handleComplete = (info) => { @@ -428,8 +711,8 @@ this.apiClient .generateModuleStream( manifest, - context.userMessage.content, - context.contextMessages, + payload.userMessage.content, + payload.contextMessages, handleChunk, handleComplete, STREAM_DEFAULT_OPTIONS @@ -445,6 +728,9 @@ .then(() => finalize({ aborted: false })) .catch((error) => { console.error('发送消息失败:', error); + if (this.streamState && this.streamState === streamState) { + this.streamState = null; + } finalize({ aborted: true }); this.addErrorMessage( error.message || '生成失败,请稍后再试', @@ -476,7 +762,12 @@ Utils.scrollToBottom(this.el.chatHistory); } - finalizeAssistantMessage(manifest, messageId, fullContent) { + finalizeAssistantMessage( + manifest, + messageId, + fullContent, + streamContext = null + ) { const container = this.el.chatHistory.querySelector( `[data-message-id="${messageId}"]` ); @@ -485,17 +776,18 @@ } const timestamp = new Date().toISOString(); - let artifactId = null; + let artifactId = streamContext?.svg?.artifactId || null; let artifactPayload = null; + let parsedResult = null; if (manifest.artifact?.parser) { try { - const parsed = manifest.artifact.parser(fullContent); - if (manifest.artifact.type === 'svg' && parsed.svgContent) { + parsedResult = manifest.artifact.parser(fullContent); + if (manifest.artifact.type === 'svg' && parsedResult.svgContent) { artifactId = Utils.generateId('svg'); - const svgBody = parsed.svgContent.trim().endsWith('') - ? parsed.svgContent.trim() - : `${parsed.svgContent.trim()}\n`; + const svgBody = parsedResult.svgContent.trim().endsWith('') + ? parsedResult.svgContent.trim() + : `${parsedResult.svgContent.trim()}\n`; artifactPayload = { id: artifactId, type: manifest.artifact.type, @@ -505,14 +797,16 @@ }; } else if ( manifest.artifact.type === 'echarts-option' && - parsed.option + parsedResult.option ) { artifactId = Utils.generateId('chart'); artifactPayload = { id: artifactId, type: manifest.artifact.type, - option: parsed.option, - optionText: parsed.optionText || JSON.stringify(parsed.option), + option: parsedResult.option, + optionText: + parsedResult.optionText || + JSON.stringify(parsedResult.option), messageId, timestamp }; @@ -522,10 +816,17 @@ } } + const messageContent = this.buildAssistantDisplayContent( + manifest, + fullContent, + parsedResult, + artifactId + ); + const messageRecord = { id: messageId, type: 'ai', - content: fullContent, + content: messageContent, timestamp, artifactId }; @@ -539,6 +840,14 @@ this.renderConversationHistory(); } + parseMarkdownContent(text) { + if (!text) return ''; + if (typeof marked !== 'undefined') { + return marked.parse(text); + } + return Utils.escapeHtml(text); + } + addErrorMessage(errorText, manifest) { const message = { id: Utils.generateId('msg'), @@ -550,6 +859,140 @@ this.renderConversationHistory(); } + buildAssistantDisplayContent(manifest, rawContent, parsedResult, artifactId) { + const trim = (text) => (typeof text === 'string' ? text.trim() : ''); + const segments = []; + + if (parsedResult) { + if (manifest.artifact?.type === 'svg') { + const before = trim(parsedResult.beforeText); + const after = trim(parsedResult.afterText); + if (before) segments.push(before); + if (after) segments.push(after); + } else if (manifest.artifact?.type === 'echarts-option') { + const before = trim(parsedResult.beforeText); + const after = trim(parsedResult.afterText); + if (before) segments.push(before); + if (after) segments.push(after); + } + } + + const content = segments.filter(Boolean).join('\n\n').trim(); + if (content) { + return content; + } + + if (artifactId) { + return `已生成 ${manifest.label} 图表,请点击占位卡片查看。`; + } + + return rawContent.trim(); + } + + processSvgStreamChunk(manifest, fullContent, streamState) { + if (!streamState) { + return; + } + if (!streamState.svg) { + streamState.svg = { + started: false, + completed: false, + artifactId: null, + latestMarkup: '' + }; + } + const svgCtx = streamState.svg; + const startPattern = + manifest.artifact?.startPattern || /```(?:svg)?\s*'); + if (closingIndex !== -1) { + svgCtx.completed = true; + cleaned = cleaned.substring(0, closingIndex + 6); + svgCtx.latestMarkup = cleaned; + this.renderTemporarySvg(cleaned, false, manifest); + } else if (cleaned.trim()) { + const temporaryMarkup = cleaned.endsWith('') + ? cleaned + : `${cleaned}\n`; + svgCtx.latestMarkup = temporaryMarkup; + this.renderTemporarySvg(temporaryMarkup, true, manifest); + } + } + + updateStreamingBubbleSvgPlaceholder(container, manifest, svgCtx) { + if (!container) return; + const beforeHtml = this.parseMarkdownContent(svgCtx.beforeText || ''); + const label = manifest.label || '图表'; + container.innerHTML = ` +
+
+ ${beforeHtml} +
+ 🎨 正在绘制${label}... +
+
+
+
+ `; + Utils.scrollToBottom(this.el.chatHistory); + } + + showViewerStreaming(manifest) { + if (!this.el.viewer) return; + const label = manifest.label || '图表'; + this.el.viewer.innerHTML = ` +
+
+ +

正在绘制${label}...

+
+
+ `; + } + + renderTemporarySvg(svgMarkup, isPartial = false, manifest = null) { + if (!this.el.viewer || !svgMarkup) return; + this.el.viewer.innerHTML = ''; + const wrapper = document.createElement('div'); + wrapper.className = 'svg-content-wrapper'; + wrapper.innerHTML = svgMarkup; + this.el.viewer.appendChild(wrapper); + if (isPartial) { + wrapper.style.opacity = '0.9'; + } else { + wrapper.style.opacity = '1'; + } + const uiState = this.runtime.getUiState( + manifest?.id || this.activeModuleId, + { zoom: 1 } + ); + wrapper.style.transform = `scale(${uiState.zoom})`; + wrapper.style.transformOrigin = 'center top'; + } + + cancelActiveStream() { if (!this.activeStreamHandle || typeof this.activeStreamHandle.cancel !== 'function') { this.pendingCancel = true; @@ -590,6 +1033,7 @@ } else if (artifact.type === 'echarts-option') { this.renderEChartsArtifact(artifact); } + this.highlightActivePlaceholder(); this.updateToolbarState(); } @@ -881,6 +1325,28 @@ this.updateToolbarState(); } + highlightActivePlaceholder() { + if (!this.el.chatHistory) return; + const placeholders = this.el.chatHistory.querySelectorAll( + '.svg-placeholder-block' + ); + placeholders.forEach((node) => + node.classList.remove('svg-placeholder-active') + ); + const activeArtifactId = this.runtime.getActiveArtifactId( + this.activeModuleId + ); + if (!activeArtifactId) { + return; + } + const activeNode = this.el.chatHistory.querySelector( + `.svg-placeholder-block[data-artifact-id="${activeArtifactId}"]` + ); + if (activeNode) { + activeNode.classList.add('svg-placeholder-active'); + } + } + openConfigModal() { if (!this.el.configModal) return; this.el.configModal.classList.add('active'); diff --git a/prompts/echarts-prompt.txt b/prompts/echarts-prompt.txt index cf9a3ba..8eac53f 100644 --- a/prompts/echarts-prompt.txt +++ b/prompts/echarts-prompt.txt @@ -5,3 +5,83 @@ 3. 如需附加解读或说明,请放在 JSON 代码块之外。 4. 如果用户没有提供数据,请生成结构清晰的示例数据并说明需要用户替换的位置。 5. 鼓励使用易读的调色板、标题和提示信息,兼顾桌面端展示。 +6. 根据用户需求选择合适的图表类型和样式 +7. 包含丰富的交互效果和美观的样式 + +可以参照以下JSON格式返回: + +{ + "title": { + "text": "图表标题", + "subtext": "副标题(可选)", + "left": "center", + "textStyle": { + "color": "#333", + "fontWeight": "bold", + "fontSize": 18 + } + }, + "tooltip": { + "trigger": "axis", + "axisPointer": { + "type": "cross", + "crossStyle": { + "color": "#999" + } + }, + "backgroundColor": "rgba(255,255,255,0.9)", + "borderColor": "#ccc", + "borderWidth": 1 + }, + "legend": { + "show": true, + "data": ["系列名称"], + "top": "bottom", + "padding": [20, 10, 10, 10] + }, + "toolbox": { + "feature": { + "dataView": { "show": true, "readOnly": false, "title": "数据视图" }, + "magicType": { "show": true, "type": ["line", "bar"], "title": {"line": "切换为折线图", "bar": "切换为柱状图"} }, + "restore": { "show": true, "title": "还原" }, + "saveAsImage": { "show": true, "title": "保存为图片" } + }, + "right": 20 + }, + "grid": { + "left": "3%", + "right": "4%", + "bottom": "12%", + "containLabel": true + }, + "xAxis": { + "type": "category", + "data": ["实际的X轴数据数组"], + "name": "X轴名称", + "axisLabel": { + "color": "#666" + } + }, + "yAxis": { + "type": "value", + "name": "Y轴名称", + "axisLabel": { + "color": "#666" + } + }, + "series": [ + { + "name": "系列名称", + "type": "bar", + "data": ["实际的Y轴数据数组"], + "itemStyle": { + "color": "#3498db", + "borderRadius": [4, 4, 0, 0] + }, + "emphasis": { + "focus": "series" + } + } + ], + "color": ["#3498db", "#e74c3c", "#2ecc71", "#f39c12", "#9b59b6"] +} \ No newline at end of file