diff --git a/.gitignore b/.gitignore index dd4fdeb..b88dc49 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ plugins.json itchat.pkl *.log logs/ +workspace +config.yaml user_datas.pkl chatgpt_tool_hub/ plugins/**/ @@ -31,4 +33,5 @@ plugins/banwords/lib/__pycache__ !plugins/role !plugins/keyword !plugins/linkai +!plugins/agent client_config.json diff --git a/channel/web/README.md b/channel/web/README.md index a4de6f0..b626985 100644 --- a/channel/web/README.md +++ b/channel/web/README.md @@ -1,7 +1,10 @@ -# Web channel -使用SSE(Server-Sent Events,服务器推送事件)实现,提供了一个默认的网页。也可以自己实现加入api +# Web Channel -#使用方法 -- 在配置文件中channel_type填入web即可 -- 访问地址 http://localhost:9899/chat -- port可以在配置项 web_port中设置 +提供了一个默认的AI对话页面,可展示文本、图片等消息交互,支持markdown语法渲染,兼容插件执行。 + +# 使用说明 + + - 在 `config.json` 配置文件中的 `channel_type` 字段填入 `web` + - 程序运行后将监听9899端口,浏览器访问 http://localhost:9899/chat 即可使用 + - 监听端口可以在配置文件 `web_port` 中自定义 + - 对于Docker运行方式,如果需要外部访问,需要在 `docker-compose.yml` 中通过 ports配置将端口监听映射到宿主机 diff --git a/channel/web/chat.html b/channel/web/chat.html index 1b1111c..50e81fa 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -6,7 +6,7 @@ AI Assistant - + @@ -14,6 +14,7 @@ + @@ -663,7 +755,7 @@ - +
AI Assistant Logo
AI 助手
+ + GitHub +
@@ -724,10 +819,30 @@ const newChatButton = document.getElementById('new-chat'); const chatHistory = document.getElementById('chat-history'); - // 简化变量,只保留用户ID - let userId = 'user_' + Math.random().toString(36).substring(2, 10); - let currentSessionId = 'default_session'; // 使用固定会话ID - + // 生成新的会话ID + function generateSessionId() { + return 'session_' + ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ); + } + + // 生成初始会话ID + let sessionId = generateSessionId(); + console.log('Session ID:', sessionId); + + // 添加一个变量来跟踪输入法状态 + let isComposing = false; + + // 监听输入法组合状态开始 + input.addEventListener('compositionstart', function() { + isComposing = true; + }); + + // 监听输入法组合状态结束 + input.addEventListener('compositionend', function() { + isComposing = false; + }); + // 自动调整文本区域高度 input.addEventListener('input', function() { this.style.height = 'auto'; @@ -748,15 +863,18 @@ }); // 处理菜单切换 - menuToggle.addEventListener('click', function() { + menuToggle.addEventListener('click', function(event) { + event.stopPropagation(); // 防止事件冒泡到 main-content sidebar.classList.toggle('active'); }); - // 处理新对话按钮 - 创建新的用户ID和清空当前对话 + // 处理新对话按钮 - 创建新的会话ID和清空当前对话 newChatButton.addEventListener('click', function() { - // 生成新的用户ID - userId = 'user_' + Math.random().toString(36).substring(2, 10); - console.log('New conversation started with user ID:', userId); + // 生成新的会话ID + sessionId = generateSessionId(); + // 将新的会话ID保存到全局变量,供轮询函数使用 + window.sessionId = sessionId; + console.log('New conversation started with new session ID:', sessionId); // 清空聊天记录 clearChat(); @@ -780,15 +898,19 @@ event.preventDefault(); } - // Enter 键发送消息 - else if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey) { + // Enter 键发送消息,但只在不是输入法组合状态时 + else if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !isComposing) { sendMessage(); event.preventDefault(); } }); + // 在发送消息函数前添加调试代码 + console.log('Axios loaded:', typeof axios !== 'undefined'); + // 发送消息函数 function sendMessage() { + console.log('Send message function called'); const userMessage = input.value.trim(); if (userMessage) { // 隐藏欢迎屏幕 @@ -810,34 +932,42 @@ input.style.height = '52px'; sendButton.disabled = true; - // 发送到服务器并等待响应 - fetch('/message', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - user_id: userId, + // 使用当前的全局会话ID + const currentSessionId = window.sessionId || sessionId; + + // 发送到服务器并获取请求ID + axios({ + method: 'post', + url: '/message', + data: { + session_id: currentSessionId, // 使用最新的会话ID message: userMessage, - timestamp: timestamp.toISOString(), - session_id: currentSessionId - }) + timestamp: timestamp.toISOString() + }, + timeout: 10000 // 10秒超时 }) .then(response => { - if (!response.ok) { - throw new Error('Failed to send message'); - } - return response.json(); - }) - .then(data => { - // 移除加载消息 - if (loadingContainer.parentNode) { - messagesDiv.removeChild(loadingContainer); - } - - // 添加AI回复 - if (data.reply) { - addBotMessage(data.reply, new Date()); + if (response.data.status === "success") { + // 保存当前请求ID,用于识别响应 + const currentRequestId = response.data.request_id; + + // 如果还没有开始轮询,则开始轮询 + if (!window.isPolling) { + startPolling(currentSessionId); + } + + // 将请求ID和加载容器关联起来 + window.loadingContainers = window.loadingContainers || {}; + window.loadingContainers[currentRequestId] = loadingContainer; + + // 初始化请求的响应容器映射 + window.requestContainers = window.requestContainers || {}; + } else { + // 处理错误 + if (loadingContainer.parentNode) { + messagesDiv.removeChild(loadingContainer); + } + addBotMessage("抱歉,发生了错误,请稍后再试。", new Date()); } }) .catch(error => { @@ -847,175 +977,117 @@ messagesDiv.removeChild(loadingContainer); } // 显示错误消息 - addBotMessage("抱歉,发生了错误,请稍后再试。", new Date()); - }); - } - } - - // 添加加载中的消息 - function addLoadingMessage() { - const botContainer = document.createElement('div'); - botContainer.className = 'bot-container loading-container'; - - const messageContainer = document.createElement('div'); - messageContainer.className = 'message-container'; - - messageContainer.innerHTML = ` -
- -
-
-
-
- - - -
-
-
- `; - - botContainer.appendChild(messageContainer); - messagesDiv.appendChild(botContainer); - scrollToBottom(); - - return botContainer; - } - - // 格式化消息内容(处理Markdown和代码高亮) - function formatMessage(content) { - // 配置 marked 以使用 highlight.js - marked.setOptions({ - highlight: function(code, language) { - if (language && hljs.getLanguage(language)) { - try { - return hljs.highlight(code, { language: language }).value; - } catch (e) { - console.error('Error highlighting code:', e); - return code; - } - } - return code; - }, - breaks: true, // 启用换行符转换为
- gfm: true, // 启用 GitHub 风格的 Markdown - headerIds: true, // 为标题生成ID - mangle: false, // 不转义内联HTML - sanitize: false, // 不净化输出 - smartLists: true, // 使用更智能的列表行为 - smartypants: false, // 不使用更智能的标点符号 - xhtml: false // 不使用自闭合标签 - }); - - try { - // 使用 marked 解析 Markdown - const parsed = marked.parse(content); - return parsed; - } catch (e) { - console.error('Error parsing markdown:', e); - // 如果解析失败,至少确保换行符正确显示 - return content.replace(/\n/g, '
'); - } - } - - // 添加消息后应用代码高亮 - function applyHighlighting() { - try { - document.querySelectorAll('pre code').forEach((block) => { - // 手动应用高亮 - const language = block.className.replace('language-', ''); - if (language && hljs.getLanguage(language)) { - try { - hljs.highlightBlock(block); - } catch (e) { - console.error('Error highlighting block:', e); - } + if (error.code === 'ECONNABORTED') { + addBotMessage("请求超时,请再试一次吧。", new Date()); } else { - hljs.highlightAuto(block); + addBotMessage("抱歉,发生了错误,请稍后再试。", new Date()); } }); - } catch (e) { - console.error('Error applying code highlighting:', e); } } - // 添加用户消息的函数 (保存到localStorage) - function addUserMessage(content, timestamp) { - // 显示消息 - displayUserMessage(content, timestamp); + // 修改轮询函数,确保正确处理多条回复 + function startPolling(sessionId) { + if (window.isPolling) return; - // 保存到localStorage - saveMessageToLocalStorage({ - role: 'user', - content: content, - timestamp: timestamp.getTime() - }); + window.isPolling = true; + console.log('Starting polling with session ID:', sessionId); + + function poll() { + if (!window.isPolling) return; + + // 如果页面已关闭或导航离开,停止轮询 + if (document.hidden) { + setTimeout(poll, 5000); // 页面不可见时降低轮询频率 + return; + } + + // 使用当前的会话ID,而不是闭包中的sessionId + const currentSessionId = window.sessionId || sessionId; + + axios({ + method: 'post', + url: '/poll', + data: { + session_id: currentSessionId + }, + timeout: 5000 + }) + .then(response => { + if (response.data.status === "success") { + if (response.data.has_content) { + console.log('Received response:', response.data); + + // 获取请求ID和内容 + const requestId = response.data.request_id; + const content = response.data.content; + const timestamp = new Date(response.data.timestamp * 1000); + + // 检查是否有对应的加载容器 + if (window.loadingContainers && window.loadingContainers[requestId]) { + // 移除加载容器 + const loadingContainer = window.loadingContainers[requestId]; + if (loadingContainer && loadingContainer.parentNode) { + messagesDiv.removeChild(loadingContainer); + } + + // 删除已处理的加载容器引用 + delete window.loadingContainers[requestId]; + } + + // 始终创建新的消息,无论是否是同一个请求的后续回复 + addBotMessage(content, timestamp, requestId); + + // 滚动到底部 + scrollToBottom(); + } + + // 继续轮询,使用原来的2秒间隔 + setTimeout(poll, 2000); + } else { + // 处理错误但继续轮询 + console.error('Error in polling response:', response.data.message); + setTimeout(poll, 3000); + } + }) + .catch(error => { + console.error('Error polling for response:', error); + // 出错后继续轮询,但间隔更长 + setTimeout(poll, 3000); + }); + } + + // 开始轮询 + poll(); } - // 添加机器人消息的函数 (保存到localStorage) - function addBotMessage(content, timestamp) { + // 添加机器人消息的函数 (保存到localStorage),增加requestId参数 + function addBotMessage(content, timestamp, requestId) { // 显示消息 - displayBotMessage(content, timestamp); + displayBotMessage(content, timestamp, requestId); // 保存到localStorage saveMessageToLocalStorage({ role: 'assistant', content: content, - timestamp: timestamp.getTime() + timestamp: timestamp.getTime(), + requestId: requestId }); } - // 只显示用户消息而不保存到localStorage - function displayUserMessage(content, timestamp) { - const userContainer = document.createElement('div'); - userContainer.className = 'user-container'; - - const messageContainer = document.createElement('div'); - messageContainer.className = 'message-container'; - - // 安全地格式化消息 - let formattedContent; - try { - formattedContent = formatMessage(content); - } catch (e) { - console.error('Error formatting user message:', e); - formattedContent = `

${content.replace(/\n/g, '
')}

`; - } - - messageContainer.innerHTML = ` -
- -
-
-
${formattedContent}
-
${formatTimestamp(timestamp)}
-
- `; - - userContainer.appendChild(messageContainer); - messagesDiv.appendChild(userContainer); - - // 应用代码高亮 - setTimeout(() => { - applyHighlighting(); - }, 0); - - scrollToBottom(); - } - - // 只显示机器人消息而不保存到localStorage - function displayBotMessage(content, timestamp) { + // 修改显示机器人消息的函数,增加requestId参数 + function displayBotMessage(content, timestamp, requestId) { const botContainer = document.createElement('div'); botContainer.className = 'bot-container'; + // 如果有requestId,将其存储在数据属性中 + if (requestId) { + botContainer.dataset.requestId = requestId; + } + const messageContainer = document.createElement('div'); messageContainer.className = 'message-container'; - // 确保时间戳是有效的 Date 对象 - if (!(timestamp instanceof Date) || isNaN(timestamp)) { - timestamp = new Date(); - } - // 安全地格式化消息 let formattedContent; try { @@ -1038,45 +1110,82 @@ botContainer.appendChild(messageContainer); messagesDiv.appendChild(botContainer); - // 使用setTimeout确保DOM已更新,并延长等待时间 + // 应用代码高亮 setTimeout(() => { - try { - // 直接对新添加的消息应用高亮 - const codeBlocks = botContainer.querySelectorAll('pre code'); - codeBlocks.forEach(block => { - // 确保代码块有正确的类 - if (!block.classList.contains('hljs')) { - block.classList.add('hljs'); - } - - // 尝试获取语言 - let language = ''; - block.classList.forEach(cls => { - if (cls.startsWith('language-')) { - language = cls.replace('language-', ''); - } - }); - - // 应用高亮 - if (language && hljs.getLanguage(language)) { - try { - hljs.highlightBlock(block); - } catch (e) { - console.error('Error highlighting specific language:', e); - hljs.highlightAuto(block); - } - } else { - hljs.highlightAuto(block); - } - }); - } catch (e) { - console.error('Error in delayed highlighting:', e); - } - }, 100); // 增加延迟以确保DOM完全更新 + applyHighlighting(); + }, 0); scrollToBottom(); } + // 处理响应 + function handleResponse(requestId, content) { + // 获取该请求的加载容器 + const loadingContainer = window.loadingContainers && window.loadingContainers[requestId]; + + // 如果有加载容器,移除它 + if (loadingContainer && loadingContainer.parentNode) { + messagesDiv.removeChild(loadingContainer); + delete window.loadingContainers[requestId]; + } + + // 为每个请求创建一个新的消息容器 + if (!window.requestContainers[requestId]) { + window.requestContainers[requestId] = createBotMessageContainer(content, new Date()); + } else { + // 更新现有消息容器 + updateBotMessageContent(window.requestContainers[requestId], content); + } + + // 保存消息到localStorage + saveMessageToLocalStorage({ + role: 'assistant', + content: content, + timestamp: new Date().getTime(), + request_id: requestId + }); + } + + // 修改createBotMessageContainer函数,使其返回创建的容器 + function createBotMessageContainer(content, timestamp) { + const botContainer = document.createElement('div'); + botContainer.className = 'bot-container'; + + const messageContainer = document.createElement('div'); + messageContainer.className = 'message-container'; + + // 安全地格式化消息 + let formattedContent; + try { + formattedContent = formatMessage(content); + } catch (e) { + console.error('Error formatting bot message:', e); + formattedContent = `

${content.replace(/\n/g, '
')}

`; + } + + messageContainer.innerHTML = ` +
+ +
+
+
${formattedContent}
+
${formatTimestamp(timestamp)}
+
+ `; + + botContainer.appendChild(messageContainer); + messagesDiv.appendChild(botContainer); + + // 应用代码高亮 + setTimeout(() => { + applyHighlighting(); + }, 0); + + scrollToBottom(); + + return botContainer; + } + // 格式化时间戳 function formatTimestamp(date) { return date.toLocaleTimeString(); @@ -1147,8 +1256,8 @@ }); }); - // 清空localStorage中的消息 - 使用用户ID作为键 - localStorage.setItem(`chatMessages_${userId}`, JSON.stringify([])); + // 清空localStorage中的消息 - 使用会话ID作为键 + localStorage.setItem(`chatMessages_${sessionId}`, JSON.stringify([])); // 在移动设备上关闭侧边栏 if (window.innerWidth <= 768) { @@ -1156,26 +1265,242 @@ } } - // 从localStorage加载消息 - 使用用户ID作为键 + // 从localStorage加载消息 - 使用会话ID作为键 function loadMessagesFromLocalStorage() { try { - return JSON.parse(localStorage.getItem(`chatMessages_${userId}`) || '[]'); + return JSON.parse(localStorage.getItem(`chatMessages_${sessionId}`) || '[]'); } catch (error) { console.error('Error loading messages from localStorage:', error); return []; } } - // 保存消息到localStorage - 使用用户ID作为键 + // 保存消息到localStorage - 使用会话ID作为键 function saveMessageToLocalStorage(message) { try { const messages = loadMessagesFromLocalStorage(); messages.push(message); - localStorage.setItem(`chatMessages_${userId}`, JSON.stringify(messages)); + localStorage.setItem(`chatMessages_${sessionId}`, JSON.stringify(messages)); } catch (error) { console.error('Error saving message to localStorage:', error); } } + + // 添加用户消息的函数 (保存到localStorage) + function addUserMessage(content, timestamp) { + // 显示消息 + displayUserMessage(content, timestamp); + + // 保存到localStorage + saveMessageToLocalStorage({ + role: 'user', + content: content, + timestamp: timestamp.getTime() + }); + } + + // 只显示用户消息而不保存到localStorage + function displayUserMessage(content, timestamp) { + const userContainer = document.createElement('div'); + userContainer.className = 'user-container'; + + const messageContainer = document.createElement('div'); + messageContainer.className = 'message-container'; + + // 安全地格式化消息 + let formattedContent; + try { + formattedContent = formatMessage(content); + } catch (e) { + console.error('Error formatting user message:', e); + formattedContent = `

${content.replace(/\n/g, '
')}

`; + } + + messageContainer.innerHTML = ` +
+ +
+
+
${formattedContent}
+
${formatTimestamp(timestamp)}
+
+ `; + + userContainer.appendChild(messageContainer); + messagesDiv.appendChild(userContainer); + + // 应用代码高亮 + setTimeout(() => { + applyHighlighting(); + }, 0); + + scrollToBottom(); + } + + // 添加加载中的消息 + function addLoadingMessage() { + const botContainer = document.createElement('div'); + botContainer.className = 'bot-container loading-container'; + + const messageContainer = document.createElement('div'); + messageContainer.className = 'message-container'; + + messageContainer.innerHTML = ` +
+ +
+
+
+
+ + + +
+
+
+ `; + + botContainer.appendChild(messageContainer); + messagesDiv.appendChild(botContainer); + scrollToBottom(); + + return botContainer; + } + + // 自动将链接设置为在新标签页打开 + const externalLinksPlugin = (md) => { + // 保存原始的链接渲染器 + const defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) { + return self.renderToken(tokens, idx, options); + }; + + // 重写链接渲染器 + md.renderer.rules.link_open = function(tokens, idx, options, env, self) { + // 为所有链接添加 target="_blank" 和 rel="noopener noreferrer" + const token = tokens[idx]; + + // 添加 target="_blank" 属性 + token.attrPush(['target', '_blank']); + + // 添加 rel="noopener noreferrer" 以提高安全性 + token.attrPush(['rel', 'noopener noreferrer']); + + // 调用默认渲染器 + return defaultRender(tokens, idx, options, env, self); + }; + }; + + // 替换 formatMessage 函数,使用 markdown-it 替代 marked + function formatMessage(content) { + try { + // 初始化 markdown-it 实例 + const md = window.markdownit({ + html: false, // 禁用 HTML 标签 + xhtmlOut: false, // 使用 '/' 关闭单标签 + breaks: true, // 将换行符转换为
+ linkify: true, // 自动将 URL 转换为链接 + typographer: true, // 启用一些语言中性的替换和引号美化 + highlight: function(str, lang) { + if (lang && hljs.getLanguage(lang)) { + try { + return hljs.highlight(str, { language: lang }).value; + } catch (e) { + console.error('Error highlighting code:', e); + } + } + return hljs.highlightAuto(str).value; + } + }); + + // 自动将图片URL转换为图片标签 + const autoImagePlugin = (md) => { + const defaultRender = md.renderer.rules.text || function(tokens, idx, options, env, self) { + return self.renderToken(tokens, idx, options); + }; + + md.renderer.rules.text = function(tokens, idx, options, env, self) { + const token = tokens[idx]; + const text = token.content.trim(); + + // 检测是否完全是一个图片链接 (以https://开头,以图片扩展名结尾) + const imageRegex = /^https?:\/\/\S+\.(jpg|jpeg|png|gif|webp)(\?\S*)?$/i; + if (imageRegex.test(text)) { + return `Image`; + } + + // 使用默认渲染 + return defaultRender(tokens, idx, options, env, self); + }; + }; + + // 应用插件 + md.use(autoImagePlugin); + + // 应用外部链接插件 + md.use(externalLinksPlugin); + + // 渲染 Markdown + return md.render(content); + } catch (e) { + console.error('Error parsing markdown:', e); + // 如果解析失败,至少确保换行符正确显示 + return content.replace(/\n/g, '
'); + } + } + + // 更新 applyHighlighting 函数 + function applyHighlighting() { + try { + document.querySelectorAll('pre code').forEach((block) => { + // 确保代码块有正确的类 + if (!block.classList.contains('hljs')) { + block.classList.add('hljs'); + } + + // 尝试获取语言 + let language = ''; + block.classList.forEach(cls => { + if (cls.startsWith('language-')) { + language = cls.replace('language-', ''); + } + }); + + // 应用高亮 + if (language && hljs.getLanguage(language)) { + try { + hljs.highlightBlock(block); + } catch (e) { + console.error('Error highlighting specific language:', e); + hljs.highlightAuto(block); + } + } else { + hljs.highlightAuto(block); + } + }); + } catch (e) { + console.error('Error applying code highlighting:', e); + } + } + + // 在 #main-content 上添加点击事件,用于关闭侧边栏 + document.getElementById('main-content').addEventListener('click', function(event) { + // 只在移动视图下且侧边栏打开时处理 + if (window.innerWidth <= 768 && sidebar.classList.contains('active')) { + sidebar.classList.remove('active'); + } + }); + + // 阻止侧边栏内部点击事件冒泡到 main-content + document.getElementById('sidebar').addEventListener('click', function(event) { + event.stopPropagation(); + }); + + // 添加遮罩层点击事件,用于关闭侧边栏 + document.getElementById('sidebar-overlay').addEventListener('click', function() { + if (sidebar.classList.contains('active')) { + sidebar.classList.remove('active'); + } + }); \ No newline at end of file diff --git a/channel/web/static/axios.min.js b/channel/web/static/axios.min.js new file mode 100644 index 0000000..79aa153 --- /dev/null +++ b/channel/web/static/axios.min.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).axios=t()}(this,(function(){"use strict";function e(t){return e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},e(t)}function t(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function n(e,t){for(var n=0;n2&&void 0!==arguments[2]?arguments[2]:{},s=i.allOwnKeys,a=void 0!==s&&s;if(null!=t)if("object"!==e(t)&&(t=[t]),l(t))for(r=0,o=t.length;r3&&void 0!==arguments[3]?arguments[3]:{},i=r.allOwnKeys;return S(t,(function(t,r){n&&m(t)?e[r]=o(t,n):e[r]=t}),{allOwnKeys:i}),e},trim:function(e){return e.trim?e.trim():e.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"")},stripBOM:function(e){return 65279===e.charCodeAt(0)&&(e=e.slice(1)),e},inherits:function(e,t,n,r){e.prototype=Object.create(t.prototype,r),e.prototype.constructor=e,Object.defineProperty(e,"super",{value:t.prototype}),n&&Object.assign(e.prototype,n)},toFlatObject:function(e,t,n,r){var o,i,s,u={};if(t=t||{},null==e)return t;do{for(i=(o=Object.getOwnPropertyNames(e)).length;i-- >0;)s=o[i],r&&!r(s,e,t)||u[s]||(t[s]=e[s],u[s]=!0);e=!1!==n&&a(e)}while(e&&(!n||n(e,t))&&e!==Object.prototype);return t},kindOf:u,kindOfTest:c,endsWith:function(e,t,n){e=String(e),(void 0===n||n>e.length)&&(n=e.length),n-=t.length;var r=e.indexOf(t,n);return-1!==r&&r===n},toArray:function(e){if(!e)return null;if(l(e))return e;var t=e.length;if(!v(t))return null;for(var n=new Array(t);t-- >0;)n[t]=e[t];return n},forEachEntry:function(e,t){for(var n,r=(e&&e[Symbol.iterator]).call(e);(n=r.next())&&!n.done;){var o=n.value;t.call(e,o[0],o[1])}},matchAll:function(e,t){for(var n,r=[];null!==(n=e.exec(t));)r.push(n);return r},isHTMLForm:T,hasOwnProperty:x,hasOwnProp:x,reduceDescriptors:N,freezeMethods:function(e){N(e,(function(t,n){var r=e[n];m(r)&&(t.enumerable=!1,"writable"in t?t.writable=!1:t.set||(t.set=function(){throw Error("Can not read-only method '"+n+"'")}))}))},toObjectSet:function(e,t){var n={},r=function(e){e.forEach((function(e){n[e]=!0}))};return l(e)?r(e):r(String(e).split(t)),n},toCamelCase:function(e){return e.toLowerCase().replace(/[_-\s]([a-z\d])(\w*)/g,(function(e,t,n){return t.toUpperCase()+n}))},noop:function(){},toFiniteNumber:function(e,t){return e=+e,Number.isFinite(e)?e:t}};function _(e,t,n,r,o){Error.call(this),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=(new Error).stack,this.message=e,this.name="AxiosError",t&&(this.code=t),n&&(this.config=n),r&&(this.request=r),o&&(this.response=o)}P.inherits(_,Error,{toJSON:function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:this.config,code:this.code,status:this.response&&this.response.status?this.response.status:null}}});var B=_.prototype,D={};["ERR_BAD_OPTION_VALUE","ERR_BAD_OPTION","ECONNABORTED","ETIMEDOUT","ERR_NETWORK","ERR_FR_TOO_MANY_REDIRECTS","ERR_DEPRECATED","ERR_BAD_RESPONSE","ERR_BAD_REQUEST","ERR_CANCELED","ERR_NOT_SUPPORT","ERR_INVALID_URL"].forEach((function(e){D[e]={value:e}})),Object.defineProperties(_,D),Object.defineProperty(B,"isAxiosError",{value:!0}),_.from=function(e,t,n,r,o,i){var s=Object.create(B);return P.toFlatObject(e,s,(function(e){return e!==Error.prototype}),(function(e){return"isAxiosError"!==e})),_.call(s,e.message,t,n,r,o),s.cause=e,s.name=e.name,i&&Object.assign(s,i),s};var F="object"==("undefined"==typeof self?"undefined":e(self))?self.FormData:window.FormData;function U(e){return P.isPlainObject(e)||P.isArray(e)}function k(e){return P.endsWith(e,"[]")?e.slice(0,-2):e}function L(e,t,n){return e?e.concat(t).map((function(e,t){return e=k(e),!n&&t?"["+e+"]":e})).join(n?".":""):t}var q=P.toFlatObject(P,{},null,(function(e){return/^is[A-Z]/.test(e)}));function z(t,n,r){if(!P.isObject(t))throw new TypeError("target must be an object");n=n||new(F||FormData);var o,i=(r=P.toFlatObject(r,{metaTokens:!0,dots:!1,indexes:!1},!1,(function(e,t){return!P.isUndefined(t[e])}))).metaTokens,s=r.visitor||l,a=r.dots,u=r.indexes,c=(r.Blob||"undefined"!=typeof Blob&&Blob)&&((o=n)&&P.isFunction(o.append)&&"FormData"===o[Symbol.toStringTag]&&o[Symbol.iterator]);if(!P.isFunction(s))throw new TypeError("visitor must be a function");function f(e){if(null===e)return"";if(P.isDate(e))return e.toISOString();if(!c&&P.isBlob(e))throw new _("Blob is not supported. Use a Buffer instead.");return P.isArrayBuffer(e)||P.isTypedArray(e)?c&&"function"==typeof Blob?new Blob([e]):Buffer.from(e):e}function l(t,r,o){var s=t;if(t&&!o&&"object"===e(t))if(P.endsWith(r,"{}"))r=i?r:r.slice(0,-2),t=JSON.stringify(t);else if(P.isArray(t)&&function(e){return P.isArray(e)&&!e.some(U)}(t)||P.isFileList(t)||P.endsWith(r,"[]")&&(s=P.toArray(t)))return r=k(r),s.forEach((function(e,t){!P.isUndefined(e)&&n.append(!0===u?L([r],t,a):null===u?r:r+"[]",f(e))})),!1;return!!U(t)||(n.append(L(o,r,a),f(t)),!1)}var d=[],h=Object.assign(q,{defaultVisitor:l,convertValue:f,isVisitable:U});if(!P.isObject(t))throw new TypeError("data must be an object");return function e(t,r){if(!P.isUndefined(t)){if(-1!==d.indexOf(t))throw Error("Circular reference detected in "+r.join("."));d.push(t),P.forEach(t,(function(t,o){!0===(!P.isUndefined(t)&&s.call(n,t,P.isString(o)?o.trim():o,r,h))&&e(t,r?r.concat(o):[o])})),d.pop()}}(t),n}function I(e){var t={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+","%00":"\0"};return encodeURIComponent(e).replace(/[!'()~]|%20|%00/g,(function(e){return t[e]}))}function M(e,t){this._pairs=[],e&&z(e,this,t)}var J=M.prototype;function H(e){return encodeURIComponent(e).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+").replace(/%5B/gi,"[").replace(/%5D/gi,"]")}function V(e,t,n){if(!t)return e;var r=e.indexOf("#");-1!==r&&(e=e.slice(0,r));var o=n&&n.encode||H,i=P.isURLSearchParams(t)?t.toString():new M(t,n).toString(o);return i&&(e+=(-1===e.indexOf("?")?"?":"&")+i),e}J.append=function(e,t){this._pairs.push([e,t])},J.toString=function(e){var t=e?function(t){return e.call(this,t,I)}:I;return this._pairs.map((function(e){return t(e[0])+"="+t(e[1])}),"").join("&")};var W,K=function(){function e(){t(this,e),this.handlers=[]}return r(e,[{key:"use",value:function(e,t,n){return this.handlers.push({fulfilled:e,rejected:t,synchronous:!!n&&n.synchronous,runWhen:n?n.runWhen:null}),this.handlers.length-1}},{key:"eject",value:function(e){this.handlers[e]&&(this.handlers[e]=null)}},{key:"clear",value:function(){this.handlers&&(this.handlers=[])}},{key:"forEach",value:function(e){P.forEach(this.handlers,(function(t){null!==t&&e(t)}))}}]),e}(),X={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1},$="undefined"!=typeof URLSearchParams?URLSearchParams:M,Q=FormData,G=("undefined"==typeof navigator||"ReactNative"!==(W=navigator.product)&&"NativeScript"!==W&&"NS"!==W)&&"undefined"!=typeof window&&"undefined"!=typeof document,Y={isBrowser:!0,classes:{URLSearchParams:$,FormData:Q,Blob:Blob},isStandardBrowserEnv:G,protocols:["http","https","file","blob","url","data"]};function Z(e){function t(e,n,r,o){var i=e[o++],s=Number.isFinite(+i),a=o>=e.length;return i=!i&&P.isArray(r)?r.length:i,a?(P.hasOwnProp(r,i)?r[i]=[r[i],n]:r[i]=n,!s):(r[i]&&P.isObject(r[i])||(r[i]=[]),t(e,n,r[i],o)&&P.isArray(r[i])&&(r[i]=function(e){var t,n,r={},o=Object.keys(e),i=o.length;for(t=0;t0;)if(t===(n=r[o]).toLowerCase())return n;return null}function le(e,t){e&&this.set(e),this[se]=t||null}function de(e,t){var n=0,r=function(e,t){e=e||10;var n,r=new Array(e),o=new Array(e),i=0,s=0;return t=void 0!==t?t:1e3,function(a){var u=Date.now(),c=o[s];n||(n=u),r[i]=a,o[i]=u;for(var f=s,l=0;f!==i;)l+=r[f++],f%=e;if((i=(i+1)%e)===s&&(s=(s+1)%e),!(u-n-1,i=P.isObject(e);if(i&&P.isHTMLForm(e)&&(e=new FormData(e)),P.isFormData(e))return o&&o?JSON.stringify(Z(e)):e;if(P.isArrayBuffer(e)||P.isBuffer(e)||P.isStream(e)||P.isFile(e)||P.isBlob(e))return e;if(P.isArrayBufferView(e))return e.buffer;if(P.isURLSearchParams(e))return t.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),e.toString();if(i){if(r.indexOf("application/x-www-form-urlencoded")>-1)return function(e,t){return z(e,new Y.classes.URLSearchParams,Object.assign({visitor:function(e,t,n,r){return Y.isNode&&P.isBuffer(e)?(this.append(t,e.toString("base64")),!1):r.defaultVisitor.apply(this,arguments)}},t))}(e,this.formSerializer).toString();if((n=P.isFileList(e))||r.indexOf("multipart/form-data")>-1){var s=this.env&&this.env.FormData;return z(n?{"files[]":e}:e,s&&new s,this.formSerializer)}}return i||o?(t.setContentType("application/json",!1),function(e,t,n){if(P.isString(e))try{return(t||JSON.parse)(e),P.trim(e)}catch(e){if("SyntaxError"!==e.name)throw e}return(n||JSON.stringify)(e)}(e)):e}],transformResponse:[function(e){var t=this.transitional||be.transitional,n=t&&t.forcedJSONParsing,r="json"===this.responseType;if(e&&P.isString(e)&&(n&&!this.responseType||r)){var o=!(t&&t.silentJSONParsing)&&r;try{return JSON.parse(e)}catch(e){if(o){if("SyntaxError"===e.name)throw _.from(e,_.ERR_BAD_RESPONSE,this,null,this.response);throw e}}}return e}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:Y.classes.FormData,Blob:Y.classes.Blob},validateStatus:function(e){return e>=200&&e<300},headers:{common:{Accept:"application/json, text/plain, */*"}}};function ge(e,t){var n=this||be,r=t||n,o=le.from(r.headers),i=r.data;return P.forEach(e,(function(e){i=e.call(n,i,o.normalize(),t?t.status:void 0)})),o.normalize(),i}function Ee(e){return!(!e||!e.__CANCEL__)}function we(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)throw new re}function Oe(e){return we(e),e.headers=le.from(e.headers),e.data=ge.call(e,e.transformRequest),(e.adapter||be.adapter)(e).then((function(t){return we(e),t.data=ge.call(e,e.transformResponse,t),t.headers=le.from(t.headers),t}),(function(t){return Ee(t)||(we(e),t&&t.response&&(t.response.data=ge.call(e,e.transformResponse,t.response),t.response.headers=le.from(t.response.headers))),Promise.reject(t)}))}function Re(e,t){t=t||{};var n={};function r(e,t){return P.isPlainObject(e)&&P.isPlainObject(t)?P.merge(e,t):P.isPlainObject(t)?P.merge({},t):P.isArray(t)?t.slice():t}function o(n){return P.isUndefined(t[n])?P.isUndefined(e[n])?void 0:r(void 0,e[n]):r(e[n],t[n])}function i(e){if(!P.isUndefined(t[e]))return r(void 0,t[e])}function s(n){return P.isUndefined(t[n])?P.isUndefined(e[n])?void 0:r(void 0,e[n]):r(void 0,t[n])}function a(n){return n in t?r(e[n],t[n]):n in e?r(void 0,e[n]):void 0}var u={url:i,method:i,data:i,baseURL:s,transformRequest:s,transformResponse:s,paramsSerializer:s,timeout:s,timeoutMessage:s,withCredentials:s,adapter:s,responseType:s,xsrfCookieName:s,xsrfHeaderName:s,onUploadProgress:s,onDownloadProgress:s,decompress:s,maxContentLength:s,maxBodyLength:s,beforeRedirect:s,transport:s,httpAgent:s,httpsAgent:s,cancelToken:s,socketPath:s,responseEncoding:s,validateStatus:a};return P.forEach(Object.keys(e).concat(Object.keys(t)),(function(e){var t=u[e]||o,r=t(e);P.isUndefined(r)&&t!==a||(n[e]=r)})),n}P.forEach(["delete","get","head"],(function(e){be.headers[e]={}})),P.forEach(["post","put","patch"],(function(e){be.headers[e]=P.merge(ve)}));var Se="1.1.2",Ae={};["object","boolean","number","function","string","symbol"].forEach((function(t,n){Ae[t]=function(r){return e(r)===t||"a"+(n<1?"n ":" ")+t}}));var je={};Ae.transitional=function(e,t,n){function r(e,t){return"[Axios v1.1.2] Transitional option '"+e+"'"+t+(n?". "+n:"")}return function(n,o,i){if(!1===e)throw new _(r(o," has been removed"+(t?" in "+t:"")),_.ERR_DEPRECATED);return t&&!je[o]&&(je[o]=!0,console.warn(r(o," has been deprecated since v"+t+" and will be removed in the near future"))),!e||e(n,o,i)}};var Te={assertOptions:function(t,n,r){if("object"!==e(t))throw new _("options must be an object",_.ERR_BAD_OPTION_VALUE);for(var o=Object.keys(t),i=o.length;i-- >0;){var s=o[i],a=n[s];if(a){var u=t[s],c=void 0===u||a(u,s,t);if(!0!==c)throw new _("option "+s+" must be "+c,_.ERR_BAD_OPTION_VALUE)}else if(!0!==r)throw new _("Unknown option "+s,_.ERR_BAD_OPTION)}},validators:Ae},xe=Te.validators,Ce=function(){function e(n){t(this,e),this.defaults=n,this.interceptors={request:new K,response:new K}}return r(e,[{key:"request",value:function(e,t){"string"==typeof e?(t=t||{}).url=e:t=e||{};var n=(t=Re(this.defaults,t)).transitional;void 0!==n&&Te.assertOptions(n,{silentJSONParsing:xe.transitional(xe.boolean),forcedJSONParsing:xe.transitional(xe.boolean),clarifyTimeoutError:xe.transitional(xe.boolean)},!1),t.method=(t.method||this.defaults.method||"get").toLowerCase();var r=t.headers&&P.merge(t.headers.common,t.headers[t.method]);r&&P.forEach(["delete","get","head","post","put","patch","common"],(function(e){delete t.headers[e]})),t.headers=new le(t.headers,r);var o=[],i=!0;this.interceptors.request.forEach((function(e){"function"==typeof e.runWhen&&!1===e.runWhen(t)||(i=i&&e.synchronous,o.unshift(e.fulfilled,e.rejected))}));var s,a=[];this.interceptors.response.forEach((function(e){a.push(e.fulfilled,e.rejected)}));var u,c=0;if(!i){var f=[Oe.bind(this),void 0];for(f.unshift.apply(f,o),f.push.apply(f,a),u=f.length,s=Promise.resolve(t);c0;)o._listeners[t](e);o._listeners=null}})),this.promise.then=function(e){var t,n=new Promise((function(e){o.subscribe(e),t=e})).then(e);return n.cancel=function(){o.unsubscribe(t)},n},n((function(e,t,n){o.reason||(o.reason=new re(e,t,n),r(o.reason))}))}return r(e,[{key:"throwIfRequested",value:function(){if(this.reason)throw this.reason}},{key:"subscribe",value:function(e){this.reason?e(this.reason):this._listeners?this._listeners.push(e):this._listeners=[e]}},{key:"unsubscribe",value:function(e){if(this._listeners){var t=this._listeners.indexOf(e);-1!==t&&this._listeners.splice(t,1)}}}],[{key:"source",value:function(){var t;return{token:new e((function(e){t=e})),cancel:t}}}]),e}();var Pe=function e(t){var n=new Ce(t),r=o(Ce.prototype.request,n);return P.extend(r,Ce.prototype,n,{allOwnKeys:!0}),P.extend(r,n,null,{allOwnKeys:!0}),r.create=function(n){return e(Re(t,n))},r}(be);return Pe.Axios=Ce,Pe.CanceledError=re,Pe.CancelToken=Ne,Pe.isCancel=Ee,Pe.VERSION=Se,Pe.toFormData=z,Pe.AxiosError=_,Pe.Cancel=Pe.CanceledError,Pe.all=function(e){return Promise.all(e)},Pe.spread=function(e){return function(t){return e.apply(null,t)}},Pe.isAxiosError=function(e){return P.isObject(e)&&!0===e.isAxiosError},Pe.formToJSON=function(e){return Z(P.isHTMLForm(e)?new FormData(e):e)},Pe})); +//# sourceMappingURL=axios.min.js.map diff --git a/channel/web/static/github.png b/channel/web/static/github.png new file mode 100644 index 0000000..9122abb Binary files /dev/null and b/channel/web/static/github.png differ diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index aac4f68..057a745 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -2,6 +2,7 @@ import sys import time import web import json +import uuid from queue import Queue, Empty from bridge.context import * from bridge.reply import Reply, ReplyType @@ -12,6 +13,8 @@ from common.singleton import singleton from config import conf import os import mimetypes # 添加这行来处理MIME类型 +import threading +import logging class WebMessage(ChatMessage): def __init__( @@ -43,39 +46,54 @@ class WebChannel(ChatChannel): def __init__(self): super().__init__() - self.message_queues = {} # 为每个用户存储一个消息队列 self.msg_id_counter = 0 # 添加消息ID计数器 + self.session_queues = {} # 存储session_id到队列的映射 + self.request_to_session = {} # 存储request_id到session_id的映射 def _generate_msg_id(self): """生成唯一的消息ID""" self.msg_id_counter += 1 return str(int(time.time())) + str(self.msg_id_counter) + def _generate_request_id(self): + """生成唯一的请求ID""" + return str(uuid.uuid4()) + def send(self, reply: Reply, context: Context): try: if reply.type in self.NOT_SUPPORT_REPLYTYPE: logger.warning(f"Web channel doesn't support {reply.type} yet") return + + if reply.type == ReplyType.IMAGE_URL: + time.sleep(0.5) + + # 获取请求ID和会话ID + request_id = context.get("request_id", None) + + if not request_id: + logger.error("No request_id found in context, cannot send message") + return - # 获取用户ID - user_id = context.get("receiver", None) - if not user_id: - logger.error("No receiver found in context, cannot send message") + # 通过request_id获取session_id + session_id = self.request_to_session.get(request_id) + if not session_id: + logger.error(f"No session_id found for request {request_id}") return - # 检查是否有响应队列 - response_queue = context.get("response_queue", None) - if response_queue: - # 直接将响应放入队列 + # 检查是否有会话队列 + if session_id in self.session_queues: + # 创建响应数据,包含请求ID以区分不同请求的响应 response_data = { "type": str(reply.type), "content": reply.content, - "timestamp": time.time() + "timestamp": time.time(), + "request_id": request_id } - response_queue.put(response_data) - logger.debug(f"Response sent to queue for user {user_id}") + self.session_queues[session_id].put(response_data) + logger.debug(f"Response sent to queue for session {session_id}, request {request_id}") else: - logger.warning(f"No response queue found for user {user_id}, response dropped") + logger.warning(f"No response queue found for session {session_id}, response dropped") except Exception as e: logger.error(f"Error in send method: {e}") @@ -83,57 +101,83 @@ class WebChannel(ChatChannel): def post_message(self): """ Handle incoming messages from users via POST request. + Returns a request_id for tracking this specific request. """ try: data = web.data() # 获取原始POST数据 json_data = json.loads(data) - user_id = json_data.get('user_id', 'default_user') - prompt = json_data.get('message', '') session_id = json_data.get('session_id', f'session_{int(time.time())}') - except json.JSONDecodeError: - return json.dumps({"status": "error", "message": "Invalid JSON"}) - except Exception as e: - return json.dumps({"status": "error", "message": str(e)}) - - if not prompt: - return json.dumps({"status": "error", "message": "No message provided"}) + prompt = json_data.get('message', '') - try: - msg_id = self._generate_msg_id() - web_message = WebMessage( - msg_id=msg_id, - content=prompt, - from_user_id=user_id, - to_user_id="Chatgpt", - other_user_id=user_id - ) + # 生成请求ID + request_id = self._generate_request_id() - context = self._compose_context(ContextType.TEXT, prompt, msg=web_message) - if not context: - return json.dumps({"status": "error", "message": "Failed to process message"}) - - # 创建一个响应队列 - response_queue = Queue() + # 将请求ID与会话ID关联 + self.request_to_session[request_id] = session_id - # 确保上下文包含必要的信息 - context["isgroup"] = False - context["receiver"] = user_id - context["session_id"] = user_id - context["response_queue"] = response_queue - - # 发送消息到处理队列 - self.produce(context) + # 确保会话队列存在 + if session_id not in self.session_queues: + self.session_queues[session_id] = Queue() - # 等待响应,最多等待30秒 - try: - response = response_queue.get(timeout=30) - return json.dumps({"status": "success", "reply": response["content"]}) - except Empty: - return json.dumps({"status": "error", "message": "Response timeout"}) + # 创建消息对象 + msg = WebMessage(self._generate_msg_id(), prompt) + msg.from_user_id = session_id # 使用会话ID作为用户ID + + # 创建上下文 + context = self._compose_context(ContextType.TEXT, prompt, msg=msg) + + # 添加必要的字段 + context["session_id"] = session_id + context["request_id"] = request_id + context["isgroup"] = False # 添加 isgroup 字段 + context["receiver"] = session_id # 添加 receiver 字段 + + # 异步处理消息 - 只传递上下文 + threading.Thread(target=self.produce, args=(context,)).start() + + # 返回请求ID + return json.dumps({"status": "success", "request_id": request_id}) except Exception as e: logger.error(f"Error processing message: {e}") - return json.dumps({"status": "error", "message": "Internal server error"}) + return json.dumps({"status": "error", "message": str(e)}) + + def poll_response(self): + """ + Poll for responses using the session_id. + """ + try: + # 不记录轮询请求的日志 + web.ctx.log_request = False + + data = web.data() + json_data = json.loads(data) + session_id = json_data.get('session_id') + + if not session_id or session_id not in self.session_queues: + return json.dumps({"status": "error", "message": "Invalid session ID"}) + + # 尝试从队列获取响应,不等待 + try: + # 使用peek而不是get,这样如果前端没有成功处理,下次还能获取到 + response = self.session_queues[session_id].get(block=False) + + # 返回响应,包含请求ID以区分不同请求 + return json.dumps({ + "status": "success", + "has_content": True, + "content": response["content"], + "request_id": response["request_id"], + "timestamp": response["timestamp"] + }) + + except Empty: + # 没有新响应 + return json.dumps({"status": "success", "has_content": False}) + + except Exception as e: + logger.error(f"Error polling response: {e}") + return json.dumps({"status": "error", "message": str(e)}) def chat_page(self): """Serve the chat HTML page.""" @@ -151,13 +195,34 @@ class WebChannel(ChatChannel): logger.info(f"Created static directory: {static_dir}") urls = ( + '/', 'RootHandler', # 添加根路径处理器 '/message', 'MessageHandler', + '/poll', 'PollHandler', # 添加轮询处理器 '/chat', 'ChatHandler', '/assets/(.*)', 'AssetsHandler', # 匹配 /assets/任何路径 ) port = conf().get("web_port", 9899) app = web.application(urls, globals(), autoreload=False) - web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port)) + + # 禁用web.py的默认日志输出 + import io + from contextlib import redirect_stdout + + # 配置web.py的日志级别为ERROR,只显示错误 + logging.getLogger("web").setLevel(logging.ERROR) + + # 禁用web.httpserver的日志 + logging.getLogger("web.httpserver").setLevel(logging.ERROR) + + # 临时重定向标准输出,捕获web.py的启动消息 + with redirect_stdout(io.StringIO()): + web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port)) + + +class RootHandler: + def GET(self): + # 重定向到/chat + raise web.seeother('/chat') class MessageHandler: @@ -165,6 +230,11 @@ class MessageHandler: return WebChannel().post_message() +class PollHandler: + def POST(self): + return WebChannel().poll_response() + + class ChatHandler: def GET(self): # 正常返回聊天页面 @@ -185,11 +255,6 @@ class AssetsHandler: current_dir = os.path.dirname(os.path.abspath(__file__)) static_dir = os.path.join(current_dir, 'static') - # 打印调试信息 - logger.info(f"Current directory: {current_dir}") - logger.info(f"Static directory: {static_dir}") - logger.info(f"Requested file: {file_path}") - full_path = os.path.normpath(os.path.join(static_dir, file_path)) # 安全检查:确保请求的文件在static目录内 diff --git a/config-template.json b/config-template.json index d0268d3..476a5e0 100644 --- a/config-template.json +++ b/config-template.json @@ -1,5 +1,5 @@ { - "channel_type": "wx", + "channel_type": "web", "model": "", "open_ai_api_key": "YOUR API KEY", "claude_api_key": "YOUR API KEY", diff --git a/plugins/agent/README.md b/plugins/agent/README.md new file mode 100644 index 0000000..2c04316 --- /dev/null +++ b/plugins/agent/README.md @@ -0,0 +1,66 @@ +# Agent插件 + +## 插件说明 + +基于 [AgentMesh](https://github.com/MinimalFuture/AgentMesh) 多智能体框架实现的Agent插件,可以让机器人快速获得Agent能力,通过自然语言对话来访问 **终端、浏览器、文件系统、搜索引擎** 等各类工具。 +同时还支持通过 **多智能体协作** 来完成复杂任务,例如多智能体任务分发、多智能体问题讨论、协同处理等。 + +AgentMesh项目地址:https://github.com/MinimalFuture/AgentMesh + +## 安装 + +1. 确保已安装依赖: + +```bash +pip install agentmesh-sdk>=0.1.2 +``` + +2. 如需使用浏览器工具,还需安装: + +```bash +pip install browser-use>=0.1.40 +playwright install +``` + +## 配置 + +插件配置文件是 `plugins/agent`目录下的 `config.yaml`,包含智能体团队的配置以及工具的配置,可以从模板文件 `config-template.yaml`中复制: + +```bash +cp config-template.yaml config.yaml +``` + +说明: + + - `team`配置是默认选中的 agent team + - `teams` 下是Agent团队配置,团队的model默认为`gpt-4.1-mini`,可根据需要进行修改,模型对应的 `api_key` 需要在项目根目录的 `config.json` 全局配置中进行配置。例如openai模型需要配置 `open_ai_api_key` + - 支持为 `agents` 下面的每个agent添加model字段来设置不同的模型 + + +## 使用方法 + +在对机器人发送的消息中使用 `$agent` 前缀来触发插件,支持以下命令: + +- `$agent [task]`: 使用默认团队执行任务 (默认团队可通 config.yaml 中的team配置修改) +- `$agent teams`: 列出可用的团队 +- `$agent use [team_name] [task]`: 使用指定的团队执行任务 + + +### 示例 + +```bash +$agent 帮我查看当前目录下有哪些文件夹 +$agent teams +$agent use software_team 帮我写一个产品预约体验的表单页面 +``` + +## 工具支持 + +目前支持多种内置工具,包括但不限于: + +- `calculator`: 数学计算工具 +- `current_time`: 获取当前时间 +- `browser`: 浏览器操作工具,注意需安装额外依赖 +- `google_search`: 搜索引擎,注意需在`config.yaml`中配置api_key +- `file_save`: 文件保存工具 +- `terminal`: 终端命令执行工具 diff --git a/plugins/agent/__init__.py b/plugins/agent/__init__.py new file mode 100644 index 0000000..75642e0 --- /dev/null +++ b/plugins/agent/__init__.py @@ -0,0 +1,3 @@ +from .agent import AgentPlugin + +__all__ = ["AgentPlugin"] \ No newline at end of file diff --git a/plugins/agent/agent.py b/plugins/agent/agent.py new file mode 100644 index 0000000..07a40c1 --- /dev/null +++ b/plugins/agent/agent.py @@ -0,0 +1,282 @@ +import os +import yaml +from typing import Dict, List, Optional + +from agentmesh import AgentTeam, Agent, LLMModel +from agentmesh.models import ClaudeModel +from agentmesh.tools import ToolManager +from config import conf + +import plugins +from plugins import Plugin, Event, EventContext, EventAction +from bridge.context import ContextType +from bridge.reply import Reply, ReplyType +from common.log import logger + + +@plugins.register( + name="agent", + desc="Use AgentMesh framework to process tasks with multi-agent teams", + version="0.1.0", + author="Saboteur7", + desire_priority=1, +) +class AgentPlugin(Plugin): + """Plugin for integrating AgentMesh framework.""" + + def __init__(self): + super().__init__() + self.handlers[Event.ON_HANDLE_CONTEXT] = self.on_handle_context + self.name = "agent" + self.description = "Use AgentMesh framework to process tasks with multi-agent teams" + self.config = self._load_config() + self.tool_manager = ToolManager() + self.tool_manager.load_tools(config_dict=self.config.get("tools")) + logger.info("[agent] inited") + + def _load_config(self) -> Dict: + """Load configuration from config.yaml file.""" + config_path = os.path.join(self.path, "config.yaml") + if not os.path.exists(config_path): + logger.warning(f"Config file not found at {config_path}") + return {} + + with open(config_path, 'r', encoding='utf-8') as f: + return yaml.safe_load(f) + + def get_help_text(self, verbose=False, **kwargs): + """Return help message for the agent plugin.""" + help_text = "通过AgentMesh实现对终端、浏览器、文件系统、搜索引擎等工具的执行,并支持多智能体协作。" + trigger_prefix = conf().get("plugin_trigger_prefix", "$") + + if not verbose: + return help_text + + teams = self.get_available_teams() + teams_str = ", ".join(teams) if teams else "未配置任何团队" + + help_text += "\n\n使用说明:\n" + help_text += f"{trigger_prefix}agent [task] - 使用默认团队执行任务\n" + help_text += f"{trigger_prefix}agent teams - 列出可用的团队\n" + help_text += f"{trigger_prefix}agent use [team_name] [task] - 使用特定团队执行任务\n\n" + help_text += f"可用团队: \n{teams_str}\n\n" + help_text += f"示例:\n" + help_text += f"{trigger_prefix}agent 帮我查看当前文件夹路径\n" + help_text += f"{trigger_prefix}agent use software_team 帮我写一个产品预约体验的表单页面" + return help_text + + def get_available_teams(self) -> List[str]: + """Get list of available teams from configuration.""" + teams_config = self.config.get("teams", {}) + return list(teams_config.keys()) + + + def create_team_from_config(self, team_name: str) -> Optional[AgentTeam]: + """Create a team from configuration.""" + # Get teams configuration + teams_config = self.config.get("teams", {}) + + # Check if the specified team exists + if team_name not in teams_config: + logger.error(f"Team '{team_name}' not found in configuration.") + available_teams = list(teams_config.keys()) + logger.info(f"Available teams: {', '.join(available_teams)}") + return None + + # Get team configuration + team_config = teams_config[team_name] + + # Get team's model + team_model_name = team_config.get("model", "gpt-4.1-mini") + team_model = self.create_llm_model(team_model_name) + + # Get team's max_steps (default to 20 if not specified) + team_max_steps = team_config.get("max_steps", 20) + + # Create team with the model + team = AgentTeam( + name=team_name, + description=team_config.get("description", ""), + rule=team_config.get("rule", ""), + model=team_model, + max_steps=team_max_steps + ) + + # Create and add agents to the team + agents_config = team_config.get("agents", []) + for agent_config in agents_config: + # Check if agent has a specific model + if agent_config.get("model"): + agent_model = self.create_llm_model(agent_config.get("model")) + else: + agent_model = team_model + + # Get agent's max_steps + agent_max_steps = agent_config.get("max_steps") + + agent = Agent( + name=agent_config.get("name", ""), + system_prompt=agent_config.get("system_prompt", ""), + model=agent_model, # Use agent's model if specified, otherwise will use team's model + description=agent_config.get("description", ""), + max_steps=agent_max_steps + ) + + # Add tools to the agent if specified + tool_names = agent_config.get("tools", []) + for tool_name in tool_names: + tool = self.tool_manager.create_tool(tool_name) + if tool: + agent.add_tool(tool) + else: + if tool_name == "browser": + logger.warning( + "Tool 'Browser' loaded failed, " + "please install the required dependency with: \n" + "'pip install browser-use>=0.1.40' or 'pip install agentmesh-sdk[full]'\n" + ) + else: + logger.warning(f"Tool '{tool_name}' not found for agent '{agent.name}'\n") + + # Add agent to team + team.add(agent) + + return team + + def on_handle_context(self, e_context: EventContext): + """Handle the message context.""" + if e_context['context'].type != ContextType.TEXT: + return + content = e_context['context'].content + trigger_prefix = conf().get("plugin_trigger_prefix", "$") + + if not content.startswith(f"{trigger_prefix}agent "): + e_context.action = EventAction.CONTINUE + return + + if not self.config: + reply = Reply() + reply.type = ReplyType.ERROR + reply.content = "未找到插件配置,请在 plugins/agent 目录下创建 config.yaml 配置文件,可根据 config-template.yml 模板文件复制" + e_context['reply'] = reply + e_context.action = EventAction.BREAK_PASS + return + + # Extract the actual task + task = content[len(f"{trigger_prefix}agent "):].strip() + + # If task is empty, return help message + if not task: + reply = Reply() + reply.type = ReplyType.TEXT + reply.content = self.get_help_text(verbose=True) + e_context['reply'] = reply + e_context.action = EventAction.BREAK_PASS + return + + # Check if task is asking for available teams + if task.lower() in ["teams", "list teams", "show teams"]: + teams = self.get_available_teams() + reply = Reply() + reply.type = ReplyType.TEXT + + if not teams: + reply.content = "未配置任何团队。请检查 config.yaml 文件。" + else: + reply.content = f"可用团队: {', '.join(teams)}" + + e_context['reply'] = reply + e_context.action = EventAction.BREAK_PASS + return + + # Check if task specifies a team + team_name = None + if task.startswith("use "): + parts = task[4:].split(" ", 1) + if len(parts) > 0: + team_name = parts[0] + if len(parts) > 1: + task = parts[1].strip() + else: + reply = Reply() + reply.type = ReplyType.TEXT + reply.content = f"已选择团队 '{team_name}'。请输入您想执行的任务。" + e_context['reply'] = reply + e_context.action = EventAction.BREAK_PASS + return + if not team_name: + team_name = self.config.get("team") + + # If no team specified, use default or first available + if not team_name: + teams = self.configself.get_available_teams() + if not teams: + reply = Reply() + reply.type = ReplyType.TEXT + reply.content = "未配置任何团队。请检查 config.yaml 文件。" + e_context['reply'] = reply + e_context.action = EventAction.BREAK_PASS + return + team_name = teams[0] + + # Create team + team = self.create_team_from_config(team_name) + if not team: + reply = Reply() + reply.type = ReplyType.TEXT + reply.content = f"创建团队 '{team_name}' 失败。请检查配置。" + e_context['reply'] = reply + e_context.action = EventAction.BREAK_PASS + return + + # Run the task + try: + logger.info(f"[agent] Running task '{task}' with team '{team_name}', team_model={team.model.model}") + result = team.run_async(task=task) + for agent_result in result: + res_text = f"🤖 {agent_result.get('agent_name')}\n\n{agent_result.get('final_answer')}" + _send_text(e_context, content=res_text) + + reply = Reply() + reply.type = ReplyType.TEXT + reply.content = "" + e_context['reply'] = reply + e_context.action = EventAction.BREAK_PASS + + except Exception as e: + logger.exception(f"Error running task with team '{team_name}'") + + reply = Reply() + reply.type = ReplyType.ERROR + reply.content = f"执行任务时出错: {str(e)}" + e_context['reply'] = reply + e_context.action = EventAction.BREAK_PASS + return + + def create_llm_model(self, model_name) -> LLMModel: + if conf().get("use_linkai"): + api_base = "https://api.link-ai.tech/v1" + api_key = conf().get("linkai_api_key") + elif model_name.startswith(("gpt", "text-davinci", "o1", "o3")): + api_base = conf().get("open_ai_api_base") or "https://api.openai.com/v1" + api_key = conf().get("open_ai_api_key") + elif model_name.startswith("claude"): + return ClaudeModel(model=model_name, api_key=conf().get("claude_api_key")) + elif model_name.startswith("moonshot"): + api_base = "https://api.moonshot.cn/v1" + api_key = conf().get("moonshot_api_key") + elif model_name.startswith("qwen"): + api_base = "https://dashscope.aliyuncs.com/compatible-mode/v1" + api_key = conf().get("dashscope_api_key") + else: + api_base = conf().get("open_ai_api_base") or "https://api.openai.com/v1" + api_key = conf().get("open_ai_api_key") + + llm_model = LLMModel(model=model_name, api_key=api_key, api_base=api_base) + return llm_model + + +def _send_text(e_context: EventContext, content: str): + reply = Reply(ReplyType.TEXT, content) + channel = e_context["channel"] + channel.send(reply, e_context["context"]) diff --git a/plugins/agent/config-template.yaml b/plugins/agent/config-template.yaml new file mode 100644 index 0000000..7c30b92 --- /dev/null +++ b/plugins/agent/config-template.yaml @@ -0,0 +1,50 @@ +# 默认选中的Agent Team名称 +team: general_team + +tools: + google_search: + # get your apikey from https://serper.dev/ + api_key: "YOUR API KEY" + +# Team config +teams: + general_team: + model: "gpt-4.1-mini" # 团队使用的模型 + description: "A versatile research and information agent team" + max_steps: 5 + agents: + - name: "通用智能助手" + description: "Universal assistant specializing in research, information synthesis, and task execution" + system_prompt: "You are a versatile assistant who answers questions and completes tasks using available tools. Reply in a clearly structured, attractive and easy to read format." + # Agent 支持使用的工具 + tools: + - time + - calculator + - google_search + - browser + - terminal + + software_team: + model: "gpt-4.1-mini" + description: "A software development team with product manager, developer and tester." + rule: "A normal R&D process should be that Product Manager writes PRD, Developer writes code based on PRD, and Finally, Tester performs testing." + max_steps: 10 + agents: + - name: "Product-Manager" + description: "Responsible for product requirements and documentation" + system_prompt: "You are an experienced product manager who creates concise PRDs, focusing on user needs and feature specifications. You always format your responses in Markdown." + tools: + - time + - file_save + - name: "Developer" + description: "Implements code based on PRD" + system_prompt: "You are a skilled developer. When developing web application, you creates single-page website based on user needs, you deliver HTML files with embedded JavaScript and CSS that are visually appealing, responsive, and user-friendly, featuring a grand layout and beautiful background. The HTML, CSS, and JavaScript code should be well-structured and effectively organized." + tools: + - file_save + - name: "Tester" + description: "Tests code and verifies functionality" + system_prompt: "You are a tester who validates code against requirements. For HTML applications, use browser tools to test functionality. For Python or other client-side applications, use the terminal tool to run and test. You only need to test a few core cases." + tools: + - file_save + - browser + - terminal diff --git a/plugins/godcmd/godcmd.py b/plugins/godcmd/godcmd.py index fe35879..ecf519c 100644 --- a/plugins/godcmd/godcmd.py +++ b/plugins/godcmd/godcmd.py @@ -155,7 +155,7 @@ def get_help_text(isadmin, isgroup): for plugin in plugins: if plugins[plugin].enabled and not plugins[plugin].hidden: namecn = plugins[plugin].namecn - help_text += "\n%s:" % namecn + help_text += "\n%s: " % namecn help_text += PluginManager().instances[plugin].get_help_text(verbose=False).strip() if ADMIN_COMMANDS and isadmin: diff --git a/requirements.txt b/requirements.txt index 255172e..06f285a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,4 @@ Pillow pre-commit web.py linkai>=0.0.6.0 - +agentmesh-sdk>=0.1.2 diff --git a/simple_login_form_test.html b/simple_login_form_test.html new file mode 100644 index 0000000..286a008 --- /dev/null +++ b/simple_login_form_test.html @@ -0,0 +1,278 @@ + + + + + +登录 + + + +
+

用户登录

+
+ + +
+ + +
+ + +
+
+ + +
+ 忘记密码? +
+
+
+
+ +