@@ -1740,6 +2014,60 @@
// Initialize Lucide icons
lucide.createIcons();
+ // ===== Bot Manager =====
+ class BotManager {
+ constructor() {
+ this.bots = [];
+ this.currentBot = null;
+ this.loadBots();
+ }
+
+ loadBots() {
+ const stored = localStorage.getItem('bot-list');
+ if (stored) {
+ try {
+ this.bots = JSON.parse(stored);
+ } catch (e) {
+ this.bots = [];
+ }
+ }
+ }
+
+ saveBots() {
+ localStorage.setItem('bot-list', JSON.stringify(this.bots));
+ }
+
+ getBotById(internalId) {
+ return this.bots.find(b => b.id === internalId);
+ }
+
+ setCurrentBot(internalId) {
+ this.currentBot = this.getBotById(internalId);
+ if (this.currentBot) {
+ sessionStorage.setItem('current-bot-id', internalId);
+ }
+ return this.currentBot;
+ }
+
+ getCurrentBot() {
+ if (!this.currentBot) {
+ const savedId = sessionStorage.getItem('current-bot-id');
+ if (savedId) {
+ this.currentBot = this.getBotById(savedId);
+ }
+ }
+ return this.currentBot;
+ }
+
+ getBotSettingsKey() {
+ const bot = this.getCurrentBot();
+ return bot ? `bot-settings-${bot.botId}` : null;
+ }
+ }
+
+ // Initialize Bot Manager
+ const botManager = new BotManager();
+
class ChatApp {
constructor() {
this.elements = {
@@ -1765,6 +2093,15 @@
customApiDomainGroup: document.getElementById('custom-api-domain-group'),
skillsContainer: document.getElementById('skills-container'),
refreshSkillsBtn: document.getElementById('refresh-skills-btn'),
+ // Bot Selector
+ botSelector: document.getElementById('bot-selector'),
+ botSelectorTrigger: document.getElementById('bot-selector-trigger'),
+ botSelectorCurrent: document.getElementById('bot-selector-current'),
+ botSelectorDropdown: document.getElementById('bot-selector-dropdown'),
+ // Bot Selection Modal
+ botSelectionModal: document.getElementById('bot-selection-modal'),
+ goToManager: document.getElementById('go-to-manager'),
+ selectExistingBot: document.getElementById('select-existing-bot'),
// MCP Manager
mcpManagerBtn: document.getElementById('open-mcp-manager-btn'),
mcpManagerModal: document.getElementById('mcp-manager-modal'),
@@ -1781,6 +2118,8 @@
mcpEditTitle: document.getElementById('mcp-edit-title')
};
+ this.currentBot = null;
+
// 从 localStorage 加载自定义 MCP 服务器
this.customMcpServers = this.loadCustomMcpServers();
@@ -1801,6 +2140,30 @@
}
initializeEventListeners() {
+ // Bot selector
+ this.elements.botSelectorTrigger.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.toggleBotDropdown();
+ });
+
+ document.addEventListener('click', () => {
+ this.elements.botSelectorDropdown.classList.remove('show');
+ this.elements.botSelectorTrigger.classList.remove('active');
+ });
+
+ this.elements.botSelectorDropdown.addEventListener('click', (e) => {
+ e.stopPropagation();
+ });
+
+ this.elements.goToManager.addEventListener('click', () => {
+ window.location.href = 'bot-manager.html';
+ });
+
+ this.elements.selectExistingBot.addEventListener('click', () => {
+ this.closeBotSelectionModal();
+ this.toggleBotDropdown();
+ });
+
// Sidebar toggle
this.elements.sidebarToggle.addEventListener('click', () => this.toggleSidebar());
this.elements.sidebarCloseBtn.addEventListener('click', () => this.closeSidebar());
@@ -1866,6 +2229,13 @@
if (e.target === this.elements.mcpEditModal) this.closeMcpEditModal();
});
+ // Import/Export Settings
+ document.getElementById('export-settings-btn').addEventListener('click', () => this.exportSettings());
+ document.getElementById('import-settings-btn').addEventListener('click', () => {
+ document.getElementById('import-file-input').click();
+ });
+ document.getElementById('import-file-input').addEventListener('change', (e) => this.importSettings(e));
+
// Suggestion chips
document.querySelectorAll('.suggestion-chip').forEach(chip => {
chip.addEventListener('click', () => {
@@ -1881,6 +2251,11 @@
}
initializeSettingsTabs() {
+ // 初始化 bot 选择器
+ this.initializeBotSelector();
+ // 初始化模型选择器
+ this.initializeModelSelector();
+
// 标签页切换
document.querySelectorAll('.settings-tab').forEach(tab => {
tab.addEventListener('click', () => {
@@ -1903,8 +2278,233 @@
});
}
+ // ===== Model Selector Methods =====
+ initializeModelSelector() {
+ const modelSelect = document.getElementById('model-select');
+ if (!modelSelect) return;
+
+ // 加载模型列表
+ this.loadModelList();
+
+ // 监听模型选择变化
+ modelSelect.addEventListener('change', () => {
+ this.onModelChange();
+ });
+ }
+
+ loadModelList() {
+ const modelSelect = document.getElementById('model-select');
+ if (!modelSelect) return;
+
+ const stored = localStorage.getItem('model-list');
+ const models = stored ? JSON.parse(stored) : [];
+
+ // 保存第一个选项
+ const firstOption = modelSelect.firstElementChild;
+ modelSelect.innerHTML = '';
+ modelSelect.appendChild(firstOption);
+
+ models.forEach(model => {
+ const option = document.createElement('option');
+ option.value = model.id;
+ option.textContent = model.name;
+ if (model.isDefault) {
+ option.textContent += ' (默认)';
+ }
+ modelSelect.appendChild(option);
+ });
+ }
+
+ onModelChange() {
+ const modelSelect = document.getElementById('model-select');
+ const selectedId = modelSelect.value;
+
+ if (!selectedId) {
+ // 清空隐藏字段
+ document.getElementById('model').value = '';
+ document.getElementById('api-key').value = '';
+ document.getElementById('model-server').value = '';
+ // 隐藏详情
+ document.getElementById('model-detail-group').style.display = 'none';
+ return;
+ }
+
+ // 从 model-list 获取模型配置
+ const stored = localStorage.getItem('model-list');
+ const models = stored ? JSON.parse(stored) : [];
+ const selectedModel = models.find(m => m.id === selectedId);
+
+ if (selectedModel) {
+ // 填充隐藏字段
+ document.getElementById('model').value = selectedModel.model;
+ document.getElementById('api-key').value = selectedModel.apiKey || '';
+ document.getElementById('model-server').value = selectedModel.server || '';
+
+ // 显示详情
+ const detailGroup = document.getElementById('model-detail-group');
+ detailGroup.style.display = 'block';
+ document.getElementById('detail-server').textContent = selectedModel.server || '默认';
+ document.getElementById('detail-apikey').textContent = this.maskApiKey(selectedModel.apiKey);
+ document.getElementById('detail-provider').textContent = selectedModel.provider || '自定义';
+ }
+
+ // 保存设置
+ this.saveSettings();
+ }
+
+ maskApiKey(key) {
+ if (!key) return '未设置';
+ if (key.length <= 8) return '*'.repeat(key.length);
+ return key.substring(0, 4) + '*'.repeat(key.length - 8) + key.substring(key.length - 4);
+ }
+
+ restoreModelSelection() {
+ const modelSelect = document.getElementById('model-select');
+ const currentModel = document.getElementById('model').value;
+
+ if (!currentModel) {
+ // 尝试选择默认模型
+ const stored = localStorage.getItem('model-list');
+ const models = stored ? JSON.parse(stored) : [];
+ const defaultModel = models.find(m => m.isDefault);
+
+ if (defaultModel) {
+ modelSelect.value = defaultModel.id;
+ this.onModelChange();
+ }
+ return;
+ }
+
+ // 根据当前 model 值查找对应的模型
+ const stored = localStorage.getItem('model-list');
+ const models = stored ? JSON.parse(stored) : [];
+ const matchedModel = models.find(m => m.model === currentModel);
+
+ if (matchedModel) {
+ modelSelect.value = matchedModel.id;
+ this.onModelChange();
+ }
+ }
+
+ // ===== End Model Selector Methods =====
+
+ // ===== Bot Selector Methods =====
+ initializeBotSelector() {
+ const currentBot = botManager.getCurrentBot();
+
+ if (!currentBot) {
+ // 没有选中的 bot,检查是否有可用的 bot
+ if (botManager.bots.length === 0) {
+ // 没有 bot,显示选择模态框
+ this.showBotSelectionModal();
+ } else {
+ // 有 bot 但没有选中,自动选择第一个
+ this.selectBot(botManager.bots[0].id);
+ }
+ } else {
+ // 有选中的 bot
+ this.currentBot = currentBot;
+ this.updateBotSelector();
+ }
+
+ this.renderBotDropdown();
+ }
+
+ showBotSelectionModal() {
+ this.elements.botSelectionModal.classList.remove('hidden');
+ }
+
+ closeBotSelectionModal() {
+ this.elements.botSelectionModal.classList.add('hidden');
+ }
+
+ toggleBotDropdown() {
+ if (botManager.bots.length === 0) {
+ window.location.href = 'bot-manager.html';
+ return;
+ }
+
+ this.elements.botSelectorDropdown.classList.toggle('show');
+ this.elements.botSelectorTrigger.classList.toggle('active');
+ this.renderBotDropdown();
+ }
+
+ renderBotDropdown() {
+ const dropdown = this.elements.botSelectorDropdown;
+ dropdown.innerHTML = '';
+
+ botManager.bots.forEach(bot => {
+ const item = document.createElement('div');
+ item.className = 'bot-selector-item';
+ if (this.currentBot && this.currentBot.id === bot.id) {
+ item.classList.add('selected');
+ }
+
+ item.innerHTML = `
+
+
+
+
+
${this.escapeHtml(bot.name)}
+
${this.escapeHtml(bot.botId)}
+
+ `;
+
+ item.addEventListener('click', () => this.selectBot(bot.id));
+ dropdown.appendChild(item);
+ });
+
+ // 添加"管理 Bots"选项
+ const manageItem = document.createElement('div');
+ manageItem.className = 'bot-selector-manage';
+ manageItem.innerHTML = `
+
+ 管理 Bots
+ `;
+ manageItem.addEventListener('click', () => {
+ window.location.href = 'bot-manager.html';
+ });
+ dropdown.appendChild(manageItem);
+
+ lucide.createIcons();
+ }
+
+ selectBot(botInternalId) {
+ const bot = botManager.setCurrentBot(botInternalId);
+ if (bot) {
+ this.currentBot = bot;
+ this.updateBotSelector();
+ // 重新加载 MCP 服务器配置
+ this.customMcpServers = this.loadCustomMcpServers();
+ this.loadSettings();
+ // 清空当前消息和会话
+ this.messages = [];
+ this.currentSessionId = null;
+ // 重新加载聊天历史
+ this.loadChatHistory();
+ // 清空聊天容器并显示欢迎界面
+ this.elements.chatContainer.innerHTML = '';
+ this.elements.chatContainer.appendChild(this.elements.welcomeScreen);
+ this.elements.welcomeScreen.style.display = 'flex';
+ }
+ }
+
+ updateBotSelector() {
+ if (this.currentBot) {
+ this.elements.botSelectorCurrent.textContent = this.currentBot.name;
+ } else {
+ this.elements.botSelectorCurrent.textContent = '选择 Bot...';
+ }
+ }
+
+ // ===== End Bot Selector Methods =====
+
loadCustomMcpServers() {
- const stored = localStorage.getItem('custom-mcp-servers');
+ const settingsKey = botManager.getBotSettingsKey();
+ if (!settingsKey) return [];
+
+ const mcpKey = `${settingsKey}-mcp`;
+ const stored = localStorage.getItem(mcpKey);
if (stored) {
try {
return JSON.parse(stored);
@@ -1917,7 +2517,11 @@
}
saveCustomMcpServers() {
- localStorage.setItem('custom-mcp-servers', JSON.stringify(this.customMcpServers));
+ const settingsKey = botManager.getBotSettingsKey();
+ if (!settingsKey) return;
+
+ const mcpKey = `${settingsKey}-mcp`;
+ localStorage.setItem(mcpKey, JSON.stringify(this.customMcpServers));
this.updateMcpCount();
}
@@ -2143,6 +2747,9 @@
this.elements.settingsModal.classList.add('active');
// 打开设置时加载技能列表
this.loadSkills();
+ // 重新加载模型列表并恢复选择
+ this.loadModelList();
+ this.restoreModelSelection();
}
closeSettings() {
@@ -2155,13 +2762,16 @@
}
loadSettings() {
- const settings = localStorage.getItem('chat-settings');
+ const settingsKey = botManager.getBotSettingsKey();
+ if (!settingsKey) return;
+
+ const settings = localStorage.getItem(settingsKey);
if (settings) {
try {
const parsed = JSON.parse(settings);
Object.keys(parsed).forEach(key => {
- // Skip session-id as it's now managed per conversation
- if (key === 'session-id') return;
+ // Skip session-id, model, api-key, model-server as they are managed separately
+ if (['session-id', 'model', 'api-key', 'model-server'].includes(key)) return;
const element = document.getElementById(key);
if (element) {
@@ -2178,10 +2788,15 @@
this.elements.customApiDomainGroup.style.display = 'block';
}
- // Clean up old session-id from settings
- if (parsed['session-id']) {
- delete parsed['session-id'];
- localStorage.setItem('chat-settings', JSON.stringify(parsed));
+ // 恢复模型相关的字段(从模型列表中读取)
+ if (parsed['model']) {
+ document.getElementById('model').value = parsed['model'];
+ }
+ if (parsed['api-key']) {
+ document.getElementById('api-key').value = parsed['api-key'];
+ }
+ if (parsed['model-server']) {
+ document.getElementById('model-server').value = parsed['model-server'];
}
} catch (e) {
console.error('Failed to load settings:', e);
@@ -2193,6 +2808,9 @@
}
saveSettings() {
+ const settingsKey = botManager.getBotSettingsKey();
+ if (!settingsKey) return;
+
// 获取选中的技能
const selectedSkills = [];
document.querySelectorAll('.skill-item input:checked').forEach(checkbox => {
@@ -2219,7 +2837,6 @@
'model-server': document.getElementById('model-server').value,
'api-domain': document.getElementById('api-domain').value,
'custom-api-domain': document.getElementById('custom-api-domain').value,
- 'bot-id': document.getElementById('bot-id').value,
'language': document.getElementById('language').value,
'robot-type': document.getElementById('robot-type').value,
'dataset-ids': document.getElementById('dataset-ids').value,
@@ -2229,12 +2846,20 @@
'mcp-settings': mcpSettingsValue,
'tool-response': document.getElementById('tool-response').checked
};
- localStorage.setItem('chat-settings', JSON.stringify(settings));
+ localStorage.setItem(settingsKey, JSON.stringify(settings));
}
async loadSkills() {
const container = this.elements.skillsContainer;
- const botId = document.getElementById('bot-id')?.value || 'test';
+ // 获取当前选中的 bot,使用其 botId(用户创建时填写的 bot_id)
+ const currentBot = botManager.getCurrentBot();
+ const botId = currentBot?.botId || '';
+
+ // 如果没有 botId,显示提示而不是使用 'test'
+ if (!botId) {
+ container.innerHTML = '
请先选择一个 Bot
';
+ return;
+ }
container.innerHTML = '
加载中...
';
@@ -2296,7 +2921,11 @@
}
loadChatHistory() {
- const historyData = localStorage.getItem('chat-sessions');
+ const settingsKey = botManager.getBotSettingsKey();
+ if (!settingsKey) return;
+
+ const historyKey = `${settingsKey}-sessions`;
+ const historyData = localStorage.getItem(historyKey);
if (historyData) {
try {
this.allSessions = JSON.parse(historyData);
@@ -2327,6 +2956,11 @@
saveCurrentSession() {
if (!this.currentSessionId) return;
+ const settingsKey = botManager.getBotSettingsKey();
+ if (!settingsKey) return;
+
+ const historyKey = `${settingsKey}-sessions`;
+
const sessionData = this.getSessionById(this.currentSessionId);
if (sessionData) {
sessionData.messages = this.messages;
@@ -2350,7 +2984,7 @@
this.allSessions.unshift(newSession);
}
- localStorage.setItem('chat-sessions', JSON.stringify(this.allSessions));
+ localStorage.setItem(historyKey, JSON.stringify(this.allSessions));
}
loadSession(sessionId) {
@@ -2387,8 +3021,13 @@
deleteSession(sessionId, event) {
if (event) event.stopPropagation();
+ const settingsKey = botManager.getBotSettingsKey();
+ if (!settingsKey) return;
+
+ const historyKey = `${settingsKey}-sessions`;
+
this.allSessions = this.allSessions.filter(s => s.id !== sessionId);
- localStorage.setItem('chat-sessions', JSON.stringify(this.allSessions));
+ localStorage.setItem(historyKey, JSON.stringify(this.allSessions));
if (this.currentSessionId === sessionId) {
if (this.allSessions.length > 0) {
@@ -2589,7 +3228,7 @@
model: getValue('model'),
apiKey: getValue('api-key'),
modelServer: getValue('model-server'),
- botId: getValue('bot-id'),
+ botId: this.currentBot?.botId || '',
language: getValue('language', 'zh-CN'),
robotType: getValue('robot-type'),
datasetIds,
@@ -2610,6 +3249,93 @@
});
}
+ exportSettings() {
+ const exportData = {
+ version: '1.0',
+ exportDate: new Date().toISOString(),
+ botList: JSON.parse(localStorage.getItem('bot-list') || '[]'),
+ modelList: JSON.parse(localStorage.getItem('model-list') || '[]'),
+ theme: localStorage.getItem('theme'),
+ botSettings: {},
+ botMcpServers: {},
+ botSessions: {}
+ };
+
+ // Collect per-bot data
+ const bots = exportData.botList;
+ bots.forEach(bot => {
+ const settingsKey = `bot-settings-${bot.botId}`;
+ exportData.botSettings[bot.botId] = localStorage.getItem(settingsKey);
+ exportData.botMcpServers[bot.botId] = localStorage.getItem(`${settingsKey}-mcp`);
+ exportData.botSessions[bot.botId] = localStorage.getItem(`${settingsKey}-sessions`);
+ });
+
+ // Download as JSON file
+ const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `ai-chat-backup-${new Date().toISOString().split('T')[0]}.json`;
+ a.click();
+ URL.revokeObjectURL(url);
+ }
+
+ importSettings(event) {
+ const file = event.target.files[0];
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ try {
+ const data = JSON.parse(e.target.result);
+
+ // Validate structure
+ if (!data.version || !Array.isArray(data.botList) || !Array.isArray(data.modelList)) {
+ alert('无效的配置文件格式');
+ return;
+ }
+
+ // Confirm import
+ const botCount = data.botList.length;
+ const modelCount = data.modelList.length;
+ if (!confirm(`确定要导入配置吗?这将覆盖现有数据。\n\n包含 ${botCount} 个机器人,${modelCount} 个模型配置`)) {
+ return;
+ }
+
+ // Restore global configurations
+ localStorage.setItem('bot-list', JSON.stringify(data.botList));
+ localStorage.setItem('model-list', JSON.stringify(data.modelList));
+ if (data.theme) {
+ localStorage.setItem('theme', data.theme);
+ }
+
+ // Restore per-bot data
+ if (data.botSettings) {
+ Object.keys(data.botSettings).forEach(botId => {
+ if (data.botSettings[botId]) {
+ localStorage.setItem(`bot-settings-${botId}`, data.botSettings[botId]);
+ }
+ if (data.botMcpServers && data.botMcpServers[botId]) {
+ localStorage.setItem(`bot-settings-${botId}-mcp`, data.botMcpServers[botId]);
+ }
+ if (data.botSessions && data.botSessions[botId]) {
+ localStorage.setItem(`bot-settings-${botId}-sessions`, data.botSessions[botId]);
+ }
+ });
+ }
+
+ alert('配置导入成功!页面将重新加载。');
+ location.reload();
+ } catch (err) {
+ alert('导入失败:' + err.message);
+ }
+ };
+ reader.readAsText(file);
+
+ // Reset file input
+ event.target.value = '';
+ }
+
updateStatus(status, text) {
this.elements.statusDot.className = 'status-dot';
if (status) this.elements.statusDot.classList.add(status);
diff --git a/public/model-manager.html b/public/model-manager.html
new file mode 100644
index 0000000..4ec77bb
--- /dev/null
+++ b/public/model-manager.html
@@ -0,0 +1,1070 @@
+
+
+
+
+
+
模型管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 模型名称 *
+
+
+
+ 服务提供商
+
+ OpenAI
+ Anthropic
+ 通义千问
+ DeepSeek
+ OpenRouter
+ 其他
+
+
+
+
+
+
+
+
+ 设为默认模型
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
确认删除
+
确定要删除这个模型配置吗?此操作无法撤销。
+
+
+
+
+
+
+
+
+
diff --git a/routes/chat.py b/routes/chat.py
index aa5e178..e69dc69 100644
--- a/routes/chat.py
+++ b/routes/chat.py
@@ -76,7 +76,7 @@ async def enhanced_generate_stream_response(
if isinstance(msg, AIMessageChunk):
# 处理工具调用
- if msg.tool_call_chunks:
+ if msg.tool_call_chunks and config.tool_response:
message_tag = "TOOL_CALL"
for tool_call_chunk in msg.tool_call_chunks:
if tool_call_chunk["name"]:
@@ -222,7 +222,7 @@ async def create_agent_and_generate_response(
meta_message_tag = msg.additional_kwargs.get("message_tag", "ANSWER")
output_text = msg.text.replace("````","").replace("````","") if meta_message_tag == "THINK" else msg.text
response_text += f"[{meta_message_tag}]\n"+output_text+ "\n"
- if len(msg.tool_calls)>0:
+ if len(msg.tool_calls)>0 and config.tool_response:
response_text += "".join([f"[TOOL_CALL] {tool['name']}\n{json.dumps(tool["args"]) if isinstance(tool["args"],dict) else tool["args"]}\n" for tool in msg.tool_calls])
elif isinstance(msg,ToolMessage) and config.tool_response:
response_text += f"[TOOL_RESPONSE] {msg.name}\n{msg.text}\n"
diff --git a/routes/skill_manager.py b/routes/skill_manager.py
index b955d01..938db8d 100644
--- a/routes/skill_manager.py
+++ b/routes/skill_manager.py
@@ -83,6 +83,45 @@ async def validate_upload_file_size(file: UploadFile) -> int:
return file_size
+def detect_zip_has_top_level_dirs(zip_path: str) -> bool:
+ """检测 zip 文件是否包含顶级目录(而非直接包含文件)
+
+ Args:
+ zip_path: zip 文件路径
+
+ Returns:
+ bool: 如果 zip 包含顶级目录则返回 True
+ """
+ try:
+ with zipfile.ZipFile(zip_path, 'r') as zip_ref:
+ # 获取所有顶级路径(第一层目录/文件)
+ top_level_paths = set()
+ for name in zip_ref.namelist():
+ # 跳过空目录项(以 / 结尾的空路径)
+ if not name or name == '/':
+ continue
+ # 提取顶级路径(第一层)
+ parts = name.split('/')
+ if parts[0]: # 忽略空字符串
+ top_level_paths.add(parts[0])
+
+ logger.info(f"Zip top-level paths: {top_level_paths}")
+
+ # 检查是否有目录(目录项以 / 结尾,或路径中包含 /)
+ for path in top_level_paths:
+ # 如果路径中包含 /,说明是目录
+ # 或者检查 namelist 中是否有以该路径/ 开头的项
+ for full_name in zip_ref.namelist():
+ if full_name.startswith(f"{path}/"):
+ return True
+
+ return False
+
+ except Exception as e:
+ logger.warning(f"Error detecting zip structure: {e}")
+ return False
+
+
async def safe_extract_zip(zip_path: str, extract_dir: str) -> None:
"""安全地解压 zip 文件,防止 ZipSlip 和 zip 炸弹攻击
@@ -380,12 +419,8 @@ async def upload_skill(file: UploadFile = File(...), bot_id: Optional[str] = For
folder_name = name_without_ext
- # 创建上传目录
+ # 创建上传目录(先保存 zip 文件)
upload_dir = os.path.join("projects", "uploads", bot_id, "skill_zip")
- extract_target = os.path.join("projects", "uploads", bot_id, "skills", folder_name)
-
- # 使用线程池避免阻塞
- await asyncio.to_thread(os.makedirs, extract_target, exist_ok=True)
await asyncio.to_thread(os.makedirs, upload_dir, exist_ok=True)
# 保存zip文件路径
@@ -395,6 +430,25 @@ async def upload_skill(file: UploadFile = File(...), bot_id: Optional[str] = For
await save_upload_file_async(file, file_path)
logger.info(f"Saved zip file: {file_path}")
+ # 检测 zip 文件结构:是否包含顶级目录
+ has_top_level_dirs = await asyncio.to_thread(
+ detect_zip_has_top_level_dirs, file_path
+ )
+ logger.info(f"Zip contains top-level directories: {has_top_level_dirs}")
+
+ # 根据检测结果决定解压目标目录
+ if has_top_level_dirs:
+ # zip 包含目录(如 a-skill/, b-skill/),解压到 skills/ 目录
+ extract_target = os.path.join("projects", "uploads", bot_id, "skills")
+ logger.info(f"Detected directories in zip, extracting to: {extract_target}")
+ else:
+ # zip 直接包含文件,解压到 skills/{folder_name}/ 目录
+ extract_target = os.path.join("projects", "uploads", bot_id, "skills", folder_name)
+ logger.info(f"No directories in zip, extracting to: {extract_target}")
+
+ # 使用线程池避免阻塞
+ await asyncio.to_thread(os.makedirs, extract_target, exist_ok=True)
+
# P1-001, P1-005: 安全解压(防止 ZipSlip 和 zip 炸弹)
await safe_extract_zip(file_path, extract_target)
logger.info(f"Extracted to: {extract_target}")
diff --git a/skills/excel-analysis/SKILL.md b/skills/excel-analysis/SKILL.md
new file mode 100644
index 0000000..7c9b4a1
--- /dev/null
+++ b/skills/excel-analysis/SKILL.md
@@ -0,0 +1,247 @@
+---
+name: excel-analysis
+description: Analyze Excel spreadsheets, create pivot tables, generate charts, and perform data analysis. Use when analyzing Excel files, spreadsheets, tabular data, or .xlsx files.
+---
+
+# Excel Analysis
+
+## Quick start
+
+Read Excel files with pandas:
+
+```python
+import pandas as pd
+
+# Read Excel file
+df = pd.read_excel("data.xlsx", sheet_name="Sheet1")
+
+# Display first few rows
+print(df.head())
+
+# Basic statistics
+print(df.describe())
+```
+
+## Reading multiple sheets
+
+Process all sheets in a workbook:
+
+```python
+import pandas as pd
+
+# Read all sheets
+excel_file = pd.ExcelFile("workbook.xlsx")
+
+for sheet_name in excel_file.sheet_names:
+ df = pd.read_excel(excel_file, sheet_name=sheet_name)
+ print(f"\n{sheet_name}:")
+ print(df.head())
+```
+
+## Data analysis
+
+Perform common analysis tasks:
+
+```python
+import pandas as pd
+
+df = pd.read_excel("sales.xlsx")
+
+# Group by and aggregate
+sales_by_region = df.groupby("region")["sales"].sum()
+print(sales_by_region)
+
+# Filter data
+high_sales = df[df["sales"] > 10000]
+
+# Calculate metrics
+df["profit_margin"] = (df["revenue"] - df["cost"]) / df["revenue"]
+
+# Sort by column
+df_sorted = df.sort_values("sales", ascending=False)
+```
+
+## Creating Excel files
+
+Write data to Excel with formatting:
+
+```python
+import pandas as pd
+
+df = pd.DataFrame({
+ "Product": ["A", "B", "C"],
+ "Sales": [100, 200, 150],
+ "Profit": [20, 40, 30]
+})
+
+# Write to Excel
+writer = pd.ExcelWriter("output.xlsx", engine="openpyxl")
+df.to_excel(writer, sheet_name="Sales", index=False)
+
+# Get worksheet for formatting
+worksheet = writer.sheets["Sales"]
+
+# Auto-adjust column widths
+for column in worksheet.columns:
+ max_length = 0
+ column_letter = column[0].column_letter
+ for cell in column:
+ if len(str(cell.value)) > max_length:
+ max_length = len(str(cell.value))
+ worksheet.column_dimensions[column_letter].width = max_length + 2
+
+writer.close()
+```
+
+## Pivot tables
+
+Create pivot tables programmatically:
+
+```python
+import pandas as pd
+
+df = pd.read_excel("sales_data.xlsx")
+
+# Create pivot table
+pivot = pd.pivot_table(
+ df,
+ values="sales",
+ index="region",
+ columns="product",
+ aggfunc="sum",
+ fill_value=0
+)
+
+print(pivot)
+
+# Save pivot table
+pivot.to_excel("pivot_report.xlsx")
+```
+
+## Charts and visualization
+
+Generate charts from Excel data:
+
+```python
+import pandas as pd
+import matplotlib.pyplot as plt
+
+df = pd.read_excel("data.xlsx")
+
+# Create bar chart
+df.plot(x="category", y="value", kind="bar")
+plt.title("Sales by Category")
+plt.xlabel("Category")
+plt.ylabel("Sales")
+plt.tight_layout()
+plt.savefig("chart.png")
+
+# Create pie chart
+df.set_index("category")["value"].plot(kind="pie", autopct="%1.1f%%")
+plt.title("Market Share")
+plt.ylabel("")
+plt.savefig("pie_chart.png")
+```
+
+## Data cleaning
+
+Clean and prepare Excel data:
+
+```python
+import pandas as pd
+
+df = pd.read_excel("messy_data.xlsx")
+
+# Remove duplicates
+df = df.drop_duplicates()
+
+# Handle missing values
+df = df.fillna(0) # or df.dropna()
+
+# Remove whitespace
+df["name"] = df["name"].str.strip()
+
+# Convert data types
+df["date"] = pd.to_datetime(df["date"])
+df["amount"] = pd.to_numeric(df["amount"], errors="coerce")
+
+# Save cleaned data
+df.to_excel("cleaned_data.xlsx", index=False)
+```
+
+## Merging and joining
+
+Combine multiple Excel files:
+
+```python
+import pandas as pd
+
+# Read multiple files
+df1 = pd.read_excel("sales_q1.xlsx")
+df2 = pd.read_excel("sales_q2.xlsx")
+
+# Concatenate vertically
+combined = pd.concat([df1, df2], ignore_index=True)
+
+# Merge on common column
+customers = pd.read_excel("customers.xlsx")
+sales = pd.read_excel("sales.xlsx")
+
+merged = pd.merge(sales, customers, on="customer_id", how="left")
+
+merged.to_excel("merged_data.xlsx", index=False)
+```
+
+## Advanced formatting
+
+Apply conditional formatting and styles:
+
+```python
+import pandas as pd
+from openpyxl import load_workbook
+from openpyxl.styles import PatternFill, Font
+
+# Create Excel file
+df = pd.DataFrame({
+ "Product": ["A", "B", "C"],
+ "Sales": [100, 200, 150]
+})
+
+df.to_excel("formatted.xlsx", index=False)
+
+# Load workbook for formatting
+wb = load_workbook("formatted.xlsx")
+ws = wb.active
+
+# Apply conditional formatting
+red_fill = PatternFill(start_color="FF0000", end_color="FF0000", fill_type="solid")
+green_fill = PatternFill(start_color="00FF00", end_color="00FF00", fill_type="solid")
+
+for row in range(2, len(df) + 2):
+ cell = ws[f"B{row}"]
+ if cell.value < 150:
+ cell.fill = red_fill
+ else:
+ cell.fill = green_fill
+
+# Bold headers
+for cell in ws[1]:
+ cell.font = Font(bold=True)
+
+wb.save("formatted.xlsx")
+```
+
+## Performance tips
+
+- Use `read_excel` with `usecols` to read specific columns only
+- Use `chunksize` for very large files
+- Consider using `engine='openpyxl'` or `engine='xlrd'` based on file type
+- Use `dtype` parameter to specify column types for faster reading
+
+## Available packages
+
+- **pandas** - Data analysis and manipulation (primary)
+- **openpyxl** - Excel file creation and formatting
+- **xlrd** - Reading older .xls files
+- **xlsxwriter** - Advanced Excel writing capabilities
+- **matplotlib** - Chart generation
diff --git a/skills/managing-scripts/SKILL.md b/skills/managing-scripts/SKILL.md
new file mode 100644
index 0000000..b102b2c
--- /dev/null
+++ b/skills/managing-scripts/SKILL.md
@@ -0,0 +1,231 @@
+---
+name: managing-scripts
+description: Manages shared scripts repository for reusable data analysis tools. Check scripts/README.md before writing, design generalized scripts with parameters, and keep documentation in sync.
+---
+
+# Managing Scripts
+
+管理可复用的数据分析脚本资源库,通过通用化设计最大化脚本复用价值。
+
+## Quick Start
+
+编写数据分析脚本时的通用化流程:
+1. 读取 `./scripts/README.md` 检查是否有可复用的现成脚本
+2. 如有合适脚本,优先复用
+3. 如需编写新脚本,**设计通用化方案**而非解决单一问题
+4. 保存到 `./scripts/` 并更新 README
+
+## Instructions
+
+### 使用前检查
+
+当用户请求任何数据处理/分析任务时:
+
+1. 检查 `./scripts/README.md` 是否存在
+2. 查找可处理**此类问题**的脚本(非完全匹配即可)
+3. 现有脚本可通过参数调整满足需求时,优先复用
+4. 告知用户使用的是现成脚本
+
+### 编写通用化脚本
+
+核心原则:**解决一类问题,而非单一问题**
+
+#### 1. 识别问题模式
+
+在编写脚本前,分析当前请求属于哪类通用模式:
+
+| 问题类型 | 通用模式 | 可参数化项 |
+|---------|---------|-----------|
+| 数据转换 | 格式A → 格式B | 输入文件、输出格式、字段映射 |
+| 数据分析 | 统计/聚合/可视化 | 数据源、分析维度、输出类型 |
+| 数据清洗 | 去重/填充/过滤 | 规则配置、阈值参数 |
+| 文件操作 | 批量处理文件 | 文件路径、匹配模式、操作类型 |
+
+#### 2. 参数化设计
+
+将硬编码值改为可配置参数:
+
+```python
+# ❌ 不通用:硬编码特定字段
+def analyze_sales():
+ df = pd.read_excel("sales_data.xlsx")
+ result = df.groupby("region")["amount"].sum()
+
+# ✅ 通用化:参数化输入
+def analyze_data(input_file, group_by_column, aggregate_column, method="sum"):
+ """
+ 通用数据聚合分析
+ :param input_file: 输入文件路径
+ :param group_by_column: 分组列名
+ :param aggregate_column: 聚合列名
+ :param method: 聚合方法 (sum/mean/count/etc)
+ """
+ df = pd.read_excel(input_file)
+ return df.groupby(group_by_column)[aggregate_column].agg(method)
+```
+
+#### 3. 使用命令行参数
+
+```python
+import argparse
+
+def main():
+ parser = argparse.ArgumentParser(description="通用数据聚合工具")
+ parser.add_argument("--input", required=True, help="输入文件路径")
+ parser.add_argument("--output", help="输出文件路径")
+ parser.add_argument("--group-by", required=True, help="分组列名")
+ parser.add_argument("--agg-column", required=True, help="聚合列名")
+ parser.add_argument("--method", default="sum", help="聚合方法")
+ args = parser.parse_args()
+ # ... 处理逻辑
+```
+
+#### 4. 配置文件支持
+
+复杂逻辑使用配置文件:
+
+```yaml
+# config.yaml
+transformations:
+ - column: "date"
+ action: "parse_format"
+ params: {"format": "%Y-%m-%d"}
+ - column: "amount"
+ action: "fill_na"
+ params: {"value": 0}
+```
+
+### 保存新脚本
+
+脚本验证成功后:
+
+1. 使用**通用性强的命名**:
+ - ✅ `aggregate_data.py`、`convert_format.py`、`clean_dataset.py`
+ - ❌ `analyze_sales_2024.py`、`fix_import_error.py`
+
+2. 保存到 `./scripts/` 文件夹
+
+3. 在 `./scripts/README.md` 添加说明,包含:
+ - **通用功能描述**(描述解决的问题类型,非具体业务)
+ - 使用方法(含所有参数说明)
+ - 输入/输出格式
+ - 使用示例
+ - 依赖要求
+
+### 修改现有脚本
+
+修改 `./scripts/` 下的脚本时:
+
+1. 保持/增强通用性,避免收缩为特定用途
+2. 同步更新 README.md 文档
+3. 在变更日志中记录修改内容
+
+## Examples
+
+**场景:用户请求分析销售数据**
+
+```
+用户:帮我分析这个 Excel 文件,按地区统计销售额
+
+思考过程:
+1. 这是一个"数据聚合"问题,属于通用模式
+2. 检查 scripts/README.md 是否有聚合工具
+3. 如没有,创建通用聚合脚本 aggregate_data.py
+4. 支持参数:--input、--group-by、--agg-column、--method
+5. 用户调用:python scripts/aggregate_data.py \
+ --input data.xlsx --group-by region --agg-column amount
+```
+
+**通用脚本模板示例:**
+
+```python
+#!/usr/bin/env python3
+"""
+通用数据聚合工具
+支持按任意列分组,对任意列进行聚合统计
+"""
+
+import argparse
+import pandas as pd
+
+def aggregate_data(input_file, group_by, agg_column, method="sum", output=None):
+ """通用聚合函数"""
+ # 根据文件扩展名选择读取方法
+ ext = input_file.split(".")[-1]
+ read_func = getattr(pd, f"read_{ext}", pd.read_csv)
+ df = read_func(input_file)
+
+ # 执行聚合
+ result = df.groupby(group_by)[agg_column].agg(method)
+
+ # 输出
+ if output:
+ result.to_csv(output)
+ return result
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description="通用数据聚合工具")
+ parser.add_argument("--input", "-i", required=True, help="输入文件路径")
+ parser.add_argument("--group-by", "-g", required=True, help="分组列名")
+ parser.add_argument("--agg-column", "-a", required=True, help="聚合列名")
+ parser.add_argument("--method", "-m", default="sum",
+ choices=["sum", "mean", "count", "min", "max"],
+ help="聚合方法")
+ parser.add_argument("--output", "-o", help="输出文件路径")
+ args = parser.parse_args()
+
+ aggregate_data(args.input, args.group_by, args.agg_column,
+ args.method, args.output)
+```
+
+## Guidelines
+
+### 通用化设计原则
+
+- **抽象思维**:识别问题的本质模式,而非表面细节
+- **参数化一切**:任何可能变化的值都应可配置
+- **避免业务术语**:使用通用技术术语(如 "group_by" 而非 "region")
+- **支持扩展**:预留扩展点,便于未来增加新功能
+- **提供默认值**:合理默认值降低使用门槛
+
+### 命名规范
+
+| 类型 | 推荐 | 避免 |
+|-----|------|------|
+| 脚本名 | `aggregate_data.py` | `sales_analysis.py` |
+| 参数名 | `--group-by` | `--region` |
+| 函数名 | `transform_data()` | `fix_sales_format()` |
+
+### 文档规范
+
+README 条目应说明:
+- **解决哪类问题**(非具体业务场景)
+- **所有参数及默认值**
+- **支持的输入格式**
+- **使用示例**(至少2个不同场景)
+
+### 何时创建新脚本
+
+创建新脚本当:
+- 现有脚本无法通过参数调整满足需求
+- 问题属于新的通用模式
+- 预计该场景会重复出现
+
+### 何时修改现有脚本
+
+修改现有脚本当:
+- 增强通用性(添加新参数/配置)
+- 修复 bug 但不破坏现有接口
+- 扩展功能范围
+
+## Directory Structure
+
+```
+./scripts/
+├── README.md # 脚本目录和使用说明(必须存在)
+├── aggregate_data.py # 通用聚合工具
+├── convert_format.py # 格式转换工具
+├── clean_dataset.py # 数据清洗工具
+└── config/ # 可选:配置文件目录
+ └── templates.yaml
+```