diff --git a/channel/web/chat.html b/channel/web/chat.html index 0f54460..6f28ebc 100644 --- a/channel/web/chat.html +++ b/channel/web/chat.html @@ -54,7 +54,7 @@ transform -translate-x-full lg:relative lg:translate-x-0 transition-transform duration-300 ease-in-out"> -
+
CowAgent
CowAgent @@ -134,7 +134,10 @@
- CowAgent v1.0 +
@@ -202,7 +205,7 @@ CowAgent

CowAgent

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

+ data-i18n-html="welcome_subtitle">I can help you answer questions, manage your computer, create and execute skills,
and keep growing through long-term memory.

Model --
-
- API Base - -- -
@@ -383,34 +382,60 @@
-
-
-

Memory

-

View agent memory files and contents

+ + +
+
+
+

Memory

+

View agent memory files and contents

+
+
+
+
+ +
+

Loading memory files...

+

Memory files will be displayed here

+
+
-
-
- + + + - +
@@ -427,13 +452,7 @@

View and manage messaging channels

-
-
- -
-

Coming Soon

-

Channel management will be available here

-
+
@@ -450,13 +469,13 @@

View and manage scheduled tasks

-
+
-

Coming Soon

-

Scheduled task management will be available here

+

Loading...

+
@@ -488,7 +507,7 @@ Live -
+

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

diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js index 8b326cb..56ad3e6 100644 --- a/channel/web/static/js/console.js +++ b/channel/web/static/js/console.js @@ -2,6 +2,11 @@ CowAgent Console - Main Application Script ===================================================================== */ +// ===================================================================== +// Version — update this before each release +// ===================================================================== +const APP_VERSION = 'v2.0.1'; + // ===================================================================== // i18n // ===================================================================== @@ -10,9 +15,9 @@ const I18N = { console: '控制台', nav_chat: '对话', nav_manage: '管理', nav_monitor: '监控', menu_chat: '对话', menu_config: '配置', menu_skills: '技能', - menu_memory: '记忆', menu_channels: '通道', menu_tasks: '定时任务', + menu_memory: '记忆', menu_channels: '通道', menu_tasks: '定时', menu_logs: '日志', - welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过长期记忆不断成长', + welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过长期记忆
不断成长', example_sys_title: '系统管理', example_sys_text: '帮我查看工作空间里有哪些文件', example_task_title: '智能任务', example_task_text: '提醒我5分钟后查看服务器情况', example_code_title: '编程助手', example_code_text: '帮我编写一个Python爬虫脚本', @@ -28,6 +33,7 @@ const I18N = { 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: '通道管理功能即将在此提供', @@ -43,7 +49,7 @@ const I18N = { 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.', + 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', @@ -59,6 +65,7 @@ const I18N = { 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', @@ -80,6 +87,9 @@ 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']); }); @@ -241,7 +251,6 @@ fetch('/config').then(r => r.json()).then(data => { const title = data.title || 'CowAgent'; document.getElementById('welcome-title').textContent = title; document.getElementById('cfg-model').textContent = data.model || '--'; - document.getElementById('cfg-api-base').textContent = data.open_ai_api_base || '--'; 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 || '--'; @@ -676,7 +685,6 @@ 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-api-base').textContent = data.open_ai_api_base || '--'; 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 || '--'; @@ -685,9 +693,279 @@ function loadConfigView() { }).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 6c70836..690211e 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -281,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对话网页已运行") # 确保静态文件目录存在 @@ -298,6 +298,11 @@ class WebChannel(ChatChannel): '/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) @@ -385,7 +390,6 @@ class ConfigHandler: "use_agent": use_agent, "title": title, "model": local_config.get("model", ""), - "open_ai_api_base": local_config.get("open_ai_api_base", ""), "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", ""), @@ -396,6 +400,125 @@ class ConfigHandler: 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: