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*')
+ ? 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 = `
+
+ `;
+ }
+
+ 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