diff --git a/bridge/agent_event_handler.py b/bridge/agent_event_handler.py index 8bc7f62..b04c77b 100644 --- a/bridge/agent_event_handler.py +++ b/bridge/agent_event_handler.py @@ -94,15 +94,15 @@ class AgentEventHandler: def _send_to_channel(self, message): """ - Try to send message to channel - - Args: - message: Message to send + Try to send intermediate message to channel. + Skipped in SSE mode because thinking text is already streamed via on_event. """ + if self.context and self.context.get("on_event"): + return + if self.channel: try: from bridge.reply import Reply, ReplyType - # Create a Reply object for the message reply = Reply(ReplyType.TEXT, message) self.channel._send(reply, self.context) except Exception as e: diff --git a/channel/channel.py b/channel/channel.py index 42d613f..f01189e 100644 --- a/channel/channel.py +++ b/channel/channel.py @@ -57,11 +57,14 @@ class Channel(object): if context and "channel_type" not in context: context["channel_type"] = self.channel_type + # Read on_event callback injected by the channel (e.g. web SSE) + on_event = context.get("on_event") if context else None + # Use agent bridge to handle the query return Bridge().fetch_agent_reply( query=query, context=context, - on_event=None, + on_event=on_event, clear_history=False ) except Exception as e: diff --git a/channel/web/chat.html b/channel/web/chat.html index 2ad5474..6f28ebc 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -1,1545 +1,524 @@ - + - CowAgent - Personal AI Agent + CowAgent Console + + + + - - - - - - - - - - - -
- - -
-
- - -
AI 助手
- - GitHub - -
- -
- -
-

AI 助手

-

我可以回答问题、提供信息或者帮助您完成各种任务

- -
-
-
📁 系统管理
-
帮我查看工作空间里有哪些文件
-
-
-
⏰ 智能任务
-
提醒我5分钟后查看服务器情况
-
-
-
💻 编程助手
-
帮我编写一个Python爬虫脚本
-
- -
-
- -
- -
-
- - -
-
-
-
- + + + + + + + + + + + +
- // 添加一个变量来跟踪输入法状态 - let isComposing = false; - - // 监听输入法组合状态开始 - input.addEventListener('compositionstart', function() { - isComposing = true; - }); - - // 监听输入法组合状态结束 - input.addEventListener('compositionend', function() { - isComposing = false; - }); - - // 自动调整文本区域高度 - input.addEventListener('input', function() { - this.style.height = 'auto'; - this.style.height = (this.scrollHeight) + 'px'; - - // 启用/禁用发送按钮 - sendButton.disabled = !this.value.trim(); - }); - - // 处理示例卡片点击 - exampleCards.forEach(card => { - card.addEventListener('click', function() { - const exampleText = this.querySelector('.example-text').textContent; - input.value = exampleText; - input.dispatchEvent(new Event('input')); - input.focus(); - }); - }); - - // 处理菜单切换 - menuToggle.addEventListener('click', function(event) { - event.stopPropagation(); // 防止事件冒泡到 main-content - sidebar.classList.toggle('active'); - }); - - // 处理新对话按钮 - 创建新的会话ID和清空当前对话 - newChatButton.addEventListener('click', function() { - // 生成新的会话ID - sessionId = generateSessionId(); - // 将新的会话ID保存到全局变量,供轮询函数使用 - window.sessionId = sessionId; - console.log('New conversation started with new session ID:', sessionId); - - // 清空聊天记录 - clearChat(); - }); - - // 发送按钮点击事件 - sendButton.addEventListener('click', function() { - sendMessage(); - }); - - // 输入框按键事件 - input.addEventListener('keydown', function(event) { - // Ctrl+Enter 或 Shift+Enter 添加换行 - if ((event.ctrlKey || event.shiftKey) && event.key === 'Enter') { - const start = this.selectionStart; - const end = this.selectionEnd; - const value = this.value; - - this.value = value.substring(0, start) + '\n' + value.substring(end); - this.selectionStart = this.selectionEnd = start + 1; - - event.preventDefault(); - } - // 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) { - // 隐藏欢迎屏幕 - const welcomeScreenElement = document.getElementById('welcome-screen'); - if (welcomeScreenElement) { - welcomeScreenElement.remove(); - } - - const timestamp = new Date(); - - // 添加用户消息到界面 - addUserMessage(userMessage, timestamp); - - // 添加一个等待中的机器人消息 - const loadingContainer = addLoadingMessage(); - - // 清空输入框并重置高度 - 移到这里,确保发送后立即清空 - input.value = ''; - input.style.height = '52px'; - sendButton.disabled = true; - - // 使用当前的全局会话ID - const currentSessionId = window.sessionId || sessionId; - - // 发送到服务器并获取请求ID - axios({ - method: 'post', - url: '/message', - data: { - session_id: currentSessionId, // 使用最新的会话ID - message: userMessage, - timestamp: timestamp.toISOString() - }, - timeout: 10000 // 10秒超时 - }) - .then(response => { - 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 => { - console.error('Error sending message:', error); - // 移除加载消息 - if (loadingContainer.parentNode) { - messagesDiv.removeChild(loadingContainer); - } - // 显示错误消息 - if (error.code === 'ECONNABORTED') { - addBotMessage("请求超时,请再试一次吧。", new Date()); - } else { - addBotMessage("抱歉,发生了错误,请稍后再试。", new Date()); - } - }); - } - } - - // 修改轮询函数,确保正确处理多条回复 - function startPolling(sessionId) { - if (window.isPolling) return; - - 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),增加requestId参数 - function addBotMessage(content, timestamp, requestId) { - // 显示消息 - displayBotMessage(content, timestamp, requestId); - - // 保存到localStorage - saveMessageToLocalStorage({ - role: 'assistant', - content: content, - timestamp: timestamp.getTime(), - requestId: requestId - }); - } - - // 修改显示机器人消息的函数,增加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'; - - // 安全地格式化消息 - let formattedContent; - try { - formattedContent = formatMessage(content); - } catch (e) { - console.error('Error formatting bot message:', e); - formattedContent = `

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

`; - } - - messageContainer.innerHTML = ` -
- + + + + + + + + + + + +
+ +
+ + + + +
+ Chat + + Chat +
+ +
+ + + + + + + + + + + +
+ + +
+ + + + +
+ +
+ +
+ CowAgent +

CowAgent

+

I can help you answer questions, manage your computer, create and execute skills,
and keep growing through long-term memory.

+ +
+
+
+
+ +
+ System +
+

Show me the files in the workspace

+
+
+
+
+ +
+ Smart Task +
+

Remind me to check the server in 5 minutes

+
+
+
+
+ +
+ Coding +
+

Write a Python web scraper script

+
+
+
+
+ + +
+
+ + +
- `; - - 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); - }; + + + +
+
+
+
+
+

Configuration

+

Manage model and agent settings

+
+
+
+ +
+
+
+ +
+

Model Configuration

+
+
+
+ Model + -- +
+
+
+ +
+
+
+ +
+

Agent Configuration

+
+
+
+ Agent Mode + -- +
+
+ Max Tokens + -- +
+
+ Max Turns + -- +
+
+ Max Steps + -- +
+
+
+ +
+
+
+ +
+

Channel Configuration

+
+
+
+ Channel Type + -- +
+
+
+
+ +
+ + Full editing capability coming soon. Currently displaying read-only configuration. +
+
+
+
- // 重写链接渲染器 - 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); - }; - }; + + + +
+
+
+
+
+

Skills

+

View, enable, or disable agent skills

+
+
+
+
+ +
+

Loading skills...

+

Skills will be displayed here after loading

+
+
+
+
+
- // 替换 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); - }; + +
+
+
+

Memory

+

View agent memory files and contents

+
+
+
+
+ +
+

Loading memory files...

+

Memory files will be displayed here

+
+ +
- 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, '
'); - } - } + + + +
+
+
+
+
+

Channels

+

View and manage messaging channels

+
+
+
+
+
+
- // 更新 applyHighlighting 函数 - function applyHighlighting() { - try { - document.querySelectorAll('pre code').forEach((block) => { - // 确保代码块有正确的类 - if (!block.classList.contains('hljs')) { - block.classList.add('hljs'); - } + + + +
+
+
+
+
+

Scheduled Tasks

+

View and manage scheduled tasks

+
+
+
+
+ +
+

Loading...

+
+ +
+
+
- // 尝试获取语言 - 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); - } - } + + + +
+
+
+
+
+

Logs

+

Real-time log output (run.log)

+
+
+ +
+
+
+ + + +
+ run.log +
+
+ + Live +
+
+
+

Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.

+
+
+
+
+
- // 在 #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/css/console.css b/channel/web/static/css/console.css new file mode 100644 index 0000000..2390569 --- /dev/null +++ b/channel/web/static/css/console.css @@ -0,0 +1,239 @@ +/* ===================================================================== + CowAgent Console Styles + ===================================================================== */ + +/* Animations */ +@keyframes pulseDot { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } +} + +/* Scrollbar */ +* { scrollbar-width: thin; scrollbar-color: #94a3b8 transparent; } +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #94a3b8; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #64748b; } +.dark ::-webkit-scrollbar-thumb { background: #475569; } +.dark ::-webkit-scrollbar-thumb:hover { background: #64748b; } + +/* Sidebar */ +.sidebar-item.active { + background: rgba(255, 255, 255, 0.08); + color: #FFFFFF; +} +.sidebar-item.active .item-icon { color: #4ABE6E; } + +/* Menu Groups */ +.menu-group-items { max-height: 0; overflow: hidden; transition: max-height 0.25s ease-out; } +.menu-group.open .menu-group-items { max-height: 500px; transition: max-height 0.35s ease-in; } +.menu-group .chevron { transition: transform 0.25s ease; } +.menu-group.open .chevron { transform: rotate(90deg); } + +/* View Switching */ +.view { display: none; height: 100%; } +.view.active { display: flex; flex-direction: column; } + +/* Markdown Content */ +.msg-content p { margin: 0.5em 0; line-height: 1.7; } +.msg-content p:first-child { margin-top: 0; } +.msg-content p:last-child { margin-bottom: 0; } +.msg-content h1, .msg-content h2, .msg-content h3, +.msg-content h4, .msg-content h5, .msg-content h6 { + margin-top: 1.2em; margin-bottom: 0.6em; font-weight: 600; line-height: 1.3; +} +.msg-content h1 { font-size: 1.4em; } +.msg-content h2 { font-size: 1.25em; } +.msg-content h3 { font-size: 1.1em; } +.msg-content ul, .msg-content ol { margin: 0.5em 0; padding-left: 1.8em; } +.msg-content li { margin: 0.25em 0; } +.msg-content pre { + border-radius: 8px; overflow-x: auto; margin: 0.8em 0; + background: #f1f5f9; padding: 1em; +} +.dark .msg-content pre { background: #111111; } +.msg-content code { + font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; + font-size: 0.875em; +} +.msg-content :not(pre) > code { + background: rgba(74, 190, 110, 0.1); color: #1C6B3B; + padding: 2px 6px; border-radius: 4px; +} +.dark .msg-content :not(pre) > code { + background: rgba(74, 190, 110, 0.15); color: #74E9A4; +} +.msg-content pre code { background: transparent; padding: 0; color: inherit; } +.msg-content blockquote { + border-left: 3px solid #4ABE6E; padding: 0.5em 1em; + margin: 0.8em 0; background: rgba(74, 190, 110, 0.05); border-radius: 0 6px 6px 0; +} +.dark .msg-content blockquote { background: rgba(74, 190, 110, 0.08); } +.msg-content table { border-collapse: collapse; width: 100%; margin: 0.8em 0; } +.msg-content th, .msg-content td { + border: 1px solid #e2e8f0; padding: 8px 12px; text-align: left; +} +.dark .msg-content th, .dark .msg-content td { border-color: rgba(255,255,255,0.1); } +.msg-content th { background: #f1f5f9; font-weight: 600; } +.dark .msg-content th { background: #111111; } +.msg-content img { max-width: 100%; height: auto; border-radius: 8px; margin: 0.5em 0; } +.msg-content a { color: #35A85B; text-decoration: underline; } +.msg-content a:hover { color: #228547; } +.msg-content hr { border: none; height: 1px; background: #e2e8f0; margin: 1.2em 0; } +.dark .msg-content hr { background: rgba(255,255,255,0.1); } + +/* SSE Streaming cursor */ +@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } +.sse-streaming::after { + content: '▋'; + display: inline-block; + margin-left: 2px; + color: #4ABE6E; + animation: blink 0.9s step-end infinite; + font-size: 0.85em; + vertical-align: middle; +} + +/* Agent steps (thinking summaries + tool indicators) */ +.agent-steps:empty { display: none; } +.agent-steps:not(:empty) { + margin-bottom: 0.625rem; + padding-bottom: 0.5rem; + border-bottom: 1px dashed rgba(0, 0, 0, 0.08); +} +.dark .agent-steps:not(:empty) { border-bottom-color: rgba(255, 255, 255, 0.08); } + +.agent-step { + font-size: 0.75rem; + line-height: 1.4; + color: #94a3b8; + margin-bottom: 0.25rem; +} +.agent-step:last-child { margin-bottom: 0; } + +/* Thinking step - collapsible */ +.agent-thinking-step .thinking-header { + display: flex; + align-items: center; + gap: 0.375rem; + cursor: pointer; + user-select: none; +} +.agent-thinking-step .thinking-header.no-toggle { cursor: default; } +.agent-thinking-step .thinking-header:not(.no-toggle):hover { color: #64748b; } +.dark .agent-thinking-step .thinking-header:not(.no-toggle):hover { color: #cbd5e1; } +.agent-thinking-step .thinking-header i:first-child { font-size: 0.625rem; margin-top: 1px; } +.agent-thinking-step .thinking-chevron { + font-size: 0.5rem; + margin-left: auto; + transition: transform 0.2s ease; + opacity: 0.5; +} +.agent-thinking-step.expanded .thinking-chevron { transform: rotate(90deg); } +.agent-thinking-step .thinking-full { + display: none; + margin-top: 0.375rem; + margin-left: 1rem; + padding: 0.5rem; + background: rgba(0, 0, 0, 0.02); + border-radius: 6px; + border: 1px solid rgba(0, 0, 0, 0.04); + font-size: 0.75rem; + line-height: 1.5; + color: #94a3b8; + max-height: 200px; + overflow-y: auto; +} +.dark .agent-thinking-step .thinking-full { + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.04); +} +.agent-thinking-step.expanded .thinking-full { display: block; } +.agent-thinking-step .thinking-full p { margin: 0.25em 0; } +.agent-thinking-step .thinking-full p:first-child { margin-top: 0; } +.agent-thinking-step .thinking-full p:last-child { margin-bottom: 0; } + +/* Tool step - collapsible */ +.agent-tool-step .tool-header { + display: flex; + align-items: center; + gap: 0.375rem; + cursor: pointer; + user-select: none; + padding: 1px 0; + border-radius: 4px; +} +.agent-tool-step .tool-header:hover { color: #64748b; } +.dark .agent-tool-step .tool-header:hover { color: #cbd5e1; } +.agent-tool-step .tool-icon { font-size: 0.625rem; } +.agent-tool-step .tool-chevron { + font-size: 0.5rem; + margin-left: auto; + transition: transform 0.2s ease; + opacity: 0.5; +} +.agent-tool-step.expanded .tool-chevron { transform: rotate(90deg); } +.agent-tool-step .tool-time { + font-size: 0.65rem; + opacity: 0.6; + margin-left: 0.25rem; +} + +/* Tool detail panel */ +.agent-tool-step .tool-detail { + display: none; + margin-top: 0.375rem; + margin-left: 1rem; + padding: 0.5rem; + background: rgba(0, 0, 0, 0.02); + border-radius: 6px; + border: 1px solid rgba(0, 0, 0, 0.04); +} +.dark .agent-tool-step .tool-detail { + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.04); +} +.agent-tool-step.expanded .tool-detail { display: block; } +.tool-detail-section { margin-bottom: 0.375rem; } +.tool-detail-section:last-child { margin-bottom: 0; } +.tool-detail-label { + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.6; + margin-bottom: 0.125rem; +} +.tool-detail-content { + font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; + font-size: 0.7rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; + max-height: 200px; + overflow-y: auto; + margin: 0; + padding: 0.25rem 0; + background: transparent; + color: inherit; +} +.tool-error-text { color: #f87171; } + +/* Tool failed state */ +.agent-tool-step.tool-failed .tool-name { color: #f87171; } + +/* Chat Input */ +#chat-input { + resize: none; height: 42px; max-height: 180px; + overflow-y: hidden; + transition: border-color 0.2s ease; +} + +/* Placeholder Cards */ +.placeholder-card { + transition: transform 0.2s ease, box-shadow 0.2s ease; +} +.placeholder-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1); +} diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js new file mode 100644 index 0000000..56ad3e6 --- /dev/null +++ b/channel/web/static/js/console.js @@ -0,0 +1,971 @@ +/* ===================================================================== + CowAgent Console - Main Application Script + ===================================================================== */ + +// ===================================================================== +// Version — update this before each release +// ===================================================================== +const APP_VERSION = 'v2.0.1'; + +// ===================================================================== +// i18n +// ===================================================================== +const I18N = { + zh: { + console: '控制台', + nav_chat: '对话', nav_manage: '管理', nav_monitor: '监控', + menu_chat: '对话', menu_config: '配置', menu_skills: '技能', + menu_memory: '记忆', menu_channels: '通道', menu_tasks: '定时', + menu_logs: '日志', + welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过长期记忆
不断成长', + example_sys_title: '系统管理', example_sys_text: '帮我查看工作空间里有哪些文件', + example_task_title: '智能任务', example_task_text: '提醒我5分钟后查看服务器情况', + example_code_title: '编程助手', example_code_text: '帮我编写一个Python爬虫脚本', + input_placeholder: '输入消息...', + config_title: '配置管理', config_desc: '管理模型和 Agent 配置', + config_model: '模型配置', config_agent: 'Agent 配置', + config_channel: '通道配置', + config_agent_enabled: 'Agent 模式', config_max_tokens: '最大 Token', + config_max_turns: '最大轮次', config_max_steps: '最大步数', + config_channel_type: '通道类型', + config_coming_soon: '完整编辑功能即将推出,当前为只读展示。', + skills_title: '技能管理', skills_desc: '查看、启用或禁用 Agent 技能', + skills_loading: '加载技能中...', skills_loading_desc: '技能加载后将显示在此处', + memory_title: '记忆管理', memory_desc: '查看 Agent 记忆文件和内容', + memory_loading: '加载记忆文件中...', memory_loading_desc: '记忆文件将显示在此处', + memory_back: '返回列表', + memory_col_name: '文件名', memory_col_type: '类型', memory_col_size: '大小', memory_col_updated: '更新时间', + channels_title: '通道管理', channels_desc: '查看和管理消息通道', + channels_coming: '即将推出', channels_coming_desc: '通道管理功能即将在此提供', + tasks_title: '定时任务', tasks_desc: '查看和管理定时任务', + tasks_coming: '即将推出', tasks_coming_desc: '定时任务管理功能即将在此提供', + logs_title: '日志', logs_desc: '实时日志输出 (run.log)', + logs_live: '实时', logs_coming_msg: '日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。', + error_send: '发送失败,请稍后再试。', error_timeout: '请求超时,请再试一次。', + }, + en: { + console: 'Console', + nav_chat: 'Chat', nav_manage: 'Management', nav_monitor: 'Monitor', + menu_chat: 'Chat', menu_config: 'Config', menu_skills: 'Skills', + menu_memory: 'Memory', menu_channels: 'Channels', menu_tasks: 'Tasks', + menu_logs: 'Logs', + welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills, and keep growing through
long-term memory.', + example_sys_title: 'System', example_sys_text: 'Show me the files in the workspace', + example_task_title: 'Smart Task', example_task_text: 'Remind me to check the server in 5 minutes', + example_code_title: 'Coding', example_code_text: 'Write a Python web scraper script', + input_placeholder: 'Type a message...', + config_title: 'Configuration', config_desc: 'Manage model and agent settings', + config_model: 'Model Configuration', config_agent: 'Agent Configuration', + config_channel: 'Channel Configuration', + config_agent_enabled: 'Agent Mode', config_max_tokens: 'Max Tokens', + config_max_turns: 'Max Turns', config_max_steps: 'Max Steps', + config_channel_type: 'Channel Type', + config_coming_soon: 'Full editing capability coming soon. Currently displaying read-only configuration.', + skills_title: 'Skills', skills_desc: 'View, enable, or disable agent skills', + skills_loading: 'Loading skills...', skills_loading_desc: 'Skills will be displayed here after loading', + memory_title: 'Memory', memory_desc: 'View agent memory files and contents', + memory_loading: 'Loading memory files...', memory_loading_desc: 'Memory files will be displayed here', + memory_back: 'Back to list', + memory_col_name: 'Filename', memory_col_type: 'Type', memory_col_size: 'Size', memory_col_updated: 'Updated', + channels_title: 'Channels', channels_desc: 'View and manage messaging channels', + channels_coming: 'Coming Soon', channels_coming_desc: 'Channel management will be available here', + tasks_title: 'Scheduled Tasks', tasks_desc: 'View and manage scheduled tasks', + tasks_coming: 'Coming Soon', tasks_coming_desc: 'Scheduled task management will be available here', + logs_title: 'Logs', logs_desc: 'Real-time log output (run.log)', + logs_live: 'Live', logs_coming_msg: 'Log streaming will be available here. Connects to run.log for real-time output similar to tail -f.', + error_send: 'Failed to send. Please try again.', error_timeout: 'Request timeout. Please try again.', + } +}; + +let currentLang = localStorage.getItem('cow_lang') || 'zh'; + +function t(key) { + return (I18N[currentLang] && I18N[currentLang][key]) || (I18N.en[key]) || key; +} + +function applyI18n() { + document.querySelectorAll('[data-i18n]').forEach(el => { + el.textContent = t(el.dataset.i18n); + }); + document.querySelectorAll('[data-i18n-html]').forEach(el => { + el.innerHTML = t(el.dataset.i18nHtml); + }); + document.querySelectorAll('[data-i18n-placeholder]').forEach(el => { + el.placeholder = t(el.dataset['i18nPlaceholder']); + }); + document.getElementById('lang-label').textContent = currentLang === 'zh' ? 'EN' : '中文'; +} + +function toggleLanguage() { + currentLang = currentLang === 'zh' ? 'en' : 'zh'; + localStorage.setItem('cow_lang', currentLang); + applyI18n(); +} + +// ===================================================================== +// Theme +// ===================================================================== +let currentTheme = localStorage.getItem('cow_theme') || 'dark'; + +function applyTheme() { + const root = document.documentElement; + if (currentTheme === 'dark') { + root.classList.add('dark'); + document.getElementById('theme-icon').className = 'fas fa-sun'; + document.getElementById('hljs-light').disabled = true; + document.getElementById('hljs-dark').disabled = false; + } else { + root.classList.remove('dark'); + document.getElementById('theme-icon').className = 'fas fa-moon'; + document.getElementById('hljs-light').disabled = false; + document.getElementById('hljs-dark').disabled = true; + } +} + +function toggleTheme() { + currentTheme = currentTheme === 'dark' ? 'light' : 'dark'; + localStorage.setItem('cow_theme', currentTheme); + applyTheme(); +} + +// ===================================================================== +// Sidebar & Navigation +// ===================================================================== +const VIEW_META = { + chat: { group: 'nav_chat', page: 'menu_chat' }, + config: { group: 'nav_manage', page: 'menu_config' }, + skills: { group: 'nav_manage', page: 'menu_skills' }, + memory: { group: 'nav_manage', page: 'menu_memory' }, + channels: { group: 'nav_manage', page: 'menu_channels' }, + tasks: { group: 'nav_manage', page: 'menu_tasks' }, + logs: { group: 'nav_monitor', page: 'menu_logs' }, +}; + +let currentView = 'chat'; + +function navigateTo(viewId) { + if (!VIEW_META[viewId]) return; + document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); + const target = document.getElementById('view-' + viewId); + if (target) target.classList.add('active'); + document.querySelectorAll('.sidebar-item').forEach(item => { + item.classList.toggle('active', item.dataset.view === viewId); + }); + const meta = VIEW_META[viewId]; + document.getElementById('breadcrumb-group').textContent = t(meta.group); + document.getElementById('breadcrumb-group').dataset.i18n = meta.group; + document.getElementById('breadcrumb-page').textContent = t(meta.page); + document.getElementById('breadcrumb-page').dataset.i18n = meta.page; + currentView = viewId; + if (window.innerWidth < 1024) closeSidebar(); +} + +function toggleSidebar() { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebar-overlay'); + const isOpen = !sidebar.classList.contains('-translate-x-full'); + if (isOpen) { + closeSidebar(); + } else { + sidebar.classList.remove('-translate-x-full'); + overlay.classList.remove('hidden'); + } +} + +function closeSidebar() { + document.getElementById('sidebar').classList.add('-translate-x-full'); + document.getElementById('sidebar-overlay').classList.add('hidden'); +} + +document.querySelectorAll('.menu-group > button').forEach(btn => { + btn.addEventListener('click', () => { + btn.parentElement.classList.toggle('open'); + }); +}); + +document.querySelectorAll('.sidebar-item').forEach(item => { + item.addEventListener('click', () => navigateTo(item.dataset.view)); +}); + +window.addEventListener('resize', () => { + if (window.innerWidth >= 1024) { + document.getElementById('sidebar').classList.remove('-translate-x-full'); + document.getElementById('sidebar-overlay').classList.add('hidden'); + } else { + if (!document.getElementById('sidebar').classList.contains('-translate-x-full')) { + closeSidebar(); + } + } +}); + +// ===================================================================== +// Markdown Renderer +// ===================================================================== +function createMd() { + const md = window.markdownit({ + html: false, breaks: true, linkify: true, typographer: true, + highlight: function(str, lang) { + if (lang && hljs.getLanguage(lang)) { + try { return hljs.highlight(str, { language: lang }).value; } catch (_) {} + } + return hljs.highlightAuto(str).value; + } + }); + const defaultLinkOpen = 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) { + tokens[idx].attrPush(['target', '_blank']); + tokens[idx].attrPush(['rel', 'noopener noreferrer']); + return defaultLinkOpen(tokens, idx, options, env, self); + }; + return md; +} + +const md = createMd(); + +function renderMarkdown(text) { + try { return md.render(text); } + catch (e) { return text.replace(/\n/g, '
'); } +} + +// ===================================================================== +// Chat Module +// ===================================================================== +let sessionId = generateSessionId(); +let isPolling = false; +let loadingContainers = {}; +let activeStreams = {}; // request_id -> EventSource +let isComposing = false; +let appConfig = { use_agent: false, title: 'CowAgent', subtitle: '' }; + +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) + ); +} + +fetch('/config').then(r => r.json()).then(data => { + if (data.status === 'success') { + appConfig = data; + const title = data.title || 'CowAgent'; + document.getElementById('welcome-title').textContent = title; + document.getElementById('cfg-model').textContent = data.model || '--'; + document.getElementById('cfg-agent').textContent = data.use_agent ? 'Enabled' : 'Disabled'; + document.getElementById('cfg-max-tokens').textContent = data.agent_max_context_tokens || '--'; + document.getElementById('cfg-max-turns').textContent = data.agent_max_context_turns || '--'; + document.getElementById('cfg-max-steps').textContent = data.agent_max_steps || '--'; + document.getElementById('cfg-channel').textContent = data.channel_type || '--'; + } +}).catch(() => {}); + +const chatInput = document.getElementById('chat-input'); +const sendBtn = document.getElementById('send-btn'); +const messagesDiv = document.getElementById('chat-messages'); + +chatInput.addEventListener('compositionstart', () => { isComposing = true; }); +chatInput.addEventListener('compositionend', () => { isComposing = false; }); + +chatInput.addEventListener('input', function() { + this.style.height = '42px'; + const scrollH = this.scrollHeight; + const newH = Math.min(scrollH, 180); + this.style.height = newH + 'px'; + this.style.overflowY = scrollH > 180 ? 'auto' : 'hidden'; + sendBtn.disabled = !this.value.trim(); +}); + +chatInput.addEventListener('keydown', function(e) { + if ((e.ctrlKey || e.shiftKey) && e.key === 'Enter') { + const start = this.selectionStart; + const end = this.selectionEnd; + this.value = this.value.substring(0, start) + '\n' + this.value.substring(end); + this.selectionStart = this.selectionEnd = start + 1; + this.dispatchEvent(new Event('input')); + e.preventDefault(); + } else if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !isComposing) { + sendMessage(); + e.preventDefault(); + } +}); + +document.querySelectorAll('.example-card').forEach(card => { + card.addEventListener('click', () => { + const textEl = card.querySelector('[data-i18n*="text"]'); + if (textEl) { + chatInput.value = textEl.textContent; + chatInput.dispatchEvent(new Event('input')); + chatInput.focus(); + } + }); +}); + +function sendMessage() { + const text = chatInput.value.trim(); + if (!text) return; + + const ws = document.getElementById('welcome-screen'); + if (ws) ws.remove(); + + const timestamp = new Date(); + addUserMessage(text, timestamp); + + const loadingEl = addLoadingIndicator(); + + chatInput.value = ''; + chatInput.style.height = '42px'; + chatInput.style.overflowY = 'hidden'; + sendBtn.disabled = true; + + fetch('/message', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId, message: text, stream: true, timestamp: timestamp.toISOString() }) + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'success') { + if (data.stream) { + startSSE(data.request_id, loadingEl, timestamp); + } else { + loadingContainers[data.request_id] = loadingEl; + if (!isPolling) startPolling(); + } + } else { + loadingEl.remove(); + addBotMessage(t('error_send'), new Date()); + } + }) + .catch(err => { + loadingEl.remove(); + addBotMessage(err.name === 'AbortError' ? t('error_timeout') : t('error_send'), new Date()); + }); +} + +function startSSE(requestId, loadingEl, timestamp) { + const es = new EventSource(`/stream?request_id=${encodeURIComponent(requestId)}`); + activeStreams[requestId] = es; + + let botEl = null; + let stepsEl = null; // .agent-steps (thinking summaries + tool indicators) + let contentEl = null; // .answer-content (final streaming answer) + let accumulatedText = ''; + let currentToolEl = null; + + function ensureBotEl() { + if (botEl) return; + if (loadingEl) { loadingEl.remove(); loadingEl = null; } + botEl = document.createElement('div'); + botEl.className = 'flex gap-3 px-4 sm:px-6 py-3'; + botEl.dataset.requestId = requestId; + botEl.innerHTML = ` + CowAgent +
+
+
+
+
+
${formatTime(timestamp)}
+
+ `; + messagesDiv.appendChild(botEl); + stepsEl = botEl.querySelector('.agent-steps'); + contentEl = botEl.querySelector('.answer-content'); + } + + es.onmessage = function(e) { + let item; + try { item = JSON.parse(e.data); } catch (_) { return; } + + if (item.type === 'delta') { + ensureBotEl(); + accumulatedText += item.content; + contentEl.innerHTML = renderMarkdown(accumulatedText); + scrollChatToBottom(); + + } else if (item.type === 'tool_start') { + ensureBotEl(); + + // Save current thinking as a collapsible step + if (accumulatedText.trim()) { + const fullText = accumulatedText.trim(); + const oneLine = fullText.replace(/\n+/g, ' '); + const needsTruncate = oneLine.length > 80; + const stepEl = document.createElement('div'); + stepEl.className = 'agent-step agent-thinking-step' + (needsTruncate ? '' : ' no-expand'); + if (needsTruncate) { + const truncated = oneLine.substring(0, 80) + '…'; + stepEl.innerHTML = ` +
+ + ${escapeHtml(truncated)} + +
+
${renderMarkdown(fullText)}
`; + } else { + stepEl.innerHTML = ` +
+ + ${escapeHtml(oneLine)} +
`; + } + stepsEl.appendChild(stepEl); + } + accumulatedText = ''; + contentEl.innerHTML = ''; + + // Add tool execution indicator (collapsible) + currentToolEl = document.createElement('div'); + currentToolEl.className = 'agent-step agent-tool-step'; + const argsStr = formatToolArgs(item.arguments || {}); + currentToolEl.innerHTML = ` +
+ + ${item.tool} + +
+
+
+
Input
+
${argsStr}
+
+
+
`; + stepsEl.appendChild(currentToolEl); + + scrollChatToBottom(); + + } else if (item.type === 'tool_end') { + if (currentToolEl) { + const isError = item.status !== 'success'; + const icon = currentToolEl.querySelector('.tool-icon'); + icon.className = isError + ? 'fas fa-times text-red-400 flex-shrink-0 tool-icon' + : 'fas fa-check text-primary-400 flex-shrink-0 tool-icon'; + + // Show execution time + const nameEl = currentToolEl.querySelector('.tool-name'); + if (item.execution_time !== undefined) { + nameEl.innerHTML += ` ${item.execution_time}s`; + } + + // Fill output section + const outputSection = currentToolEl.querySelector('.tool-output-section'); + if (outputSection && item.result) { + outputSection.innerHTML = ` +
${isError ? 'Error' : 'Output'}
+
${escapeHtml(String(item.result))}
`; + } + + if (isError) currentToolEl.classList.add('tool-failed'); + currentToolEl = null; + } + + } else if (item.type === 'done') { + es.close(); + delete activeStreams[requestId]; + + const finalText = item.content || accumulatedText; + + if (!botEl && finalText) { + if (loadingEl) { loadingEl.remove(); loadingEl = null; } + addBotMessage(finalText, new Date((item.timestamp || Date.now() / 1000) * 1000), requestId); + } else if (botEl) { + contentEl.classList.remove('sse-streaming'); + if (finalText) contentEl.innerHTML = renderMarkdown(finalText); + applyHighlighting(botEl); + } + scrollChatToBottom(); + + } else if (item.type === 'error') { + es.close(); + delete activeStreams[requestId]; + if (loadingEl) { loadingEl.remove(); loadingEl = null; } + addBotMessage(t('error_send'), new Date()); + } + }; + + es.onerror = function() { + es.close(); + delete activeStreams[requestId]; + if (loadingEl) { loadingEl.remove(); loadingEl = null; } + if (!botEl) { + addBotMessage(t('error_send'), new Date()); + } else if (accumulatedText) { + contentEl.classList.remove('sse-streaming'); + contentEl.innerHTML = renderMarkdown(accumulatedText); + applyHighlighting(botEl); + } + }; +} + +function startPolling() { + if (isPolling) return; + isPolling = true; + + function poll() { + if (!isPolling) return; + if (document.hidden) { setTimeout(poll, 5000); return; } + + fetch('/poll', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId }) + }) + .then(r => r.json()) + .then(data => { + if (data.status === 'success' && data.has_content) { + const rid = data.request_id; + if (loadingContainers[rid]) { + loadingContainers[rid].remove(); + delete loadingContainers[rid]; + } + addBotMessage(data.content, new Date(data.timestamp * 1000), rid); + scrollChatToBottom(); + } + setTimeout(poll, 2000); + }) + .catch(() => { setTimeout(poll, 3000); }); + } + poll(); +} + +function addUserMessage(content, timestamp) { + const el = document.createElement('div'); + el.className = 'flex justify-end px-4 sm:px-6 py-3'; + el.innerHTML = ` +
+
+ ${renderMarkdown(content)} +
+
${formatTime(timestamp)}
+
+ `; + messagesDiv.appendChild(el); + scrollChatToBottom(); +} + +function addBotMessage(content, timestamp, requestId) { + const el = document.createElement('div'); + el.className = 'flex gap-3 px-4 sm:px-6 py-3'; + if (requestId) el.dataset.requestId = requestId; + el.innerHTML = ` + CowAgent +
+
+ ${renderMarkdown(content)} +
+
${formatTime(timestamp)}
+
+ `; + messagesDiv.appendChild(el); + applyHighlighting(el); + scrollChatToBottom(); +} + +function addLoadingIndicator() { + const el = document.createElement('div'); + el.className = 'flex gap-3 px-4 sm:px-6 py-3'; + el.innerHTML = ` + CowAgent +
+
+ + + +
+
+ `; + messagesDiv.appendChild(el); + scrollChatToBottom(); + return el; +} + +function newChat() { + // Close all active SSE connections for the current session + Object.values(activeStreams).forEach(es => { try { es.close(); } catch (_) {} }); + activeStreams = {}; + + sessionId = generateSessionId(); + isPolling = false; + loadingContainers = {}; + messagesDiv.innerHTML = ''; + const ws = document.createElement('div'); + ws.id = 'welcome-screen'; + ws.className = 'flex flex-col items-center justify-center h-full px-6 py-12'; + ws.innerHTML = ` + CowAgent +

${appConfig.title || 'CowAgent'}

+

${t('welcome_subtitle')}

+
+
+
+
+ +
+ ${t('example_sys_title')} +
+

${t('example_sys_text')}

+
+
+
+
+ +
+ ${t('example_task_title')} +
+

${t('example_task_text')}

+
+
+
+
+ +
+ ${t('example_code_title')} +
+

${t('example_code_text')}

+
+
+ `; + messagesDiv.appendChild(ws); + ws.querySelectorAll('.example-card').forEach(card => { + card.addEventListener('click', () => { + const textEl = card.querySelector('[data-i18n*="text"]'); + if (textEl) { + chatInput.value = textEl.textContent; + chatInput.dispatchEvent(new Event('input')); + chatInput.focus(); + } + }); + }); + if (currentView !== 'chat') navigateTo('chat'); +} + +// ===================================================================== +// Utilities +// ===================================================================== +function formatTime(date) { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; +} + +function formatToolArgs(args) { + if (!args || Object.keys(args).length === 0) return '(none)'; + try { + return escapeHtml(JSON.stringify(args, null, 2)); + } catch (_) { + return escapeHtml(String(args)); + } +} + +function scrollChatToBottom() { + messagesDiv.scrollTop = messagesDiv.scrollHeight; +} + +function applyHighlighting(container) { + const root = container || document; + setTimeout(() => { + root.querySelectorAll('pre code').forEach(block => { + if (!block.classList.contains('hljs')) { + hljs.highlightElement(block); + } + }); + }, 0); +} + +// ===================================================================== +// Config View +// ===================================================================== +function loadConfigView() { + fetch('/config').then(r => r.json()).then(data => { + if (data.status !== 'success') return; + document.getElementById('cfg-model').textContent = data.model || '--'; + document.getElementById('cfg-agent').textContent = data.use_agent ? 'Enabled' : 'Disabled'; + document.getElementById('cfg-max-tokens').textContent = data.agent_max_context_tokens || '--'; + document.getElementById('cfg-max-turns').textContent = data.agent_max_context_turns || '--'; + document.getElementById('cfg-max-steps').textContent = data.agent_max_steps || '--'; + document.getElementById('cfg-channel').textContent = data.channel_type || '--'; + }).catch(() => {}); +} + +// ===================================================================== +// Skills View +// ===================================================================== +let skillsLoaded = false; +function loadSkillsView() { + if (skillsLoaded) return; + fetch('/api/skills').then(r => r.json()).then(data => { + if (data.status !== 'success') return; + const emptyEl = document.getElementById('skills-empty'); + const listEl = document.getElementById('skills-list'); + const skills = data.skills || []; + if (skills.length === 0) { + emptyEl.querySelector('p').textContent = currentLang === 'zh' ? '暂无技能' : 'No skills found'; + return; + } + emptyEl.classList.add('hidden'); + listEl.innerHTML = ''; + + const builtins = skills.filter(s => s.source === 'builtin'); + const customs = skills.filter(s => s.source !== 'builtin'); + + function renderGroup(title, items) { + if (items.length === 0) return; + const header = document.createElement('div'); + header.className = 'sm:col-span-2 text-xs font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500 mt-2'; + header.textContent = title; + listEl.appendChild(header); + items.forEach(sk => { + const card = document.createElement('div'); + card.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-4 flex items-start gap-3'; + const iconColor = sk.enabled ? 'text-primary-400' : 'text-slate-300 dark:text-slate-600'; + const statusDot = sk.enabled + ? '' + : ''; + card.innerHTML = ` +
+ +
+
+
+ ${escapeHtml(sk.name)} + ${statusDot} +
+

${escapeHtml(sk.description || '--')}

+
`; + listEl.appendChild(card); + }); + } + renderGroup(currentLang === 'zh' ? '内置技能' : 'Built-in Skills', builtins); + renderGroup(currentLang === 'zh' ? '自定义技能' : 'Custom Skills', customs); + skillsLoaded = true; + }).catch(() => {}); +} + +// ===================================================================== +// Memory View +// ===================================================================== +let memoryPage = 1; +const memoryPageSize = 10; + +function loadMemoryView(page) { + page = page || 1; + memoryPage = page; + fetch(`/api/memory?page=${page}&page_size=${memoryPageSize}`).then(r => r.json()).then(data => { + if (data.status !== 'success') return; + const emptyEl = document.getElementById('memory-empty'); + const listEl = document.getElementById('memory-list'); + const files = data.list || []; + const total = data.total || 0; + + if (total === 0) { + emptyEl.querySelector('p').textContent = currentLang === 'zh' ? '暂无记忆文件' : 'No memory files'; + emptyEl.classList.remove('hidden'); + listEl.classList.add('hidden'); + return; + } + emptyEl.classList.add('hidden'); + listEl.classList.remove('hidden'); + + const tbody = document.getElementById('memory-table-body'); + tbody.innerHTML = ''; + files.forEach(f => { + const tr = document.createElement('tr'); + tr.className = 'border-b border-slate-100 dark:border-white/5 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors'; + tr.onclick = () => openMemoryFile(f.filename); + const typeLabel = f.type === 'global' + ? 'Global' + : 'Daily'; + const sizeStr = f.size < 1024 ? f.size + ' B' : (f.size / 1024).toFixed(1) + ' KB'; + tr.innerHTML = ` + ${escapeHtml(f.filename)} + ${typeLabel} + ${sizeStr} + ${escapeHtml(f.updated_at)}`; + tbody.appendChild(tr); + }); + + // Pagination + const totalPages = Math.ceil(total / memoryPageSize); + const pagEl = document.getElementById('memory-pagination'); + if (totalPages <= 1) { pagEl.innerHTML = ''; return; } + let pagHtml = `${page} / ${totalPages}
`; + if (page > 1) pagHtml += ``; + if (page < totalPages) pagHtml += ``; + pagHtml += '
'; + pagEl.innerHTML = pagHtml; + }).catch(() => {}); +} + +function openMemoryFile(filename) { + fetch(`/api/memory/content?filename=${encodeURIComponent(filename)}`).then(r => r.json()).then(data => { + if (data.status !== 'success') return; + document.getElementById('memory-panel-list').classList.add('hidden'); + const panel = document.getElementById('memory-panel-viewer'); + document.getElementById('memory-viewer-title').textContent = filename; + document.getElementById('memory-viewer-content').innerHTML = renderMarkdown(data.content || ''); + panel.classList.remove('hidden'); + applyHighlighting(panel); + }).catch(() => {}); +} + +function closeMemoryViewer() { + document.getElementById('memory-panel-viewer').classList.add('hidden'); + document.getElementById('memory-panel-list').classList.remove('hidden'); +} + +// ===================================================================== +// Channels View +// ===================================================================== +function loadChannelsView() { + const container = document.getElementById('channels-content'); + const channelType = appConfig.channel_type || 'web'; + const channelMap = { + web: { name: 'Web', icon: 'fa-globe', color: 'primary' }, + terminal: { name: 'Terminal', icon: 'fa-terminal', color: 'slate' }, + feishu: { name: 'Feishu', icon: 'fa-paper-plane', color: 'blue' }, + dingtalk: { name: 'DingTalk', icon: 'fa-comments', color: 'blue' }, + wechatcom_app: { name: 'WeCom', icon: 'fa-building', color: 'emerald' }, + wechatmp: { name: 'WeChat MP', icon: 'fa-comment-dots', color: 'emerald' }, + wechatmp_service: { name: 'WeChat Service', icon: 'fa-comment-dots', color: 'emerald' }, + }; + const info = channelMap[channelType] || { name: channelType, icon: 'fa-tower-broadcast', color: 'sky' }; + container.innerHTML = ` +
+
+ +
+
+
+ ${info.name} + + Active +
+

${escapeHtml(channelType)}

+
+
`; +} + +// ===================================================================== +// Scheduler View +// ===================================================================== +let tasksLoaded = false; +function loadTasksView() { + if (tasksLoaded) return; + fetch('/api/scheduler').then(r => r.json()).then(data => { + if (data.status !== 'success') return; + const emptyEl = document.getElementById('tasks-empty'); + const listEl = document.getElementById('tasks-list'); + const allTasks = data.tasks || []; + // Only show active (enabled) tasks + const tasks = allTasks.filter(t => t.enabled !== false); + if (tasks.length === 0) { + emptyEl.querySelector('p').textContent = currentLang === 'zh' ? '暂无定时任务' : 'No scheduled tasks'; + return; + } + emptyEl.classList.add('hidden'); + listEl.classList.remove('hidden'); + listEl.innerHTML = ''; + + tasks.forEach(task => { + const card = document.createElement('div'); + card.className = 'bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 p-4'; + const typeLabel = task.type === 'cron' + ? `${escapeHtml(task.cron || '')}` + : `${escapeHtml(task.type || 'once')}`; + let nextRun = '--'; + if (task.next_run_at) { + // next_run_at is an ISO string, not a Unix timestamp + const d = new Date(task.next_run_at); + if (!isNaN(d.getTime())) nextRun = d.toLocaleString(); + } + card.innerHTML = ` +
+ + ${escapeHtml(task.name || task.id || '--')} +
+ ${typeLabel} +
+

${escapeHtml(task.prompt || task.description || '')}

+
+ ${currentLang === 'zh' ? '下次执行' : 'Next run'}: ${nextRun} +
`; + listEl.appendChild(card); + }); + tasksLoaded = true; + }).catch(() => {}); +} + +// ===================================================================== +// Logs View +// ===================================================================== +let logEventSource = null; + +function startLogStream() { + if (logEventSource) return; + const output = document.getElementById('log-output'); + output.innerHTML = ''; + + logEventSource = new EventSource('/api/logs'); + logEventSource.onmessage = function(e) { + let item; + try { item = JSON.parse(e.data); } catch (_) { return; } + + if (item.type === 'init') { + output.textContent = item.content || ''; + output.scrollTop = output.scrollHeight; + } else if (item.type === 'line') { + output.textContent += item.content; + output.scrollTop = output.scrollHeight; + } else if (item.type === 'error') { + output.textContent = item.message || 'Error loading logs'; + } + }; + logEventSource.onerror = function() { + logEventSource.close(); + logEventSource = null; + }; +} + +function stopLogStream() { + if (logEventSource) { + logEventSource.close(); + logEventSource = null; + } +} + +// ===================================================================== +// View Navigation Hook +// ===================================================================== +const _origNavigateTo = navigateTo; +navigateTo = function(viewId) { + // Stop log stream when leaving logs view + if (currentView === 'logs' && viewId !== 'logs') stopLogStream(); + + _origNavigateTo(viewId); + + // Lazy-load view data + if (viewId === 'skills') loadSkillsView(); + else if (viewId === 'memory') { + // Always start from the list panel when navigating to memory + document.getElementById('memory-panel-viewer').classList.add('hidden'); + document.getElementById('memory-panel-list').classList.remove('hidden'); + loadMemoryView(1); + } + else if (viewId === 'channels') loadChannelsView(); + else if (viewId === 'tasks') loadTasksView(); + else if (viewId === 'logs') startLogStream(); +}; + +// ===================================================================== +// Initialization +// ===================================================================== +applyTheme(); +applyI18n(); +document.getElementById('sidebar-version').textContent = `CowAgent ${APP_VERSION}`; +chatInput.focus(); diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 43c245e..690211e 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -3,7 +3,6 @@ import time import web import json import uuid -import io from queue import Queue, Empty from bridge.context import * from bridge.reply import Reply, ReplyType @@ -13,7 +12,7 @@ from common.log import logger from common.singleton import singleton from config import conf import os -import mimetypes # 添加这行来处理MIME类型 +import mimetypes import threading import logging @@ -47,9 +46,10 @@ class WebChannel(ChatChannel): def __init__(self): super().__init__() - self.msg_id_counter = 0 # 添加消息ID计数器 - self.session_queues = {} # 存储session_id到队列的映射 - self.request_to_session = {} # 存储request_id到session_id的映射 + self.msg_id_counter = 0 + self.session_queues = {} # session_id -> Queue (fallback polling) + self.request_to_session = {} # request_id -> session_id + self.sse_queues = {} # request_id -> Queue (SSE streaming) self._http_server = None @@ -71,22 +71,30 @@ class WebChannel(ChatChannel): 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 - - # 通过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 - - # 检查是否有会话队列 + + # SSE mode: push done event to SSE queue + if request_id in self.sse_queues: + content = reply.content if reply.content is not None else "" + self.sse_queues[request_id].put({ + "type": "done", + "content": content, + "request_id": request_id, + "timestamp": time.time() + }) + logger.debug(f"SSE done sent for request {request_id}") + return + + # Fallback: polling mode if session_id in self.session_queues: - # 创建响应数据,包含请求ID以区分不同请求的响应 response_data = { "type": str(reply.type), "content": reply.content, @@ -94,69 +102,133 @@ class WebChannel(ChatChannel): "request_id": request_id } self.session_queues[session_id].put(response_data) - logger.debug(f"Response sent to queue for session {session_id}, request {request_id}") + logger.debug(f"Response sent to poll queue for session {session_id}, request {request_id}") else: 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}") + def _make_sse_callback(self, request_id: str): + """Build an on_event callback that pushes agent stream events into the SSE queue.""" + def on_event(event: dict): + if request_id not in self.sse_queues: + return + q = self.sse_queues[request_id] + event_type = event.get("type") + data = event.get("data", {}) + + if event_type == "message_update": + delta = data.get("delta", "") + if delta: + q.put({"type": "delta", "content": delta}) + + elif event_type == "tool_execution_start": + tool_name = data.get("tool_name", "tool") + arguments = data.get("arguments", {}) + q.put({"type": "tool_start", "tool": tool_name, "arguments": arguments}) + + elif event_type == "tool_execution_end": + tool_name = data.get("tool_name", "tool") + status = data.get("status", "success") + result = data.get("result", "") + exec_time = data.get("execution_time", 0) + # Truncate long results to avoid huge SSE payloads + result_str = str(result) + if len(result_str) > 2000: + result_str = result_str[:2000] + "…" + q.put({ + "type": "tool_end", + "tool": tool_name, + "status": status, + "result": result_str, + "execution_time": round(exec_time, 2) + }) + + return on_event + 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数据 + data = web.data() json_data = json.loads(data) session_id = json_data.get('session_id', f'session_{int(time.time())}') prompt = json_data.get('message', '') - - # 生成请求ID + use_sse = json_data.get('stream', True) + request_id = self._generate_request_id() - - # 将请求ID与会话ID关联 self.request_to_session[request_id] = session_id - - # 确保会话队列存在 + if session_id not in self.session_queues: self.session_queues[session_id] = Queue() - - # Web channel 不需要前缀,确保消息能通过前缀检查 + + if use_sse: + self.sse_queues[request_id] = Queue() + trigger_prefixs = conf().get("single_chat_prefix", [""]) if check_prefix(prompt, trigger_prefixs) is None: - # 如果没有匹配到前缀,给消息加上第一个前缀 if trigger_prefixs: prompt = trigger_prefixs[0] + prompt logger.debug(f"[WebChannel] Added prefix to message: {prompt}") - - # 创建消息对象 + msg = WebMessage(self._generate_msg_id(), prompt) - msg.from_user_id = session_id # 使用会话ID作为用户ID - - # 创建上下文,明确指定 isgroup=False + msg.from_user_id = session_id + context = self._compose_context(ContextType.TEXT, prompt, msg=msg, isgroup=False) - - # 检查 context 是否为 None(可能被插件过滤等) + if context is None: logger.warning(f"[WebChannel] Context is None for session {session_id}, message may be filtered") + if request_id in self.sse_queues: + del self.sse_queues[request_id] return json.dumps({"status": "error", "message": "Message was filtered"}) - # 覆盖必要的字段(_compose_context 会设置默认值,但我们需要使用实际的 session_id) context["session_id"] = session_id context["receiver"] = session_id context["request_id"] = request_id - - # 异步处理消息 - 只传递上下文 + + if use_sse: + context["on_event"] = self._make_sse_callback(request_id) + threading.Thread(target=self.produce, args=(context,)).start() - - # 返回请求ID - return json.dumps({"status": "success", "request_id": request_id}) - + + return json.dumps({"status": "success", "request_id": request_id, "stream": use_sse}) + except Exception as e: logger.error(f"Error processing message: {e}") return json.dumps({"status": "error", "message": str(e)}) + def stream_response(self, request_id: str): + """ + SSE generator for a given request_id. + Yields UTF-8 encoded bytes to avoid WSGI Latin-1 mangling. + """ + if request_id not in self.sse_queues: + yield b"data: {\"type\": \"error\", \"message\": \"invalid request_id\"}\n\n" + return + + q = self.sse_queues[request_id] + timeout = 300 # 5 minutes max + deadline = time.time() + timeout + + try: + while time.time() < deadline: + try: + item = q.get(timeout=1) + except Empty: + yield b": keepalive\n\n" + continue + + payload = json.dumps(item, ensure_ascii=False) + yield f"data: {payload}\n\n".encode("utf-8") + + if item.get("type") == "done": + break + finally: + self.sse_queues.pop(request_id, None) + def poll_response(self): """ Poll for responses using the session_id. @@ -209,8 +281,8 @@ class WebChannel(ChatChannel): logger.info("[WebChannel] 5. wechatcom_app - 企微自建应用") logger.info("[WebChannel] 6. wechatmp - 个人公众号") logger.info("[WebChannel] 7. wechatmp_service - 企业公众号") - logger.info(f"[WebChannel] 🌐 本地访问: http://localhost:{port}/chat") - logger.info(f"[WebChannel] 🌍 服务器访问: http://YOUR_IP:{port}/chat (请将YOUR_IP替换为服务器IP)") + logger.info(f"[WebChannel] 🌐 本地访问: http://localhost:{port}") + logger.info(f"[WebChannel] 🌍 服务器访问: http://YOUR_IP:{port} (请将YOUR_IP替换为服务器IP)") logger.info("[WebChannel] ✅ Web对话网页已运行") # 确保静态文件目录存在 @@ -223,8 +295,14 @@ class WebChannel(ChatChannel): '/', 'RootHandler', '/message', 'MessageHandler', '/poll', 'PollHandler', + '/stream', 'StreamHandler', '/chat', 'ChatHandler', '/config', 'ConfigHandler', + '/api/skills', 'SkillsHandler', + '/api/memory', 'MemoryHandler', + '/api/memory/content', 'MemoryContentHandler', + '/api/scheduler', 'SchedulerHandler', + '/api/logs', 'LogsHandler', '/assets/(.*)', 'AssetsHandler', ) app = web.application(urls, globals(), autoreload=False) @@ -272,6 +350,21 @@ class PollHandler: return WebChannel().poll_response() +class StreamHandler: + def GET(self): + params = web.input(request_id='') + request_id = params.request_id + if not request_id: + raise web.badrequest() + + web.header('Content-Type', 'text/event-stream; charset=utf-8') + web.header('Cache-Control', 'no-cache') + web.header('X-Accel-Buffering', 'no') + web.header('Access-Control-Allow-Origin', '*') + + return WebChannel().stream_response(request_id) + + class ChatHandler: def GET(self): # 正常返回聊天页面 @@ -282,28 +375,150 @@ class ChatHandler: class ConfigHandler: def GET(self): - """返回前端需要的配置信息""" + """Return configuration info for the web console.""" try: - use_agent = conf().get("agent", False) - + local_config = conf() + use_agent = local_config.get("agent", False) + if use_agent: title = "CowAgent" - subtitle = "我可以帮你解答问题、管理计算机、创造和执行技能,并通过长期记忆不断成长" else: - title = "AI 助手" - subtitle = "我可以回答问题、提供信息或者帮助您完成各种任务" - + title = "AI Assistant" + return json.dumps({ "status": "success", "use_agent": use_agent, "title": title, - "subtitle": subtitle + "model": local_config.get("model", ""), + "channel_type": local_config.get("channel_type", ""), + "agent_max_context_tokens": local_config.get("agent_max_context_tokens", ""), + "agent_max_context_turns": local_config.get("agent_max_context_turns", ""), + "agent_max_steps": local_config.get("agent_max_steps", ""), }) except Exception as e: logger.error(f"Error getting config: {e}") return json.dumps({"status": "error", "message": str(e)}) +def _get_workspace_root(): + """Resolve the agent workspace directory.""" + from common.utils import expand_path + return expand_path(conf().get("agent_workspace", "~/cow")) + + +class SkillsHandler: + def GET(self): + web.header('Content-Type', 'application/json; charset=utf-8') + try: + from agent.skills.service import SkillService + from agent.skills.manager import SkillManager + workspace_root = _get_workspace_root() + manager = SkillManager(custom_dir=os.path.join(workspace_root, "skills")) + service = SkillService(manager) + skills = service.query() + return json.dumps({"status": "success", "skills": skills}, ensure_ascii=False) + except Exception as e: + logger.error(f"[WebChannel] Skills API error: {e}") + return json.dumps({"status": "error", "message": str(e)}) + + +class MemoryHandler: + def GET(self): + web.header('Content-Type', 'application/json; charset=utf-8') + try: + from agent.memory.service import MemoryService + params = web.input(page='1', page_size='20') + workspace_root = _get_workspace_root() + service = MemoryService(workspace_root) + result = service.list_files(page=int(params.page), page_size=int(params.page_size)) + return json.dumps({"status": "success", **result}, ensure_ascii=False) + except Exception as e: + logger.error(f"[WebChannel] Memory API error: {e}") + return json.dumps({"status": "error", "message": str(e)}) + + +class MemoryContentHandler: + def GET(self): + web.header('Content-Type', 'application/json; charset=utf-8') + try: + from agent.memory.service import MemoryService + params = web.input(filename='') + if not params.filename: + return json.dumps({"status": "error", "message": "filename required"}) + workspace_root = _get_workspace_root() + service = MemoryService(workspace_root) + result = service.get_content(params.filename) + return json.dumps({"status": "success", **result}, ensure_ascii=False) + except FileNotFoundError: + return json.dumps({"status": "error", "message": "file not found"}) + except Exception as e: + logger.error(f"[WebChannel] Memory content API error: {e}") + return json.dumps({"status": "error", "message": str(e)}) + + +class SchedulerHandler: + def GET(self): + web.header('Content-Type', 'application/json; charset=utf-8') + try: + from agent.tools.scheduler.task_store import TaskStore + workspace_root = _get_workspace_root() + store_path = os.path.join(workspace_root, "scheduler", "tasks.json") + store = TaskStore(store_path) + tasks = store.list_tasks() + return json.dumps({"status": "success", "tasks": tasks}, ensure_ascii=False) + except Exception as e: + logger.error(f"[WebChannel] Scheduler API error: {e}") + return json.dumps({"status": "error", "message": str(e)}) + + +class LogsHandler: + def GET(self): + """Stream the last N lines of run.log as SSE, then tail new lines.""" + web.header('Content-Type', 'text/event-stream; charset=utf-8') + web.header('Cache-Control', 'no-cache') + web.header('X-Accel-Buffering', 'no') + + from config import get_root + log_path = os.path.join(get_root(), "run.log") + + def generate(): + if not os.path.isfile(log_path): + yield b"data: {\"type\": \"error\", \"message\": \"run.log not found\"}\n\n" + return + + # Read last 200 lines for initial display + try: + with open(log_path, 'r', encoding='utf-8', errors='replace') as f: + lines = f.readlines() + tail_lines = lines[-200:] + chunk = ''.join(tail_lines) + payload = json.dumps({"type": "init", "content": chunk}, ensure_ascii=False) + yield f"data: {payload}\n\n".encode('utf-8') + except Exception as e: + yield f"data: {{\"type\": \"error\", \"message\": \"{e}\"}}\n\n".encode('utf-8') + return + + # Tail new lines + try: + with open(log_path, 'r', encoding='utf-8', errors='replace') as f: + f.seek(0, 2) # seek to end + deadline = time.time() + 600 # 10 min max + while time.time() < deadline: + line = f.readline() + if line: + payload = json.dumps({"type": "line", "content": line}, ensure_ascii=False) + yield f"data: {payload}\n\n".encode('utf-8') + else: + yield b": keepalive\n\n" + time.sleep(1) + except GeneratorExit: + return + except Exception: + return + + return generate() + + class AssetsHandler: def GET(self, file_path): # 修改默认参数 try: