模式历史与SVG;通过 buildActionToolbar 和 renderConversationHistory 仅为最新 AI 气泡保留“重新生成”;
新增 setSendButtonState、startStreamingMessage、cancelActiveStream 实现终止按钮与流式中断,回滚后同
步复位查看面板。
- js/utils.js:24-334:增强 parseSVGResponse 容错截断SVG/反引号;StreamProcessor 增加完成态管理;
createStreamRequest 返回 {cancel, finished} 并支持 AbortController,保证中止时回调依然收尾。
- js/apiclient.js:150-208:流式接口返回新的句柄对象而非简单等待,使前端可触发中止,同时保持异常转换。
- css/style.css:220-224:去除悬浮显隐,操作按钮常显,并保留色彩过渡以提示交互。
- js/app.js:900-970:regenerateMessage 改用“处理中”状态并重绘气泡(按最新可见状态),避免历史重复
生成。
1127 lines
37 KiB
JavaScript
1127 lines
37 KiB
JavaScript
/**
|
||
* 应用核心逻辑
|
||
*/
|
||
|
||
// 配置Markdown解析器
|
||
if (typeof marked !== 'undefined') {
|
||
marked.setOptions({
|
||
breaks: true, // 支持换行
|
||
gfm: true, // 支持GitHub风格的Markdown
|
||
sanitize: false, // 允许HTML(因为我们自己处理SVG)
|
||
smartLists: true, // 智能列表
|
||
smartypants: true // 智能标点
|
||
});
|
||
}
|
||
|
||
class ProductCanvasApp {
|
||
constructor() {
|
||
this.currentMode = 'canvas'; // 'canvas' 或 'swot'
|
||
this.svgStorage = {};
|
||
this.currentSvgId = null;
|
||
this.conversationHistory = {};
|
||
this.isProcessing = false;
|
||
this.activeStreamHandle = null;
|
||
|
||
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.downloadSvgBtn = document.getElementById('download-svg-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);
|
||
});
|
||
|
||
// 底部操作按钮
|
||
this.downloadSvgBtn.addEventListener('click', () => this.downloadSVG());
|
||
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();
|
||
|
||
// 加载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 = `
|
||
<span class="flex items-center gap-1 text-red-600 font-bold">
|
||
<iconify-icon icon="ph:hand-palm-bold" class="text-2xl"></iconify-icon>
|
||
<span>终止</span>
|
||
</span>
|
||
`;
|
||
this.sendButton.classList.add('terminate-mode');
|
||
this.sendButton.title = '终止当前生成';
|
||
} else if (state === 'terminating') {
|
||
this.sendButton.innerHTML = `
|
||
<span class="flex items-center gap-1 text-orange-500 font-bold">
|
||
<iconify-icon icon="ph:hourglass-medium-bold" class="text-2xl"></iconify-icon>
|
||
<span>终止中</span>
|
||
</span>
|
||
`;
|
||
this.sendButton.classList.add('terminate-mode');
|
||
this.sendButton.title = '正在终止生成';
|
||
} else if (state === 'busy') {
|
||
this.sendButton.innerHTML = `
|
||
<span class="flex items-center gap-1 text-blue-600 font-bold">
|
||
<iconify-icon icon="ph:clock-bold" class="text-2xl"></iconify-icon>
|
||
<span>处理中</span>
|
||
</span>
|
||
`;
|
||
this.sendButton.classList.add('terminate-mode');
|
||
this.sendButton.title = '正在处理请求';
|
||
} else {
|
||
this.sendButton.innerHTML = '<iconify-icon icon="ph:paper-plane-tilt-fill" class="text-3xl"></iconify-icon>';
|
||
this.sendButton.classList.remove('terminate-mode');
|
||
this.sendButton.title = '发送';
|
||
}
|
||
}
|
||
|
||
showSvgPlaceholder() {
|
||
const label = this.getModeDisplayName();
|
||
this.currentSvgId = null;
|
||
this.svgViewer.innerHTML = `
|
||
<div id="svg-placeholder" class="text-center text-gray-400">
|
||
<iconify-icon icon="ph:image-square" class="text-6xl mx-auto text-purple-400"></iconify-icon>
|
||
<p class="mt-2 font-bold" id="placeholder-text">生成的${label}将在此处显示</p>
|
||
</div>
|
||
`;
|
||
this.placeholderText = this.svgViewer.querySelector('#placeholder-text');
|
||
}
|
||
|
||
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.svgViewer.innerHTML = svgStore[latestSvgId].content;
|
||
this.placeholderText = null;
|
||
} else {
|
||
this.showSvgPlaceholder();
|
||
}
|
||
}
|
||
|
||
// 发送消息
|
||
async sendMessage() {
|
||
const message = this.chatInput.value.trim();
|
||
if (!message || this.isProcessing) return;
|
||
|
||
// 检查API配置
|
||
if (!window.apiClient.isConfigValid()) {
|
||
alert('⚠️ 请先配置API设置!点击右上角齿轮图标进行配置。');
|
||
this.openConfigModal();
|
||
return;
|
||
}
|
||
|
||
this.isProcessing = true;
|
||
this.setSendButtonState('busy');
|
||
this.sendButton.disabled = true;
|
||
|
||
// 添加用户消息
|
||
this.addUserMessage(message);
|
||
this.chatInput.value = '';
|
||
Utils.autoResizeTextarea(this.chatInput);
|
||
|
||
try {
|
||
// 获取对话上下文
|
||
const contextMessages = this.conversationHistory[this.currentMode]
|
||
.slice(-10) // 只取最近10条消息作为上下文
|
||
.map(msg => ({
|
||
role: msg.type === 'user' ? 'user' : 'assistant',
|
||
content: msg.content
|
||
}));
|
||
|
||
// 开始流式接收消息
|
||
await this.startStreamingMessage(message, contextMessages);
|
||
|
||
} catch (error) {
|
||
console.error('发送消息失败:', error);
|
||
this.addErrorMessage(error.message);
|
||
this.isProcessing = false;
|
||
this.setSendButtonState('idle');
|
||
this.activeStreamHandle = null;
|
||
}
|
||
}
|
||
|
||
// 开始流式接收消息
|
||
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.activeStreamHandle = null;
|
||
};
|
||
|
||
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*<svg[\s\S]*?>/i);
|
||
if (svgStartMatch) {
|
||
svgStarted = true;
|
||
svgId = svgId || Utils.generateId('svg');
|
||
|
||
const svgStartIndex = svgStartMatch.index;
|
||
beforeText = fullContent.substring(0, svgStartIndex);
|
||
|
||
this.updateStreamingMessageWithPlaceholder(messageContainer, beforeText, svgId);
|
||
|
||
this.svgViewer.innerHTML = `
|
||
<div class="flex items-center justify-center h-full">
|
||
<div class="text-center">
|
||
<iconify-icon icon="ph:spinner-gap" class="text-6xl text-purple-500 animate-spin"></iconify-icon>
|
||
<p class="mt-4 font-bold text-gray-600">正在绘制${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'}...</p>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
if (svgStarted) {
|
||
if (fullContent.includes('</svg>')) {
|
||
const svgEndIndex = fullContent.indexOf('</svg>') + 6;
|
||
const svgStartMatch = fullContent.match(/```(?:svg)?\s*<svg[\s\S]*?>/i);
|
||
if (svgStartMatch) {
|
||
const svgStartIndex = svgStartMatch.index;
|
||
let svgWithMarkers = fullContent.substring(svgStartIndex, svgEndIndex);
|
||
|
||
svgContent = svgWithMarkers.replace(/```(?:svg)?\s*/, '').replace(/```$/, '').trim();
|
||
if (!svgContent.endsWith('</svg>')) {
|
||
svgContent += '</svg>';
|
||
}
|
||
|
||
this.svgViewer.innerHTML = svgContent;
|
||
|
||
this.svgStorage[this.currentMode][svgId] = {
|
||
content: svgContent,
|
||
messageId,
|
||
mode: this.currentMode,
|
||
timestamp: new Date().toISOString()
|
||
};
|
||
|
||
this.updatePlaceholderToClickable(messageContainer, svgId);
|
||
|
||
svgStarted = false;
|
||
const afterText = fullContent.substring(svgEndIndex);
|
||
this.updateStreamingMessageAfterSVG(messageContainer, beforeText, svgId, afterText);
|
||
}
|
||
} else {
|
||
const svgStartMatch = fullContent.match(/```(?:svg)?\s*<svg[\s\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('</svg>')) {
|
||
tempSvgContent += '</svg>';
|
||
}
|
||
|
||
this.svgViewer.innerHTML = 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;
|
||
|
||
await streamHandle.finished;
|
||
|
||
if (!streamClosed) {
|
||
finalizeStream({ aborted: false });
|
||
}
|
||
} catch (error) {
|
||
streamClosed = true;
|
||
this.activeStreamHandle = null;
|
||
this.isProcessing = false;
|
||
this.setSendButtonState('idle');
|
||
|
||
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') {
|
||
return;
|
||
}
|
||
|
||
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 = `
|
||
<div class="chat-bubble-ai relative streaming-text" data-message-id="${messageId}">
|
||
<div class="typing-cursor"></div>
|
||
</div>
|
||
`;
|
||
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 = `
|
||
<div class="chat-bubble-ai relative streaming-text" data-message-id="${container.dataset.messageId}">
|
||
<div>
|
||
${parsedBeforeText}
|
||
<div class="svg-drawing-placeholder" data-svg-id="${svgId}">
|
||
<span class="svg-drawing-text">🎨 正在绘制${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'}...</span>
|
||
</div>
|
||
<div class="typing-cursor"></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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.innerHTML = `📊 点击查看${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'} SVG`;
|
||
placeholder.setAttribute('onclick', `app.viewSVG('${svgId}')`);
|
||
}
|
||
}
|
||
|
||
// 更新SVG后的消息内容
|
||
updateStreamingMessageAfterSVG(container, beforeText, svgId, afterText) {
|
||
// 使用Markdown解析文本
|
||
const parsedBeforeText = typeof marked !== 'undefined' ? marked.parse(beforeText) : Utils.escapeHtml(beforeText);
|
||
const parsedAfterText = typeof marked !== 'undefined' ? marked.parse(afterText) : Utils.escapeHtml(afterText);
|
||
|
||
container.innerHTML = `
|
||
<div class="chat-bubble-ai relative streaming-text" data-message-id="${container.dataset.messageId}">
|
||
<div>
|
||
${parsedBeforeText}
|
||
<div class="svg-placeholder-block" data-svg-id="${svgId}" onclick="app.viewSVG('${svgId}')">
|
||
📊 点击查看${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'} SVG
|
||
</div>
|
||
<div class="typing-cursor">${parsedAfterText}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
Utils.scrollToBottom(this.chatHistory);
|
||
}
|
||
|
||
buildActionToolbar(messageId, { allowRegenerate = false, allowRollback = true } = {}) {
|
||
const actions = [];
|
||
|
||
if (allowRollback) {
|
||
actions.push(`
|
||
<button class="bubble-action-btn flex items-center gap-1 text-xs text-gray-600 hover:text-blue-600 transition-colors" data-action="rollback" onclick="app.rollbackToMessage('${messageId}')">
|
||
<iconify-icon icon="ph:arrow-u-up-left-bold"></iconify-icon>
|
||
<span>退回</span>
|
||
</button>
|
||
`);
|
||
}
|
||
|
||
if (allowRegenerate) {
|
||
actions.push(`
|
||
<button class="bubble-action-btn flex items-center gap-1 text-xs text-gray-600 hover:text-green-600 transition-colors" data-action="regenerate" onclick="app.regenerateMessage('${messageId}')">
|
||
<iconify-icon icon="ph:arrow-clockwise-bold"></iconify-icon>
|
||
<span>重新生成</span>
|
||
</button>
|
||
`);
|
||
}
|
||
|
||
if (!actions.length) {
|
||
return '';
|
||
}
|
||
|
||
return `
|
||
<div class="message-actions flex gap-2 mt-2 pt-2 border-t border-gray-200">
|
||
${actions.join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 组装标准化的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) {
|
||
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('<svg')) {
|
||
const svgBody = parsed.svgContent.trim().endsWith('</svg>')
|
||
? parsed.svgContent.trim()
|
||
: `${parsed.svgContent.trim()}
|
||
</svg>`;
|
||
|
||
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.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';
|
||
messageDiv.innerHTML = `
|
||
<div class="chat-bubble-user">
|
||
${Utils.escapeHtml(message.content)}
|
||
</div>
|
||
`;
|
||
} else if (message.type === 'error') {
|
||
messageDiv.className = 'flex justify-start';
|
||
messageDiv.innerHTML = `
|
||
<div class="chat-bubble-ai border-red-500">
|
||
<iconify-icon icon="ph:warning-circle" class="text-red-500 mr-2"></iconify-icon>
|
||
${Utils.escapeHtml(message.content)}
|
||
</div>
|
||
`;
|
||
} 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 });
|
||
messageDiv.className = 'flex justify-start';
|
||
messageDiv.innerHTML = `
|
||
<div class="chat-bubble-ai relative" data-message-id="${message.id}">
|
||
<div class="mb-1">
|
||
${parsedContent}
|
||
</div>
|
||
${actions}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 });
|
||
|
||
messageDiv.className = 'flex justify-start';
|
||
messageDiv.innerHTML = `
|
||
<div class="chat-bubble-ai relative" data-message-id="${message.id}">
|
||
<div>
|
||
${beforeHtml}
|
||
<div class="svg-placeholder-block" data-svg-id="${svgId}" onclick="app.viewSVG('${svgId}')">
|
||
📊 点击查看 ${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'} SVG
|
||
</div>
|
||
${afterHtml}
|
||
</div>
|
||
${actions}
|
||
</div>
|
||
`;
|
||
|
||
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('<svg');
|
||
if (hasSvgContent) {
|
||
if (!svgId) {
|
||
const normalizedSvg = parsed.svgContent.trim().endsWith('</svg>')
|
||
? parsed.svgContent.trim()
|
||
: `${parsed.svgContent.trim()}\n</svg>`;
|
||
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 || []);
|
||
}
|
||
|
||
Utils.scrollToBottom(this.chatHistory);
|
||
}
|
||
|
||
// 显示SVG
|
||
viewSVG(svgId) {
|
||
if (!this.svgStorage[this.currentMode][svgId]) {
|
||
console.error('SVG not found:', svgId);
|
||
return;
|
||
}
|
||
|
||
this.currentSvgId = svgId;
|
||
const svgContent = this.svgStorage[this.currentMode][svgId].content;
|
||
this.svgViewer.innerHTML = svgContent;
|
||
}
|
||
|
||
// 退回到指定消息
|
||
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;
|
||
|
||
this.isProcessing = true;
|
||
this.setSendButtonState('busy');
|
||
this.sendButton.disabled = true;
|
||
|
||
try {
|
||
// 重新生成响应
|
||
const response = await window.apiClient.regenerateResponse(messageId, this.conversationHistory[this.currentMode]);
|
||
|
||
// 退回到指定消息
|
||
this.rollbackToMessage(messageId);
|
||
|
||
// 添加新的AI回复
|
||
this.addAIMessage(response);
|
||
|
||
} catch (error) {
|
||
console.error('重新生成失败:', error);
|
||
this.addErrorMessage(error.message);
|
||
} finally {
|
||
this.isProcessing = false;
|
||
this.setSendButtonState('idle');
|
||
this.activeStreamHandle = null;
|
||
}
|
||
}
|
||
|
||
// 下载SVG
|
||
downloadSVG() {
|
||
if (!this.currentSvgId) {
|
||
alert('请先生成SVG图表');
|
||
return;
|
||
}
|
||
|
||
const svgContent = this.svgStorage[this.currentMode][this.currentSvgId].content;
|
||
const filename = `${this.currentMode}-${Utils.formatDateTime().replace(/[/:]/g, '-')}.svg`;
|
||
Utils.downloadFile(svgContent, filename, 'image/svg+xml');
|
||
}
|
||
|
||
// 导出为图片
|
||
exportAsImage() {
|
||
if (!this.currentSvgId) {
|
||
alert('请先生成SVG图表');
|
||
return;
|
||
}
|
||
|
||
// 这里可以实现SVG转PNG的功能
|
||
// 由于需要额外的库,这里先提示用户
|
||
alert('SVG转PNG功能需要额外的库支持,您可以使用下载SVG功能,然后使用在线工具转换。');
|
||
}
|
||
|
||
// 查看SVG代码
|
||
viewSVGCode() {
|
||
if (!this.currentSvgId) {
|
||
alert('请先生成SVG图表');
|
||
return;
|
||
}
|
||
|
||
const svgContent = this.svgStorage[this.currentMode][this.currentSvgId].content;
|
||
|
||
// 创建代码查看模态窗
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal-overlay active';
|
||
modal.innerHTML = `
|
||
<div class="modal-content">
|
||
<div class="bg-gradient-to-r from-blue-600 to-purple-600 p-4 border-b-4 border-black flex items-center justify-between">
|
||
<div class="flex items-center gap-2">
|
||
<iconify-icon icon="ph:code-bold" class="text-3xl text-white"></iconify-icon>
|
||
<h2 class="text-xl font-black text-white">SVG 源代码</h2>
|
||
</div>
|
||
<button class="close-modal text-white hover:bg-white/20 p-2 transition-all">
|
||
<iconify-icon icon="ph:x-bold" class="text-2xl"></iconify-icon>
|
||
</button>
|
||
</div>
|
||
<div class="p-4">
|
||
<pre class="bg-gray-100 p-4 border-2 border-gray-300 rounded overflow-auto max-h-96 text-sm"><code>${Utils.escapeHtml(svgContent)}</code></pre>
|
||
<div class="mt-4 flex gap-2 justify-end">
|
||
<button class="copy-btn px-4 py-2 bg-blue-500 text-white font-bold border-2 border-black hover:bg-blue-600 transition-all flex items-center gap-2">
|
||
<iconify-icon icon="ph:copy-bold"></iconify-icon>
|
||
复制代码
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
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 = '<iconify-icon icon="ph:check-bold"></iconify-icon> 已复制';
|
||
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();
|
||
});
|