6. 增加一个svg放大缩小功能;

7. 目前的svg下载,下载图片,显示代码实现有问题,好像没找到svg;再增加一个复制图片到剪切板功能;
8. 点重新生成按钮,应该再添加一个气泡啊,而且流式响应,现在点击重新生成,就时等待,没有实时显示;
9. 点击查看画布,没有区分现在显示的是哪个气泡或占位符,需要标记区分下;
This commit is contained in:
史悦
2025-10-24 20:31:49 +08:00
parent cbf59e3450
commit b0c487a4ef
3 changed files with 594 additions and 269 deletions

View File

@@ -223,6 +223,16 @@ body {
transition: color 0.2s ease; transition: color 0.2s ease;
} }
.svg-placeholder-active {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
}
.svg-content-wrapper {
display: inline-block;
transform-origin: center top;
}
/* 小手摇摆动画 */ /* 小手摇摆动画 */
@keyframes wave { @keyframes wave {
0%, 100% {transform: translateX(0px) rotate(90deg);} 0%, 100% {transform: translateX(0px) rotate(90deg);}
@@ -344,4 +354,8 @@ body {
.clear-history-btn:hover iconify-icon { .clear-history-btn:hover iconify-icon {
animation: shake 0.5s ease-in-out; animation: shake 0.5s ease-in-out;
} }
#send-button.terminate-mode {
border-color: #dc2626;
}

View File

@@ -1,199 +1,211 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>产品画布 / SWOT分析</title> <title>产品画布 / SWOT分析</title>
<script src="https://cdn.tailwindcss.com/3.4.1"></script> <script src="https://cdn.tailwindcss.com/3.4.1"></script>
<script src="https://code.iconify.design/iconify-icon/2.1.0/iconify-icon.min.js"></script> <script src="https://code.iconify.design/iconify-icon/2.1.0/iconify-icon.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css"> <link rel="stylesheet" href="css/style.css">
</head> </head>
<body class="bg-gray-100 h-screen flex flex-col"> <body class="bg-gray-100 h-screen flex flex-col">
<!-- 顶部标题栏 --> <!-- 顶部标题栏 -->
<header class="bg-gradient-to-r from-orange-500 to-pink-500 p-3 flex items-center justify-between border-b-4 border-black"> <header class="bg-gradient-to-r from-orange-500 to-pink-500 p-3 flex items-center justify-between border-b-4 border-black">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<iconify-icon icon="ph:lightning-fill" class="text-3xl text-white"></iconify-icon> <iconify-icon icon="ph:lightning-fill" class="text-3xl text-white"></iconify-icon>
<h1 id="page-title" class="text-2xl font-black text-white tracking-tight">产品画布</h1> <h1 id="page-title" class="text-2xl font-black text-white tracking-tight">产品画布</h1>
</div> </div>
<!-- 右侧按钮组 --> <!-- 右侧按钮组 -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<!-- API配置按钮 --> <!-- API配置按钮 -->
<button id="settings-btn" class="settings-btn bg-white/20 text-white p-2 border-2 border-white hover:bg-white/30 transition-all" title="API配置"> <button id="settings-btn" class="settings-btn bg-white/20 text-white p-2 border-2 border-white hover:bg-white/30 transition-all" title="API配置">
<iconify-icon icon="ph:gear-six-fill" class="text-2xl"></iconify-icon> <iconify-icon icon="ph:gear-six-fill" class="text-2xl"></iconify-icon>
</button> </button>
<span class="text-white font-bold text-sm">点击切换模式</span> <span class="text-white font-bold text-sm">点击切换模式</span>
<iconify-icon icon="ph:hand-pointing-fill" class="text-2xl text-yellow-300 wave-hand"></iconify-icon> <iconify-icon icon="ph:hand-pointing-fill" class="text-2xl text-yellow-300 wave-hand"></iconify-icon>
<button id="canvas-mode-btn" class="mode-btn-active bg-white text-orange-600 px-4 py-2 font-bold border-2 border-black hover:bg-orange-50 transition-all duration-200"> <button id="canvas-mode-btn" class="mode-btn-active bg-white text-orange-600 px-4 py-2 font-bold border-2 border-black hover:bg-orange-50 transition-all duration-200">
<iconify-icon icon="ph:pen-nib-duotone" class="align-middle mr-1"></iconify-icon> <iconify-icon icon="ph:pen-nib-duotone" class="align-middle mr-1"></iconify-icon>
产品画布 产品画布
</button> </button>
<button id="swot-mode-btn" class="mode-btn-inactive bg-white text-purple-600 px-4 py-2 font-bold border-2 border-black hover:bg-purple-50 transition-all duration-200"> <button id="swot-mode-btn" class="mode-btn-inactive bg-white text-purple-600 px-4 py-2 font-bold border-2 border-black hover:bg-purple-50 transition-all duration-200">
<iconify-icon icon="ph:chart-bar-duotone" class="align-middle mr-1"></iconify-icon> <iconify-icon icon="ph:chart-bar-duotone" class="align-middle mr-1"></iconify-icon>
SWOT分析 SWOT分析
</button> </button>
</div> </div>
</header> </header>
<!-- 主内容区 --> <!-- 主内容区 -->
<main class="flex-1 grid grid-cols-1 md:grid-cols-3 gap-4 p-4 overflow-hidden"> <main class="flex-1 grid grid-cols-1 md:grid-cols-3 gap-4 p-4 overflow-hidden">
<!-- 左侧对话面板 --> <!-- 左侧对话面板 -->
<div class="md:col-span-1 bg-white wild-border border-cyan-500 flex flex-col"> <div class="md:col-span-1 bg-white wild-border border-cyan-500 flex flex-col">
<!-- 对话历史顶部栏 --> <!-- 对话历史顶部栏 -->
<div class="p-3 border-b-3 border-gray-300 bg-gradient-to-r from-cyan-500 to-blue-500 flex items-center justify-between"> <div class="p-3 border-b-3 border-gray-300 bg-gradient-to-r from-cyan-500 to-blue-500 flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<iconify-icon icon="ph:chats-circle-fill" class="text-2xl text-white"></iconify-icon> <iconify-icon icon="ph:chats-circle-fill" class="text-2xl text-white"></iconify-icon>
<span class="font-black text-white">对话历史</span> <span class="font-black text-white">对话历史</span>
</div> </div>
<button id="clear-history-btn" class="clear-history-btn bg-red-500 text-white px-3 py-1 border-2 border-black hover:bg-red-600 transition-all flex items-center gap-1 font-bold" title="清空对话历史"> <button id="clear-history-btn" class="clear-history-btn bg-red-500 text-white px-3 py-1 border-2 border-black hover:bg-red-600 transition-all flex items-center gap-1 font-bold" title="清空对话历史">
<iconify-icon icon="ph:trash-bold" class="text-lg"></iconify-icon> <iconify-icon icon="ph:trash-bold" class="text-lg"></iconify-icon>
<span class="text-sm">清空</span> <span class="text-sm">清空</span>
</button> </button>
</div> </div>
<!-- 对话历史区 --> <!-- 对话历史区 -->
<div id="chat-history" class="flex-1 p-4 overflow-y-auto space-y-3"> <div id="chat-history" class="flex-1 p-4 overflow-y-auto space-y-3">
<!-- 欢迎消息 --> <!-- 欢迎消息 -->
<div class="flex justify-start"> <div class="flex justify-start">
<div class="chat-bubble-ai"> <div class="chat-bubble-ai">
👋 欢迎使用产品画布/SWOT分析工具请输入您的需求我将为您生成专业的分析图表。 👋 欢迎使用产品画布/SWOT分析工具请输入您的需求我将为您生成专业的分析图表。
</div> </div>
</div> </div>
</div> </div>
<!-- 输入区 --> <!-- 输入区 -->
<div class="p-3 border-t-3 border-gray-300 bg-yellow-50"> <div class="p-3 border-t-3 border-gray-300 bg-yellow-50">
<div class="relative flex items-center gap-2"> <div class="relative flex items-center gap-2">
<textarea <textarea
id="chat-input" id="chat-input"
placeholder="输入您的想法按Enter发送Shift+Enter换行..." placeholder="输入您的想法按Enter发送Shift+Enter换行..."
class="flex-1 auto-resize-input border-2 border-gray-800 focus:border-cyan-500 focus:outline-none transition-colors font-medium" class="flex-1 auto-resize-input border-2 border-gray-800 focus:border-cyan-500 focus:outline-none transition-colors font-medium"
rows="1" rows="1"
></textarea> ></textarea>
<button id="send-button" class="text-cyan-600 hover:text-cyan-700 transition-colors p-2 hover:scale-110 transform duration-200"> <button id="send-button" class="text-cyan-600 hover:text-cyan-700 transition-colors p-2 hover:scale-110 transform duration-200">
<iconify-icon icon="ph:paper-plane-tilt-fill" class="text-3xl"></iconify-icon> <iconify-icon icon="ph:paper-plane-tilt-fill" class="text-3xl"></iconify-icon>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- 右侧显示面板 --> <!-- 右侧显示面板 -->
<div class="md:col-span-2 bg-white wild-border border-purple-600 flex flex-col"> <div class="md:col-span-2 bg-white wild-border border-purple-600 flex flex-col">
<div id="svg-viewer" class="flex-1 flex items-center justify-center p-4 bg-gradient-to-br from-purple-50 to-pink-50 overflow-auto"> <div id="svg-viewer" class="flex-1 flex items-center justify-center p-4 bg-gradient-to-br from-purple-50 to-pink-50 overflow-auto">
<div id="svg-placeholder" class="text-center text-gray-400"> <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> <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">生成的产品画布将在此处显示</p> <p class="mt-2 font-bold" id="placeholder-text">生成的产品画布将在此处显示</p>
</div> </div>
</div> </div>
<!-- 底部操作栏 --> <!-- 底部操作栏 -->
<div class="p-3 border-t-3 border-gray-300 flex justify-end items-center gap-2 bg-gray-800"> <div class="p-3 border-t-3 border-gray-300 flex justify-end items-center gap-2 bg-gray-800">
<button id="download-svg-btn" class="p-2 bg-orange-500 text-white border-2 border-black hover:bg-orange-600 transition-all" title="下载SVG"> <button id="zoom-out-btn" class="p-2 bg-white text-gray-700 border-2 border-black hover:bg-gray-200 transition-all" title="缩小">
<iconify-icon icon="mdi:download-outline" class="text-xl"></iconify-icon> <iconify-icon icon="ph:magnifying-glass-minus-bold" class="text-xl"></iconify-icon>
</button> </button>
<button id="export-image-btn" class="p-2 bg-green-500 text-white border-2 border-black hover:bg-green-600 transition-all" title="导出为图片"> <button id="zoom-in-btn" class="p-2 bg-white text-gray-700 border-2 border-black hover:bg-gray-200 transition-all" title="放大">
<iconify-icon icon="mdi:image-outline" class="text-xl"></iconify-icon> <iconify-icon icon="ph:magnifying-glass-plus-bold" class="text-xl"></iconify-icon>
</button> </button>
<button id="view-code-btn" class="p-2 bg-blue-500 text-white border-2 border-black hover:bg-blue-600 transition-all" title="查看代码"> <button id="zoom-reset-btn" class="p-2 bg-white text-gray-700 border-2 border-black hover:bg-gray-200 transition-all" title="重置缩放">
<iconify-icon icon="mdi:code-tags" class="text-xl"></iconify-icon> <iconify-icon icon="ph:arrow-counter-clockwise-bold" class="text-xl"></iconify-icon>
</button> </button>
</div> <button id="download-svg-btn" class="p-2 bg-orange-500 text-white border-2 border-black hover:bg-orange-600 transition-all" title="下载SVG">
</div> <iconify-icon icon="mdi:download-outline" class="text-xl"></iconify-icon>
</button>
</main> <button id="copy-image-btn" class="p-2 bg-yellow-500 text-white border-2 border-black hover:bg-yellow-600 transition-all" title="复制图片到剪贴板">
<iconify-icon icon="ph:clipboard-bold" class="text-xl"></iconify-icon>
<!-- API配置模态窗 --> </button>
<div id="config-modal" class="modal-overlay"> <button id="export-image-btn" class="p-2 bg-green-500 text-white border-2 border-black hover:bg-green-600 transition-all" title="导出为图片">
<div class="modal-content"> <iconify-icon icon="mdi:image-outline" class="text-xl"></iconify-icon>
<!-- 模态窗头部 --> </button>
<div class="bg-gradient-to-r from-blue-600 to-purple-600 p-4 border-b-4 border-black flex items-center justify-between"> <button id="view-code-btn" class="p-2 bg-blue-500 text-white border-2 border-black hover:bg-blue-600 transition-all" title="查看代码">
<div class="flex items-center gap-2"> <iconify-icon icon="mdi:code-tags" class="text-xl"></iconify-icon>
<iconify-icon icon="ph:plugs-connected-fill" class="text-3xl text-white"></iconify-icon> </button>
<h2 class="text-xl font-black text-white">API 配置</h2> </div>
</div> </div>
<button id="close-modal-btn" class="text-white hover:bg-white/20 p-2 transition-all">
<iconify-icon icon="ph:x-bold" class="text-2xl"></iconify-icon> </main>
</button>
</div> <!-- API配置模态窗 -->
<div id="config-modal" class="modal-overlay">
<!-- 模态窗内容 --> <div class="modal-content">
<div class="p-6 space-y-4"> <!-- 模态窗头部 -->
<!-- API URL --> <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> <div class="flex items-center gap-2">
<label class="block font-bold text-gray-800 mb-2 flex items-center gap-2"> <iconify-icon icon="ph:plugs-connected-fill" class="text-3xl text-white"></iconify-icon>
<iconify-icon icon="ph:link-bold" class="text-lg text-blue-600"></iconify-icon> <h2 class="text-xl font-black text-white">API 配置</h2>
API URL </div>
</label> <button id="close-modal-btn" class="text-white hover:bg-white/20 p-2 transition-all">
<input <iconify-icon icon="ph:x-bold" class="text-2xl"></iconify-icon>
id="api-url" </button>
type="text" </div>
placeholder="https://api.example.com/v1/chat"
class="config-input" <!-- 模态窗内容 -->
value="" <div class="p-6 space-y-4">
/> <!-- API URL -->
</div> <div>
<label class="block font-bold text-gray-800 mb-2 flex items-center gap-2">
<!-- API Key --> <iconify-icon icon="ph:link-bold" class="text-lg text-blue-600"></iconify-icon>
<div> API URL
<label class="block font-bold text-gray-800 mb-2 flex items-center gap-2"> </label>
<iconify-icon icon="ph:key-bold" class="text-lg text-green-600"></iconify-icon> <input
API Key id="api-url"
</label> type="text"
<input placeholder="https://api.example.com/v1/chat"
id="api-key" class="config-input"
type="password" value=""
placeholder="sk-xxxxxxxxxxxxxxxx" />
class="config-input" </div>
value=""
/> <!-- API Key -->
</div> <div>
<label class="block font-bold text-gray-800 mb-2 flex items-center gap-2">
<!-- Model --> <iconify-icon icon="ph:key-bold" class="text-lg text-green-600"></iconify-icon>
<div> API Key
<label class="block font-bold text-gray-800 mb-2 flex items-center gap-2"> </label>
<iconify-icon icon="ph:robot-bold" class="text-lg text-purple-600"></iconify-icon> <input
模型 (Model) id="api-key"
</label> type="password"
<input placeholder="sk-xxxxxxxxxxxxxxxx"
id="api-model" class="config-input"
type="text" value=""
placeholder="gpt-4-turbo" />
class="config-input" </div>
value=""
/> <!-- Model -->
</div> <div>
<label class="block font-bold text-gray-800 mb-2 flex items-center gap-2">
<!-- 状态显示 --> <iconify-icon icon="ph:robot-bold" class="text-lg text-purple-600"></iconify-icon>
<div id="config-status" class="p-3 border-2 border-gray-300 bg-gray-50 text-sm text-gray-600 hidden"> 模型 (Model)
<iconify-icon icon="ph:info" class="align-middle"></iconify-icon> </label>
<span id="status-text">等待操作...</span> <input
</div> id="api-model"
</div> type="text"
placeholder="gpt-4-turbo"
<!-- 模态窗底部按钮 --> class="config-input"
<div class="p-4 border-t-3 border-gray-300 bg-gray-100 flex gap-3 justify-end"> value=""
<button id="test-api-btn" class="px-4 py-2 bg-yellow-500 text-white font-bold border-2 border-black hover:bg-yellow-600 transition-all flex items-center gap-2"> />
<iconify-icon icon="ph:flask-bold"></iconify-icon> </div>
测试连接
</button> <!-- 状态显示 -->
<button id="save-config-btn" class="px-4 py-2 bg-green-500 text-white font-bold border-2 border-black hover:bg-green-600 transition-all flex items-center gap-2"> <div id="config-status" class="p-3 border-2 border-gray-300 bg-gray-50 text-sm text-gray-600 hidden">
<iconify-icon icon="ph:floppy-disk-bold"></iconify-icon> <iconify-icon icon="ph:info" class="align-middle"></iconify-icon>
保存配置 <span id="status-text">等待操作...</span>
</button> </div>
</div> </div>
</div>
</div> <!-- 模态窗底部按钮 -->
<div class="p-4 border-t-3 border-gray-300 bg-gray-100 flex gap-3 justify-end">
<!-- 引入JavaScript文件 --> <button id="test-api-btn" class="px-4 py-2 bg-yellow-500 text-white font-bold border-2 border-black hover:bg-yellow-600 transition-all flex items-center gap-2">
<script src="js/utils.js"></script> <iconify-icon icon="ph:flask-bold"></iconify-icon>
<script src="js/apiclient.js"></script> 测试连接
<script src="js/app.js"></script> </button>
</body> <button id="save-config-btn" class="px-4 py-2 bg-green-500 text-white font-bold border-2 border-black hover:bg-green-600 transition-all flex items-center gap-2">
</html> <iconify-icon icon="ph:floppy-disk-bold"></iconify-icon>
保存配置
</button>
</div>
</div>
</div>
<!-- 引入JavaScript文件 -->
<script src="js/utils.js"></script>
<script src="js/apiclient.js"></script>
<script src="js/app.js"></script>
</body>
</html>

437
js/app.js
View File

@@ -21,6 +21,11 @@ class ProductCanvasApp {
this.conversationHistory = {}; this.conversationHistory = {};
this.isProcessing = false; this.isProcessing = false;
this.activeStreamHandle = null; this.activeStreamHandle = null;
this.svgZoom = { canvas: 1, swot: 1 };
this.activeSvgPlaceholder = null;
this.pendingSvgId = null;
this.pendingCancel = false;
this.copyClipboardSupported = typeof ClipboardItem !== 'undefined' && !!navigator.clipboard;
this.initElements(); this.initElements();
this.initEventListeners(); this.initEventListeners();
@@ -51,7 +56,11 @@ class ProductCanvasApp {
this.placeholderText = this.svgViewer.querySelector('#placeholder-text'); this.placeholderText = this.svgViewer.querySelector('#placeholder-text');
// 底部操作按钮 // 底部操作按钮
this.zoomOutBtn = document.getElementById('zoom-out-btn');
this.zoomInBtn = document.getElementById('zoom-in-btn');
this.zoomResetBtn = document.getElementById('zoom-reset-btn');
this.downloadSvgBtn = document.getElementById('download-svg-btn'); this.downloadSvgBtn = document.getElementById('download-svg-btn');
this.copyImageBtn = document.getElementById('copy-image-btn');
this.exportImageBtn = document.getElementById('export-image-btn'); this.exportImageBtn = document.getElementById('export-image-btn');
this.viewCodeBtn = document.getElementById('view-code-btn'); this.viewCodeBtn = document.getElementById('view-code-btn');
@@ -100,7 +109,18 @@ class ProductCanvasApp {
}); });
// 底部操作按钮 // 底部操作按钮
if (this.zoomOutBtn) this.zoomOutBtn.addEventListener('click', () => this.adjustSvgZoom(-0.25));
if (this.zoomInBtn) this.zoomInBtn.addEventListener('click', () => this.adjustSvgZoom(0.25));
if (this.zoomResetBtn) this.zoomResetBtn.addEventListener('click', () => this.resetSvgZoom());
this.downloadSvgBtn.addEventListener('click', () => this.downloadSVG()); this.downloadSvgBtn.addEventListener('click', () => this.downloadSVG());
if (this.copyImageBtn) {
if (this.copyClipboardSupported) {
this.copyImageBtn.addEventListener('click', () => this.copySvgToClipboard());
} else {
this.copyImageBtn.disabled = true;
this.copyImageBtn.title = '当前浏览器不支持复制图片到剪贴板';
}
}
this.exportImageBtn.addEventListener('click', () => this.exportAsImage()); this.exportImageBtn.addEventListener('click', () => this.exportAsImage());
this.viewCodeBtn.addEventListener('click', () => this.viewSVGCode()); this.viewCodeBtn.addEventListener('click', () => this.viewSVGCode());
@@ -141,6 +161,7 @@ class ProductCanvasApp {
}; };
this.renderSvgViewerForMode(); this.renderSvgViewerForMode();
this.setSendButtonState('idle');
// 加载API配置 // 加载API配置
const apiConfig = window.apiClient.getConfig(); const apiConfig = window.apiClient.getConfig();
@@ -236,6 +257,8 @@ class ProductCanvasApp {
</div> </div>
`; `;
this.placeholderText = this.svgViewer.querySelector('#placeholder-text'); this.placeholderText = this.svgViewer.querySelector('#placeholder-text');
this.setActivePlaceholder(null);
this.updateZoomButtons();
} }
renderSvgViewerForMode() { renderSvgViewerForMode() {
@@ -257,52 +280,175 @@ class ProductCanvasApp {
if (latestSvgId && svgStore[latestSvgId]) { if (latestSvgId && svgStore[latestSvgId]) {
this.currentSvgId = latestSvgId; this.currentSvgId = latestSvgId;
this.svgViewer.innerHTML = svgStore[latestSvgId].content; this.mountSvgMarkup(svgStore[latestSvgId].content);
this.placeholderText = null; this.setActivePlaceholder(latestSvgId);
} else { } else {
this.currentSvgId = null;
this.showSvgPlaceholder(); this.showSvgPlaceholder();
} }
this.updateZoomButtons();
}
adjustSvgZoom(delta) {
const current = this.svgZoom[this.currentMode] || 1;
const next = Math.min(3, Math.max(0.25, parseFloat((current + delta).toFixed(2))));
this.svgZoom[this.currentMode] = next;
this.applySvgZoom();
}
resetSvgZoom() {
this.svgZoom[this.currentMode] = 1;
this.applySvgZoom();
}
applySvgZoom() {
const zoom = this.svgZoom[this.currentMode] || 1;
const wrapper = this.svgViewer.querySelector('.svg-content-wrapper');
if (wrapper) {
wrapper.style.transform = `scale(${zoom})`;
wrapper.style.transformOrigin = 'center top';
}
this.updateZoomButtons();
}
updateZoomButtons() {
if (!this.zoomInBtn || !this.zoomOutBtn || !this.zoomResetBtn) return;
const hasActiveSvg = !!this.currentSvgId && !!(this.svgStorage[this.currentMode] || {})[this.currentSvgId];
const zoom = this.svgZoom[this.currentMode] || 1;
const disableControls = !hasActiveSvg;
this.zoomInBtn.disabled = disableControls || zoom >= 3;
this.zoomOutBtn.disabled = disableControls || zoom <= 0.25;
this.zoomResetBtn.disabled = disableControls || Math.abs(zoom - 1) < 0.01;
if (!hasActiveSvg) {
if (this.copyImageBtn) this.copyImageBtn.disabled = true;
this.downloadSvgBtn.disabled = true;
this.exportImageBtn.disabled = true;
this.viewCodeBtn.disabled = true;
} else {
if (this.copyImageBtn) this.copyImageBtn.disabled = !this.copyClipboardSupported;
this.downloadSvgBtn.disabled = false;
this.exportImageBtn.disabled = false;
this.viewCodeBtn.disabled = false;
}
}
setActivePlaceholder(svgId) {
const previousActive = this.chatHistory.querySelectorAll('.svg-placeholder-active');
previousActive.forEach(el => el.classList.remove('svg-placeholder-active'));
if (svgId) {
const next = this.chatHistory.querySelector(`.svg-placeholder-block[data-svg-id="${svgId}"], .svg-drawing-placeholder[data-svg-id="${svgId}"]`);
if (next) {
next.classList.add('svg-placeholder-active');
this.activeSvgPlaceholder = svgId;
} else {
this.activeSvgPlaceholder = null;
}
} else {
this.activeSvgPlaceholder = null;
}
}
mountSvgMarkup(svgMarkup, temporary = false) {
this.svgViewer.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.className = 'svg-content-wrapper';
wrapper.innerHTML = svgMarkup;
this.svgViewer.appendChild(wrapper);
this.placeholderText = null;
if (!temporary) {
this.applySvgZoom();
} else {
const zoom = this.svgZoom[this.currentMode] || 1;
wrapper.style.transform = `scale(${zoom})`;
wrapper.style.transformOrigin = 'center top';
}
}
renderSvgContent(svgId) {
const store = this.svgStorage[this.currentMode] || {};
if (!svgId || !store[svgId]) {
this.showSvgPlaceholder();
return;
}
this.currentSvgId = svgId;
this.mountSvgMarkup(store[svgId].content);
this.setActivePlaceholder(svgId);
this.applySvgZoom();
}
renderTemporarySvg(svgMarkup) {
this.mountSvgMarkup(svgMarkup, true);
}
buildContextForUserMessage(userIndex) {
const history = this.conversationHistory[this.currentMode] || [];
if (userIndex < 0 || userIndex >= history.length) {
return null;
}
const target = history[userIndex];
if (!target || target.type !== 'user') {
return null;
}
const start = Math.max(0, userIndex - 10);
const contextSlice = history.slice(start, userIndex);
const contextMessages = contextSlice
.filter(msg => msg.type === 'user' || msg.type === 'ai')
.map(msg => ({
role: msg.type === 'user' ? 'user' : 'assistant',
content: msg.content
}));
return {
userMessage: target.content,
contextMessages
};
} }
// 发送消息 // 发送消息
async sendMessage() { async sendMessage() {
const message = this.chatInput.value.trim(); const message = this.chatInput.value.trim();
if (!message || this.isProcessing) return; if (!message || this.isProcessing) return;
// 检查API配置
if (!window.apiClient.isConfigValid()) { if (!window.apiClient.isConfigValid()) {
alert('⚠️ 请先配置API设置点击右上角齿轮图标进行配置。'); alert('⚠️ 请先配置API设置点击右上角齿轮图标进行配置。');
this.openConfigModal(); this.openConfigModal();
return; return;
} }
this.pendingCancel = false;
this.isProcessing = true; this.isProcessing = true;
this.setSendButtonState('busy');
this.sendButton.disabled = true;
// 添加用户消息
this.addUserMessage(message); this.addUserMessage(message);
this.chatInput.value = ''; this.chatInput.value = '';
Utils.autoResizeTextarea(this.chatInput); Utils.autoResizeTextarea(this.chatInput);
const history = this.conversationHistory[this.currentMode] || [];
const targetIndex = history.length - 1;
const payload = this.buildContextForUserMessage(targetIndex);
if (!payload) {
console.warn('无法构建上下文,终止发送');
this.isProcessing = false;
this.setSendButtonState('idle');
return;
}
this.setSendButtonState('streaming');
this.sendButton.disabled = false;
try { try {
// 获取对话上下文 await this.startStreamingMessage(payload.userMessage, payload.contextMessages);
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) { } catch (error) {
console.error('发送消息失败:', error); console.error('发送消息失败:', error);
this.addErrorMessage(error.message); this.addErrorMessage(error.message);
this.isProcessing = false; this.isProcessing = false;
this.setSendButtonState('idle'); this.setSendButtonState('idle');
this.activeStreamHandle = null; this.activeStreamHandle = null;
this.sendButton.disabled = false;
} }
} }
@@ -339,7 +485,9 @@ class ProductCanvasApp {
this.isProcessing = false; this.isProcessing = false;
this.setSendButtonState('idle'); this.setSendButtonState('idle');
this.sendButton.disabled = false;
this.activeStreamHandle = null; this.activeStreamHandle = null;
this.pendingCancel = false;
}; };
const onChunk = (chunk) => { const onChunk = (chunk) => {
@@ -363,11 +511,13 @@ class ProductCanvasApp {
if (svgStartMatch) { if (svgStartMatch) {
svgStarted = true; svgStarted = true;
svgId = svgId || Utils.generateId('svg'); svgId = svgId || Utils.generateId('svg');
this.pendingSvgId = svgId;
const svgStartIndex = svgStartMatch.index; const svgStartIndex = svgStartMatch.index;
beforeText = fullContent.substring(0, svgStartIndex); beforeText = fullContent.substring(0, svgStartIndex);
this.updateStreamingMessageWithPlaceholder(messageContainer, beforeText, svgId); this.updateStreamingMessageWithPlaceholder(messageContainer, beforeText, svgId);
this.setActivePlaceholder(svgId);
this.svgViewer.innerHTML = ` this.svgViewer.innerHTML = `
<div class="flex items-center justify-center h-full"> <div class="flex items-center justify-center h-full">
@@ -377,6 +527,7 @@ class ProductCanvasApp {
</div> </div>
</div> </div>
`; `;
this.updateZoomButtons();
} }
} }
@@ -393,8 +544,6 @@ class ProductCanvasApp {
svgContent += '</svg>'; svgContent += '</svg>';
} }
this.svgViewer.innerHTML = svgContent;
this.svgStorage[this.currentMode][svgId] = { this.svgStorage[this.currentMode][svgId] = {
content: svgContent, content: svgContent,
messageId, messageId,
@@ -402,6 +551,8 @@ class ProductCanvasApp {
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}; };
this.pendingSvgId = null;
this.renderSvgContent(svgId);
this.updatePlaceholderToClickable(messageContainer, svgId); this.updatePlaceholderToClickable(messageContainer, svgId);
svgStarted = false; svgStarted = false;
@@ -420,7 +571,7 @@ class ProductCanvasApp {
tempSvgContent += '</svg>'; tempSvgContent += '</svg>';
} }
this.svgViewer.innerHTML = tempSvgContent; this.renderTemporarySvg(tempSvgContent);
} }
} }
} else { } else {
@@ -441,6 +592,12 @@ class ProductCanvasApp {
this.activeStreamHandle = streamHandle; this.activeStreamHandle = streamHandle;
if (this.pendingCancel) {
const shouldCancel = this.pendingCancel;
this.pendingCancel = false;
this.cancelActiveStream();
}
await streamHandle.finished; await streamHandle.finished;
if (!streamClosed) { if (!streamClosed) {
@@ -451,6 +608,8 @@ class ProductCanvasApp {
this.activeStreamHandle = null; this.activeStreamHandle = null;
this.isProcessing = false; this.isProcessing = false;
this.setSendButtonState('idle'); this.setSendButtonState('idle');
this.sendButton.disabled = false;
this.pendingCancel = false;
const bubble = this.chatHistory.querySelector(`[data-message-id="${messageId}"]`); const bubble = this.chatHistory.querySelector(`[data-message-id="${messageId}"]`);
if (bubble) { if (bubble) {
@@ -469,9 +628,12 @@ class ProductCanvasApp {
cancelActiveStream() { cancelActiveStream() {
if (!this.activeStreamHandle || typeof this.activeStreamHandle.cancel !== 'function') { if (!this.activeStreamHandle || typeof this.activeStreamHandle.cancel !== 'function') {
this.pendingCancel = true;
this.setSendButtonState('terminating');
return; return;
} }
this.pendingCancel = false;
this.setSendButtonState('terminating'); this.setSendButtonState('terminating');
try { try {
this.activeStreamHandle.cancel(); this.activeStreamHandle.cancel();
@@ -532,22 +694,26 @@ class ProductCanvasApp {
if (placeholder) { if (placeholder) {
placeholder.classList.remove('svg-drawing-placeholder'); placeholder.classList.remove('svg-drawing-placeholder');
placeholder.classList.add('svg-placeholder-block'); placeholder.classList.add('svg-placeholder-block');
placeholder.setAttribute('data-svg-id', svgId);
placeholder.innerHTML = `📊 点击查看${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'} SVG`; placeholder.innerHTML = `📊 点击查看${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'} SVG`;
placeholder.setAttribute('onclick', `app.viewSVG('${svgId}')`); placeholder.setAttribute('onclick', `app.viewSVG('${svgId}')`);
if (this.currentSvgId === svgId) {
placeholder.classList.add('svg-placeholder-active');
}
} }
} }
// 更新SVG后的消息内容 // 更新SVG后的消息内容
updateStreamingMessageAfterSVG(container, beforeText, svgId, afterText) { updateStreamingMessageAfterSVG(container, beforeText, svgId, afterText) {
// 使用Markdown解析文本
const parsedBeforeText = typeof marked !== 'undefined' ? marked.parse(beforeText) : Utils.escapeHtml(beforeText); const parsedBeforeText = typeof marked !== 'undefined' ? marked.parse(beforeText) : Utils.escapeHtml(beforeText);
const parsedAfterText = typeof marked !== 'undefined' ? marked.parse(afterText) : Utils.escapeHtml(afterText); const parsedAfterText = typeof marked !== 'undefined' ? marked.parse(afterText) : Utils.escapeHtml(afterText);
const placeholderClass = this.currentSvgId === svgId ? 'svg-placeholder-block svg-placeholder-active' : 'svg-placeholder-block';
container.innerHTML = ` container.innerHTML = `
<div class="chat-bubble-ai relative streaming-text" data-message-id="${container.dataset.messageId}"> <div class="chat-bubble-ai relative streaming-text" data-message-id="${container.dataset.messageId}">
<div> <div>
${parsedBeforeText} ${parsedBeforeText}
<div class="svg-placeholder-block" data-svg-id="${svgId}" onclick="app.viewSVG('${svgId}')"> <div class="${placeholderClass}" data-svg-id="${svgId}" onclick="app.viewSVG('${svgId}')">
📊 点击查看${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'} SVG 📊 点击查看${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'} SVG
</div> </div>
<div class="typing-cursor">${parsedAfterText}</div> <div class="typing-cursor">${parsedAfterText}</div>
@@ -615,6 +781,7 @@ class ProductCanvasApp {
// 完成流式消息 // 完成流式消息
finalizeStreamingMessage(messageId, fullContent, svgId = null) { finalizeStreamingMessage(messageId, fullContent, svgId = null) {
this.pendingSvgId = null;
const parsed = Utils.parseSVGResponse(fullContent); const parsed = Utils.parseSVGResponse(fullContent);
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
@@ -676,6 +843,7 @@ class ProductCanvasApp {
// 清空当前模式的SVG存储 // 清空当前模式的SVG存储
this.svgStorage[this.currentMode] = {}; this.svgStorage[this.currentMode] = {};
this.svgZoom[this.currentMode] = 1;
this.showSvgPlaceholder(); this.showSvgPlaceholder();
// 保存数据 // 保存数据
@@ -804,11 +972,12 @@ class ProductCanvasApp {
const actions = this.buildActionToolbar(message.id, { allowRegenerate, allowRollback }); const actions = this.buildActionToolbar(message.id, { allowRegenerate, allowRollback });
messageDiv.className = 'flex justify-start'; messageDiv.className = 'flex justify-start';
const placeholderClass = this.currentSvgId === svgId ? 'svg-placeholder-block svg-placeholder-active' : 'svg-placeholder-block';
messageDiv.innerHTML = ` messageDiv.innerHTML = `
<div class="chat-bubble-ai relative" data-message-id="${message.id}"> <div class="chat-bubble-ai relative" data-message-id="${message.id}">
<div> <div>
${beforeHtml} ${beforeHtml}
<div class="svg-placeholder-block" data-svg-id="${svgId}" onclick="app.viewSVG('${svgId}')"> <div class="${placeholderClass}" data-svg-id="${svgId}" onclick="app.viewSVG('${svgId}')">
📊 点击查看 ${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'} SVG 📊 点击查看 ${this.currentMode === 'canvas' ? '产品画布' : 'SWOT分析'} SVG
</div> </div>
${afterHtml} ${afterHtml}
@@ -889,19 +1058,19 @@ class ProductCanvasApp {
Utils.storage.set('swotHistory', this.conversationHistory.swot || []); Utils.storage.set('swotHistory', this.conversationHistory.swot || []);
} }
this.setActivePlaceholder(this.currentSvgId);
Utils.scrollToBottom(this.chatHistory); Utils.scrollToBottom(this.chatHistory);
} }
// 显示SVG // 显示SVG
viewSVG(svgId) { viewSVG(svgId) {
if (!this.svgStorage[this.currentMode][svgId]) { const store = this.svgStorage[this.currentMode] || {};
if (!store[svgId]) {
console.error('SVG not found:', svgId); console.error('SVG not found:', svgId);
return; return;
} }
this.currentSvgId = svgId; this.renderSvgContent(svgId);
const svgContent = this.svgStorage[this.currentMode][svgId].content;
this.svgViewer.innerHTML = svgContent;
} }
// 退回到指定消息 // 退回到指定消息
@@ -943,64 +1112,194 @@ class ProductCanvasApp {
// 重新生成消息 // 重新生成消息
async regenerateMessage(messageId) { async regenerateMessage(messageId) {
if (this.isProcessing) return; if (this.isProcessing) return;
const history = this.conversationHistory[this.currentMode] || [];
const targetIndex = history.findIndex(msg => msg.id === messageId && msg.type === 'ai');
if (targetIndex === -1) {
console.warn('未找到可重新生成的消息');
return;
}
let userIndex = -1;
for (let i = targetIndex - 1; i >= 0; i--) {
if (history[i].type === 'user') {
userIndex = i;
break;
}
}
if (userIndex === -1) {
alert('未找到对应的用户消息,无法重新生成');
return;
}
const payload = this.buildContextForUserMessage(userIndex);
if (!payload) {
alert('无法构建对话上下文,请稍后重试');
return;
}
this.pendingCancel = false;
this.isProcessing = true; this.isProcessing = true;
this.setSendButtonState('busy'); this.setSendButtonState('streaming');
this.sendButton.disabled = true; this.sendButton.disabled = false;
try { try {
// 重新生成响应 await this.startStreamingMessage(payload.userMessage, payload.contextMessages);
const response = await window.apiClient.regenerateResponse(messageId, this.conversationHistory[this.currentMode]);
// 退回到指定消息
this.rollbackToMessage(messageId);
// 添加新的AI回复
this.addAIMessage(response);
} catch (error) { } catch (error) {
console.error('重新生成失败:', error); console.error('重新生成失败:', error);
this.addErrorMessage(error.message); this.addErrorMessage(error.message);
} finally {
this.isProcessing = false; this.isProcessing = false;
this.setSendButtonState('idle'); this.setSendButtonState('idle');
this.activeStreamHandle = null; this.activeStreamHandle = null;
this.sendButton.disabled = false;
}
}
getActiveSvgRecord(showWarning = true) {
const store = this.svgStorage[this.currentMode] || {};
if (this.currentSvgId && store[this.currentSvgId]) {
return { id: this.currentSvgId, ...store[this.currentSvgId] };
}
if (showWarning) {
alert('请先生成并选择一个图表');
}
return null;
}
parseSvgDimensions(svgContent) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(svgContent, 'image/svg+xml');
const svgEl = doc.querySelector('svg');
if (!svgEl) {
return { width: 1024, height: 768 };
}
const parseLength = (value) => {
if (!value) return null;
const match = /([0-9.]+)/.exec(value);
return match ? parseFloat(match[1]) : null;
};
let width = parseLength(svgEl.getAttribute('width'));
let height = parseLength(svgEl.getAttribute('height'));
const viewBox = svgEl.getAttribute('viewBox');
if ((!width || !height) && viewBox) {
const parts = viewBox.split(/\s+/).map(Number).filter(n => !Number.isNaN(n));
if (parts.length === 4) {
width = width || parts[2];
height = height || parts[3];
}
}
return {
width: width || 1024,
height: height || 768
};
} catch (error) {
console.warn('解析SVG尺寸失败:', error);
return { width: 1024, height: 768 };
}
}
loadImageFromUrl(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
img.src = url;
});
}
async convertSvgToPngBlob(svgContent) {
const { width, height } = this.parseSvgDimensions(svgContent);
const svgBlob = new Blob([svgContent], { type: 'image/svg+xml' });
const url = URL.createObjectURL(svgBlob);
try {
const img = await this.loadImageFromUrl(url);
const canvas = document.createElement('canvas');
const canvasWidth = Math.max(1, img.naturalWidth || width || 1024);
const canvasHeight = Math.max(1, img.naturalHeight || height || 768);
canvas.width = canvasWidth;
canvas.height = canvasHeight;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
ctx.drawImage(img, 0, 0, canvasWidth, canvasHeight);
return await new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('无法生成PNG图像'));
}
}, 'image/png');
});
} finally {
URL.revokeObjectURL(url);
}
}
async copySvgToClipboard() {
const record = this.getActiveSvgRecord();
if (!record) return;
if (!navigator.clipboard || typeof ClipboardItem === 'undefined') {
alert('当前浏览器不支持复制图片到剪贴板,请使用下载功能。');
return;
}
try {
const blob = await this.convertSvgToPngBlob(record.content);
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
alert('图像已复制到剪贴板');
} catch (error) {
console.error('复制图片失败:', error);
alert('复制失败,请稍后重试。');
} }
} }
// 下载SVG // 下载SVG
downloadSVG() { downloadSVG() {
if (!this.currentSvgId) { const record = this.getActiveSvgRecord();
alert('请先生成SVG图表'); if (!record) return;
return;
}
const svgContent = this.svgStorage[this.currentMode][this.currentSvgId].content;
const filename = `${this.currentMode}-${Utils.formatDateTime().replace(/[/:]/g, '-')}.svg`; const filename = `${this.currentMode}-${Utils.formatDateTime().replace(/[/:]/g, '-')}.svg`;
Utils.downloadFile(svgContent, filename, 'image/svg+xml'); Utils.downloadFile(record.content, filename, 'image/svg+xml');
} }
// 导出为图片 // 导出为图片
exportAsImage() { async exportAsImage() {
if (!this.currentSvgId) { const record = this.getActiveSvgRecord();
alert('请先生成SVG图表'); if (!record) return;
return;
try {
const blob = await this.convertSvgToPngBlob(record.content);
const filename = `${this.currentMode}-${Utils.formatDateTime().replace(/[/:]/g, '-')}.png`;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
} catch (error) {
console.error('导出图片失败:', error);
alert('导出图片失败,请稍后重试。');
} }
// 这里可以实现SVG转PNG的功能
// 由于需要额外的库,这里先提示用户
alert('SVG转PNG功能需要额外的库支持您可以使用下载SVG功能然后使用在线工具转换。');
} }
// 查看SVG代码 // 查看SVG代码
viewSVGCode() { viewSVGCode() {
if (!this.currentSvgId) { const record = this.getActiveSvgRecord();
alert('请先生成SVG图表'); if (!record) return;
return;
} const svgContent = record.content;
const svgContent = this.svgStorage[this.currentMode][this.currentSvgId].content;
// 创建代码查看模态窗 // 创建代码查看模态窗
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.className = 'modal-overlay active'; modal.className = 'modal-overlay active';