Compare commits

...

5 Commits

Author SHA1 Message Date
史悦
b0c487a4ef 6. 增加一个svg放大缩小功能;
7. 目前的svg下载,下载图片,显示代码实现有问题,好像没找到svg;再增加一个复制图片到剪切板功能;
8. 点重新生成按钮,应该再添加一个气泡啊,而且流式响应,现在点击重新生成,就时等待,没有实时显示;
9. 点击查看画布,没有区分现在显示的是哪个气泡或占位符,需要标记区分下;
2025-10-24 20:31:49 +08:00
史悦
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
史悦
06e1d5ca19 修改了提示词 2025-10-24 19:18:03 +08:00
史悦
64e93d25b8 - 在 js/utils.js:24-79 强化 parseSVGResponse,兼容缺失结尾反引号和截断的 SVG,自动补齐 </svg> 并去除残
留的 ```,确保后续渲染能稳定提取图形。
2025-10-24 19:03:40 +08:00
史悦
bacafd66dc 修复气泡 2025-10-24 18:52:18 +08:00
7 changed files with 2653 additions and 2129 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -1,351 +1,361 @@
body { body {
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
} }
/* 狂野线条效果 */ /* 狂野线条效果 */
.wild-border { .wild-border {
border: 3px solid; border: 3px solid;
box-shadow: 4px 4px 0px rgba(0,0,0,0.3); box-shadow: 4px 4px 0px rgba(0,0,0,0.3);
} }
/* 切换按钮激活状态 */ /* 切换按钮激活状态 */
.mode-btn-active { .mode-btn-active {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 0 rgba(0,0,0,0.3); box-shadow: 0 4px 0 rgba(0,0,0,0.3);
} }
.mode-btn-inactive { .mode-btn-inactive {
opacity: 0.6; opacity: 0.6;
} }
/* 对话气泡样式 */ /* 对话气泡样式 */
.chat-bubble-user { .chat-bubble-user {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
padding: 10px 14px; padding: 10px 14px;
max-width: 80%; max-width: 80%;
border: 2px solid #000; border: 2px solid #000;
box-shadow: 2px 2px 0 rgba(0,0,0,0.2); box-shadow: 2px 2px 0 rgba(0,0,0,0.2);
} }
.chat-bubble-ai { .chat-bubble-ai {
background: #fff; background: #fff;
color: #1f2937; color: #1f2937;
padding: 10px 14px; padding: 10px 14px;
max-width: 85%; max-width: 85%;
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
border: 2px solid #10b981; border: 2px solid #10b981;
box-shadow: 2px 2px 0 rgba(16, 185, 129, 0.3); box-shadow: 2px 2px 0 rgba(16, 185, 129, 0.3);
} }
/* Markdown样式 */ /* Markdown样式 */
.chat-bubble-ai h1 { .chat-bubble-ai h1 {
font-size: 1.5em; font-size: 1.5em;
font-weight: bold; font-weight: bold;
margin: 0.5em 0; margin: 0.5em 0;
color: #1f2937; color: #1f2937;
} }
.chat-bubble-ai h2 { .chat-bubble-ai h2 {
font-size: 1.3em; font-size: 1.3em;
font-weight: bold; font-weight: bold;
margin: 0.5em 0; margin: 0.5em 0;
color: #1f2937; color: #1f2937;
} }
.chat-bubble-ai h3 { .chat-bubble-ai h3 {
font-size: 1.1em; font-size: 1.1em;
font-weight: bold; font-weight: bold;
margin: 0.5em 0; margin: 0.5em 0;
color: #1f2937; color: #1f2937;
} }
.chat-bubble-ai p { .chat-bubble-ai p {
margin: 0.5em 0; margin: 0.5em 0;
line-height: 1.5; line-height: 1.5;
} }
.chat-bubble-ai ul, .chat-bubble-ai ol { .chat-bubble-ai ul, .chat-bubble-ai ol {
margin: 0.5em 0; margin: 0.5em 0;
padding-left: 1.5em; padding-left: 1.5em;
} }
.chat-bubble-ai li { .chat-bubble-ai li {
margin: 0.25em 0; margin: 0.25em 0;
line-height: 1.4; line-height: 1.4;
} }
.chat-bubble-ai code { .chat-bubble-ai code {
background: #f3f4f6; background: #f3f4f6;
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
border-radius: 3px; border-radius: 3px;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
font-size: 0.9em; font-size: 0.9em;
color: #e11d48; color: #e11d48;
} }
.chat-bubble-ai pre { .chat-bubble-ai pre {
background: #f9fafb; background: #f9fafb;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 4px; border-radius: 4px;
padding: 0.75em; padding: 0.75em;
overflow-x: auto; overflow-x: auto;
margin: 0.5em 0; margin: 0.5em 0;
} }
.chat-bubble-ai pre code { .chat-bubble-ai pre code {
background: none; background: none;
padding: 0; padding: 0;
color: #1f2937; color: #1f2937;
} }
.chat-bubble-ai blockquote { .chat-bubble-ai blockquote {
border-left: 4px solid #d1d5db; border-left: 4px solid #d1d5db;
padding-left: 1em; padding-left: 1em;
margin: 0.5em 0; margin: 0.5em 0;
color: #6b7280; color: #6b7280;
font-style: italic; font-style: italic;
} }
.chat-bubble-ai strong { .chat-bubble-ai strong {
font-weight: bold; font-weight: bold;
color: #1f2937; color: #1f2937;
} }
.chat-bubble-ai em { .chat-bubble-ai em {
font-style: italic; font-style: italic;
color: #4b5563; color: #4b5563;
} }
.chat-bubble-ai a { .chat-bubble-ai a {
color: #3b82f6; color: #3b82f6;
text-decoration: underline; text-decoration: underline;
} }
.chat-bubble-ai a:hover { .chat-bubble-ai a:hover {
color: #1d4ed8; color: #1d4ed8;
} }
.chat-bubble-ai table { .chat-bubble-ai table {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
margin: 0.5em 0; margin: 0.5em 0;
} }
.chat-bubble-ai th, .chat-bubble-ai td { .chat-bubble-ai th, .chat-bubble-ai td {
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
padding: 0.5em; padding: 0.5em;
text-align: left; text-align: left;
} }
.chat-bubble-ai th { .chat-bubble-ai th {
background: #f9fafb; background: #f9fafb;
font-weight: bold; font-weight: bold;
} }
/* SVG占位符样式 - 块级换行 + 新配色 */ /* SVG占位符样式 - 块级换行 + 新配色 */
.svg-placeholder-block { .svg-placeholder-block {
display: block; display: block;
background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%); background: linear-gradient(135deg, #f59e0b 0%, #ef4444 100%);
color: white; color: white;
padding: 8px 14px; padding: 8px 14px;
margin: 8px 0; margin: 8px 0;
border: 2px solid #000; border: 2px solid #000;
box-shadow: 3px 3px 0 rgba(0,0,0,0.25); box-shadow: 3px 3px 0 rgba(0,0,0,0.25);
font-weight: bold; font-weight: bold;
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
text-align: center; text-align: center;
} }
.svg-placeholder-block:hover { .svg-placeholder-block:hover {
transform: translateX(2px) translateY(-2px); transform: translateX(2px) translateY(-2px);
box-shadow: 4px 4px 0 rgba(0,0,0,0.3); box-shadow: 4px 4px 0 rgba(0,0,0,0.3);
background: linear-gradient(135deg, #fb923c 0%, #f87171 100%); background: linear-gradient(135deg, #fb923c 0%, #f87171 100%);
} }
/* SVG绘制中状态占位符 */ /* SVG绘制中状态占位符 */
.svg-drawing-placeholder { .svg-drawing-placeholder {
display: block; display: block;
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
color: white; color: white;
padding: 8px 14px; padding: 8px 14px;
margin: 8px 0; margin: 8px 0;
border: 2px solid #000; border: 2px solid #000;
box-shadow: 3px 3px 0 rgba(0,0,0,0.25); box-shadow: 3px 3px 0 rgba(0,0,0,0.25);
font-weight: bold; font-weight: bold;
font-size: 13px; font-size: 13px;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
text-align: center; text-align: center;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.svg-drawing-placeholder:hover { .svg-drawing-placeholder:hover {
transform: translateX(2px) translateY(-2px); transform: translateX(2px) translateY(-2px);
box-shadow: 4px 4px 0 rgba(0,0,0,0.3); box-shadow: 4px 4px 0 rgba(0,0,0,0.3);
background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%); background: linear-gradient(135deg, #60a5fa 0%, #a78bfa 100%);
} }
/* 绘制中动画效果 */ /* 绘制中动画效果 */
@keyframes drawing-pulse { @keyframes drawing-pulse {
0% { opacity: 1; } 0% { opacity: 1; }
50% { opacity: 0.7; } 50% { opacity: 0.7; }
100% { opacity: 1; } 100% { opacity: 1; }
} }
.svg-drawing-placeholder::before { .svg-drawing-placeholder::before {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
left: -100%; left: -100%;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent); background: linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent);
animation: drawing-shine 2s infinite; animation: drawing-shine 2s infinite;
} }
@keyframes drawing-shine { @keyframes drawing-shine {
0% { left: -100%; } 0% { left: -100%; }
100% { left: 100%; } 100% { left: 100%; }
} }
.svg-drawing-text { .svg-drawing-text {
animation: drawing-pulse 1.5s infinite; animation: drawing-pulse 1.5s infinite;
} }
/* 气泡操作按钮 */ /* 气泡操作按钮 */
.bubble-action-btn { .bubble-action-btn {
opacity: 0; opacity: 1;
transition: opacity 0.2s; transition: color 0.2s ease;
} }
.chat-bubble-ai:hover .bubble-action-btn { .svg-placeholder-active {
opacity: 1; border-color: #2563eb;
} box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
}
/* 小手摇摆动画 */
@keyframes wave { .svg-content-wrapper {
0%, 100% {transform: translateX(0px) rotate(90deg);} display: inline-block;
10%, 30%, 50%, 70%, 90% {transform: translateX(-1px) rotate(90deg);} transform-origin: center top;
20%, 40%, 60%, 80% {transform: translateX(1px) rotate(90deg);} }
}
/* 小手摇摆动画 */
.wave-hand { @keyframes wave {
animation: wave 3s ease-in-out infinite; 0%, 100% {transform: translateX(0px) rotate(90deg);}
display: inline-block; 10%, 30%, 50%, 70%, 90% {transform: translateX(-1px) rotate(90deg);}
transform: rotate(90deg); 20%, 40%, 60%, 80% {transform: translateX(1px) rotate(90deg);}
} }
/* 模态窗样式 */ .wave-hand {
.modal-overlay { animation: wave 3s ease-in-out infinite;
display: none; display: inline-block;
position: fixed; transform: rotate(90deg);
top: 0; }
left: 0;
right: 0; /* 模态窗样式 */
bottom: 0; .modal-overlay {
background: rgba(0, 0, 0, 0.7); display: none;
z-index: 1000; position: fixed;
align-items: center; top: 0;
justify-content: center; left: 0;
} right: 0;
bottom: 0;
.modal-overlay.active { background: rgba(0, 0, 0, 0.7);
display: flex; z-index: 1000;
} align-items: center;
justify-content: center;
.modal-content { }
background: white;
border: 4px solid #000; .modal-overlay.active {
box-shadow: 8px 8px 0 rgba(0,0,0,0.4); display: flex;
max-width: 500px; }
width: 90%;
max-height: 90vh; .modal-content {
overflow-y: auto; background: white;
} border: 4px solid #000;
box-shadow: 8px 8px 0 rgba(0,0,0,0.4);
/* 表单输入框样式 */ max-width: 500px;
.config-input { width: 90%;
width: 100%; max-height: 90vh;
padding: 10px; overflow-y: auto;
border: 2px solid #000; }
font-size: 14px;
transition: all 0.2s; /* 表单输入框样式 */
} .config-input {
width: 100%;
.config-input:focus { padding: 10px;
outline: none; border: 2px solid #000;
border-color: #667eea; font-size: 14px;
box-shadow: 3px 3px 0 rgba(102, 126, 234, 0.3); transition: all 0.2s;
} }
/* 齿轮旋转动画 */ .config-input:focus {
@keyframes rotate { outline: none;
from { transform: rotate(0deg); } border-color: #667eea;
to { transform: rotate(360deg); } box-shadow: 3px 3px 0 rgba(102, 126, 234, 0.3);
} }
.settings-btn:hover iconify-icon { /* 齿轮旋转动画 */
animation: rotate 1s linear infinite; @keyframes rotate {
} from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
/* 自适应高度输入框 */ }
.auto-resize-input {
min-height: 40px; .settings-btn:hover iconify-icon {
max-height: 120px; /* 5行左右的高度 */ animation: rotate 1s linear infinite;
resize: none; }
overflow-y: auto;
line-height: 1.5; /* 自适应高度输入框 */
padding: 8px 12px; .auto-resize-input {
} min-height: 40px;
max-height: 120px; /* 5行左右的高度 */
/* 清空按钮样式 */ resize: none;
.clear-btn { overflow-y: auto;
transition: all 0.2s; line-height: 1.5;
} padding: 8px 12px;
}
.clear-btn:hover {
transform: scale(1.05); /* 清空按钮样式 */
} .clear-btn {
transition: all 0.2s;
.clear-btn:active { }
transform: scale(0.95);
} .clear-btn:hover {
transform: scale(1.05);
/* 流式输出动画 */ }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); } .clear-btn:active {
to { opacity: 1; transform: translateY(0); } transform: scale(0.95);
} }
.streaming-text { /* 流式输出动画 */
animation: fadeIn 0.3s ease-in-out; @keyframes fadeIn {
} from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
/* 打字机效果光标 */ }
@keyframes blink {
0%, 50% { opacity: 1; } .streaming-text {
51%, 100% { opacity: 0; } animation: fadeIn 0.3s ease-in-out;
} }
.typing-cursor::after { /* 打字机效果光标 */
content: '|'; @keyframes blink {
animation: blink 1s infinite; 0%, 50% { opacity: 1; }
color: #667eea; 51%, 100% { opacity: 0; }
font-weight: bold; }
}
.typing-cursor::after {
/* 清空历史按钮摇动动画 */ content: '|';
@keyframes shake { animation: blink 1s infinite;
0%, 100% { transform: translateX(0); } color: #667eea;
10%, 30%, 50%, 70%, 90% { transform: translateX(-2px); } font-weight: bold;
20%, 40%, 60%, 80% { transform: translateX(2px); } }
}
/* 清空历史按钮摇动动画 */
.clear-history-btn:hover iconify-icon { @keyframes shake {
animation: shake 0.5s ease-in-out; 0%, 100% { transform: translateX(0); }
} 10%, 30%, 50%, 70%, 90% { transform: translateX(-2px); }
20%, 40%, 60%, 80% { transform: translateX(2px); }
}
.clear-history-btn:hover iconify-icon {
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>

View File

@@ -1,299 +1,299 @@
/** /**
* API客户端 - 处理与AI服务的交互 * API客户端 - 处理与AI服务的交互
*/ */
class APIClient { class APIClient {
constructor() { constructor() {
this.config = { this.config = {
url: '', url: '',
key: '', key: '',
model: '' model: ''
}; };
this.prompts = { this.prompts = {
canvas: '', canvas: '',
swot: '' swot: ''
}; };
this.loadConfig(); this.loadConfig();
this.loadPrompts(); this.loadPrompts();
} }
// 加载API配置 // 加载API配置
loadConfig() { loadConfig() {
const savedConfig = Utils.storage.get('apiConfig'); const savedConfig = Utils.storage.get('apiConfig');
if (savedConfig) { if (savedConfig) {
this.config = { ...this.config, ...savedConfig }; this.config = { ...this.config, ...savedConfig };
} }
} }
// 加载系统提示词 // 加载系统提示词
async loadPrompts() { async loadPrompts() {
try { try {
// 加载产品画布提示词 // 加载产品画布提示词
const canvasResponse = await fetch('prompts/canvas-prompt.txt'); const canvasResponse = await fetch('prompts/canvas-prompt.txt');
this.prompts.canvas = await canvasResponse.text(); this.prompts.canvas = await canvasResponse.text();
// 加载SWOT分析提示词 // 加载SWOT分析提示词
const swotResponse = await fetch('prompts/swot-prompt.txt'); const swotResponse = await fetch('prompts/swot-prompt.txt');
this.prompts.swot = await swotResponse.text(); this.prompts.swot = await swotResponse.text();
} catch (error) { } catch (error) {
console.error('加载提示词失败:', error); console.error('加载提示词失败:', error);
// 使用默认提示词 // 使用默认提示词
this.prompts.canvas = '你是一个专业的产品战略分析师,擅长创建产品画布。'; this.prompts.canvas = '你是一个专业的产品战略分析师,擅长创建产品画布。';
this.prompts.swot = '你是一个专业的商业战略分析师擅长进行SWOT分析。'; this.prompts.swot = '你是一个专业的商业战略分析师擅长进行SWOT分析。';
} }
} }
// 保存API配置 // 保存API配置
saveConfig(config) { saveConfig(config) {
this.config = { ...this.config, ...config }; this.config = { ...this.config, ...config };
return Utils.storage.set('apiConfig', this.config); return Utils.storage.set('apiConfig', this.config);
} }
// 获取当前配置 // 获取当前配置
getConfig() { getConfig() {
return { ...this.config }; return { ...this.config };
} }
// 验证配置是否完整 // 验证配置是否完整
isConfigValid() { isConfigValid() {
return this.config.url && this.config.key && this.config.model; return this.config.url && this.config.key && this.config.model;
} }
// 测试API连接 // 测试API连接
async testConnection() { async testConnection() {
if (!this.isConfigValid()) { if (!this.isConfigValid()) {
throw new Error('API配置不完整请填写所有字段'); throw new Error('API配置不完整请填写所有字段');
} }
try { try {
const response = await this.makeRequest([ const response = await this.makeRequest([
{ role: 'user', content: '测试连接' } { role: 'user', content: '测试连接' }
], 5); ], 5);
return { success: true, data: response }; return { success: true, data: response };
} catch (error) { } catch (error) {
throw new Error(`连接测试失败: ${error.message}`); throw new Error(`连接测试失败: ${error.message}`);
} }
} }
// 发送聊天请求 // 发送聊天请求
async sendChatMessage(messages, options = {}) { async sendChatMessage(messages, options = {}) {
if (!this.isConfigValid()) { if (!this.isConfigValid()) {
throw new Error('API配置不完整请先配置API设置'); throw new Error('API配置不完整请先配置API设置');
} }
const maxTokens = options.maxTokens || 2000; const maxTokens = options.maxTokens || 2000;
const temperature = options.temperature || 0.7; const temperature = options.temperature || 0.7;
try { try {
const response = await this.makeRequest(messages, maxTokens, temperature); const response = await this.makeRequest(messages, maxTokens, temperature);
return response; return response;
} catch (error) { } catch (error) {
throw new Error(`API请求失败: ${error.message}`); throw new Error(`API请求失败: ${error.message}`);
} }
} }
// 核心请求方法 // 核心请求方法
async makeRequest(messages, maxTokens, temperature = 0.7) { async makeRequest(messages, maxTokens, temperature = 0.7) {
const requestBody = { const requestBody = {
model: this.config.model, model: this.config.model,
messages: messages, messages: messages,
max_tokens: maxTokens, max_tokens: maxTokens,
temperature: temperature temperature: temperature
}; };
const response = await fetch(this.config.url, { const response = await fetch(this.config.url, {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${this.config.key}`, 'Authorization': `Bearer ${this.config.key}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify(requestBody) body: JSON.stringify(requestBody)
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error?.message || `HTTP ${response.status}: ${response.statusText}`); throw new Error(errorData.error?.message || `HTTP ${response.status}: ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
if (!data.choices || !data.choices.length || !data.choices[0].message) { if (!data.choices || !data.choices.length || !data.choices[0].message) {
throw new Error('API返回数据格式异常'); throw new Error('API返回数据格式异常');
} }
return data.choices[0].message.content; return data.choices[0].message.content;
} }
// 生成产品画布的专用方法 // 生成产品画布的专用方法
async generateProductCanvas(userRequest, context = []) { async generateProductCanvas(userRequest, context = []) {
const messages = [ const messages = [
{ role: 'system', content: this.prompts.canvas }, { role: 'system', content: this.prompts.canvas },
...context, ...context,
{ role: 'user', content: userRequest } { role: 'user', content: userRequest }
]; ];
return await this.sendChatMessage(messages, { maxTokens: 3000 }); return await this.sendChatMessage(messages, { maxTokens: 18000 });
} }
// 生成SWOT分析的专用方法 // 生成SWOT分析的专用方法
async generateSWOTAnalysis(userRequest, context = []) { async generateSWOTAnalysis(userRequest, context = []) {
const messages = [ const messages = [
{ role: 'system', content: this.prompts.swot }, { role: 'system', content: this.prompts.swot },
...context, ...context,
{ role: 'user', content: userRequest } { role: 'user', content: userRequest }
]; ];
return await this.sendChatMessage(messages, { maxTokens: 3000 }); return await this.sendChatMessage(messages, { maxTokens: 18000 });
} }
// 流式生成产品画布 // 流式生成产品画布
async generateProductCanvasStream(userRequest, context = [], onChunk, onComplete) { async generateProductCanvasStream(userRequest, context = [], onChunk, onComplete) {
const messages = [ const messages = [
{ role: 'system', content: this.prompts.canvas }, { role: 'system', content: this.prompts.canvas },
...context, ...context,
{ role: 'user', content: userRequest } { role: 'user', content: userRequest }
]; ];
return await this.sendChatMessageStream(messages, { maxTokens: 3000 }, onChunk, onComplete); return this.sendChatMessageStream(messages, { maxTokens: 13000 }, onChunk, onComplete);
} }
// 流式生成SWOT分析 // 流式生成SWOT分析
async generateSWOTAnalysisStream(userRequest, context = [], onChunk, onComplete) { async generateSWOTAnalysisStream(userRequest, context = [], onChunk, onComplete) {
const messages = [ const messages = [
{ role: 'system', content: this.prompts.swot }, { role: 'system', content: this.prompts.swot },
...context, ...context,
{ role: 'user', content: userRequest } { role: 'user', content: userRequest }
]; ];
return await this.sendChatMessageStream(messages, { maxTokens: 3000 }, onChunk, onComplete); return this.sendChatMessageStream(messages, { maxTokens: 13000 }, onChunk, onComplete);
} }
// 流式发送聊天请求 // 流式发送聊天请求
async sendChatMessageStream(messages, options = {}, onChunk, onComplete) { async sendChatMessageStream(messages, options = {}, onChunk, onComplete) {
if (!this.isConfigValid()) { if (!this.isConfigValid()) {
throw new Error('API配置不完整请先配置API设置'); throw new Error('API配置不完整请先配置API设置');
} }
const maxTokens = options.maxTokens || 2000; const maxTokens = options.maxTokens || 2000;
const temperature = options.temperature || 0.7; const temperature = options.temperature || 0.7;
const stream = true; const stream = true;
const requestBody = { const requestBody = {
model: this.config.model, model: this.config.model,
messages: messages, messages: messages,
max_tokens: maxTokens, max_tokens: maxTokens,
temperature: temperature, temperature: temperature,
stream: stream stream: stream
}; };
const url = this.config.url.replace('/chat/completions', '/chat/completions'); const url = this.config.url.replace('/chat/completions', '/chat/completions');
try { try {
await Utils.createStreamRequest( return Utils.createStreamRequest(
url, url,
{ {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${this.config.key}`, 'Authorization': `Bearer ${this.config.key}`,
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify(requestBody) body: JSON.stringify(requestBody)
}, },
onChunk, onChunk,
onComplete onComplete
); );
} catch (error) { } catch (error) {
throw new Error(`流式API请求失败: ${error.message}`); throw new Error(`流式API请求失败: ${error.message}`);
} }
} }
// 重新生成响应 // 重新生成响应
async regenerateResponse(messageId, conversationHistory) { async regenerateResponse(messageId, conversationHistory) {
// 找到指定消息ID之前的所有对话历史 // 找到指定消息ID之前的所有对话历史
const contextMessages = conversationHistory const contextMessages = conversationHistory
.filter(msg => msg.id <= messageId) .filter(msg => msg.id <= messageId)
.map(msg => ({ .map(msg => ({
role: msg.type === 'user' ? 'user' : 'assistant', role: msg.type === 'user' ? 'user' : 'assistant',
content: msg.content content: msg.content
})); }));
if (contextMessages.length === 0) { if (contextMessages.length === 0) {
throw new Error('没有找到有效的对话上下文'); throw new Error('没有找到有效的对话上下文');
} }
// 移除最后一条消息(需要重新生成的消息) // 移除最后一条消息(需要重新生成的消息)
if (contextMessages.length > 0 && contextMessages[contextMessages.length - 1].role === 'assistant') { if (contextMessages.length > 0 && contextMessages[contextMessages.length - 1].role === 'assistant') {
contextMessages.pop(); contextMessages.pop();
} }
// 根据当前模式选择相应的生成方法 // 根据当前模式选择相应的生成方法
const lastUserMessage = contextMessages.filter(msg => msg.role === 'user').pop(); const lastUserMessage = contextMessages.filter(msg => msg.role === 'user').pop();
if (!lastUserMessage) { if (!lastUserMessage) {
throw new Error('没有找到用户消息'); throw new Error('没有找到用户消息');
} }
const mode = Utils.storage.get('currentMode', 'canvas'); const mode = Utils.storage.get('currentMode', 'canvas');
if (mode === 'canvas') { if (mode === 'canvas') {
return await this.generateProductCanvas(lastUserMessage.content, contextMessages.slice(0, -1)); return await this.generateProductCanvas(lastUserMessage.content, contextMessages.slice(0, -1));
} else { } else {
return await this.generateSWOTAnalysis(lastUserMessage.content, contextMessages.slice(0, -1)); return await this.generateSWOTAnalysis(lastUserMessage.content, contextMessages.slice(0, -1));
} }
} }
// 模拟API响应用于测试 // 模拟API响应用于测试
simulateAPIResponse(userMessage, mode = 'canvas') { simulateAPIResponse(userMessage, mode = 'canvas') {
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(() => { setTimeout(() => {
const mockResponses = [ const mockResponses = [
`好的!我为您生成了一个${mode === 'canvas' ? '产品画布' : 'SWOT分析'} `好的!我为您生成了一个${mode === 'canvas' ? '产品画布' : 'SWOT分析'}
\`\`\`svg \`\`\`svg
<svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"> <svg width="600" height="400" xmlns="http://www.w3.org/2000/svg">
<defs> <defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" /> <stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" /> <stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="100%" height="100%" fill="#f8f9fa"/> <rect width="100%" height="100%" fill="#f8f9fa"/>
<rect x="50" y="50" width="500" height="300" fill="url(#grad1)" rx="10"/> <rect x="50" y="50" width="500" height="300" fill="url(#grad1)" rx="10"/>
<text x="300" y="200" text-anchor="middle" font-size="24" fill="white" font-weight="bold"> <text x="300" y="200" text-anchor="middle" font-size="24" fill="white" font-weight="bold">
这是${mode === 'canvas' ? '产品画布' : 'SWOT分析'}示例SVG 这是${mode === 'canvas' ? '产品画布' : 'SWOT分析'}示例SVG
</text> </text>
<circle cx="150" cy="150" r="40" fill="#ffffff" opacity="0.3"/> <circle cx="150" cy="150" r="40" fill="#ffffff" opacity="0.3"/>
<circle cx="450" cy="250" r="30" fill="#ffffff" opacity="0.3"/> <circle cx="450" cy="250" r="30" fill="#ffffff" opacity="0.3"/>
</svg> </svg>
\`\`\` \`\`\`
包含了关键要素和模块。点击上方标签可在右侧查看详细图表。`, 包含了关键要素和模块。点击上方标签可在右侧查看详细图表。`,
`已经为您调整完成! `已经为您调整完成!
\`\`\`svg \`\`\`svg
<svg width="600" height="400" xmlns="http://www.w3.org/2000/svg"> <svg width="600" height="400" xmlns="http://www.w3.org/2000/svg">
<defs> <defs>
<linearGradient id="grad2" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="grad2" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#f59e0b;stop-opacity:1" /> <stop offset="0%" style="stop-color:#f59e0b;stop-opacity:1" />
<stop offset="100%" style="stop-color:#ef4444;stop-opacity:1" /> <stop offset="100%" style="stop-color:#ef4444;stop-opacity:1" />
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="100%" height="100%" fill="#fff"/> <rect width="100%" height="100%" fill="#fff"/>
<rect x="75" y="75" width="450" height="250" fill="url(#grad2)" rx="15"/> <rect x="75" y="75" width="450" height="250" fill="url(#grad2)" rx="15"/>
<text x="300" y="200" text-anchor="middle" font-size="28" fill="white" font-weight="bold"> <text x="300" y="200" text-anchor="middle" font-size="28" fill="white" font-weight="bold">
${mode === 'canvas' ? '优化后的产品画布' : '优化后的SWOT分析'} ${mode === 'canvas' ? '优化后的产品画布' : '优化后的SWOT分析'}
</text> </text>
<rect x="100" y="120" width="80" height="60" fill="#ffffff" opacity="0.2" rx="5"/> <rect x="100" y="120" width="80" height="60" fill="#ffffff" opacity="0.2" rx="5"/>
<rect x="420" y="220" width="80" height="60" fill="#ffffff" opacity="0.2" rx="5"/> <rect x="420" y="220" width="80" height="60" fill="#ffffff" opacity="0.2" rx="5"/>
</svg> </svg>
\`\`\` \`\`\`
采用了更加鲜明的色彩组合,希望您满意!` 采用了更加鲜明的色彩组合,希望您满意!`
]; ];
const response = mockResponses[Math.floor(Math.random() * mockResponses.length)]; const response = mockResponses[Math.floor(Math.random() * mockResponses.length)];
resolve(response); resolve(response);
}, 1000 + Math.random() * 1000); // 1-2秒的随机延迟 }, 1000 + Math.random() * 1000); // 1-2秒的随机延迟
}); });
} }
} }
// 创建全局API客户端实例 // 创建全局API客户端实例
window.apiClient = new APIClient(); window.apiClient = new APIClient();

2410
js/app.js

File diff suppressed because it is too large Load Diff

View File

@@ -1,289 +1,352 @@
/** /**
* 工具函数集合 * 工具函数集合
*/ */
// HTML转义防止XSS攻击 // HTML转义防止XSS攻击
function escapeHtml(text) { function escapeHtml(text) {
const div = document.createElement('div'); const div = document.createElement('div');
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
// 滚动到指定元素的底部 // 滚动到指定元素的底部
function scrollToBottom(element) { function scrollToBottom(element) {
if (element) { if (element) {
element.scrollTop = element.scrollHeight; element.scrollTop = element.scrollHeight;
} }
} }
// 生成唯一ID // 生成唯一ID
function generateId(prefix = 'id') { function generateId(prefix = 'id') {
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
} }
// 解析SVG响应提取SVG内容和前后文本 // 解析SVG响应提取SVG内容和前后文本,容错缺失的结束反引号
function parseSVGResponse(response) { function parseSVGResponse(response = '') {
const svgRegex = /```svg\s*([\s\S]*?)```/i; const content = typeof response === 'string' ? response : String(response || '');
const match = response.match(svgRegex); const svgFenceRegex = /```(?:svg)?\s*([\s\S]*?)```/i;
const fenceMatch = content.match(svgFenceRegex);
if (match) {
const svgContent = match[1].trim(); if (fenceMatch) {
const beforeText = response.substring(0, match.index).trim(); const svgBody = fenceMatch[1].trim();
const afterText = response.substring(match.index + match[0].length).trim(); const beforeText = content.substring(0, fenceMatch.index).trim();
let afterText = content.substring(fenceMatch.index + fenceMatch[0].length).trim();
return { afterText = afterText.replace(/^\s*```/, '').trim();
svgContent,
beforeText, return {
afterText svgContent: svgBody,
}; beforeText,
} afterText
};
return { }
svgContent: null,
beforeText: response, // 兼容缺失结束反引号的情况
afterText: '' const svgStartRegex = /```(?:svg)?\s*<svg[\s\S]*$/i;
}; const startMatch = content.match(svgStartRegex);
}
if (startMatch) {
// 下载文件 const startIndex = startMatch.index;
function downloadFile(content, filename, mimeType = 'text/plain') { const beforeText = content.substring(0, startIndex).trim();
const blob = new Blob([content], { type: mimeType }); let svgSection = content.substring(startIndex).replace(/```(?:svg)?\s*/i, '').trim();
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); // 去掉尾部残留的反引号
a.href = url; svgSection = svgSection.replace(/```$/, '').trim();
a.download = filename;
document.body.appendChild(a); // 拆分 SVG 正文与额外文本
a.click(); let afterText = '';
document.body.removeChild(a); const svgEndIndex = svgSection.lastIndexOf('</svg>');
URL.revokeObjectURL(url); if (svgEndIndex !== -1) {
} afterText = svgSection.substring(svgEndIndex + 6).replace(/```/, '').trim();
svgSection = svgSection.substring(0, svgEndIndex + 6).trim();
// 显示状态信息 }
function showStatus(element, message, type = 'info') {
if (!element) return; // 补齐缺失的结束标签
if (svgSection && !svgSection.endsWith('</svg>')) {
element.classList.remove('hidden'); svgSection += '\n</svg>';
element.textContent = message; }
// 移除所有状态类 return {
element.classList.remove('border-gray-300', 'bg-gray-50', 'text-gray-600'); svgContent: svgSection || null,
element.classList.remove('border-green-500', 'bg-green-50', 'text-green-700'); beforeText,
element.classList.remove('border-red-500', 'bg-red-50', 'text-red-700'); afterText
element.classList.remove('border-blue-500', 'bg-blue-50', 'text-blue-700'); };
}
// 根据类型添加相应的样式类
switch (type) { return {
case 'success': svgContent: null,
element.classList.add('border-green-500', 'bg-green-50', 'text-green-700'); beforeText: content.trim(),
break; afterText: ''
case 'error': };
element.classList.add('border-red-500', 'bg-red-50', 'text-red-700'); }
break;
case 'loading': // 下载文件
element.classList.add('border-blue-500', 'bg-blue-50', 'text-blue-700'); function downloadFile(content, filename, mimeType = 'text/plain') {
break; const blob = new Blob([content], { type: mimeType });
default: const url = URL.createObjectURL(blob);
element.classList.add('border-gray-300', 'bg-gray-50', 'text-gray-600'); const a = document.createElement('a');
} a.href = url;
} a.download = filename;
document.body.appendChild(a);
// 本地存储操作 a.click();
const storage = { document.body.removeChild(a);
// 保存数据到本地存储 URL.revokeObjectURL(url);
set(key, value) { }
try {
localStorage.setItem(key, JSON.stringify(value)); // 显示状态信息
return true; function showStatus(element, message, type = 'info') {
} catch (error) { if (!element) return;
console.error('保存到本地存储失败:', error);
return false; element.classList.remove('hidden');
} element.textContent = message;
},
// 移除所有状态类
// 从本地存储获取数据 element.classList.remove('border-gray-300', 'bg-gray-50', 'text-gray-600');
get(key, defaultValue = null) { element.classList.remove('border-green-500', 'bg-green-50', 'text-green-700');
try { element.classList.remove('border-red-500', 'bg-red-50', 'text-red-700');
const item = localStorage.getItem(key); element.classList.remove('border-blue-500', 'bg-blue-50', 'text-blue-700');
return item ? JSON.parse(item) : defaultValue;
} catch (error) { // 根据类型添加相应的样式类
console.error('从本地存储获取数据失败:', error); switch (type) {
return defaultValue; case 'success':
} element.classList.add('border-green-500', 'bg-green-50', 'text-green-700');
}, break;
case 'error':
// 删除本地存储中的数据 element.classList.add('border-red-500', 'bg-red-50', 'text-red-700');
remove(key) { break;
try { case 'loading':
localStorage.removeItem(key); element.classList.add('border-blue-500', 'bg-blue-50', 'text-blue-700');
return true; break;
} catch (error) { default:
console.error('删除本地存储数据失败:', error); element.classList.add('border-gray-300', 'bg-gray-50', 'text-gray-600');
return false; }
} }
}
}; // 本地存储操作
const storage = {
// 防抖函数 // 保存数据到本地存储
function debounce(func, wait) { set(key, value) {
let timeout; try {
return function executedFunction(...args) { localStorage.setItem(key, JSON.stringify(value));
const later = () => { return true;
clearTimeout(timeout); } catch (error) {
func(...args); console.error('保存到本地存储失败:', error);
}; return false;
clearTimeout(timeout); }
timeout = setTimeout(later, wait); },
};
} // 从本地存储获取数据
get(key, defaultValue = null) {
// 节流函数 try {
function throttle(func, limit) { const item = localStorage.getItem(key);
let inThrottle; return item ? JSON.parse(item) : defaultValue;
return function() { } catch (error) {
const args = arguments; console.error('从本地存储获取数据失败:', error);
const context = this; return defaultValue;
if (!inThrottle) { }
func.apply(context, args); },
inThrottle = true;
setTimeout(() => inThrottle = false, limit); // 删除本地存储中的数据
} remove(key) {
}; try {
} localStorage.removeItem(key);
return true;
// 格式化日期时间 } catch (error) {
function formatDateTime(date = new Date()) { console.error('删除本地存储数据失败:', error);
return date.toLocaleString('zh-CN', { return false;
year: 'numeric', }
month: '2-digit', }
day: '2-digit', };
hour: '2-digit',
minute: '2-digit', // 防抖函数
second: '2-digit' function debounce(func, wait) {
}); let timeout;
} return function executedFunction(...args) {
const later = () => {
// 深拷贝对象 clearTimeout(timeout);
function deepClone(obj) { func(...args);
if (obj === null || typeof obj !== 'object') return obj; };
if (obj instanceof Date) return new Date(obj.getTime()); clearTimeout(timeout);
if (obj instanceof Array) return obj.map(item => deepClone(item)); timeout = setTimeout(later, wait);
if (typeof obj === 'object') { };
const clonedObj = {}; }
for (const key in obj) {
if (obj.hasOwnProperty(key)) { // 节流函数
clonedObj[key] = deepClone(obj[key]); function throttle(func, limit) {
} let inThrottle;
} return function() {
return clonedObj; const args = arguments;
} const context = this;
} if (!inThrottle) {
func.apply(context, args);
// 检查对象是否为空 inThrottle = true;
function isEmpty(obj) { setTimeout(() => inThrottle = false, limit);
if (obj == null) return true; }
if (Array.isArray(obj) || typeof obj === 'string') return obj.length === 0; };
if (typeof obj === 'object') return Object.keys(obj).length === 0; }
return false;
} // 格式化日期时间
function formatDateTime(date = new Date()) {
// 自动调整文本域高度 return date.toLocaleString('zh-CN', {
function autoResizeTextarea(textarea) { year: 'numeric',
if (!textarea) return; month: '2-digit',
day: '2-digit',
// 重置高度以获取正确的scrollHeight hour: '2-digit',
textarea.style.height = 'auto'; minute: '2-digit',
second: '2-digit'
// 计算新高度,限制最大高度 });
const newHeight = Math.min(textarea.scrollHeight, 120); // 最大120px约5行 }
textarea.style.height = newHeight + 'px';
} // 深拷贝对象
function deepClone(obj) {
// 流式文本处理 if (obj === null || typeof obj !== 'object') return obj;
class StreamProcessor { if (obj instanceof Date) return new Date(obj.getTime());
constructor(onChunk, onComplete) { if (obj instanceof Array) return obj.map(item => deepClone(item));
this.onChunk = onChunk; if (typeof obj === 'object') {
this.onComplete = onComplete; const clonedObj = {};
this.buffer = ''; for (const key in obj) {
} if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key]);
// 处理数据块 }
processChunk(chunk) { }
this.buffer += chunk; return clonedObj;
}
// 尝试解析完整的JSON行 }
const lines = this.buffer.split('\n');
this.buffer = lines.pop() || ''; // 保留不完整的行 // 检查对象是否为空
function isEmpty(obj) {
for (const line of lines) { if (obj == null) return true;
if (line.trim()) { if (Array.isArray(obj) || typeof obj === 'string') return obj.length === 0;
try { if (typeof obj === 'object') return Object.keys(obj).length === 0;
// 处理SSE格式 return false;
if (line.startsWith('data: ')) { }
const data = line.slice(6);
if (data === '[DONE]') { // 自动调整文本域高度
this.onComplete(); function autoResizeTextarea(textarea) {
return; if (!textarea) return;
}
// 重置高度以获取正确的scrollHeight
const parsed = JSON.parse(data); textarea.style.height = 'auto';
this.onChunk(parsed);
} // 计算新高度,限制最大高度
} catch (error) { const newHeight = Math.min(textarea.scrollHeight, 120); // 最大120px约5行
console.warn('解析流数据失败:', error, line); textarea.style.height = newHeight + 'px';
} }
}
} // 流式文本处理
} class StreamProcessor {
} constructor(onChunk, onComplete) {
this.onChunk = onChunk;
// 创建流式请求 this.onComplete = onComplete;
async function createStreamRequest(url, options, onChunk, onComplete) { this.buffer = '';
const processor = new StreamProcessor(onChunk, onComplete); this.completed = false;
}
try {
const response = await fetch(url, { complete(info = {}) {
...options, if (this.completed) return;
headers: { this.completed = true;
...options.headers, if (typeof this.onComplete === 'function') {
'Accept': 'text/event-stream', this.onComplete(info);
'Cache-Control': 'no-cache' }
} }
});
// 处理数据块
if (!response.ok) { processChunk(chunk) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`); this.buffer += chunk;
}
// 尝试解析完整的JSON行
const reader = response.body.getReader(); const lines = this.buffer.split('\n');
const decoder = new TextDecoder(); this.buffer = lines.pop() || ''; // 保留不完整的行
while (true) { for (const line of lines) {
const { done, value } = await reader.read(); if (line.trim()) {
if (done) break; try {
// 处理SSE格式
const chunk = decoder.decode(value, { stream: true }); if (line.startsWith('data: ')) {
processor.processChunk(chunk); const data = line.slice(6);
} if (data === '[DONE]') {
} catch (error) { this.complete({ aborted: false });
throw error; return;
} }
}
const parsed = JSON.parse(data);
// 导出工具函数 this.onChunk(parsed);
window.Utils = { }
escapeHtml, } catch (error) {
scrollToBottom, console.warn('解析流数据失败:', error, line);
generateId, }
parseSVGResponse, }
downloadFile, }
showStatus, }
storage, }
debounce,
throttle, // 创建流式请求
formatDateTime, function createStreamRequest(url, options, onChunk, onComplete) {
deepClone, const processor = new StreamProcessor(onChunk, onComplete);
isEmpty, const controller = new AbortController();
autoResizeTextarea,
StreamProcessor, const fetchPromise = (async () => {
createStreamRequest 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}`);
}
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;
}
})();
return {
cancel: () => controller.abort(),
finished: fetchPromise
};
}
// 导出工具函数
window.Utils = {
escapeHtml,
scrollToBottom,
generateId,
parseSVGResponse,
downloadFile,
showStatus,
storage,
debounce,
throttle,
formatDateTime,
deepClone,
isEmpty,
autoResizeTextarea,
StreamProcessor,
createStreamRequest
};

View File

@@ -3,7 +3,11 @@
请用中文回复并在回复中包含SVG格式的产品画布图表。 请用中文回复并在回复中包含SVG格式的产品画布图表。
产品精益画布助手下面是SVG画布的模板注意使用markdown格式回复 产品精益画布助手下面是SVG画布的模板注意使用markdown格式回复
- 解决方案、门槛优势、关键指标、渠道 文字不要超过7行
- 成本分析、收入分析 文字不要超过6行
``` ```
<svg width="900" height="550" viewBox="0 0 900 550" xmlns="http://www.w3.org/2000/svg" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif"> <svg width="900" height="550" viewBox="0 0 900 550" xmlns="http://www.w3.org/2000/svg" font-family="'PingFang SC', 'Microsoft YaHei', sans-serif">
<defs> <defs>
@@ -71,12 +75,6 @@
<g transform="translate(75, 20)"> <g transform="translate(75, 20)">
<text class="title" fill="#f57c00">独特卖点</text> <text class="title" fill="#f57c00">独特卖点</text>
</g> </g>
<g transform="translate(75, 60)">
<text class="desc" style="font-size: 14px; font-weight: bold;" fill="#f57c00">
<tspan x="0" dy="0">微信扫一扫,</tspan>
<tspan x="0" dy="20">老少皆宜智能回收</tspan>
</text>
</g>
<text x="10" y="120" class="content-bold">对用户价值:</text> <text x="10" y="120" class="content-bold">对用户价值:</text>
<text x="10" y="132" class="content">• 扫码即用,操作超简单</text> <text x="10" y="132" class="content">• 扫码即用,操作超简单</text>
<text x="10" y="144" class="content">• 价格透明,立即到账</text> <text x="10" y="144" class="content">• 价格透明,立即到账</text>