- js/app.js:165-260, 310-420, 560-893:重构模式切换与流式渲染逻辑;引入 renderSvgViewerForMode 分离各

模式历史与SVG;通过 buildActionToolbar 和 renderConversationHistory 仅为最新 AI 气泡保留“重新生成”;
    新增 setSendButtonState、startStreamingMessage、cancelActiveStream 实现终止按钮与流式中断,回滚后同
    步复位查看面板。
  - js/utils.js:24-334:增强 parseSVGResponse 容错截断SVG/反引号;StreamProcessor 增加完成态管理;
    createStreamRequest 返回 {cancel, finished} 并支持 AbortController,保证中止时回调依然收尾。
  - js/apiclient.js:150-208:流式接口返回新的句柄对象而非简单等待,使前端可触发中止,同时保持异常转换。
  - css/style.css:220-224:去除悬浮显隐,操作按钮常显,并保留色彩过渡以提示交互。
  - js/app.js:900-970:regenerateMessage 改用“处理中”状态并重绘气泡(按最新可见状态),避免历史重复
    生成。
This commit is contained in:
史悦
2025-10-24 19:56:40 +08:00
parent 06e1d5ca19
commit cbf59e3450
4 changed files with 1838 additions and 1760 deletions

View File

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

1747
js/app.js

File diff suppressed because it is too large Load Diff

View File

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