Files
productcanvas/js/app.js
史悦 cbf59e3450 - js/app.js:165-260, 310-420, 560-893:重构模式切换与流式渲染逻辑;引入 renderSvgViewerForMode 分离各
模式历史与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 改用“处理中”状态并重绘气泡(按最新可见状态),避免历史重复
    生成。
2025-10-24 19:56:40 +08:00

1127 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 应用核心逻辑
*/
// 配置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();
});