- 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 改用“处理中”状态并重绘气泡(按最新可见状态),避免历史重复
    生成。
This commit is contained in:
史悦
2025-10-24 19:56:40 +08:00
parent 06e1d5ca19
commit cbf59e3450
4 changed files with 1838 additions and 1760 deletions

View File

@@ -219,12 +219,8 @@ body {
/* 气泡操作按钮 */
.bubble-action-btn {
opacity: 0;
transition: opacity 0.2s;
}
.chat-bubble-ai:hover .bubble-action-btn {
opacity: 1;
transition: color 0.2s ease;
}
/* 小手摇摆动画 */

View File

@@ -155,7 +155,7 @@ class APIClient {
{ role: 'user', content: userRequest }
];
return await this.sendChatMessageStream(messages, { maxTokens: 13000 }, onChunk, onComplete);
return this.sendChatMessageStream(messages, { maxTokens: 13000 }, onChunk, onComplete);
}
// 流式生成SWOT分析
@@ -166,7 +166,7 @@ class APIClient {
{ role: 'user', content: userRequest }
];
return await this.sendChatMessageStream(messages, { maxTokens: 13000 }, onChunk, onComplete);
return this.sendChatMessageStream(messages, { maxTokens: 13000 }, onChunk, onComplete);
}
// 流式发送聊天请求
@@ -190,7 +190,7 @@ class APIClient {
const url = this.config.url.replace('/chat/completions', '/chat/completions');
try {
await Utils.createStreamRequest(
return Utils.createStreamRequest(
url,
{
method: 'POST',

535
js/app.js
View File

@@ -20,12 +20,13 @@ class ProductCanvasApp {
this.currentSvgId = null;
this.conversationHistory = {};
this.isProcessing = false;
this.currentStreamingMessage = null;
this.activeStreamHandle = null;
this.initElements();
this.initEventListeners();
this.loadSavedData();
this.updateModeUI();
this.setSendButtonState('idle');
}
getModeDisplayName(mode = this.currentMode) {
@@ -47,7 +48,7 @@ class ProductCanvasApp {
// SVG显示
this.svgViewer = document.getElementById('svg-viewer');
this.placeholderText = document.getElementById('placeholder-text');
this.placeholderText = this.svgViewer.querySelector('#placeholder-text');
// 底部操作按钮
this.downloadSvgBtn = document.getElementById('download-svg-btn');
@@ -74,14 +75,22 @@ class ProductCanvasApp {
this.swotBtn.addEventListener('click', () => this.switchMode('swot'));
// 发送消息
this.sendButton.addEventListener('click', () => this.sendMessage());
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();
this.sendMessage();
if (!this.isProcessing) {
this.sendMessage();
}
}
});
@@ -147,8 +156,7 @@ class ProductCanvasApp {
this.currentMode = mode;
Utils.storage.set('currentMode', mode);
this.currentSvgId = null;
this.currentMode = mode;
Utils.storage.set('currentMode', mode);
this.showSvgPlaceholder();
this.updateModeUI();
this.renderConversationHistory();
this.renderSvgViewerForMode();
@@ -156,27 +164,68 @@ class ProductCanvasApp {
// 更新模式UI
updateModeUI() {
if (this.currentMode === 'canvas') {
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');
this.pageTitle.textContent = '产品画布';
if (!this.currentSvgId) {
this.placeholderText.textContent = '生成的产品画布将在此处显示';
}
} 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 = 'SWOT分析';
if (!this.currentSvgId) {
this.placeholderText.textContent = '生成的SWOT分析将在此处显示';
}
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;
@@ -186,6 +235,7 @@ class ProductCanvasApp {
<p class="mt-2 font-bold" id="placeholder-text">生成的${label}将在此处显示</p>
</div>
`;
this.placeholderText = this.svgViewer.querySelector('#placeholder-text');
}
renderSvgViewerForMode() {
@@ -208,6 +258,7 @@ class ProductCanvasApp {
if (latestSvgId && svgStore[latestSvgId]) {
this.currentSvgId = latestSvgId;
this.svgViewer.innerHTML = svgStore[latestSvgId].content;
this.placeholderText = null;
} else {
this.showSvgPlaceholder();
}
@@ -226,8 +277,8 @@ class ProductCanvasApp {
}
this.isProcessing = true;
this.setSendButtonState('busy');
this.sendButton.disabled = true;
this.sendButton.innerHTML = '<iconify-icon icon="ph:spinner-gap" class="text-3xl animate-spin"></iconify-icon>';
// 添加用户消息
this.addUserMessage(message);
@@ -250,14 +301,13 @@ class ProductCanvasApp {
console.error('发送消息失败:', error);
this.addErrorMessage(error.message);
this.isProcessing = false;
this.sendButton.disabled = false;
this.sendButton.innerHTML = '<iconify-icon icon="ph:paper-plane-tilt-fill" class="text-3xl"></iconify-icon>';
this.setSendButtonState('idle');
this.activeStreamHandle = null;
}
}
// 开始流式接收消息
async startStreamingMessage(userMessage, contextMessages) {
// 创建流式消息容器
const messageId = Utils.generateId('msg');
const messageContainer = this.createStreamingMessageContainer(messageId);
this.chatHistory.appendChild(messageContainer);
@@ -269,118 +319,164 @@ class ProductCanvasApp {
let svgId = null;
let beforeText = '';
const onChunk = (chunk) => {
if (chunk.choices && chunk.choices[0] && chunk.choices[0].delta) {
const content = chunk.choices[0].delta.content || '';
fullContent += content;
let streamClosed = false;
this.activeStreamHandle = null;
// 检测SVG开始标记
if (!svgStarted) {
// 使用正则表达式更准确地检测SVG代码块开始
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) {
svgStarted = true;
svgId = Utils.generateId('svg');
// 提取SVG开始前的文本
const svgStartIndex = svgStartMatch.index;
beforeText = fullContent.substring(0, svgStartIndex);
let svgWithMarkers = fullContent.substring(svgStartIndex, svgEndIndex);
// 显示绘制中占位符
this.updateStreamingMessageWithPlaceholder(messageContainer, beforeText, svgId);
// 初始化SVG显示区域
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>
`;
}
}
// 如果SVG已经开始收集SVG内容
if (svgStarted) {
// 检查是否有SVG结束标记
if (fullContent.includes('</svg>')) {
const svgEndIndex = fullContent.indexOf('</svg>') + 6; // +6 是 '</svg>' 的长度
// 提取完整的SVG内容
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();
// 补全SVG结束标签如果没有的话
if (!svgContent.endsWith('</svg>')) {
svgContent += '</svg>';
}
// 实时显示SVG
this.svgViewer.innerHTML = svgContent;
// 存储SVG内容
this.svgStorage[this.currentMode][svgId] = {
content: svgContent,
messageId: messageId,
mode: this.currentMode,
timestamp: new Date().toISOString()
};
// 更新占位符为可点击状态
this.updatePlaceholderToClickable(messageContainer, svgId);
// 重置SVG状态继续接收剩余文本
svgStarted = false;
const afterText = fullContent.substring(svgEndIndex);
this.updateStreamingMessageAfterSVG(messageContainer, beforeText, svgId, afterText);
svgContent = svgWithMarkers.replace(/```(?:svg)?\s*/, '').replace(/```$/, '').trim();
if (!svgContent.endsWith('</svg>')) {
svgContent += '</svg>';
}
} else {
// SVG还在继续更新内容
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();
this.svgViewer.innerHTML = svgContent;
// 补全SVG结束标签以便实时显示
let tempSvgContent = svgContent;
if (!tempSvgContent.endsWith('</svg>')) {
tempSvgContent += '</svg>';
}
this.svgStorage[this.currentMode][svgId] = {
content: svgContent,
messageId,
mode: this.currentMode,
timestamp: new Date().toISOString()
};
// 实时更新SVG显示
this.svgViewer.innerHTML = tempSvgContent;
}
this.updatePlaceholderToClickable(messageContainer, svgId);
svgStarted = false;
const afterText = fullContent.substring(svgEndIndex);
this.updateStreamingMessageAfterSVG(messageContainer, beforeText, svgId, afterText);
}
} else {
// 普通文本更新
this.updateStreamingMessage(messageContainer, fullContent);
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 = () => {
// 流式接收完成,处理完整消息
this.finalizeStreamingMessage(messageId, fullContent, svgId);
this.isProcessing = false;
this.sendButton.disabled = false;
this.sendButton.innerHTML = '<iconify-icon icon="ph:paper-plane-tilt-fill" class="text-3xl"></iconify-icon>';
const onComplete = (info = {}) => {
finalizeStream(info);
};
// 调用流式API
if (this.currentMode === 'canvas') {
await window.apiClient.generateProductCanvasStream(userMessage, contextMessages, onChunk, onComplete);
} else {
await window.apiClient.generateSWOTAnalysisStream(userMessage, contextMessages, onChunk, onComplete);
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);
}
}
@@ -390,7 +486,7 @@ class ProductCanvasApp {
messageDiv.className = 'flex justify-start';
messageDiv.dataset.messageId = messageId;
messageDiv.innerHTML = `
<div class="chat-bubble-ai relative group streaming-text" data-message-id="${messageId}">
<div class="chat-bubble-ai relative streaming-text" data-message-id="${messageId}">
<div class="typing-cursor"></div>
</div>
`;
@@ -417,7 +513,7 @@ class ProductCanvasApp {
const parsedBeforeText = typeof marked !== 'undefined' ? marked.parse(beforeText) : Utils.escapeHtml(beforeText);
container.innerHTML = `
<div class="chat-bubble-ai relative group streaming-text" data-message-id="${container.dataset.messageId}">
<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}">
@@ -448,7 +544,7 @@ class ProductCanvasApp {
const parsedAfterText = typeof marked !== 'undefined' ? marked.parse(afterText) : Utils.escapeHtml(afterText);
container.innerHTML = `
<div class="chat-bubble-ai relative group streaming-text" data-message-id="${container.dataset.messageId}">
<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}')">
@@ -461,6 +557,38 @@ class ProductCanvasApp {
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 = [];
@@ -487,17 +615,6 @@ class ProductCanvasApp {
// 完成流式消息
finalizeStreamingMessage(messageId, fullContent, svgId = null) {
let container = document.querySelector(`.chat-bubble-ai[data-message-id="${messageId}"]`);
if (!container) {
const fallback = document.querySelector(`[data-message-id="${messageId}"]`);
if (fallback) {
container = fallback.classList.contains('chat-bubble-ai')
? fallback
: fallback.querySelector('.chat-bubble-ai');
}
}
if (!container) return;
const parsed = Utils.parseSVGResponse(fullContent);
const timestamp = new Date().toISOString();
@@ -508,28 +625,14 @@ class ProductCanvasApp {
timestamp
};
container.classList.remove('streaming-text');
container.setAttribute('data-message-id', messageId);
const actionFooter = `
<div class="flex gap-2 mt-2 pt-2 border-t border-gray-200">
<button class="bubble-action-btn flex items-center gap-1 text-xs text-gray-600 hover:text-blue-600 transition-colors" onclick="app.rollbackToMessage('${messageId}')">
<iconify-icon icon="ph:arrow-u-up-left-bold"></iconify-icon>
<span>退回</span>
</button>
<button class="bubble-action-btn flex items-center gap-1 text-xs text-gray-600 hover:text-green-600 transition-colors" onclick="app.regenerateMessage('${messageId}')">
<iconify-icon icon="ph:arrow-clockwise-bold"></iconify-icon>
<span>重新生成</span>
</button>
</div>
`;
let targetSvgId = svgId || null;
if (parsed.svgContent && parsed.svgContent.includes('<svg')) {
const svgBody = parsed.svgContent.trim().endsWith('</svg>')
? parsed.svgContent.trim()
: `${parsed.svgContent.trim()}\n</svg>`;
: `${parsed.svgContent.trim()}
</svg>`;
let targetSvgId = svgId || null;
if (!targetSvgId || !this.svgStorage[this.currentMode][targetSvgId]) {
targetSvgId = targetSvgId || Utils.generateId('svg');
}
@@ -541,51 +644,24 @@ class ProductCanvasApp {
timestamp
};
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))
: '';
container.innerHTML = `
<div>
${beforeHtml}
<div class="svg-placeholder-block" data-svg-id="${targetSvgId}" onclick="app.viewSVG('${targetSvgId}')">
📊 点击查看 ${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'} SVG
</div>
${afterHtml}
</div>
${actionFooter}
`;
this.currentSvgId = targetSvgId;
this.svgViewer.innerHTML = svgBody;
message.content = this.buildSVGMessageContent(parsed.beforeText, svgBody, parsed.afterText);
} else {
const sanitizedText = fullContent.replace(/^\s*```/, '').replace(/```$/, '').trim();
const parsedContent = sanitizedText
? (typeof marked !== 'undefined' ? marked.parse(sanitizedText) : Utils.escapeHtml(sanitizedText))
: '';
container.innerHTML = `
<div class="mb-1">
${parsedContent}
</div>
${actionFooter}
`;
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);
}
@@ -600,17 +676,7 @@ class ProductCanvasApp {
// 清空当前模式的SVG存储
this.svgStorage[this.currentMode] = {};
// 如果当前显示的是被清空的模式的SVG清空显示
if (this.currentSvgId && this.svgStorage[this.currentMode][this.currentSvgId]) {
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">生成的${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'}将在此处显示</p>
</div>
`;
}
this.showSvgPlaceholder();
// 保存数据
Utils.storage.set('canvasHistory', this.conversationHistory.canvas);
@@ -620,6 +686,7 @@ class ProductCanvasApp {
// 重新渲染对话历史
this.renderConversationHistory();
this.renderSvgViewerForMode();
}
// 添加用户消息
@@ -653,30 +720,25 @@ class ProductCanvasApp {
this.conversationHistory[this.currentMode].push(message);
// 如果包含SVG存储SVG内容
let svgId = null;
if (parsed.svgContent) {
const svgId = Utils.generateId('svg');
svgId = Utils.generateId('svg');
this.svgStorage[this.currentMode][svgId] = {
content: parsed.svgContent,
messageId: messageId,
messageId,
mode: this.currentMode,
timestamp: new Date().toISOString()
};
Utils.storage.set('canvasSVGs', this.svgStorage.canvas);
Utils.storage.set('swotSVGs', this.svgStorage.swot);
this.viewSVG(svgId);
// 渲染包含SVG占位符的消息
this.renderMessageWithSVG(message, parsed, svgId);
} else {
// 渲染普通消息
this.renderMessage(message);
}
Utils.scrollToBottom(this.chatHistory);
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();
}
// 添加错误消息
@@ -697,7 +759,8 @@ class ProductCanvasApp {
}
// 渲染消息
renderMessage(message) {
renderMessage(message, options = {}) {
const { allowRegenerate = false, allowRollback = message.type === 'ai' } = options;
const messageDiv = document.createElement('div');
if (message.type === 'user') {
@@ -715,24 +778,16 @@ class ProductCanvasApp {
${Utils.escapeHtml(message.content)}
</div>
`;
} else {
} 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 group" data-message-id="${message.id}">
<div class="chat-bubble-ai relative" data-message-id="${message.id}">
<div class="mb-1">
${typeof marked !== 'undefined' ? marked.parse(message.content) : Utils.escapeHtml(message.content)}
</div>
<div class="flex gap-2 mt-2 pt-2 border-t border-gray-200">
<button class="bubble-action-btn flex items-center gap-1 text-xs text-gray-600 hover:text-blue-600 transition-colors" onclick="app.rollbackToMessage('${message.id}')">
<iconify-icon icon="ph:arrow-u-up-left-bold"></iconify-icon>
<span>退回</span>
</button>
<button class="bubble-action-btn flex items-center gap-1 text-xs text-gray-600 hover:text-green-600 transition-colors" onclick="app.regenerateMessage('${message.id}')">
<iconify-icon icon="ph:arrow-clockwise-bold"></iconify-icon>
<span>重新生成</span>
</button>
${parsedContent}
</div>
${actions}
</div>
`;
}
@@ -741,29 +796,24 @@ class ProductCanvasApp {
}
// 渲染包含SVG的消息
renderMessageWithSVG(message, parsed, svgId) {
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 group" data-message-id="${message.id}">
<div class="chat-bubble-ai relative" data-message-id="${message.id}">
<div>
${typeof marked !== 'undefined' ? marked.parse(parsed.beforeText) : Utils.escapeHtml(parsed.beforeText)}
${beforeHtml}
<div class="svg-placeholder-block" data-svg-id="${svgId}" onclick="app.viewSVG('${svgId}')">
📊 点击查看 ${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'} SVG
</div>
${typeof marked !== 'undefined' ? marked.parse(parsed.afterText) : Utils.escapeHtml(parsed.afterText)}
</div>
<div class="flex gap-2 mt-2 pt-2 border-t border-gray-200">
<button class="bubble-action-btn flex items-center gap-1 text-xs text-gray-600 hover:text-blue-600 transition-colors" onclick="app.rollbackToMessage('${message.id}')">
<iconify-icon icon="ph:arrow-u-up-left-bold"></iconify-icon>
<span>退回</span>
</button>
<button class="bubble-action-btn flex items-center gap-1 text-xs text-gray-600 hover:text-green-600 transition-colors" onclick="app.regenerateMessage('${message.id}')">
<iconify-icon icon="ph:arrow-clockwise-bold"></iconify-icon>
<span>重新生成</span>
</button>
${afterHtml}
</div>
${actions}
</div>
`;
@@ -780,6 +830,14 @@ class ProductCanvasApp {
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);
@@ -811,11 +869,11 @@ class ProductCanvasApp {
hasStorageUpdate = true;
hasHistoryUpdate = true;
}
this.renderMessageWithSVG(message, parsed, svgId);
this.renderMessageWithSVG(message, parsed, svgId, { allowRegenerate: message.id === lastAiMessageId });
continue;
}
this.renderMessage(message);
this.renderMessage(message, { allowRegenerate: message.id === lastAiMessageId });
} else {
this.renderMessage(message);
}
@@ -862,13 +920,7 @@ class ProductCanvasApp {
// 如果当前显示的是被删除的SVG清空显示
if (this.currentSvgId === svgId) {
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">生成的${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'}将在此处显示</p>
</div>
`;
this.showSvgPlaceholder();
}
}
}
@@ -885,6 +937,7 @@ class ProductCanvasApp {
// 重新渲染对话历史
this.renderConversationHistory();
this.renderSvgViewerForMode();
}
// 重新生成消息
@@ -892,8 +945,8 @@ class ProductCanvasApp {
if (this.isProcessing) return;
this.isProcessing = true;
this.setSendButtonState('busy');
this.sendButton.disabled = true;
this.sendButton.innerHTML = '<iconify-icon icon="ph:spinner-gap" class="text-3xl animate-spin"></iconify-icon>';
try {
// 重新生成响应
@@ -910,8 +963,8 @@ class ProductCanvasApp {
this.addErrorMessage(error.message);
} finally {
this.isProcessing = false;
this.sendButton.disabled = false;
this.sendButton.innerHTML = '<iconify-icon icon="ph:paper-plane-tilt-fill" class="text-3xl"></iconify-icon>';
this.setSendButtonState('idle');
this.activeStreamHandle = null;
}
}

View File

@@ -238,6 +238,15 @@ class StreamProcessor {
this.onChunk = onChunk;
this.onComplete = onComplete;
this.buffer = '';
this.completed = false;
}
complete(info = {}) {
if (this.completed) return;
this.completed = true;
if (typeof this.onComplete === 'function') {
this.onComplete(info);
}
}
// 处理数据块
@@ -255,7 +264,7 @@ class StreamProcessor {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
this.onComplete();
this.complete({ aborted: false });
return;
}
@@ -271,36 +280,56 @@ class StreamProcessor {
}
// 创建流式请求
async function createStreamRequest(url, options, onChunk, onComplete) {
function createStreamRequest(url, options, onChunk, onComplete) {
const processor = new StreamProcessor(onChunk, onComplete);
const controller = new AbortController();
try {
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache'
const fetchPromise = (async () => {
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
...options.headers,
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
processor.processChunk(chunk);
if (processor.completed) {
break;
}
}
if (!processor.completed) {
processor.complete({ aborted: false });
}
} catch (error) {
if (error.name === 'AbortError') {
processor.complete({ aborted: true });
return;
}
throw error;
}
})();
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
processor.processChunk(chunk);
}
} catch (error) {
throw error;
}
return {
cancel: () => controller.abort(),
finished: fetchPromise
};
}
// 导出工具函数